zfb

Type to search...

to open search from anywhere

ビルドエンジン

作成2026年6月1日Takeshi Takatsudo

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-islandsclient:* ディレクティブを走査し、アイランドごとの 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_htmlハイドレーションのエントリポイントの <script> タグやアイランドのマウントマーカーを、完全なパースなしにレンダリング済みの HTML に注入するために使うストリーミング HTML リライターです。zfb-build/head_inject.rs の内部で使用。公開トレイトは不要 — 低レベルのユーティリティです

レイヤリングの仕組み

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-build/head_inject.rs の内部で実行されます。オーケストレーターから見れば、入力は素の文字列で、出力も素の文字列です。

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

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 に変換されてブロードキャストされます。CSS のみの変更は css イベントを発し、ブラウザはクライアントの状態を失うことなくスタイルシートを差し替えます。それ以外はすべて page イベントを発し、location.reload() をトリガーします。

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 アダプターからの _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