@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
315 lines (243 loc) • 13.1 kB
Markdown
# 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 `/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 `-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.