zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

ビルドエンジン

zfb のクレートがどう組み合わさるか、なぜリビルドがページ単位なのか、そして `dist/` への書き込みを安全にしている仕組み。

このページは zfb のビルドエンジンの 構造 についての解説です。ファイルを保存したときに何が起こるかの手順を追いたい場合は Build pipeline を読んでください。このページではなぜこの構造になっているのかを説明します。

関連: Architecture overview · Islands · Incremental rebuild

クレートの分割

zfb は Rust のワークスペースです。各クレートはビルドの 1 つの領域を担当し、クレート間を流れるデータは小さく明示的です。

  • zfb-routerpages/ を走査してファイルをルートに変換します。ファイル名から URL への変換規約だけを担当し、それ以外は何もしません。

  • zfb-graph は依存グラフを保持します。各ページは自分がどのソース(コンポーネント、レイアウト、コンテンツ、スタイル)に依存しているかを把握し、各ソースは自分にどのページが依存しているかを把握します。これがページ単位のリビルドを可能にするインデックスです。

  • zfb-watchernotify クレートをラップし、ノイズの多いネイティブイベントを論理的な保存 1 回につき 1 つの Change に正規化して、チャンネルに送出します。

  • zfb-build はオーケストレーターです。Change の値を消費して分類し、どのページをリビルドする必要があるかをグラフに問い合わせ、アセットパイプラインを実行します。

  • zfb-render は SWC を通して TSX をコンパイルし、その結果の JS を RenderHost に渡して HTML を書き出します。

  • zfb-css は CSS モジュールとグローバルスタイルを処理します。

  • zfb-islands"use client" ディレクティブを走査し、すべてのアイランドを単一の共有 JS バンドルにまとめて、ハイドレーションのエントリを送出します。

  • zfb-content は Markdown / MDX のコンテンツコレクションをパースし、unified のプラグインチェーンを実行します。

  • zfb-server は dev 専用の HTTP サーバーです。ページキャッシュ、静的ファイルのルート、ライブリロードの SSE ストリーム、そして prerender = false ページ向けのリクエスト時 SSR ディスパッチャーを備えます。

クレートは下方向に依存します。オーケストレーターは render・css・islands・content を知っています。レンダラーはオーケストレーターを知りません。サーバーは読み取るだけです。

バンドル単位ではなく、ページ単位のリビルド

バンドラーベースのツール(Vite、esbuild)はモジュールとチャンクで考えます。変更があるとモジュールが無効化され、バンドラーは import グラフをたどって何を再出力するかを決めます。zfb は ページ で考えます。

ファイルが変更されると、zfb-watcherChange を送出し、オーケストレーターは zfb-graph にこのパスに依存しているのはどのページかを問い合わせ、そのページだけが再レンダリングされます。3 つのページから使われているリーフコンポーネントは 3 つのページリビルドを生み出します。サイト全体のリビルドでもなければ、バンドラーのグラフ走査でもありません。

得られるのは粒度です。2,000 ページのサイトでも、共有ヘッダーが変更されたときに影響を受けるページをミリ秒単位でリビルドします。コストはグラフが正直でなければならないことです。zfb-graph は成功したレンダリングごとに更新され、次のクエリが最新の状態を参照できるようにします。

エンジンが使うツール(とそれらが差し替え可能な理由)

zfb はオーケストレーターです。依存グラフ、ページ単位のリビルドのコントラクト、クレート間を流れるデータを所有しています。zfb が呼び出す外部ツールは実装の詳細です。それぞれが Rust のトレイトの背後に位置するため、パイプラインの残りに触れることなくいずれも差し替えられます。

ツール役割トレイト境界
esbuildサーバーサイドのワーカーバンドル(zfb-build/bundler.rs)と共有アイランドクライアントバンドル(zfb-islands)をバンドルします。高速で、TSX/JSX、MDX ローダー、ツリーシェイキングを扱えます。ClientBundlerzfb-islands 内)
deno_core (V8)SSG と dev プレビューサーバー向けにサーバーサイドのワーカーバンドルを実行する、組み込みの V8 アイソレートです。Tauri での配布には Node 依存のない単一バイナリが必要なため選ばれました。理由は JS runtime を参照してください。RenderHostzfb-render 内)
SWCTSX のソースを JavaScript にコンパイルしてから esbuild やレンダーホストに渡します。zfb-render の内部に存在します。zfb-render の内部。そのクレートの transform ステップを置き換えることで差し替え可能
lol_htmlselector-aware な変更が必要な箇所で使うストリーミング HTML リライターです。アイランドマーカー処理、base リンクの書き換え、dev サーバーの HTML 注入で使われます。プロダクションの head アセット注入は lol_html パスではなく、バイトレベルの splice です。zfb-islands/src/html_tree.rszfb-build/src/link_base_rewrite.rszfb-server で使用。公開トレイトは不要 — 低レベルのユーティリティです

