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.

300 lines (235 loc) • 13.5 kB
# Where it runs > A host is a Nitro preset, not a server you write. There used to be a page here for each runtime, and a server file generated to match. There is no server file now, and no choice about it either: [Nitro](https://nitro.build) builds the server around the route tree, and choosing where an app runs is choosing a preset. ```ts title="vite.config.ts" import { defineConfig } from 'vite'; import { nitro } from 'nitro/vite'; import react from '@vitejs/plugin-react'; import { rscKit } from '@rsc-kit/core/vite'; export default defineConfig({ plugins: [ nitro({ preset: 'bun', serveStatic: 'inline' }), rscKit(), react(), ], }); ``` `bun create rsc-kit@latest my-app` writes that for you. Changing where it deploys is changing the one string. ## The presets `rsc-kit` asks about three, because those are the ones it can check for you. Nitro carries [many more](https://nitro.build/deploy) and they need nothing from this package — a preset is a string, not an integration. | `--host` | preset | what you get | | --- | --- | --- | | `bun` | `bun` | `.output/server/index.mjs`, and `bun run compile` for a single binary | | `node` | `node` | `.output/server/index.mjs`, run with `node` | | `worker` | `cloudflare_module` | a Worker, plus the `wrangler.json` and `_headers` Nitro generates | Everything else — Vercel, Netlify, Azure, Deno, AWS Amplify — is the same change: ```ts nitro({ preset: 'vercel', serveStatic: 'inline' }) ``` ## Streams on serverless platforms `events()` routes and `refreshOn` hold a connection open. On Workers and serverless functions each isolate or instance watches for its own tabs, a platform's request limits cut streams (they reconnect and catch up), and versions must live in a shared store. [On Workers and serverless](/guides/live-data#on-workers-and-serverless) has the details. ## Running it ```bash npm run dev # vite is the renderer; nothing is prebuilt npm run build # writes .output/ npm run start # runs .output/server/index.mjs ``` A Worker has no `start`, because it is deployed rather than started: ```bash npm run preview # wrangler dev, on workerd npm run deploy # nitro deploy --prebuilt ``` ## Compiling to a single binary Bun only, and the whole application ends up inside one file — engine, route tree, frozen pages and assets, the precompressed variants included: ```bash npm run compile # builds, then bun build --compile --splitting .output/server/compile.mjs ./dist/app ``` `compile.mjs` is written by the build beside the server. It is what puts the frozen pages inside the binary: the server reads them through a computed import, which a compile cannot see, and this entry imports them by name and hands them over before starting the server. Compile `index.mjs` instead and the binary still works — it renders those pages live. Nothing else has to travel with the binary: not `.output/public`, not `.output/server`. A `Dockerfile` copies `dist/app` and runs it. `./app` starts the server. If the app has [`src/commands`](/guides/commands/), `./app migrate` runs one of them instead, without starting the server, and `./app --help` lists them. The same files run from a plain build as `node .output/server/commands.mjs migrate`, so a release step or a worker does not need the binary or Bun. The scaffold compiles with `--splitting`, so that a stop that arrives while Bun parses the binary is held for a worker rather than ending the process: [where a stop is held from](/guides/commands/#where-a-stop-is-held-from). It builds first on purpose. Compiling whatever `.output` happens to hold means a binary one version behind the source with nothing to say so — and on a project that has never been built, an `ENOENT` naming a path the app did not write. Partially prerendered pages are resumed inside the binary exactly as they are by the built server. Compiling merges every module into one scope and renames whatever collides — `--keep-names` does not prevent that — so the build pins each component's name as a `displayName` the shell can rely on. No flag. `--compile` implies `--production`; `--target bun` is the default. `--bytecode` compiles the JavaScript ahead of time for a faster cold start at the cost of a larger binary — a production port measured 275 ms → 72 ms to the first healthy response for 81 MB → 117 MB, and left it out, since on a pod the pull costs more than the boot saves. Bytecode is CommonJS, and in CommonJS `import.meta` does not exist: Bun lowers `import.meta.url`, `.dirname`, `.dir` and `.main`, and a module using any other form — `.env`, `.filename`, `.resolve`, the object itself — fails the step. Bun then reports "Failed to generate bytecode", names no file, and **exits 0**, so a CI ships a binary that dies at boot with "import.meta is only valid inside modules". The engine's own output is clean; a dependency's `import.meta.env` or `import.meta.resolve` is the usual cause. `--bytecode --format=esm` applies regardless: the same bytecode, as a module. <Aside type="note" title="A Worker serves the frozen pages from a module"> A Worker has no filesystem, so the directory the build writes is not there at request time. The build also writes the same pages as one module beside the bundle, `rsc-static-inline.mjs`, which wrangler uploads with the rest and the server imports when the directory is missing. Nothing to configure; it is why `○` on a Worker means the stored page and not a live render that happens to be the same. It counts toward the Worker's script size, which is worth knowing on a site with hundreds of frozen pages. </Aside> <Aside type="caution" title="serveStatic: 'inline' is what makes this work"> Without it the binary compiles, starts, serves pages, and 404s every asset. Inside a compiled binary the static path resolves into Bun's virtual filesystem — where the files on disk are not — and the failure is `ENOENT: /$bunfs/public/assets/…` with the pages themselves looking fine. The generated config sets it. If you write your own, set it too. </Aside> ## Reading a stack trace A trace from a built app names the build output, not `src/`: ``` at zz_throw_default (.output/server/_ssr/zz-throw-D9B2cblU.mjs:7:12) ``` That file is Nitro's copy of the chunk Vite built for the module. It is named for the module (`zz-throw` is `src/commands/zz-throw.ts`, `Counter` is `Counter.tsx`) plus a hash that changes every build, and it is not in the binary's directory or shipped with it, so the path is a label and not a file you can open. The line and column are the chunk's, not the source's. Frames from the engine and the rest of the app show `rsc.mjs:<line>`. Source maps do not change this today: - **Bun ignores input source maps.** `bun .output/server/commands.mjs` and a binary compiled with `--sourcemap` both show the chunk path, even when the chunk has a map that is correct (a hand-made one resolved to the exact source line under Node and was ignored under Bun). `--sourcemap` maps the binary's bundle back to its input files and no further. That is Bun's limit, and rsc-kit does not work around it. - **Nitro writes the maps for `_ssr/` with no mappings.** Vite's own map for the chunk is correct and points at `src/`, but Nitro's copy of the chunk has a map with a source and nothing else, so `node --enable-source-maps` shows the chunk too (checked with `build.sourcemap` and Nitro's `sourcemap` both on). This is Nitro's, and fixing it would help only a Node server. ## Native dependencies stay outside the bundle A package with a native binary — `sharp`, `bcrypt`, `better-sqlite3`, `@prisma/client` — cannot be rolled into a server bundle: the build succeeds and the server cannot load its own binary. The usual ones are left external by default, and Nitro traces each into `.output/server/node_modules` with its binaries, so the deployment is still one directory. For one that is not on the list: ```ts rscKit({ serverExternalPackages: ['@acme/native-thing'] }) ``` ## A polyfill runs first A dependency that checks for a polyfill at module evaluation — `tsyringe` wants `Reflect.getMetadata`, under `@peculiar/x509`, under `@simplewebauthn/server` — depends on `import 'reflect-metadata'` running before it, and the source has it there. A bundler does not keep that place: it emits a chunk's imports before its external ones, whatever the source said, so the check ran first — survivable by luck as a directory, fatal compiled into a binary. When the project's graph has `reflect-metadata` anywhere in it, the build loads it in a Nitro plugin, which the server's entry evaluates before any of the app is imported; a project without it has nothing that checks. Nothing to add to `instrumentation.ts`. ## A dependency's `"use client"` is read The other direction. A dependency is bundled into the server graphs — and so has its `"use client"` directives read — when it declares `react` as a peer dependency, which is what a React library does. One that imports React and never says so (a generated component wrapper, a workspace package with `react` under `dependencies`, a package whose author forgot) would be left external, and its directive is then a string nobody reads: the server loads it as a server module and the first `useState` in it fails at render, with an error that names React and not the package. So the build reads the direct dependencies once, and bundles any that carry a `"use client"` file and plugin-rsc would not have. It says so: ```text [rsc-kit] bundling @acme/chat-widget: it has "use client" files but does not declare react as a peer dependency, so its components would otherwise run on the server. ``` Nothing to configure; the line is there so the package's author hears about the missing peer. The React-using dependencies under such a package — the runtime a generated wrapper calls into — are bundled with it, so there is one React for all of them: left external, that runtime would load React through Node while everything else got it through Vite, and the hooks dispatcher is null in one of the two. Only direct dependencies are read for the directive itself — a directive two levels down is the concern of the package between, which declared it. ## Offline `rscKit({ offline: true })` writes a service worker into `.output/public` alongside the assets, so a page someone has visited survives a reload with no network. Off by default, and covered in [Offline](/guides/offline#surviving-without-one). ## Why `nitro` is pinned The generated `package.json` pins an exact version rather than a range: ```json "nitro": "3.0.260903-beta" ``` Nitro's own `latest` tag is a dated prerelease, and it sorts **above** the plain `3.0.0` on npm. So `^3.0.0` resolves to the older release, which builds without complaint and then answers 404 to every route. TanStack Start pins a dated beta for the same reason. ## Assets are Nitro's The build writes browser assets to `.output/public`, and Nitro serves them from its own root. There is no `assetsDir` or `assetsUrl` to set: both were removed, and `rscKit()` refuses a config that still passes them rather than reading a prefix and ignoring it. The alternative was the silent version — the markup asks for the app's prefix, Nitro answers at its own, and the page arrives unstyled and never hydrates with nothing logged anywhere. Assets live in `.output/public` and are served by the same process that serves your pages. If you want nginx or a CDN serving them instead, point it at `.output/public` — that directory is the deployment. ## Nothing is sent raw A bun or node server answering the internet by itself compresses what it sends, because nothing in front of it will. The build writes a `.br` and a `.gz` beside every public asset larger than a kilobyte, and Nitro serves whichever the request accepts — a 147 kB stylesheet is 18 kB on the wire. The host gzips what it answers itself — documents, streamed pages, RSC payloads, stored pages, api routes — for a request that accepts it, flushing every chunk so a streamed shell still reaches the browser before the holes fill; a stored page is compressed once and kept. A port measured what this is worth: with every byte raw, first paint on a throttled phone was 3.8 s where 1.2 s was the baseline. That was the whole regression. On a Worker the platform compresses and the host does nothing. Behind a CDN or nginx that compresses, the proxy sees an already-encoded answer and passes it through. A deployment that would rather its proxy did all of it turns the host's half off with `compress: false` on the handler, and Nitro's with `compressPublicAssets: false` in its config; `Cache-Control: no-transform` on an answer leaves that answer alone. --- Next: [Deploying →](/hosts/deployment) ## Which entry point is whose `@rsc-kit/core/host` is the engine's front door for a **host adapter** — the handler, and beside it the few functions an adapter needs when it embeds the engine. Several of those are re-exports of app-facing modules, so an editor's auto-import may offer `redirect` or `revalidate` from `host`. They are the same functions; an app imports them from their own entry points: | in an app | not | | --- | --- | | `@rsc-kit/core/redirect` | `@rsc-kit/core/host` | | `@rsc-kit/core/revalidate` | `@rsc-kit/core/host` | Only an adapter imports `host`. The re-exports there are marked internal, so they sort below the app entry in a completion list.