UNPKG

@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
# 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.