ビルドエンジン
zfb のクレートがどう組み合わさるか、なぜリビルドがページ単位なのか、そして `dist/` への書き込みを安全にしている仕組み。
このページは zfb のビルドエンジンの 構造 についての解説です。ファイルを保存したときに何が起こるかの手順を追いたい場合は Build pipeline を読んでください。このページではなぜこの構造になっているのかを説明します。
関連: Architecture overview · Islands · Incremental rebuild
クレートの分割
zfb は Rust のワークスペースです。各クレートはビルドの 1 つの領域を担当し、クレート間を流れるデータは小さく明示的です。
zfb-routerはpages/を走査してファイルをルートに変換します。ファイル名から URL への変換規約だけを担当し、それ以外は何もしません。zfb-graphは依存グラフを保持します。各ページは自分がどのソース(コンポーネント、レイアウト、コンテンツ、スタイル)に依存しているかを把握し、各ソースは自分にどのページが依存しているかを把握します。これがページ単位のリビルドを可能にするインデックスです。zfb-watcherはnotifyクレートをラップし、ノイズの多いネイティブイベントを論理的な保存 1 回につき 1 つのChangeに正規化して、チャンネルに送出します。zfb-buildはオーケストレーターです。Changeの値を消費して分類し、どのページをリビルドする必要があるかをグラフに問い合わせ、アセットパイプラインを実行します。zfb-renderは SWC を通して TSX をコンパイルし、その結果の JS をRenderHostに渡して HTML を書き出します。zfb-cssは CSS モジュールとグローバルスタイルを処理します。zfb-islandsはclient:*ディレクティブを走査し、アイランドごとの JS をバンドルして、ハイドレーションのエントリを送出します。zfb-contentは Markdown / MDX のコンテンツコレクションをパースし、unified のプラグインチェーンを実行します。zfb-serverは dev 専用の HTTP サーバーです。ページキャッシュ、静的ファイルのルート、ライブリロードの SSE ストリーム、そしてprerender = falseページ向けのリクエスト時 SSR ディスパッチャーを備えます。
クレートは下方向に依存します。オーケストレーターは render・css・islands・content を知っています。レンダラーはオーケストレーターを知りません。サーバーは読み取るだけです。
バンドル単位ではなく、ページ単位のリビルド
バンドラーベースのツール(Vite、esbuild)はモジュールとチャンクで考えます。変更があるとモジュールが無効化され、バンドラーは import グラフをたどって何を再出力するかを決めます。zfb は ページ で考えます。
ファイルが変更されると、zfb-watcher が Change を送出し、オーケストレーターは zfb-graph にこのパスに依存しているのはどのページかを問い合わせ、そのページだけが再レンダリングされます。3 つのページから使われているリーフコンポーネントは 3 つのページリビルドを生み出します。サイト全体のリビルドでもなければ、バンドラーのグラフ走査でもありません。
得られるのは粒度です。2,000 ページのサイトでも、共有ヘッダーが変更されたときに影響を受けるページをミリ秒単位でリビルドします。コストはグラフが正直でなければならないことです。zfb-graph は成功したレンダリングごとに更新され、次のクエリが最新の状態を参照できるようにします。
エンジンが使うツール(とそれらが差し替え可能な理由)
zfb はオーケストレーターです。依存グラフ、ページ単位のリビルドのコントラクト、クレート間を流れるデータを所有しています。zfb が呼び出す外部ツールは実装の詳細です。それぞれが Rust のトレイトの背後に位置するため、パイプラインの残りに触れることなくいずれも差し替えられます。
| ツール | 役割 | トレイト境界 |
|---|---|---|
| esbuild | サーバーサイドのワーカーバンドル(zfb-)とアイランドごとのクライアントバンドル(zfb-islands)をバンドルします。高速で、TSX/JSX、MDX ローダー、ツリーシェイキングを扱えます。 | ClientBundler(zfb-islands 内) |
| deno_core (V8) | SSG と dev プレビューサーバー向けにサーバーサイドのワーカーバンドルを実行する、組み込みの V8 アイソレートです。Tauri での配布には Node 依存のない単一バイナリが必要なため選ばれました。理由は JS runtime を参照してください。 | RenderHost(zfb-render 内) |
| SWC | TSX のソースを JavaScript にコンパイルしてから esbuild やレンダーホストに渡します。zfb-render の内部に存在します。 | zfb-render の内部。そのクレートの transform ステップを置き換えることで差し替え可能 |
| lol_html | ハイドレーションのエントリポイントの <script> タグやアイランドのマウントマーカーを、完全なパースなしにレンダリング済みの HTML に注入するために使うストリーミング HTML リライターです。 | zfb- の内部で使用。公開トレイトは不要 — 低レベルのユーティリティです |
レイヤリングの仕組み
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.tsのConfig.output("static"/"hybrid"/"auto"、デフォルトは"auto")。prerender = falseをエクスポートするルートとして検出された集合。adapter なしでは SSR を許さない事前条件がすでに使っているのと同じデータです。prerender_mapを 1 回走査し、判定箇所は 1 か所です。
output | SSR ルートの有無 | 結果 |
|---|---|---|
"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_string(zfb-build の atomic.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/・/ の SSE エンドポイントを配信する axum ルーターを所有します。
配線箇所は zfb-server の livereload.rs にある outcome_to_events です。noop でないすべての BuildOutcome は ReloadEvent に変換されてブロードキャストされます。CSS のみの変更は css イベントを発し、ブラウザはクライアントの状態を失うことなくスタイルシートを差し替えます。それ以外はすべて page イベントを発し、location.reload() をトリガーします。
prerender = false ルート向けのリクエスト時 SSR
dev サーバーは prerender = false ルートも、ビルド時の SSG を駆動するのと 同じ 組み込み V8 ホストを通して配信します。スタンプ済みの静的スナップショットからではありません。dev ルーターのリクエストごとの優先順位は次のとおりです。
- プラグインの dev ミドルウェア(最長プレフィックス一致が勝つ)、
SsrRouteSetに一致する URL に対する リクエスト時 SSR、- メモリ上のページキャッシュ(SSG 出力)、
- ディスク上の
dist/へのフォールバック、 - ディスク上の
public/へのフォールバック、 - dev の 404。
SSR レイヤーは zfb-server の小さな SsrDispatcher トレイトを介して配線されます。bin クレート(crates/)が 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 ディスパッチャーの間 に組み込まれます。
- プラグインの dev ミドルウェア、
- ホストが登録した Rust ハンドラ(新規 — ライブラリ組み込み時のみ)、
SsrRouteSetに一致する URL に対するリクエスト時 SSR、- メモリ上のページキャッシュ(SSG 出力)、
- ディスク上の
dist/へのフォールバック、 - ディスク上の
public/へのフォールバック、 - dev の 404。
ホストハンドラは同一パスのランタイム SSR ページより優先されます。それこそがこの継ぎ目の主眼です。ビルダーの形・ハンドラのシグネチャ・コード上の優先順位コントラクトについては Embed-as-library guide を参照してください。