探索メモ: ts 版のブログで、tinymarkdown を TS で書かずに MoonBit の js ビルドで済ませる #
ts 版のブログ(ts/)は、go/pkg/tinymarkdown を TS に移した ts/pkg/tinymarkdown で Markdown を読んでいた。この移植を持たずに、moonbit/pkg/tinymarkdown を **js ターゲットでビルドしたもの**をそのまま使えるかを確かめ、そのとおりに替えた記録(2026-10-17)。
調べたのは js ビルドそのもので、Wasm ではない。
| 問い | 答え | 確かめ方 |
|---|---|---|
| 1. TS の移植なしで、TS から型付きで使えるか | **使える** | MoonBit 側(moonbit/pkg/tinymarkdown/js)で、TS の型と同じ形の JS オブジェクトを組んで返す。ts/ の型はそのまま残し、実装だけ tinymarkdown.mjs からの再 export にした。置き換えた時点で、npm run check(vitest 146 件と tsc --noEmit)が通った |
| 2. ブログが同じ HTML を返すか | **返す** | 替える前と後を vite build し、wrangler dev で配って全ページを比べた。49 パスで差分なし |
| 3. 代わりに払うもの: 大きさ | Worker のバンドルが **+64 KB(gzip で +9 KB)** | vite build の出力。446.95 KB → 510.57 KB、gzip で 138.74 KB → 148.12 KB |
| 4. 代わりに払うもの: 速さ | parse が、温まった後で **約 4 倍**、JIT が効く前は **約 10 倍** 遅い | Node 24 で、記事と文書 22 本(279 KiB)を解析。温まった後は TS の移植 0.60 ms、MoonBit 2.46 ms。新しいプロセスで 1 回だけ、JIT なしで解析すると 3.0 ms と 29 ms |
| 5. 本番の起動時間 | Worker Startup Time が **51 ms**(TS の移植のときは 11〜18 ms) | npm run deploy の表示(1 回のデプロイ) |
結論 #
- **TS の移植は要らない。** MoonBit の js ビルドに TS の型を付けるだけで、ブログのコードは変えずに動き、出力も同じ。Markdown の解析の実装は MoonBit の 1 つになり、TS 側に残るのは型の定義だけ。tinymarkdown のテスト(Go のテストケースを TS に移したもの)も、MoonBit 側のテストと重なるので消した。JS オブジェクトの形は
moonbit/pkg/tinymarkdown/jsのテストで見る。 - **払うのは、バンドルの gzip で 9 KB と、起動時間の 30〜40 ms。** 温まった後の差は 2 ms 弱だが、ts 版は isolate の起動時に全部の文章を 1 回だけ解析するので、JIT が効く前の遅さがそのまま出る。MoonBit の js ビルドは最適化する JIT に頼っていて、それがないと TS の移植の約 10 倍かかる(下の「4. 速さ」)。本番の Worker Startup Time は 11〜18 ms から 51 ms になった。
- **生成した JS はコミットする。**
ts/の作業に moon を要らないようにするため。MoonBit 側を直したらmoonbit/でmake tsを走らせる。忘れるとmake checkが落ちる。 - **tinyfrontmatter は同じようにはできなかった。** MoonBit の
Mapが読み込みのときに乱数を使うので、workerd で Worker が起動しない(下)。
形 #
moonbit/pkg/tinymarkdown/js/js.mbt:parse(src) -> Jsとinlines(s) -> Js。@tinymarkdownの結果をたどり、{ kind: "section", level, title, children }のような JS オブジェクトをextern "js"の小さな関数({}を作る、キーに値を入れる、配列に push する)で組む。supported_targets = "+js"なので、wasm-gc と native のmoon checkには入らない。テストはmoon test --target jsで、JSON.stringifyした結果を見る(make testに入れた)。moonbit/Makefileのts:moon build --target js --release pkg/tinymarkdown/jsの出力をts/pkg/tinymarkdown/tinymarkdown.mjsに写す。make checkは最後にmake tsを走らせ、差分があれば落ちる(.mbtiと同じやり方)。ts/pkg/tinymarkdown/tinymarkdown.ts: 型(Document、Block、Inlineなど)と、export { inlines, parse } from "./tinymarkdown.mjs"。ts/pkg/tinymarkdown/tinymarkdown.d.mts:tinymarkdown.mjsの型。moon が生成する.d.tsは戻り値がanyなので、手で書いた。moon 0.1.20261012 の.d.tsで型が付くのはString、Int、Double、Bool、T?(T | undefined)と関数だけで、Array、struct、enum、#external typeはanyになる。型を指定する方法も見当たらなかった。
.mbti から .d.ts を作る mizchi/mbts(0.0.3、最後のコミットは 2025-12-21)もある。これを使っても、MoonBit 側で JS オブジェクトを組む部分は省けない。値の形を moon 0.1.20261012 の js ビルドで確かめると、次のようだった。
| MoonBit の型 | mbts が付ける型 | 実際の値 |
|---|---|---|
Array[T] | Array<T> | JS の配列。合っている |
struct | フィールドを持つ interface | フィールドを持つオブジェクト。合っている |
enum | $tag がケースの名前、中身が $0、$1 | $tag は数(0、1)、中身は _0、_1。合わない |
Map[K, V] | JS の Map<K, V> | MoonBit のハッシュ表のオブジェクト(entries、size などを持つ)。合わない |
tinymarkdown の Block と Inline は enum なので、型が合わない。仮に合っていたとしても、ブログのコードは kind: "section" で分けているので、$tag の数で分けるように書き直すことになる。
MoonBit の js ビルドでは、String は JS の文字列、Int は JS の数になる。enum は、ケースごとのクラスに $tag と _0、_1 のようなフィールドを持たせた形になる。そのままでは TS の kind で分ける型にならないので、MoonBit 側で JS オブジェクトを組んだ。
問いごとの結果 #
替える前に、TS の移植を残したまま、Vite のプラグインで import 先だけを MoonBit 版に替える spike で確かめた(コミット 4f1eb12 の moonbit/experiments/js-tinymarkdown。置き換えたあとに消した)。表の 1 と 2 は置き換えたあとにも確かめ直した。
1. 型付きで使えるか #
spike では、build/tinymarkdown.mjs を空の結果を返すものに替えると 95 件が落ち、元に戻すと 146 件が通った。差し替えが実際に効いていることの確認。置き換えたあとは、ts/ の vitest 146 件と tsc --noEmit が通った。その後 tinymarkdown のテストを消したので、今の ts/ に残るのはブログのテストだけ。Node 24 から tinymarkdown.ts を直接 import しても動く。
2. ブログが同じ HTML を返すか #
置き換える前の ts/pkg/tinymarkdown/tinymarkdown.ts に戻してビルドしたものと、置き換えたあとのものを wrangler dev で配った。/、/docs/、存在しないページから、たどれる記事と文書を全部取って比べ、49 パスでステータスも本文も同じだった。workerd の中でも動いている。
3. 大きさ #
| 大きさ | gzip | |
|---|---|---|
| ブログのバンドル(TS の移植) | 446.95 KB | 138.74 KB |
| ブログのバンドル(MoonBit の js ビルド) | 510.57 KB | 148.12 KB |
| tinymarkdown だけ、TS の移植(esbuild で minify) | 3,452 B | 1,388 B |
| tinymarkdown だけ、MoonBit の js ビルド(esbuild で minify) | 20,898 B | 6,241 B |
ブログのバンドルは Vite の既定のまま(Worker 向けは minify しない)。MoonBit の js ビルドには、文字列や配列の実装など core の一部が入る。tinymarkdown だけの行は spike のときのもの。
4. 速さ #
spike の bench.mjs の結果(Node 24.21、Apple Silicon(arm64)の Mac。ウォームは 20 回回してから 50 回の平均)。対象は posts/*.md、TODO.md、docs/**/*.md の 22 本、279 KiB と、そこに含まれる段落と見出し 573 個。両方の結果が同じであることも確かめた。
| 初回 | ウォーム | |
|---|---|---|
TS の移植の parse | 0.86 ms | 0.60 ms |
MoonBit の parse | 3.30 ms | 2.46 ms |
| MoonBit の解析だけ(JS オブジェクトを組まない) | 2.61 ms | 2.42 ms |
TS の移植の inlines | 0.67 ms | 0.16 ms |
MoonBit の inlines | 0.40 ms | 0.27 ms |
解析だけでもほぼ同じ時間がかかるので、遅いのは JS オブジェクトを組むところではなく、MoonBit の解析そのもの。どこが遅いか(行の分割や文字列の組み立てなど)は調べていない。
JIT が効く前の速さは、新しい Node のプロセスで、モジュールを読み込んで 22 本を 1 回ずつ解析した時間で見た(5 回ずつ。TS の移植は esbuild で JS にしたもの)。V8 のフラグで、使う JIT を減らしていった。
| V8 | TS の移植 | MoonBit |
|---|---|---|
| 既定 | 2.3 ms | 7.0 ms |
--no-opt --no-maglev(最適化する JIT なし。Sparkplug まで) | 2.5 ms | 18 ms |
--no-opt --no-maglev --no-sparkplug(インタープリターだけ) | 3.0 ms | 29 ms |
TS の移植はどれでもほとんど変わらないが、MoonBit は最適化する JIT がないと大きく遅くなる。読み込みはどちらも 1 ms 未満。どの関数が遅いか(小さい関数の呼び出しが多いのかなど)は調べていない。
5. 本番の起動時間 #
2026-10-17 に npm run deploy でデプロイしたときの表示。
| TS の移植(前の記録) | MoonBit の js ビルド | |
|---|---|---|
| Total Upload | — | 502.26 KiB(gzip で 145.30 KiB) |
| Worker Startup Time | 11〜18 ms(4 回) | 51 ms(1 回) |
増えた 33〜40 ms は、上の表のインタープリターだけのときの差(26 ms)と同じくらいの大きさ。ただし 1 回のデプロイの値なので、ばらつきは分からない。同じ日の MoonBit 版(Wasm)の blog は 2 ms(Total Upload は 409.86 KiB、gzip で 128.18 KiB)で、こちらは最初のリクエストで解析している。
tinyfrontmatter は同じようにできるか #
**できなかった。** 同じ形で moonbit/pkg/tinyfrontmatter/js を作り、ts/pkg/tinyfrontmatter を置き換えてみた(2026-10-17、コミットはしていない)。Node の vitest は通ったが、wrangler dev では Worker が起動しなかったので戻した。ts/pkg/tinyfrontmatter は TS の移植のまま。
- **workerd で起動しない。**
@tinyfrontmatter.splitはMapを返す。Mapを使うと、MoonBit の core のハッシュのシードを作るコードが入る。このコードはモジュールのトップレベルでcrypto.getRandomValuesを呼ぶ(生成された JS のconst _M0FPB4seed = _M0FPB12random__seed();)。workerd はグローバルスコープでの乱数の生成を禁じているので、Disallowed operation called within global scopeで止まる。Node では止まらないので、vitest では気づけない。tinymarkdown はMapを使わないので、このコードは入らない。 - **core の実装は共有されない。**
.mjsはパッケージごとに、MoonBit の core のうち使う部分を抱える。38 行の TS の移植を置き換えるだけで、ブログのバンドルが 511.15 KB → 557.12 KB、gzip で 148.37 KB → 155.98 KB 増えた。
MoonBit の js ビルドを Worker で使うなら、Map のようにトップレベルで乱数を使う core の機能を避けるか、wrangler dev で起動まで確かめる必要がある。