アーキテクチャ

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に伝え、応答本文の所有者 が後始末を完了できるようにします。