レイヤリングの仕組み

zfb はオーケストレーション、依存グラフ、ページ単位のコントラクトを所有します。各ツールはちょうど 1 つのレイヤーに登場します。

  • esbuild は 2 か所で呼ばれます。zfb-build(サーバーワーカーバンドル)と zfb-islands(共有アイランドクライアントバンドル)です。どちらも TypeScript/JSX のソースツリーをバンドルするために使い、いずれも上位のクレートには公開しません。

  • deno_core (V8)RenderHost の背後にある JS エンジンです。zfb-render のレンダラーが RenderHost::call_default を呼び、具体的なホスト(EmbeddedV8RenderHost)がインプロセスの V8 アイソレートでバンドルを実行します。zfb-build のオーケストレーターはエンジンの名前を一切口にせず、レンダリング済みの HTML を受け取るだけです。

  • SWC はモジュールがレンダーホストに到達する前に zfb-render の内部で、または esbuild がソースを見る前に zfb-build/bundler.rs の内部で実行されます。いずれにせよオーケストレーターからは見えません。

  • lol_html は、本物の要素またはコメント認識が必要な HTML 変更ユーティリティで実行されます。zfb-islands/src/html_tree.rszfb-build/src/link_base_rewrite.rszfb-server の dev HTML 注入パスです。zfb-build/src/head_inject.rs は意図的に使いません。このヘルパーは </head> だけを探し、安定した CSS / island アセットタグを閉じタグの前に splice します。

この構造により、各ツールのトレイト境界より上のクレートは、ツールが変わっても影響を受けません。ClientBundler のコントラクトについては Islands を、依存グラフがどのようにページ単位のポリシーへ供給されるかについては Incremental rebuild を参照してください。

シャドウステージング: バンドラーが esbuild に一貫したツリーを渡す仕組み

サーバーサイドのワーカーバンドル向けに esbuild が走る前に、zfb-build のバンドラー(crates/zfb-build/src/bundler.rs)は シャドウツリー を実体化します。これはプロジェクトのソースルート(pages/content/components/layouts/)の一時的なコピーで、プロジェクト自身のディレクトリ構造をそのまま反映するため、相対 import は変更なしで解決されます。各 .mdx ファイルはその場で JSX 出力にコンパイルされ、.mdx 拡張子は保持されるため esbuild の --loader:.mdx=jsx がそのまま適用され、フレームワークのハイドレーションシムもそれらと並んで実ファイルとして実体化されます。

シャドウルートには 2 つの合成ファイルが置かれます。

  • 合成された tsconfig.json バンドラーはユーザーの tsconfig パスエイリアス(すでにプロジェクト自身の extends チェーンに対して解決済み)をシャドウツリー向けにリベースします — rebase_tsconfig_paths_to_shadow — その後 write_synthetic_tsconfig で結果を書き込みます。esbuild は --tsconfig= 経由でこのファイルを読み、プロジェクトのエイリアス(@/components/foo./components/foo)を解決します。そのため、エイリアス付き import は esbuild がライブツリーから読んでもシャドウコピーから読んでも同じように振る舞います。

  • 合成された entry.mjs シャドウの pages/ 配下で見つかったすべてのページモジュール、ハイドレーションシム、そして @takazudo/zfb-runtime/servercreatePageRouter を import し、routes マップと esbuild がバンドルする Workers スタイルの default { fetch } エントリを再エクスポートします。

