調査メモ: 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 からその中身は読めないので、書けない。
- 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 版に置き換えた(「ブログ全体の比較とデプロイ」の節)。
環境 #
| バージョン | |
|---|---|
| moon | 0.1.20260920(moon new は新しい moon.pkg / moon.mod 形式を生成する) |
| moonc | v0.10.14(2026-09-18) |
| Node.js | 24.21.0(V8 での動作確認用) |
| wrangler | 4.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 は次のとおり。
_.<文字列リテラル>:文字列定数ごとの globalwasm: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 functionWebAssembly.Module.imports() にも _ と wasm:js-string が残っており、workerd は builtins を有効にせずにコンパイルしていることが分かる。
3. JS の polyfill では補えない #
コンパイルのオプションなしで、import を JS で与えてみた(Node.js で確認)。
| import | JS で与えられるか |
|---|---|
_.<リテラル> | 与えられる。型が (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、コード単位の ABI | 4,807 B | 2,450 B |
| wasm(線形メモリ)、コード単位の ABI | 8,239 B | 4,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%3E | Not found ページに /a<b> と表示される。ステータスはまだ 200 |
/%E6%97%A5%E6%9C%AC | /日本 と表示される |
/%E0%A4%A | 400 |
この時点ではステータスは常に 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%3E | Not found ページに /a<b> と表示される(ステータスはまだ 200) |
/%E6%97%A5%E6%9C%AC | /日本 と表示される |
/%E0%A4%A | 400 |
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 µs | 1.5 µs | 約 8 割 |
/big(40,195 コード単位、put 40,231 回) | 79〜87 µs | 369〜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 B | 2,744 B |
wasm-opt -O3 | 4,752 B | 2,266 B |
wasm-opt -Oz | 4,021 B | 2,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と同じく、"を"、'を'にする。siteの今のエスケープは"を使っているので、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 では非公開の enumFlat(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 のテストのケース(
Parse57 件、Inlines31 件)を、名前、入力、期待する構文木の組の表として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 の出力は、変える前のもの。
| ビルド | Wasm | wrangler の Total Upload | Worker 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)」にある。
- 後日の注記: ファイルごとの取得が subrequest の上限(無料プランで 50)を超えて本番で失敗したので、#92 の修正で全部を 1 つの
- テストと
experiments/は、今までどおりembed.shの埋め込みを使う。
| ビルド(2026-10-22、投稿 10 本・12 ファイル、文書 21) | 変更前 | 変更後 |
|---|---|---|
Wasm(--release、wasm-opt 前) | 649,223 B | 175,001 B |
app.wasm(wasm-opt -Oz の後) | 615,994 B | 141,966 B |
| Total Upload | 605.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 ms | 62〜73 ms |
温まったあとの /(3 回の中央値の範囲) | 5.8〜5.9 ms | 6.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" },
});
},
};