Making a plugin system in Rust using Wasmtime and WIT
Wanting to make a service that needed to be extended via plugins, I found myself searching for the available solutions.
I started with a simple search, to find work other people may have already done on this subject, I was met with an excellent series of blog posts from NullDeref, about you guessed it A Plugin System in Rust.
The series goes over the different ways to make a plugin system, like integrating a scripting engine, dynamically loading a library, or the interesting part WebAssembly.
WebAssembly was however ditched because of its data exchange capability. You see, Wasm limits you to integer and float for data sharing, greatly limiting the possibilities.
However, a proposal is currently being discussed to enable Wasm to share way more data types, like strings, arrays, tuples, functions and even classes. However, this proposal, the WebAssembly Component Model proposal, is still being developed and stabilized. This means it is still not native to Wasm, but implementations exist.
By searching for implementation, you might fall upon two repositories, wai-bindgen and wit-bindgen.
Wai-bindgen
wai-bindgen is a project maintained by Wasmer, one of the most popular WebAssembly runtimes, however, no updates have been pushed for more than 8 months. Which makes it no ideal because, the proposal evolved since
Wit-bindgen
The second one wit-bindgen, is a project maintained by the Bytecode Alliance and is trying to stay as close as possible to the proposal, furthermore, in the same way as Wasmer, the Bytecode Alliance maintains a WebAssembly runtime, called wasmtime
Working with WIT
The Wasm Interface Type (WIT) format is an IDL to provide tooling for the WebAssembly Component Model
package example:host
world host {
import print: func(msg: string)
export run: func()
}
The example given by the wit format page
This WIT file defines a package named host
in the namespace example
, as written in the first line package example:host
the whole namespace name defines the ID of the package. Then comes a world
named host, a world
is describing both imports and exports of a component. Here we are importing the print
function and then exporting the run
function. We need to keep in mind that in a WIT file, we are writing from the point of view of the guest
, which means that on the host
side of things, being the runtime, imports, and exports will be reversed
With that in mind, we can translate this simple WIT file to Rust :
wit_bindgen::generate!({
world: "host",
exports: {
world: Host,
},
});
struct Host {}
impl Guest for Host {
fn run() {
//We can call print here
print("Test")
}
}
Notice the usage of the wit_bindgen
generate macro, which will write all the glue and the Guest
trait for us.
pub trait Guest {
fn run();
}
It also exports the run
function through the __export_run
function under the name run
const _: () = {
#[doc(hidden)]
#[export_name = "run"]
#[allow(non_snake_case)]
unsafe extern "C" fn __export_run() {
#[allow(unused_imports)]
use wit_bindgen::rt::{alloc, vec::Vec, string::String};
<_GuestImpl as Guest>::run();
}
};
use Host as _GuestImpl;
On the other side, for the imports, looking at the print function we can see the black magic
#[allow(clippy::all)]
pub fn print(msg: &str) {
#[allow(unused_imports)]
use wit_bindgen::rt::{alloc, vec::Vec, string::String};
unsafe {
let vec0 = msg;
let ptr0 = vec0.as_ptr() as i32;
let len0 = vec0.len() as i32;
#[cfg(not(target_arch = "wasm32"))]
fn wit_import(_: i32, _: i32) {
::core::panicking::panic("internal error: entered unreachable code")
}
wit_import(ptr0, len0);
}
}
It passes a pointer and the length of the string to the host so that the host can read it from the guest's memory.
Building the plugin
Now that we have looked behind the scene, we can go on and compile our plugin to use it later. To build it, we first need to install the wasm32-unknown-unknown
target so run rustup target add wasm32-unknown-unknown
Once the binary is built, you will need to install wasm-tools via cargo install wasm-tools
, you will need it to transform the binary which is a WASM module to a WASM component. For that, you will need to run wasm-tools component new target/wasm32-unknown-unknown/debug/<filename>.wasm -o component.wasm
Running the plugin
I will create a second project to write my runner. The runner will depend on wasmtime, to run the WebAssembly plugin.
After some digging in the crate documentation, I have found out that you need the component-model
feature to generate host bindings from the wit file. This feature gives access to the component
module, which gives us access to the bindgen macro. This macro takes as input the name of the world, so for us, it will be :
wasmtime::component::bindgen!("hello-world");
A macro that in the same way as for the guest will generate a trait that needs to be implemented, so I created the HostImports
struct and implemented the print
function that we defined in the wit file.
struct HostImports {}
impl HelloWorldImports for HostImports {
fn print(&mut self, msg: String) -> wasmtime::Result<()> {
println!("{}", msg);
Ok(())
}
}
Instantiating the component
Wasmtime documentation contains a great hello world for embedding into a Rust program.
A slimed down example would look like this :
use anyhow::Result;
use wasmtime::*;
struct State {}
fn main() -> Result<()> {
let engine = Engine::default();
// Change file path
let component = Component::from_file(&engine, "examples/hello.wat")?;
let mut store = Store::new(
&engine,
State {},
);
let imports = [];
let instance = Instance::new(&mut store, &component, &imports)?;
Ok(())
}
It loads a module and that's all, however, if I try to load our Wasm binary, we are met with a runtime error
Error: failed to parse WebAssembly module
Caused by:
unknown binary version and encoding combination: 0xd and 0x1, note: encoded as a component but the WebAssembly component model feature is not enabled - enable the feature to allow component validation (at offset 0x0)
Checking the docs, I can see that the engine config has a wasm_component_model
method to enable the component model, so I changed the code to :
let mut config = Config::default();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
Now, where to go from this, if I look through the code generated by the macro I can see that HelloWorld
got some methods and in them the instantiate
one with is used to initialize the struct, however, if I look at the definition, I can see that it needs a Component
as the second argument, however, I only got a Module
but the same way as Module
, Component
got a from_file
method, so we can swap it to get something like this
let mut config = Config::default();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let component = Component::from_file(&engine, "plugins/plugin.wasm")?;
let mut store = Store::new(&engine, State {});
let mut linker = Linker::new(&engine);
let (hello_world, _) = HelloWorld::instantiate(store.as_context_mut(), &component, &linker)?;
Now we can try to call our plugin run
function with
hello_world.call_run(store.as_context_mut())?;
However, running this will lead to a runtime error
Error: import `print` has the wrong type
Caused by:
expected func found nothing
To fix this, we need to link our HostImports
to the component, and we can do that by using the add_to_linker
method and replacing our state with a HostImports
instance. So we can add :
HelloWorld::add_to_linker(&mut linker, |caller| {
return caller;
})?;
The final code looks like this :
use anyhow::Result;
use wasmtime::{
component::{Component, Linker},
AsContextMut, Config, Engine, Store,
};
wasmtime::component::bindgen!("hello-world");
struct HostImports {}
impl HelloWorldImports for HostImports {
fn print(&mut self, msg: String) -> wasmtime::Result<()> {
println!("{}", msg);
Ok(())
}
}
fn main() -> Result<()> {
let mut config = Config::default();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let component = Component::from_file(&engine, "plugins/plugin.wasm")?;
let mut store = Store::new(&engine, HostImports {});
let mut linker = Linker::new(&engine);
HelloWorld::add_to_linker(&mut linker, |caller| {
return caller;
})?;
let (hello_world, _) = HelloWorld::instantiate(store.as_context_mut(), &component, &linker)?;
hello_world.call_run(store.as_context_mut())?;
Ok(())
}
Running this will finally let us see the simple Test
message
The source code of both project is available on my GitLab here : https://gitlab.com/blog-aime23/wasmtime-plugin-system