--preserve-symlinksnode_modules シンボリックリンク。 デフォルトでは、MDX 以外のソースファイルはコピーではなくライブファイルへのシンボリックリンクとしてシャドウツリーに配置されます — 安価であり、esbuild はそれを透過的に読み取ります。copy_mode(シンボリックリンクの代わりに実コピー)が発動するのは、esbuild が --preserve-symlinks なし で動く唯一の構成、すなわち tsconfig パスが空でないワークスペースプロジェクトのときだけです。この場合、esbuild はシンボリックリンクされたソースをライブツリーへカノニカライズしてしまい、import.meta.glob 展開のようなシャドウ専用の変換が見えなくなってしまいます。それとは別に、呼び出し側が node_modules_dir(例: pnpm ワークスペースの <workspace-root>/node_modules/.pnpm/node_modules)を設定している場合、バンドラーは <shadow>/node_modules シンボリックリンクを作成し、esbuild がシャドウツリーの内側からワークスペースパッケージを解決できるようにします。--preserve-symlinks は、解決中に esbuild をライブパスへカノニカライズして戻すのではなく、<shadow>/node_modules/<pkg> シンボリックリンクに留めます — esbuild は、解決済みパスに node_modules セグメントを含むあらゆるインポーターについて tsconfig.json の探索をスキップするため、このアンカーを失うと、ワークスペースパッケージの内部に書かれた import のエイリアス解決が静かに壊れてしまいます(このフラグが存在する理由となった回帰については issue #443 / #450 を参照してください)。

コマンドレイヤーは自前のツリーをステージし、トリガーも別物です。 上記の copy_mode の規則は SSR バンドラー自身の判定であり、crates/zfb-build/src/bundler.rs のシャドウツリーだけを対象とします。クライアントスクリプトとアイランドのステージングはそれとは別に、コマンドレイヤー(crates/zfb/src/commands/build.rs)で行われ、コピーモードの判定も独自に導出します。こちらは 2 つの条件の どちらか一方 が成り立てばコピーモードになります。1 つは、ステージ対象のクロージャに ワークスペースのシブリング(プロジェクト境界を pnpm ワークスペースルートまで広げたファーストパーティルートから見ればプロジェクトローカルでありながら、プロジェクト自身のルートの外にあるファイル)が含まれること。もう 1 つは、上で述べた node_modules と空でない tsconfig paths の条件が成り立つことです。前者は後者を絞り込むための条件ではなく、単独で成立する条件です。コピー経路には node_modules の存在を必要とする処理が何もありませんし、シブリングをシンボリックリンクとしてステージすると esbuild がそれをライブツリーへカノニカライズしてしまい、ステージングが防ごうとしているまさにそのエスケープを招いてしまうからです。

設計上サポートしないケース: シブリングパッケージのホイストされていない node_modules へパス形式で extends する。 コマンドレイヤーはプロジェクトの tsconfig extends チェーンを自前のステージへミラーしますが、パスに node_modules セグメントを含む設定ファイルは対象外とします(internal_shadow_config_path)。依存パッケージ側の設定は、ステージに張られた node_modules シンボリックリンク経由で解決される前提だからです。ただしその一括シンボリックリンクは、ワークスペースルートのインストール分と、アクティブなプロジェクト自身のネストしたインストール分の 2 本しかありません。ワークスペースのシブリング が自前のホイストされていない node_modules を抱えている場合、そのどちらにも含まれません。そのため、そこへ踏み込む パス形式extends"extends": "../other-pkg/node_modules/@scope/tsconfig/base.json")は、ライブツリーでは解決できてもステージの内側には解決先が存在しません。相対パスでも絶対パスでも結果は同じです。internal_shadow_config_path が受け取るのは解決済みのパスであり、どちらの書き方も同じ場所に解決されて同じ形で弾かれるからです。この形は意図的にサポート対象外としています。パッケージ名だけを書く extends は影響を受けません — そちらは islands_shadow_config_bytes が絶対パス(実体のパス)へ書き換えるため、ライブツリーで解決できるものはステージの内側でも同じように解決できます。どの回避策を採れるかは、extends を書いている設定ファイル自体がどこにあるかで決まります。その設定ファイルが シブリングの内側 にあるなら、extends をパッケージ名で書けば済みます。resolve_tsconfig_extends_file が設定ファイルの位置から上へ辿る途中でシブリング自身の node_modules を見つけ、値は絶対パスに固定されます。一方、extends を書いている設定ファイルが シブリングの外側 にある場合、どちらの手だけでも足りません。パッケージ名で書いてもシブリングの node_modules はその設定ファイルの祖先ディレクトリではないため解決できませんし、ホイストしただけではパスの指す先がシブリングのネストしたツリーのままなので何も変わりません。この場合は、依存をワークスペースルートの node_modules へホイストした うえでextends をパッケージ名に書き換える必要があります。この判断は issue #2322 に記録されています。現時点でこの形はあくまで理論上のものであり(コードベースにこれを再現するフィクスチャもテストも存在しません)、ステージングのゲートキーパーを esbuild の解決結果をケースバイケースで予測する方向へ育てるのは、収束しないと分かっている罠だからです。将来サポートするとしても、手を入れるのは解決ロジックではなくシンボリックリンクの カバー範囲 です。

