wa.dev に WebAssembly Component Model のライブラリを公開してみた
MoonBit で書いたシンタックスハイライトを、WebAssembly Component Model 形式で wa.dev に公開してみました。
これです。
wa.dev とは
wa.dev は、WebAssembly Component Model のパッケージレジストリ。npm や crates.io の WASM Component 版のようなものです。
Warg (WebAssembly Registry) プロトコルに基づいており、以下の特徴があります。
-
言語非依存:
.wasmで配布されるので、言語に依存しません - 型安全な相互運用: WIT で定義されたインターフェースにより、言語間で型安全にライブラリを共有できます
- 分散型: Warg プロトコルにより、複数のレジストリを連携可能
WIT については Component Model Documentation を参照してください。
関連ツール:
-
wkg- パッケージの取得 (wkg get)cargo install wkg
-
warg- .wasm のダウンロードやパッケージの公開 (warg publish)cargo install warg-cli
公開したもの
mizchi:tmgrammar - シンタックスハイライト用のレンダラーライブラリ
シンタックスハイライターです。
トークン配列を受け取って、HTML または ANSI エスケープシーケンスに変換するライブラリです。
ここで配布してる .wasm を wkg で落としてくる... 前に、 wkg config --edit で wa.dev から落としてくることを指定します。分散レジストリという仕様なので、どこから落とすかをまず指定する必要がある、という理解
[namespace_registries]
mizchi = "wa.dev"
[package_registry_overrides]
[registry]
[registry."wa.dev"]
type = "warg"
$ wkg get mizchi:tmgrammar
No version specified; fetching version list...
Getting mizchi:tmgrammar@0.1.1...
Wrote './mizchi_tmgrammar@0.1.1.wasm'
wasmtime で実行してみます。
❯ wasmtime run --invoke 'highlight-ansi("fn main() {}", "moonbit")' ./mizchi_tmgrammar@0.1.1.wasm
"\u{1b}[90m1 │ \u{1b}[0m\u{1b}[34;1mfn\u{1b}[0m \u{1b}[37mmain\u{1b}[0m\u{1b}[37m(\u{1b}[0m\u{1b}[37m)\u{1b}[0m \u{1b}[37m{\u{1b}[0m\u{1b}[37m}\u{1b}["
ターミナルで装飾する用の制御コードつきで吐いています。 wasmtime は WAVE というフォーマットでエンコードされて返ってくるので、そのままだとエスケープされています。このエスケープを剥がすなら、次のように置換してください。
❯ wasmtime run --invoke 'highlight-ansi("fn main() {}", "moonbit")' ./mizchi_tmgrammar@0.1.1.wasm | sed 's/^"//;s/"$//' | sed 's/\\u{1b}/\x1b/g; s/\\n/\n/g'
他に型定義の .wit で落としてきたり、パッケージマネージャとして使う方法もあるっぽいんですが、それは別の記事で解説します。(というか自分もまだ調べてない)
JavaScript / Node.js から使う場合
component model にはそれ自体に型定義が埋め込んであり、それをみてホスト側のバインディングを生成します。
JS なら jco を使います。
# jco で JS バインディングを生成
npm install -g @bytecodealliance/jco
jco transpile mizchi_tmgrammar@0.1.1.wasm -o tmgrammar
mizchi/tmgrammar shiki 互換のフォーマットを返すように実装しているので、この機能を使ってみます。
import { htmlRenderer, ansiRenderer } from './tmgrammar.js';
// トークン配列(shiki などのトークナイザーから取得)
const lines = [
{
tokens: [
{ content: 'fn ', scopes: ['keyword'] },
{ content: 'main', scopes: ['entity.name.function'] },
{ content: '() {', scopes: ['punctuation'] },
]
},
{
tokens: [
{ content: ' ', scopes: [] },
{ content: '// comment', scopes: ['comment'] },
]
},
{
tokens: [
{ content: '}', scopes: ['punctuation'] },
]
}
];
// HTML 出力(VS Code Dark+ テーマ)
const theme = htmlRenderer.darkPlusTheme();
const html = htmlRenderer.renderHtml(lines, theme, {
lineNumbers: false,
preClass: null,
codeClass: null,
language: 'moonbit',
});
console.log(html);
出力:
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4">
<code data-language="moonbit">
<span class="line"><span style="color:#569CD6">fn </span><span style="color:#DCDCAA">main</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#D4D4D4"> </span><span style="color:#6A9955">// comment</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
</code></pre>
使うだけならこれだけです。以下は、自分でこれを作りたい人向けの解説。
使い方
WASM Component Model とは
WASM Component Model は、WIT (WebAssembly Interface Types) という IDL でインターフェースを定義し、言語間で型安全にデータをやり取りできる仕組み。
今回の例で、WIT で定義されたインターフェース
package mizchi:tmgrammar@0.1.1;
interface types {
record token { content: string, scopes: list<string> }
record token-line { tokens: list<token> }
record theme-color { foreground: option<string>, background: option<string>, font-style: option<string> }
record theme-rule { scope: string, color: theme-color }
record html-theme { name: string, foreground: string, background: string, rules: list<theme-rule> }
record html-render-options { line-numbers: bool, pre-class: option<string>, code-class: option<string>, language: option<string> }
}
interface html-renderer {
render-html: func(lines: list<token-line>, theme: html-theme, options: html-render-options) -> string;
dark-plus-theme: func() -> html-theme;
light-plus-theme: func() -> html-theme;
}
interface ansi-renderer {
render-ansi: func(lines: list<token-line>, line-numbers: bool) -> string;
highlight-ansi: func(code: string, language: string) -> string; // コードを直接ハイライト
}
MoonBit での実装
ここからは、このライブラリを MoonBit で実装して wa.dev に公開するまでの手順。
Moonbit なのは、エコシステムが不足してても wasm 経由でライブラリを使えれば問題が解決すると思ったので
必要なツール
# MoonBit コンパイラ
# https://www.moonbitlang.com/ からインストール
# wit-bindgen: WIT からバインディング生成
cargo install wit-bindgen-cli
# wasm-tools: WASM Component の作成
cargo install wasm-tools
# warg: wa.dev への publish
cargo install warg-cli
WIT インターフェースの定義
wit/world.wit を作成し、エクスポートする関数と型を定義する。
MoonBit バインディングの生成
wit-bindgen moonbit wit/world.wit --out-dir ./component --derive-eq --derive-show
生成されるもの:
-
component/ffi/- FFI プリミティブ -
component/gen/- エクスポート用ラッパー -
component/gen/interface/*/stub.mbt- 実装を書くスタブファイル
UTF-8 変換の実装(重要)
MoonBit は内部で UTF-16 を使用するが、Component Model は UTF-8 を期待する。
wit-bindgen 0.47.0 時点ではこの変換が自動で行われないため、自前で UTF-8 エンコーダー/デコーダーを実装する必要がある。
// UTF-8 エンコード
pub fn str_to_utf8_ptr(s : String) -> (Int, Int) {
let bytes = string_to_utf8_bytes(s)
let len = bytes.length()
let ptr = malloc(len)
for i = 0; i < len; i = i + 1 {
store8(ptr + i, bytes[i].to_int())
}
(ptr, len)
}
// UTF-8 デコード
pub fn utf8_ptr_to_str(ptr : Int, len : Int) -> String {
if len == 0 { return "" }
let bytes : Array[Byte] = []
for i = 0; i < len; i = i + 1 {
bytes.push(load8_u(ptr + i).to_byte())
}
utf8_bytes_to_string(bytes, len)
}
生成された ffi/top.mbt の str2ptr / ptr2str をこれらで置き換える。
Step 4: スタブの実装
gen/interface/*/stub.mbt に実際の処理を実装:
pub fn render_html(
lines : Array[@types.TokenLine],
theme : @types.HtmlTheme,
options : @types.HtmlRenderOptions,
) -> String {
let buf = StringBuilder::new()
buf.write_string("<pre class=\"shiki ")
buf.write_string(theme.name)
// ... 実装
buf.to_string()
}
ビルド
# MoonBit で WASM にコンパイル
moon build --target wasm
# WIT を埋め込んでコンポーネント化
wasm-tools component embed wit/world.wit target/wasm/release/build/gen/gen.wasm -o tmgrammar.core.wasm
wasm-tools component new tmgrammar.core.wasm -o tmgrammar.component.wasm
Step 6: wa.dev への公開
# ログイン(ブラウザで GitHub OAuth)
warg login --registry wa.dev
# namespace 登録(初回のみ)
warg key new
# 公開
warg publish --registry wa.dev --package mizchi:tmgrammar --version 0.1.0 tmgrammar.component.wasm
ハマった点
1. UTF-16 / UTF-8 問題
MoonBit の文字列は UTF-16 だが、Component Model は UTF-8 を期待する。wit-bindgen が生成するコードはこの変換をしないので、自前で実装する必要がある。
2. wit-bindgen の再生成で実装が消える
wit-bindgen moonbit を再実行すると stub.mbt が上書きされる。実装は別ディレクトリに保存して、ビルドスクリプトでコピーするのが良い。
まとめ
- WASM Component Model により、言語を問わず型安全にライブラリを共有できる
- MoonBit から WASM Component を作成し、wa.dev で公開できる
- UTF-8 変換の実装が現時点では必要(将来改善される可能性あり)
リンク
- パッケージ: https://wa.dev/mizchi:tmgrammar
- MoonBit: https://www.moonbitlang.com/
- WASM Component Model: https://component-model.bytecodealliance.org/
- wit-bindgen: https://github.com/bytecodealliance/wit-bindgen
- wkg: https://github.com/bytecodealliance/wasm-pkg-tools
Discussion