Configuration
Configure how your bot is built and started, what it logs, and the settings your own code reads.
You'll learn
- Set the token, logging and build options in meocord.config.ts
- Load .env files, one per environment
- Read and check your own settings once, before the bot logs in
- Tell the build's options from the app's
Before this
A MeoCord bot has two kinds of settings. meocord.config.ts, at the project's root, holds how the bot is built and
started: its token, what it logs, how it's bundled, where its commands are registered.
@MeoCord({...}) holds what the app is made of: its controllers, its theme, its guards.
When to use it
Put a setting in meocord.config.ts when the CLI or the process needs it before your code runs: the token, the log
level, a build rule, sharding. Everything about how the bot behaves once it runs goes in @MeoCord, and each of
those options is taught on its own page, listed below.
Your own values, such as a channel ID or an API key, belong in neither. Read them from the environment into a provider, so they're checked once and injected where they're used.
Example
import 'dotenv/config'
import { type MeoCordConfig } from 'meocord/interface'
export default {
// Every log line starts with it, so a bot's lines stand out among other processes'
appName: 'Feedback',
discordToken: process.env.DISCORD_TOKEN!,
// Warnings and errors only; MEOCORD_LOG_LEVEL=debug prints everything for one run, without a rebuild
logLevel: 'warn',
} satisfies MeoCordConfigThe bot reads its token from .env, starts each log line with "Feedback", and prints only warnings and errors.
How it works
meocord build compiles the config with the bot, into dist/meocord.config.mjs. When the bot starts, it loads
that compiled copy, however it's started: meocord start, node dist/main.js, bun, pm2 or Docker. Nothing in the
built bot reads meocord.config.ts itself.
build, start and register check the config first. An option of the wrong type stops them with a list of every
problem; an option MeoCord doesn't know, often a typo, is reported as a warning.
meocord start --dev watches meocord.config.ts and reloads it on every change. A production bot keeps the config
it was built with, until the next meocord build --prod.
Options
| Option | Default | What it does |
|---|---|---|
discordToken | none | The bot token. Read it from the environment rather than writing it here. |
appName | none | Starts every log line. |
logLevel | 'log' | The least severe line the bot prints; 'debug' while NODE_ENV is development. See Logging. |
sourceMappedStacks | true | Stack traces name your source files and lines, not the bundle's. See Stack traces. |
startupErrors | 'first' | 'all' reports every startup error a decorator finds at once, as the bot starts. See Every startup error at once. |
shutdownTimeout | 10000 | Milliseconds shutdown waits for the onShutdown hooks, all of them together. |
commands | global | Where commands are registered, and whether at startup: see Slash commands. |
sharding | none | Splits the gateway connection into shards: see Sharding. |
rsbuild | none | (config) => config: adjusts the Rsbuild configuration the bot is built with. See the build hook. |
bundleDependencies | false | Puts everything the bot needs inside dist/, so it runs without node_modules. |
externals | [] | Modules to keep out of the bundle. |
optionalExternals | [] | Packages a dependency tries to load and runs without, such as supports-color. |
The last two matter mostly for a bundled bot: see Self-contained builds.
Each option, with its full type, its default and the version it first appeared in, is listed in the meocord.config.ts reference.
Logging
logLevel is the least severe line Logger prints, MeoCord's own lines included:
| Level | Prints |
|---|---|
'debug' | everything |
'log' | everything but [DEBUG] lines |
'warn' | warnings and errors |
'error' | errors only |
'silent' | nothing |
Without it, the bot prints 'debug' while NODE_ENV is development, as under meocord start --dev, and 'log'
otherwise, NODE_ENV unset included. A self-contained build goes by the mode it was built in.
To change the level for one run, without a rebuild, set MEOCORD_LOG_LEVEL. It wins over logLevel, ignores letter
case, so DEBUG works, and can be set in .env. An unknown value is ignored, with a warning. logLevel applies to
the built bot; the CLI's own output and your tests read only the variable. The level is read once, when the first
line is logged, so a busy bot never looks it up again, and setting the variable from code after that changes nothing.
MEOCORD_LOG_LEVEL=debug node dist/main.jslogger.info() and logger.verbose() print at the 'log' level, tagged [INFO] and [VERBOSE], so a filter on
[LOG] doesn't match them. A line is in colour on a terminal and plain where the output isn't one, such as a file or a
log collector, objects included. Set FORCE_COLOR=1 to keep the colour where your log viewer shows it.
Stack traces
A stack trace names your source, such as src/services/profile.service.ts:42:11, not the bundle, on Node and Bun
alike. The build writes dist/main.js.map beside the bundle, in development and production, and:
meocord startruns Node with--enable-source-maps, so Node maps each stack itself. The shard processes it starts inherit the flag.- A bundle started any other way, with
node dist/main.jsin a DockerCMD, under pm2, or with Bun, which applies no source map to a bundle, maps its stacks throughError.prepareStackTrace. The map is read the first time a stack needs it, and each frame keeps the runtime's format,at fn (/abs/path/src/file.ts:line:col), so a tool that parseserror.stackreads it as before. - A hook already set on
Error.prepareStackTrace, such as a preloaded error tracker's, receives the mapped call sites. One set later replaces MeoCord's unless it calls the hook it found. - Bun reports a call's column further along than Node does. In a minified production bundle, a frame for a call can map to the statement just before it, one line up; the frame that threw maps exactly.
Set sourceMappedStacks: false when an error tracker applies uploaded source maps to the bundle's own positions, or
you ship a source mapper of your own. meocord start then passes no flag, and the bundle installs no hook.
Environment variables
Load .env in meocord.config.ts, not in main.ts. The bot loads its config before main.ts, so every value the
files set is there by the time @MeoCord({...}) and the rest of your modules read process.env. A new app's config
reads the files Bun reads for its mode: the production files in a production build and the development files in a
development build, however the bot is started, and .env.test where it runs from source under NODE_ENV=test. On Bun,
a production build you start yourself, with NODE_ENV unset, also gets the values of .env.development and
.env.development.local, which Bun loads first; meocord start --prod sets NODE_ENV for you, and elsewhere set
NODE_ENV=production, as Deployment explains. For an environment of your own, pick the
files with a variable of your own, as below:
import { config } from 'dotenv'
import { type MeoCordConfig } from 'meocord/interface'
// The .env files Bun reads, most specific first: dotenv keeps the first value a variable is given, and one set in the
// shell over all of them. A production build reads the production files, however the bot is started.
const mode = process.env.NODE_ENV || 'development'
config({
path: [`.env.${mode}.local`, ...(mode === 'test' ? [] : ['.env.local']), `.env.${mode}`, '.env'],
quiet: true,
})
export default {
discordToken: process.env.DISCORD_TOKEN!,
} satisfies MeoCordConfigThe mode is NODE_ENV, development unless it's set: meocord start --dev builds in development and runs the bot as
development, whatever NODE_ENV the shell holds, so it reads and watches the development files, and
meocord build --prod writes production into the config it compiles. A more specific file wins, and a variable the
shell sets wins over every file, so .env.local can hold your own values beside the committed .env.development.
To pick files by something other than NODE_ENV, such as a staging server, put the choice in a module the config
imports:
import { existsSync } from 'node:fs'
import path from 'node:path'
import { config } from 'dotenv'
// APP_ENV picks the file: .env.dev, .env.staging, .env.prod, resolved from the directory the bot starts in
const file = path.resolve(`.env.${process.env.APP_ENV ?? 'dev'}`)
config({ path: existsSync(file) ? file : path.resolve('.env'), quiet: true })import './load-env'
import { type MeoCordConfig } from 'meocord/interface'
export default {
discordToken: process.env.DISCORD_TOKEN!,
} satisfies MeoCordConfigAPP_ENV=staging node dist/main.jsStart the bot from the project root: the .env files are read from the working directory, so set cwd in pm2 and
WORKDIR in a Dockerfile. dist/meocord.config.mjs is found beside the bundle wherever the bot starts.
Your own settings
A value read from process.env wherever it's needed is checked nowhere: a missing channel ID shows up as a failed
call, long after the bot started. Read the environment once, in a factory, and check it there:
// What the bot reads from the environment, each value checked
const Env = z.object({
REPORT_CHANNEL_ID: z.string().regex(/^\d{17,20}$/, 'must be a channel ID'),
SUPPORT_URL: z.url().default('https://example.com/support'),
})
export interface Settings {
reportChannelId: string
supportUrl: string
}
export const SETTINGS = createToken<Settings>('Settings')
export function loadSettings(env: NodeJS.ProcessEnv = process.env): Settings {
const result = Env.safeParse(env)
if (!result.success) throw new Error(`The environment is incomplete:\n${z.prettifyError(result.error)}`)
return { reportChannelId: result.data.REPORT_CHANNEL_ID, supportUrl: result.data.SUPPORT_URL }
}
// Made once, before the bot logs in, so a missing value stops it at startup rather than at the first call
export const settingsProvider: Provider = { provide: SETTINGS, useFactory: () => loadSettings() }Provide it on the app, and inject it by its token:
@MeoCord({
controllers: [ReportController],
providers: [settingsProvider],
clientOptions: { intents: [GatewayIntentBits.Guilds] },
})
export default class App {}@Service()
export class ReportService {
constructor(@Inject(SETTINGS) private readonly settings: Settings) {}
channelId(): string {
return this.settings.reportChannelId
}
}MeoCord runs every factory before the bot logs in. When a value is missing, the bot logs the factory's error, naming
Settings and each value, and stops. A test gives the loader an environment of its own:
describe('loadSettings', () => {
it('reads the environment, with defaults for what is optional', () => {
expect(loadSettings({ REPORT_CHANNEL_ID: '123456789012345678' })).toEqual({
reportChannelId: '123456789012345678',
supportUrl: 'https://example.com/support',
})
})
it('names each value that is missing or malformed', () => {
expect(() => loadSettings({ SUPPORT_URL: 'not a url' })).toThrow(/REPORT_CHANNEL_ID[\s\S]*SUPPORT_URL/)
})
})A test of ReportService provides SETTINGS with useValue instead, as Services
shows.
The app's options
@MeoCord({...}) takes controllers and clientOptions, which every bot needs, and these, each taught on its own
page:
| Options | Taught in |
|---|---|
services, providers | Services and injection |
theme, themeFor, themeCache, themeForTimeoutMs | Theming |
i18n | Localisation |
presenter | Presenters |
warnUnanswered | Answering with respond() |
messages | Message commands |
guards | Guards |
interceptors | Interceptors |
filters | Exception filters |
cooldownStore, cooldownStoreFailure, cooldownStoreTimeoutMs | Cooldowns |
observers | Observers |
To share options between app classes, type the object as MeoCordOptions from
meocord/decorator. Its guards, interceptors and filters are checked against the classes they hold, as in
@MeoCord itself, so a filter in guards is refused. Typed plainly, it takes any params on a { provide, params }
entry. To have those checked too, give the entries as its type arguments, guards, then interceptors, then filters, as in
MeoCordOptions<[{ provide: typeof ChannelGuard }]>, or write them in @MeoCord({...}).
activities lists the bot's statuses, shown in order: the first once it's ready, then the next every 10 seconds,
starting again after the last.
The build hook
MeoCord builds with Rsbuild. The rsbuild hook receives its configuration and returns it,
modified:
import 'dotenv/config'
import { type MeoCordConfig } from 'meocord/interface'
export default {
appName: 'MyBot',
// Read from the environment: this file is committed, and .env is not
discordToken: process.env.DISCORD_TOKEN!,
rsbuild: config => {
// Import .md and .html files as their text
config.tools ??= {}
config.tools.rspack = (_rspackConfig, { addRules }) => {
addRules([{ test: /\.(md|html)$/i, type: 'asset/source' }])
}
return config
},
} satisfies MeoCordConfigSome things need no rule of your own:
- Imported images, fonts, SVG, media, PDFs, text files and the other kinds
src/types/assets.d.tsdeclares as a path are emitted todist/assets/under their own names, and importing one gives its absolute path on disk, ready forfs, a canvas or a Discord attachment. A file of another kind, JSON and WebAssembly aside, which the build handles itself, needs a rule of its own intools.rspack, as Markdown does above. The path is set as the bot starts, from where itsdistis, so a build made in CI or another folder finds its assets. Nothing is inlined as a data URI. An imported WebAssembly module goes todist/assets/as<hash>.module.wasm, so two modules of one name stay apart; a wasm file read throughnew URL('./file.wasm', import.meta.url)keeps its own name. - Asset file names: two imported files of one name in different folders stop the build with Rspack's conflict
error, naming the file.
output.filename.image, andsvg,font,mediaandassets, accept a function to keep both. - Raw bundler rules go through
tools.rspack, as above. - Source maps:
source-mapin production andcheap-module-source-mapin development. Change them withoutput.sourceMap.js. Aneval-…devtool is built as the same map without the eval, and plainevalas none, with a warning: the bundle readsimport.meta, which a module evaluated from a string can't.
Gotchas
.envloaded inmain.tsis too late for the config and for@MeoCord({...}), which readprocess.envfirst. Load it inmeocord.config.ts.- A production bot doesn't see a config change until it's built again: run
meocord build --prod, ormeocord start --build --prod. logLevelhides your own lines too.Loggerprints through it, so'warn'also hides yourlogger.log()calls.- The token doesn't belong in the file.
meocord.config.tsis committed;.envisn't.
Next steps
- Services and injection: provide values and inject them by token.
- Self-contained builds: ship
dist/withoutnode_modules. - Deployment: run the built bot under pm2, Docker or a host's process manager.