A server is not the only thing an app runs. A deploy runs migrations before the
new version takes traffic; a queue is drained by a process that serves nothing.
Those are your code, with your dependencies, started differently. A file in
src/commands is one of them:
import { db } from '@repo/database'
export const description = 'Apply the database migrations'
export const register = true
export default async function (args: string[]) {
const applied = await db.migrate({ dryRun: args.includes('--dry-run') })
console.log(`${applied} migrations applied`)
}./app migrate --dry-run # the compiled binary
node .output/server/commands.mjs migrate --dry-run # a plain build, node or bun./app with no arguments starts the server, as it always did. Anything else
is a command, and the server is not started: a release step run beside the
version that is serving does not try to bind its port.
The command is built into the same bundle as the pages, so @repo/database
and the logger are the packages the pages use. There is no second build, and
a binary carries the commands inside it.
What a command is
A file directly in src/commands, with a default export:
import type { CommandContext } from '@rsc-kit/core/commands'
export default async function (args: string[], context: CommandContext): Promise<number | void>defineCommand types a command without writing the annotations, and returns
what it is given: export default defineCommand(async (args, context) => {}).
Both come from @rsc-kit/core/commands.
argsis everything after the name, untouched../app migrate --helpgivesmigratethe--help; parsing is yours.- The return value is the exit code. Nothing is
0; a number from0to255is itself. A command that throws exits1with the error and everycauseunder it, each on a[cause]:line - a library that wraps an error, as Drizzle does a failed query, keeps the reason there. One that returns anything else (a string,256) exits1and says what it returned, rather than reporting success for a mistake. contextis{ signal, deadline }:signalis aborted bySIGTERMandSIGINT, anddeadlinesays when the command has to be done. See a worker.description, an optional exported string, is what./app --helplists beside the name. It is read from the source as a plain string literal, so listing never loads a command.
The name is the file’s name without its extension. Declaration files, tests
(*.test.ts, *.spec.ts) and anything starting with _ or . are not
commands — _shared.ts is how two commands share a helper. A folder is not
one either: src/commands/db/seed.ts is not db:seed, and the build says so
rather than leaving it out silently.
$ ./app --help
Usage: ./app <command> [args]
Commands:
migrate Apply the database migrations
work
$ ./app migrat
Not a command: migrat
…
$ echo $?
22 is an unknown name, as it is for any tool that was misused. 1 is a
command that failed. A deploy that runs ./app migrate and stops on a
non-zero exit is therefore right in both cases.
register() is yours to ask for
src/instrumentation.ts validates the environment at import and connects in
register(). That is the server’s environment, and
a command does not load the file unless it asks:
- By default it is not imported. A command runs from a bundle of its own,
without instrumentation.ts, so a queue drain does not fail on the web’s
Stripe key. What the command imports still runs: one that imports
@repo/env/workervalidates the worker’s schema, and only that one. export const register = trueruns the command from the server’s bundle: instrumentation.ts is imported, so its environment is validated, andregister()runs before the command. It has to be the literaltrue- the build reads it off the source to choose the bundle, and a computed one is refused when it is true.
A command that opted in gets register() first, and shutdown() after it
returns — also after it throws — so the pool register() opened is closed.
Both are bounded by RSC_SHUTDOWN_TIMEOUT (10 seconds), and a shutdown()
that fails is reported without changing the command’s exit code.
A worker
A command that does not return until it is told to is a worker, and signal is
how it is told:
import { defineCommand } from '@rsc-kit/core/commands'
import { queue } from '@repo/queue'
export const register = true
export default defineCommand(async (_args, { signal }) => {
while (!signal.aborted) await queue.processNext({ signal })
})The first SIGTERM or SIGINT aborts signal and gives the command
RSC_SHUTDOWN_TIMEOUT seconds to return; if it does, the process runs
shutdown() and exits with the command’s code, 0 for a clean stop. A
command that does not stop in time exits 1, and says so. A second signal does
not wait.
signal.aborted is the state; the abort event is the moment it changed, and
it happens once. A stop reaches a command one of two ways, so a command that
waits needs both:
- Before it starts. A stop that arrives while the app loads (tens of
milliseconds) or while
register()runs findssignalalready aborted, and the event has fired before the command could listen for it. The runner gives the event loop one turn before it calls the command, so a stop that was waiting is delivered first, the same on Node, on Bun and in a binary. - After it starts. The usual case:
signal.abortedturns true and theabortevent fires while the command is running.
So check signal.aborted as the command starts, and listen for abort for the
rest, or pass signal to something that does (sleep(ms, undefined, { signal })
rejects at once). Checking alone misses a stop that arrives during a long
await; listening alone waits for ever on a stop that came first. The worker
above does both: the loop tests signal.aborted, and queue.processNext({ signal })
returns when the signal aborts. A command that only waits does it by hand:
export default defineCommand(async (_args, { signal }) => {
if (signal.aborted) return
await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
})deadline is when the runner stops waiting, as a Date.now() time, and is
undefined until the command has been told to stop. A worker finishing its
current job reads context.deadline - Date.now() instead of working out
RSC_SHUTDOWN_TIMEOUT and its default for itself.
The process is kept alive for as long as the command runs, whether or not it is holding a socket. A command waiting only for its signal waits, on Node as on Bun.
Where a stop is held from
The entry holds SIGTERM and SIGINT from its first line, however long the app
takes to load after that, and hands them to the command. Before its first line
a stop is the operating system’s, and ends the process with 143.
In a plain build that is nearly nothing: commands.mjs is a few lines and
loads the app after it holds the signals. In a compiled binary there is one
more step first. Bun parses every module in the file before it runs the
first line of any, so a stop that arrives while a large app is being parsed
(a production app measured about 170 ms, for 580 modules and 119 MB) ends the
process.
--splitting makes the entry its own chunk, which runs first and loads the rest
after it. The scaffold’s compile script has it:
bun build --compile --splitting .output/server/compile.mjs --outfile dist/appAn app made before it was added keeps its old script until you add the flag. An
app with no src/commands has nothing to split, so the flag changes nothing
there until it gets one. dist/app is still the only file the binary needs. A Bun that does not accept the pair fails the compile
loudly: 1.2 says cannot use --compile with --splitting.
The same stop, 250 ms into starting a binary of 80 MB, exits 143 without it
and 0 with it, and ./app --help answers without loading the app.
Nothing else about the binary changes: the server, the frozen pages and the
commands run from it as they do without.
Asking needs no environment
./app --help and a name that is not a command are answered before the app is
loaded, from a list the build wrote into the entry. A build machine, or a CI step
that has no database url, can list the commands. A real command loads the app,
and so validates the environment, and fails there with the schema’s own message.
Running one
Anywhere that can run the binary or the build:
FROM oven/bun:1
COPY dist/app /app
ENTRYPOINT ["/app"]
CMD []docker run --rm myapp # the server
docker run --rm myapp migrate # a release step
docker run --rm myapp work # a workerThe same shape is a Kubernetes Job’s args: ["migrate"], a release command, or
a process type. It does not matter to the app which: an image whose entrypoint
is the binary starts a server or a command depending on the argument, and
nothing in src/commands names a platform.
From a plain build, run the commands entry, not the server’s:
node .output/server/commands.mjs migrate
bun .output/server/commands.mjs migrateThe build lists them in .output/rsc-kit.json as commands, for a deploy that
wants to know before running one.
Where there are no commands
vite dev. There is no build to run one from. While developing, run the file:bun src/commands/migrate.ts(Bun runs TypeScript, and the app’stsconfigpaths).- A Worker. The bundle targets workerd, where a database driver or
node:child_processis a different build of the package or not there at all, so a command run from it would run something other than what you wrote. The build leavessrc/commandsout and says so. A Worker’s release step is the file itself, run in CI before the deploy, orwranglerfor D1. - Any other preset (Vercel, Netlify, Deno): commands are built for the
bunandnodepresets, whose output is a process. Anywhere else the build says so and leavessrc/commandsout; run the file from CI.
Recurring work
A scheduler is a command that keeps running, the way Laravel’s schedule:work
is. Start one instance of it; every minute it runs whatever is due, and it
stops when it is told to:
import { setTimeout as sleep } from 'node:timers/promises'
import { defineCommand } from '@rsc-kit/core/commands'
export const register = true
export default defineCommand(async (_args, { signal }) => {
while (!signal.aborted) {
await runDueJobs()
// Wakes at once when told to stop, instead of finishing the minute.
await sleep(60_000, undefined, { signal }).catch(() => {})
}
})./app scheduleRun one of it. Two would run every job twice, which is why the web server is not the place for a schedule: it runs as many copies as you scale it to.
A job that runs once a day does not need a process waiting for it. Let the
platform’s cron run an ordinary command (./app cleanup) and let it exit.
Things that fail quietly
A process.exit() inside a command skips shutdown(). Return the code
instead; the runner exits for you, after it has closed what register() opened.
A command that imports a package with a side effect at import runs it for the server too, if a page imports that package. Commands are loaded only when named, so the command’s own module never runs in a server. What it imports is the bundle’s, shared with the pages.
A command is not a route. It is not prerendered, has no request, and
headers() and cookies() have nothing to read. It is your code, not a page’s.