🌓

moonbitがcomonponent-modelをサポートしたのでjco+TypeScript から呼んでみた

2024/08/10に公開1

Moonbit が Component Model に対応した。

https://www.moonbitlang.com/blog/component-model

これはずっと自分がほしかった機能で、これによって moonbit が実用言語に一つ近づいたと思う。やっていきたい。

何が可能になったか

できることになった例

  • Moonbit で書いたコードを TypeScript で型をつけて呼び出せる
  • rust-wasm で生成した wasm コードを moonbit で呼べる
  • wasi で CLI, wasi-http でサーバーが書けるようになる

component-model とは

wasm はそのままだと数値の関数呼び出ししかインターフェースを持てない。
component-model は wit という IDL でインターフェースを宣言して、wasm バイナリにインターフェースを埋め込む。

https://github.com/WebAssembly/component-model/blob/main/design/mvp/WIT.md

利用側(guest)は埋め込まれたインターフェースから 自分の言語用の呼び出しコードを生成する。

JS の component-model の生成ツールは jco で、Rust だと wit-bindgen になる。

https://github.com/bytecodealliance/jco
https://github.com/bytecodealliance/wit-bindgen

今回は moonbit 用の wit-bindgen がサポートされたという話になる。

component-model のFFIを定義する WIT IDL は特定の言語に依存しないIDL。例。

package local:demo;

world app {
  record point {
    x: u32,
    y: u32,
  }
  export add: func(a: point, b: point) -> point;
}

examples/wasi-http

https://github.com/moonbitlang/moonbit-docs/tree/main/examples/wasi-http を手元で動かす。

現状だと rust で実装されているので、 cargo が必要。

# moonbit と rust のインストールは略
$ brew install wasmtime
$ cargo install wasm-tools wit-deps
$ cargo install wit-bindgen-cli --git https://github.com/peter-jerry-ye/wit-bindgen/ --branch moonbit

# checkout and cd
$ cd examples/wasi-http
$ make regenerate

(各タスクが何をやってるかはあとで解説)

これで型定義に応じた各種スタブが用意される。

interface/exports/wasi/http/incomingHandler/moonbit.pkg.json で wasi/io/streams を import に追加。

{
  "import": [
    {
      "path": "moonbit/example/interface/imports/wasi/http/types",
      "alias": "types"
    },
    "moonbit/example/interface/imports/wasi/io/streams"
  ]
}

(io/steams がないと OutputStream に対する impl が呼べずに、次のコードがコンパイルできなかった)

interface/exports/wasi/http/incomingHandler/top.mbt を次のように編集する。

pub fn handle(
  request : @types.IncomingRequest,
  response_out : @types.ResponseOutparam
) -> Unit {
  let response = match request.path_with_query() {
      None | Some("/") => make_response(b"Hello, World")
      _ => make_response(b"Not Found", status_code=404)
    }
    |> Ok
  response_out.set(response)
}

fn make_response(
  body : Bytes,
  ~status_code : UInt = 200
) -> @types.OutgoingResponse {
  let response = @types.outgoing_response(@types.fields())
  response
  .body()
  .unwrap()
  .write()
  .unwrap()
  .blocking_write_and_flush(body)
  .unwrap()
  response.set_status_code(status_code).unwrap()
  response
}

この状態でビルドして wasmtime で実行(前に、今のバージョンだとパッチを当てる必要がある。後述)

# 下記の Error: 20240810 のパッチを当てる
$ make build
$ make serve # localhost:8080 でサーバが立ち上がる。

HelloWorld が返ってくれば成功。

Error: 20240810

(ここは問題が解決したら消す)

たぶん今だけの問題だと思うが、 コンパイラで予約語が追加されたか何かの問題で、 interface/imports/wasi/io/poll/top.mbt がビルドできなくなった。

in を in2 にリネームしてあげると動いた。

pub fn poll(in2 : Array[Pollable]) -> Array[UInt] {
  let address = @ffi.malloc(in2.length() * 4)
  for index = 0; index < in2.length(); index = index + 1 {
    let element : Pollable = in2[index]
    let base = address + index * 4
    @ffi.store32(base + 0, element.0)
  }
  let return_area = @ffi.malloc(8)
  wasmImportPoll(address, in2.length(), return_area)
  let array : Array[UInt] = []
  for index2 = 0; index2 < @ffi.load32(return_area + 4); index2 = index2 + 1 {
    let base1 = @ffi.load32(return_area + 0) + index2 * 4
    array.push(@ffi.load32(base1 + 0).to_uint())
  }
  @ffi.free(@ffi.load32(return_area + 0))
  @ffi.free(address)
  @ffi.free(return_area)
  return array
}

