A few people sitting at a French cafe overlooking a bay containing sailboats on a beautiful dayA few people sitting at a French cafe overlooking a bay containing sailboats on a beautiful day

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.

  1. Uses the standard wasmtime crate as host.
  2. Allow plugin distribution as compiled .wasm components.
  3. Use WASI preview 2's component model, so plugins could be written in any language(eventually).
  4. 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:

Rust code
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.

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.

bash
cargo new --lib plugin_api

Let's add a dependency on wit-bindgen, which we'll use to generate Rust types from the file

bash
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.

Rust code
//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:

bash
#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.

bash
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

TOML markup
[package.metadata.component]  
package = "my:new-world"

Next we'll do most of the setup in our lib.rs

Rust code
// 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

bash
cargo component build

and it should build our plugin. Look for the location it provides for the .wasm file, which is your plugin!

bash
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

bash
cargo init plugin_host

For this crate, there are only two required crates and one optional one.

bash
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

bash
#We may or not need full here
cargo add tokio --features=full

The basic flow looks like this:

  1. Define Config to configure the wasmtime engine
  2. Create an Engine that'll run all the plugins. This can be reused throughout the app.
  3. 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.
  4. Create a new Linker which is responsible for providing any import or dependencies the plugins need
  5. 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
  6. Create a WasiCtxBuilder that'll define env vars, define what functionality is available to plugins from the Wasi standard, and more.
  7. Create a state struct that contains any state your plugin needs. You'll have to implement WasiView for your state struct, and store the WasiCtx we created earlier, and a new ResourceTable in that struct as well.
  8. Create a Store, which is a collection of Webassembly items that are tied to an Instance. We'll pass our state struct to it as well.
  9. 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
  10. 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)?
  11. Next we'll need to load whichever Component/Plugin/.wasm file you created from disk with a call to Component::from_file("/path/to/file.wasm").
  12. Next you'll need to instantiate(load) the component into an Instance. bindgen!() should have generated a handy helper for this called either NewWorld::instantiate() or NewWorld::instantiate_async(). You can use instantiate_pre() to do more heavy processing up front, which makes sense if you plan to reuse it.
  13. Grab the World struct instance returned in a tuple from instantiate_asnyc() and call the function you'd like with the generated call_fn_name() methods on it. In this case .call_greeting().
  14. Store the Instance and World struct from the instantiate call to use later(optional)
  15. Do something with it!

Here's how this looks as a complete file:

Rust code
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

bash
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:

  1. Discover plugins from a central location and instantiate them automatically
  2. Take plugin commands(plugin names?) and pass them to clap for users to call
  3. Store instantiated plugins somewhere to call later
  4. 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!