Skip to content
GitHub

Migrating from MeoCord 3 to 4

MeoCord 4 builds with Rsbuild instead of webpack, and requires dotenv 18 and Node.js 22.13. Most bots need two changes: upgrade dotenv, and rename one hook in meocord.config.ts. Everything else is either unchanged or new and optional.

Checklist

  • Run Node.js 22.13 or newer, if you run Node 22
  • Upgrade dotenv to 18.0.5 or a later 18.x, and discord.js to 14.27 or a later 14.x
  • Rename the webpack hook in meocord.config.ts to rsbuild, and reshape its body
  • Replace any use of the MeoCordWebpackConfig type
  • Stop importing MeoCord's internal routing helpers from meocord/decorator, if you did
  • Run meocord build and meocord start
  • Optional: turn on bundleDependencies to deploy without node_modules

MeoCord 4 needs Node.js 22.13 or newer; MeoCord 3 accepted any Node 22. MeoCord 4.1 needs discord.js 14.27 or a later 14.x, where MeoCord 3 and 4.0 accepted 14.26.4. Bun is unaffected.

Before you start: Node.js 22.13

A built bot loads its compiled config with require() of an ES module. Node runs that without a flag from 22.12, but 22.12 still prints an experimental warning on every start and crashes on a config that throws, rather than letting MeoCord report it; 22.13 does neither. On 22.0 to 22.11 the bot cannot load its config and stops before logging in. Check with node --version, and update the Node version in your Dockerfile, CI or hosting settings if it pins an older 22.

1. Upgrade dotenv to 18

MeoCord's dotenv peer dependency moved from ^17.4.2 to ^18.0.5.

npm install discord.js@^14.27.0 dotenv@^18.0.5
bun add discord.js@^14.27.0 dotenv@^18.0.5
pnpm add discord.js@^14.27.0 dotenv@^18.0.5
yarn add discord.js@^14.27.0 dotenv@^18.0.5

Your code does not change. import 'dotenv/config', the form apps generated before 4.1 use, behaves the same in 18. Two differences to know about:

  • dotenv 18 dropped .env.vault support. If you used it, move those variables to your deployment's environment or a plain .env file.
  • Its injection notice (◇ injected env (…) from .env) now goes to stderr instead of stdout. That matters only if a script parses the bot's stdout.

Until dotenv and discord.js are upgraded, installing MeoCord 4.1 fails with ERESOLVE on npm 7+, errors on pnpm, and warns on bun and yarn.

2. Replace the webpack hook with rsbuild

A meocord.config.ts that still declares webpack stops the build with a message saying to rename it to rsbuild, rather than building with your customisation silently ignored.

The hook works the same way — it receives the configuration and returns it, modified — but the configuration is now Rsbuild's. Many webpack rules have no counterpart to write, because Rsbuild does that work itself.

Before — the hook MeoCord 3's generated apps shipped with:

TypeScript
import { type MeoCordConfig } from 'meocord/interface'

export default {
  discordToken: process.env.DISCORD_TOKEN!,
  webpack: config => {
    config.module.rules?.push({
      test: /\.(md|html)$/i,
      type: 'asset/source',
    })
    config.module.rules?.push({
      test: /\.(gif|jpg|jpeg|png|svg|woff|woff2|eot|ttf|otf)$/i,
      type: 'asset/resource',
      exclude: /node_modules/,
      generator: { filename: 'assets/[name][ext]' },
    })
    return config
  },
} satisfies MeoCordConfig

After:

TypeScript
import { type MeoCordConfig } from 'meocord/interface'

export default {
  discordToken: process.env.DISCORD_TOKEN!,
  rsbuild: config => {
    // Images, fonts, svg and media need no rule: Rsbuild emits them to dist/assets/ itself.
    // Markdown and HTML still need one, for `import readme from './readme.md'` to give its text.
    config.tools ??= {}
    config.tools.rspack = (_rspackConfig, { addRules }) => {
      addRules([{ test: /\.(md|html)$/i, type: 'asset/source' }])
    }
    return config
  },
} satisfies MeoCordConfig

If your hook did nothing but these two rules, you can delete the markdown rule too when you do not import .md or .html files — and then the whole hook.

Where each webpack setting goes

webpack (MeoCord 3)Rsbuild (MeoCord 4)
asset/resource rule for images, fonts, svg, mediaNothing. Emitted to dist/assets/ by default
generator.filename on that ruleoutput.filename.image (and svg, font, media) — a string or a function
asset/source ruletools.rspack → addRules([{ test, type: 'asset/source' }])
Any other module.rules entrytools.rspack → addRules([...]); it takes the same webpack-shaped rule
pluginstools.rspack → appendPlugins(...), or an Rsbuild plugin
devtooloutput.sourceMap.js
resolve.aliasresolve.alias
externalsMeoCord's own externals option, alongside rsbuild
optimization.minimizeroutput.minify

Custom asset file names

MeoCord 3's template wrote every asset flat into dist/assets/[name][ext]. MeoCord 4 does the same by default. If you gave the webpack rule a generator.filename function — typically to keep two files with the same name in different folders from colliding — move it to output.filename. It is joined to dist/assets/, so return a path relative to that:

TypeScript
import path from 'node:path'
import { type MeoCordConfig } from 'meocord/interface'

export default {
  discordToken: process.env.DISCORD_TOKEN!,
  rsbuild: config => {
    config.output ??= {}
    config.output.filename = {
      ...config.output.filename,
      // src/assets/image/hsr/star.webp -> dist/assets/image/hsr/star.webp
      image: ({ filename }) =>
        path
          .relative('src/assets', filename ?? '')
          .split(path.sep)
          .join('/'),
    }
    return config
  },
} satisfies MeoCordConfig

Do not spread config.output.distPath to change a directory: its type is string | DistPathConfig, and spreading a possible string does not compile under strict.

Things that stay the same

You do not need to check these; they are listed so you know what was kept:

  • The output layout: dist/main.js, imported images, fonts, SVG and media under dist/assets/ without content hashes, and dist/meocord.config.mjs. Other imported files move there too; see Smaller changes.
  • Asset imports resolve to absolute paths on disk, ready for fs, canvas, or a Discord attachment. No asset is ever inlined as a data URI, whatever its size.
  • Source map types: source-map in production. Development builds use cheap-module-source-map from 4.1; see Smaller changes. Where a production map points is different; see Build and start.
  • Production builds are minified and keep class names, which dependency injection relies on; MeoCord 4 keeps function names too.

3. Replace MeoCordWebpackConfig

The MeoCordWebpackConfig type is removed. If you typed a hook or a helper with it, use Rsbuild's config type instead:

TypeScript
import { type RsbuildConfig } from 'meocord/interface'

export function addMarkdown(config: RsbuildConfig): RsbuildConfig {
  config.tools ??= {}
  config.tools.rspack = (_rspackConfig, { addRules }) => {
    addRules([{ test: /\.md$/i, type: 'asset/source' }])
  }
  return config
}

MeoCord re-exports the type, so this works without adding @rsbuild/core to your own dependencies — which pnpm would require if you imported it from there directly.

4. Build and start

npx meocord build --prod
npx meocord start --prod
bunx meocord build --prod
bunx meocord start --prod
pnpm exec meocord build --prod
pnpm exec meocord start --prod
yarn meocord build --prod
yarn meocord start --prod

Five things behave differently:

  • Builds read meocord.config.ts every time. MeoCord 3 read the compiled copy the previous build left in dist, so a config edit took effect one build late, and the watcher's reload on a config change reloaded nothing. If you worked around that by building twice or deleting dist, you can stop.
  • meocord start runs bun with --no-install. Without it, bun downloads any package it cannot find at runtime. If you launch dist/main.js with bun directly — a Docker CMD, for example — add the flag yourself: bun --no-install dist/main.js.
  • Production source maps name real paths. dist/main.js.map lists each source relative to dist, as ../src/app.ts, where webpack wrote webpack://<your-app>/./src/app.ts. An error tracker that uploads source maps and rewrites or matches paths by the webpack:// prefix needs that rule updated.
  • A failed login fails the start. app.start() rejects when Discord refuses the token or cannot be reached, and the process exits with code 1. It used to log the error and resolve, so main.ts went on to log "Application started" and the process exited 0 — which Docker's restart: on-failure, systemd and CI read as success. The generated main.ts needs no change: its catch still logs the error. From 4.1, MeoCord logs its own explanation of a refused token first; see Adopting 4.1 patterns to skip it in that catch. Code that awaits start() and carries on after it failed now gets the error instead.
  • lint covers meocord.config.ts. The shared config from meocord/eslint used to skip it, so upgrading can surface lint findings in that file for the first time — typically an unused import. If ESLint instead reports that meocord.config.ts is not included in any of the provided projects, add it to include in tsconfig.eslint.json, as apps generated by MeoCord 3.2 already have it.

