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.

406 lines (311 loc) • 17.1 kB
# Deploying > What to ship, and the two settings that fail quietly. `npm run build` writes one directory, and it is the deployment: ``` .output/server the server Nitro built, and the engine it calls .output/public hashed assets and the pages frozen at build time ``` ```sh npm run build npm run start # node or bun .output/server/index.mjs ``` `npx vite preview` runs the same build through Vite's preview server, which is what Nitro suggests at the end of a build. Both serve the real thing; `start` is what a deployment runs. ## Anywhere that runs the runtime `.output/` is self-contained. Copy it and start it — a container, a VPS, a process manager, a platform that runs a Node or Bun process. There is nothing to register and no platform API to satisfy, and **no `node_modules`**: the dependencies are in the bundle. ```dockerfile FROM oven/bun:1 AS build WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . RUN bun run build FROM oven/bun:1 WORKDIR /app COPY --from=build /app/.output ./.output CMD ["bun", ".output/server/index.mjs"] ``` The second stage carries `.output` and nothing else. On the docs application that is 1.0 MB against 104 MB of `node_modules`. <Aside type="tip" title="What a port measured"> A production app ported from Next, same pages, same backend, the Bun binary image beside the Next one it replaced: | | Next | rsc-kit | | --- | --- | --- | | docker image | 104.56 MiB | 50.12 MiB | | docker build | 5 m 13 s | 2 m 45 s | | Lighthouse | — | 99 mobile, 100 desktop | Nothing was tuned for the numbers. The image is smaller because the bundle carries no `node_modules`; the build is faster because there is one build, not a build and a trace. </Aside> ### A release step, and processes that are not the server The image above starts a server because that is what the binary does with no arguments. With `src/commands` it does something else with one: ```sh docker run --rm myapp migrate # before the new version takes traffic docker run --rm myapp work # a worker, beside the web processes ``` Any platform that can run a command in the image does the same — a Kubernetes Job, a release command, a process type — and nothing in the app names which. See [Commands](/guides/commands/). ### Stopping gracefully Nothing to write. A rollout sends the old process `SIGTERM` (or `SIGINT`), and the built server stops the way it should: 1. It stops taking connections and gives in-flight requests `SERVER_SHUTDOWN_TIMEOUT` seconds to finish (5 by default). 2. It calls `shutdown()` from [`instrumentation.ts`](/guides/instrumentation/) if you export one, for whatever you opened: a database pool, a queue client, a telemetry exporter. It gets `RSC_SHUTDOWN_TIMEOUT` seconds (10); one that hangs or throws is reported and does not hold the process. 3. It exits with code 0. An app with nothing open exits as soon as the drain finishes. Two things still undo it quietly: - **Another `SIGTERM` handler that exits.** A logger or SDK that calls `process.exit()` on a signal runs first if it was imported first, and the drain never starts. In-flight requests, webhooks included, are dropped on every rollout, and the process exits 0, so nothing looks wrong. A logger should flush on `exit`, not handle the signal. - **A grace period shorter than the drain.** On Kubernetes, `terminationGracePeriodSeconds` has to cover `SERVER_SHUTDOWN_TIMEOUT`, then `shutdown()`, then any `preStop` sleep, or the `SIGKILL` arrives first. ## Or a platform, without a Dockerfile Change the preset and Nitro produces what that platform expects — a Worker and its `wrangler.json`, a Vercel function, a Netlify handler. See [Where it runs](/hosts/where-it-runs). ### Bindings on Workers A D1 database or an R2 bucket named in `wrangler.jsonc` is read from `cloudflare:workers`, the runtime's own module — the bundle leaves it to the runtime, like `bun:sqlite`. `vite dev` under Bun or Node has no such module, so import it where the code runs on Workers, behind a check for the runtime: ```ts title="src/db.ts" const onWorkers = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers' export async function db() { if (onWorkers) { const { env } = await import('cloudflare:workers') return env.DB // the D1 binding, as wrangler.jsonc names it } const { Database } = await import('bun:sqlite') return new Database('data/local.sqlite') } ``` The build renders pages through the same module, so a page that reads a table with no request in it is frozen from the local database, and only the pages that read the url or the request reach D1 at request time. ## Two things that fail quietly **Do not set `NODE_ENV` when starting the server.** The build bakes its mode into the bundle, so a server started with nothing set is production because it was *built* that way. Setting it at start time is a second source of truth and the one that can disagree — and when it disagrees the failure is silent: every page renders, and none of them hydrate. The check is React's debug rows in the payload: ```sh curl -s https://your-app.example.com/ | grep -c ':D{' ``` `0` on a correct production build. Anything else means a development bundle reached the client. **Serve `.output/public` at the root.** Nitro does this itself, so this only matters behind a CDN: point it at that directory and let the hashed filenames do the caching — they are content-addressed, so they can be cached forever. ## What the document sends ahead Every document answers with a `Link` header naming what it needs first — its stylesheet, the client entry, and the fonts its layout preloads: ``` Link: </assets/index-D_il52MF.css>; rel=preload; as=style, </assets/index-C-u4Ll0r.js>; rel=modulepreload, </assets/geist-latin-wght-normal-BgDaEnEv.woff2>; rel=preload; as=font; crossorigin ``` Cloudflare turns that into a `103 Early Hints` sent before the origin answers and cached at the edge, so those three download while the HTML is still on its way; any other CDN passes it through, and a browser reads it the moment the headers land, before the parser reaches the `<head>`. The critical set only, never the chunks: a hint promotes what it names, and naming every module demotes the document behind them. Stored documents carry it too — a stored document's own head names its fonts; a rendered document's head is not known until after its headers have gone, so it names the stylesheet and the entry. Nothing to configure, and nothing it replaces: the stylesheet still blocks paint, and inlining critical CSS stacks on top of it. ## Streams and idle timeouts An `events()` route and the [`refreshOn`](/guides/live-data) stream hold a connection open and are quiet most of the time. Anything in front that closes a quiet connection - the server's idle timeout, a proxy's read timeout - would cut them, so each sends a keepalive line every **eight seconds**: under Bun's default ten, and well under nginx's sixty and Cloudflare's hundred. If something in front closes idle connections sooner, set the keepalive below it: ```sh RSC_STREAM_KEEPALIVE_MS=4000 # under a 5-second idle timeout ``` Too long a keepalive does not lose anything - the browser reconnects, and a tab catches up on whatever moved - but every open tab is cut and reopens each time the timeout passes. Both streams are sent with `Cache-Control: no-store, no-transform` and are never compressed, so nothing in front buffers them. ## Secrets rsc-kit reads up to three. Each is its own key for its own job - never reuse one as another, so one leaking does not hand out the rest. | Variable | What it protects | Who needs it | Read by | | --- | --- | --- | --- | | `RSC_HOST_CALL_SECRET` | calls between the renderer and a Go or Laravel backend | an app with a backend - required | the renderer **and** the backend | | `RSC_SIGNING_SECRET` | the names a page hands a tab for [`refreshOn`](/guides/live-data) | an app that uses `refreshOn` - required in production | the renderer | | `RSC_ACTION_ENCRYPTION_KEY` | values a server action sends through the browser, [across a deploy](#server-actions-across-a-deploy) | optional | the renderer, from the build | `bun create rsc-kit` and `rsc-kit init` generate the first two into `.env` for an app with a backend, and `php artisan rsc:install` does the same for Laravel. That is development. For production, generate each once and keep it where you keep secrets - your platform's environment settings, not the repo: ```sh openssl rand -base64 32 ``` or, without openssl: ```sh node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" php -r "echo base64_encode(random_bytes(32)), PHP_EOL;" ``` Three rules apply to all of them: - **The same value on every instance.** Each is checked by a process that may not be the one that issued it: a tab served by one renderer, a call landing on another backend replica. A value per instance fails for some requests and not others. - **At runtime, from the environment.** The built server reads them when it starts, so one build deploys anywhere. `RSC_ACTION_ENCRYPTION_KEY` is the exception: set it at build time too. - **Never in git.** The scaffold's `.gitignore` already leaves out `.env`. **Rotating one** is a deploy, and each says so differently: - `RSC_HOST_CALL_SECRET` - the renderer and the backend must change together. Between the two, every `rpc()` is refused, so deploy both at once. - `RSC_SIGNING_SECRET` - pages already open hold names signed with the old key. Set the old value as `RSC_SIGNING_SECRET_PREVIOUS` for the deploy that rotates it: it is accepted when checking a signature and never used to make one, so open tabs keep refreshing while every page rendered after the rotation carries the new key. Remove it a day later. Without it, those tabs stop refreshing until reloaded; nothing else breaks. - `RSC_ACTION_ENCRYPTION_KEY` - an action called from a page loaded before the change fails, the same window as a deploy without the key pinned. ## What the build produces A deploy that plans before building - a binary or a folder, which script to run - asks first: ```sh bunx rsc-kit info --json ``` It loads the app's own Vite config the way a build does, so an `output` computed in the config, or `RSC_OUTPUT=export` in the environment, resolves exactly as the build will resolve it. Then every build writes the same answer to `.output/rsc-kit.json` as it finishes - the confirmation, from the code that decided; refuse a deploy where the two disagree: ```json { "version": "0.29.6", "output": "server", "compile": "compile", "binary": "dist/app" } ``` ```json { "version": "0.29.6", "output": "export", "dir": "dist" } ``` - **`output: "server"`** - run the server. `compile` and `binary` are there when the app's `package.json` has a `compile` script: run it, and the file its `--outfile` names is the single binary to ship. - **`output: "export"`** - `dir` is the static site; serve it as files. A build carries no backend address and no secret. `RSC_BACKEND`, `RSC_HOST_CALL_SECRET` and `RSC_SIGNING_SECRET` are read when the server runs, so one build runs against whichever backend its deployment names, and rotating a secret is a restart, not a rebuild. ## Is it ready `GET /_rsc/health` says whether this renderer can serve, for a platform deciding when to route to it - which an open port alone does not say: ```json { "ok": true, "version": "08f805d3", "backend": "ok" } ``` - **200** - up. `backend` is `"ok"` when the backend answered, `"none"` for an app without one. - **503** - the backend did not answer within 2 seconds: unreachable, or refusing the secret. Why it is not ready names the app's insides, so the reason and `backend` go only to a probe that sends the host-call secret, as `x-rsc-host-secret`. Anyone else gets `{ "ok": true }` or `{ "ok": false }` and the status - which is all a router needs. Not keyed on localhost: behind a reverse proxy on the same machine, every visitor is localhost. The backend check asks the route guards with none to run - every adapter passes an empty list - so it runs no app code and reads no data, yet it proves the address, the network, the shared secret and the adapter. Never cached; `HEAD` answers with the status alone. ## Pages across a deploy A page from the previous build asks for a payload from the new one, whose client components its own manifest cannot load. Every document carries which build it is (`<meta name="rsc-kit:build">`, an id derived from the client entry), the client sends it as `X-RSC-Version` on every payload request, and a server on another build answers `409` with where to go; the client loads the document from the current build, once. A payload that still names a client reference the page lacks — a CDN answering from cache, say — reloads the document the same way a missing chunk does. Nothing to configure, and nothing to set: the id is the client's, so a deploy that changed only the server leaves every open page alone. There is no override — a version set anywhere else would disagree with what the document says, and a client's honest claim would be refused on every navigation. ## Server actions across a deploy A server action can close over server-side values, and React encrypts those before sending them to the browser so the page cannot read them. The process that decrypts them on the way back has to hold the same key. By default that key is generated at build time and baked in. Every instance running the same build agrees, so the only exposure is the deploy itself: a browser sitting on a page from the old build calls an action on the new one, and the key has changed underneath it. The call fails. For most apps that window is seconds and nobody notices. If yours is long enough to care about — a slow rollout, long-lived pages, an app people leave open — pin the key: ```sh # once, kept wherever you keep secrets openssl rand -base64 32 ``` ```sh RSC_ACTION_ENCRYPTION_KEY=<that value> ``` Set it at **build time** and the build stops baking its own; the value is read from the environment when the server runs, so the same artifact deploys anywhere. :::danger[Set it everywhere or nowhere] Half-configured is worse than unconfigured. If one instance reads the variable and another falls back to a baked key, they disagree, and the symptom is an action that fails for some visitors and not others with nothing in the logs pointing at a key. Unset, everything works — the build-time key is used, which is the default precisely because it cannot be got half right. ::: ## Frozen pages and the routes that own them `.output/public` holds whole frozen pages and PPR shells alongside the assets. They are read through the `prerendered` reader, which is a function rather than a directory precisely so a runtime with no filesystem — a Worker — can supply them from a binding instead. A route that declares middleware is never cached publicly: it is sent as `private, no-store`, because middleware runs per visitor. If something in front of your app also owns the response — an auth proxy re-issuing a session on pass-through — give the paths it covers a `middleware.ts` so this host knows they are covered. See [serving shells from a CDN](/guides/edge-caching). ## Rebuild on deploy Cached responses carry a build version, so a deploy invalidates them. **Build, then start — never build under a running server.** The server loads its rsc and ssr services lazily, on the first request that needs each, and a `vite build` that is rewriting `.output/` at that moment hands it a file that is half-written or briefly missing. The runtime caches that failed import — Bun does, and so does Node's ESM loader — so the route answers 500 with an `ENOENT` for a file that now exists, and keeps doing so until the process restarts. In development use `vite` (the dev server), which owns its own rebuilds; in production build into a fresh directory or stop the server first, and start it against a finished `.output/`. Ship `.output/` from the same commit as the code that serves it. A server running one build against another's frozen pages is the one combination nothing checks for you. ## HTTP/2 in front Every client component is its own chunk, so a page loads only the code it renders — and a page with many of them makes several small requests. Over HTTP/2 or HTTP/3 those share one connection and cost nothing extra; over HTTP/1.1 a browser opens at most six connections per origin and the rest queue. Behind Cloudflare there is nothing to do: HTTP/2 and HTTP/3 are on by default, for proxied sites and Workers alike. The case to check is a Bun or Node server exposed to the internet directly, which speaks HTTP/1.1 — put anything that speaks HTTP/2 in front of it (a CDN, an ingress controller, Caddy, nginx), which is how almost every deployment runs anyway. `curl -sI --http2 https://your.site` answers `HTTP/2` when it is right.