探索メモ: wasm-gc の Wasm だけで、ブラウザーの tinymarkdown と el を動かす #
記事の編集ページのようなもの(今はない)で、入力中の Markdown を MoonBit の tinymarkdown で読み、el で HTML にしてその場でプレビューする。これを **wasm-gc の Wasm だけで**できるかを確かめた記録(2026-10-16)。
spike は moonbit/experiments/browser-render に置いた。
何を確かめたか #
MoonBit は JS にもビルドできるので、js ビルドならブラウザーでも Worker でも動くのは当たり前。それでもこのブログは、Wasm で動かすこと自体を目的にしている(Go から MoonBit への「ロマン」)。
なので、ここで確かめたのは **MoonBit が生成する JS を使わず、wasm-gc の .wasm 1 つで足りるか**だ。js ビルドはベースラインとして置いただけで、結論には使っていない。
問いと答えは次のとおり。どの問いも、確かめたのは wasm-gc ビルド(build/lib.wasm)。
| 問い | 答え | 確かめ方 |
|---|---|---|
1. MoonBit が生成する JS なしに、.wasm だけでブラウザーで動くか | **動く** | Chrome 155 で index.html を開き、プレビューが描画された。ページが取ったのは / と /build/lib.wasm だけ(サーバーのログ) |
| 2. 文字列の受け渡しに、Workers 版のような ABI(wire 形式、線形メモリ)が要るか | **要らない** | js-string builtins を有効にしてコンパイルすると、import が 69 個から 0 個になる。render は JS の文字列を受け取り、JS の文字列を返す(Node) |
| 3. Workers から配って動くか | **動く** | wrangler dev --assets . の Static Assets で配り、Chrome で描画された。Worker のコードは書いていない |
4. 同じ .wasm を Worker の中でも呼べるか | **呼べない** | Worker で import すると、69 個の import が残り、インスタンスを作るところで TypeError になる。同じ Worker でベースラインは動くので、Worker の環境の問題ではない |
結論 #
- **ブラウザーでは、wasm-gc の
.wasmだけで足りる。** ページに要る JS は、Wasm をコンパイルし、renderを呼び、戻り値をinnerHTMLに入れる十数行だけ。MoonBit が生成した JS は 1 行も読み込んでいない。 - **文字列はそのまま渡せる。**
"use-js-builtin-string": trueでビルドし、{ builtins: ["js-string"], importedStringConstants: "_" }を付けてコンパイルすれば、Stringを引数と戻り値にした関数をそのまま export すればよい。Workers 版のアダプター(wire 形式)は、workerd が builtins を使えないことへの回避策で、ブラウザーでは要らない。 - **Worker の中では、同じ
.wasmはまだ動かない。** workerd はimportした Wasm を builtins なしでコンパイルするので、文字列の import が解決されない(調査メモと同じ問題)。ブラウザー向けの.wasmとブログの Worker の.wasm(wire 形式のserve)は別のビルドになり、共有できるのは MoonBit のパッケージまで。
spike の形 #
moonbit/experiments/browser-render は、ほかの experiments と同じく独立したモジュールで、moon.work でブログのモジュール(../..)を読む。
lib/lib.mbt:render(md : String) -> String。@tinymarkdown.parseで読み、picoelで要素を組み、@el.render_htmlで文字列にする。lib/moon.pkg: link 設定。wasm-gc は builtins を使う。js はベースライン。
options(
link: {
"js": { "exports": [ "render" ], "format": "esm" },
"wasm-gc": { "exports": [ "render" ], "use-js-builtin-string": true },
},
)index.html: 編集ページ。左の textarea に入力するたびにlib.wasmのrenderを呼び、右に表示する。読み込むのはlib.wasmだけ。baseline.html:index.htmlのlib.wasmを js ビルド(lib.mjs)に替えたもの。try.mjs: Node でlib.wasmを builtins なしとありでコンパイルして import を数え、renderを呼ぶ。最後にベースラインと出力を比べる。worker.mjs、wrangler-worker.json:lib.wasmを Worker でimportして呼ぶ(問い 4)。Makefile:build、try、serve、dev(Static Assets)、dev-worker、screenshot。
render はブログの render_blocks と render_inlines(moonbit/examples/blog/site/views.mbt)を小さく写したもの。見出しは h2 と h3 だけで、リンクの URL は検査しない。本文の文字はすべて @picoel.text を通るので、Markdown に書いた <b> はエスケープされ、出力を innerHTML に入れてよい。
問いごとの結果 #
1. .wasm だけでブラウザーで動くか #
make serve で配った index.html を、headless の Chrome 155 で --dump-dom と --screenshot を付けて開いた。見出し、コード、リンク、リスト、表が描画された。
ページのスクリプトは次の部分がすべて(update は入力のたびに呼ぶ)。
const { instance } = await WebAssembly.instantiateStreaming(fetch("./build/lib.wasm"), {},
{ builtins: ["js-string"], importedStringConstants: "_" });
const { render } = instance.exports;
// ...
document.getElementById("out").innerHTML = render(src.value);python3 -m http.server のログに残ったリクエストは、次の 3 つだけ。
"GET / HTTP/1.1" 200
"GET /build/lib.wasm HTTP/1.1" 200
"GET /favicon.ico HTTP/1.1" 4041 回の描画(render と innerHTML の代入)は 1.7〜6.2 ms だった。計ったのは数回だけで、ベンチマークではない。
2. 文字列の受け渡しに ABI が要るか #
make try の出力。
without builtins: 69 imports (63 from "_", 6 from "wasm:js-string")
with builtins: 0 imports
render: string: <article><section><h2>見出し</h2><p>本文 <code>code</code> と <a href="https://example.com">リンク</a> <b>esc</b></p><ul><li>a</li><li>b</li></ul></section></article>
baseline (js) gives the same HTML: truebuiltins なしでコンパイルすると、文字列リテラル(_ の global、63 個)と wasm:js-string の関数(6 個)が import として残る。builtins を有効にすると、これらはエンジンが埋めるので import は 0 個になり、import オブジェクトなしでインスタンスを作れる。render の戻り値は typeof が string の、JS の文字列そのもの。
3. Workers から配って動くか #
make dev(wrangler dev --assets . --compatibility-date 2026-10-01、wrangler 4.148.0)で同じページを配り、Chrome で開いた。.wasm は Content-Type: application/wasm で返り、問い 1 と同じく描画された。Worker は Static Assets を返すだけで、Worker の JS は書いていない。
--assets . はディレクトリをまるごとアセットにするので、ローカルで確かめる用。そのまま deploy すると _build なども上がる。
4. 同じ .wasm を Worker の中でも呼べるか #
make dev-worker(wrangler 4.148.0)の応答。
{"imports":69,"error":"TypeError: WebAssembly.Instance(): Import #0 \"_\": module is not an object or function","baseline":"<article><section><h2>見出し</h2></section></article>"}workerd は import した Wasm を builtins なしでコンパイルするので、import は Node で builtins なしにしたときと同じ 69 個のまま残り、インスタンスを作れない。同じ Worker の中でベースラインの js ビルドは HTML を返したので、失敗の原因は Worker の環境ではなく、builtins がないこと。
サイズ #
moon build --release の出力そのまま(wasm-opt なし)。
| ビルド | 大きさ |
|---|---|
wasm-gc(lib.wasm) | 25,342 B |
js(lib.mjs、ベースライン) | 94,610 B |
確かめられなかったこと #
- **Safari と Firefox。** 確かめたのは Chrome だけ。
js-stringbuiltins に対応していないブラウザーでは、問い 1 と 2 の答えが変わる。 - **入力しながらの更新。** headless の Chrome で見たのは、ページを開いたときの最初の描画だけ。入力のたびに呼ぶ
updateは同じ関数だが、キー入力を送って確かめてはいない。 - **workerd の互換性フラグ
wasm_esm_integration_builtins**(workerd PR #7685、2026-10-12 マージ)。問い 4 の答えが変わるかもしれないが、今回の wrangler の workerd はマージより前のもので、試していない。 - **大きな文章での速さ。** 試したのは数行の Markdown だけ。
次の一歩 #
1. 編集ページを作るなら、ブログの site.css を当てて、見た目もブログに合わせる。
2. Worker の中でも同じ .wasm を使いたくなったら、wasm_esm_integration_builtins を試す。