5. Optional: deploy without node_modules

MeoCord 4 can put everything a bot needs inside dist, so deploying is copying one directory:

TypeScript
import { type MeoCordConfig } from 'meocord/interface'

export default {
  discordToken: process.env.DISCORD_TOKEN!,
  bundleDependencies: true,
} satisfies MeoCordConfig

Plain JavaScript dependencies are bundled into main.js. Native addons — packages with a compiled .node binary, such as sharp or a canvas binding — cannot be inlined, so MeoCord finds them while building and copies each one, with its binary, into dist/node_modules. You list nothing; the build prints what it packed.

A build with native addons only runs on the operating system, CPU and C library it was built on. Build on the platform you deploy to — for a container, inside the image:

Dockerfile
FROM oven/bun:1 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile && bunx meocord build --prod

FROM oven/bun:1
WORKDIR /app
COPY --from=build /app/dist ./dist
CMD ["bun", "--no-install", "dist/main.js"]

The build records its platform in dist/meocord.platform.json. A bot started anywhere else stops before going online and says so, instead of failing on the first command that loads the addon.

See Self-contained builds for the details.

Internal helpers are no longer exported

meocord/decorator exported six helpers the framework uses to route interactions: getCommandMap, getMessageHandlers, getReactionHandlers, getAutocompleteHandlers, findAmbiguousRoutes and PARAM_SEPARATOR. They are internal now, and meocord/decorator exports only the decorators. An app that imported one fails to compile with "has no exported member"; the routing they expose is MeoCord's to change, so remove the dependency rather than copy the helper.

If you used them to test which handler a customId reaches, resolveRoute and findRouteConflicts from meocord/testing answer that directly, the way dispatch does:

TypeScript
const route = resolveRoute(App, { type: CommandType.BUTTON, customId: 'profile/111/8000' })
expect(route?.handler).toBe(ProfileController.prototype.showProfile)
expect(findRouteConflicts(App)).toEqual([])

If your app was generated by MeoCord 3

These changes are in MeoCord 4's app template. They are not required for an existing app, but are worth taking if you upgrade the same tools in yours:

  • vitest 5 and @vitest/coverage-istanbul 5. vitest 5 removed coverage.all, so all: false no longer keeps files no test imports out of coverage — and istanbul's parser fails on a decorated one: Support for the experimental syntax 'decorators' isn't currently enabled. Remove all: false, and give swc.vite the include below so a file no test imports goes through SWC too:

    TypeScript
    swc.vite({
      include: /\.m?[jt]sx?(?:\?.*)?$/,
      // ...the options already there
    }),
    TypeScript
    coverage: {
      provider: 'istanbul',
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.spec.ts', 'src/**/*.d.ts', 'src/app.ts', 'src/main.ts'],
    },
  • unplugin-swc 2 takes the same options; 4.1's template adds the include above.

  • meocord.config.ts in tsconfig.json. The template now lists it under include rather than exclude, so tsc in your lint script typechecks the config, and your editor resolves paths aliases in it — import '@src/common/utils/load-env.util' — the way the build already does. noEmit stays on, so nothing is written beside it:

    JSON
    "include": ["src/**/*.ts", "meocord.config.ts"],
    "exclude": ["dist", "vitest.config.ts", "node_modules", "src/**/*.spec.ts"]

Fixed along the way

Three type errors that MeoCord 3 users hit are fixed. If you worked around them with a cast or a // @ts-expect-error, you can remove it:

  • A slash command builder that adds options — new SlashCommandBuilder().addStringOption(...) — was rejected by @CommandBuilder(CommandType.SLASH). All three forms a slash builder takes are accepted now.
  • createMockMessage().deleted did not compile, though the mock always tracked it. It is typed now.
  • A CommonJS project — require('meocord/core'), or TypeScript with module: node16 — got the ES module declarations and was told it could not require MeoCord. Each entry point now ships CommonJS declarations too.

Upgrading from 4.0 to 4.1

4.1 is a minor release, and most 4.0 bots and their tests build and run without edits. A few 4.0 patterns now stop the bot, fail to compile or change a test's result, such as two component handlers with the same customId pattern or a test that relied on a mock's defaults; the checklist names each, and the sections below say what to check. Everything else in 4.1 is new and optional, and Adopting 4.1 patterns shows where it can replace code you wrote yourself.

  • Upgrade discord.js to 14.27 or a later 14.x, and dotenv to 18.0.5 or a later 18.x
  • Check class guards on controllers that extend another controller
  • Check subclasses of a controller with class-level guards, interceptors, filters or cooldowns
  • Check class guards on controllers with @Autocomplete handlers
  • Check what users see when a command throws after it replied or deferred
  • Fix any meocord.config.ts option of the wrong type, since it now stops build, start and register
  • Fix or replace the generated src/guards/rate-limit.guard.ts, if your app still has it
  • Check @MessageHandler keywords, which match in any case, and of which only one runs
  • Set logLevel: 'debug' where a bot outside development should still print [DEBUG] lines
  • Update tests that relied on 4.0's mock defaults, such as getMember() returning the user, or an option getter of the wrong type returning null, which now throws discord.js's error
  • Decorate each class whose constructor injects, where the startup error names one with no decorator
  • Add { bots: true } to any @ReactionHandler that should run for reactions from bots
  • Give each component handler its own customId pattern, since two with the same one stop the bot
  • Check decorators combined with applyDecorators, which now apply in the order they would stack
  • Check colours read from Theme, which is deprecated and now gives the theme's, contrast-tuned defaults
  • Create a new app for each login attempt, rather than calling start() again after one fails
  • Fix each command or autocomplete handler the startup warning lists as one Discord never sends
  • Replace SetMetadata and string keys with createMetadata, and ReactionHandlerOptions with ReactionEvent
  • Remove imports of MetadataKey, CommandMetadata and AutocompleteMetadata
  • Write @MessageHandler() for a listener declared as @MessageHandler('')
  • Declare every route a re-declared handler should answer, where the startup warning names one it inherits
  • Keep one @Autocomplete for each option, where the startup warning names a second
  • Make overlapping component patterns distinct, where the startup warning says MeoCord 5 runs the other one
  • Fix any @Catch entry the startup warning names as not a class
  • Move to its class each class decorator the startup warning names on a method
  • On pnpm, or npm 11.16 and later, add the install settings a new app gets
  • Move to a controller each handler the startup warning names on a class that isn't one
  • Rebuild

Class guards now cover inherited handlers

A class-level @UseGuard on a controller that extends another controller now guards the handlers it inherits too. In 4.0 it guarded only the handlers the subclass declared itself, so inherited commands, components, message and reaction handlers ran without the subclass's guards.

TypeScript
@Controller()
@UseGuard(StaffGuard)
export class AdminController extends ModerationController {}

In 4.1, ModerationController's handlers, when reached through AdminController, run ModerationController's own guards first, then StaffGuard, as a base's stages wrap what extends it. If a bot relied on inherited handlers skipping the subclass's guards, move those handlers out of the subclass, or give the subclass a guard that allows them. The base controller itself is unaffected.

A base controller's class stages cover its subclasses

Class-level @UseGuard, @UseInterceptor, @UseFilter and @Cooldown on a controller now also apply to the handlers a subclass declares itself, as they do in NestJS. In 4.0 and the earlier 4.1 betas they reached only the handlers the base class declared, so a subclass of a guarded base ran its own handlers unguarded:

TypeScript
@Controller()
@UseGuard(StaffGuard)
export abstract class StaffController {}

@Controller()
export class BanController extends StaffController {
  @Command('ban', BanCommandBuilder)
  async ban(interaction: ChatInputCommandInteraction) {} // now runs StaffGuard first
}

