JS ランタイム
zfb がサーバーサイドの JavaScript 実行を組み込み V8 アイソレートでどうホストしているか、そしてなぜランタイムがバイナリによって固定されるのか。
zfb は組み込み V8 アイソレートを介してサーバーサイドで TSX をレンダリングします。これは @takazudo/zfb-runtime からビルドされた Hono スタイルのワーカーバンドルを実行する、Rust がホストする V8 エンジンです。
ランタイムの選択に至った経緯
V8 を組み込むという決定は、利用可能な JS ホストの計測的な評価を経て到達しました。初期のイテレーションはインプロセスの deno_core アイソレートを使い(deno_core・ssr_rs・rquickjs の 3 候補をスパイクした上で承認)、その後 Cloudflare Workers の本番ターゲットとランタイムの一致を得るために、Node がホストする miniflare ワーカーへ移行しました。Tauri での配布が最終的にその選択を覆しました。zfb をデスクトップアプリとして出荷するには、エンドユーザーのマシンに Node.js 依存のない単一バイナリが必要であり、miniflare は Node のパッケージだからです。バイナリサイズと初回ビルド時間のコストを受け入れた上で、deno_core を介した V8 組み込みが再採用されました。Node なしの単一バイナリ制約を満たす唯一の経路だからです。
この間ずっと安定した本番のコントラクトは バンドルの形(export default { fetch })であり、ビルド時のエンジンではありませんでした。zfb がビルド時にレンダリングするのと同じバンドルが、ランタイム SSR 向けに Cloudflare Workers へ変更なしでデプロイされます。
そもそもなぜ JS エンジンが必要なのか — そしてなぜ特に V8 なのか
zfb が JS エンジンを使うのは TSX が JS エンジンを必要とするからではなく、コンパイルされた出力を 評価する ことがそれを必要とするからです。この区別は重要です。
レイヤー 1 — JSX は単なる構文
SWC、oxc、esbuild はいずれも JSX を h(...) 呼び出しにコンパイルします。そのどれも JS エンジンを必要としません。TSX のパースと変換は純粋な構文操作です。だから「TSX をサポートする」ことは論理的に「V8 が必要だ」を意味しません。問題は変換の後に何が起こるかです。
レイヤー 2 — コンパイルされた JS はやはり実行が必要
JSX が h(...) になったら、何かがそのコードを 評価 しなければなりません。呼び出しツリーをたどって VDOM を生成し、次に renderToString を呼んで HTML を生成します。評価はどこかに JS エンジンを必要としますが、必ずしも V8 である必要はありません。軽量な代替が 2 つ存在します。
- Boa(純粋な Rust、約 30 秒の増分ビルド) — C++ ツールチェーン不要で完全に Rust で書かれた JS エンジン。ECMA 仕様を追っていますが、新しめの機能のカバレッジでは V8 に後れを取ります。
- rquickjs(QuickJS への Rust バインディング、バイナリへの寄与は約 210 KB) — コンパイルが速く、フットプリントが最小です。
どちらもシンプルで自己完結した Preact コンポーネントの SSR には十分機能します。問題は「どんな frontend 開発者の TSX でも、彼らが import したあらゆる npm パッケージとともに」です。ユーザーがカレンダーライブラリ、i18n ヘルパー、あるいは Proxy ベースの状態ストアを使うコンポーネントを追加した瞬間、Boa の ECMA の穴と QuickJS の限られた ES2022+ の表面は、静かな誤出力や手痛いパニックを生み始めます。これらは診断が極めて難しい失敗モードです。任意のエコシステムコードに対しては V8 が安全な賭けです。Node.js と Chromium が動作対象とするのと同じエンジンだからです。
両方の軽量エンジンの経路についてはリサーチが進行中です。#344 — 本番ランタイムでのフィーチャーゲート付き V8 と #345 — SSG 専用 JS エンジンとしての Boa / QuickJS を参照してください。
レイヤー 3 — Rust のテンプレーティングは JS を丸ごとコンパイルで消す
Leptos と Yew は JSX に似た構文(RSX)を受け取り、それをビルド時に Rust にコンパイルします。ランタイムにもビルド時にも JS エンジンは不要です。落とし穴は、彼らが借りたのは構文であって言語ではないことです。任意の npm パッケージのための import はなく、ランタイム値を捕捉する JS クロージャもなく、GitHub からそのまま使える Preact コンポーネントもありません。Leptos のプロジェクトは Rust のプロジェクトです。Rust を書き、Rust のクレートに依存します。これはコアな対象読者への約束を破ります。frontend 開発者の既存の TSX と npm の知識が転用できなくなるからです。
対象読者のトレードオフ
zfb の対象読者は、すでに TSX を知っている frontend 開発者です。V8 はその読者への入場料であり、「あなたの既存のコンポーネントがそのまま動く」をベストエフォートの近似ではなく真の言明にしているものです。この読者優先の枠組みについては design philosophy のセクションで詳しく説明します。
純粋な Rust の SSG(Boa/QuickJS)と、ランタイムでの V8 なし(ビルド時のみの V8)は、現実的な将来の方向性です — #344 と #345 を参照してください — が、これらはデフォルトの上の最適化であって、デフォルトそのものではありません。
今日動いているもの
zfb build / zfb dev / zfb preview では、Rust のオーケストレーターが deno_core を介してインプロセスの V8 アイソレートを生成します。それは @takazudo/zfb-runtime が esbuild でビルドする workerd 形のバンドルを読み込みます。ルートごとの props は JSON として渡され、アイソレートは合成された Request でバンドルの fetch エントリを呼び、Response(HTML)を集めて返します。オーケストレーターは素の HTML を dist/ に書き込みます。サブプロセスは生成されず、Node.js も不要です。
同じバンドル(export default { fetch })は、prerender = false ルートのランタイム SSR 向けに Cloudflare Workers へ変更なしでデプロイされます。workerd がリクエスト時にそれを実行します。本番の経路は、どのビルド時エンジンがバンドルを生成したかを関知しません。
必要性
zfb のレンダラーはページの TSX を SWC を通してコンパイルし、その結果の ESM を JS ホストに渡し、モジュールの default エクスポートを呼び、返された HTML をディスクに書き込みます。ホストは次を満たさなければなりません。
- ページが共有コンポーネントを自然に import できるよう、ESM(
import/export)をサポートすること。 - ページがモジュールスコープで
await fetch(...)やawait loadCollection(...)できるよう、トップレベル await をサポートすること。 - スローされたエラーを ソースに忠実な 位置で表面化すること — スタックトレースの行が、ラップされたスクリプト内のオフセットではなく、ユーザーの TSX ファイル内の行に一致しなければなりません。
- 数百ページのビルドにわたって同じモジュールを繰り返し評価しても、メモリをリークしないこと。
ランタイム候補の評価
スパイク用クレート(crates/zfb-runtime-spike/)は、重い V8 ビルドをオプトインにするため cargo フィーチャーでゲートした上で、同じ RenderHost トレイトを 3 つの候補に対して実装しました。次の表は歴史的な文脈です — この評価がランタイムの選択を裏付けました。
候補
deno_core— Deno の再利用可能なコアでラップされた V8。ES モジュールローダー、トップレベル await のためのイベントループ、ソースマップ付きエラーをすぐに備えています。現在の選択。ssr_rs— Preact / React の SSR に特化した薄い V8 ラッパー。スコープが狭く、単一バンドルのエントリポイントモデルです。rquickjs— QuickJS をラップする Rust バインディング。フットプリントが小さく、コンパイルが速く、シングルスレッドです。
もう 2 つは計測的なスパイクなしに却下されました。rusty_v8 は deno_core を通して得られるのと同じ V8 を、ただより低レベルで扱うものです。これを選ぶことは、deno_core がすでに与えてくれるローダーとアイソレートの配管を再実装することを意味します。boa は純粋な Rust の ESM 実装です。ECMA への準拠とソースマップの忠実度が V8 に対して十分に劣るため、実世界の Preact / React の SSR は未対応の隅に当たります。
トレードオフのマトリクス
スパイクのベンチハーネスは 5 つの代表的なシナリオ(静的ページ、動的ルート、コンテンツコレクション、"use client"、トップレベル await)を読み込み、コールドスタート、ウォームの平均、ウォームの p95、定常状態の RSS を計測しました。計測実行からの主要な数値は次のとおりです。
| 軸 | deno_core | ssr_rs | rquickjs |
|---|---|---|---|
| ウォームレンダー | 16us | 1.37ms | 106us |
| コールドスタート | 181us | 5.88ms | 572us |
| 定常状態の RSS | 19MB | 317MB | 4.5MB |
| ESM | native | bundle | partial |
| トップレベル await | native | bundle | not supported sync |
| ソースマップ | native | partial | offsets only |
| ビルドコスト | 約 3 分 | 同程度 | 約 30 秒 |
V8 クラスのエンジンは正しさの軸で勝ち、QuickJS はフットプリントとビルドコストで勝ちます。ssr_rs のデフォルトのレンダリングごとのアイソレート生成は他のすべてを支配し、負荷がかかるとメモリを蓄積します。
zfb が課す制約
2 つのプロジェクトレベルの不変条件が選択を形作ります。
レンダースレッドごとに単一のアイソレート。 V8 のアイソレートは 1 つのスレッドにピン留めされます。レンダラーはホストを !Send として扱い、専用のスレッドで実行し、作業をチャンネル越しに交換します。
ランタイムはバイナリによって固定され、設定では決まらない。 zfb.config.ts は自前の JS ランタイムを選べません。ランタイムは、あなたがインストールした zfb バイナリがコンパイルされたときのものです。これにより zfb とユーザーコードの間のコントラクトが単一値に保たれます — 1 つのバイナリでビルドされたすべてのプロジェクトが同じランタイムを使います。ブートストラップのルールは crates/ にあります。
境界
ホストより上のすべては、crates/ の RenderHost トレイトに対して書かれています。
pub trait RenderHost {
fn execute_module(&mut self, name: &str, source: &str) -> Result<ModuleHandle>;
fn call_default(&mut self, handle: &ModuleHandle, props: JsonValue) -> Result<String>;
fn get_export(&mut self, handle: &ModuleHandle, name: &str) -> Result<JsonValue>;
}
これがレンダラーが使うインターフェースの全体です。zfb-render、zfb-build、そしてフレームワークアダプターは、具体的なランタイムの名前を一切口にしません。呼び出し箇所に触れることなく実装を差し替えられます — 新しいホストを追加し、ベンチを再実行し、アダプターの結合テストを走らせてバイト単位で同一の HTML を確認します。