42
2026.09 / Cloudflare Browser Run

Kitesurf を自作してわかった、JS エンジンを 2 つ積む理由

発表記事に挙がっているクレートで、Kitesurf と同じものを組んだ。ページの JavaScript を Boa に任せているのはなぜか、V8 だけでは組めないのかを、両方組んで比べた。

2026 年 8 月 6 日に Cloudflare が Kitesurf を発表しました。Chromium のバイナリを一切使わず、Rust で書いたブラウザエンジンを Wasm にして Workers の V8 isolate の上で動かします。エージェントが欲しいのはスクリーンショットと HTML の抽出くらいなのに、そのために Chromium のメモリと CPU を丸ごと払っている。だから必要な部分だけ作った、と書いてあります。

isolate には OS も、ファイルシステムも、スレッドも、システムフォントもありません。そこにブラウザを置こうとするのは、いい意味で変態です。Workers にブラウザを載せようと考える人がいる、というのが面白いと思いました。

売りは CPU とメモリで、Chromium の 3〜7 分の 1。速さは 1.7 倍遅いと書いてあります。Browser Run で測れるのはその wall time だけで、課金メーターから読めます。CPU とメモリは、使用量の API にもページの中にも CDP にも出てきません。

測れないなら、同じものを自分のアカウントで動かして測ればいい。使っているクレートの名前は発表記事に全部書いてあります。

以下、Cloudflare のものを Kitesurf、この記事で組んだものを 疑似 Kitesurf と書きます。

疑似 Kitesurf動いているもの。任意の URL を入れて撮れるnot-kitesurf.aiji42.dev aiji42/browser-on-workersこの記事で組んだコードgithub.com

Kitesurf を構成するライブラリ

発表記事は、使っているクレートを名前で挙げています。それをそのまま使いました。土台は Blitz で、blitz- で始まるものは全部その一部です。

役割 使ったもの
HTML のパース html5ever (blitz-html 経由)
DOM blitz-dom
CSS Stylo — Firefox の CSS エンジン
レイアウト Taffy
テキスト整形 Parley
描画 blitz-paint + vello_cpu
JavaScript Boa (blitz-vibey-script 経由)
画像のデコード image

並べたクレートを wasm32-unknown-unknown に向けてビルドして、Worker から呼びます。PNG のエンコーダだけは自分で書きました。Workers に画像を書き出す API が無いので、行ごとにフィルタ種別のバイトを挟み、CompressionStream('deflate') に通し、CRC32 を付ける。100 行ほどです。

意外だったのは Stylo が何の対応もなしに wasm32 に載ることでした。並列トラバースが rayon を要求するので苦労すると踏んでいたのですが、blitz-dom の StyleThreading は既定が Sequential で、はじめから単一スレッドで動きます。CSS のパースとカスケードは OS に依存しない計算なので、むしろ載って当然でした。

JavaScript を実行するクレートが crates.io に公開されていません。 発表記事が Boa を使うと書いているので、Blitz 側にその橋渡しがあるはずだと探すと、blitz-vibey-script というクレートが Blitz の workspace の中にだけあります。公開されている 0.3.0-beta.2 のリリースコミットより後に追加されたもので、cargo add はできません。upstream の特定のコミットから vendor/ にコピーして使いました。手を入れた状態でも、既存の 6 本の描画結果が公開版と 1 バイトも変わらないことを確認しています。

つまり Kitesurf も同じ状況にいるはずです。「Boa を使っている」という記述の裏にあるクレートが、公開されたバージョンには入っていない。

なお、Chromium と見た目を完全に一致させるのは目標にしていません。Kitesurf 自身も Chromium とはずれます。エージェントに読ませるための画像なので、読めれば足りることにしました。

Kitesurf の特徴的な JS エンジンの使い方

JavaScript の置き方だけ、Kitesurf は変わっています。

Workers で evalnew Function を呼ぶと EvalError: Code generation from strings disallowed for this context になります。一方で、ページに eval が書かれていればブラウザはそれを実行しなければなりません。土台の V8 が禁じているので、文字列からコードを作る役は別のエンジンに任せるしかありません。

発表記事の説明はこうです。

For each found <script> tag or .wasm file we run the JavaScript and WebAssembly code inside the same isolate. (…) Our solution is to use Boa JS, an ECMAScript engine written in Rust, to compile and run on Workers. We are basically executing a runtime on top of a runtime, which doesn’t seem optimal, and it isn’t, but it works well enough to handle the occasional evals we find in the code.