A base's class stages wrap everything that extends it, as global stages wrap controllers. For a handler a subclass declares or inherits, guards and interceptors run the global ones first, then the top base's, then each subclass's in turn, then the method's. Filters are tried the other way: the method's, then the subclass's, then each base's, then the global ones, so a subclass's specific filter is tried before a catch-all on its base. Class cooldowns count base first.

Earlier 4.1 betas ran a subclass's guards and interceptors before its base's, and tried a base's filters before a subclass's. 4.0 never ran both on one handler, so this matters only for a bot built on a 4.1 beta: check a subclass guard that assumed it ran before the base's auth guard, and a subclass filter a base catch-all used to shadow. Class cooldowns keep their order, so stored cooldown keys don't change.

What a bot relying on 4.0's rule sees:

  • A subclass's own handlers run the base's class guards, and are refused where those guards refuse.
  • They run inside the base's class interceptors, and the base's class filters handle their errors before the built-in fallback.
  • The base's class cooldowns count their calls. Each cooldown counts under its position in the handler's list, so a handler that had its own cooldowns counts them under new keys once, and a shared cooldown store starts those counts afresh.
  • A direct call to a guarded handler runs the same guards in the same order as dispatch, where it ran each decorator's guards in the order the decorators wrapped the method.

To keep a subclass's handlers to its own class and method stages, set inheritStages: false. The handlers it inherits keep their base's stages:

TypeScript
@Controller({ inheritStages: false })
export class PublicController extends StaffController { ... }

Class guards now cover autocomplete handlers

A class-level @UseGuard now also guards the controller's @Autocomplete handlers. In 4.0 it guarded commands, components, message and reaction handlers, and autocomplete handlers ran without it.

A guard there receives an AutocompleteInteraction: it has no reply(), and an autocomplete request must be answered within three seconds. A guard that denies returns false, and MeoCord closes the menu with an empty list. Check a class guard that reads command-only data, such as a chat input option it requires, or that replies on denial:

TypeScript
canActivate(interaction: BaseInteraction): boolean {
  if (interaction.isAutocomplete()) return true // or decide from interaction.user, without replying
  // ...
}

With ExecutionContext injected, this.context.getType() === 'autocomplete' tells the same.

Errors after a reply or deferral are answered

A command that throws after deferReply() now has its deferred reply edited into the error message, or, for an error shown only to the user such as a UserError, a private follow-up in place of a public deferral, with a private deferral edited into it, instead of showing "thinking…" until Discord times it out. A command or component that throws after it already replied now gets a private follow-up with the error, where 4.0 sent nothing. A button, select menu or modal submitted from a public message is answered with a private follow-up, never by editing the message the user clicked; on a private (ephemeral) message, the error is added to that message.

If a handler relied on the old silence, for instance because it edits its own reply into an error before rethrowing, register an exception filter that handles the error: filters run before the built-in answer and replace it.

TypeScript
@Catch()
export class LogOnlyFilter implements ExceptionFilter {
  private readonly logger = new Logger('Errors')

  catch(error: unknown): void {
    this.logger.error(error)
  }
}

A command builder that throws stops registration

A builder whose toJSON() throws, such as a slash command without a description, stops that start's registration, and no commands are sent, as in 4.0. 4.1 builds every command before sending any, and its error names the builder and the command, where 4.0 logged only discord.js's validation error. Fix the builder; the next start registers everything.

A config option of the wrong type stops the CLI

build, start and register now check meocord.config.ts before anything else. An option of the wrong type, such as externals: 'sharp' where an array is expected, stops them with a list of every problem and exit code 1, where 4.0 built some silently and failed on others with an internal error. A config that fails to load stops them with the file and line. An option MeoCord does not know, often a typo, is only reported as a warning. Fix what the list names, then run the command again.

The generated rate-limit guard limits

The RateLimitGuard that meocord create copied into 4.0 applications never limited anything: a new guard instance is created for every call, so the counts it kept on the instance started empty each time. Upgrading meocord does not change your copy. Move its rateLimits map out of the class to module level, so every instance shares it, or drop the guard for @Cooldown, which does the same job without code of your own and is what new applications use.

SetMetadata refuses MeoCord's own keys

4.1 keeps MeoCord's own metadata under keys beginning meocord:. SetMetadata stores any key a 4.0 bot used, such as 'guards' or 'commandType', as 4.0 did. It throws as the decorator applies, naming the key, only for a key beginning meocord: or one of the two keys dependency injection reads, design:paramtypes and inversify's injectable flag.

Code that read MeoCord's guard list or a builder's command type under 'guards' or 'commandType' no longer finds them there; read a handler's guards with inspectHandler from meocord/testing. In 4.0, 'commandType' set on a @CommandBuilder class changed that builder's type; 4.1 stores it and reads nothing from it.

SetMetadata itself is deprecated in 4.1; see SetMetadata and string metadata keys are deprecated.

Message keywords match in any case, and only one runs

@MessageHandler takes a pattern now, with params, prefixes and a ranking across controllers (see Adopting 4.1 patterns). A 4.0 keyword is a pattern without params and still matches the whole message, with three differences:

  • Case. @MessageHandler('hello') also matches Hello and HELLO. Set @MeoCord({ messages: { caseSensitive: true } }), or { caseSensitive: true } on the handler, to match the case written.
  • Words, not characters. A keyword is compared word by word, so 'hello there' also matches hello there.
  • One handler per message. When two patterns match, only the most specific runs: 'roll 20' wins over 'roll {sides}'. Two handlers with the same keyword, which both ran in 4.0, now stop the bot at startup, naming both; merge them into one handler. One handler declared under two spellings, such as the generated @MessageHandler('baka') and @MessageHandler('Baka'), is one route and keeps working; the second decorator can go. A keyword with { or } in a word is read as a param, and stops the bot if it is not a whole word, such as 'a{b}'.

@MessageHandler() without a keyword still runs for every message, after the patterned handler.

A prefix you configure in @MeoCord({ messages: { prefix: '!' } }) applies to every patterned handler, existing keywords included, so 'ping' then needs !ping. Give a handler that should keep matching the bare message { prefix: false }:

TypeScript
@MessageHandler('good morning', { prefix: false })

Reactions from bots reach no handler

@ReactionHandler now skips reactions from bots, the bot's own included, as @MessageHandler has always skipped messages from bots. In 4.0 every handler ran for them: a bot that reacted to its own message ran its reaction handlers for that reaction, and one that answered reactions could answer itself, or another bot, in a loop.

A handler that should still run for bot reactions, such as one relaying a bot's pins, sets bots: true:

TypeScript
@ReactionHandler('📌', { bots: true })
@ReactionHandler({ bots: true }) // every emoji

A handler that counted reactions, such as poll votes, no longer counts the ones the bot added to seed the choices; check a count that subtracted them.

Two component handlers with the same customId pattern stop the bot

Two buttons, select menus or modals of one type whose @Command patterns match exactly the same customIds, such as profile/{uid} in one controller and profile/{id} in another, now stop the bot at startup with an error naming both handlers. In 4.0 the bot started, warned about the pair at the first component interaction, and sent every click to one of them, chosen by the order the controllers were listed; the other never ran.

This happens most often with controllers from meocord generate in 4.0 and the earlier 4.1 betas, which gave every generated button button-click and button-with/{id}, every select menu of a type the same id, such as select-menu, and every modal submit-modal. Give each handler a pattern of its own, and update the customIds the bot sends on its buttons, menus and modals to match:

TypeScript
@Command('feedback-open', CommandType.BUTTON) // was 'button-click'

Registering a base controller and a subclass of it together gives the same error, since the subclass inherits every route; register only the one that should handle them. One handler declared with two spellings of a pattern, such as card/{id} and card/{cardId}, is one route and keeps working. findRouteConflicts, resolveRoute and MeoCordTestingModule.compile() throw the same error, so a test catches it too; a test that expected invoke() to reject such a module now sees compile() throw. With process sharding, the shard manager refuses the pair before it spawns a shard.

Two handlers of one command stop the bot

From 4.1.0-beta.7, two @Command handlers of one slash command name or subcommand path, or of one context menu name and kind, stop the bot as it is created, with an error naming both handlers. Before, the bot started, and every call went to the first of them in the order the controllers were listed; the other never ran. meocord start, meocord register, a shard manager and a testing module all refuse it:

text
StatsController.stats: it and AdminController.adminStats both handle the slash command "stats", so only
StatsController.stats would ever run. Keep one handler for it, or give the other a name or subcommand path of its own.

