同じ 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 つのコンパイルのオプションだけで止まっている。 フラグが使えるようになったら、続きを書く。
詳しくは探索メモにまとめてある。