見つかった <script> タグや .wasm ファイルは、同じ isolate の中で JavaScript と WebAssembly として実行する。(中略) 私たちの解決策は、Rust で書かれた ECMAScript エンジンである Boa JS を Workers 上でコンパイルして動かすことだ。要するにランタイムの上でランタイムを走らせているわけで、最適とは思えないし実際そうではないが、コードの中にときどき現れる eval を扱うには十分に機能する。 Introducing Kitesurf: The agent-first browser that runs in V8 isolates on Cloudflare Workers, Cloudflare Blog, 2026-08-06

つまり エンジンが 2 つです。ページのコードは V8 が実行し、evalnew Function に渡された文字列だけが、同じ isolate の中で Wasm として動く Boa に回ります。

Workers の isolate = V8そのまま書いたコードV8 がそのまま実行文字列で渡したコードeval / new FunctionV8 には渡せないEvalErrorWasm モジュール (Rust)BoaRust で書かれた JS エンジン同じ isolate の中で Wasm として動く文字列からコードを作る役だけが、Wasm の中の Boa に回る
FIGKitesurf の中の分担。ページのコードは V8 が実行し、eval や new Function に渡された文字列だけを、同じ isolate の中で Wasm として動く Boa が引き受ける。

Kitesurf の中で、Boa は 226 倍遅い

しかし、Boa は速くありません。Kitesurf で開いたページの中で 20 万回のループを 3 通りの書き方で実行し、どの書き方でも計算結果が一致することを確認したうえで測りました。

書き方ChromiumKitesurf
素のコード0.31 msV80.36 msV8
new Function で作った関数の呼び出し0.35 msV881.8 msBoa
eval (毎回コンパイルから)74.2 msV8571.5 msBoa
ページの中で計測。Workers の Date.now() は I/O の無い区間で進まず、Kitesurf の中では 3 ミリ秒刻みでしか動かないので、合計 300 ミリ秒を超えるまで繰り返して 1 回あたりを割り出した

オレンジのセルだけ Boa が答えています。素のコードは Chromium とほぼ同じで、1.2 倍しか違いません。

注目してほしいのは new Function の行です。Chromium ではこの書き方でも遅くなりません。V8 は 1 度コンパイルしたあとを通常のコードと同じように最適化するので、0.35 ミリ秒は素のコードと変わりません。Kitesurf では 81.8 ミリ秒かかります。同じページの素のコードより 226 倍遅い。

実行時に new Function を使うライブラリは珍しくありません。テンプレートエンジン、実行時コンパイラを持つフレームワーク、スキーマから検証関数を組み立てるライブラリ。Chromium ではただで済んでいたものが、ここでは 200 倍以上遅くなり、しかも Boa に入ったコードは Error.stack を持たず、globalThis から見えるプロパティも 478 個から 59 個に減ります。例外は 1 つも出ません。

ブラウザエンジンをページごとの Dynamic Worker に置く

Kitesurf は、ページを開くたびに Dynamic Workers で PageScript の isolate を立てます。発表記事によれば、その isolate は長く生き続けてページのセッションを受け持ち、中にはきれいな globalThis と DOM の document があります。

疑似 Kitesurf も同じ形で組みました。 ブラウザエンジンは 1 つの Wasm にまとめ、ページの JavaScript は Boa。どちらも子 Worker の handler の中で走ります。

本体の WorkerDynamic Worker — 1 ページにつき 1 つ立てる1 つの Wasm モジュールBoa … ページの JavaScriptblitz-dom … DOMStylo / Taffy … CSS とレイアウトblitz-paint … 描画発表記事でいう PageScript と PageRenderer はここ外向きの口発表記事でいう SandboxOutbound外のサイトここだけ isolate の外ブラウザエンジンは 1 つの Wasm。ページを閉じれば、子 Worker ごと消える
FIGどのクレートがどこで動くか。DOM も CSS もレイアウトも描画も JavaScript も 1 つの Wasm モジュールの中にあって、それをページごとの子 Worker の中で動かす。外に出る口だけ本体の Worker に置いてある。

