Skip to content

Instantly share code, notes, and snippets.

@mizchi
Created August 10, 2024 07:08
Show Gist options
  • Save mizchi/87b4e0e9580b9777fbb75a4bc256fc27 to your computer and use it in GitHub Desktop.
Save mizchi/87b4e0e9580b9777fbb75a4bc256fc27 to your computer and use it in GitHub Desktop.
Moonbit Supports Component Model: Calling from jco+TypeScript

Original Version(Japanese)

https://zenn.dev/mizchi/articles/moonbit-component-model-support


Moonbit Supports Component Model: Calling from jco+TypeScript

What has become possible

Examples of what is now possible:

  • Call Moonbit code with TypeScript types
  • Call rust-wasm generated wasm code from Moonbit
  • Write CLI with wasi, and servers with wasi-http

What is component-model?

Wasm by itself can only have numerical function calls as interfaces. Component-model declares interfaces with an IDL called wit and embeds interfaces into wasm binaries.

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

The user side (guest) generates calling code for their language from the embedded interface.

The component-model generation tool for JS is jco, and for Rust, it's wit-bindgen.

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

This time, it's about wit-bindgen for Moonbit being supported.

The WIT IDL that defines FFI for component-model is language-independent. Example:

package local:demo;

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

examples/wasi-http

Running https://github.com/moonbitlang/moonbit-docs/tree/main/examples/wasi-http locally.

Currently, it's implemented in Rust, so cargo is needed.

# Install moonbit and rust (steps omitted)
$ 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

(I'll explain what each task does later)

This prepares various stubs according to the type definition.

Add wasi/io/streams to import in interface/exports/wasi/http/incomingHandler/moonbit.pkg.json.

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

(Without io/streams, the impl for OutputStream couldn't be called, and the next code couldn't compile)

Edit interface/exports/wasi/http/incomingHandler/top.mbt as follows:

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
}

Build in this state and run with wasmtime (before that, you need to apply a patch for the current version. Explained later)

# Apply the patch for Error: 20240810 mentioned below
$ make build
$ make serve # Server starts on localhost:8080

If HelloWorld is returned, it's successful.

Error: 20240810

(This section will be removed once the problem is resolved)

Probably just a current issue, but interface/imports/wasi/io/poll/top.mbt couldn't be built, maybe due to a reserved word being added to the compiler or some other problem.

It worked when I renamed 'in' to '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
}

I've reported it, so it should be fixed soon.

What happened in make

Let's look at regenerate, which generates type definitions in make.

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

wit-deps update

wit-deps is like a version manager for wit type definitions.

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

This resolves external type references from wit/deps.toml.

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

When you run wit-deps update, wit/deps.lock is generated, and wit files are generated under wit/deps/*.

What's actually needed before running wit-deps is just this:

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

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

This generates the following Moonbit code targeting moonbit:

interfaces/: Stubs for interfaces declared in wit
worlds/: Stubs for implementations corresponding to wit worlds
ffi/: Helpers for memory allocation
gen/: Entry points for building

ffi and gen seem to have no room for modification, so they could be .gitignored.

Although interfaces/ and worlds/ are marked "DO NOT EDIT!", I couldn't find any other entry point to edit, so I edited 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")
}

I wrote the implementation in the previous code here.

interface/exports/* is where you write implementations of interfaces provided externally, but interfaces/imports/* just wraps interfaces to external components (wasi-http), so it seems it could be .gitignored.

This implementation is called by gen/interface_exports_wasi_http_incoming_handler_export.mbt.

Dependency declaration in 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"
    }
  ]

Finally, moon fmt is run on the generated code.

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

Build with --target wasm, then embed the component-model definition into the binary with wasm-tools component embed wit .... This completes the executable binary.

Finally, run this wasm binary as a server process with wasmtime:

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

In gen/moon.pkg.json, it seems to export the entry point to match the wasmtime interface. (I'll investigate this later)

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

Calling moonbit component from TypeScript using jco

The previous example was more about using wasi-http rather than component-model, so let's define our own wit and call a simple module as TypeScript.

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

Define a simple add function as an example.

package local:demo;

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

Generate code based on this with wit-bindgen moonbit --out-dir . wit --derive-show --derive-eq

worlds/app/top.mbt looks like this:

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

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

Although it says DO NOT EDIT, write the implementation here.

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

Build it.

$ 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

At this point, the component-model wasm binary is complete.

Next, use jco to generate TypeScript calling code from the component-model binary.

$ 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 has types.

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

Try calling it from deno.

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

So, we were able to call code generated from moonbit with types from TypeScript. This nicely connects TypeScript and Moonbit.

What didn't work

Everything was fine up to here, but when I tried to pass structured data using record in wit, the call failed.

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)

From reading the generated wat, it seems that with --target wasm, the instructions for wasm-gc are replaced with unreachable. I tried replacing moon build --target wasm -g with moon build --target wasm-gc -g, but then wasm-tools component new target/wasm/debug/build/gen/gen.wasm -o target/wasm/debug/build/gen/gen.wasm -g doesn't work.

I'm asking on Discord, but I think it might not be supported yet?

There's a discussion about cross-over component-model in the following issue, could it be related? I'll check later.

WebAssembly/component-model#275

Impression

It works, sort of. It works, but... there are many unorganized parts that require brute force. I think we need to organize the tooling nicely. Support for structured data (record) needs to be confirmed.

It was really just implemented last week, so it should be organized from here. There's a large part where I'm just reading and forcing it to work on my own. I learned a lot in the process of making it work, but it seems unreasonable to expect this from general users.

wasi-http was featured, but I wanted to use component-model for communication with TypeScript locally, not wasi, so I want to give feedback on that area. I want them to specify what to write in .gitignore, and clearly state which parts of interfaces and worlds are entry points.

Nevertheless, this has brought it much closer to practical use.

The last missing piece for moonbit is async await. I'm looking forward to it.

Also, I'm the only one researching moonbit, so I hope everyone else will try it too.

TODO

https://github.com/oboard/mocket

Is this a Moonbit server using wasi-http?

Create a rust + wasm sample.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment