調査メモ: MoonBit の Wasm を Cloudflare Workers で動かす #

TODO.md の「Reimplement the blog in MoonBit」の前段の調査。ブログを移植する前に、MoonBit の Wasm バックエンド(wasm-gc と wasm)でビルドしたものを Workers から呼べるか、文字列をどう受け渡すかを、小さな spike で確かめた。 spike はリポジトリには入れておらず、要点のコードを末尾に載せた。 その後、spike を moonbit/ に取り込み、wrangler dev で動くところまで進めた(「moonbit/ への取り込み」の節)。

結論 #

  • MoonBit を Workers に載せる公式の手順や定番のライブラリ(Go の syumai/workers-go に当たるもの)は見つからなかった。JS の薄い Worker(worker.mjs)から Wasm を import して呼ぶ形を自分で書く。
  • **use-js-builtin-string を使った wasm-gc は、そのままでは Workers で動かない。**
    • JS の文字列をそのまま受け渡すこの方式は、コンパイル時に builtins: ["js-string"] と importedStringConstants を渡すことが前提になっている。
    • しかし Workers では import した Wasm はコンパイル済みの WebAssembly.Module として渡され、コンパイルのオプションを指定できない。wrangler dev で試すと、import(_ と wasm:js-string)が解決されないまま残っていた。
    • JS で polyfill しようにも、wasm:js-string.fromCharCodeArray は wasm-gc の配列を受け取り、JS からその中身は読めないので、書けない。
  • **文字列を UTF-16 のコード単位ごとに受け渡す小さな ABI なら、wasm-gc(builtin string なし)でも wasm(線形メモリ)でも、wrangler dev 上で動いた。** wasm-gc が workerd で動くことも、これで確認できた。
  • **本番の Workers にもデプロイでき、wasm-gc の Wasm がそのまま動いた**(「デプロイ」の節)。アップロードは 5.85 KiB(gzip 後 3.17 KiB)、起動時間は 2 ms。
  • サイズは、ルーティングと HTML の組み立てだけの小さな関数で wasm-gc が 4.8 KB(gzip 後 2.4 KB)、wasm が 8.2 KB(同 4.2 KB)。Go 版(TinyGo で約 2 MiB)とは桁が違うが、ブログ全体を移植してから比べないと公平な比較にはならない。
  • **コード単位の ABI は、時間の約 8 割を JS との受け渡しに使うが、51 KB のページでも 1 リクエスト 0.4 ms 前後で済む**(「計測」の節)。今のところ、受け渡し方を変える必要はない。
  • wasm-opt -Oz をかけると 5,279 B が 4,021 B になる(gzip 後は 2,744 B が 2,110 B)。差は 600 B ほどなので、ビルドには入れていない。
  • 移植は **wasm-gc(builtin string なし)+ コード単位の ABI** から始めるのがよい。サイズが小さく、GC を Wasm 側で持たずに済む。
  • **ブログ全体を移植すると、アップロードは 237.41 KiB(gzip 後 78.50 KiB)、起動時間は 2 ms で、TinyGo 版(2117.89 KiB、8 ms)より小さく速い。** 出力は Go 版と同じなので、2026-10-15 に blog を MoonBit 版に置き換えた(「ブログ全体の比較とデプロイ」の節)。

環境 #

バージョン
moon0.1.20260920(moon new は新しい moon.pkg / moon.mod 形式を生成する)
mooncv0.10.14(2026-09-18)
Node.js24.21.0(V8 での動作確認用)
wrangler4.147.0(npx wrangler@4)。spike はローカルの wrangler dev だけで確認した。moonbit/ のものは後日デプロイした(「デプロイ」の節)

調べたこと #

Workers 側 #

  • Workers の WebAssembly のドキュメント によると、WebAssembly.instantiate() はコンパイル済みの module しか受け付けない。バイト列から実行時にコンパイルすることはできず、ES module 形式の Worker から .wasm を import して使う。
  • 対応する提案は「おおむね Chrome と同じ」とあるだけで、wasm-gc や js-string builtins への言及はなかった。実際に試して確かめた(後述)。
  • Wasm の Worker は JS より大きくなりがちで起動に時間がかかるので、wasm-opt で小さくすることが勧められている。

MoonBit 側 #

  • MoonBit は wasm(線形メモリ)、wasm-gc、js、native を出力できる。
  • wasm-gc で JS と文字列をやり取りする方法として、JS String Builtins がある。moon.pkg の link 設定で "use-js-builtin-string": true にすると、MoonBit の String が JS の文字列としてそのまま渡る。ホスト側は builtins: ["js-string"] と importedStringConstants: "_" を付けてコンパイルする。
  • 新しい moon.pkg 形式では、link 設定は options(link: { ... }) に書く(package の設定)。export する関数は "exports" に列挙する。

確認したこと #

1. builtin string の wasm-gc は、オプション付きでコンパイルすれば動く #

render(path : String) -> String(パスに応じた HTML を返すだけの関数)を use-js-builtin-string でビルドした。import は次のとおり。

  • _.<文字列リテラル>:文字列定数ごとの global
  • wasm:js-string.length、charCodeAt、equals、fromCharCodeArray

Node.js で new WebAssembly.Module(bytes, { builtins: ["js-string"], importedStringConstants: "_" }) としてコンパイルすると、import なしでインスタンス化でき、render("/") が HTML を返した。

2. Workers ではコンパイルのオプションが効かない #

同じ Wasm を worker.mjs から import し、import オブジェクトを空にして WebAssembly.instantiate すると、wrangler dev 上で次のエラーになった。

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

WebAssembly.Module.imports() にも _ と wasm:js-string が残っており、workerd は builtins を有効にせずにコンパイルしていることが分かる。

3. JS の polyfill では補えない #

コンパイルのオプションなしで、import を JS で与えてみた(Node.js で確認)。

importJS で与えられるか
_.<リテラル>与えられる。型が (ref extern) の immutable な global なので、WebAssembly.Global ではなく文字列そのものを渡す。new Proxy({}, { get: (_, k) => k }) で全リテラルをまとめて渡せる
length、charCodeAt、equals与えられる(s.length など)
fromCharCodeArray(array, start, end)**与えられない**。array は wasm-gc の i16 配列で、JS からは中身を読めない。StringBuilder の to_string などが使う

wasm-gc の配列を読む関数を MoonBit 側から export すれば回避できるかもしれないが、コンパイラが生成する内部の型に依存することになるので試していない。

4. コード単位の ABI は両方のバックエンドで動く #

文字列を JS と直接やり取りするのをやめ、UTF-16 のコード単位(Int)を 1 つずつ受け渡すことにした。

  • リクエストのパスは、JS が export された path_push(code) を 1 文字ずつ呼んで渡す。
  • serve() で HTML を組み立て、import した host.put(code) で 1 文字ずつ JS に返す。

こうすると import は host.put だけになり、use-js-builtin-string なしの wasm-gc でも線形メモリの wasm でも同じ JS から呼べる。wrangler dev で両方を試し、トップページと 404(パスの < > のエスケープを含む)が同じ HTML を返した。

サイズ #

--release でビルドしたもの。wasm-opt はかけていない。

ビルドサイズgzip -9
wasm-gc、use-js-builtin-string(render を export)4,249 B—
wasm-gc、コード単位の ABI4,807 B2,450 B
wasm(線形メモリ)、コード単位の ABI8,239 B4,231 B
参考: Go 版のブログ、TinyGo(レポート)約 2 MiB約 760 KiB
参考: Go 版のブログ、標準 Go約 8 MiB—

MoonBit の数字は「ルーティングと HTML の組み立てだけ」のもので、Markdown のパーサーも記事の埋め込みもない。Go 版との比較は、ブログを移植してから同じ条件で取り直す。

moonbit/ への取り込み #

TODO.md の「Experiment with MoonBit's Wasm backends on Cloudflare Workers」の子タスクとして、spike の方式(wasm-gc、builtin string なし、コード単位の ABI)をリポジトリに入れた。

構成 #

パス中身
moonbit/moon.modモジュール podhmo/blog。preferred_target = "wasm-gc"
moonbit/site/render(path)。トップページと、パスをエスケープした Not found ページを返す。moon test の対象
moonbit/worker/Wasm の入り口。path_push と serve を export し、host.put を import する
moonbit/workers/Cloudflare Worker。worker.mjs と wrangler.jsonc(Worker 名は、当初は blog-moonbit、今は blog)
  • **site と worker を分けた理由**:host.put の import がテスト用のバイナリに入ると、moon test の実行時にその import を解決できない。render だけを持つ site をテストし、import は worker に閉じ込めた。
  • **workers/ という名前**:TODO では moonbit/worker/ と書いていたが、worker は MoonBit のパッケージ名で使ったので、Go 版の go/examples/blog/workers にそろえた。
  • ビルドした Wasm の export は path_push と serve だけ、import は host.put だけになっていることを確認した。

spike からの変更 #

  • **インスタンスは isolate ごとに 1 つ**:spike はリクエストのたびに WebAssembly.instantiate していたが、worker.mjs のトップレベルで一度だけ作るようにした。Workers の isolate は 1 スレッドで動くので、リクエスト間で状態が混ざることはない。serve はパスを読んだ直後に空にするので、render の途中で失敗しても次のリクエストに前のパスは残らない。
  • **パスのデコード**:decodeURIComponent に失敗する壊れた % エスケープには、Wasm を呼ばずに 400 を返す。
  • **ビルド**:wrangler.jsonc のビルドコマンドで moon build --target wasm-gc --release を実行し、Wasm を workers/build/app.wasm にコピーする(git では無視)。build.watch_dir に ../site と ../worker を指定し、wrangler dev 中にソースを書き換えると 1 秒ほどで再ビルドされて表示に反映されることを確認した。

