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.

331 lines (254 loc) • 14 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> ### 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; their watching is refused and they 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. ## 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.