Skip to content

Commands

A release step or a worker is the same app started differently — ./app migrate.

Updated View as Markdown

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:

src/commands/migrate.tsts
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.

  • args is everything after the name, untouched. ./app migrate --help gives migrate the --help; parsing is yours.
  • The return value is the exit code. Nothing is 0; a number from 0 to 255 is itself. A command that throws exits 1 with the error and every cause under 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) exits 1 and says what it returned, rather than reporting success for a mistake.
  • context is { signal, deadline }: signal is aborted by SIGTERM and SIGINT, and deadline says when the command has to be done. See a worker.
  • description, an optional exported string, is what ./app --help lists 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 $?
2

2 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/worker validates the worker’s schema, and only that one.
  • export const register = true runs the command from the server’s bundle: instrumentation.ts is imported, so its environment is validated, and register() runs before the command. It has to be the literal true - 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:

src/commands/work.tsts
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 finds signal already 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.aborted turns true and the abort event 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/app

An 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 worker

The 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 migrate

The 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’s tsconfig paths).
  • A Worker. The bundle targets workerd, where a database driver or node:child_process is 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 leaves src/commands out and says so. A Worker’s release step is the file itself, run in CI before the deploy, or wrangler for D1.
  • Any other preset (Vercel, Netlify, Deno): commands are built for the bun and node presets, whose output is a process. Anywhere else the build says so and leaves src/commands out; 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:

src/commands/schedule.tsts
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 schedule

Run 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close