Keep one handler for the command, or give the other a name or subcommand path of its own. Registering a base controller and a subclass of it together gives the same error, since the subclass inherits every handler; register only the one that should handle them.

Two builder classes that build one application command, which only a builder that sets its own name instead of the name @Command gives it can do, stop the bot the same way, naming both builders and both handlers. Before, the first was registered and the bot warned. Keep one builder, on a single @Command, and declare the other handlers with the plain CommandType, such as CommandType.SLASH.

One builder on a command and its own subcommand paths is still one command, and a user and a message context menu may still share a name.

applyDecorators applies its decorators in the order they stack

applyDecorators(A, B) now applies its decorators as @A @B written above a class or a method applies them: B first, then A. In 4.0 it applied A first, so the stages it combined ran in the reverse of the order they were listed in, and moving stacked decorators into applyDecorators changed what ran first.

TypeScript
export const StaffOnly = () => applyDecorators(UseGuard(StaffGuard), UseGuard(AuditGuard))

// 4.1 runs StaffGuard, then AuditGuard, as @UseGuard(StaffGuard) @UseGuard(AuditGuard) does.
// 4.0 ran AuditGuard first. To keep that, list them the other way round:
export const StaffOnly = () => applyDecorators(UseGuard(AuditGuard), UseGuard(StaffGuard))

The same holds for interceptors, pipes and filters combined this way. A decorator that returns a new method or class in place of the one it was given, as a wrapping decorator does, now hands it on to the next decorator and to TypeScript; 4.0 dropped it.

Theme is deprecated, and its colours changed

Theme from meocord/common still works, and goes in MeoCord 5. Each colour now reads the matching role of the theme where it is read, so code written against Theme.errorColor follows @MeoCord({ theme }) and @UseTheme. With no theme set, it gives MeoCord's new defaults, tuned for at least 3:1 contrast against every Discord surface, rather than 4.0's values:

ThemeRole4.04.1
Theme.successColorcolors.success#28A745#26A042
Theme.infoColorcolors.info#17A2B8#1699AE
Theme.errorColorcolors.danger#DC3545#E3606D
Theme.warningColorcolors.warning#FFC107#B08400

To keep 4.0's colours, set them in @MeoCord({ theme: { colors: { danger: '#DC3545', … } } }). Theme.primaryColor is new in 4.1, and reads colors.primary, #7680F4 by default.

Reading a Theme colour, as setColor(Theme.errorColor) does, logs a warning once naming the role to read instead. Assigning one still recolours MeoCord's views, beneath any theme the app sets, and logs a warning naming the role to set. Read the theme with useTheme() in new code; see Adopting 4.1 patterns.

MeoCord's own error replies change colour too. 4.0 gave each one Theme.errorColor, #DC3545 unless the bot assigned another. 4.1 gives an error in the bot colors.danger, and one the user can fix, such as a UserError, a refused guard, a cooldown or invalid input, colors.warning.

Retrying start() after a failed login is deprecated

In 4.0, calling start() again on an app whose login failed attached every event handler a second time, so after a successful retry each command, message and reaction handler ran twice. 4.1 logs in again with the handlers it already has, and warns once:

text
Retrying start() after a failed login is deprecated; in the next major version (5.0) it rejects. Use MeoCordFactory.create to make a new app instead.

In a retry loop, create the app for each attempt instead:

TypeScript
for (let attempt = 1; ; attempt++) {
  const app = MeoCordFactory.create(App)
  try {
    await app.start()
    break
  } catch (error) {
    if (attempt === 5) throw error
  }
}

Retrying after one of the app's provider factories failed stays supported, with no warning.

SetMetadata and string metadata keys are deprecated

SetMetadata(key, value), and ExecutionContext.get(key) and getAll(key) with a string or symbol key, the same on a HandlerRegistry entry included, still work, and each logs a warning once, naming what to use instead. MeoCord 5 removes them. Declare the decorator with createMetadata, whose value is typed and whose key can't collide with another library's, and read it by the decorator:

TypeScript
// 4.0
export const Roles = (...roles: string[]) => SetMetadata('roles', roles)
const required = context.get<string[]>('roles')

// 4.1
export const Roles = createMetadata<string[]>()
const required = context.get(Roles)

A handler then takes @Roles(['admin']).

ReactionHandlerOptions is now ReactionEvent

The second argument of a @ReactionHandler method, { user, action }, is typed ReactionEvent, imported from meocord/interface. ReactionHandlerOptions stays as a deprecated alias of it until MeoCord 5, since every other …Options type is what a decorator takes. Only the type's name changes.

MetadataKey, CommandMetadata and AutocompleteMetadata are deprecated

They're the shapes MeoCord keeps its own metadata in, and MeoCord 5 stops exporting them. Nothing replaces them: drop any import. To read what a handler carries, as a test does, use inspectHandler from meocord/testing.

@MessageHandler('') logs a warning

An empty pattern still runs for every message, as @MessageHandler() does, and logs a warning naming the handler; MeoCord 5 refuses it as the bot loads:

text
@MessageHandler('') on Chat.every is deprecated; in the next major version (5.0) it is refused. Use @MessageHandler() instead.

Write @MessageHandler() for a listener. If the pattern is built from a value, check why that value is empty.

A @Catch entry that isn't a class logs a warning

An entry of @Catch that isn't a class, most often an undefined from an import cycle, is named in a warning as the filter loads, and matches no error; MeoCord 5 refuses it:

text
Broken: @Catch's first entry, undefined, which matches no error, is deprecated; in the next major version (5.0) it is refused. Use an error class, such as @Catch(CooldownError), instead.

From the sixth entry on, the warning names the entry by number, "entry 6". In 4.0, the first error to reach the filter threw "Right-hand side of 'instanceof' is not an object" instead, which hid the handler's own error and handled the call twice. Import the class from where it's defined, or break the cycle.

A re-declared handler that keeps its inherited route logs a warning

A handler a subclass re-declares follows one rule for @Command, @MessageHandler, @ReactionHandler and @Autocomplete.

On the route it inherits, the subclass's declaration takes that route's place, so its own options apply. A re-declared @Command('ping', LoudPingBuilder), @MessageHandler('roll', { description }) or @ReactionHandler('👍', { bots: true }) uses its own builder, description or settings. In 4.0 the base's applied, since the base's declaration came first. The routes the subclass answers are unchanged.

On another route, the subclass still answers the inherited one too, as in 4.0, and the bot names it in a warning as it starts. In MeoCord 5, a handler's own routes replace the ones it inherits:

TypeScript
@Controller()
export class Pager {
  @Command('page/{n}', CommandType.BUTTON)
  page() {}
}

@Controller()
export class ShopPager extends Pager {
  // Answers shop/page/{n}, and page/{n} as well, with a warning
  @Command('shop/page/{n}', CommandType.BUTTON)
  page() {}
}
1 re-declared handler still answers routes it inherits:
  ShopPager.page answers button "page/{n}", which it inherits, as well as its own button "shop/page/{n}".
In the next major version (5.0), a handler's own routes replace the ones it inherits. To keep an inherited route, declare it on the subclass's method as well.

To keep an inherited route, declare it on the subclass's method as well. A class between the two that declares nothing changes neither rule. A subclass that declares every route itself, or re-declares none, gets no warning.

A second @Autocomplete for one option logs a warning

Two @Autocomplete handlers that complete the same option of one command path, or every option of one path, are named in 4.1's startup warning about command handlers that never run: the one that never does, and the one that runs instead, the first controller listed. 4.0 started silently. The bot still starts, and MeoCord 5 refuses to. Only an option Discord asks to complete is checked this way; a handler for one whose builder leaves autocomplete off is named for that alone:

text
2 command handlers never run:
  More.second: Stats.first also completes the option "user" of "stats", and runs first. Keep one, or give this one a path or option of its own.
  More.plain: the option "plain" of "stats" does not have autocomplete on, so Discord never asks to complete it. Turn it on in the builder with setAutocomplete(true).
The next major version (5.0) refuses to start with these.

Keep one handler, or give the other an option or a path of its own.

Overlapping component patterns: MeoCord 5 prefers the one that spells out more