すべてのバンドル後に、esbuild 自身のメタファイルに対して fail-closed な ステージエスケープ監査 が実行されます。ある入力が、ステージされたスペリングを一切持たずにシャドウツリーの外にあるライブなファーストパーティソースへ解決された場合、あるいはライブなワークスペースのシブリングへエスケープする node_modules シンボリックリンクへ解決された場合、ビルドをハードエラーにします。シャドウツリー自体はビルド時専用の成果物です。バンドルが書き出されると破棄され、dist/ 内の何もそれの存在に依存しません。

V8 モードのゲート: output と自動検出

zfb-render は、デフォルトで有効な embed_v8 cargo フィーチャーの背後に組み込み V8 ホストを公開します。ビルドエンジンはすべてのビルドの開始時に、デプロイ成果物が V8 を備えたランタイムを構造の一部として前提とするかどうか(V8Mode の判定)を決定し、設定とルートテーブルが食い違う場合には明確なエラーを表示します。

この判定は 2 つの入力で駆動されます。

  • zfb.config.tsConfig.output"static" / "hybrid" / "auto"、デフォルトは "auto")。

  • prerender = false をエクスポートするルートとして検出された集合。adapter なしでは SSR を許さない事前条件がすでに使っているのと同じデータです。prerender_map を 1 回走査し、判定箇所は 1 か所です。

outputSSR ルートの有無結果
"static"なしV8Mode::Off
"static"ありエラー
"hybrid"任意V8Mode::On
"auto"なしV8Mode::Off
"auto"ありV8Mode::On

このエラーはバンドラーが走る前に発火し、output の設定と最初に問題となったルートの両方を示し、それ以上ある場合は残りの数をカウントします。そのため、static を宣言したプロジェクトがコピー & ペーストの結果として SSR ルートをうっかり拾い上げることはありません。ビルドはルートのデプロイ形態を黙って切り替えるのではなく、その矛盾を拒否します。

2 つの手動オーバーライド("static""hybrid")とデフォルト("auto")は、3 つの異なる意図をカバーします。

  • "auto" — ルートテーブルに判断を任せます。すでに合致しているプロジェクトにとって最良のデフォルトです。

  • "static" — 意図を先に宣言します。「誰かがページに prerender = false を追加する」という失敗モードを静かにではなく大きく顕在化させたい SSG 専用サイトに有用です。

  • "hybrid" — 逆方向に意図を宣言します。あとから SSR ルートを追加する予定で、それまでビルドのトポロジーを安定させておきたいプロジェクトに有用です。

V8Mode::Off が今日実際に行うことは、正直に述べておくべき部分です。出荷される zfb バイナリ上では観測的なものにとどまります。ビルドマシンの zfb は SSG ページをレンダリングするために常に V8 を起動します — それがパイプラインの動作の仕組みであり、embed_v8 = off はすでに zfb build でハードエラーになります。このモードは、将来の出荷経路(Tauri サイドカー、スタンドアロン SSR サーバー、cargo install によるデプロイ)が同じ判定を再導出せずに読み取れるよう配線されています。今日の負荷を担う、ユーザーから見える役割は "static" の事前条件エラーです。

アトミックな書き込み

dist/ 内のすべてのファイルは atomic_write_stringzfb-buildatomic.rs)を通して書き込まれます。同じディレクトリ内の兄弟一時ファイルに書き込み、その後あて先に対して rename します。rename は同一ディスク上のファイルに対して POSIX でアトミックであり、Windows も MoveFileExW の replace-existing セマンティクスを通して同じ保証を持ちます。

具体的には次のとおりです。

  • ビルドの途中で dist/index.html を開いた読み手は、古いバイト列か新しいバイト列のどちらかを見ます。途中まで書かれた状態や空の状態を見ることは決してありません。

  • クラッシュしたビルドは孤立した *.tmp-<pid>-<seq> ファイルを残しますが、出力を壊すことは決してありません。この命名は意図的です。クラッシュ後に ls dist/ すると、処理中だったものが分かります。

  • dev サーバーは、オーケストレーターが書き換えている最中の dist/ を配信できます。調整は不要です。

これは退屈な種類の正しさです。機能ではなく、決して破らない不変条件です。

ウォッチャーのデバウンスと変更の集約

zfb-watcher はデフォルト 50ms のウィンドウでネイティブイベントをデバウンスします。エディタの保存は雑然としています。vim はスワップファイルに書き込んでからリネームし、vscode は複数のメタデータイベントを発し、git checkout は一度に数百のイベントを生み出します。デバウンスは各バーストをパスごとに 1 つの Change に折りたたみます。