wrangler dev で確認したこと #

リクエスト結果
/200、text/html; charset=utf-8、トップページ
/a%3Cb%3ENot found ページに /a&lt;b&gt; と表示される。ステータスはまだ 200
/%E6%97%A5%E6%9C%AC/日本 と表示される
/%E0%A4%A400

この時点ではステータスは常に 200 だった。その後 ABI を広げて 404 を返すようにした(「ステータスとヘッダー」の節)。

なお、wrangler dev の既定のポート 8787 は、Go 版の Worker を wrangler dev で動かしていると使えない(bind(): Address already in use で workerd が起動に失敗する)。そのときは --port で別のポートを指定する。

サイズ #

moonbit/ のビルド(render は spike より少し大きい)は 4,906 B、gzip -9 で 2,524 B。spike の 4,807 B とほぼ同じ。

デプロイ #

moonbit/workers で npx wrangler deploy を実行し、blog-moonbit という Worker として公開した。Go 版の blog とは別の Worker なので、そちらはそのまま残っている。この時点の render はトップページと Not found ページだけで、Go 版のブログの再現はまだしていない。目的は疎通の確認。

wrangler の出力(抜粋):

[custom build] Running: cd .. && moon build --target wasm-gc --release && mkdir -p workers/build && cp _build/wasm-gc/release/build/worker/worker.wasm workers/build/app.wasm
Total Upload: 5.85 KiB / gzip: 3.17 KiB
Worker Startup Time: 2 ms
Uploaded blog-moonbit (1.06 sec)
Deployed blog-moonbit triggers (0.84 sec)
  https://blog-moonbit.ababjam61.workers.dev
  • アップロード時に wasm-gc が拒否されることはなく、追加の設定(互換性フラグなど)も要らなかった。
  • アップロードの 5.85 KiB は、Wasm(4,906 B)と worker.mjs を合わせたもの。

公開 URL に対して確認したこと:

リクエスト結果
/200、content-type: text/html; charset=utf-8、トップページ
/a%3Cb%3ENot found ページに /a&lt;b&gt; と表示される(ステータスはまだ 200)
/%E6%97%A5%E6%9C%AC/日本 と表示される
/%E0%A4%A400

wrangler dev と同じ結果になった。手元(日本)から curl で / を 5 回取ったときの所要時間は 0.38〜0.44 秒だったが、TLS の接続を含む往復の時間で、Worker の処理時間ではない。

デプロイしたのは、次の「ステータスとヘッダー」より前の版なので、公開中の Worker は存在しないパスにも 200 を返す。

ステータスとヘッダー #

site.render が Response(status、headers、body)を返すようにし、ABI を次のように広げた。import は 4 つになった。

import役割
host.put(code)UTF-16 のコード単位を 1 つ、JS 側のバッファに足す
host.status(code)ステータスコードを設定する
host.header_name()バッファの中身を取り出し、次のヘッダーの名前にする
host.header_value()バッファの中身を取り出し、直前の名前のヘッダーの値にする

serve は、ステータス、ヘッダー(名前と値の組ごと)、本文の順に送る。serve が返った時点でバッファに残っているものが本文になる。文字列を渡す手段は put だけのままなので、ヘッダーの数や種類が増えても import は増えない。

wrangler dev で、/ は 200、存在しないパスは 404(どちらも Content-Type: text/html; charset=utf-8)、壊れた % エスケープは 400 を返すことを確認した。moon test でも、render が選ぶステータスと content-type を確かめている。Wasm は 5,279 B になった。

Makefile #

moonbit/Makefile に build、test、check(moon fmt --check、moon check、moon test)、dev、deploy、clean を用意した。go/Makefile と同じく、moonbit/ の中で実行する(ルートの Makefile はない)。make dev PORT=8798 のようにポートを変えられるので、Go 版の Worker が 8787 番を使っていても動かせる。git で管理しているファイルだけをコピーした、clone 直後と同じ状態から make test dev が動くことを確認した。

計測 #

コード単位の受け渡しにかかる時間 #

Workers の中では、Spectre 対策で Date.now() や performance.now() が処理の途中で進まない(I/O を待つまで止まっている)ので、Worker の中では計れない。そこで、同じ V8 で動く Node.js 24 で、worker.mjs と同じ受け渡しの処理を書いて測った。