報告したので、さすがに治ると思う。

make で何が起こっていたか

make で型定義を生成する regenerate から見ていこう。

regenerate:
	@wit-deps update
	@wit-bindgen moonbit --out-dir . wit --derive-show --derive-eq
	@moon fmt

wit-deps update

wit-deps は wit の型定義のバージョンマネージャみたいなもの。

https://github.com/bytecodealliance/wit-deps

これによって、 wit/desp.toml から外部の型参照を解決する。

wit/deps.toml
http = "https://github.com/WebAssembly/wasi-http/archive/v0.2.1.tar.gz"

wit-deps update を叩くと、 wit/deps.lock が生成され、 wit/deps/* 以下に wit ファイルが生成される。

wit-deps を叩く前に必要なものは、実際はこれだけ。

$ tree .
.
├── Makefile
├── README
├── moon.mod.json
└── wit
    ├── deps.toml
    └── world.wit

wit-bindgen moonbit --out-dir . wit --derive-show --derive-eq

moonbit をターゲットに以下の moonbit のコードを生成する。

interfaces/: wit で宣言された interface のスタブ
worlds/: wit の worlds に対応する実装のスタブ
ffi/: メモリアロケーションのヘルパ
gen/: ビルド用のエントリポイント

ffigen はいじる余地がないので .gitignore してよさそうに見える。

interfaces/ と worlds/ は DO NOT EDIT! と書かれているが、ここを編集する以外のエントリポイントがわからなかったので、 interface/exports/wasi/http/incomingHandler/top.mbt を編集していく。

// Generated by `wit-bindgen` 0.29.0. DO NOT EDIT!
/// This function is invoked with an incoming HTTP Request, and a resource
/// `response-outparam` which provides the capability to reply with an HTTP
/// Response. The response is sent by calling the `response-outparam.set`
/// method, which allows execution to continue after the response has been
/// sent. This enables both streaming to the response body, and performing other
/// work.
///
/// The implementor of this function must write a response to the
/// `response-outparam` before returning, or else the caller will respond
/// with an error on its behalf.
pub fn handle(
  request : @types.IncomingRequest,
  response_out : @types.ResponseOutparam
) -> Unit {
  abort("todo")
}

先程のコードはここに実装を書いた。

interface/exports/* は外部に提供する interface の実装を書く場所だが、 interfaces/imports/* は外部のコンポーネント(wasi-http)へのインターフェースをラップしているだけなので、 .gitignore してよさそうに見える。

この実装が gen/interface_exports_wasi_http_incoming_handler_export.mbt によって呼ばれている。

gen/moon.pkg.json の依存宣言

  "import": [
    { "path": "moonbit/example/ffi", "alias": "ffi" },
    {
      "path": "moonbit/example/interface/exports/wasi/http/incomingHandler",
      "alias": "incomingHandler"
    },
    {
      "path": "moonbit/example/interface/imports/wasi/http/types",
      "alias": "types"
    }
  ]

最後に生成したコードを moon fmt して終わり。

make build ~ make server

build:
	@moon build --target wasm -g
	@wasm-tools component embed wit target/wasm/debug/build/gen/gen.wasm -o target/wasm/debug/build/gen/gen.wasm --encoding utf16 -g
	@wasm-tools component new target/wasm/debug/build/gen/gen.wasm -o target/wasm/debug/build/gen/gen.wasm -g

--target wasm でビルドし、 wasm-tools component embed wit ... でバイナリに component-model 定義を埋め込む。これで実行可能なバイナリが完成。

最後に wasmtime でこの wasm バイナリをサーパープロセスとして実行する

serve: build
	@wasmtime serve target/wasm/debug/build/gen/gen.wasm

gen/moon.pkg.json では wasmtime のインテーフェースに合わせて、エントリポイントを export してそう。(あとで調べる)

  "link": {
    "wasm": {
      "exports": [
        "cabi_realloc:cabi_realloc",
        "wasmExportHandle:wasi:http/incoming-handler@0.2.1#handle"
      ],
      "export-memory-name": "memory",
      "heap-start-address": 0
    }
  },

jco で TypeScript から moonbit component を呼び出す

今までの例は component-model というより wasi-http を使うものという側面が強かったので、自分で wit を定義し、簡単なモジュールを typescript として呼び出す。

$ moon new trywit
$ rm -r src
$ cd trywit
$ code wit/world.wit

シンプルなadd関数を定義する例。

package local:demo;

world app {
  export add: func(a: u32, b: u32) -> u32;
}

これを元に wit-bindgen moonbit --out-dir . wit --derive-show --derive-eq でコードを生成

worlds/app/top.mbt がこうなってる。

// Generated by `wit-bindgen` 0.29.0. DO NOT EDIT!

pub fn add(a : UInt, b : UInt) -> UInt {
      abort("todo")
}

DO NOT EDIT となっているが、ここに実装を書く。

pub fn add(a : UInt, b : UInt) -> UInt {
  return a + b
}

ビルドする。

$ moon build --target wasm -g
Finished. moon: ran 3 tasks, now up to date
$ wasm-tools component embed wit target/wasm/debug/build/gen/gen.wasm -o target/wasm/debug/build/gen/gen.wasm --encoding utf16 -g
$ wasm-tools component new target/wasm/debug/build/gen/gen.wasm -o target/wasm/debug/build/gen/gen.wasm -g

ここまでで component-model 化された wasm バイナリが完成。

次に、 jco を使って component-model 化されたバイナリから、TypeScript の呼び出しコードを生成する。

$ npx @bytecodealliance/jco transpile target/wasm/debug/build/gen/gen.wasm -o out
# check
$ tree out
out
├── gen.core.wasm
├── gen.d.ts
└── gen.js

gen.d.ts に型が付いている。

export function add(a: number, b: number): number;

これを deno から呼び出してみる。

$ deno eval 'import { add } from "./out/gen.js"; console.log(add(1, 2));'
3

というわけで、moonbit から生成したコードを、typescript から型がついた状態で呼び出せた。
これで TypeScript と Moonbit がいい感じにつながる。

動かなかったもの

ここまではよかったが、wit で record を使って構造化データを受け渡そうとすると呼び出しに失敗した。

package local:demo;

world app {
  record point {
    x: u32,
    y: u32,
  }
  export add: func(a: point, b: point) -> point;
}
error: Uncaught (in promise) RuntimeError: unreachable
    at moonbit.free (file:///Users/kotaro.chikuba/repo/moonbitlang/moonbit-docs/examples/cmp2/out/gen.core.wasm:1:1531)
    at moonbit.gc.free (file:///Users/kotaro.chikuba/repo/moonbitlang/moonbit-docs/examples/cmp2/out/gen.core.wasm:1:1694)

生成された wat を読む限り、これは --target wasm だと wasm-gc 用の命令が unreachable に置換されてるように見えた。試しに moon build --target wasm -gmoon build --target wasm-gc -g に置き換えてみたが、それだと wasm-tools component new target/wasm/debug/build/gen/gen.wasm -o target/wasm/debug/build/gen/gen.wasm -g が通らない。

Discord で質問しているが、おそらくまだ未対応という気がする?

以下の issue で cross-over component-model はないの? という話題があがっているが、関係あるだろうか。あとで

https://github.com/WebAssembly/component-model/issues/275

感想

一応動く。動くが...未整理な部分が多く、腕力がいる。いい感じにツーリングを整理していく必要があると思われる。構造化データ(record)のサポートは要確認。

本当に先週実装されたばかりなので、ここから整理されているはず。自分が勝手に読み込んで無理やり動かしているというところが大きい。動かす過程で色々と勉強になったが、一般的なユーザーに期待するのは無理がありそう。

wasi-http がフューチャーされていたが、自分は wasi ではなく ローカルで TypeScript との疎通のために component-model を使いたかったので、そのへんをフィードバックしていきたい。 何を .gitignore に書いて、interfaces や worlds のどこがエントリポイントなのかを明記してほしい。

とはいえ、これで実用にぐっと近づいた。

moonbit の最後のミッシングパーツは、async await。今後に期待している。

あと自分しか moonbit について調べてないので、みんなも触ってみてほしい。

TODO

https://github.com/oboard/mocket

wasi-http を使った Moonbit サーバーだろうか。

rust + wasm のサンプルを作る。

Discussion

YAMAMOTO YujiYAMAMOTO Yuji

Component Modelが現状GC typesをサポートしておらず、MoonbitがrecordをGC typeとして実装してるからComponent Modelのrecordもサポートしてない、ってことなんすかねぇ。現状Component Modelのrecordは値型しか含まないので実装できない訳じゃないとは思いますが…