# Commands

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

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:

```ts title="src/commands/migrate.ts"
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`)
}
```

```sh
./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:

```ts
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 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](#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()`](/guides/instrumentation/). A command gets the first and not the
second:

- **The environment is validated.** The file is imported before any command
  runs, so a missing `DATABASE_URL` fails the command at that import, with the
  schema's own message, as it would stop the server.
- **`register()` does not run** unless the command says
  `export const register = true`. A command that only reads the schema should
  not fail because a queue it never touches is not up.

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:

```ts title="src/commands/work.ts"
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:

```ts
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.

<Aside type="caution" title="Do not destructure deadline">
  `deadline` is a getter, because what it answers changes. Destructuring it,
  as in `({ signal, deadline }) => …`, reads it once, as the command starts,
  when it is always `undefined`, and keeps that. Read `context.deadline` at the
  moment you need it. `signal` is a plain property and can be destructured.
</Aside>

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:

```sh
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`.

<Aside type="note" title="--sourcemap writes files beside the binary">
  With `--compile`, `--sourcemap` embeds the map in the binary, and Bun also
  writes `.map` files next to the output: one for a plain compile, and one per
  chunk with `--splitting` (46 for the example, about 224 for a large app, and a
  `dist` that grew to 201 MB). The binary does not read them. A stack trace is
  the same with them deleted, so ship the binary alone and remove them
  (`rm -f dist/*.map`), or leave `--sourcemap` off, which is what the scaffold
  does. `--sourcemap=inline` does not prevent them under `--compile`. What
  a trace shows either way is in [reading a stack trace](/hosts/where-it-runs/#reading-a-stack-trace).
</Aside>

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:

```dockerfile
FROM oven/bun:1
COPY dist/app /app
ENTRYPOINT ["/app"]
CMD []
```

```sh
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:

```sh
node .output/server/commands.mjs migrate
bun  .output/server/commands.mjs migrate
```

<Aside type="caution" title="Not .output/server/index.mjs">
  `index.mjs` is the server, and it does not read its arguments:
  `node .output/server/index.mjs migrate` starts the server and migrates
  nothing. `commands.mjs` is the entry for a command. The binary has both
  behind one file because it knows from `argv` which was meant.
</Aside>

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:

```ts title="src/commands/schedule.ts"
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(() => {})
  }
})
```

```sh
./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.