計測用に、一時的なコピーへ次の 2 つを足した(リポジトリには入れていない)。

  • /big:600 段落、40,195 コード単位(UTF-8 で 50,995 B)の HTML を返すページ。
  • bench_render:render だけを実行し、本文の長さを返す export。JS との受け渡しをしない。
ページrender だけ(Wasm の中)serve で受け渡しまで受け渡しの割合
/(148 コード単位、put 184 回)0.3〜0.4 µs1.5 µs約 8 割
/big(40,195 コード単位、put 40,231 回)79〜87 µs369〜446 µs約 8 割

/big は 3 回測った範囲。受け渡しのコストは、コード単位の数(= put の呼び出し回数)に比例する。割合は大きいが、50 KB のページでも 0.5 ms を切る。Workers の無料プランの CPU 時間の上限(10 ms)に比べても十分小さいので、今のところ UTF-8 のバイト列を線形メモリ越しにまとめて渡す ABI に切り替える必要はない。ページが数百 KB になる、あるいは 1 リクエストで何度も呼ぶようになったら考え直す。

wrangler dev に curl を 20 回送った往復時間(中央値)も、/ が 1.0 ms、/big が 1.8 ms で、差は 1 ms 程度だった。

wasm-opt #

binaryen の wasm-opt(version 132、npx -p binaryen で実行)をかけた。

ビルドサイズgzip -9
moon build --release そのまま5,279 B2,744 B
wasm-opt -O34,752 B2,266 B
wasm-opt -Oz4,021 B2,110 B

-Oz で 24% 小さくなり、最適化後の Wasm も同じ応答を返した。ただし gzip 後の差は 600 B ほどなので、ビルドの依存(binaryen)を増やしてまで入れてはいない。ブログ全体を移植して Wasm が大きくなったら、もう一度測る。

wasm-opt に渡す機能の指定には注意が要る。

  • -all だと、V8 がまだ対応していない機能も有効になり、出力が読み込めなくなる(invalid heap type 'exact'、--disable-custom-descriptors を足しても unknown import kind 0x7e)。
  • --enable-gc --enable-reference-types --enable-bulk-memory --enable-sign-ext --enable-nontrapping-float-to-int --enable-mutable-globals --enable-multivalue と、使う機能だけを指定すると通った。

Go 版との比較スクリプト #

ブログを移植するあいだの物差しとして、Go 版のブログと MoonBit の Worker に同じパスを取りにいき、ステータス、Content-Type、本文を比べる CLI を MoonBit で書いた(moonbit/compare)。make compare で動く。

  • 比べる処理(compare パッケージ)はターゲットを問わない純粋な関数にして、moon test で確かめている。本文の差分は core の @diff(Myers 法)で取る。Go 版はページの大半を 1 行に書くので、行単位だとページ全体が 1 つの変更になってしまう。そこで、改行と > の後ろで区切った単位で比べる。
  • HTTP の取得は moonbitlang/async@0.22.5 の @http.get を使う実行ファイル(compare/cmd)に分けた。async の I/O は native でしか動かないので、compare/cmd は supported_targets = "+native"、ホストの import が native でコンパイルできない worker は "+wasm-gc" にした。これで、moon.mod に async を足しても Worker の Wasm は 5,279 B のまま、バイト単位で変わらない(export は path_push と serve、import は host の 4 つのまま)。make check には moon check --target native を足した。
  • @http.get はリダイレクトを追わないので、リダイレクトはそのまま比べられる。
  • /、/posts/hello、/docs/ja/design、/docs/、/favicon.svg、/no-such-page を既定のパスにした。Go 版は go generate で TODO.md と docs/ をコピーしてから起動しないと文書を返さない。

今の結果は、予想どおり 6 つとも違う。

パス違うところ
/本文
/posts/helloステータス(Go 200、MoonBit 404)、本文
/docs/ja/designステータス(Go 200、MoonBit 404)、本文
/docs/ステータス(Go 200、MoonBit 404)、本文
/favicon.svgステータス、Content-Type(Go image/svg+xml、MoonBit text/html; charset=utf-8)、本文
/no-such-page本文(どちらも 404)

Go 版どうしを比べると 6 つとも same になり、終了コードは 0 だった。

記事の埋め込み #

