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
dotenvto 18.0.5 or a later 18.x, anddiscord.jsto 14.27 or a later 14.x - Rename the
webpackhook inmeocord.config.tstorsbuild, and reshape its body - Replace any use of the
MeoCordWebpackConfigtype - Stop importing MeoCord's internal routing helpers from
meocord/decorator, if you did - Run
meocord buildandmeocord start - Optional: turn on
bundleDependenciesto deploy withoutnode_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.5bun add discord.js@^14.27.0 dotenv@^18.0.5pnpm add discord.js@^14.27.0 dotenv@^18.0.5yarn add discord.js@^14.27.0 dotenv@^18.0.5Your 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.vaultsupport. If you used it, move those variables to your deployment's environment or a plain.envfile. - 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:
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 MeoCordConfigAfter:
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 MeoCordConfigIf 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, media | Nothing. Emitted to dist/assets/ by default |
generator.filename on that rule | output.filename.image (and svg, font, media) — a string or a function |
asset/source rule | tools.rspack → addRules([{ test, type: 'asset/source' }]) |
Any other module.rules entry | tools.rspack → addRules([...]); it takes the same webpack-shaped rule |
plugins | tools.rspack → appendPlugins(...), or an Rsbuild plugin |
devtool | output.sourceMap.js |
resolve.alias | resolve.alias |
externals | MeoCord's own externals option, alongside rsbuild |
optimization.minimizer | output.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:
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 MeoCordConfigDo 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 underdist/assets/without content hashes, anddist/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-mapin production. Development builds usecheap-module-source-mapfrom 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:
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 --prodbunx meocord build --prod
bunx meocord start --prodpnpm exec meocord build --prod
pnpm exec meocord start --prodyarn meocord build --prod
yarn meocord start --prodFive things behave differently:
- Builds read
meocord.config.tsevery time. MeoCord 3 read the compiled copy the previous build left indist, 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 deletingdist, you can stop. meocord startruns bun with--no-install. Without it, bun downloads any package it cannot find at runtime. If you launchdist/main.jswith bun directly — a DockerCMD, for example — add the flag yourself:bun --no-install dist/main.js.- Production source maps name real paths.
dist/main.js.maplists each source relative todist, as../src/app.ts, where webpack wrotewebpack://<your-app>/./src/app.ts. An error tracker that uploads source maps and rewrites or matches paths by thewebpack://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, somain.tswent on to log "Application started" and the process exited 0 — which Docker'srestart: on-failure, systemd and CI read as success. The generatedmain.tsneeds no change: itscatchstill 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 thatcatch. Code that awaitsstart()and carries on after it failed now gets the error instead. lintcoversmeocord.config.ts. The shared config frommeocord/eslintused to skip it, so upgrading can surface lint findings in that file for the first time — typically an unused import. If ESLint instead reports thatmeocord.config.tsis not included in any of the provided projects, add it toincludeintsconfig.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:
import { type MeoCordConfig } from 'meocord/interface'
export default {
discordToken: process.env.DISCORD_TOKEN!,
bundleDependencies: true,
} satisfies MeoCordConfigPlain 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:
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:
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-istanbul5. vitest 5 removedcoverage.all, soall: falseno 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. Removeall: false, and giveswc.vitetheincludebelow 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-swc2 takes the same options; 4.1's template adds theincludeabove.meocord.config.tsintsconfig.json. The template now lists it underincluderather thanexclude, sotscin yourlintscript typechecks the config, and your editor resolvespathsaliases in it —import '@src/common/utils/load-env.util'— the way the build already does.noEmitstays 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().deleteddid not compile, though the mock always tracked it. It is typed now.- A CommonJS project —
require('meocord/core'), or TypeScript withmodule: node16— got the ES module declarations and was told it could notrequireMeoCord. 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
@Autocompletehandlers - Check what users see when a command throws after it replied or deferred
- Fix any
meocord.config.tsoption of the wrong type, since it now stopsbuild,startandregister - Fix or replace the generated
src/guards/rate-limit.guard.ts, if your app still has it - Check
@MessageHandlerkeywords, 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 returningnull, 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@ReactionHandlerthat 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
SetMetadataand string keys withcreateMetadata, andReactionHandlerOptionswithReactionEvent - Remove imports of
MetadataKey,CommandMetadataandAutocompleteMetadata - 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
@Autocompletefor 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
@Catchentry 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.
@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:
@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:
@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:
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.
@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 matchesHelloandHELLO. 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 matcheshello 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 }:
@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:
@ReactionHandler('📌', { bots: true })
@ReactionHandler({ bots: true }) // every emojiA 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:
@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:
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.
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:
Theme | Role | 4.0 | 4.1 |
|---|---|---|---|
Theme.successColor | colors.success | #28A745 | #26A042 |
Theme.infoColor | colors.info | #17A2B8 | #1699AE |
Theme.errorColor | colors.danger | #DC3545 | #E3606D |
Theme.warningColor | colors.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:
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:
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:
// 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:
@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:
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:
@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:
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:
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 thesettingshandler without a word; - it's given a customId pattern or a
route(), which these handler types don't take; - its builder's
setNamediffers from its@Commandname; - 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:
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:
@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:
pnpm add -D reflect-metadata @types/nodepnpm 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:
allowBuilds:
'@swc/core': false
unrs-resolver: false
minimumReleaseAgeExclude:
- meocordBoth 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:
"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 awaitnext.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: falsebeside an app with no prefix, or two a mention starts. They were taken as different starts, so the order of yourcontrollersdecided 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,
undefinedornullfor 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 takesundefinedandnull, soreturn prefixes.get(id)needs no cast.A new app reads every
.envfile, however it starts. Itsmeocord.config.tsloads.env.<mode>.local,.env.local(not undertest),.env.<mode>and.env, as Bun reads them, so a value in.env.localreaches the bot undernode dist/main.js, pm2, systemd or Docker as it does undermeocord start. An app made before this keepsimport 'dotenv/config', which reads.envalone 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 --prodcompilesmeocord.config.tsin production mode, as it builds the bot; it was always compiled in development. A config that branches onNODE_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 --devruns the bot withNODE_ENV=development, whatever the shell sets, so its config and.envfiles are the development ones; 4.0 kept aNODE_ENVthe 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 everyLoggermethod of the CommonJS build threwchalk.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=productionwhere you start a production bot yourself, as withbun dist/main.js. With it unset, or set to anything butproductionortest, Bun loads.env.developmentbefore 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-fileworks 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, soimport logo from './logo.png'passes the app's owntscand lint. Copy it into yoursrc/types/to have the same, and add'src/**/*.d.ts'tocoverage.excludeinvitest.config.ts, where istanbul would try to read it as code. Keep it a file with noimportorexport: itsdeclare 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 threemeocord.cooldownwait keys of the earlier 4.1 betas are replaced bymeocord.cooldown.until. A catalog that translated them translatesuntilinstead, keeping{when};expectCompleteCatalogreports each old key as "… is not one of MeoCord's texts". A filter that words the wait itself readserror.retryAt, theDatethe next call is allowed.Presenters from an earlier 4.1 beta get the call's theme as
context.theme, and an error'stone,'warning'or'danger'. A spec that builds aResponseContextor aPresentedErrorby hand adds them:theme: createMockTheme()frommeocord/testing, andtone: 'danger'.meocord.dm.errorfrom 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.callfrom an earlier 4.1 beta types each result as JSON gives it back, asJsonified<T>frommeocord/core: a method returningPromise<Date>givesstringvalues, where the type saidDate. Code that read the method's own type reads the JSON form. A param JSON would change, such as aDate, can't be passed: the compile error names the type to declare. Anundefinedargument arrives asundefined, where it wasnull, 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.tssets@typescript-eslint/no-deprecatedto'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, asexpect(interaction.reply)does, would be warned about the deprecated overloadreplyalso has. Add the same two to your own config to have them.meocord generatewrites components in 4.1's style, answering withrespond(), 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/banmakesAdminBanButtonController. 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 withmessages.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, andcooldownStoreTimeoutMssets the wait.From an earlier 4.1 beta:
ShardedCooldownStoreno 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:
@Cooldowncountssecondsin whole milliseconds. It roundssecondsto the millisecond, and a value outside0.001to4320000000000, such asInfinity, stops the bot where the decorator applies.meocord start --devexits 1 when watch mode can't start, such as when anrsbuildhook inmeocord.config.tsthrows, asmeocord builddoes. It exited 0, so a script or process manager around it saw success.Loggertagsinfo()andverbose()as[INFO]and[VERBOSE], where 4.0 tagged them[LOG]. They still print at theloglevel, 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; setFORCE_COLOR=1where your log viewer shows colour. A small object prints on one line, asconsole.logprints it, where 4.0 gave each property a line of its own.[DEBUG]lines print only in development. 4.0 printeddebug()everywhere. A bot started withNODE_ENVother thandevelopment, as a production build is, now prints fromlogup; setlogLevel: 'debug'inmeocord.config.ts, orMEOCORD_LOG_LEVEL=debugfor one run, to see them. See Logging.Loggerreads the config only in the built bot. In 4.0 a logger's first line loadeddist/meocord.config.mjswherever it ran, and with it the.envfiles that config imports. A test or a script that logged before readingprocess.envgot the bot's environment from that load; it now loads its own, as withdotenv/configor the test runner'senv. 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.MessageandPartials.Reactionis 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 withCommandType.SLASH. One that builds its name from the path still fails, now with an error naming the handler.meocord start --devsends 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-registersends it anyway. Production starts register as 4.0 did.Only
meocord start --devclears the screen, in a terminal, and keeps the scrollback.meocord buildandmeocord start --prodno longer clear it, and no command writes escape codes into piped output, such as CI logs ordocker logs.Imported files other than images, fonts, SVG and media go to
dist/assetsunder their own names. 4.0 put a PDF, a text file or a web manifest indist/staticwith a content hash in the name. Code that read those paths from disk, not from the import, readsdist/assets/<name>. Two imported files of one name now stop the build with Rspack's conflict error. An imported WebAssembly module goes todist/assetsas<hash>.module.wasm; a wasm file read throughnew URL()keeps its own name.A production build keeps every class's own name. 4.0's
meocord build --prodrenamed one of two classes that shared a name across modules, such asShoptoshop_controller_Shop, so cooldowns were counted under the new name in production and errors andExecutionContext.getController().namegave 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 --coveragestopped 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 anincludethat allows the query coverage adds; in an existing app'svitest.config.ts, add it toswc.vite({ ... }):TypeScript swc.vite({ include: /\.m?[jt]sx?(?:\?.*)?$/, // ...the options already there }),process.envvalues thatmeocord.config.tsloads are set before any application module runs, after a rebuild. An option such as@MeoCord({ activities: [{ name: process.env.STATUS! }] })readundefinedin 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 theusers,members,rolesorchannelsdiscord.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()returnstrueonly when the mock has amessage, as a real modal does, rather thanundefined.Mocks from
meocord/testingcarry ids like Discord's. An interaction'sid,channelIdanduser.id, a message'sid,author.id,channelIdandguildId, andcreateMockUser().idare 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 aguildIdhasguildId,guildandmembernull, 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 readguildormemberfrom a mock without aguildIdgives 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 havebot: false.getMember()returns the member in the interaction's server, ornullin 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,
nullor its own message: a getter of another type, such asgetString()on a number orgetInteger()on a fraction, a missing required option,getChannel()given channel types the channel isn't, andgetSubcommand()with no subcommand. Read a fraction withgetNumber(), and writegetSubcommand(false)to read none asnull. A user or member read as a role, or the reverse, reads asnull, or throws when the option is required, where 4.0 returned the object. - A member given to
createChatInputOptionsreaches a handler'sUserparam as itsUser, where 4.0 passed theGuildMember. - 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()andisDMBased(), run discord.js's logic. - A select menu has picked nothing unless given
values. - A server has Discord's defaults, an interaction with a
guildIdhas amember, and a message'sinGuild()answers from itsguildId.
MeoCordFactory.create()returns theMeoCordApplicationtype frommeocord/interface, with the samestart()andregisterCommands().meocord/eslintreports a promise nothing awaits, with@typescript-eslint/no-floating-promises, solintmay flag code that passed in 4.0.awaitorreturneach one, as an interceptor'snext.handle()always should be, or mark itvoidand handle its failure with.catch(). To keep 4.0's lint, set the rule to'off'ineslint.config.ts; see ESLint.A built bot finds its config and its assets beside
dist/main.js, wherever it's started; 4.0 readdist/meocord.config.mjsfrom the working directory. A bot started from the project root needs no change; rebuild to pick up the asset paths..envis still read from the working directory, by the dotenv import inmeocord.config.ts, so a bot started elsewhere, such as pm2 withoutcwd, a systemd unit withoutWorkingDirectory, orcd 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.messageis 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 callsawait 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 theDirectMessageReactionsintent, MeoCord fetches the DM channel on its first reaction and delivers the reaction to@ReactionHandlerand to your ownmessageReactionAddandmessageReactionRemovelisteners. Code that fetched DM channels to work around it can go.A handler may return a value.
return interaction.reply(…)compiles on@Command,@MessageHandler,@ReactionHandlerand@Autocomplete.@Autocomplete<void>(…), which 4.0 needed, still compiles; its type parameter goes in MeoCord 5.A select menu's choices are typed.
valuesisstring[],usersisUser[], andmembers,rolesandchannelsare what discord.js resolves. A declaration no choice can have, such asvalues: number,users: stringorvalues: string, now fails to compile, where it failed at the first selection. Declare the array; a narrower or readonly type, such asmembers: GuildMember[]orreadonly Role[], still compiles. A route param of the same name, as inpick/{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, naminguid, which was alwaysundefinedat 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 thecause:Stats.stats: StatsBuilder could not be made for "stats": missing translator.In 4.0, the bare error escaped at import.A
@Commandhandler called with the wrong interaction, as a direct call in a test can be, throwsCards.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
activitieslists some. 4.0 rotatedactivitiesevery 10 seconds even when none were set, clearing the activity each time, so a status set inonReady, inclientOptions.presenceor by a command vanished 10 seconds later. 4.1 leaves the presence alone withoutactivities, 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.activitiescycle 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 startandmeocord registerpass 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 buildno longer rewritestsconfig.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 packageextendsin 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
evaldevtool. Development builds emitcheap-module-source-maprather thaneval-source-map, and aneval-*devtool set throughoutput.sourceMaportools.rspackin yourrsbuildhook is built as its non-eval equivalent, with a warning: an eval'd module cannot readimport.meta, so withbundleDependenciessuch 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.jsunless Node ran with--enable-source-maps. 4.1.0-beta.4 dropped the eval, so development traces on Bun pointed into the bundle too. Nowmeocord startruns node with--enable-source-maps, and a bundle started any other way, bun included, maps its own stacks fromdist/main.js.map. An error tracker that applies uploaded source maps to the bundle's positions wantssourceMappedStacks: falseinmeocord.config.ts; see Stack traces.A
bundleDependenciesbuild starts under Bun. A bundled ES module that probes for CommonJS, as lodash-es does withtypeof exports, made Bun read the whole bundle as CommonJS and refuse itsimportstatements; those probes now seeundefined, as they do under Node.A customId param holding
%2For%25reaches the handler decoded, as/or%, so ids built byroute()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.
// 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.
// 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.
// 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.
// 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 }).
// 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:
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:
import { isExplainedError, Logger } from 'meocord/common'
bootstrap().catch(error => {
if (!isExplainedError(error)) logger.error('Error during startup:', error)
})Upgrading from 4.1 to 4.2
4.2 is a minor release, and a 4.1 bot and its tests build and run without edits. By default, startup errors arrive together, a few warnings are new or say more, and customId patterns rank segment by segment. Only the ranking can change what a bot does, and only for pattern pairs 4.1's startup warning named; the checklist names what to look at.
- Upgrade
meocordto 4.2 - Read a startup failure's report to its end, since it lists every error the checks found
- Check each customId pattern pair 4.1's startup warning named, which can now reach the other handler
- Decide, for each message command that takes 5 seconds or more, whether its listeners should run beside it
- Add
useMockFn(vi.fn)tovitest.setup.ts, as a new project has it - Try the options that bring 5.0's defaults early, one at a time
Startup errors arrive together
MeoCordFactory.create() and a testing module's compile() report every error their checks find, not only the first,
and log each with the file it comes from. They then throw the first, the same error with the same message, so code and
tests that catch it or match its text keep working, and a lone error is reported as before. A decorator's error, such as
an invalid customId pattern, is still thrown as its class is defined, with its message unchanged, and names its
handler as error.declaration and its file as error.file. Neither is an enumerable property, so a test that compares
the error with toEqual sees what it saw in 4.1. See
Every startup error at once.
customId patterns rank segment by segment
Component customId patterns rank segment by segment, left to right. At the first segment where one pattern spells out literal text and the other leaves a param, the literal one runs, whatever order the controllers are listed in. If that leaves a pair tied, the narrower type at the first param where they differ runs. 4.1 ranked by how much literal text a whole pattern had, so only overlapping pairs can change handler, and 4.1's startup warning named every one:
- A pattern with more literal text now loses to one that spells out an earlier segment:
a/{x}runs fora/abcdinstead of{x}/abcd. - Pairs 4.1 left to listing order, such as
a/{x}/canda/b/{y}, go to the earlier literal,a/b/{y}, whatever the listing: the handler 4.1's warning said 5.0 would run. - Typed params are compared position by position, not summed:
{n:int}/{s}runs for7/7instead of whichever of it and{s}/{n:int}is listed first.
For each pair the 4.1 warning named, check that the handler that runs now is the one you want, or make the patterns
distinct. The startup warning now names only pairs the ranking still can't tell apart, and findRouteConflicts lists
only those, so a test that expected a pair the ranking now decides, such as profile/summary/{uid} and
profile/{ownerId}/{uid}, gets []. See Overlapping patterns.
A slow message handler is named
Under the default, messages: { handlers: 'sequential' }, a running bot warns once per handler when a message's
handler takes 5 seconds or more with listeners waiting after it, and names the option that runs them side by side. If
the listeners read what the handler writes for the same message, keep the default and turn the warning off with
messages: { slowHandlerWarning: false }. A testing module warns only with slowHandlerWarning: true. See
Running them side by side.
Mocks made with your test runner
A new project's vitest.setup.ts calls useMockFn(vi.fn), so Vitest's clearMocks, mockReset and vi.mocked reach
MeoCord's mocks. Add the same line to an existing setup file, before any mock is made. Under jest and bun, whose
mockReset drops a mock's starting behaviour, reset with MeoCord's resetAllMocks(). See
Your test runner's mocks.
A mock's type guards, such as isButton() and inGuild(), keep answering after resetAllMocks(), where 4.1.0 and
4.1.1 returned undefined, so a mock made once in beforeAll passes its type checks in every test.
Without useStrictMocks(), a voice channel's joinable and speakable and a DM channel's partial now give the
one-time placeholder warning the other placeholders give, so a test that asserts nothing was warned may notice.
Every placeholder warning from meocord/testing now ends ", or call useStrictMocks() to have the mock compute it now.",
so a test that matches a warning's whole text needs the new ending.
Options that bring 5.0's defaults early
Each of these is off in 4.2 and on by default in 5.0. Turn them on one at a time, and the bot and its tests show what 5.0 changes before you upgrade:
| Option | What it changes | See |
|---|---|---|
@Controller({ inheritedRoutes: 'replace' }) | A handler the class re-decorates answers only the routes the class declares for it; the startup warning names each it would drop. | A subclass's routes |
startupErrors: 'all' in meocord.config.ts | Decorators keep their errors, and the bot reports every one in a run; in a test, call reportAllStartupErrors(). | Configuration |
useStrictMocks() in a test setup file | Mocks compute what discord.js computes, such as editable or kickable, where they read a placeholder and warn otherwise. | Strict mocks |
A new project's vitest.setup.ts calls useStrictMocks() already.
Something here did not match what you saw? Open an issue.