アーキテクチャのトレードオフ
このドキュメントでは、主要なアーキテクチャ上の決定事項、それらを形作った制約、および受け入れられたトレードオフについて説明します。ランタイムモデルとReact Server Componentsに精通していることを前提としています。
@lazarv/react-server は、レイヤー構造のランタイムとして設計されています:
実行基盤
↓ 実行環境とレンダリングパイプラインを定義
実行拡張
↓ 追加の実行コンテキストを導入
アプリケーションモデル
↓ アプリケーションの構造化方法を形成
運用モデル
↓ デプロイと本番環境の動作を管理
各上位レイヤーは、下位レイヤーの保証に依存しています。
これらの決定は、ランタイムの最下層を定義します:使用するReactのバージョン、サポートするJavaScriptランタイム、モジュールの解決方法、使用するビルドツール、そしてコアとなるレンダリングおよびハイドレーションパイプラインの動作です。上位のすべてはこれらの選択に依存しています。
問題:
React Server Componentsは、react-server-dom-webpackとReactリコンサイラー間の不安定なワイヤーフォーマットに依存しています。シリアライゼーションプロトコル、フライトフォーマット、およびディレクティブのセマンティクス("use client"、"use server")は、Reactのパブリックsemverコントラクトではまだカバーされていません。ランタイムが使用するReactバージョンとアプリケーションがインストールするバージョンの不一致は、サイレントなシリアライゼーション失敗やランタイムクラッシュを引き起こします。
制約: RSCワイヤーフォーマットは、サーバーレンダラー、クライアントハイドレーター、およびフライトエンコーダー/デコーダー間でバージョンロックされている必要があります。ユーザーがインストールしたReactを許可すると、Reactが頻繁に内部的な破壊的変更を含む実験的ビルドを公開するため、信頼性のあるテストができない組み合わせの互換性表面が生まれます。
決定:
特定のReact実験的ビルドを直接の依存関係としてバンドルします。起動時に、react、react-dom、react/jsx-runtime、およびreact-server-dom-webpackのすべてのインポートがランタイムにバンドルされたコピーに解決されるようにモジュール解決をハイジャックします。Node.jsではカスタムローダーを使用したmodule.register()を、Bunではmodule.mock()/module.alias()を、Denoではインポートマップをディスクに書き込み--import-mapでプロセスを再起動します。
トレードオフ:
- ユーザーはReactを独立してアップグレードできません。新しいReact機能の採用はランタイムのリリースに依存します。
- モジュールエイリアスの仕組みはランタイム固有であり脆弱です — 各JavaScriptランタイム(Node、Bun、Deno)は異なるインターセプト戦略を必要とします。いずれかのパスのバグがRSCパイプライン全体をサイレントに破壊します。
- Denoパスはファイルシステムの書き込みとプロセスの再起動を必要とし、起動レイテンシーが追加され、読み取り専用ファイルシステム環境での使用ができません。
結果: ワイヤーフォーマットはすべてのレンダリングパスで一貫性が保証されます。ユーザーはReactをインストールする必要がなく、依存関係の競合やバージョンのずれが軽減されます。ただし、ランタイムのリリースサイクルがReactのアップグレードのボトルネックとなり、3方向のエイリアスレイヤーは各ランタイムのリリースサイクルに対して検証が必要なメンテナンス対象となります。
問題: JavaScriptサーバーエコシステムは、Node.js、Bun、Denoに分断されています。各ランタイムは異なるモジュール解決セマンティクス、ワーカースレッド、ファイルシステムアクセス、プロセス管理のAPIを持っています。Node.jsのみをターゲットにすると、増加するデプロイメントの一部が除外されます。
制約:
ランタイム抽象化は、モジュールエイリアス(固定されたReactバージョンを参照)、プロセスレベルのAPI(cwd、argv、exit、環境変数)、および並行処理プリミティブ(setImmediate対setTimeout、Buffer対Uint8Array)をカバーする必要があります。エッジ環境(Cloudflare Workers、Netlify Edge、Deno Deploy)はnode:ビルトインモジュールが一切利用できません。
決定:
すべてのランタイム依存APIを、グローバル検出(typeof Deno、typeof Bun、typeof EdgeRuntime)に基づく条件付き実装を持つシステムレイヤー(sys.mjs)の背後に抽象化します。各ランタイムは独自のモジュールエイリアス戦略を持ちます。エッジ環境は別途検出され、ファイルシステム、ワーカースレッド、クラスターモードのない制限されたサブセットとして扱われます。
トレードオフ:
- すべてのランタイム依存コードパスは、3つのランタイムと複数のエッジターゲットでテストする必要があります。テストマトリクスはプラットフォーム機能に比例して乗算的にスケールします。
- 抽象化レイヤーは間接参照を導入します。モジュール解決の失敗をデバッグするには、現在のランタイムでどのエイリアスパスが取られたかを理解する必要があります。
- エッジランタイムの検出はグローバルスニッフィング(
navigator.userAgent === "Cloudflare-Workers"、typeof Lagonなど)に依存しており、脆弱で新しいエッジプラットフォームの出現に合わせて更新する必要があります。 - BunとDenoの互換性レイヤーは、安定性においてNode.jsに遅れをとっています。Nodeで動作する機能が他のランタイムでサイレントに失敗する可能性があります。
結果: 同じアプリケーションコードがNode.js、Bun、Denoでコード変更なしに実行されます。エッジデプロイメントは制限された実行モードを使用します。ただし、マルチランタイムの対応範囲は、ランタイム固有のリグレッションが発生しやすく、環境間での再現が困難であることを意味します。
問題: Node.jsエコシステムは歴史的にCommonJSに依存してきました。CJSモジュールは同期的であり、ツリーシェイキングのための静的解析ができず、ブラウザネイティブのモジュール読み込みとの摩擦を生みます。ツールにおけるCJS/ESMの二重サポートは複雑さとエッジケースを追加します。
制約:
ViteはESMでネイティブに動作します。React Server Componentsの"use client"および"use server"ディレクティブのモジュール解決は、静的インポート解析に依存しています。CJSのサポートは両方の目標を損なう互換性レイヤーを必要とします。
決定: スタック全体(サーバー、クライアント、ビルドツール)でESMのみをターゲットにします。
トレードオフ:
- CJS専用パッケージはトランスパイルを必要とするか、直接使用できません。これにより、即座に互換性のあるnpmパッケージのセットが縮小されます。
- 一部のNode.js APIや古いライブラリはCJSセマンティクスを前提としています(例:
__dirname、require.resolve)。ポリフィルまたは代替手段が必要です。 - CJSのみを公開するライブラリ作者は、Viteレベルでシムする必要があります。
結果:
静的解析が信頼でき、正確なツリーシェイキングとディレクティブ検出が可能になります。モジュールグラフは開発と本番で一貫しています。ブラウザネイティブのimport()がモジュールフォーマット変換レイヤーなしで動作します。
問題: カスタムバンドラーと開発サーバーをゼロから構築するのは、膨大なエンジニアリング作業です。Webpackベースのソリューションは設定のオーバーヘッドが大きく、リビルド時間が遅くなります。ツールレイヤーは、HMRサポートを備えたサーバーとクライアントの両方のモジュールグラフを処理する必要があります。
制約: 選択するツールは、ESMをネイティブにサポートし、RSCモジュール解決を実装するのに十分な拡張性を持つプラグインAPIを提供し、SSRをそのままサポートする必要があります。また、活発なエコシステムとコミュニティを持つ必要があります。
決定: Viteをビルドおよび開発の基盤として使用します。ViteのRollupベースの本番ビルドとesbuild/Rolldown搭載の開発サーバーが、必要なESMファーストアーキテクチャを提供します。
トレードオフ:
- ランタイムはViteの内部API、プラグインライフサイクル、モジュール解決の動作に結合されています。Viteの破壊的変更には協調的な更新が必要です。
- ViteのSSRサポートは機能的ですが、もともとよりシンプルなユースケース向けに設計されていました。RSCストリーミングとフライトフォーマットのシリアライゼーションには、ViteのSSRパイプライン上での非自明な統合作業が必要でした。
- Viteの開発サーバーはモジュールをオンザフライで変換しますが、エッジケースではRollup本番バンドルと異なる動作をする可能性があります。
結果: HMRレイテンシーはフルリバンドルではなくViteの変換パイプラインによって制限されますが、実際の速度はモジュールグラフのサイズとプラグインの数に依存します。Viteプラグインインターフェースが直接使用されます — ViteやRollupプラグインをビルドに組み込むためのアダプターやラッパーは不要です。これによりランタイムのリリースサイクルがViteに結合され、ViteのSSRレイヤーのリグレッションが継承されます。ViteのWhy Viteドキュメントも参照してください。
問題: 従来のSSRアプローチは、完全にレンダリングされたHTMLを送信し、ハイドレーション中にクライアント上でコンポーネントツリー全体を再実行します。これは作業の重複を生み、不要なJavaScriptを配信し、UIのどの部分が静的でどの部分がインタラクティブかについて開発者のコントロールが限定的です。
制約: サーバーとクライアント間のレンダリング境界は、コンポーネントレベルで明示的である必要があります。ReactのServer Componentsプロトコルは、サーバーコンポーネントツリーのシリアライゼーションとクライアントコンポーネントコードの選択的配信をランタイムがオーケストレーションすることを要求します。
決定:
React Server Componentsをプライマリレンダリングモデルとして採用します。コンポーネントはデフォルトでサーバーレンダリングされます。クライアントコンポーネントは"use client"ディレクティブによるオプトインであり、そのコードのみがブラウザに送信されます。
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
);
}
トレードオフ:
- データフェッチパターンは従来のReactとは異なります。サーバーコンポーネントで非同期データアクセスをコロケーションするには、フックベースのフェッチとは異なるメンタルモデルが必要です。
- サーバー/クライアントの分割は感染的です:一度コンポーネントがクライアントコンポーネントとしてマークされると、サーバーコンポーネントから
childrenやpropsとして渡されない限り、すべての子コンポーネントがクライアントで実行されます。 - propsがサーバー-クライアント間のワイヤーを越えられない場合、シリアライゼーション境界のデバッグは非自明になり得ます。
結果: 完全な静的ページではクライアントサイドJavaScriptをゼロにすることが可能です。インタラクティブなアイランドは明示的にスコープされます。ランタイムがシリアライゼーションフォーマットを制御するため、固定されたReactバージョンが必要です(以下を参照)。
問題: 従来のSSRは、クライアントに送信する前に完全なHTMLレスポンスをバッファリングします。遅いデータソースを持つページはレスポンス全体をブロックし、最初のバイトまでの時間と体感レイテンシーが増加します。
制約:
サーバーは利用可能になったHTMLを順次送信する必要があります。サーバー上のSuspense境界がチャンク分割ポイントを定義します — Suspenseフォールバック内のコンテンツは、非同期処理が完了するとストリーミングされます。ブラウザは受信したチャンクを段階的にレンダリングできる必要があります。
決定:
デフォルトでストリーミングSSRを使用します。シェルと最初に解決されたSuspense境界の準備ができ次第、レスポンスのフラッシュが開始されます。後続のチャンクはデータの解決に応じて配信されます。
import { Suspense } from "react";
async function AsyncData() {
const data = await getData();
return <div>{data}</div>;
}
export default function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AsyncData />
</Suspense>
);
}
トレードオフ:
- HTTPステータスコードとヘッダーはストリームの開始前に決定される必要があります。ストリームの途中で発生するエラーやリダイレクトは、遡及的にステータスコードを変更できません。
- プロキシサーバー、CDN、または完全なレスポンスをバッファリングするミドルウェアは、ストリーミングの利点を無効にします。
- ストリーミングレスポンスのデバッグは、単一のHTMLペイロードを検査するよりも困難です。
結果:
最初のバイトまでの時間は、ページの最も遅い部分ではなく、最も速い部分によって決定されます。遅いデータソースは初期描画をブロックしません。Suspense境界はコンポーネントツリーにおける明示的なローディング状態の宣言として機能します。
問題: フルページハイドレーションは、SSR後にクライアントサイドのコンポーネントツリー全体を再実行します。これはまだ表示されていない、またはインタラクティブでないページの部分に対してもCPU時間を浪費し、インタラクティビティを遅延させます。
制約: ハイドレーションはストリーミングモデルと整合する必要があります — チャンクが到着しクライアントコンポーネントがDOMにレンダリングされると、ページ全体を待つことなくインタラクティブになる必要があります。Reactランタイムの選択的ハイドレーションメカニズムがこれを処理しますが、サーバーはコンポーネント境界を正しい順序で出力する必要があります。
決定: Reactの組み込みの選択的ハイドレーションに依存します。クライアントコンポーネントはHTMLとJavaScriptの到着に応じて独立してハイドレートされます。カスタムハイドレーションスケジューラーは追加されません。
トレードオフ:
- ハイドレーションの順序はReactのヒューリスティクス(例:ユーザーインタラクションの優先度)によって制御され、開発者の意図した優先度と一致しない場合があります。
Suspenseとlazy()が提供するもの以外に、ハイドレーション順序を強制したり特定のサブツリーのハイドレーションを遅延させる明示的なAPIはありません。- 選択的ハイドレーションは各クライアントコンポーネントのJavaScriptが利用可能であることに依存しています — 大きなバンドルはインタラクティビティを遅延させます。
結果: コンポーネントは、兄弟や親を待つことなく、コードが到着し次第インタラクティブになります。ストリーミングと組み合わせることで、ページは段階的にレンダリングとハイドレーションの両方が行われます。コンポーネントごとのバンドルサイズがインタラクティブになるまでの時間に直接影響します。
これらの決定は、基本のレンダリングモデルを追加の実行コンテキストに拡張します。それぞれがコンポーネントの実行場所や実行方法を変更する新しいディレクティブまたはプリミティブを導入します — リアルタイムプッシュ、バックグラウンドスレッド、クロスオリジン合成、ハイブリッドレンダリング境界、およびサーバー/クライアントミューテーションインターフェースです。
問題: 従来のRSCレンダリングはリクエスト-レスポンス方式です:サーバーは一度レンダリングして結果を送信します。サーバーコンポーネントが時間の経過とともにクライアントに更新をプッシュする組み込みメカニズムはありません — リアルタイムダッシュボード、フィード、モニタリングUIは、コンポーネントモデル外の別個のWebSocketインフラストラクチャを必要とします。
制約: 更新はReactツリーでなければなりません — クライアントは生データではなく、レンダリング可能なRSCペイロードを受信する必要があります。各コンポーネントインスタンスには、独立したライフサイクル管理を持つ独自のサーバーサイド実行コンテキストが必要です。トランスポートはSPAでのページレベルナビゲーションを生き延びる必要があります。
決定:
"use live"ディレクティブを導入します。このディレクティブでマークされた非同期ジェネレーター関数はライブコンポーネントにコンパイルされます。最初のyieldは通常のSSRレスポンスの一部としてレンダリングされます。
"use live";
export default async function* Clock() {
while (true) {
yield <div>Current time: {new Date().toLocaleTimeString()}</div>;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
初期レンダリング後、後続のyieldはRSCフライトペイロードとしてシリアライズされ、Socket.IO接続を介してクライアントにプッシュされます。各コンポーネントインスタンスは独自のSocket.IOネームスペースとAbortControllerを取得します。
トレードオフ:
- 永続的なサーバープロセスと長寿命の接続が必要です — サーバーレスおよびエッジデプロイメントとは互換性がありません。
- コンポーネントインスタンスごとに1つのSocket.IOネームスペースが、接続されたすべてのクライアントのアクティブなライブコンポーネント数に比例してメモリとファイルディスクリプタを消費します。スティッキーセッションまたはpub/subバックプレーンなしでは水平スケーリングできません。
- 非同期ジェネレーターパターンは、サーバーサイドの状態がジェネレーターのクロージャに存在することを意味します。サーバープロセスが再起動すると、すべてのジェネレーター状態が失われ、クライアントは最初から再接続する必要があります。
- Socket.IOは非自明な依存関係を追加し、独自の接続管理の複雑さ(ハートビート、再接続、多重化)をもたらします。
結果:
リアルタイムUI更新は、命令的なWebSocketメッセージハンドラーではなく、yieldを持つReactコンポーネントとして表現されます。コンポーネントモデルはエンドツーエンドで保持されます。ただし、ライブコンポーネントは長時間実行サーバー環境に制限され、インスタンスごとのリソースコストが同時ライブコンポーネントインスタンスの最大数を制限します。
問題: CPU集約的な操作(画像処理、データ変換、圧縮)はサーバー上でメインイベントループをブロックし、すべての同時ユーザーのリクエストスループットを低下させます。この処理を別のプロセスやサービスに移すと、デプロイメントとオーケストレーションの複雑さが増します。
制約:
オフローディングメカニズムはRSCシリアライゼーションプロトコルと統合する必要があります — 引数と戻り値(Reactエレメントを含む)はフライトフォーマットを介してワーカー境界を越える必要があります。サーバーではnode:worker_threads、ブラウザではWeb Workersを意味します。エッジランタイムにはどちらもありません。
決定:
"use worker"ディレクティブを導入します。Viteプラグインがモジュールをプロキシに書き換えます:Node.js/Bunでは、各呼び出しがRSCフライトプロトコルでシリアライズされた引数とpostMessageで転送されるWorkerスレッドを生成または再利用します。
"use worker";
export async function computeFactorial(n) {
if (n <= 1) return 1;
return n * computeFactorial(n - 1);
}
ブラウザでは、同じディレクティブがWeb Workerインポートを生成します。エッジランタイムでは、プロキシは同期的なインプロセス呼び出しにフォールバックします — 実際の並列性はありません。
トレードオフ:
- エッジデプロイメントは並行処理の利点が得られません —
"use worker"はノーオップラッパーになります。これはサイレントであり、オフローディングを期待する開発者を誤解させる可能性があります。 - RSCシリアライゼーションオーバーヘッドは、引数と戻り値を含むワーカー境界を越えるすべての関数呼び出しに適用されます。小さなペイロードでは、このオーバーヘッドが計算の節約を上回る可能性があります。
- ワーカーは
AsyncLocalStorageではなくプロセスグローバルのMapにキャッシュされるため、ワーカーのライフサイクルはリクエストスコープではありません。リークまたはクラッシュしたワーカーは後続のリクエストに影響します。 - 各
"use worker"モジュールは個別のビルドチャンクとして出力され、ビルド出力が増加しチャンク分析が複雑になります。
結果: 重い計算はNode.jsおよびBunでメインイベントループから離れて実行されます。ReactエレメントをワーカーからDirectに返すことができます。ただし、利点は環境に依存し、シリアライゼーションコストは呼び出しごとに発生し、ワーカーキャッシュはグローバルなミュータブルリソースです。
問題: 大規模な組織は、フロントエンドアプリケーションを複数のチームとリポジトリにまたがってデプロイします。独立してデプロイされたUIをコンポーネントレベルで合成すること — iframeやクライアントサイドモジュールフェデレーションを介したページレベルではなく — には、共有レンダリングプロトコルと依存関係の重複排除が必要です。
制約: リモートコンポーネントは、ホストアプリケーションが消費可能なRSCフライトペイロードを生成する必要があります。リモートアプリケーションからのクライアントサイドJavaScriptは、ブラウザで共有依存関係(React、react-dom)を重複させてはいけません。合成は、特別なクライアントサイドグルーコードなしで、ストリーミングおよびハイドレーションパイプラインと連携する必要があります。
決定:
レンダリング時にリモートのreact-serverインスタンスからRSCフライトデータをフェッチする<RemoteComponent src="..." />プリミティブを提供します。
import RemoteComponent from "@lazarv/react-server/remote";
export default function Home() {
return (
<div>
<h1>Host application</h1>
<RemoteComponent src="http://localhost:3001" />
</div>
);
}
ホストはリモートのブラウザマニフェストをフェッチし、自身のマニフェストとクロスリファレンスし、共有依存関係のリモートのクライアントモジュールURLをホストのコピーにリマップするインライン<script type="importmap">を出力します。各リモートサブツリーは、独立したハイドレーションとリフレッシュのための独自のアウトレットIDを取得します。
トレードオフ:
- ホストとリモートの両方が
@lazarv/react-serverインスタンスである必要があります。合成はランタイム固有のエンドポイント(.remote.x-component)とブラウザマニフェストJSON構造に依存しています。これはオープンなフェデレーションプロトコルではありません。 - インポートマップのマージはレンダリング時に行われ、リモートコンポーネントの数に比例して初期レスポンスにレイテンシーが追加されます。
- 共有依存関係の解決は、ホストとリモートのマニフェスト間のバージョン互換性に依存します。ホストとリモート間のReactバージョンの不一致はハイドレーション失敗を引き起こします。
isolateプロパティ(Shadow DOMカプセル化)はスタイルのリークを防ぎますが、共有CSSデザインシステムがリモートサブツリーに適用されることも防ぎます。
結果: 独立してデプロイされたReactアプリケーションが、共有依存関係の重複排除を伴いコンポーネントレベルで合成されます。ただし、システムは非react-serverリモートには閉じており、ホストとリモート間のバージョン結合は暗黙的であり、インポートマップ生成はレンダリングごとにオーバーヘッドを追加します。
問題: 完全な静的生成は高速な初期ロードを生成しますが、パーソナライズされたコンテンツや時間的に敏感なコンテンツを提供できません。完全なサーバーレンダリングは動的コンテンツを処理しますが、最初のバイトまでの時間が長くなります。ページレベルで2つのどちらかを選ぶのは粒度が粗すぎます — 多くのページには静的なシェルと小さな動的領域があります。
制約: コンポーネントツリーは、開発者がページを別々のエンドポイントに再構築することなく、コンポーネントレベルで静的部分と動的部分に分割可能でなければなりません。静的シェルはビルド時にプリレンダリング可能でCDNから配信可能でなければなりません。動的部分はリクエスト時に同じレンダリングパイプラインを使用してストリーミングされる必要があります。
決定:
"use dynamic"と"use static"ディレクティブを導入します。"use dynamic"はReactのストリーミングレンダラーが境界として解釈するpostponeシグナルを注入します — 静的シェルがその周りにレンダリングされ、動的コンテンツがリクエスト時にストリーミングされます。
import { Suspense } from "react";
async function UserGreeting() {
"use dynamic";
const user = await getUser();
return <p>Welcome, {user.name}</p>;
}
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading...</p>}>
<UserGreeting />
</Suspense>
</div>
);
}
"use static"は、プリレンダリング対応のメモリキャッシュドライバーを持つ"use cache: static"のエイリアスです。ビルド時に、ランタイムはpostpone境界を含むページのプリレンダリングされたHTMLと共に.postponed.jsonファイルを出力します。アダプターはこれらのファイルを使用して、動的部分のストリーミングレンダラーにリクエストをルーティングします。
トレードオフ:
"use dynamic"はRSC環境のサーバーコンポーネント内でのみ動作します。クライアントコンポーネントで使用するとサイレントに無視されます。- postponeメカニズムはReactの内部的な
Postponeエラータイプに基づくランタイムの規約であり、安定したReact APIではありません。SuspenseやストリーミングセマンティクスへのReactの将来の変更によって破壊される可能性があります。 - アダプターはPPR対応である必要があります:
.postponed.jsonコンパニオンを持つ静的ファイルは静的ファイルサーバーから除外され、代わりにストリーミングランタイムで処理される必要があります。これによりアダプターの複雑さが増し、PPRをサポートできるホスティングプラットフォームが制限されます。 "use static"はキャッシュレイヤーを経由するため、その動作は純粋なビルド時プリレンダリングではなく、キャッシュドライバーの設定とTTL設定に依存します。
結果: ページはCDNから静的シェルを即座に配信し、動的領域はリクエスト時にストリーミングされます。ただし、この機能はアダプターのサポートに依存し、不安定なReact内部を使用し、2つのディレクティブは対称的なネーミングにもかかわらず非対称な実装パス(postpone対キャッシュ)を持っています。
問題: 従来のWebアプリケーションは、クライアントからのミューテーションを処理するために別のAPIレイヤー(REST、GraphQL、tRPC)が必要です。これにより、ルート登録、シリアライゼーションコントラクト、および「UIコード」と「APIコード」間のコンテキストスイッチが導入されます。
制約:
Reactの"use server"ディレクティブは、関数レベルでシリアライゼーション境界を定義します。ランタイムは、これらのアノテーションされた関数をインターセプトし、HTTPエンドポイントとして公開し、引数/戻り値のシリアライゼーションを透過的に処理する必要があります。このメカニズムは、プログレッシブエンハンスメントのためにクライアントサイドJavaScriptなしでも動作する必要があります。
決定: サーバー関数をプライマリミューテーションインターフェースとしてサポートします。アノテーションされた関数はクライアントコンポーネントおよびプレーンHTMLフォームから呼び出し可能です。別のAPIルートレイヤーは不要です。
export default function App() {
async function addItem(formData) {
"use server";
await db.insert({ name: formData.get("name") });
}
return (
<form action={addItem}>
<input name="name" />
<button type="submit">Add</button>
</form>
);
}
トレードオフ:
- サーバー関数はReactシリアライゼーションプロトコルに密結合されています。汎用的なAPIエンドポイントではありません — 非Reactクライアントから呼び出すのは簡単ではありません。
- TypeScriptの型は境界を越えて流れますが、受信引数のランタイムバリデーションは開発者の責任です。デフォルトのスキーマレイヤーはありません。
<form action={fn}>によるプログレッシブエンハンスメントは、HTMLフォームがネイティブに表現できるものにインタラクションモデルを制限します。
結果: ミューテーションロジックはレンダリングコードとコロケーションされ、別のAPIルートレイヤーが排除されますが、UIとサーバーロジック間の結合が増加します。フォームベースの送信はクライアントサイドJavaScriptなしで動作しますが、より豊かなインタラクションパターンのコストが伴います。TypeScriptの型はシリアライゼーション境界を越え、静的型チェックを提供しますが、ランタイムバリデーションはありません — 開発者が明示的なチェックを追加しない限り、不正なペイロードは拒否されません。
これらの決定は、実行レイヤーの上でアプリケーションを構築・合成する方法を形成します:コアの拡張方法、ルートの定義方法、コンテンツの記述方法、利用可能な出力モード、およびキャッシュとプログレッシブエンハンスメントがレンダリングパイプラインとどのように統合されるかです。
問題: すべての機能(ルーティング、MDX、静的エクスポートなど)をコアにバンドルするモノリシックなランタイムは、シンプルなユースケースには膨大であり、開発者がカスタマイズできる範囲を制限します。
制約: プラグイン境界は推論可能なほど狭くなければなりませんが、ファーストパーティ機能(例:ファイルシステムルーティング)をコアロジックではなくプラグインとして実装できるほど表現力がある必要があります。Viteのプラグインインターフェースとの互換性が必要であり、独自の拡張モデルは避けるべきです。
決定: コアを最小限に保ちます。ファイルシステムルーターを含む高レベル機能をプラグインとして実装します。Viteのプラグインコントラクトに沿った設定とライフサイクルフックを公開します。
トレードオフ:
- プラグインの合成は、デバッグが困難な順序依存の動作を生む可能性があります。
- 薄いコアは、より多くの統合責任がプラグイン作者と設定に委ねられることを意味します。
- ファーストパーティプラグインはコアの変更と歩調を合わせてメンテナンスする必要があり、調整コストが発生します。
結果: ルーティングやMDXを使用しないアプリケーションは、それらの機能のためのコードや設定のコストを支払いません。新しい機能はコアを変更することなくプラグインとして追加できますが、これにより統合の複雑さがプラグイン作者に移ります。プラグインインターフェースがViteネイティブであるため、アダプターレイヤーは不要です — ただし、プラグインの動作はViteのプラグイン順序とライフサイクルセマンティクスに従います。
問題: プログラマティックなルート設定は、アプリケーションの成長とともにメンテナンスの負担になります。ルート定義が実際のファイル構造から乖離し、パスのリファクタリングにはファイルと設定の両方の更新が必要です。
制約: ルーティングはオプショナルでなければなりません — カスタムエントリーポイントを使用するアプリケーションは規約に強制されるべきではありません。ルーティングが使用される場合、RSCレンダリングパイプラインと統合し、レイアウト、ネストされたルート、ルートレベルでの非同期データロードをサポートする必要があります。
決定: ファイルシステムベースのルーティングをオプトインプラグインとして実装します。ディレクトリとファイルの命名規約がルートセグメントにマッピングされます。カスタムルーティングソリューションは、直接エントリーポイント使用により引き続きサポートされます。
pages/
index.tsx → /
about.tsx → /about
(root).layout.tsx → layout wrapper
auth/
login.tsx → /auth/login
posts/
[id].tsx → /posts/:id
トレードオフ:
- ファイルシステムの規約は、プロジェクトの好みと競合する可能性のある命名制約を課します。
- 動的ルートパターン(キャッチオール、オプショナルセグメント)はファイル名規約に依存しており、プログラマティックな定義より明示的ではありません。
- ネストされたレイアウトはディレクトリ構造に基づいて暗黙的であり、ネストが意図したUI階層と一致しない場合、驚きを引き起こす可能性があります。
結果: ルート構造はディレクトリレイアウトによって決定され、別のルート設定ファイルの必要性がなくなりますが、ファイルシステムにルーティングセマンティクスが埋め込まれます。ルートの変更はファイルのリネームまたは移動であり、リファクタリングが簡素化されますが、ファイルシステムがルーティング規約に準拠する必要があります。カスタムエントリーポイントパスはフォールバックとして引き続き利用可能ですが、ファイルシステムルーティングからプログラマティックルーティングへの移行にはプロジェクトの再構築が必要です。
問題: ドキュメントサイトやコンテンツの多いページをJSXコンポーネントとして作成するのは面倒です。より軽量な記述フォーマットが必要ですが、インタラクティブなReactコンポーネントの埋め込みも可能でなければなりません。
制約: コンテンツページはRSCレンダリングパイプラインに参加するためにサーバーコンポーネントである必要があります。Markdown/MDX処理はビルド時またはサーバー上で行われ、クライアントでは行われません。RemarkおよびRehypeプラグインの互換性が期待されます。
決定: ページソースとしてMarkdownおよびMDXをサポートします。MDXファイルはサーバーコンポーネントにコンパイルされ、標準のimport/export構文を介してクライアントコンポーネントを埋め込めます。RemarkおよびRehypeプラグインチェーンは設定可能です。
import Counter from "./Counter"; # My Page Here is an interactive counter embedded in MDX: <Counter />
トレードオフ:
- MDXコンパイルはコンテンツ量に比例してビルド時のオーバーヘッドを追加します。
- MDXのJSX-in-Markdown構文にはエッジケース(インデント感度、式のエスケープ)があり、フォーマットに不慣れな作者を驚かせます。
- MDXコンパイラのバージョンはRSCパイプラインとの互換性を維持するために固定されています。
結果: コンテンツの記述はJSXではなくMarkdown構文を使用し、非コンポーネントコンテンツの障壁を下げますが、混在コンテンツでMDX固有のエッジケースが導入されます。RemarkおよびRehypeプラグインはアダプターなしでビルドパイプラインに組み込み可能ですが、プラグインの互換性は固定されたMDXコンパイラバージョンに依存します。
問題: すべてのページが実行中のサーバーを必要とするわけではありません。マーケティングページ、ドキュメント、その他の静的コンテンツは、ビルド時にHTMLにプリレンダリングすることで恩恵を受けます。ただし、純粋な静的システムは動的ページを処理できません。
制約: 静的エクスポートは、HTML(初期ロード用)とRSCフライトペイロード(クライアントサイドナビゲーション用)の両方を生成する必要があります。同じコンポーネントツリーが、コード変更なしで静的モードとサーバーモードの両方でレンダリング可能でなければなりません。
決定: エクスポートステップとして静的生成をサポートします。ページは個別に静的エクスポート用にマークできます。出力にはHTMLとRSCペイロードの両方が含まれます。エクスポートされたファイルは任意の静的ファイルサーバーまたはランタイム自体で配信可能です。
// about.static.ts — /aboutを静的生成用にマーク
export default true;
// posts/[id].static.ts — 動的ルートのパラメータを列挙
export default [{ id: "1" }, { id: "2" }, { id: "3" }];
トレードオフ:
- 静的ページは次のビルドまで古くなります。CDNエッジでの組み込みのインクリメンタル静的再生成はありません。
- 動的データを持つページのエクスポートには、ビルド時にすべての可能なパラメータの組み合わせを列挙する必要があります。
- 1つのデプロイメントで静的ページとサーバーレンダリングページを混在させるには、動的サブセットのためのサーバーランタイムが必要であり、ホスティングが複雑になります。
結果: 静的ページはランタイムを起動せずに配信され、それらのルートのサーバーコンピュートが排除されますが、コンテンツの更新にはリビルドが必要です。静的ページ間のクライアントサイドナビゲーションはプリレンダリングされたフライトペイロードを使用し、エクスポートされたページ数に比例してビルド出力サイズが増加します。ハイブリッドデプロイメント(一部静的、一部動的)には静的ファイルホストと実行中のサーバープロセスの両方が必要であり、デプロイメントトポロジーの複雑さが増します。
問題: JavaScriptに依存するアプリケーションは、JavaScriptが無効なユーザー、制限されたネットワーク上のユーザー、または主に初期HTMLとやり取りする支援技術を使用するユーザーにとってアクセスしにくくなります。
制約:
サーバー関数はクライアントサイドJavaScriptなしで標準HTTP POSTを介したフォーム送信を受け付ける必要があります。ナビゲーションは<a>タグで動作する必要があります。アーキテクチャはJavaScriptが利用可能な場合にリッチなインタラクティビティをサポートしながら、グレースフルに劣化する必要があります。
決定: フォームはサーバー関数エンドポイントに直接送信されます。リンクはフルページサーバーレンダリングをトリガーします。クライアントサイドJavaScriptは、ロードされるとクライアントサイドトランジションとストリーミング更新でフォームとナビゲーションをプログレッシブに拡張します。
トレードオフ:
- JavaScriptがなければ、クライアントサイドナビゲーションはありません — すべてのインタラクションがフルページロードになります。
- JavaScriptなしのフォームベースのインタラクションは、HTMLフォームが表現できるものに制限されます(楽観的更新やインラインバリデーションはありません)。
- 開発者は2つのコードパスをテストする必要があります:JSなしのベースラインと拡張されたJS体験です。
結果: JavaScript無しのパスは機能的ですが劣化した体験を生成します — ナビゲーションはフルページロードをトリガーし、フォームインタラクションにはクライアントサイドのフィードバックがありません。JavaScriptが利用可能な場合、クライアントコンポーネントがハイドレートしベースラインを拡張しますが、これにより独立してテストする必要がある2つの異なる動作パスが作成されます。ESMを介した遅延ロードクライアントコンポーネントはコンポーネント境界ごとにJavaScriptペイロードを分割しますが、バンドルされない限り各追加クライアントコンポーネントがネットワークリクエストを追加します。
問題:
命令的キャッシュAPI(useCache()、useResponseCache())は、開発者がキャッシュ可能な関数やルートハンドラーを手動でラップすることを要求します。これによりキャッシュロジックがコードベース全体に散在し、どの関数がどのTTLとタグでキャッシュされているかを監査することが困難になります。
制約: キャッシュディレクティブは宣言的で、適用する関数とコロケーションされている必要があります。キャッシュキーはビルド間で安定している必要があります — コード変更はキャッシュを暗黙的に無効化するべきです。キャッシュバックエンドはプラガブル(メモリ、localStorage、sessionStorage、Unstorageドライバーを介した外部ストア)である必要があります。サーバーとクライアントコンポーネントの両方にキャッシュサポートが必要ですが、異なるストレージバックエンドを使用します。
決定:
インライン設定構文を持つ"use cache"ディレクティブをサポートします:"use cache: <provider>; ttl=<ms>; tags=<tag1>,<tag2>; profile=<name>"。
async function getTodos() {
"use cache; ttl=200; tags=todos";
const res = await fetch("https://jsonplaceholder.typicode.com/todos");
return res.json();
}
Viteプラグインがコンパイル時に関数本体を書き換え、ファイルパス、ソースコンテンツ、AST位置のMD5ハッシュをキャッシュキーとしてuseCache()でラップします。ロックマップが同時キャッシュミスのサンダリングハード問題を防ぎます。組み込みプロバイダーにはメモリ、サーバー(プリレンダリング対応)、クライアント(ブラウザメモリ)、localStorage、sessionStorage、nullが含まれます。カスタムプロバイダーは設定可能です。
トレードオフ:
- ディレクティブ構文(
"use cache: memory; ttl=60000; tags=posts")はコンパイル時に解析される文字列DSLです。標準的なJavaScriptではなく、TypeScriptによる検証もされません — プロバイダー名やタグ文字列のタイプミスはランタイムまでサイレントです。 - MD5ベースのキャッシュキーにはソースコンテンツが含まれるため、キャッシュされた関数のコード編集はキャッシュを無効化します。一貫性のために正しいですが、無操作のコード変更(例:コメントの追加)を投入するとキャッシュがフラッシュされることを意味します。
- コンパイル時の書き換えは、ストレージレベルのキャッシュの上にリクエストレベルの重複排除のためにReactの
cache()で関数をラップします。これら2つのキャッシュレイヤーが相互作用します — リクエストレベルのキャッシュヒットはストレージチェックをスキップしますが、一方のレイヤーの無効化はもう一方に伝播しません。 - クライアントサイドキャッシュは
AsyncLocalStorageコンテキストなしのシンプルなインメモリマップを使用するため、同時操作間のキャッシュ分離はブラウザのシングルスレッド実行モデルに依存します。
結果: キャッシュは関数レベルでプロバイダー、TTL、タグを1行で宣言されます。キャッシュキーは自動的に安定し、無効化に安全です。ただし、文字列DSLは静的解析ツールに対して不透明であり、二重レイヤーキャッシュは非自明な相互作用効果を生み、プラガブルプロバイダーシステムはディレクティブ自体を超えた設定知識を必要とします。
問題: すべてのリクエストに対して同一のRSC出力を再レンダリングすると、サーバーリソースが浪費されます。一般的なページ(ランディングページ、商品リスト)はユーザー間で同一であることが多く、キャッシュから配信できます。
制約: キャッシュはオプトインで、レスポンスごとに制御可能でなければなりません。キャッシュキー、TTL、再バリデーション戦略は開発者定義でなければなりません。キャッシュレイヤーは特定のインフラストラクチャ(Redis、CDNなど)を前提としてはいけません。
決定: 明示的なキャッシュ制御APIを備えたインメモリレスポンスキャッシュを提供します。開発者はルートまたはレスポンスごとにキャッシュディレクティブを設定します。キャッシュ実装は置換可能です。
トレードオフ:
- インメモリキャッシュはプロセスの再起動を生き延びず、デフォルトではクラスターワーカーや複数のサーバー間で共有されません。
- キャッシュの無効化は手動です。データベースの変更イベントやWebhookトリガーのパージとの組み込みの統合はありません。
- 抽象化は最小限です — 本番デプロイメントでは通常、分散シナリオ用のカスタムキャッシュアダプターが必要です。
結果: キャッシュされたページは再レンダリングなしで配信されます。再バリデーションにより動的データの結果整合性が可能になります。プラガブルキャッシュインターフェースにより、ランタイムの変更なしでRedis、Memcached、またはCDNレベルのキャッシュとの統合が可能です。
これらの決定は、本番環境、開発中、およびデプロイメントターゲット全体でシステムがどのように動作するかを管理します:プラットフォーム固有のビルドアダプター、マルチプロセススケーリング、開発フィードバックループ、およびHTTPリクエスト/レスポンスを超えてサーバーの機能を拡張するプロトコル統合です。
問題: デプロイメントプラットフォーム(Vercel、Netlify、Cloudflare、Bun、Deno)はそれぞれ異なる出力構造、エントリーポイント形式、ランタイムAPIを必要とします。Node.jsサーバーバンドルを生成するビルドは、大幅な再構築なしにCloudflare Workersにデプロイできません。
制約:
アダプターは、ランタイムの内部ビルド出力(サーバー、クライアント、静的、RSCファイルを含む.react-server/ディレクトリ)を、アプリケーションコードを変更することなくプラットフォームの期待する出力に変換する必要があります。アダプターはPPR対応ルーティング(静的配信から.postponed.jsonファイルを除外)、依存関係トレーシング(Node.jsサーバーレスバンドル用)、エッジ対Node.jsエントリーポイントの選択を処理する必要があります。
決定:
パイプラインを標準化するcreateAdapter()ファクトリを提供します:出力をクリア → ファイルをコピー/分類 → アダプター固有のハンドラーを呼び出し → オプションでデプロイ。各アダプターは出力ディレクトリ構造、エントリーポイント(エッジ:@lazarv/react-server/edge、Node:@lazarv/react-server/node)、およびプラットフォーム固有の設定生成(Vercel Build Output API、wrangler.toml、Netlify _redirects)を指定します。Node.jsアダプターは依存関係トレーシングに@vercel/nftを使用し、エッジアダプターはすべてを単一チャンクにバンドルします。
トレードオフ:
- 各アダプターはプラットフォームの出力API(Vercel Build Output v3、Cloudflare Workers形式など)に密結合されています。プラットフォームAPIの変更にはアダプターの更新が必要です。
- Node.jsアダプターの
@vercel/nft依存関係トレーシングがパフォーマンスのボトルネックです — 複数のエクスポート条件パス(react-server、node/import、node/require)を通じて完全なnode_modulesグラフをトレースする必要があります。 - エッジアダプターはNode.js APIを必要とする機能を失います:ワーカースレッド(
"use worker"はノーオップになる)、クラスターモード、ファイルシステムベースのキャッシュ。 - アダプターシステムは
.react-server/ビルド出力構造を前提としています。カスタムビルドパイプラインや非標準の出力レイアウトは、カスタムアダプターを作成しない限りサポートされません。
結果:
単一の--adapter <name>フラグでプラットフォーム固有のデプロイメント出力が生成されます。アプリケーションコードはターゲット間で変更されません。ただし、エッジデプロイメントはフルランタイムの制限されたサブセットであり、依存関係トレーシングはNode.jsターゲットのビルド時間を追加し、各プラットフォームの独特な出力形式には専用のアダプターメンテナンスが必要です。
問題: 単一のNode.jsプロセスは1つのCPUコアを使用します。マルチコアマシンでの本番デプロイメントはハードウェアを十分に活用できず、単一のブロックされたイベントループはすべての同時リクエストのスループットを低下させます。
制約: Node.jsクラスタリングはスレッドベースではなくプロセスベースです。各ワーカーは独自のメモリ空間を持つ独立したプロセスです。共有状態(インメモリキャッシュ、WebSocket接続)はワーカー間で自動的に伝播しません。
決定: 本番ビルドでNode.jsクラスターモードをサポートします。ランタイムはデフォルトでCPUコアごとに1つのワーカーを生成します。各ワーカーは独立してリクエストを処理します。
# 利用可能なすべてのCPUコアで起動
REACT_SERVER_CLUSTER=on pnpm react-server start
# またはワーカー数を指定
REACT_SERVER_CLUSTER=8 pnpm react-server start
トレードオフ:
- インメモリキャッシュはワーカーごとであり共有されないため、ワーカー間で冗長なレンダリングや一貫性のないキャッシュ状態が発生する可能性があります。
- メモリ使用量はワーカー数に比例してスケールします。各ワーカーは完全なアプリケーションとその依存関係をロードします。
- クラスターモードはNode.js環境でのみ利用可能です — サーバーレス、エッジ、Denoデプロイメントは異なる並行処理モデルを使用します。
結果: スループットは利用可能なCPUコアに応じてスケールします。ブロックまたはクラッシュしたワーカーがプロセス全体をダウンさせることはありません。並行処理モデルはアプリケーションコードに対して透過的です — クラスターモードで実行するためにコード変更は不要です。
注意: クラスターモードはNode.jsの本番ビルドでのみ利用可能です。
問題: コード変更時のフルページリロードは開発フローを中断し、クライアントサイドの状態を破棄します。RSCはサーバーコンポーネントをサーバー上で再レンダリングし、更新されたフライトペイロードを配信する必要があるため、複雑さが追加されます。
制約: HMRはクライアントとサーバーの両方のコンポーネントをカバーする必要があります。ViteのHMRプロトコルはクライアントモジュールをネイティブに処理しますが、サーバーコンポーネントの変更にはサーバーサイドの再レンダリングとRSC出力の差分が必要です。
決定: クライアントコンポーネントにはViteのHMRインフラストラクチャを活用します。サーバーコンポーネントではファイル変更時にサーバーサイドの再レンダリングをトリガーし、更新されたRSCペイロードをクライアントにストリーミングすることで拡張します。
トレードオフ:
- サーバーコンポーネントのHMRは真の「ホットリプレースメント」ではありません — サーバー上でコンポーネントツリーを再レンダリングし、クライアントでリコンサイルするため、複雑なツリーではクライアントのみのHMRより遅くなる可能性があります。
- サーバーコンポーネントのHMR境界検出はクライアントコンポーネントよりも精度が低く、必要以上に広範な再レンダリングが発生することがあります。
- HMRがフルページロードで表面化する問題をマスクする場合、開発時の動作が本番時の動作と乖離する可能性があります。
結果: サーバーコンポーネントのHMRレイテンシーは、インプレースモジュールスワップではなくサーバーサイドの再レンダリングとフライトペイロードの差分が必要なため、クライアントのみのコンポーネントよりも高くなります。影響を受けないサブツリーのクライアントサイド状態はサーバーコンポーネントHMR間で保持されますが、再レンダリング境界内のコンポーネントの状態はリセットされます。開発時のモジュール変換の動作は、本番のみのバンドリングの問題を再現しない可能性があります。
問題: AIエージェントは標準化されたプロトコルを通じてサーバーサイドのツールを発見し呼び出す必要があります。MCP(Model Context Protocol)サーバーの構築には、通常、独自のHTTP処理、スキーマバリデーション、トランスポート管理を持つ別のサービスが必要です — アプリケーションサーバーが既に提供しているインフラストラクチャの重複です。
制約: MCPツールは、既存のアプリケーションコードベース内でサーバー関数として定義可能でなければならず、同じモジュールシステムとデプロイメントパイプラインを使用します。MCPトランスポートはステートレスなエッジデプロイメントとの互換性のためにストリーマブルHTTP(WebSocketのみではない)でなければなりません。ツール入力スキーマはランタイムでバリデーションされる必要があります。
決定:
ランタイムのエクスポートとしてcreateTool()、createResource()、createPrompt()、およびcreateServer()プリミティブを提供します。MCPツールは入力バリデーション用のZodスキーマを持つ"use server"モジュールで定義されます。
"use server";
import { createTool } from "@lazarv/react-server/mcp";
import { z } from "zod";
export const echo = createTool({
id: "echo",
title: "Echo",
description: "Echoes the input back",
inputSchema: { input: z.string() },
async handler({ input }) {
return `Echo: ${input}`;
},
});
createServer()は@modelcontextprotocol/sdkからMcpServerをインスタンス化し、すべてのツールを登録し、MCPストリーミングトランスポートをReadableStreamボディを持つWeb Responseにブリッジする非同期HTTPハンドラーを返します。
トレードオフ:
- MCPサーバーはステートレス(
sessionIdGenerator: undefined)です — ツール呼び出しはリクエスト間で会話コンテキストを維持できません。ステートフルなプロトコルには外部セッション管理が必要です。 - Zodはスキーマ定義の必須依存関係です。MCPを使用しないプロジェクトも、ツリーシェイキングされない限り依存関係のコストを支払います。
- トランスポートブリッジはNode.jsスタイルの
writeHead/write/endとWebReadableStream間を変換し、ランタイム間で異なる動作をする可能性のある互換性レイヤーを追加します。 - MCPツールはランタイムのデプロイメントライフサイクルに結合されています — アプリケーションサーバーから独立してデプロイまたはスケールすることはできません。
結果: MCPサーバーは、おなじみのサーバー関数パターンを使用してアプリケーションコードベース内で定義可能です。AIエージェントは標準化されたMCPプロトコルを介してツールを発見します。ただし、ステートレスなトランスポートは会話型ワークフローを制限し、Zodの依存関係はツール作者にとって非オプショナルであり、MCPエンドポイントはアプリケーションサーバーのリソース制約を共有します。