MoonBit に go:embed はないが、moon.pkg の rule と dev_build で、moon check・moon build・moon test の前にコマンドを実行してソースを生成できる(moon 0.1.20261012 で確認)。組み込みの :embed(moon tool embed)は 1 ファイルを 1 つの String にするだけなので、go/examples/blog/posts/*.md をまとめて (ファイル名, 本文) の配列にするスクリプト moonbit/scripts/embed.sh を書き、本文の変換には moon tool embed --text を使った。生成先の moonbit/posts/embedded.mbt は git に入れない。

試して分かったこと。

  • dev_build の input と output は、ドキュメントにある module のルートではなく、パッケージのディレクトリからの相対パスとして解決される。コマンドは module のルートで実行され、$input は ./../go/... の形で渡る。output をパッケージの外に置くと、コンパイル対象に入らないという警告が出る。
  • input には配列も書けるが、glob(*.md)は使えない(そのままのファイル名として探して missing になる)。ディレクトリを渡すと、ディレクトリの mtime しか見ないので、記事の追加と削除では再生成されるが、既存の記事の編集では再生成されない。
  • そこで make build・make test・make check からも毎回スクリプトを実行する(内容が変わったときだけ書き換えるので、変わらなければ再ビルドは起きない)。wrangler のビルドコマンドは moon build から make build に変え、watch_dir に記事のディレクトリを足した。make dev 中に記事を編集すると、再ビルドで embedded.mbt が更新されることを確かめた。
  • まだ site から使っていないので、Worker の Wasm は 5,279 B のまま変わらない。試しに site から 1 記事を出力すると 15,687 B になり、記事の編集が make build で Wasm に反映された。
  • clone した直後(embedded.mbt がない状態)から、make build も素の moon test も通る。

HTML ビルダー(el) #

Go 版の pkg/el を moonbit/el に移植した。picoel はまだ。

  • **ノード**:Go の Node インターフェースと 5 つの型は、閉じた enum Node(Text、Raw、Element、Attr、BoolAttr)にした。要素名と属性名は、Go と同じく別の型(タプル構造体 Tag(String) と AttrName(String))にしてあり、取り違えるとコンパイルが通らない。derive(Eq, Debug) で == と assert_eq が使える。
  • **名前の定数**:const はプリミティブ型にしか使えず、大文字で始める決まりなので、pub let div : Tag = Tag("div") のように小文字の値にした。MoonBit のキーワードと重なる属性は、title_attr と同じく _attr を付けた(defer_attr、type_attr)。
  • **レンダリング**:Go の Renderer インターフェースと Render の型スイッチは、render_html と render_plain の 2 つの関数の match にした。enum は閉じているので未知のノードはなく、出力先は StringBuilder なので書き込みエラーもない。Go のテストのうち、nil の子、未知のノード、書き込みエラーの 3 つはこのため移植していない。それ以外のケースは moon test で同じ出力を確かめている。
  • **エスケープ**:html.EscapeString と同じく、" を &#34;、' を &#39; にする。site の今のエスケープは &quot; を使っているので、el に乗り換えるときに出力が Go 版にそろう。
  • **警告 implicit_impl_as_method**:moonc v0.10.15 では、公開した型に derive(Eq, Debug) を付けるだけで、equal などがメソッドに昇格することへの警告が出る。core(cmp/extends.mbt)にならい、#deprecated を付けた pub extend ... with Eq::{equal, not_equal} を書いて消した(el/extends.mbt)。
  • worker はまだ el を使っていないので、Wasm は 5,279 B のまま。

Markdown の移植 #

go/pkg/tinymarkdown を moonbit/tinymarkdown に移植した(pkg/tinyfrontmatter はまだ)。

  • **構文木**:Go の Block / Inline インターフェースと構造体は、MoonBit の enum 1 つずつにした(Section(level~, title~, children~)、Paragraph(String) など)。derive(Eq, Debug) で比べて表示できる。見出しを入れ子にする前の平らな列は、Go では非公開の heading 型を Block に混ぜていたが、MoonBit では非公開の enum Flat(Block(Block) と Heading(Int, String))で表した。
  • **インデックス**:Go は文字列をバイトで、MoonBit は UTF-16 のコード単位で数える。パーサーが探す文字はすべて ASCII なので、同じ位置で切れる。s[i] == '`' のように、コード単位と文字リテラルをそのまま比べられる。
  • **空白の除去**:String::trim() の既定は "\t\n\r " だけで、Go の strings.TrimSpace(unicode.IsSpace、U+00A0 なども含む)より狭い。同じ文字の集合を定数にして trim(chars=...) に渡した。
  • **derive の警告**:公開する型に derive(Eq, Debug) を付けると、生成したメソッドが暗黙にメソッドになるのは非推奨だという警告(implicit_impl_as_method)が出る。公式ドキュメントの例と同じく pub extend Block with Eq::{not_equal, equal} と pub extend Block with Debug::{to_repr} を書いた。pub を付けない extend では、今度は未使用の警告が出る。
  • **テスト**:Go のテストのケース(Parse 57 件、Inlines 31 件)を、名前、入力、期待する構文木の組の表として moon test に移した。手で書き直すと間違えるので、Go のリテラルを読んで MoonBit のリテラルに変換する使い捨てのスクリプトで生成し、件数を Go と比べた。Go と同じく、違うケースをすべて名前つきで報告する。assert_eq に msg を渡すと差分の表示が消えるので、@debug.to_string で got と want を自分で出している。
  • **サイズ**:worker はまだ tinymarkdown を使わないので、Wasm は 5,279 B のまま。

記事 #

/ と /posts/{slug} を、Go 版のブログと同じレイアウトで返すようにした。比較スクリプトで、/、4 つの記事、存在しない記事と存在しないパス(どちらも 404)の 7 つが Go 版と一致した。

  • **移植したもの**:Go 版の internal/site の posts.go、views.go、links.go、docs.go のうち、記事のページに要る部分。記事が docs/ の文書にリンクしているので、リンクの解決(linker)と文書の読み込みもこの段階で移した。文書のページは次の段階。
  • **net/url と path**:リンクの解決は Go の url.Parse、URL.String、path.Join に頼っている。MoonBit の core にはないので、使う部分だけを書いた(スキームの判定、%XX の解釈と書き出し、path.Clean)。Go の links_test.go のケースに、エスケープや url.Parse が失敗する入力のケースを足して確かめている。// の後の authority の検査は移していない。
  • **日付**:core に時刻の型がないので、日付はファイル名の YYYY-MM-DD の文字列のまま持ち、time.Parse と同じ言葉(month out of range など)で月と日を確かめる。並べ替えも文字列の比較でできる。
  • **site.css のハッシュ**:Go 版はページに /site.css?v=<SHA-256 の先頭 4 バイト> を書く。core に SHA-256 がないので、scripts/embed-assets.sh がビルドの前に sha256sum(なければ shasum -a 256)で計算し、site.css と favicon.svg と一緒に assets/embedded.mbt に書く。
  • **BASE_URL**:Go 版の Worker と同じく wrangler.jsonc の vars に置き、worker.mjs が最初のリクエストで path_push と新しい export の set_base_url で Wasm に渡す。記事は最初の serve で読み込み、読み込めなければすべてのリクエストに理由を書いた 500 を返す。比較では Go 版を同じ -base-url で起動する。
  • **文字列の比較**:String::compare は短い文字列を先に並べるので、Go と同じ順にするには lexical_compare を使う。
  • **サイズ**:記事と文書(TODO.md と docs/)をすべて埋め込んだので、Wasm は 205,578 B(gzip で 68,092 B)になった。埋め込んだソースは合わせて約 124 KB。

文書とリンク #

/docs/ と /docs/{path} を返すようにした。リンクの解決と文書の読み込みは記事の段階で移してあったので、足したのは文書の一覧と文書のページ、/docs/ の下の経路だけだった。比較スクリプトで、/docs/、6 つの文書(TODO.md と docs/**/*.md)、存在しない文書が Go 版と一致した。文書の中の相対リンク(/docs/ や /posts/ のページへ、GitHub へ、リンクにしない)も、本文の比較に含まれている。

  • Go 版は go generate で文書をコピーしてから埋め込むので、文書を編集したら、比較の前に Go 版でも go generate ./... をやり直す必要がある。やり直さないと、古いコピーとの差分が出る。

