Host spec — createHost over per-target manifests (draft)
Status: DRAFT for review. Companion to the runner spec: that one declares the paradigm at build time; this one makes prod serving a one-liner over the artifacts the build already emits. Prod stays "a thin host over the emitted artifacts and manifest" — this spec makes the thin host something vprs hands you instead of something every consumer re-derives.
Problem
The build emits everything a server needs, and then the consumer writes the
server by hand anyway. The official demo's start.tsx is ~180 lines, and
every load-bearing line re-states knowledge an artifact already holds:
| hand-written in the consumer server | where the build already knows it |
|---|---|
/^/pokedex/[^/]+$/ — which URLs render dynamically | the router's route table (routePatterns, staticPaths) |
existsSync(join(staticDir, route, "index.html")) — prerendered or not | the prerender worklist that emitted dist/static |
collectManifestCss(manifest, "src/css/pokemon.module.css") + inline-threshold dance | the static manifest + the same css.inlineThreshold the build used |
staticManifest["index.html"].file — bootstrap module for hydration | the client build's manifest |
reading dist/static/404/index.html to answer notFound() | the prerendered 404 page |
handleRouteAction(request, { projectRoot }) wiring | the baked sealed gate in dist/server-edge/render.js |
pathToFileURL(join(buildDir, "client")) as moduleBaseURL | the build laid out dist/client for exactly this |
None of this is configuration — it is archaeology. The consumer can get every line subtly wrong (stale-port serving, bare-text 404s, missing charset-era bugs all shipped this way in real consumers), and no two consumers write the same server.
Two well-known escapes exist and both are rejected:
- Become a framework: own the server, the listener, the deploy story. Rejected — it inverts ownership. vprs consumers mount vprs into their server, never the reverse (see the router design boundary: escape hatches, not core).
- Stay as-is: every consumer copies the demo's
start.tsxand drifts. Rejected by the evidence above.
The API
Two entry shapes, one contract — because "read an arbitrary directory at
runtime" and "run on workerd" are incompatible. A fetch runtime needs its
imports statically discoverable at bundle time; it cannot readFile a
manifest or dynamically import ../server-edge/render.js from a buildDir
it has no filesystem for. So the filesystem-walking form is a Node-only
convenience, and the portable form is a generated host entry the build
emits with every import baked in:
// Node (convenience form): inspects buildDir at startup.
import { createHost, toNodeListener } from "vite-plugin-react-server/host";
http.createServer(toNodeListener(createHost({ buildDir: "./dist" }))).listen(3000);
// Any fetch runtime (portable form): the build emits dist/server-edge/host.js —
// a generated module that statically imports its render/consumer bundles and
// inlines its manifest, then calls the same core with resolved artifacts.
// The default export IS the module-worker mount — a workerd deploy points
// its main at host.js with no wrapper file at all. Runtimes that want the
// bare function import it by name:
import hostModule, { handler } from "./dist/server-edge/host.js";
export default hostModule; // workerd / Bun / Deno ({ fetch: handler })
export default handler; // Vercel function
Both forms delegate to one runtime-agnostic core,
createHostFromManifest({ manifest, loadRender, serveStatic }) — the Node
form derives those from buildDir; the generated entry bakes them. The
handler owns routing between the artifacts: static files for prerendered
URLs, per-request render for dynamic ones, the action gate for
x-rsc-action POSTs, the prerendered 404 for everything else. It owns no
listener, no port, no process — the consumer mounts it, wraps it, or
composes middleware around it. toNodeListener remains the Node adapter it
already is.
The host manifest
Derivation needs a stable contract, not directory spelunking. The build emits one additional artifact:
Each host flavor gets its OWN manifest next to its own artifacts —
dist/server/host-manifest.json for the Node/worker serving path,
dist/server-edge/host-manifest.json baked into the generated edge entry —
because one build can legitimately emit BOTH (the runner spec keeps
build.edge as an artifact knob on the main/isolated runners). A single
shared manifest would leave such a build with two valid deployment targets
and no way to say which one a given host serves; per-target manifests make
each entry self-describing.
The shared core (routing + policy) is identical in both; only the render source and how relative paths resolve differ. The edge shape, whose paths resolve against its own directory:
dist/server-edge/host-manifest.json
{
"version": 1,
"target": "edge",
"base": "/",
"routes": [
{ "pattern": "/pokedex/$name", "dynamic": true },
{ "pattern": "/pokedex/gen/$gen", "dynamic": true },
{ "pattern": "/", "dynamic": false }
],
"prerendered": ["/", "/pokedex", "/pokedex/pikachu", "…"],
"assets": ["index-abc123.js", "assets/css/pokemon-xyz.css", "…"],
"cssByPattern": { "/pokedex/$name": ["assets/css/pokemon-xyz.css"] },
"inlineThreshold": 10000,
"bootstrapModules": ["/index-abc123.js"],
"notFoundPage": "404/index.html",
"errorPage": "500/index.html",
"etags": { "index.html": "W/\"a1b2c3\"", "…": "…" },
"precompressed": ["br", "gzip"],
"transport": "webpack",
"renderBundle": "./render.js",
"consumerBundle": "./consumer.js"
}
The Node/worker shape (dist/server/host-manifest.json) carries the same
routing and policy fields with "target": "node", and instead of the baked
pair it names ITS render source: the server bundle's request entry (and the
worker files when the build is worker-based). assets is the exact-match
inventory step 2 of the request algorithm serves from — every emitted static
file that is not a prerendered document — so asset serving derives from the
manifest like everything else, not from an existsSync per request.
Everything in it is information the build holds at emit time; nothing is computed at runtime that the build already computed. The file is versioned so the host helper and the plugin can evolve independently — a host reading a manifest it doesn't understand fails loudly at startup, not per-request.
cssByPattern retires the per-route collectManifestCss dance: the build
walks each route's page module, its props loader and its route.tsx layout
chain once (the same roots the SSG pass seeds its css walk with, so a
stylesheet only props.ts or a layout imports is recorded too) and records
the resolved CSS files; the host applies the same
inline-vs-link threshold the static build used, so a per-request document and
its prerendered sibling agree by construction. The webpack freeze renders
through the same resolver, handing each route's files to the baked producer
as cssFiles for the document and for the navigation flight alike.
transport + consumerBundle make the flavor a followed fact instead of a
wiring exercise. Under a webpack bake the host renders HTML through the baked
consumer (the runtime esm consumer cannot resolve module-map reference rows —
hand-wired servers hit this as a 500 with "Element type is invalid"), and it
stamps the self.__vprsFlightTransport hint on every per-request document it
serves (prerendered documents carry the hint from the freeze; a live document
without it hydrates through the wrong decoder and dies with React #306). Both
were rediscovered by hand in the official demo's server; under createHost
neither is a consumer decision.
Request algorithm
For GET/HEAD on pathname:
- Normalize: base strip,
.rscsuffix detection, trailing-slash canonicalization (/pokedex→ 308 →/pokedex/, matching what the prerender emitted — one URL per page, not two). - Exact asset lookup, before any routing.
pathnamematches a file in the manifest'sassetsinventory (hashed chunks, CSS, images, fonts — every emitted static file that is not a prerendered document) → serve it with the asset cache profile. The classification is the manifest's, not the adapter's: once a path is a known static artifact, route matching never sees it — a catch-all pattern must not swallow/assets/app-abc123.jsand render a 404 document for it. Serving goes through theserveStaticseam (below); an adapter MISS on a known artifact is answered with a plain 404 naming the asset, never by falling through to a render. Understatics: "platform"the platform normally answers these before the handler runs; the same rule covers the ones that still reach it. pathnameinprerendered→ serve the static document (samestatics: "platform"delegation).- Else match
routes; adynamicmatch → per-request render through the render bundle (full document, or headless flight for.rsc/Accept), with the pattern's CSS, under the request's abort signal and a render deadline. - Outcomes are kept distinct — this is a server, not a router with one
apology page:
- loader
notFound()/ no match → the prerendered 404 page, status 404; - loader
redirect()→ the 3xx, passed through; - render failure (thrown error, upstream fetch dead, deadline hit) →
status 500 with the error page, after
onError— never disguised as a 404, never a clean 200. There is deliberately no stale-fallback knob: step 3 serves every prerendered URL before a render is attempted, so by the time a render fails there is no prerendered copy of that URL to fall back to. A serve-prerendered-but-revalidate-per-request mode (which WOULD create that fallback window) is real scope, but it needs its own revalidation semantics — out of scope for v1.
- loader
POST with the action header → the baked sealed gate (its own 404/500
separation follows the same rule). Everything else → 405 with Allow.
Prod-grade HTTP
The reason to bless one host is that nobody hand-writes this layer — no
consumer start.tsx ever grew conditional requests. All of it derives from
the manifest at startup; none of it is computed per-request from disk:
- Conditional requests: every static file gets an
ETagfrom the build's content hashes (recorded in the host manifest — no runtime hashing);If-None-Matchanswers 304. Dynamic renders are uncacheable and say so. - Cache profiles: content-hashed assets →
immutable, max-age=1y; prerenderedindex.html/index.rsc→no-cache(revalidate, serve 304); dynamic documents and flight →no-store. The.rsc/document split on one URL setsVary: Accept, so a shared cache never serves flight bytes to a browser. - Compression: the build emits precompressed
.br/.gzsiblings for compressible statics (build knob, default on); the host negotiates viaAccept-Encodingand never compresses at request time. Dynamic streams pass through uncompressed by default (streaming first; platform compression where available). - Correct metadata everywhere:
text/html; charset=utf-8and friends from one MIME table (the charset half of a real hydration bug this month),Content-Lengthwhere known, honestHEAD,X-Content-Type-Options: nosniff. Anything more opinionated (CSP, HSTS) is app policy — wrap the handler. - Timeouts and cancellation: the request's
AbortSignalreaches the loader (already the loader contract); arenderDeadlineMs(default 30s) turns a hung upstream into a 500, not a hung connection. - Error page: the manifest names it. If the app defines a
/500route it is prerendered and used; otherwise the build emits a minimal styled fallback. Same rule as the 404: a miss or failure never produces a bare-text body.
One host, three runners
The runner spec's paradigm matrix has a "prod shape" row; createHost is
that row implemented once, flavored by what the runner baked:
main | isolated | edge | |
|---|---|---|---|
| render source | in-process render under the process flag | rsc-worker bridge | baked pair (render.js) |
| host runtime needs | Node + --conditions react-server | Node, no flag | any fetch runtime, no node:* |
| static serving | host serves dist/static | host serves | statics: "platform" default — the CDN/platform serves, handler answers only dynamic + actions |
The consumer never states which flavor: the host manifest records what the
build emitted, and createHost follows it. A mismatch (edge bundle absent,
worker files missing) is a startup error naming the runner that would fix it.
Knobs
createHost({
buildDir: "./dist",
statics?: "serve" | "platform", // default per runner (table above)
onNotFound?: (url, request) => Response | null, // null → prerendered 404
onError?: (error, url) => void, // default: console.error (never swallow)
renderDeadlineMs?: number, // default 30_000 → 500, not a hung socket
cache?: (url) => HeadersInit, // override the cache-profile defaults
rewrite?: (url) => string | null, // escape hatch before matching
})
The knobs are not Node-only. The generated edge entry exports the SAME option surface as a factory, with the zero-config default handler built from it:
// dist/server-edge/host.js (generated) — shape, not implementation:
export function createHost(options?: HostOptions): (request: Request, ...platform) => Promise<Response>;
export default createHost(); // the zero-config handler
HostOptions here is every knob above except buildDir (the entry's
artifacts are baked; there is no directory) and statics: "serve" (a fetch
runtime has no filesystem to serve from), plus the seam that makes
statics: "platform" a contract instead of hand-waving:
serveStatic?: (request: Request, ...platform: unknown[]) => Promise<Response | null>,
// null → the ADAPTER couldn't serve it. What happens next is the
// manifest's call, not the adapter's: a path classified as a known static
// artifact (assets / prerendered) answers 404 naming the file; only paths
// the manifest doesn't know continue to route matching. An adapter miss
// can therefore never leak an asset URL into a catch-all render.
This is the same serveStatic the shared createHostFromManifest core
already takes — the Node form derives it from buildDir, and a platform
wrapper passes the platform's own asset layer. The handler is created ONCE
at module scope; the platform's per-request arguments (workerd's env,
ctx) arrive as the handler's trailing arguments and are forwarded to the
seam:
// workerd, assets binding — created once, not per fetch:
const handler = createHost({
serveStatic: async (req, env) => {
const res = await (env as { ASSETS: { fetch: typeof fetch } }).ASSETS.fetch(req);
return res.status === 404 ? null : res;
},
});
export default { fetch: handler };
Small on purpose. Anything beyond this is composition: wrap the returned
handler. Ejecting stays possible — createRequestHandler, createEdgeHandler,
collectManifestCss, toNodeListener remain exported, and createHost is
specified as expressible in terms of them (it is the blessed composition, not
a private pipeline).
Worked example
The official demo's production server, today (abridged from ~180 lines):
const pokemonCss = toCssMap(collectManifestCss(staticManifest, "src/css/pokemon.module.css"));
const clientEntry = staticManifest["index.html"]?.file;
// … 40 lines of manifest/css/bootstrap assembly …
render: async (pathname, request) => {
const route = pathname.replace(/\/index\.rsc$|\.rsc$|\/$/, "");
if (!/^\/pokedex\/[^/]+$/.test(route)) return null;
if (fs.existsSync(path.join(staticDir, route.slice(1), "index.html"))) return null;
// … flight-vs-document branch, notFound dressing, error handling …
}
After:
import http from "node:http";
import { createHost, toNodeListener } from "vite-plugin-react-server/host";
http.createServer(toNodeListener(createHost({ buildDir: "../.." }))).listen(3000);
The deleted lines are the point: nothing in them was a decision. The decisions that DO exist (custom 404 behavior, cache policy, rewrites) are the knobs that remain.
Non-goals
- Owning a listener, a port, a process manager, or a deploy target. The handler is mountable; mounting is the consumer's.
- Runtime configuration. The host manifest is a build artifact; changing
behavior means rebuilding or wrapping the handler. Nothing in
dist/readsvite.configat runtime. - Replacing the runner spec. The runner decides what gets baked; the host follows the bake. They compose; neither implies the other.
- Dev serving. The dev server already owns that surface; this is prod only.
Resolutions (proposed, for review)
- Explicit
host-manifest.jsonover directory inference — one per host flavor. Inference (existsSync-per-request, glob-the-static-dir) works until base paths, platform statics, or.html-suffixed prerenders make it lie. An emitted file, versioned, is the contract; the build is the only writer — and it writes one PER emitted host target, so a build carrying both a Node serving path and abuild.edgeartifact has two self-describing entries instead of one ambiguous manifest with two valid deployments. statics: "platform"is the edge default. Serving static files from an edge function is paying compute to imitate a CDN; the platform serves statics and the handler answers only what needs compute."serve"stays the Node default sonpm run demoworks with zero platform assumptions.- 404 and 500 are different failures with different pages. The
prerendered 404 answers misses (unknown route, loader
notFound()); a render failure answers 500 with the error page afteronError. Bare-text bodies from inner handlers are a host-layer bug. Collapsing failures into one apology page is what "just a 404" criticism rightly calls out — a prod-ready handler keeps the distinction. - The host owns platform context. Fetch runtimes hand the handler more
than a
Request— workerd callsfetch(request, env, ctx), and anything binding-backed (a D1 database, KV, secrets) only exists on thatenv. The baked bundles stay platform-blind, so today an action that needs a binding has no seam to receive it.createHostis that seam: the returned handler accepts the platform's extra arguments and threads them asctx.platforminto action execution and loader context, the same wayctx.requestalready travels. Node hosts simply have an emptyplatform. The alternative — every consumer inventing aglobalThisstash — is a convention where this can be a contract.