WASI preview 2 has been stabilized, and heralds a new milestone for webassembly. Besides standardizing a number of new APIs like async and sockets, it also brings the Component Model into the limelight.
One of the most long awaited features, it hopes to bring a reality in which any language with a wasi compiler can compile binary modules and talk to any other module through a common interface.
In a previous article I took my Rust/C package femark, built it into a WASIp2 component, and used it from JavaScript in the browser.
Today I find myself rewriting cargo-leptos, the rust CLI build tool for Leptos, to make it cleaner, add new features, and more pluggable. Can I leverage a WASIp2 plugin system to achieve that goal?
Prior Art
I am not the first person to consider using Webassembly for plugins in a Rust app. Let's take a look at a few of them.
Zellij
Zellij, the rust based terminal multiplezer, has a plugin system they've implemented based on creating a WASI preview 1 modules by implementing the ZellijPlugin
trait in their zellij-tile crate. I quite like what they've done. They've implemented a way to get around the lack of async with a call to setup workers on the server. Instead of using WIT as an interface language, they're using Protobuf definitions. WIT's come a long way since preview 1, so it's possible this was the best choice at the time. I know at least one person who's using MessagePack with preview 2 because it's more versatile. These plugins are then compiled and distributed as .wasm
files.
Zed
Zed , a new lightweight ide, has a plugin system that uses WASIp2 and the component model. It seems unique in that plugins are rust crates that build against the zed_extension_api crate, asking you to implement the Extension
trait and run a macro to register it. This abstracts away the rust setup required to compile a WASIp2 module into zed itself, which is a choice. They use WIT as their interface language, but that is hidden behind the api crate, meaning rust developers won't need to worry about wit. That's a neat optimization.
Veloren
Veloren, an open source action RPG, currently has a plugin system based on WASI preview 1, with a system based on WASIp2 proposed here.
The WASIp2 system is interesting in that it appears the intention is to have developers use the WIT files to develop against, instead of hiding them and using a rust crate. All in all, this implementation looks closest to my desired approach. Looking forward to seeing it in production soon.
All of these are good approaches, and I'd encourage you to check them out if they sound appealing. Webassembly as a means of providing plugins for a Rust app appears to be gaining traction in the community as a whole, so I've probably missed several others. Feel free to leave a comment or reach out if you know of any.
None of these quite met my desired goals, which I'll talk about in the next section.
Goals
For cargo-leptos, I'd like a plugin system with the following features.
- Uses the standard wasmtime crate as host.
- Allow plugin distribution as compiled
.wasm
components. - Use WASI preview 2's component model, so plugins could be written in any language(eventually).
- Be easy for people to contribute and install plugins as they please
Overall, I see this as a learning experience. I couldn't find a decent walkthrough/tutorial on how to setup this infrastructure, so I hope this will be useful to others considering this path.
Architecture
Let's give a brief overview of each piece in the system before we dive into their specifics.
Client
I'm using the word Client here to mean an individual plugin. WASI preview 2 calls these components and WASIp1 calls them modules. I'll stick to preview 2 here. I'll need a way to easily define the interface provided by the server, and provide any types or utilities I think might be useful.
WIT seems like a good choice for this interface, and a rust lib crate can be used to abstract that away so Rust developers don't need to think about it, which I see as a bonus.
Server
cargo-leptos is a Rust CLI app, so we'll need to embed a webassembly plugin host inside it. The only real choices for this are wasmtime or something built on it. That'll need to be able to call an unknown at compile time number of plugins. This would mean a system of registering plugins and integrating that with clap. This system seems to be left to the implementor for the most part.
Proof of Concept
First, let's define an API for our plugins to have access to. To do that, we'll use the WIT language, to define a simple function that'll greet the user. This is what it'll look like in Rust:
pub fn greet(name: String)-> String{}
Each plugin will be able to define their own greeting, receiving a name from the host and return a String. Defining this interface should be pretty easy in WIT.
package my:new-world; // World name is used a lot in wasmtime and wit-bindgen world new-world { // export a function called greeting that takes a name with type string and returns a string export greeting: func(name: string) -> string; }
For more details on the WIT language, check out their docs. The language seems fairly close to Rust, and I appreciate it. Let's save that to a world.wit
file for later.
Plugin API Crate
To prevent people from having to think about WIT, I'm going to define a Rust lib crate they can import and implement regular old Rust traits and functions.
cargo new --lib plugin_api
Let's add a dependency on wit-bindgen
, which we'll use to generate Rust types from the file
cargo add wit-bindgen
Next let's create a folder called wit
in the package root, and place the world.wit
file we created in there. We're almost done!
Next we'll go into the lib.rs
and clean it out. We can then add the generate!()
macro to generate the types and export macro, and then export those.
//lib.rs pub mod bindings { use wit_bindgen::generate; // The first arg is the path to our wit file, the second tells it to make our export macro public(which we'll need), and the third will let us change the macro name generate!({path: "./wit/world.wit", pub_export_macro: true, export_macro_name: "export" }); } // Reexport the Guest trait as a different name. entirely optional pub use crate::bindings::{export, Guest as Plugin};
All a person would need to do to create a custom plugin is define a struct, import and implement the Plugin trait on it, and then call the export!()
macro. The export! macro's job is to take a component that implements the trait(Guest/Plugin) and generate #[no_mangle]
functions to implement the ABI. If you recall, the Component model has it's own ABI definition, which is how all the languages talk to each other!
That's it for the plugin_api
crate!
A Custom Plugin Crate
So how will a user create a plugin? Let's make one and find out! Because cargo doesn't implement everything needed for a WASIp2 Component yet, just a WASIp1 Module, we'll use cargo-component to automate the process of setting up and calling Cargo. It can be installed with cargo:
#from source cargo install cargo-component --locked # from binary cargo binstall cargo-component
You'll also want to add the wasm32-wasi
target to rustup so Cargo can build the initial target.
rustup target add wasm32-wasi
You should end up with a pretty standard Rust lib crate. If it's not already set, you'll need to add the name of the world in your wit file to Cargo.toml
[package.metadata.component] package = "my:new-world"
Next we'll do most of the setup in our lib.rs
// We'll import the trait and the export macro from our plugin_api crate use plugin_api::bindings::export; use plugin_api::Plugin; // Define a new struct for your plugin. Name doesn't matter struct MyPlugin; // impl the trait provided by the Plugin API. rust-analyzer should tell you that it expects a function that matches the shape of the function defined in the wit file impl Plugin for MyPlugin { fn greeting(name: String) -> String { println!("STDIO WORKS!"); format!("Greetings {name}! I'm a WASI plugin!") } } // Here we call the export! macro with the struct for our plugin, but since the bindings for wit are defined in plugin_api, we need to add `with_types_in` as the second arg and the path to the bindings as the third export!(MyPlugin with_types_in plugin_api::bindings);
We can now run
cargo component build
and it should build our plugin. Look for the location it provides for the .wasm file
, which is your plugin!
Creating component /projects/wit_plugins/target/wasm32wasip1/debug/custom_plugin.wasm
Now that we have a plugin, let's look at building our host!
Plugin Host
This is the hardest part of the process, mostly because it's not really documented in any of the crates we'll be using. Thanks to Joel Dice for answering my questions as I worked through it.
Let's create a binary crate
cargo init plugin_host
For this crate, there are only two required crates and one optional one.
cargo add wasmtime --features=component-model,cranelift,std,runtime cargo add wasmtime-wasi
We'll be using wasmtime to do the heavy lifting, so we'll need to add it with features to enable creation and processing Components. It also supports WASIp1 modules, and the documentation for wasmtime doesn't always seem to use modules and components to refer to their respective versions. Bear with me here.
If you want to use/support async for either the host or the plugins, you'll also need to add tokio
#We may or not need full here cargo add tokio --features=full
The basic flow looks like this:
- Define
Config
to configure the wasmtime engine - Create an
Engine
that'll run all the plugins. This can be reused throughout the app. - Call the
bindgen!()
macro with the info of your world and whatever settings you'd like. This'll generate helper structs and methods to make your life easier. - Create a new
Linker
which is responsible for providing any import or dependencies the plugins need - Add any wasi imports your plugins need to the linker with a call to
wasmtime_wasi::add_to_linker_async()
. If you chose not to enable async, just drop_async
from that function call - Create a
WasiCtxBuilder
that'll define env vars, define what functionality is available to plugins from the Wasi standard, and more. - Create a state struct that contains any state your plugin needs. You'll have to implement
WasiView
for your state struct, and store theWasiCtx
we created earlier, and a newResourceTable
in that struct as well. - Create a
Store
, which is a collection of Webassembly items that are tied to anInstance
. We'll pass our state struct to it as well. - We don't here, but if you have imports in your WASI, you'll have to impl a trait on your state struct based on your world name, in this case it would be
NewWorldImports
- If you do have imports, you'll need to add them to the linker with a call to `NewWorld::add_to_linker(&mut linker, |state: &mut MyState| state)?
- Next we'll need to load whichever Component/Plugin/
.wasm
file you created from disk with a call toComponent::from_file("/path/to/file.wasm")
. - Next you'll need to instantiate(load) the component into an
Instance
.bindgen!()
should have generated a handy helper for this called eitherNewWorld::instantiate()
orNewWorld::instantiate_async()
. You can useinstantiate_pre()
to do more heavy processing up front, which makes sense if you plan to reuse it. - Grab the World struct instance returned in a tuple from
instantiate_asnyc()
and call the function you'd like with the generatedcall_fn_name()
methods on it. In this case.call_greeting()
. - Store the
Instance
and World struct from the instantiate call to use later(optional) - Do something with it!
Here's how this looks as a complete file:
use wasmtime::{Engine, Result, Store, Config}; use wasmtime::component::{ResourceTable, Linker, bindgen, Component}; use wasmtime_wasi::{WasiCtx, WasiView, WasiCtxBuilder}; #[tokio::main] async fn main() -> Result<()> { let mut config = Config::new(); config.async_support(true); config.wasm_component_model(true); config.debug_info(true); let engine = Engine::new(&config)?; bindgen!({world: "new-world", path: "../plugin_api/wit/world.wit", async: true}); let mut linker = Linker::new(&engine); wasmtime_wasi::add_to_linker_async(&mut linker)?; // Only needed if our wit world had imports //NewWorld::add_to_linker(&mut linker, |state: &mut MyState| state)?; // ... configure `builder` more to add env vars, args, etc ... let mut builder = WasiCtxBuilder::new(); // Without this, not even println! would work. What WASI functions plugins have access to is defined here builder.inherit_stdio(); let mut store = Store::new( &engine, MyState { ctx: builder.build(), table: ResourceTable::new(), }, ); // Load the plugin file from disk let component = Component::from_file(&engine, "./plugins/custom_plugin.wasm")?; // Setup an Instance and a world Component that can be called // Instance needs to be not dropped when the world component is called. let (world, instance) = NewWorld::instantiate_async(&mut store, &component, &linker).await?; // Here our `greet` function doesn't take any parameters for the component, // but in the Wasmtime embedding API the first argument is always a `Store`. let greeting = world.call_greeting(&mut store).await?; println!("{greeting}"); Ok(()) } struct MyState { ctx: WasiCtx, table: ResourceTable, } impl WasiView for MyState { fn table(&mut self) -> &mut ResourceTable { &mut self.table } fn ctx(&mut self) -> &mut WasiCtx { &mut self.ctx } }
With that all in place, and the plugin .wasm
file copied to the plugins folder in the plugin_host folder, the below command
cargo run
returns this output
Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.19s Running `/projects/wit_plugins/target/debug/plugin_host` STDIO WORKS! Greetings Ben! I'm a WASI plugin!
Conclusion
And now we have a basic WASI preview 2 plugin system in place. There's still more work to do, including but not limited to:
- Discover plugins from a central location and instantiate them automatically
- Take plugin commands(plugin names?) and pass them to clap for users to call
- Store instantiated plugins somewhere to call later
- Everything else
The complete set of crates in a workspace can be found here if you'd like to check the code. Comments or PRs welcome.
This should be enough to get started, and I'm sure we'll make some progress in a later post. It feels like there's lots of potential here, with time and effort WASI plugins could be a useful addition to many apps!