残りのルート #

/favicon.svg、/site.css、記事と文書の .md 版、リダイレクト(正規化されていないパスと /docs から /docs/)、404 ページを足し、Go 版のブログのルートをすべて移した。

  • **比較**:Go 版の / と /docs/ からたどれるすべてのページとその .md 版、/favicon.svg、/site.css(?v= 付きと無し)、/docs、/docs?x=1、//posts/hello、存在しない記事・文書・パスとその .md 版、Go 版の -watch 用のパス(/_livereload.js など、どちらも 404)の 41 パスを比べ、40 パスが一致した。
  • **一致しない 1 パス**:/docs/ja/../TODO のように . や .. を含むパスは、Go 版が 307 で /docs/TODO にリダイレクトするのに対し、Worker は 200 で /docs/TODO を返す。workerd がリクエストを受けた時点で URL を正規化するので(wrangler のログにも正規化後のパスが出る)、request.url にも元のパスは残っておらず、Worker の側では区別できない。Wasm の render は Go 版と同じくリダイレクトを返し、moon test で確かめている。
  • **リダイレクトの本文**:Go の http.Redirect は GET に <a href="...">Temporary Redirect</a>. と改行 2 つの本文を付ける。比較は本文も見るので、同じ本文を返す。Location のクエリーを保つため、worker.mjs はクエリーを新しい export の set_query で渡す。
  • **メソッド**:Go 版は GET と HEAD 以外に 404 を返すが、Worker はメソッドを見ずに同じ応答を返す。比較は GET だけで行う。

ブログ全体の比較とデプロイ #

2026-10-15 に、Go 版のルートをすべて移した MoonBit の Worker を、まず blog-moonbit として、続けて make deploy-blog(wrangler.jsonc の env blog)で blog としてデプロイした。https://blog.podhmo.workers.dev は、TinyGo 版から MoonBit 版に置き換わった。Go 版に戻すには、go/examples/blog/workers でもう一度デプロイする。

その後、wrangler.jsonc の Worker 名を blog にして env blog と make deploy-blog をなくし、make deploy がそのまま blog にデプロイするようにした。不要になった blog-moonbit の Worker は削除した。

同じ日に、アカウントの workers.dev のサブドメインを ababjam61 から podhmo に変えたので、ブログの URL は https://blog.podhmo.workers.dev になった(BASE_URL も同じく変えた)。上の「デプロイ」の節の wrangler の出力は、変える前のもの。

ビルドWasmwrangler の Total UploadWorker Startup Time
MoonBit(wasm-gc、--release)241,375 B(gzip -9 後 79,216 B)237.41 KiB(gzip 後 78.50 KiB)2 ms
Go、TinyGo(レポート)2,092,466 B(同 741,246 B)2117.89 KiB(gzip 後 757.41 KiB)8 ms
Go、標準 Go(同)8,560,688 B(同 2,327,300 B)——
  • アップロードは TinyGo 版の約 1/9、標準 Go 版の Wasm の約 1/35。記事 4 本と文書 7 本、site.css、favicon.svg を埋め込んだ大きさで、spike(5.85 KiB)からの増分の多くは埋め込んだ文章。
  • 起動時間は TinyGo 版の 8 ms に対して 2 ms。標準 Go 版はデプロイの記録がない。
  • いちばん大きいページは /docs/sketch/ja/moonbit-modernize(53,796 B、36,682 コード単位)。wrangler dev に curl を 20 回送った往復時間の中央値は 1.7 ms で、/(1,729 B)の 1.2 ms との差は 0.5 ms ほど。「計測」の節の見積もり(50 KB で受け渡しが 0.5 ms 弱)と合っており、コード単位の ABI のままでよい。
  • 比較スクリプトで、Go 版の / と /docs/ からたどれる記事 4 本と文書 7 本とその .md 版、/、/docs/、/docs、/posts/、/favicon.svg、/site.css?v=...、//posts/../、存在しないパスの 30 パスを、ローカルの Go 版(同じ -base-url)とデプロイした blog-moonbit と blog のそれぞれで比べ、すべて一致した。blog-moonbit へのデプロイ直後の 1 回だけ 2 パスが一致しなかったが、続けて 3 回実行するとすべて一致した。どのパスだったかは記録していない。
  • サイズと起動時間で MoonBit 版が上回り、出力は Go 版と同じなので、blog は MoonBit 版にした。Go 版は、サーバーとしての go run ./examples/blog と比較の基準として残す。

アダプターの見直し #

受け渡しの方法と export / import の形を見直す探索は、別のメモに記録した(2026-10-16)。線形メモリに UTF-8 でまとめて渡すと、いちばん大きいページの受け渡しにかかる時間は約 390 µs から約 50 µs に減る。workerd に js-string builtins を有効にするフラグが入ったことも、そちらに書いた。

Static Assets への移行 #

2026-10-22 に、記事と文書を Wasm の外に出した。詳しい設計、実装、つまずいた点は別のレポートに書いた。ここには、サイズと時間の変化だけを残す。

  • 記事と文書は examples/blog/cloudflare/build/content/ にコピーし、wrangler.jsonc の assets でアップロードする。Worker は ASSETS から files.txt と全部のファイルを初回リクエストで読み、file(n) で Wasm に渡す。
    • 後日の注記: ファイルごとの取得が subrequest の上限(無料プランで 50)を超えて本番で失敗したので、#92 の修正で全部を 1 つの content.bin にまとめ、1 回の取得で読むようにした。files.txt はもうない。経緯は Static Assets のレポートの「追記: subrequest の上限(#92)」にある。
  • テストと experiments/ は、今までどおり embed.sh の埋め込みを使う。
ビルド(2026-10-22、投稿 10 本・12 ファイル、文書 21)変更前変更後
Wasm(--release、wasm-opt 前)649,223 B175,001 B
app.wasm(wasm-opt -Oz の後)615,994 B141,966 B
Total Upload605.23 KiB(gzip 195.43 KiB)144.55 KiB(gzip 63.56 KiB)
  • wrangler deploy --dry-run はアセットを Read 41 files と数えるが(34 ファイルと 7 ディレクトリ)、Total Upload には入らない。
  • 記事を 1 本足しても app.wasm はバイト単位で変わらない(SHA-256 が同じ)。wrangler dev を動かしたまま足すと 20 秒ほどで反映され、消すと files.txt から外れて 404 に戻る(今は content.bin から外れる)。
  • 比較は、Go 版(-debug)と 62 パスで 0 of 62 paths differ。