Between two equally specific component patterns that can match the same customId, such as a/{x}/c and a/b/{y}, the one whose controller is listed first, or in one controller the handler declared first, runs, as in 4.0. 4.1's startup warning about such a pair names the handler that runs, and why. In MeoCord 5, the pattern that spells out the first segment where the two differ runs instead, a/b/{y} for a/b/c, whatever the order; where that changes which handler runs, the warning says so, and what to do:

text
1 pattern pair(s) can match the same customId, so which one runs is decided by ranking rather than by the ids themselves:
  "a/{x}/c"  vs  "a/b/{y}": Pages.one runs, as it is declared first. In the next major version (5.0), Pages.two runs instead, as "a/b/{y}" spells out the first segment where the two differ. Declare Pages.two first, or make the patterns distinct.

To be ready, make the patterns distinct, or list the controllers so the pattern MeoCord 5 would pick comes first. The warning is given once, as the bot starts: by meocord start, meocord register and the shard manager, where each process-sharded shard gave it again, and by MeoCordTestingModule.compile(), before anything is dispatched.

A command handler Discord never sends logs a warning

At startup, one warning lists every slash command, context menu or entry point handler that can never run, and the bot starts anyway; MeoCord 5 refuses to start with them. A handler is listed when:

  • its subcommand path isn't one its command's builder registers, such as a typo in 'settings notfy', which in 4.0 fell back to the settings handler without a word;
  • it's given a customId pattern or a route(), which these handler types don't take;
  • its builder's setName differs from its @Command name;
  • no builder registers its command.

The same warning names an @Autocomplete handler Discord never asks to complete: one whose command no builder registers, whose path isn't a subcommand the builder registers, or whose option the builder doesn't register, or builds without setAutocomplete(true). A handler for the whole command is checked against every option of the command, its subcommands' included.

Correct the path or the name, give the command a builder, or turn autocomplete on for the option. MeoCordTestingModule.compile() warns about the same handlers, except one without a builder, which is how a test's fixture is often written. meocord generate controller autocomplete <name> ends by saying what to add to the command's builder.

A handler on a class that isn't a controller logs a warning

MeoCord dispatches commands, components, messages and reactions only to the app's @MeoCord({ controllers }). A @MessageHandler, @ReactionHandler, @Command or @Autocomplete on any other class, such as a service, or a class a controller injects that no option lists, never runs, and in 4.0 nothing said so. At startup, one warning names each of them with its decorator, and the bot starts anyway; MeoCord 5 refuses to start with them:

text
4 handlers in classes that are not controllers never run: MeoCord dispatches only to @MeoCord({ controllers }).
  Stats.stats: @MessageHandler('stats')
  Stats.like: @ReactionHandler('👍')
  Stats.refresh: @Command('stats/refresh')
  Stats.period: @Autocomplete('stats', 'period')
Move them to a controller. The next major version (5.0) refuses to start with these.

Move each handler to a controller the app lists, which can call the service for its work. MeoCordFactory.create() and MeoCordTestingModule.compile() give the warning once; a sharded bot gives it from its manager. The warnings about missing intents and partials leave these handlers out, since no intent would make them run. @On and @Once handlers run on any class the app binds, as before.

A class decorator on a method logs a warning

@Controller, @Service, @Guard, @CommandBuilder and @MeoCord go on a class. On a method, each applies nothing, as in 4.0, and now logs a warning once, naming the method; MeoCord 5 refuses it:

text
@Guard on the method Shop.buy is deprecated; in the next major version (5.0) it is refused. Use @Guard on a class instead.

Move the decorator to the class. The decorators 4.1 adds that go only on a class, @Interceptor, @Catch, @Pipe and @Observer, stop the bot on a method instead, and a decorator that goes only on a method, such as @Command or @Defer, stops it on a class, each naming where it was applied. In 4.0, a handler decorator on a class failed with a TypeError. A decorator that goes on either, such as @UseGuard, @Cooldown or one createMetadata makes, applies as before.

Installing with pnpm, or npm 11.16 and later

An app that meocord create made for pnpm or npm in 4.0 lacks a few settings a new 4.1 app gets.

pnpm. pnpm links only what package.json declares, and the app's test setup imports reflect-metadata and its tsconfig names @types/node, so on pnpm 10 and later lint and test fail without them. Add both:

Shell
pnpm add -D reflect-metadata @types/node

pnpm 11 and later also refuse to install while a dependency's build script is neither allowed nor denied, stopping at ERR_PNPM_IGNORED_BUILDS for @swc/core and unrs-resolver, and hold back a meocord released less than a day ago. Add a pnpm-workspace.yaml beside package.json:

YAML
allowBuilds:
  '@swc/core': false
  unrs-resolver: false
minimumReleaseAgeExclude:
  - meocord

Both scripts only check the native binding pnpm installs for your platform, and fetch a fallback without it.

npm 11.16 and later list every dependency install script package.json neither allows nor denies. Add to package.json:

JSON
"allowScripts": {
  "@swc/core": false,
  "fsevents": false,
  "unrs-resolver": false
}

@swc/core and unrs-resolver check the native binding npm installs, and fsevents, on macOS, ships its binary prebuilt. npm before 11.16 ignores the field. An app on yarn or bun needs neither change.

