zfb
GitHub リポジトリ

検索したい単語を入力

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

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 なしの単一バイナリ制約を満たす唯一の経路だからです。その後 zfb は deno_core を、v8 クレートを直接扱う独自のホストに置き換えました。#4390 でこれがデフォルトになり、#4395 で deno_core のホストが削除されました。V8 は引き続き組み込まれており、変わったのは zfb と V8 の間の層だけです。

この間ずっと安定した本番のコントラクトは バンドルの形(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) — コンパイルが速く、フットプリントが最小です。

当初の JS ホストの評価では、どちらもシンプルで自己完結した 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 では、Rust のオーケストレーターが zfb-js-host クレートの direct V8 ホストを介してインプロセスの V8 アイソレートを生成します。それは @takazudo/zfb-runtime が esbuild でビルドする workerd 形のバンドルを読み込みます。ルートごとの props は JSON として渡され、アイソレートは合成された Request でバンドルの fetch エントリを呼び、Response(HTML)を集めて返します。オーケストレーターは素の HTML を dist/ に書き込みます。サブプロセスは生成されず、Node.js も不要です。

V8 自体はプロセスごとに 1 回、CLI が Tokio やホストのスレッドを起動する前にメインスレッドで初期化されます(zfb-js-host::process::initialize)。1 つのスレッドで同時に生きていられるホストは 1 つだけです。

