同じ wasm をブラウザーと Worker で動かしたい #

記事を書く画面があったとして、打った Markdown をその場でプレビューしたい。 このブログが記事を描画しているのは、MoonBit の tinymarkdown と el だ。 同じ処理をブラウザーでも動かせば、プレビューは記事のページとずれない。

前の記事に書いたとおり、このブログを wasm で動かしているのはロマンだ。 MoonBit は JS にもビルドできるので、JS にすればブラウザーでも Worker でも動くのは当たり前。 だからここでも JS ではなく、wasm-gc の .wasm だけでやる。

理想の形 #

pub fn render(md : String) -> String

この関数を 1 つ export した .wasm を作る。 Worker はこれを呼んで記事のページを返し、ブラウザーは同じファイルを読み込んでプレビューを描く。 処理を parser.wasm(Markdown から JSON)と html.wasm(JSON から HTML)に分けて、組み合わせてもいい。

ブラウザーでは、つながった #

wasm-gc の MoonBit は、use-js-builtin-string でビルドすると、文字列を JS の文字列のまま扱う。 ブラウザーで builtins: ["js-string"] を付けてコンパイルすると、wasm の import が 69 個から 0 個になった。 MoonBit が生成した JS を 1 行も読み込まずに、render(src.value) の戻り値をそのまま innerHTML に入れられる。

2 つの wasm をつなぐのも、思ったより素直だった。

  • **同じスレッドで呼び合う。** linked.wasm が import として宣言した render に、html.wasm の export をそのまま渡すと、wasm から別の wasm の関数を呼べた。MoonBit の側は fn render(json : String) -> String = "html" "render" と書くだけ。
  • **Worker に分ける。** parser と html をそれぞれ Web Worker に置き、MessageChannel で Worker 同士をつないだ。ブラウザーの中で 2 つの小さなサービスが動いている形になる。

約 210 万文字の Markdown を通すと、同じスレッドの形は処理のあいだ(約 360 ms)ページを止めた。 Worker の形で止まったのは 11 ms だけだった。 打ちながらのプレビューなら同期で数 ms なので、重いときだけ Worker に回せばいい。

Worker では、つながらない #

先に、Worker で wasm がどう動くかを書いておく。

Cloudflare Workers で JS や wasm を動かしているのは、workerd というランタイムだ。 Chrome と同じ V8 を使っていて、オープンソースになっている。 本番の Workers も、手元の wrangler dev も、中で動いているのはこの workerd だ。

ブラウザーでは、ページの JS が WebAssembly.instantiateStreaming(fetch(...), {}, { builtins: ["js-string"], ... }) のように、自分で wasm をコンパイルする。 だから、builtins を付けるかどうかをページの側で決められる。

Worker では、wasm は JS のファイルから import wasm from "./app.wasm" で読み込む。 コンパイルするのはこちらのコードではなく、Worker を読み込むときの workerd だ。 しかも、Workers ではバイト列から実行時に wasm をコンパイルすること(new WebAssembly.Module(bytes, options))も許されていない。 つまり、Worker の側には、コンパイルのオプションを渡す口がない。

そして workerd は、import した wasm を builtins なしでコンパイルする。 同じ .wasm を Worker で import すると、インスタンスを作るところで失敗する。

TypeError: WebAssembly.Instance(): Import #0 "_": module is not an object or function

ブラウザーでは消えた 69 個の import が、文字列リテラルと wasm:js-string の関数として残ったままになるからだ。

このブログの Worker が、線形メモリで文字列を受け渡す小さな ABI を持っているのは、これを避けるためだった。 その結果、ブログの Worker の wasm(ABI の serve を export する)と、ブラウザーの wasm(文字列をそのまま受け渡す)は、別のビルドになる。 同じ MoonBit のコードから作っても、同じファイルにはならない。

ブラウザー用の .wasm と編集ページを、ブログの Worker から配ることはできる。 描画はブラウザーでするので、Worker はファイルを返すだけでいい。 ただしそれは「同じ wasm でつながった」のではなく、別々の wasm が並んでいるだけだ。

つながりそうな道 #

workerd に、互換性フラグ wasm_esm_integration_builtins が入った(PR #7685、2026-10-12 マージ)。 import した wasm を、ブラウザーと同じ js-string builtins を付けてコンパイルするフラグだ。 互換性フラグは、workerd の振る舞いを Worker ごとに選ぶスイッチで、wrangler.jsonc の compatibility_flags に書く。 コンパイルのオプションを渡す口がない代わりに、workerd の側に「builtins を付けてコンパイルする」と頼めるようになる。 これが効けば、ブログの Worker でも文字列をそのまま受け渡せて、ABI はいらなくなる。 そうなれば、Worker とブラウザーで同じ .wasm を使える。

ただ、このフラグはまだ試せていない。 フラグが入った workerd は v1.20261013.1 からで、この記事を書いた時点の wrangler(4.151.1)に入っている workerd はそれより前のものだった。 本番の Workers で使えるかも、まだ確かめていない。

理想ではつながるはずの形が、今は 1 つのコンパイルのオプションだけで止まっている。 フラグが使えるようになったら、続きを書く。

詳しくは探索メモにまとめてある。