Smaller changes

  • From an earlier 4.1 beta: an interceptor that calls next.handle() without returning or awaiting it no longer crashes the bot when the handler throws. The error was an unhandled rejection, which ended the process. Now the call ends when the handler does and fails with what it throws, so its filters and the fallback answer it. Return or await next.handle() as before; nothing else changes.

  • From an earlier 4.1 beta: two message handlers that can take the same message stop the bot, wherever their starts are known as it starts: one with its own prefix: '!' and one using the app's '!', own prefixes that share one ('!' and ['!', '?']), prefix: false beside an app with no prefix, or two a mention starts. They were taken as different starts, so the order of your controllers decided which ran. Give one another prefix or pattern.

  • From an earlier 4.1 beta: a prefix function that finds no prefix lets none start a command. Returning an empty list, undefined or null for a message takes no prefix for it, where it took the message as it is, so in a server with no prefix of its own, plain chat starting with a command's word ran that command. Return '' to take a message as it is. The function's type takes undefined and null, so return prefixes.get(id) needs no cast.

  • A new app reads every .env file, however it starts. Its meocord.config.ts loads .env.<mode>.local, .env.local (not under test), .env.<mode> and .env, as Bun reads them, so a value in .env.local reaches the bot under node dist/main.js, pm2, systemd or Docker as it does under meocord start. An app made before this keeps import 'dotenv/config', which reads .env alone on node, as 4.0 did. To read them all, replace that import with:

    TypeScript
    import { config } from 'dotenv'
    
    const mode = process.env.NODE_ENV || 'development'
    config({
      path: [`.env.${mode}.local`, ...(mode === 'test' ? [] : ['.env.local']), `.env.${mode}`, '.env'],
      quiet: true,
    })
  • A production build's config sees NODE_ENV=production. meocord build --prod compiles meocord.config.ts in production mode, as it builds the bot; it was always compiled in development. A config that branches on NODE_ENV, such as to register commands to a development guild, now takes its production branch in a production build. Check what that branch does before you deploy, and rebuild.

  • meocord start --dev runs the bot with NODE_ENV=development, whatever the shell sets, so its config and .env files are the development ones; 4.0 kept a NODE_ENV the shell exported.

  • A class whose constructor injects but has no decorator stops the bot as it starts, naming the decorator to add, such as @Guard() or @Service(). In 4.0 such a guard failed only at its first call.

  • A project that loads meocord with require() logs again. In 4.0 every Logger method of the CommonJS build threw chalk.bold is not a function, so CommonJS code, and Jest in CommonJS mode, could not log. A built bot loads the ES module build and was not affected.

  • On Bun, set NODE_ENV=production where you start a production bot yourself, as with bun dist/main.js. With it unset, or set to anything but production or test, Bun loads .env.development before any code runs, and its values win over .env.production. The bot warns when that happens, naming each variable with its development value where the production files give another; bun --no-env-file works too.

  • Typed asset imports. A new app has src/types/assets.d.ts, which types an image, font or media import as its path and a Markdown or HTML import as its text, so import logo from './logo.png' passes the app's own tsc and lint. Copy it into your src/types/ to have the same, and add 'src/**/*.d.ts' to coverage.exclude in vitest.config.ts, where istanbul would try to read it as code. Keep it a file with no import or export: its declare module '*.png' declarations only work in one.

  • Cooldown counts from an earlier 4.1 beta start afresh once. Each cooldown's count is now kept under a key named for its window rather than its position (#0, #1), so a store that outlives the upgrade, such as Redis, doesn't carry the old counts over.

  • A cooldown's wait is a Discord timestamp. A refused call is told "Slow down: try again {when}.", the end of the wait as <t:…:R>, which Discord words in the reader's language and counts down. The three meocord.cooldown wait keys of the earlier 4.1 betas are replaced by meocord.cooldown.until. A catalog that translated them translates until instead, keeping {when}; expectCompleteCatalog reports each old key as "… is not one of MeoCord's texts". A filter that words the wait itself reads error.retryAt, the Date the next call is allowed.

  • Presenters from an earlier 4.1 beta get the call's theme as context.theme, and an error's tone, 'warning' or 'danger'. A spec that builds a ResponseContext or a PresentedError by hand adds them: theme: createMockTheme() from meocord/testing, and tone: 'danger'.

  • meocord.dm.error from an earlier 4.1 beta says why the command failed: it reads {command} in {channel} on {server}: {reason}, with the fallback's answer as the reason, where it said "Something went wrong running … Try again later." for every failure. A catalog that translates it adds {reason}.

  • A modal's file upload from an earlier 4.1 beta reaches the handler as an array of the uploaded Attachments, where it gave their ids. A handler that read ids takes them from the attachments: files.map(file => file.id).

  • ShardContext.call from an earlier 4.1 beta types each result as JSON gives it back, as Jsonified<T> from meocord/core: a method returning Promise<Date> gives string values, where the type said Date. Code that read the method's own type reads the JSON form. A param JSON would change, such as a Date, can't be passed: the compile error names the type to declare. An undefined argument arrives as undefined, where it was null, so the method's default applies. With process sharding, a provided class that shares a name with a controller, a service or another provided class stops the bot at startup.

  • New apps warn on deprecated APIs. Their eslint.config.ts sets @typescript-eslint/no-deprecated to 'warn', which names what replaces an API MeoCord or a library has deprecated, and turns it off for **/*.spec.ts: a spec that references a mocked method, as expect(interaction.reply) does, would be warned about the deprecated overload reply also has. Add the same two to your own config to have them.

  • meocord generate writes components in 4.1's style, answering with respond(), and derives each button's, modal's, select menu's and message handler's customId or pattern from its name. A nested name gives its whole path to the class: admin/ban makes AdminBanButtonController. Files you generated before keep theirs; one whose 'baka' pattern clashes with the sample's stops the bot at startup, and renaming either fixes it.

  • From an earlier 4.1 beta: stacked cooldowns are counted together. With the stores MeoCord ships, a call is counted against all of a handler's cooldowns only if all of them allow it, so a call one refuses no longer spends those declared before it; the order you write them in stops mattering. A store of your own counts them as before, one after another, unless it overrides consumeMany.

  • From an earlier 4.1 beta: a failing cooldown store refuses the call. A store that throws, rejects or does not answer within a second now refuses calls with CooldownStoreError, answered privately (a message command's only with messages.dmOnError) and logged once per outage, where its error used to reach the fallback, and the log, on every call. @MeoCord({ cooldownStoreFailure: 'allow' }) lets such calls run uncounted instead, and cooldownStoreTimeoutMs sets the wait.

  • From an earlier 4.1 beta: ShardedCooldownStore no longer counts in the shard when the manager does not answer. Under the default 'deny', those calls are refused until it answers; cooldownStoreFailure: 'allow' keeps them running, uncounted, rather than counting per shard.

  • From an earlier 4.1 beta: @Cooldown counts seconds in whole milliseconds. It rounds seconds to the millisecond, and a value outside 0.001 to 4320000000000, such as Infinity, stops the bot where the decorator applies.

  • meocord start --dev exits 1 when watch mode can't start, such as when an rsbuild hook in meocord.config.ts throws, as meocord build does. It exited 0, so a script or process manager around it saw success.

  • Logger tags info() and verbose() as [INFO] and [VERBOSE], where 4.0 tagged them [LOG]. They still print at the log level, but a filter or parser that matches [LOG] no longer catches them. An object argument is now in colour only where its line's stream is a terminal, as the rest of the line is, rather than as raw escape codes in a file or a log collector; set FORCE_COLOR=1 where your log viewer shows colour. A small object prints on one line, as console.log prints it, where 4.0 gave each property a line of its own.

  • [DEBUG] lines print only in development. 4.0 printed debug() everywhere. A bot started with NODE_ENV other than development, as a production build is, now prints from log up; set logLevel: 'debug' in meocord.config.ts, or MEOCORD_LOG_LEVEL=debug for one run, to see them. See Logging.

  • Logger reads the config only in the built bot. In 4.0 a logger's first line loaded dist/meocord.config.mjs wherever it ran, and with it the .env files that config imports. A test or a script that logged before reading process.env got the bot's environment from that load; it now loads its own, as with dotenv/config or the test runner's env. Lines printed outside the built bot, such as the CLI's and a test's, carry no [appName] prefix.

  • Startup warns about missing intents and partials. A handler whose events Discord won't send without an intent, or that discord.js drops without a partial, is named at startup: "The … intent is not in clientOptions.intents, so Discord will not send what … handles." or "Partials.… is not in clientOptions.partials, so … will miss …". A reaction bot without Partials.Message and Partials.Reaction is told so, for reactions to messages sent before it started. 4.0 said nothing. Add what the line names, or leave it if the handler doesn't need those events.

  • Under meocord start --dev, a handler that ends without answering is warned about, once per handler, since Discord then shows "The application did not respond". Set @MeoCord({ warnUnanswered: false }) to turn it off.

  • A component no handler takes waits 1.5 seconds for another listener. When a button, select menu or modal matches no route and something else listens on the client, such as a collector, MeoCord answers "Command not found!" only if that listener hasn't answered within 1.5 seconds. 4.0 answered at once, so a collector's own components needed a workaround, such as a customId MeoCord routes to a handler that does nothing; it can go.

  • Code MeoCord refuses as it loads is reported on one line. A decorator given what it can't use, such as an invalid pattern, stops the bot with Class.method: … and the source file, rather than a stack, and the message begins with what the decorator was applied to. A test that matched a refusal's whole message matches the part after that.

  • A slash command builder on a subcommand path is named for what it is. A builder given as @Command('settings notify email', SettingsCommandBuilder) that names its command itself keeps working, and is warned about once at startup: give the builder to @Command('settings'), and declare the handler with CommandType.SLASH. One that builds its name from the path still fails, now with an error naming the handler.

  • meocord start --dev sends only the commands that changed. A scope whose commands are the same as at the last start isn't sent again; meocord start --dev --force-register sends it anyway. Production starts register as 4.0 did.

  • Only meocord start --dev clears the screen, in a terminal, and keeps the scrollback. meocord build and meocord start --prod no longer clear it, and no command writes escape codes into piped output, such as CI logs or docker logs.

  • Imported files other than images, fonts, SVG and media go to dist/assets under their own names. 4.0 put a PDF, a text file or a web manifest in dist/static with a content hash in the name. Code that read those paths from disk, not from the import, reads dist/assets/<name>. Two imported files of one name now stop the build with Rspack's conflict error. An imported WebAssembly module goes to dist/assets as <hash>.module.wasm; a wasm file read through new URL() keeps its own name.

  • A production build keeps every class's own name. 4.0's meocord build --prod renamed one of two classes that shared a name across modules, such as Shop to shop_controller_Shop, so cooldowns were counted under the new name in production and errors and ExecutionContext.getController().name gave it.

  • A user and a message context menu with the same name each reach their own handler. In 4.0, the handler declared first under the name took both.

  • A guard shared as one instance reads each call's own params, such as one listed in @MeoCord({ services }) or injected into a service. In 4.0, overlapping calls with different params could read each other's, and a shared guard that called another made the inner one read the outer's.

  • Log lines quote what a user sent on one line. A message's text, a reaction's emoji and a component's customId are quoted with line breaks and quotes escaped, and a message's text is cut after 200 characters. A log filter that matched the raw text matches the quoted form.

  • A reply Discord can't read is logged as an error. A refused send that only the code building it can fix, an invalid form body (50035), invalid JSON (50109) or an empty message (50006), is logged at error with its cause; a state Discord reports, such as a missing permission or an interaction already answered, stays at debug.

  • Coverage of untested files. vitest run --coverage stopped with a syntax error on a file no spec imports that holds TypeScript, such as a type annotation, because SWC skipped it. New apps pass SWC an include that allows the query coverage adds; in an existing app's vitest.config.ts, add it to swc.vite({ ... }):

    TypeScript
    swc.vite({
      include: /\.m?[jt]sx?(?:\?.*)?$/,
      // ...the options already there
    }),
  • process.env values that meocord.config.ts loads are set before any application module runs, after a rebuild. An option such as @MeoCord({ activities: [{ name: process.env.STATUS! }] }) read undefined in 4.0.

  • A modal handler's second argument also carries the submitted fields, keyed by customId, beside the customId params, and a select menu handler's carries its choices: values, and the users, members, roles or channels discord.js resolves. A param wins over a field or a choice of the same name, and in development a warning names the clash.

  • createMockInteraction(ModalSubmitInteraction).isFromMessage() returns true only when the mock has a message, as a real modal does, rather than undefined.

  • Mocks from meocord/testing carry ids like Discord's. An interaction's id, channelId and user.id, a message's id, author.id, channelId and guildId, and createMockUser().id are distinct snowflake strings, where each was a mock object that read as [object Object], so every default user was the same one. An interaction mock made without a guildId has guildId, guild and member null, as a direct message does, where they were truthy. A test that relied on two mocks sharing a user, such as one checking a per-user cooldown, gives them one: { user: first.user }. One that read guild or member from a mock without a guildId gives it one.

  • Other mock defaults follow Discord, so a 4.0 test that relied on a stub's answer can change:

    • createMockUser() and a message's author have bot: false.
    • getMember() returns the member in the interaction's server, or null in a DM, where 4.0 returned the user.
    • An option getter throws discord.js's own error where discord.js would, where 4.0 returned the value as given, null or its own message: a getter of another type, such as getString() on a number or getInteger() on a fraction, a missing required option, getChannel() given channel types the channel isn't, and getSubcommand() with no subcommand. Read a fraction with getNumber(), and write getSubcommand(false) to read none as null. A user or member read as a role, or the reverse, reads as null, or throws when the option is required, where 4.0 returned the object.
    • A member given to createChatInputOptions reaches a handler's User param as its User, where 4.0 passed the GuildMember.
    • An autocomplete mock's respond() rejects more than 25 choices, as Discord does.
    • A mock's promise methods resolve, and a manager's fetch(id) returns the item with that id.
    • A channel's type guards, such as isTextBased() and isDMBased(), run discord.js's logic.
    • A select menu has picked nothing unless given values.
    • A server has Discord's defaults, an interaction with a guildId has a member, and a message's inGuild() answers from its guildId.
  • MeoCordFactory.create() returns the MeoCordApplication type from meocord/interface, with the same start() and registerCommands().

  • meocord/eslint reports a promise nothing awaits, with @typescript-eslint/no-floating-promises, so lint may flag code that passed in 4.0. await or return each one, as an interceptor's next.handle() always should be, or mark it void and handle its failure with .catch(). To keep 4.0's lint, set the rule to 'off' in eslint.config.ts; see ESLint.

  • A built bot finds its config and its assets beside dist/main.js, wherever it's started; 4.0 read dist/meocord.config.mjs from the working directory. A bot started from the project root needs no change; rebuild to pick up the asset paths. .env is still read from the working directory, by the dotenv import in meocord.config.ts, so a bot started elsewhere, such as pm2 without cwd, a systemd unit without WorkingDirectory, or cd dist && node main.js, needs its environment set there, or dotenv pointed at the file.

  • A reaction no longer fetches a message the bot already holds whole. reaction.message is the copy the gateway keeps current, and MeoCord fetches it only when the bot holds the message by its id alone, as for one sent before it started; 4.0 fetched it for every reaction. A handler that relied on data straight from Discord after a missed update calls await reaction.message.fetch() itself.

  • A reaction in a DM the bot hasn't cached reaches its handlers again. From discord.js 14.26.2, such a reaction was dropped before any listener saw it, even with Partials.Channel. With the DirectMessageReactions intent, MeoCord fetches the DM channel on its first reaction and delivers the reaction to @ReactionHandler and to your own messageReactionAdd and messageReactionRemove listeners. Code that fetched DM channels to work around it can go.

  • A handler may return a value. return interaction.reply(…) compiles on @Command, @MessageHandler, @ReactionHandler and @Autocomplete. @Autocomplete<void>(…), which 4.0 needed, still compiles; its type parameter goes in MeoCord 5.

  • A select menu's choices are typed. values is string[], users is User[], and members, roles and channels are what discord.js resolves. A declaration no choice can have, such as values: number, users: string or values: string, now fails to compile, where it failed at the first selection. Declare the array; a narrower or readonly type, such as members: GuildMember[] or readonly Role[], still compiles. A route param of the same name, as in pick/{values}, is checked as the param.

  • A plain-string customId pattern's keys are checked. With @Command('stats/{id}', CommandType.BUTTON), a handler declaring { uid } fails to compile, naming uid, which was always undefined at runtime. Use the name the pattern captures.

  • A builder whose constructor throws is refused as one whose build() throws, such as a field initialiser that reads a translator or the environment, as the class loads, naming the builder and the command, with the builder's error as the cause: Stats.stats: StatsBuilder could not be made for "stats": missing translator. In 4.0, the bare error escaped at import.

  • A @Command handler called with the wrong interaction, as a direct call in a test can be, throws Cards.card: @Command('card/{id}', CommandType.BUTTON) takes a ButtonInteraction, not a ChatInputCommandInteraction., or …; it was given undefined. for something that isn't an interaction. It was "Invalid interaction type passed to @Command for method: card"; update a test that matched it.

  • MeoCord sets the bot's presence only when activities lists some. 4.0 rotated activities every 10 seconds even when none were set, clearing the activity each time, so a status set in onReady, in clientOptions.presence or by a command vanished 10 seconds later. 4.1 leaves the presence alone without activities, and shows the first one at ready rather than 10 seconds in. A timer that set the status again to work around it can go.

  • activities cycle in order. 4.0 picked one at random every 10 seconds, so the same status could show several times in a row. 4.1 shows the first at ready, then the next, starting again after the last.

  • A signal after a failed login exits with the login's code. SIGINT or SIGTERM after a failed login exited 0; it now exits 1, the code the failed login set, so a process supervisor doesn't read a bot that never came online as a clean stop.

  • meocord start and meocord register pass SIGINT and SIGTERM on to the bot, so a signal from Docker, pm2 or systemd, sent to the CLI alone, shuts the bot down through its own shutdown path; in 4.0 it stopped the CLI and could leave the bot running.

  • meocord build no longer rewrites tsconfig.json: it reads comments and trailing commas as TypeScript does, where 4.0 "repaired" the file and wrote it back without your comments. A relative or package extends in it now works.

  • A controller, service or guard that extends another decorated class gets its own constructor's dependencies injected, and a base controller no longer lists, or routes to, the handlers a subclass declares.

  • Builds no longer use an eval devtool. Development builds emit cheap-module-source-map rather than eval-source-map, and an eval-* devtool set through output.sourceMap or tools.rspack in your rsbuild hook is built as its non-eval equivalent, with a warning: an eval'd module cannot read import.meta, so with bundleDependencies such a bundle stopped at startup with a SyntaxError.

  • Stack traces name your source on Node and Bun. In 4.0, a development build's eval'd modules named the source, and a production trace pointed into dist/main.js unless Node ran with --enable-source-maps. 4.1.0-beta.4 dropped the eval, so development traces on Bun pointed into the bundle too. Now meocord start runs node with --enable-source-maps, and a bundle started any other way, bun included, maps its own stacks from dist/main.js.map. An error tracker that applies uploaded source maps to the bundle's positions wants sourceMappedStacks: false in meocord.config.ts; see Stack traces.

  • A bundleDependencies build starts under Bun. A bundled ES module that probes for CommonJS, as lodash-es does with typeof exports, made Bun read the whole bundle as CommonJS and refuse its import statements; those probes now see undefined, as they do under Node.

  • A customId param holding %2F or %25 reaches the handler decoded, as / or %, so ids built by route() round-trip. A handler that decoded them itself now receives the decoded value; drop its own decoding.

Adopting 4.1 patterns

Nothing here is required. Each item replaces something a 4.0 bot had to write by hand; the Guide covers each in full.

Handler metadata: createMetadata and ExecutionContext. A 4.0 guard read SetMetadata values with Reflect.getMetadata, and reading them from the interaction found nothing. Declare the decorator with createMetadata and inject ExecutionContext into the guard; its value is typed, and the handler's wins over the controller's.

TypeScript
// 4.0
export const Roles = (...roles: string[]) => SetMetadata('roles', roles)
const required: string[] = Reflect.getMetadata('roles', interaction.constructor) ?? []

// 4.1
export const Roles = createMetadata<string[]>('roles')

@Guard()
export class RolesGuard implements GuardInterface {
  constructor(private readonly context: ExecutionContext) {}

  canActivate(interaction: ChatInputCommandInteraction): boolean {
    const required = this.context.get(Roles) ?? []
    // ...
  }
}

Answering: respond(). Checks such as interaction.deferred || interaction.replied before choosing between reply, editReply, update and followUp can go: respond(interaction).send(...) picks the call from where the answer stands, and acknowledge() defers once however often it is called. It answers through the interaction's own methods, so it also works where a user-installed app is used without the bot. A custom error embed built in each handler or filter can become a presenter, registered with @MeoCord({ presenter }), which styles every error MeoCord shows.

TypeScript
// 4.0
if (interaction.deferred || interaction.replied) await interaction.editReply(payload)
else await interaction.reply(payload)

// 4.1
await respond(interaction).send(payload)

Colours: useTheme() and @MeoCord({ theme }). Colours read from Theme, or written into embeds by hand, can read the call's theme by role, which an app sets once and a controller or handler changes with @UseTheme. A new app declares tokens of its own in src/types/theme.d.ts; copy the template's into yours, keeping its import 'meocord/interface' line, which makes it extend the module rather than replace it. See Theming.

TypeScript
// 4.0
embed.setColor(Theme.errorColor)

// 4.1
embed.setColor(useTheme().colors.danger)

Deferring and locking: @Defer. A handler that called deferReply() or deferUpdate() first, then disabled its message's buttons and put them back when done, can take @Defer() instead. It acknowledges before guards run, locks a component's message with a loading view once the call is allowed, and respond(interaction).send() without components puts the buttons back as they were — including ones disabled on purpose.

TypeScript
// 4.0
await interaction.deferUpdate()
await interaction.message.edit({ components: disabledCopyOf(interaction.message.components) })
// ...work...
await interaction.editReply({ embeds: [card], components: interaction.message.components })

// 4.1
@Defer()
async refresh(interaction: ButtonInteraction) {
  // ...work...
  await respond(interaction).send({ embeds: [card] })
}

Denying with a message: GuardDeniedError. Instead of replying from the guard and returning false, throw new GuardDeniedError('Only the owner can use this.'). The user who made the call sees the message privately, and an exception filter can phrase it otherwise.

Rate limits: @Cooldown. The generated RateLimitGuard, or a guard of your own that counts calls, can become @Cooldown({ uses: 5, seconds: 60 }) on the handler or the controller. It counts per user, server, channel or for everyone, only once guards and validation have let the call through, and answers a blocked call privately with how long to wait. With process sharding, pass a shared CooldownStore, such as one on Redis, to @MeoCord({ cooldownStore }).

TypeScript
// 4.0
@UseGuard({ provide: RateLimitGuard, params: { limit: 5, windowInSeconds: 60 } })

// 4.1
@Cooldown({ uses: 5, seconds: 60 })

Guards for the whole bot: @MeoCord({ guards }). A guard repeated on every controller, such as a blocklist, can be listed once in @MeoCord, where it runs before every handler's own guards. Give it types: ['interaction'] in @Guard if it should skip messages, reactions and events.

Error handling: exception filters. A try/catch repeated in handlers to answer the user can move into a @Catch filter, on the controller with @UseFilter or for the whole bot in @MeoCord({ filters }).

Cross-cutting work: interceptors. Timing, logging and caching written into each handler can move into an @Interceptor, which runs around the handler and sees what it returns or throws.

Parsing options: @Validate and pipes. Reading, checking and converting options inside the handler can become a schema: the handler receives typed, valid values, and invalid input gets a private reply listing each issue. A pipe turns a valid value into what the handler works with, such as a record loaded by its id.

Message commands: patterns and prefixes. A @MessageHandler() that checked message.content.startsWith('!') and split the rest into words can become @MessageHandler('roll {sides} {note...?}') with @MeoCord({ messages: { prefix: '!' } }): the handler receives { sides, note }, quoted words count as one, and @Validate and @Cooldown({ by }) see the params. Only the most specific pattern runs, across controllers.

Client events: @On and @Once. A service that injected Client and called client.on(...) in its constructor can declare @On('guildMemberAdd') on a method instead. The arguments are typed, the handler runs through guards, interceptors and filters, and an error is logged rather than crashing the bot. Tests send the event with module.emit.

Startup and shutdown: lifecycle hooks. Work started from a ready listener, or cleanup registered with process.on('SIGTERM'), belongs in onReady and onShutdown from OnReady and OnShutdown. They run in dependency order, and shutdown waits for them up to shutdownTimeout. To stop the bot from code, as an owner-only shutdown command, a graceful restart or an integration test does, call app.stop(): it runs the onShutdown hooks and closes the client, and with process sharding it stops every shard, whichever process calls it. A bot in one process keeps its process running, and so does a shard manager; a shard's call ends that shard's process with the others. A stopped app doesn't start again; create a new one.

Testing a handler: invoke. Calling a controller method directly runs its guards only. module.invoke(Controller, 'method', interaction) runs everything dispatch runs, global stages included when the module is created with app: App, and resolves to { ran }. inspectHandler lists the stages a handler runs without building a module.

Registration: the commands setting and meocord register. A development bot can register every command to one guild with commands.developmentGuild, where changes show at once, and a deployment can register from CI with meocord register and commands.register: false. @CommandBuilder(type, { guilds }) keeps a staff command in its own guilds.

Loading .env: the config only. An import 'dotenv/config' at the top of main.ts, added so the environment was set before App loaded, is no longer needed: the build loads meocord.config.ts, and with it .env, ahead of main.ts. Keep the import in meocord.config.ts and remove the one in main.ts.

Sharding: the sharding setting. A hand-written discord.js ShardingManager script can become sharding: { shards: 'auto', mode: 'process' } in meocord.config.ts, started as usual with meocord start or node dist/main.js. Call a service in every shard with ShardContext.call.

Localisation: createTranslator. Command names, descriptions and replies kept in hand-rolled maps can move into one typed catalog per locale, checked at compile time, with expectCompleteCatalog in tests.

Optional packages: optionalExternals. With bundleDependencies, a package a dependency only tries to load, such as supports-color, belongs in optionalExternals rather than externals, where a missing copy stopped the bot at startup.

Typing a stage wrapper: named options. GuardOptions, InterceptorOptions, ObserverOptions and ValidateOptions come from meocord/interface, so a decorator that wraps @Guard or the others can type what it passes on, instead of Parameters<typeof Guard>[0]. A generic @Validate wrapper types its pipes as ValidatePipes<S> of its schema, so they're checked against the schema's output as @Validate checks them:

TypeScript
import { Validate } from 'meocord/decorator'
import { type StandardSchemaV1, type ValidateOptions, type ValidatePipes } from 'meocord/interface'

export const Checked = <S extends StandardSchemaV1, const P extends ValidatePipes<S> = Record<never, never>>(
  schema: S,
  options?: ValidateOptions<P>,
) => Validate(schema, options)

PrimaryEntryPointCommandData, the body an entry point builder returns, is exported too; it was writable only as CommandBuildResult<CommandType.PRIMARY_ENTRY_POINT>.

Startup errors: isExplainedError. When Discord refuses the token or a privileged intent, MeoCord logs what to fix, such as which intents the bot requests and where to enable them, then app.start() rejects as before. A 4.0 main.ts logs that error again, with its stack trace. Skip the errors MeoCord already explained:

TypeScript
import { isExplainedError, Logger } from 'meocord/common'

bootstrap().catch(error => {
  if (!isExplainedError(error)) logger.error('Error during startup:', error)
})

Something here did not match what you saw? Open an issue.