🌟

wa.dev に WebAssembly Component Model のライブラリを公開してみた

に公開

MoonBit で書いたシンタックスハイライトを、WebAssembly Component Model 形式で wa.dev に公開してみました。

これです。

https://wa.dev/mizchi:tmgrammar

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

https://zenn.dev/chikoski/articles/wkg-command-101

公開したもの

mizchi:tmgrammar - シンタックスハイライト用のレンダラーライブラリ

https://wa.dev/mizchi:tmgrammar

シンタックスハイライターです。
トークン配列を受け取って、HTML または ANSI エスケープシーケンスに変換するライブラリです。

ここで配布してる .wasmwkg で落としてくる... 前に、 wkg config --editwa.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 を使います。

https://github.com/bytecodealliance/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.mbtstr2ptr / 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 変換の実装が現時点では必要(将来改善される可能性あり)

リンク

Discussion