同じバンドル(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 ファイル内の行に一致しなければなりません。

  • 数百ページのビルドにわたって同じモジュールを繰り返し評価しても、メモリをリークしないこと。

ランタイム候補の評価

いまは削除されたスパイク用クレートが、重い V8 ビルドをオプトインにするため cargo フィーチャーでゲートした上で、同じ RenderHost トレイトを 3 つの候補に対して実装しました。次の表は歴史的な文脈です — この評価がランタイムの選択を裏付けました。

候補

  • deno_core — Deno の再利用可能なコアでラップされた V8。ES モジュールローダー、トップレベル await のためのイベントループ、ソースマップ付きエラーをすぐに備えています。このスパイクで選ばれ、#4395 で direct V8 ホストに置き換えられるまで使われました。

  • ssr_rs — Preact / React の SSR に特化した薄い V8 ラッパー。スコープが狭く、単一バンドルのエントリポイントモデルです。

  • rquickjs — QuickJS をラップする Rust バインディング。フットプリントが小さく、コンパイルが速く、シングルスレッドです。

もう 2 つは計測的なスパイクなしに却下されました。rusty_v8 は deno_core を通して得られるのと同じ V8 を、ただより低レベルで扱うものです。これを選ぶことは、deno_core がすでに与えてくれるローダーとアイソレートの配管を再実装することを意味します。(このコストは後に、v8 クレートを直接扱う direct V8 ホスト(後述)のために意図して受け入れられました。これが現在の zfb の唯一のランタイムです。)boa は純粋な Rust の ESM 実装です。ECMA への準拠とソースマップの忠実度が V8 に対して十分に劣るため、実世界の Preact / React の SSR は未対応の隅に当たります。

トレードオフのマトリクス

スパイクのベンチハーネスは 5 つの代表的なシナリオ(静的ページ、動的ルート、コンテンツコレクション、"use client"、トップレベル await)を読み込み、コールドスタート、ウォームの平均、ウォームの p95、定常状態の RSS を計測しました。計測実行からの主要な数値は次のとおりです。

軸deno_coressr_rsrquickjs
ウォームレンダー16us1.37ms106us
コールドスタート181us5.88ms572us
定常状態の RSS19MB317MB4.5MB
ESMnativebundlepartial
トップレベル awaitnativebundlenot supported sync
ソースマップnativepartialoffsets only
ビルドコスト約 3 分同程度約 30 秒

V8 クラスのエンジンは正しさの軸で勝ち、QuickJS はフットプリントとビルドコストで勝ちます。ssr_rs のデフォルトのレンダリングごとのアイソレート生成は他のすべてを支配し、負荷がかかるとメモリを蓄積します。

このビルドコストの行はワークスペースのビルド時間ではありません

上記の約 3 分という数値は、いまは削除されたスパイク用クレートが deno_core への依存を単体で(1 クレート、1 つの依存グラフとして)コンパイルしたものです。これは なぜ Rust か に記載されている、zfb の ワークスペース全体 の最初の cargo build にかかる 15〜30 分という数値とは異なる計測です。後者はワークスペースのすべてのクレートと V8 をまとめてコンパイルします。この 2 つの数値は、ビルドの対象範囲が異なることを表しています。

zfb が課す制約

2 つのプロジェクトレベルの不変条件が選択を形作ります。

レンダースレッドごとに単一のアイソレート。 V8 のアイソレートは 1 つのスレッドにピン留めされます。レンダラーはホストを !Send として扱い、専用のスレッドで実行し、作業をチャンネル越しに交換します。スレッドごとのガードが同じスレッドで 2 つ目のホストが生きることを拒否し、設定の評価は呼び出しごとに新しいホストを作ります。

ランタイムはバイナリによって固定され、設定では決まらない。 zfb.config.ts は自前の JS ランタイムを選べず、CLI フラグもありません。ランタイムは、あなたがインストールした zfb バイナリがコンパイルされたときのもので、ビルド時の Cargo フィーチャーで決まります。これにより zfb とユーザーコードの間のコントラクトが単一値に保たれます — 1 つのバイナリでビルドされたすべてのプロジェクトが同じランタイムを使います。ブートストラップのルールは crates/zfb/src/config.rs にあります。

境界

ホストより上のすべては、crates/zfb-render/src/render_host.rs の RenderHost トレイトに対して書かれています。

#[async_trait(?Send)]
pub trait RenderHost {
    async fn execute_module(&mut self, name: &str, source: &str) -> Result<ModuleHandle>;
    async fn call_default(&mut self, handle: &ModuleHandle, props: JsonValue) -> Result<String>;
    async fn get_export(&mut self, handle: &ModuleHandle, name: &str) -> Result<JsonValue>;
}

これがレンダラーが使うインターフェースの全体です。zfb-render、zfb-build、そしてフレームワークアダプターは、具体的なランタイムの名前を一切口にしません。呼び出し箇所に触れることなく実装を差し替えられます — 新しいホストを追加し、ベンチを再実行し、アダプターの結合テストを走らせてバイト単位で同一の HTML を確認します。

zfb-render は direct V8 ホストである zfb_js_host::native::NativeHost を EmbeddedV8RenderHost という名前で公開します。そのため zfb-build、dev サーバー、CLI のスレッドアダプターはこの名前を使い、ホストのクレートを直接参照しません。

現在のデフォルトと実験

zfb の JS ランタイムは 1 つだけです。zfb-js-host クレートにある direct V8 ホストです。固定版の v8 クレート(=147.4.0)を直接扱い、モジュール読み込み、Web API サービス、ディスパッチ、dev のリロードを自前で実装しています。#4390 以降はデフォルトの direct-v8 Cargo フィーチャーです。#4395 で、以前の deno_core によるホスト、その embed_v8 フィーチャー、そして deno_core・deno_ops・deno_error・serde_v8 クレートが削除されました。v4.6.0 までのリリース済みバイナリは引き続き deno_core のホストを使い、#4395 以降に切られるリリースは direct ホストを使います。

zfb をソースからコンパイルするときは、Cargo フィーチャーでビルドを選びます。

# デフォルト: direct V8 ホスト
cargo build --locked -p zfb --bin zfb

# スリム: V8 なし
cargo build --locked -p zfb --no-default-features --bin zfb
  • V8 が必要とする ICU 77 のデータは crates/zfb-js-host/icu/icudtl.dat に同梱されており、コンパイル時に埋め込まれます。環境変数もダウンロードも不要です。

  • スリムビルドには V8 がありません。zfb.config.ts は node サブプロセスで評価され(そのため PATH に node が必要です)、zfb build と zfb dev は direct-v8 = off を示すエラーで停止します。

direct ホストの既知の制限:

  • 無限ループに陥ったページのような CPU バウンドの JavaScript を中断することはできません。ホストは CPU 時間の制限もハードなヒープ制限も設けていません。CLI のスレッド化されたホストアダプターは、終了時に V8 スレッドを最大 10 秒待ってから切り離します。切り離されたスレッドはアイソレートのクリーンな終了ではありません。

  • 検証用ハーネスのウォッチドッグは、kill した子プロセスの出力をベストエフォートで回収します(#4289)。これは証跡用ハーネスの制限であり、出荷される CLI の制限ではありません。

ラベルで起動する必須ではない CI レーン(direct-v8.yml)が、Linux x64、Linux arm64、Windows x64 のネイティブ行を記録します。Darwin の行は Mac 上で手作業で実行します。コントリビューター向けの詳細は prototypes/direct-v8/CLI.md にあります。

ページとアイランドは、デフォルトで Rolldown がバンドルします(rolldown-prototype フィーチャーはデフォルトフィーチャーに含まれます)。ZFB_BUNDLER=esbuild を設定すると、これらのバンドルに組み込みの esbuild を使います。zfb.config.ts とプラグインのコンパイルには引き続き esbuild を使います。direct-v8-rolldown レーンは Rolldown を direct V8 と一緒にビルドします。

zfb preview は再ビルドせず、ビルド済み出力を配信します。Cloudflare アダプターでは Wrangler に委譲します。一般に組み込み V8 でページを再レンダリングするコマンドではありません。

Revision History

作成更新