オーケストレーターは 2 段目の処理を行います。tick が発火したとき、パイプラインを呼び出す前にチャンネルにすでにある Change をすべて drain します。速い保存のバーストでも、自然な小休止ごとに 1 回のパイプライン実行になります。オーケストレーターはウォッチャーの上にさらなる時間ベースの集約を行いません。ウォッチャーがすでに正しいことをしているからです。速くタイピングしてもビルドがばたつくことはありません。

dev サーバーとの関係

dev サーバー(zfb-server)は、オーケストレーターの出力の上に乗る薄い読み手です。PageCache(URL パスからレンダリング済み HTML へのマップで、レンダリングごとに更新される)、ReloadEvent 値の tokio::sync::broadcast チャンネル、そしてキャッシュ・dist/assets/public//__zfb/reload の SSE エンドポイントを配信する axum ルーターを所有します。

配線箇所は zfb-serverlivereload.rs にある outcome_to_events です。noop でないすべての BuildOutcomeReloadEvent の 3 つのバリアントのうち 1 つ以上に変換され、ブロードキャストされます。CSS のみの変更は Css イベントを発し、ブラウザはクライアントの状態を失うことなくスタイルシートをインプレースで差し替えます。共有アイランドバンドルが変わった場合は Islands イベントを発し、ブラウザは新しいバンドルを動的インポートしてハイドレーションをやり直します。それ以外はすべて Page イベントを発し、location.reload() をトリガーします。イベント種別の完全な内訳と、どの編集シナリオがどのイベントを発火させるかについては Dev mode lifecycle を参照してください。

prerender = false ルート向けのリクエスト時 SSR

dev サーバーは prerender = false ルートも、ビルド時の SSG を駆動するのと 同じ 組み込み V8 ホストを通して配信します。スタンプ済みの静的スナップショットからではありません。dev ルーターのリクエストごとの優先順位は次のとおりです。

  1. プラグインの dev ミドルウェア(最長プレフィックス一致が勝つ)、

  2. SsrRouteSet に一致する URL に対する リクエスト時 SSR

  3. メモリ上のページキャッシュ(SSG 出力)、

  4. ディスク上の dist/ へのフォールバック、

  5. ディスク上の public/ へのフォールバック、

  6. dev の 404。

SSR レイヤーは zfb-server の小さな SsrDispatcher トレイトを介して配線されます。bin クレート(crates/zfb/src/commands/dev.rs)が EmbeddedV8SsrAdapter を介して具体的な実装を提供します。これはレンダラーの Arc<Mutex<Option<RendererState>>> へのハンドルをクローンし、spawn_blocking タスク上で EmbeddedV8Host::dispatch_fetch を通してディスパッチします。V8 アイソレートは専用の OS スレッドにとどまり、アダプターは 2 つ目のスレッドを生成しません。

これが「dev が prod と一致する」を本物の保証にしています。dev プレビューの prerender = false 出力は、同じソースから Cloudflare アダプターが生成するものと 意味的に等価(同じステータス・ボディ・コンテンツタイプ。タイムスタンプとリクエスト ID は異なる場合があります)です。共有された V8 ホストにより、ドリフトする 2 つ目のレンダラーが存在しません。

サーバーは dev 専用です。本番ではエッジ CDN 向けの静的ファイルに加えて、prerender = false ルート向けに Cloudflare アダプター(Workers Static Assets、Pages 互換)からの _worker.js を出力します。dev サーバーを安全にしているのと同じアトミック書き込みの保証が、あらゆる本番デプロイを安全にします。

ライブラリとしての組み込み: ホストが供給する HTTP ハンドラ

Rust のホスト(Tauri デスクトップアプリ、CLI ツール、コンテナ化されたサービス)は、Server::builder() API を介して zfb-server をインプロセスで実行できます。このビルダーは with_ssr_handler(pattern, handler) も公開しており、URL パターンに対してホストが所有する非同期関数を登録します。

登録されたハンドラは、dev ルーターの優先順位チェーンの プラグインの dev ミドルウェアとランタイム SSR ディスパッチャーの間 に組み込まれます。

  1. プラグインの dev ミドルウェア、

  2. ホストが登録した Rust ハンドラ(新規 — ライブラリ組み込み時のみ)、

  3. SsrRouteSet に一致する URL に対するリクエスト時 SSR、

  4. メモリ上のページキャッシュ(SSG 出力)、

  5. ディスク上の dist/ へのフォールバック、

  6. ディスク上の public/ へのフォールバック、

  7. dev の 404。

ホストハンドラは同一パスのランタイム SSR ページより優先されます。それこそがこの継ぎ目の主眼です。ビルダーの形・ハンドラのシグネチャ・コード上の優先順位コントラクトについては Embed-as-library guide を参照してください。

Revision History

作成更新