Architecture
05. From a route to HTML and Flight
How Effront renders an application into HTML and React Server Component payloads.
Document requests and client navigation share one Page rendering path. Both start with a React Flight stream: navigation consumes it directly, while SSR decodes it into HTML and embeds a copy for hydration.
One render, two response formats
After routing and request-service acquisition, render in packages/core/src/server/application.ts calls FlightRenderer. It returns Flight only when Accept is exactly text/x-component. Every other value selects HTML, including Accept lists containing that media type.
HtmlRenderer loads the SSR environment through import.meta.viteRsc.loadModule("ssr", "index"). In server/ssr.tsx, tee() splits Flight into two branches:
- SSR:
@vitejs/plugin-rsc/ssrdecodes the payload, thenreact-dom/server.edgerenders itsRouteTreeto HTML. - Browser: Flight bytes are embedded in the HTML for hydration.
SSR consumes the RSC result rather than running the Page's server render Effect again. It also receives form state, a request-scoped abort signal, and a bootstrap script that imports the client entry. Both formats use Cache-Control: private, no-store. GET and POST pre-response handlers preserve existing Vary fields and add Accept unless Accept or * is already present.
Build the shared Flight payload
renderRouteTree in rsc/render-route-tree.tsx starts with the matched Page and walks destination scopes from inner to outer, adding Loading boundaries and Layouts. It produces a RouteTreeModel with id, content, and child, not HTML. Layout IDs use scope identity. Page and Loading IDs also include the pathname.
For non-POST requests, a Page parameter Schema decodes route parameters before tree construction. Failure returns an empty 404 without starting Flight rendering. POST passes encoded parameters to the Page instead, so prevalidation is not universal.
FlightPayload in rsc/flight.ts contains routeTree, formState, and serverFnResult. FlightRenderer passes it to renderToReadableStream from @vitejs/plugin-rsc/rsc/server, with temporary references as an option when supplied. The payload carries rendered data and action state, not an automatic copy of the service Context.
Keep asynchronous rendering inside the request
React may continue invoking Page, Layout, or Component Effects after returning a readable stream. FlightRenderer gives that work a child Scope and returns its release operation alongside the stream and abort signal:
packages/core/src/server/flight-renderer.tsx const errorDigest = yield* nextErrorDigest;
const parentScope = yield* Effect.scope;
const renderScope = yield* Scope.fork(parentScope);
const release = Scope.close(renderScope, Exit.void);
return yield* Effect.gen(function* () {
const runtime = yield* FiberSet.makeRuntimePromise<Services>().pipe(
Scope.provide(renderScope),
);
const signal = yield* Effect.abortSignal.pipe(Scope.provide(renderScope));
const { renderToReadableStream } = yield* Effect.promise(
() => import("@vitejs/plugin-rsc/rsc/server"),
);
const stream = renderRuntime.bind(runtime, middleware, () => {
const payload = { formState, routeTree, serverFnResult } satisfies FlightPayload;
return renderToReadableStream(payload, {
onError: (error: unknown) => {
if (!signal.aborted) {
void runtime(
Effect.logError(error).pipe(Effect.annotateLogs("errorDigest", errorDigest)),
);
}
return errorDigest;
},
signal,
temporaryReferences,
});
});
return { release, signal, stream } satisfies FlightRender;
}).pipe(Effect.onError(() => release));FiberSet.makeRuntimePromise supplies the Promise-based Effect runner, with fibers owned by renderScope. renderRuntime.bind in application/render-runtime.ts stores that runner and active middleware in AsyncLocalStorage. Each definition's run call requires both the binding and every middleware scope it declared. Missing either throws TypeError, enforcing the definition's service and scope contract at runtime.
server/application.ts attaches Stream.ensuring(flight.release) to both response formats and releases Flight if HTML startup fails. Flight startup failure also closes the child Scope. React errors are logged through the runner unless its signal is aborted. HTML loading or startup failures become HtmlRenderError, while later failures propagate through the response body.
Deliver Flight without breaking the HTML stream
HTML chunks can end inside tags or other syntax, so inserting Flight scripts at arbitrary chunk boundaries could corrupt the document. server/flight-html-stream.ts instead waits for HTML EOF:
packages/core/src/server/flight-html-stream.ts const transform = new TransformStream<Uint8Array, Uint8Array>({
async flush(controller) {
try {
htmlWriter.finish(controller);
// HTML chunks are arbitrary bytes, not parser boundaries. Only HTML EOF is
// safe for injection without a tokenizer. The SSR tee branch keeps pulling
// Flight while its browser branch queues, so HTML still streams normally.
await writeFlightStream(flightReader, controller, options?.nonce);
controller.enqueue(htmlTrailer);
} catch (cause) {
controller.error(cause);
} finally {
releaseFlight();
}
},
transform(chunk, controller) {
htmlWriter.write(chunk, controller);
},
});makeHtmlWriter forwards markup while retaining a possible final </body></html> trailer. At EOF it writes Flight scripts, then the closing tags. HTML still streams because SSR keeps consuming Flight, but the browser branch queues until insertion. Embedded Flight is not delivered incrementally beside each HTML chunk.
Each Flight chunk is independently decoded with a fatal UTF-8 decoder. Invalid or incomplete UTF-8 falls back to base64 and reconstructs a Uint8Array in the browser. Inline scripts escape </script and <!-- sequences. client/initial-flight-stream.ts converts strings in self.__FLIGHT_DATA back to bytes and forwards byte arrays unchanged. It closes at DOMContentLoaded, or immediately if the document is ready, supplying the stream for hydration.
Cancellation first cancels the Flight reader and releases its lock, then cancels the HTML reader. It does not await the Flight tee branch's cancellation Promise, which may wait for a sibling that itself needs the request's abort signal. Read and flush errors reach the stream controller so the response-body owner can finish cleanup.