Writing a simple WASM API layer using interface types and Wasmtime
The WebAssembly interface types proposal aims to add a new common set of interface types to the core specification that would describe abstract, higher level values, and the ability to adapt the interface of a module so that different hosts can inter-operate using the higher level types. For a comprehensive explainer of the problems interface types are supposed to solve, Lin Clark has an excellent article on the Mozilla Hacks blog, with a demo of using the same markdown renderer compiled to WebAssembly, and using native strings from languages like Rust, Python, or JavaScript:
The goal is for compiler toolchains to automatically generate interface types when compiling a module, as well as to read interface types and adapt the types needed in the module to the types used by the host. For the purpose of this article, however, you can think of an interface type as a regular interface or trait: it defines shared behavior in an abstract way, without providing an actual implementation for how to achieve that behavior.
In this article, we will manually write an interface type for a simple calculator module, then use Wasmtime tooling to correctly implement that interface type in Rust, link our implementation, and use it in a module that requires a calculator library. The examples used will be purposefully simple, and the goal is to show how to currently use this set of tools.
You can find the complete project on GitHub.
Writing a simple interface type in WITX
The current proposal describes interface types using WITX
, an
experimental file format based on the WebAssembly Text Format, with added
support for module types and annotations. It is
how the WASI API is defined, and if you are familiar with .wat
files, .witx
should seem familiar.
The goal is to have a calculator module with a single function that adds two
numbers. Let’s describe this using WITX in a file called calculator.witx
:
(use "errno.witx")
;;; Add two integers
(module $calculator
(@interface func (export "add")
(param $lh s32)
(param $rh s32)
(result $error $errno)
(result $res s32)
)
)
errno.witx
is another WITX file that describes a custom, user-defined error type returned from the function. You can find its definition in the repository here.
The WITX file defines a calculator
module with a single function, add
, which
takes two 32-bit signed integers and returns a 32-bit signed integer, or an
error. Now we can use wiggle
, a Rust crate that generates Rust code
based on interface types definitions, and get a strongly-typed Rust trait based
on the WITX file above:
wiggle::from_witx!({
witx: ["examples/calculator.witx"],
ctx: CalculatorCtx,
});
pub struct CalculatorCtx {}
According to the wiggle
documentation, the from_witx
macro
takes a WITX file and generates a set of public Rust modules based on the
interface type definition. Specifically, it generates a types
module that
contains all user-defined types, and one module for each WITX module
defined
that contains Rust traits that have to be implemented by the structure passed as
“context”.
This means there is now a Calculator
trait with a single add
method that our
CalculatorCtx
structure has to satisfy:
::: src/calculator.rs:6:1
|
6 | pub struct CalculatorCtx {}
| ------------------------ method `add` not found for this
|
= help: items from traits can only be used if the trait is implemented and in scope
= note: the following traits define an item `add`, perhaps you need to implement one of them:
candidate #1: `calculator::calculator::Calculator`
candidate #2: `std::ops::Add`
error[E0277]: the trait bound `calculator::CalculatorCtx: calculator::calculator::Calculator` is not satisfied
--> src/calculator.rs:1:1
|
1 | wiggle::from_witx!({
| ^------------------
| |
| _required by `calculator::calculator::Calculator::add`
| |
2 | | witx: ["examples/calculator.witx"],
3 | | ctx: CalculatorCtx,
4 | | });
| |___^ the trait `calculator::calculator::Calculator` is not implemented for `calculator::CalculatorCtx`
calculator.rs(12, 1): implement the missing item:
fn add(&self, _: i32, _: i32) -> std::result::Result<i32, calculator::types::Errno> { todo!() }
We implement the add
method, and at this point, CalculatorCtx
can be used as
our implementation for the calculator interface.
impl calculator::Calculator for CalculatorCtx {
fn add(&self, lh: i32, rh: i32) -> Result<i32, types::Errno> {
Ok(lh + rh)
}
}
The interface type definition for the add
method uses s32
, or signed
integers. However, s32
is not a fundamental WebAssembly data type (quick
reminder that the fundamental data types in WebAssembly are:
ππ₯π€ | ππ¨π¦ | πΏπ₯π€ | πΏπ¨π¦
). This is where the interface types
proposal defines the additional integer data types:
In addition to
string
, the proposal includes the integer typesu8
,s8
,u16
,s16
,u32
,s32
,u64
, ands64
. […] Since values of these types are proper integers, not bit sequences like core wasmi32
andi64
values, there is no additional information needed to interpret their value as a number.
The trait generated by wiggle
maps the signed 32-bit integer from the
interface types proposal to the Rust signed integer, i32
(not to
be confused with the WebAssembly i32
data type, which is not inherently
signed or unsigned, [its] interpretation is determined by individual
operations). Before actually instantiating the module that will use this
implementation, the Rust i32
will be mapped to the WebAssembly i32
data
type.
Note that at this point, the Rust implementation above can also be compiled as a standalone WebAssembly module and instantiated separately.
Using Wasmtime to link the calculator library
Now that we have an actual implementation that we know satisfies the interface
defined in the WITX file, we can use it to instantiate a module that imports
that functionality in a file called using_add.wat
:
(module
(import "calculator" "add" (func $calc_add (param i32 i32) (result i32)))
(func $consume_add (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs
local.get $rhs
call $calc_add)
(export "consume_add" (func $consume_add))
)
There is nothing special about the WAT file above: it defines an import for an
add
function that takes two i32
parameters and returns an i32
. (Note the
parameters are fundamental WebAssembly types). Then, we call this function later
in our module’s implementation.
In order to successfully instantiate the module above, we need to satisfy its
imports. This is where our implementation becomes useful - we can use the
Wasmtime linker to define the implementation for the add
function
required by the module by creating an instance of the CalculatorCtx
structure
defined earlier, and returning the result of its add
method.
let mut linker = Linker::new(store);
linker.func("calculator", "add", |x: i32, y: i32| {
let ctx = calculator::CalculatorCtx {};
ctx.add(x, y).unwrap()
})?;
linker.instantiate(&module)
Then, we continue to use Wasmtime to instantiate, use stdout
and stderr
for
output and error reporting, and link the current WASI implementation. The result
of building the Rust program that instantiates WebAssembly modules using
Wasmtime and links our calculator implementation is an executable, wasm-calc
,
whose complete implementation can be found on GitHub. Now we can use
this binary to instantiate any generic WASI module, as well as modules that
require a calculator
module. Similarly to how Wasmtime works, the arguments
are the module to run, the name of the function to execute, followed by its
arguments:
Building the project and executing our .wat
file, we see that our add
implementation is properly linked, the module correctly instantiated, and the
consume_add
function from the module successfully executed:
$ ./target/debug/wasm-calc examples/using_add.wat consume_add 1 2
3
For simplicity, the example above is written in the WebAssembly text
format - but in a real scenario, something similar to that would
be generated by a compiler. If compiled using a WebAssembly target
(wasm32-unknown-unknown
or wasm32-wasi
), the following Rust
program generates an equivalent module:
#[link(wasm_import_module = "calculator")]
extern "C" {
fn add(lh: i32, rh: i32) -> i32;
}
#[no_mangle]
pub unsafe extern "C" fn consume_add(lh: i32, rh: i32) -> i32 {
add(lh, rh)
}
For a guide on manually linking WASI imports in Rust, check out @kubkon’s article.
For an example of using .NET to instantiate WebAssembly modules using Wasmtime, check out @peterhuene’s article on the Mozilla Hacks blog.
While the semantics are different, functionally, the same thing happens: we
declare the signature of an external function that we expect from a module
called calculator
, and we use that function later in the program, and we can
invoke the consume_add
function in the same way from our wasm-calc
binary:
$ ./target/debug/wasm-calc examples/using_add/target/wasm32-wasi/debug/using_add.wasm consume_add 1 99
100
Conclusion
The big advantage here is that if either the interface type definition, or the
implementation changes, we get a Rust compilation error because the
calculator::Calculator
trait is no longer satisfied by CalculatorCtx
,
ensuring that the interface and implementation are always in sync.
This doesn’t mean, that the experience cannot be improved - for example
determining if an interface type definition satisfies the imports for
instantiating a given module, compiler support for generating interface types,
or automatically linking all the exports required by a module given a list of
dependent modules (the wig
crate does something similar for WASI
imports).
In this article we explored a very narrow use case by manually writing an
interface type file. But as more and more compiler toolchains start supporting
WebAssembly, that is probably not how most people will end up using interface
types, and in an ideal scenario, most consumers of modules would not be aware of
interface types, but benefit from tools that automatically generate them and
code based on them (wasm-bindgen
is an excellent example of
tooling that generates both Rust bindings based on interface types, as well as
interface types based on exported members in Rust code).
The interface types proposal is still in early stages, but if you are interested in language interoperability and sandboxing, these are incredibly exciting times.
Special thanks to @peterhuene, @kubkon, and @ppog_penguin for reviewing this article.