子 Worker にすると、ページごとに isolate が分かれます。ページを閉じればメモリも一緒に消え、隣のページの globalThis も見えません。スクリーンショットを 1 枚撮るだけなら本体の Worker の handler でも足ります。セッションを張って同じ Worker で何ページも開くなら、ここが分かれていないと困ります。

Dynamic Worker は worker_loaders binding で、実行時に別の Worker のコードを読み込んで起動する仕組みです。open beta なので、有料プランなら誰でも使えます。

ページごとに立てられるのは、Wasm の渡し方のおかげです。Wasm を動かすには、バイト列をコンパイルして WebAssembly.Module を作り、そこからメモリを確保して動く実体を起こす (instantiate) という 2 段が要ります。エンジンは 15 MB あるので、ページごとにコンパイルからやっていたら間に合いません。modules にはコンパイル済みの WebAssembly.Module をそのまま渡せるので、子 Worker がやるのは instantiate だけになり、立ち上がるまでが 170 ミリ秒で済みます。Static Assets から 15 MB を読んで ArrayBuffer で渡す形も試しましたが、こちらは子 Worker がコンパイルからやることになって 1567 ミリ秒、9 倍かかりました。

HTML を解釈しながら、足りないリソースを取りに行く

blitz-dom は、HTML や CSS を読んでいる途中でまだ手元に無いリソースの URL を見つけると、ブラウザエンジンの外へ要求を出します。実装ではその要求にその場で答えず、URL を控えるだけにして最後まで読み切ります。読み切ったところでエンジンは止まり、控えた URL の一覧を Dynamic Worker の handler が受け取って、まとめて並列に fetch し、届いたバイト列をエンジンに返します。エンジンは同じ document のまま、届いた CSS のスタイル解決から続けます。

エンジンが読む(Wasm の中)そこに書かれていた URL のうちまだ手元に無いものDynamic Worker の handler がまとめて fetch1 往復目HTMLlink, img, script srcCSS・画像・スクリプトの URLHTML に書かれている分まとめて渡すまとめて並列に fetchバイト列をエンジンに返す2 往復目届いた CSSカスケードを解く背景画像・フォントの URLurl() と @font-face からHTML には出てこないまとめて並列に fetch終わり届いた画像とフォント新しい URL は出ない描く。1 回だけページの JavaScript とタイマーを流してからURL を渡したエンジンは止まって待ち、届いたら同じ document のまま続きを読む
FIGリソースの URL は 2 段で見つかる。HTML に書かれた URL を取ると、届いた CSS の中から次の URL が出る。画像とフォントからは出ないので、試したページはどれも 2 往復までで止まった。

MDN の font-family を撮ると、1 往復目が 22 本、2 往復目が 31 本でした。1 往復目の 22 本は CSS 18 枚とスクリプト 4 本で、このページの HTML には <img> が 1 つもありません。ナビゲーションもロゴもメニューのアイコンも、2 往復目に出てくる CSS の url() から来ます。「HTML を先に読んで、必要なものを並列で取ってから描く」という素朴な作りでは、このページは崩れたまま描かれます。

子 Worker のネットワークは、専用の口に向ける

Dynamic Worker の globalOutbound に Fetcher を渡すと、子の fetch が全部そこに届きます。 URL はページが要求したものそのままです。子のコードにネットワークの権限を持たせず、大きさの上限も自分のオリジンの拒否も親の 1 箇所に置ける。Kitesurf の SandboxOutbound がこの位置です。

Boa でも、実際のページは動く

リソースが揃えば、次はページの JavaScript です。ここを担うのは Boa です。Rust で書かれた JavaScript のインタプリタで、ブラウザエンジンと同じ Wasm の中に入っています。結論から言うと、React のハイドレーションも、HTML の中が空の SPA も動きます。

速度より先に問題になるのは、止められないことです。Boa には実行を中断する仕組みがありません。ページのスクリプトが暴れると Worker ごと殺されるので、ループの回数と再帰の深さに上限を設定する必要があります。

React はその上限に触れませんでした。 reconciler は while ループで回っていて再帰しないので、DOM の木がどれだけ深くても呼び出しの深さは増えません。深さ 155 までは通って 160 で止まる設定で、react.dev を描いても RuntimeLimitError は 1 度も出ませんでした。

react.dev は 14 本のスクリプトが全部エラー無しで走り、ハイドレーションも通りました。

1000×780

react.dev のトップページ。React のロゴ、見出し、The library for web and native user interfaces、Learn React と API Reference のボタン、その下の本文が出ている。ロゴだけ枠からはみ出して大きい。
FIG疑似 Kitesurf が描いた react.dev。ロゴが大きいのは、レイアウトは正しい大きさを出しているのに描画がその箱を超えるため。

226 倍遅いインタプリタでも、React の初期化は最後まで走ります。

サーバが吐いた HTML があるページも、HTML の中が空で全部スクリプトが描くページも、同じように描けます。

1000×780

TodoMVC のページ。todos の見出しと、カーソルの入った入力欄、その下に Double-click to edit a todo などの説明が出ている。
FIG疑似 Kitesurf が描いた todomvc.com の React の例。HTML の中は空で、見えているものは全部ページのスクリプトが描いた。

描けなかったページもあります。regex101 は、サイトのほうが「このブラウザは非対応です」と表示します。

Debug results: Worker=false, Promise=true, WASM=false

エンジン自体が Wasm で動いているのに、ページからは WASM=false に見える。Web Workers も出していません。02 章に引いたとおり、Kitesurf は見つけた .wasm も同じ isolate で実行します。ここは埋まっていない差です。

失敗が音を立てない

普通のブラウザなら DevTools のコンソールに出るものが、ヘッドレスのエンジンには出口がありません。失敗が何も残らない経路が 3 つありました。

黙るもの どう黙るか
ライブラリが自分で握る失敗 例外を投げずに console.error へ流す。React のハイドレーションの不一致がそれで、hydrateRootonRecoverableError の既定がこの形。エンジンのエラー一覧には入らない
Boa の実行上限 RuntimeLimitError はページの try で捕まらない。Boa はこれを EngineError として扱い、VM は catch を探さずに全フレームを巻き戻す。そのスクリプトの残りは飛び、後続の <script> だけが走る
タイマーの打ち切り 回数や予算の上限に引っかかっても、何も記録しない

どれも JavaScript のエラーは 0 件のままで、スクリーンショットにも出ません。ページの側が使う通知経路 — console.erroronRecoverableError — をエンジンが拾えないと、そこで起きたことはエンジンの観測範囲の外に出ます。だから console.error を控えて document の中に書き出させ、そのまま描いて読めるようにしました。

差は速さではなく、描けるかどうかに出た

Kitesurf と疑似 Kitesurf に同じページを同じ時刻に撮らせました。撮ったのは Cloudflare のブログのトップです。

1470×612

Cloudflare のブログのトップページを 2 つのエンジンで描いた画像を横に並べたもの。見出し、本文、日付、記事のイラストはほぼ同じ位置に出ている。右側だけ上部にロゴとハンバーガーがあり、左側だけ横方向の破線が見えている。
FIG左が Kitesurf、右が疑似 Kitesurf。同時刻に 1000×780 で撮影。文字の位置と折り返し、イラストの中身まで一致している。

差は 2 つだけです。疑似 Kitesurf は上部のロゴとハンバーガーを出していて、Kitesurf はそこが白いまま。逆に Kitesurf にある横方向の破線が、疑似 Kitesurf では端の切れ端しか出ていません。

所要時間も測りました。16 の URL に 3 者を同時刻に叩いて、各 3 回の中央値です。

ページ Chromium Kitesurf 疑似 Kitesurf
example.com 1000 ms 854 ms 1100 ms
news.ycombinator.com 1722 ms 2064 ms 1661 ms
aozora.gr.jp 1038 ms 1949 ms 2260 ms
gnu.org 1591 ms 3466 ms 3132 ms
MDN Array.prototype.map 1007 ms 4785 ms 2668 ms
MDN 日本語版の CSS 1433 ms 3239 ms 3574 ms
docs.python.org 1755 ms 1206 ms 1740 ms
developers.cloudflare.com 2021 ms 4572 ms 3531 ms
react.dev 1099 ms 3902 ms 9432 ms
vuejs.org 1417 ms 5693 ms 3168 ms
astro.build 1567 ms 3171 ms 5670 ms
tailwindcss.com 1252 ms 3962 ms 7422 ms
todomvc.com 1763 ms 1776 ms 1958 ms
ja.wikipedia「メインページ」 1921 ms 7503 ms 3910 ms
zenn.dev 1540 ms 4954 ms 4230 ms
qiita.com 3256 ms 7514 ms 描けない
手元から測った wall time、1000×780、各 3 回の中央値、2026 年 9 月 9 日に 3 者同時刻で撮影

疑似 Kitesurf は本家と同じくらいでした。 ページごとの比は中央値 1.10 倍です。ただし 0.52 倍から 2.42 倍まで散ります。MDN と ja.wikipedia では倍近く速く、react.dev では 2.4 倍遅い。どちらが速いとは言えません。

Chromium との差ははっきり出ます。課金メーターで比べると Kitesurf は Chromium の中央値 2.72 倍で、docs.python.org の 0.92 倍から MDN の 7.48 倍まで開きます。CSS とスクリプトが多いページほど悪くなります。

描けないページもありました。表にある qiita.com は 3 回とも Invalid array buffer length で止まります。表には入れていませんが、ja.wikipedia.org/wiki/コーヒー も 300 秒経っても返ってきません。どちらも原因を突き止められていません。

同じクレートを使っているなら Kitesurf も同じところで止まるはずだと思って、Browser Run で撮ってみました。止まりません。

ページ Kitesurf 疑似 Kitesurf
qiita.com 課金 6400 ms で描ける Invalid array buffer length
ja.wikipedia の「コーヒー」 課金 8691 ms で描ける 300 秒経っても返らない

クレートの名前は同じでも、バージョンや駆動の仕方まで同じにはなりません。公開情報だけで組むと、こういう細部で差が出ます。

Workers の V8 でも、eval が動く場所はある

速さで差が出ないなら、次に気になるのは中身です。ページの JavaScript は、素のコードより 226 倍遅いエンジンで走っています。それを引き受けている理由は、Workers が文字列からコードを作らせないという 1 点だけです。

02 章で evalnew FunctionEvalError になると書きましたが、通る区間があります。モジュールを評価している最中です。 Worker が起動して最初の 1 度だけ走るところです。

// probe は渡された関数を try/catch で呼んで、返り値か例外の文言を返すだけ
// ここはモジュールの評価中。Worker が起動したときに 1 度だけ走る
const evalAtStartup = probe(() => eval('1+1')); // ok: 2
const fetchAtStartup = probe(() => fetch(SOME_URL)); // Error: Disallowed operation
export default {
async fetch() {
// ここは handler の中。リクエストが来るたびに走る
const evalInHandler = probe(() => eval('1+1')); // EvalError: Code generation from strings disallowed
const fetchInHandler = probe(() => fetch(SOME_URL)); // ok: Response
return Response.json({ evalAtStartup, fetchAtStartup, evalInHandler, fetchInHandler });
},
};

Dynamic Worker のコードとして同じものを渡しても、4 つの結果は 1 つも変わりませんでした。境目は Dynamic Worker かどうかではなく、モジュールの評価中かどうかです。

同じ 2 つの区間で、ブラウザが要るものを 1 つずつ試しました。

eval fetch setTimeout Date.now()
モジュールの評価中 通る Disallowed operation Disallowed operation 0 を返す
handler の中 EvalError 通る 通る 実時刻

表を見てのとおり、排他になっています。 ブラウザに要る仕事は 3 つあります。

1 つ目は評価中でしか、あとの 2 つは handler でしかできません。

Kitesurf はページを解釈しながらリソースを取りに行くので、3 つとも handler の中で起きます。そこで eval の代わりに Boa を持ち込んでいる、というのが Kitesurf の分担の理由です。

いつ走るか (上から順)evalfetchそこでやること本体の Worker のモジュールの評価中起動時に 1 度だけここでやることは無い1 度きりで、ページごとには使えない本体の Worker の handler の中リクエストが来るたびリソースを先に全部取る取ったリソースとページの <script> を渡すDynamic Worker のモジュールの評価中1 ページにつき 1 つ、新しく開くページの <script> を V8 で実行setTimeout は積んで、最後に 1 回だけ描くfetch が要る仕事を handler で済ませてから起動すれば、残りは評価中に収まる
FIGeval が通る区間と fetch が通る区間は重ならない。本体の Worker の handler でリソースを先に全部取り、それを渡して Dynamic Worker を立てると、そのモジュールの評価中でページの JavaScript を V8 で実行できる。スクリーンショット 1 枚なら、handler に入らずに終わる。

順番を変えれば V8 で通ります。fetch が要る仕事を本体の Worker の handler で全部済ませてから、取ったリソースとページの <script> を渡して Dynamic Worker を立てる。新しい Worker なのでモジュールの評価中がもう 1 度始まり、そこでは eval が通ります。本体の Worker の評価中は起動時の 1 度しかないので、ページごとに新しい評価を起こせる Dynamic Worker がここで効きます。

ここで、02 章に書いたことが半分崩れます。V8 が eval を禁じているから文字列を扱う役は別のエンジンに任せるしかない、と書きましたが、禁じられているのは handler の中だけでした。

Boa は同じページの素のコードより 226 倍遅いので、外せるなら外したい。Kitesurf とは違う形になりますが、Boa が本当に要るのかは、組んでみないと分かりません。 次の章で組みます。

全部 V8 で組んでみる

前の章の順で組みます。本体の Worker の handler でリソースを全部揃え、ページの <script> と一緒に Dynamic Worker へ渡す。Dynamic Worker のモジュールの評価中は eval が通るので、ページのスクリプトは V8 が実行します。new Function も通り、速度も V8 のものです。

リソースは Dynamic Worker を立てる前に揃えます。HTML に書かれた URL を取ったら、本体の Worker のブラウザエンジンに 1 度通して、まだ手元に無い URL を吐かせる。背景画像とフォントの URL は届いた CSS の中からしか出てこないので、取ってまた通す、を新しい URL が出なくなるまで繰り返します。掛け合わせではエンジンが document を開いたまま待てましたが、評価中には fetch がないので、代わりに本体の Worker でエンジンを回します。

揃ったものは全部モジュールとして渡します。Dynamic Worker への入力は I/O では渡せないので、モジュールの種類を使い分けます。

Dynamic Worker に渡す modules評価される順engine.wasmコンパイル済みの WebAssembly.Modulepage.htmltext モジュールres0.bin … / font0.ttf …data モジュール。handler で取ったものsources.jstype の無い <script> の文字列m0.js …<script type=module> の本体モジュールの評価中 — 起動時に 1 度01setup.jsinitSync でエンジンを起こし、DOM を開く<script> は (0, eval) で文書順に02pagemods.js<script type=module> はモジュールとして評価03finish.jsタイマーを流して 1 回だけ描くhandler の中 — リクエストが来るたび04entry.js の fetch()出来上がった結果を返すだけ下が上を import するので、評価の順は import の依存で決まる。fetch も setTimeout も出てこない
FIGDynamic Worker に渡すモジュールと、評価される順。ブラウザエンジンはコンパイル済みの WebAssembly.Module、HTML は text モジュール、取ってきたリソースは data モジュールとして渡す。準備・ページのスクリプト・描画の 3 本は import の依存で順を固定してあって、全部がモジュールの評価中に終わる。handler は出来上がった結果を返すだけ。

instantiate からページのスクリプトの実行、描画まで、全部がモジュールの評価中に終わります。リクエストが届いた時点で描画はもう済んでいて、handler は出来上がった結果を返すだけです。

全部が起動時に入るので、Workers の起動時 CPU の上限に引っかかります。ページのループの回数を上げていくと、8.5 億回 (8.6 秒) は通り、10 億回で Script startup exceeded CPU time limit. が出ました。境目は 9〜11 秒で揺れます。スクリーンショット 1 枚には足りますが、セッションを抱えたブラウザを起動時に収めることはできません。

全部を評価中に収めるために、決めたことが 3 つあります。

評価の順は、import の依存で固定する

モジュールが評価される順は、書いた順ではなく import の依存の順です。入口から 2 本を並べて import しただけでは、どちらが先かは決まりません。どのモジュールも同期なら書いた順になりますが、片方に top-level await があるとそこは非同期モジュールになり、兄弟は待ちません。

だから描く側が準備する側を import する形にして、準備する側からは top-level await を外しました。wasm-bindgen は同期の初期化を出しているので、15 MB のエンジンでも評価中に instantiate できます。

glue.initSync({ module: wasm });

<script type="module"> を文書順に走らせるのにも、この性質が要ります。

<script> は間接 eval で走らせ、type="module" はモジュールとして置く

type の無い <script> をモジュールにして渡すと、スコープが壊れます。ブラウザのクラシックスクリプトはグローバルスコープで走るので、var xfunction f()window に乗って後のスクリプトから見えます。モジュールにすると 1 本ずつ別のスコープになります。

走らせる場所はモジュールの評価中なので、eval が通ります。間接 eval で走らせれば、ブラウザと同じグローバルスコープになります。

for (const src of SOURCES) (0, eval)(src);

type="module" は逆で、間接 eval に流すと export の行で構文エラーになります。こちらは Dynamic Worker のモジュールとして置き、V8 にそのままコンパイルさせます。ただし import の指定子が 1 本でも解決できないと、Dynamic Worker はまるごと起動しません。webpack の runtime チャンクのように <script src> として HTML に出てこないファイルがあるので、import をたどって揃うものだけモジュールにして、揃わないものは間接 eval に回します。

タイマーは積んで、あとで流す

モジュールの評価中は setTimeout も禁止です。スクリーンショットを 1 枚撮るだけなので、待つ意味もありません。積んで最後にまとめて流します。

React のスケジューラは、仕事をマクロタスクに逃がすのに setImmediateMessageChannelsetTimeout の順で使えるものを探します。workerd は setImmediate を持っているので、塞がないと仕事がそこへ消えます。3 つとも自前のキューに寄せました。React は起きて少し進めてまた積むので、1 周では終わりません。await Promise.resolve() を挟みながら 12 周まで流すと、React が DOM を組みました。await は I/O ではないので、評価中でも使えます。

ページの側から確かめると、new Error().stack が文字列で返り、(1)() のエラー文言が V8 のもので、eval("6*7") が 42 を返します。02 章の表でオレンジのセルだった書き方が、ここでは V8 で走っています。

V8 + Boa と、V8 だけを比べる

V8 だけ先に全部渡すV8 + Boa解釈しながら取りに行く繰り返す (実測 1〜2 回)解釈する描く足りない URL が残る最後の 1 周だけが残る前の描画は捨てる描いてから足りない URL が分かるので、描画も繰り返しに入る繰り返す (実測 2 回)解釈する取りに行く要求が出なくなったら描く1 回だけ描く前に揃うので、繰り返しに入るのは取得だけ
FIG繰り返しの中に「描く」が入るかどうかの違い。先に全部渡す形では、描いてはじめて足りない URL が分かるので、描画も繰り返しに入る。解釈しながら取りに行く形では、描く前に揃うので繰り返すのは取得だけになる。

Kitesurf と同じ V8 + Boa の構成と、Boa を外して V8 だけにした構成に、06 章と同じ 16 の URL を通しました。2 つを同時刻に叩いて、各 3 回の中央値です。

ページ V8 + Boa V8 だけ 捨てる描画
example.com 1487 ms 1647 ms 1 回
news.ycombinator.com 2505 ms 2637 ms 2 回
aozora.gr.jp 2567 ms 3215 ms 1 回
MDN Array.prototype.map 3238 ms 5191 ms 2 回
MDN 日本語版の CSS 3831 ms 4589 ms 2 回
docs.python.org 2809 ms 1981 ms 2 回
developers.cloudflare.com 3824 ms 4666 ms 2 回
react.dev 15649 ms 8658 ms 2 回
vuejs.org 3630 ms 4235 ms 4 回
astro.build 5804 ms 15562 ms 2 回
tailwindcss.com 7373 ms 13721 ms 2 回
todomvc.com 3000 ms 2334 ms 1 回
ja.wikipedia「メインページ」 4091 ms 7169 ms 2 回
zenn.dev 6931 ms 2698 ms 2 回
qiita.com 描けない 9352 ms 2 回
手元から測った wall time、1000×780、各 3 回の中央値、2026 年 9 月 9 日に 2 者同時刻で撮影。gnu.org は上流が 522 と 503 を返したので外した

どちらが速いとは言えませんでした。 ページごとの比は中央値 1.18 倍で、V8 だけのほうがわずかに遅い。ただし 0.39 倍から 2.68 倍まで散ります。所要時間の大半は上流のサイトからの取得で、JS エンジンの違いはその揺れに埋もれます。226 倍速いエンジンに替えても、スクリーンショットが返る時間は変わりません。

差が出るのは払うものの形です。V8 だけの構成は、何が必要かを知るために「捨てるための描画」を先にやります。多くのページで 2 回、vuejs.org は 4 回でした。V8 + Boa にはこれがありません。

描けないページも入れ替わります。06 章で V8 + Boa が落ちた qiita.com を、V8 だけの構成は 9.4 秒で描きます。

CPU では並べられませんでした。Dynamic Worker の中の実行は wrangler tail に出てきません。 2 億回のループを子 Worker で回しても、親の cpuTime は 24 ミリ秒で、wall time は 1334 ミリ秒です。子 Worker を使う構成の cpuTime は親の分しか表さないので、同じ土俵に乗りません。

CDP を実装するなら、Boa は外せない

全部 V8 で動きました。速度も V8 のもので、eval も通ります。それでも Kitesurf の掛け合わせのほうが正しい、という結論になりました。理由は 1 つです。

Puppeteer の page.evaluate は、CDP の Runtime.evaluate に JavaScript の文字列を載せて WebSocket で送ります。届くのは handler で、そこでは V8 の evalEvalError になります。

Dynamic Worker のモジュールの評価中 — 起動時に 1 度まだ何も繋がっていないブラウザエンジンを instantiatedocument はまだ無いCDP クライアント (Puppeteer など)Dynamic Worker の handler の中 — セッションが続く間isolate と document は生きたままV8 の eval はここでは EvalError文字列を評価できるのは Wasm の中の Boa だけWasm モジュール — Boa と blitz-dom01Page.navigateURL を渡すHTML を解釈しながらリソースを取る06 章と同じ 2 往復02Runtime.evaluatepage.evaluate が渡す JavaScript の文字列Boa が文字列をその場で評価するScriptDocument::eval03Input.dispatchMouseEvent座標を渡すノードを引いて DOM イベントを起こすdispatch_dom_event04Runtime.evaluate閉じるまで何度でも届くBoa が文字列をその場で評価するScriptDocument::eval命令は 1 つも起動時の区間には届かない。全部が handler に来て、Wasm の中に渡る
FIGCDP を話すときの流れ。Puppeteer などのクライアントは Worker が起動したあとに繋ぎ、命令はセッションが続く間ずっと handler に届く。handler では V8 の `eval` が `EvalError` になるので、`Runtime.evaluate` の文字列を評価できるのは Wasm の中の Boa だけ。受け口は vendor したクレートに揃っている。

クライアントが繋いでくるのは起動が終わったあとで、そこから届いた文字列を評価できるのは、Wasm の中のインタプリタである Boa だけです。全部 V8 の形は、1 枚のスクリーンショットなら成立しますが、セッションを張って外から命令を受けるブラウザには使えません。

Kitesurf は CDP を話すブラウザです。Engine Worker、PageScript、PageRenderer、SandboxOutbound の 4 つに分かれて RPC で繋がっている作りも、そこから来ています。ページを解釈しながらリソースを取りに行くために、解釈する側を Dynamic Worker にする。その子から外に出るために、外向きの口を親のコンポーネントに分ける。どちらも好みで選んだ形ではなく、制約から出てきた形です。

図に描いたもののうち、疑似 Kitesurf にまだ無いのは 2 つです。セッションの器 (WebSocketPair と Durable Object) と、座標からノードを引くヒットテスト。Page.navigate のためのリソース取得は、いま組んであるものがそのまま使えます。

受ける側の口は、vendor したクレートに揃っていました。図の右の箱に添えた関数がそれで、ほかに run_due_timers が時間を進める役です。Boa を積んだ時点で、CDP を話す準備はできているという作りになっています。

まとめ

Kitesurf は V8 と Boa を組み合わせています。ページのコードは V8 が実行し、evalnew Function に渡された文字列だけが Wasm の中の Boa に回る。組んでいる途中で、その Boa を外せることに気づきました。eval が禁じられているのは、リクエストを処理している最中だけでした。モジュールの評価中なら通ります。V8 だけにすれば 226 倍速い。

外して比べたら、スクリーンショットが返る時間は変わりませんでした。所要時間の大半は上流からの取得で、JS エンジンの速度はそこに埋もれます。代わりに、リソースを先に全部揃えるための描画が増えました。多くのページで 2 回です。

そして CDP を実装する段になると、V8 だけでは組めません。命令は起動後に handler へ届きますが、eval が通るのは起動時だけです。V8 + Boa は妥協ではなく、eval が使える場所とブラウザが必要とする場所のずれに対する答えでした。

測れなかったものも残りました。CPU とメモリの比較は最後まで手が届かず、Dynamic Worker の中の CPU も wrangler tail には出てきません。描けないページも残ったままです。

一次情報

公開 2026年9月8日