探索メモ: MoonBit の Workers 用アダプターを汎用化する #
TODO.md の「MoonBit の Workers 用アダプターを汎用化する」(issue #33)の記録(2026-10-16)。
PR #31 の探索メモで磨いたアダプターは、ブログ専用だった。要求として渡すのは BASE_URL、メソッド、クエリー、パスだけで、POST の本文もヘッダーも Wasm に届かない。
これを、どの Worker でも使えるライブラリの形に切り出せるかを確かめた。Go 版や net/http に寄せることは目的にせず、ルーティングと要求・応答の扱いで同じような機能が使えることを目指した。
結論 #
- **切り出せる。** 形は次の 3 つに分かれる。
1.
http(moonbit/http): 要求、応答、ヘッダー、クエリー、フォーム、ルーター。ホストに依存しない純粋な MoonBit で、native でもテストできる。 2.http/wasm(moonbit/http/wasm): 線形メモリを通した受け渡し(wire 形式、handle)。wasm-gc 専用。 3.http/wasm/adapter.mjs: JS の側。wasmApp(module, { maxBodySize })がfetchを返す(94 行)。 - アプリの側に要るのは、
pub fn serve(n : Int) -> Int { @wasm.handle(n, handler) }の 1 関数と、moon.pkgの link 設定("exports": ["serve"]、"export-memory-name": "memory")だけ。 - **POST、クエリー、本文(テキスト、JSON、バイト列)、フォーム(urlencoded と multipart)、パスの wildcard、メソッドごとのルート、405、複数の
Set-Cookie、バイナリの応答、Worker の変数**が、Node とwrangler devの両方で使えた(例:moonbit/pkg/http/wasm/integration)。 - **ブログもこのライブラリの上に載せ替えた。**
workerは要求をSite::handleに渡すだけになり、SiteのルーティングはRouterになった。make compareは前と同じく/%FF//以外が Go 版と一致し、Wasm は約 5 KB(1.8%)増え、速さは変わらない。 - 汎用化で新しく分かった制約: **いまの
serveは同期なので、ハンドラーから KV、R2、D1、fetchなどの非同期の I/O を呼べない。** ただし **JSPI(JavaScript Promise Integration)を使えば、Wasm から JS のfetchを同期の関数のように呼んで待てることを、Node とwrangler devで確かめた**(「JSPI で JS の fetch を待つ」の節)。本文はすべて一度に受け渡すので、ストリーミングはできない。
ライブラリの形 #
http(ホストに依存しない部分) #
moon info の出力(moonbit/http/pkg.generated.mbti)から、主なものを抜き出す。
pub struct Request {
verb : String // メソッド。`method` は予約語なので使えない
url : String // ホストから受け取ったまま
host : String
raw_path : String // エスケープしたまま
path : String // デコードした後
raw_query : String
headers : Headers
body : Bytes
env : Map[String, String] // Worker の変数など、要求の外から渡るもの
params : Map[String, String] // Router が埋める wildcard の値
}
pub fn Request::Request(verb~ : String, url~ : String, headers? : Headers, body? : Bytes, env? : Map[String, String]) -> Self raise BadRequest
pub fn Request::query(Self) -> Values
pub fn Request::param(Self, String) -> String
pub fn Request::text(Self) -> String
pub fn Request::json(Self) -> Json raise BadRequest
pub fn Request::form(Self) -> Form raise BadRequest
pub(all) enum Body { Text(String); Binary(Bytes) }
pub struct Response { status : Int; headers : Headers; body : Body }
pub fn Response::Response / text / html / json / typed / redirect
pub fn Response::header(Self, String, value~ : String) -> Unit // その場で書き換える。`..header(..)` でつなぐ
pub struct Router { ... }
pub fn Router::Router(decoded_path? : Bool, not_found? : Handler, method_not_allowed? : (Request, Array[String]) -> Response) -> Self
pub fn Router::handle(Self, String, Handler) -> Unit
pub fn Router::serve(Self, Request) -> ResponseHeadersは名前と値の組の配列で、名前は大文字小文字を区別せずに引く。同じ名前を何度も持てる(Set-Cookie)。Valuesはクエリーや urlencoded のフォームの組の配列。getは最初の値、get_allはすべての値。Formは、ファイルでない欄のValuesと、File(欄の名前、ファイル名、Content-Type、Bytes)の配列。- エラーは
BadRequest1 つにまとめた。要求の解釈に失敗したら投げ、http/wasmは 400 で返す。
ルーター #
Go 1.22 の ServeMux のパターンをまねた。"[METHOD ]/path" で、セグメントはリテラル、{name}(1 セグメント、空は不可)、{name...}(残り全部)、{$}(そこで終わる)。/ で終わるパターンは、その下のすべてのパスに当たる。
- GET のパターンは HEAD にも当たる。
- パスに当たるパターンが、ほかのメソッドのものしかなければ 405 と
Allowを返す。何にも当たらなければ 404。どちらもRouter::Routerの引数で差し替えられる。 - **優先順位**: より狭いパターンが勝つ。セグメントごとに、リテラル >
{$}>{name}>{name...}>/で終わる部分木の順に比べ、同じならメソッドを指定したほうが勝つ。登録の順番は、同じ強さのときだけ効く。Go は重なりがあいまいなパターンを登録すると panic するが、こちらは登録の順で決める。 - **パスの分け方**: 既定では、エスケープしたままのパスを
/で分けてから、セグメントごとにデコードする(Go 1.22 と同じ)。/posts/a%2Fbの{slug}はa/bになる。decoded_path=trueにすると、デコードした後のパスを/で分ける。ブログは後者を使う(下の「ブログの載せ替え」)。 - 足りないもの:
{slug}.mdのような、wildcard の後ろに続く接尾辞のパターンはない(ブログはハンドラーで接尾辞を見ている)。ホスト名でのルーティング、/aから/a/への自動のリダイレクト、ミドルウェアの仕組みもない。ミドルウェアは、ハンドラーを包む関数を書けば足りる(ブログのメソッドの確認とリダイレクトがそう)。
http/wasm(受け渡し) #
PR #31 の案 D'(線形メモリに UTF-8 でまとめて書く)を、要求の全体を運べるように広げた。
要求(JS が番地 0 から書く):
method \n url \n (name \t value \n)* \n (name \t value \n)* \n bodyメソッド、URL、ヘッダー、空行、env、空行、本文(バイト列のまま)。
応答(Wasm が上に書く。serve は長さを返す):
status \n (name \t value \n)* \n body- 本文の前までは UTF-8 のテキストで、名前と値に改行は入らない(
Headersが改行を受け付けないため)。値にはタブが入りうるので、名前と値は**最初の**タブで分ける。PR #31 のworker.mjsはsplit("\t")で全部のタブで分けていたので、タブを含む値が壊れる可能性があった(ブログの応答のヘッダーには入らない)。 - env は、文字列の値で改行を含まないものだけを渡す(
varsの JSON の値やバインディングのオブジェクトは渡さない)。PR #31 ではBASE_URLだけを決め打ちで渡していた。 - 応答の本文は
Body::Textなら UTF-8 に書き、Body::Binaryならバイトをそのまま写す。 - **本文の上限**:
wasmAppのmaxBodySize(既定は 1 MiB)を超える本文には、Wasm を呼ばずに 413 を返す。本文はストリームのまま上限まで読み、超えたらそこで捨てる(Content-Lengthのない要求にも効く)。Wasm の線形メモリは一度増えると減らないので、上限がないと、大きな POST を 1 つ送るだけで isolate のメモリ(128 MB)を食いつぶしうる。Workers は要求の本文をプランに応じて 100 MB 以上まで通す。PR #31 のアダプターは本文を読まなかったので、この危険は汎用にして初めて生じた。 - JS の側では、101、103、204、205、304 の応答は本文を
nullにする。new Response(body, { status: 204 })は本文があるとTypeErrorになる。ブログでは起きなかったが、汎用にすると要る。
アプリから見た形 #
let app : @http.Router = {
let r = @http.Router()
r.handle("GET /hello/{name}", req => @http.Response::text(200, "hello, \{req.param("name")}\n"))
r.handle("POST /form", req => { ... req.form() ... })
r
}
pub fn serve(n : Int) -> Int {
@wasm.handle(n, req => app.serve(req))
}import wasm from "../../../../_build/wasm-gc/release/build/pkg/http/wasm/integration/integration.wasm";
import { wasmApp } from "../../http/wasm/adapter.mjs";
export default wasmApp(wasm);wasmApp は fetch のほかに、応答を部品({ status, headers, body })で返す call も返す。ブログの worker.mjs は call の結果をキャッシュし、toResponse で毎回 Response を作る(PR #31 の案 I をそのまま残した)。
ブログは本文を読まない(GET と HEAD 以外は 404)ので、worker.mjs は本文のない Request を作り直して call に渡す。413 にせず、Go 版と同じ 404 を返すため。50,000,000 B の POST にも、Go 版と同じ 404 を返した(wrangler dev)。
new Request(request, { body: null }) では本文が消えない(Node で確かめた。init.body が null でも、元の要求の本文が使われる)。new Request(request.url, request) も、request を init として読むので本文が付いてくる。new Request(url, { method, headers }) と書く。
試したこと #
1. ライブラリへの切り出し #
worker/memory.mbt を http/wasm に移し、要求の解釈を wire 形式に置き換えた。ブログの worker は、req.env の BASE_URL で Site を読み、Site::render(req.path, query=req.raw_query, verb=req.verb) の結果を @http.Response に直すだけになった。
2. 例(moonbit/pkg/http/wasm/integration) #
ライブラリの機能を一通り使う例を作った。make test は Node で、make dev-echo は wrangler dev で動かす(デプロイはしない)。
| ルート | 確かめたこと |
|---|---|
GET /{$} | フォームのある HTML |
GET /hello/{name} | wildcard(%E6%97%A5 は 日)、クエリー(greeting=hey+you の + は空白) |
/echo(メソッドなし) | メソッド、ホスト、パス、同じ名前が 2 回あるクエリー、Content-Type、本文(改行を含む日本語) |
POST /json | JSON の本文。壊れた JSON は 400 |
POST /form | urlencoded と multipart。multipart は 100,000 B のランダムなバイナリのファイル(curl の -F)と、Node の FormData + Blob |
PUT / DELETE /items/{id} | メソッドごとのルート。DELETE の 204 は本文なし。GET は 405 で Allow: PUT, DELETE |
GET /files/{path...} | 残り全部の wildcard |
GET /redirect | 303 と Location |
GET /bytes | 0〜255 のバイト列の応答(SHA-1 が一致) |
GET /cookie | 要求の Cookie と、2 つの Set-Cookie |
GET /env | wrangler.jsonc の vars |
ほかに、200,000 B の本文(線形メモリの最初の 1 ページ、64 KiB を超える)、壊れたエスケープのパス(/%zz は 400)、HEAD(GET のルートに当たり、本文なし)も確かめた。
3. ブログの載せ替え #
Site::render の手書きの分岐を Router にした(Site::handle)。
- メソッドの確認(GET と HEAD 以外は 404)と、正規化したパスへのリダイレクトは、Go 版の
routeと同じくルーターの前に置いた。どちらもルーターの仕組みでは書けない(メソッドが違えば 405 ではなく 404 にしたい、すべてのパスに先にかけたい)ので、ハンドラーを包む形になる。 - **
decoded_pathが要った。** Go 版はr.URL.Path(デコードした後)で分岐するので、/docs%2Fは/docs/の一覧になる。既定の分け方(Go 1.22 の ServeMux と同じ)ではdocs/という 1 つのセグメントになって 404 になり、make compareで一致しなかった。ブログは手書きの分岐を移植したもので、ServeMux とは振る舞いが違う、というのが理由。 siteのResponseと、パスのデコード(unescape、unescape_path)は消し、httpのものを使うようにした。Site::renderはテストのために残し、パスとクエリーから@http.Requestを作ってhandleを呼ぶ。- ルーターは最初の要求で作り、
Siteに持たせる。
計測 #
Go 版との比較 #
make dev とローカルの Go 版(-base-url https://blog.podhmo.workers.dev)を、55 パスで比べた。/ と /docs/ からたどれる記事と文書とその .md 版、/docs、/docs?x=1、//posts/../、/_livereload.js、存在しないパス、/favicon.svg、/site.css(?v=other 付きも)、PR #31 の変なエスケープの 14 パス、/posts/hell%6F。
| 一致しないパス | |
|---|---|
| 1(切り出し)の後 | /%FF// だけ(3 回続けて) |
| 3(載せ替え)の後 | /%FF// だけ(2 回続けて) |
/%FF//は PR #31 の案 H の節にある既知の違い。- POST の
/posts/hello、/docs、/は、どちらも 404 で本文が一致した。 - 最初に比べたとき、
/docs/TODOなどが一致しなかった。Go 版のrepodocsはgo generateでコピーした文書を埋め込むので、TODO.mdを書き換えた後にgo generate ./examples/blog/repodocsを忘れていた。アダプターとは関係ない。 - 本文の上限を入れた版を
make deployでblogにデプロイし(Total Upload 310.05 KiB、gzip 後 98.60 KiB、Worker Startup Time 2 ms)、同じ 55 パスで比べた。一致しないのは PR #31 の案 H の節と同じ/%FF//、/%zz、/a%00bの 3 つだった。50,000,000 B の POST にも、Go 版と同じ 404 を返した。
サイズ #
同じ文書を埋め込んだビルドで比べた(-Oz の後)。
| Wasm | gzip -9 | |
|---|---|---|
| PR #31 のアダプター | 285,293 B | 89,616 B |
| 1. ライブラリに切り出した後 | 287,329 B(+2,036 B) | 90,433 B |
3. ブログを Router に載せ替えた後 | 290,295 B(+5,002 B) | 91,956 B |
例の echo.wasm(文書を埋め込まない) | 92,519 B | 44,106 B |
- 切り出しで増えたのは、ヘッダーと env の解釈と
Requestの組み立て。Routerを使うと、さらに約 3 KB 増える。 - 埋め込んだ文章がない
echo.wasmでも 92 KB ある。中身(JSON の解析と文字列化、Map、ルーター)の内訳は見ていない。
処理時間(Node) #
PR #31 と同じ測り方(fetch に Request を渡して本文を読み終えるまで、1,000 回 × 5 回の中央値)。キャッシュは使わない。user-agent と accept のヘッダーを付けた。load average は 4〜6。
/ | いちばん大きいページ(/docs/sketch/ja/moonbit-modernize) | |
|---|---|---|
| PR #31 のアダプター | 16.2〜18.5 µs | 382〜401 µs |
| 1. 切り出した後 | 17.8〜22.3 µs | 391〜402 µs |
3. Router に載せ替えた後 | 18.1〜18.6 µs | 389〜407 µs |
- 差はばらつきの範囲。ヘッダーを渡すぶんの増え方は、
/で 1 µs 前後。 - 本文をすべて写すコストは、本文の 1 バイトあたり約 1.4 ns(1,000,000 B の本文で、本文を読まない 405 の応答が約 1.4 ms)。
- ただし、本文を文字列にして JSON で返す
echoのルートは、1 バイトあたり約 30 ns かかった(1,000,000 B で約 33 ms)。decode_lossy、Json::stringify、コード単位ごとの書き戻しのどれが重いのかは分けて測っていない。ブログのフォームくらいの大きさ(数 KB)なら数十 µs で済む。 - 典型的なブラウザーのヘッダー(9 個、約 700 B)を付けると、
echoのGET /hello/xは 8.5 µs から 15.5 µs になる。ヘッダーは 1 つずつHeadersに積むので、本文より 1 バイトあたりが重い。
分かったこと #
- **MoonBit の
OptionのToJsonは、Noneを[]、Some(x)を[x]にする。**Json(req.headers.get("content-type"))は["text/plain"]になり、テストで気づいた。nullにしたければ自分でmatchする。 - **
methodは予約語**(PR #31 と同じ)。Requestのフィールドもverbにした。 Refは、siteパッケージに同じ名前の型(リンクの参照)があるので、Ref[T]と書くとsiteのRefになってエラーになった。Siteの遅延初期化はmutのフィールドにした。typealiasの書き方はpub type Handler = (Request) -> Responseだった(pub typealias ... as ...は構文エラー)。- 関数を値に持つ
pub structのフィールドをmutにすると、パッケージの中で書き換えないのでunused_mutの警告(このリポジトリではエラー扱い)になる。差し替えはRouter::Routerの省略可能な引数で受けることにした。 matchの腕にそのままguardは書けない(Using guard statement in the action part of a matching case directly is not allowed)。if !(... is ...) { return None }にした。
ライブラリとして出すときに考えること #
- **非同期の I/O。** いまの
serveは同期で、ハンドラーは要求を受けて応答を返すだけ。KV、R2、D1、Static Assets、外へのfetchを使うには、(a) Wasm が「この I/O をして」という応答を返し、JS が実行して結果を付けて呼び直す、(b) JSPI で Wasm から Promise を待つ、のどちらかが要る。(a) は wire 形式を広げれば今の形のまま書ける。(b) は spike で動いた(次の節)。Static Assets の TODO で文章を Wasm の外に出すときも、同じ問題になる。 - **ストリーミング。** 要求も応答も、全部を線形メモリに置く。大きなアップロードやストリーミングの応答(SSE)はできない。ブログの Go 版の live reload(SSE)は、Worker 版では元からない。
- **線形メモリ。** moon は wasm-gc でも 1 ページの線形メモリを出し、使っていない、という前提に乗っている(PR #31 と同じ)。将来 moon がこのメモリを使い始めたら壊れる。メモリは一度増えたら減らない。
- **配り方。** MoonBit のパッケージ(
http、http/wasm)と JS のファイル(adapter.mjs)を一緒に配る必要がある。mooncakes.io に JS のファイルを含めて出せるか、wrangler からどう参照させるかは調べていない。いまは同じリポジトリの相対パスでimportしている(wrangler の esbuild がまとめる)。 - **
moon.pkgの link 設定は、アプリの側に書くしかない。** export する関数はアプリのパッケージにあり、ハンドラーもアプリが渡すので、serveの 1 関数はアプリが書く。 - **js-string builtins(PR #31 の案 E)にした場合。**
wasm_esm_integration_builtinsが使えるようになっても、本文がバイト列のまま渡せる線形メモリの形は残す価値がある(バイナリの本文を JS の文字列にすると、壊れるか遅い)。
JSPI で JS の fetch を待つ(spike) #
「Wasm の側から JS.global.fetch のように JS の fetch を呼べないか」を試した。
呼ぶこと自体は import でできる。問題は、fetch が Promise を返し、Wasm のコードは同期なので待てないこと。
JSPI はこれを埋める仕組みで、JS の async 関数を new WebAssembly.Suspending(f) で包んで import に渡し、export を WebAssembly.promising(serve) で包む。
Wasm がその import を呼ぶと、Wasm のスタックごと止まり、Promise が解決したら続きから動く。Wasm から見ると、ただの同期の関数呼び出しになる。
- **どこで使えるか**: Node 24.21.0 と、wrangler 4.148.0 の workerd(1.20261006.1)の両方で、
WebAssembly.SuspendingとWebAssembly.promisingが、フラグなしで関数として存在した。 - **MoonBit の側**: 何も特別なことは要らない。
fn host_fetch(n : Int) -> Int = "host" "fetch"という普通の import 宣言で、wasm-gc のまま動いた。止まっているあいだも、GC の参照を持ったままのスタックが保たれる。 - **確かめたこと**: spike(
moonbit/experiments/wasm-fetch。make try、make concurrent、make dev)で、GET /proxy?url=https://example.com/が上流の 200 と本文を返した。1 つの要求の中で 2 回続けてfetchしても動いた。Node でもwrangler devでも同じ。 - **同時に来た要求**: Wasm が 1 つの要求で止まっているあいだに、次の要求が
serveに入ってくる。応答の長さが違う 3 つの要求(Node では上流が 300 ms と 50 ms で返す、wrangler devでは example.com / .org / .net)を同時に送り、どれも自分の応答を受け取った。
ただし、線形メモリの番地 0 を共有する今の受け渡しは、そのままでは同時の要求で壊れる。spike では次のようにした。
1. 要求は、serve を呼んだその場で(最初に止まる前に)Wasm が読み終える。
2. fetch の結果は、止まる import(host.fetch)では JS の側に番号札を付けて取っておくだけにし、Wasm がその番号札で止まらない import(host.take(ticket))を呼んだときに番地 0 に写す。止まる import が値を返してから Wasm が動き出すまでのあいだに、ほかの要求の JS が走りうるため。最初の版は結果を 1 つの変数に取っておいていたので、2 つの fetch がほぼ同時に解決すると、別の要求の結果を取りうる形だった(spike をリポジトリに移すときに気づいて直した。make concurrent は、同時に解決する 2 つの上流を含めて、どの要求も自分の結果を受け取ることを確かめる)。
3. 応答も同じ理由で、serve の戻り値ではなく、止まらない import(host.done(id, n))で、その場で JS に写させる。serve には要求ごとの id を渡す。
ライブラリにするなら、@wasm.handle とアダプターをこの形(serve(id, n)、take(ticket)、done)に変え、Request に「ホストの fetch を呼ぶ」関数を足せばよさそう。KV や R2 も、同じように import を足せば呼べるはず(試していない)。
- 本番の Workers で JSPI が使えるかは確かめていない(spike の Worker をデプロイしていない)。
- 止まるたびのコスト、止まったスタックのメモリ、例外が JS と Wasm をまたぐときの扱いは見ていない。
計測の道具 #
この節までの計測は moonbit/experiments/http-bench に置いた。make build が PR #31 の最後のコミット、ライブラリに切り出したコミット、Router に載せ替えたコミットのブログを git の worktree でビルドし、make bench が「処理時間(Node)」の表を、make bench-body が本文の大きさごとの数字を出す。
確かめられなかったこと #
- 例の
echoはデプロイしていないので、POST の本文やフォームを本番の Workers で受けたことはない。本番ではcf-*などのヘッダーが増える。 - multipart の、
Content-Transfer-Encodingや、欄の名前に RFC 2231 のfilename*=を使う形は扱っていない。filename*は無視され、filenameがなければファイルでない欄として扱われる。 - 本文の上限は決めていない。Workers の要求の本文の上限(プランによる)と、Wasm の線形メモリの上限(4 GiB)のどちらに先に当たるかは見ていない。
次の一歩 #
1. JSPI の形(spike)をライブラリに取り込み、本番の Workers で動くか確かめる。Static Assets から文章を取る TODO の決めることと、そのままつながる(Wasm が Static Assets の fetch を待てれば、文章を先に全部渡さなくてよい)。
2. ライブラリを別の module(moon.mod)に分けて、ブログから依存する形にしてみる。
3. 本文の文字列化と JSON 化が 1 バイトあたり 30 ns かかる内訳を分けて測る。