アーキテクチャ
05. ルートからHTMLとFlightへ
アプリケーションから HTML と React Server Components のデータを生成する描画処理を読み解きます。
ドキュメントのリクエストとクライアント遷移は、一つのPage描画経路を共有します。 どちらもReactのFlightストリームから始まります。遷移では直接読み取り、SSRではHTMLにデコードしてhydration用のコピーを埋め込みます。
一つの描画から二つのレスポンス形式を返す
ルーティングと リクエストのサービス取得 の後、packages/core/src/server/application.ts の render が FlightRenderer を呼びます。 Acceptが text/x-component と完全に一致する場合だけFlightを返します。 それ以外は、このメディアタイプを含むAcceptの候補リストも含めてHTMLを選びます。
HtmlRenderer は import.meta.viteRsc.loadModule("ssr", "index") でSSR環境を読み込みます。server/ssr.tsx では、tee() がFlightを二つに分けます。
- SSR:
@vitejs/plugin-rsc/ssrがペイロードをデコードし、react-dom/server.edgeがそのRouteTreeをHTMLへ描画します。 - ブラウザー:hydration用のFlightバイト列をHTMLへ埋め込みます。
SSRはRSCの結果を読み取り、Pageのサーバー描画Effectを再実行しません。 フォーム状態、リクエストのScopeに属するabort signal、クライアントエントリをimportするbootstrap scriptも受け取ります。 どちらの形式も Cache-Control: private, no-store を使います。 GETとPOSTのpre-response handlerは既存のVaryフィールドを保持し、Acceptまたは * がなければAcceptを加えます。
共通のFlightペイロードを組み立てる
rsc/render-route-tree.tsx の renderRouteTree は、一致したPageから描画先のスコープを内側から外側へたどり、Loading境界とLayoutを加えます。 生成するのはHTMLではなく、id、content、child を持つ RouteTreeModel です。 LayoutのIDはスコープのidentityを使い、PageとLoadingのIDにはpathnameも含めます。
非POSTリクエストでは、PageのパラメーターSchemaがツリー構築前に ルートパラメーター をデコードします。 失敗するとFlight描画を始めずに本文なしの404を返します。 POSTは代わりにエンコード済みパラメーターをPageへ渡すため、事前検証はすべてのリクエストには適用されません。
rsc/flight.ts の FlightPayload は、routeTree、formState、serverFnResult を含みます。FlightRenderer はこれを @vitejs/plugin-rsc/rsc/server の renderToReadableStream へ渡し、一時参照があればオプションとして渡します。 ペイロードが運ぶのは描画データとactionの状態であり、サービスContextの自動的なコピーではありません。
非同期の描画をリクエストの中で実行する
ReactはReadableStreamを返した後も、Page、Layout、ComponentのEffectを呼ぶことがあります。FlightRenderer はこの処理に子Scopeを与え、ストリームと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 がPromiseベースのEffect実行関数を供給し、Fiberは renderScope に属します。application/render-runtime.ts の renderRuntime.bind が、その実行関数と有効なmiddlewareをAsyncLocalStorageへ保持します。 定義の run は、この束縛と宣言済みのすべてのmiddlewareスコープを要求します。 どちらかが欠ければ TypeError になり、定義のサービスとスコープの契約 を実行時に検査します。
server/application.ts は両方の応答形式に Stream.ensuring(flight.release) を付け、HTML開始時の失敗でもFlightを解放します。 Flight開始時の失敗も子Scopeを閉じます。 Reactのエラーは、signalがabortされていなければ実行関数を通して記録します。 HTMLの読み込みや開始時の失敗は HtmlRenderError になり、その後の失敗は応答本文を通して伝わります。
HTMLストリームを壊さずにFlightを届ける
HTMLチャンクはタグなどの構文の途中で終わることがあり、任意のチャンク境界へFlightのscriptを挿入するとドキュメントを壊すおそれがあります。server/flight-html-stream.ts は代わりに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 は末尾の </body></html> 候補を保留しながらマークアップを転送します。 EOFではFlightのscriptを書き、その後に閉じタグを書きます。 SSRがFlightを読み続けるためHTMLはストリーミングできますが、ブラウザー側の分岐は挿入までキューにたまります。 埋め込みFlightがHTMLの各チャンクとともに逐次届くわけではありません。
各FlightチャンクはfatalなUTF-8 decoderで独立にデコードします。 不正または不完全なUTF-8はbase64へ切り替え、ブラウザーで Uint8Array を復元します。 inline scriptは </script と <!-- をescapeします。client/initial-flight-stream.ts は self.__FLIGHT_DATA の文字列をバイト列へ戻し、バイト配列はそのまま転送します。 DOMContentLoaded時、またはドキュメントの準備が済んでいれば直ちに閉じ、hydration 用のストリームを供給します。
キャンセルでは先にFlight readerをキャンセルしてlockを解放し、次にHTML readerをキャンセルします。 Flightのtee分岐のキャンセルPromiseはawaitしません。もう一方の分岐を待つことがあり、その分岐もリクエストのabort signalを必要とする可能性があるためです。 読み取りとflushのエラーはstream controllerに伝え、応答本文の所有者 が後始末を完了できるようにします。