時間(Node.js 24 で load してから call、33 ファイル、約 396 KB):

処理時間
app.load(files)7.66 ms
最初の app.call(/)(Site の組み立てを含む)8.88 ms
2 回目の app.call(/)0.26 ms

wrangler dev の往復時間は、同じ --persist-to を指定した変更前と変更後で次になった。

条件変更前変更後
起動後の最初のリクエスト(3 回の範囲)20〜23 ms62〜73 ms
温まったあとの /(3 回の中央値の範囲)5.8〜5.9 ms6.1〜6.6 ms

最初のリクエストだけ 40〜50 ms ほど遅い。内容の取得と Site の組み立ての分で、ローカルの workerd の数字。本番の Startup Time と最初のリクエストの CPU 時間はまだ測っていない。

計測の途中で、変更後だけ 8 ms ほど遅く見えることがあった。原因はコードではなく、変更前の worktree が /tmp(tmpfs)にあったことだった。wrangler dev はローカルの状態を .wrangler/state に持つので、--persist-to で両方の置き場所をそろえると差が消えた。詳しくは別のレポートの「つまずいたこと」に書いた。

次の一歩 #

1. (済み)トップレベルに moonbit/ を作り、spike の ABI と worker.mjs を置く。「moonbit/ への取り込み」の節を参照。 2. (済み)ステータスとヘッダーを返せるように ABI を広げ、存在しないパスに 404 を返す。「ステータスとヘッダー」の節を参照。 3. (済み)moonbit/Makefile に build、test、dev、deploy を用意する。「Makefile」の節を参照。 4. (済み)本番に blog-moonbit としてデプロイし、wasm-gc が受け付けられるかを確かめて、アップロードサイズを追記する。「デプロイ」の節を参照。 5. (済み)wasm-opt -Oz をかけたサイズと、コード単位の受け渡しにかかる時間を測る。「計測」の節を参照。 6. (済み)Go 版のブログと、パスごとにステータス、Content-Type、本文を比べる CLI を moonbit/compare に置く。「Go 版との比較スクリプト」の節を参照。 7. (済み)ブログ全体を移植し、Go 版とサイズを比べて、このレポートに追記する。手順は TODO.md の「Reimplement the blog in MoonBit」の子タスクに分けた(Go 版との比較スクリプト、記事と文書の埋め込み、HTML ビルダー、Markdown、記事、文書とリンク、残りのルート、比較と記録)。 8. (済み)記事と文書を Static Assets に移し、記事を足しても Wasm が太らないようにする。「Static Assets への移行」の節を参照。

spike のコード #

記録として残す。今のコードは moonbit/ にある(worker/worker.mbt、cloudflare/worker.mjs(当時は workers/))。

moon new mbspike で作ったプロジェクトのルートパッケージ。

moon.pkg:

options(
  link: {
    "wasm-gc": { "exports": [ "path_push", "serve" ] },
    "wasm": { "exports": [ "path_push", "serve" ] },
  },
)

abi.mbt(render はパスに応じた HTML を String で返す関数):

///|
/// put sends one UTF-16 code unit of the response body to the host.
fn put(code : Int) = "host" "put"

///|
let input : StringBuilder = StringBuilder()

///|
/// path_push appends one UTF-16 code unit of the request path.
pub fn path_push(code : Int) -> Unit {
  input.write_char(code.unsafe_to_char())
}

///|
/// serve renders the page for the pushed path, sends it through put, and
/// clears the path.
pub fn serve() -> Unit {
  let path = input.to_string()
  input.reset()
  let html = render(path)
  for i in 0..<html.length() {
    put(html.code_unit_at(i).to_int())
  }
}

worker.mjs(wrangler.jsonc は "main": "./worker.mjs" だけ。ビルドした Wasm を隣に置く):

import gc from "./app-gc.wasm";
import linear from "./app-linear.wasm";

// render runs one of the MoonBit builds: the path goes in and the HTML
// comes out one UTF-16 code unit at a time, through path_push and host.put.
async function render(mod, path) {
  let out = [];
  const inst = await WebAssembly.instantiate(mod, {
    host: { put: (c) => out.push(c) },
  });
  for (let i = 0; i < path.length; i++) inst.exports.path_push(path.charCodeAt(i));
  inst.exports.serve();
  let s = "";
  for (let i = 0; i < out.length; i += 8192) s += String.fromCharCode(...out.slice(i, i + 8192));
  return s;
}

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const mod = url.searchParams.get("backend") === "wasm" ? linear : gc;
    return new Response(await render(mod, decodeURIComponent(url.pathname)), {
      headers: { "content-type": "text/html; charset=utf-8" },
    });
  },
};