Calculator with WebAssembly Plugins

This example demonstrates how to embed Wasmtime to create an application that uses plugins. The plugins are WebAssembly components.

You can browse this source code online and clone the wasmtime repository to run this example locally.

This application is a simplified version of the application presented in Sy Brand's blog post "Building Native Plugin Systems with WebAssembly Components ". Consult that blog post for a more complex example of embedding Wasmtime and using plugins.

The calculator

The calculator being implemented is very simple; it takes an expression represented in prefix form, without parentheses, on the command line. For example:

target/release/calculator --plugins plugins/ add 1 2

or

target/release/calculator --plugins plugins/ subtract -1 -2

The set of operations available is defined by the set of plugins present in the plugins/ directory.

Each plugin is a component that supports two operations:

  • get_plugin_name: Returns the name of the arithmetic operation that this plugin implements.
  • evaluate: Takes two signed integer arguments and returns the result of evaluating the operation on the arguments.

Two example plugins are included: an add plugin implemented in C, and a subtract plugin implemented in JavaScript. Running cargo build --release will generate the plugins: c-plugin/add.wasm and js-plugin/subtract.wasm. To run the code, you should copy both of these files into the plugins/ directory that you provide with the --plugins option.

To build the plugins, you must install wit-bindgen and the WASI SDK (for building the C plugin) and jco (for building the JavaScript plugin). For instructions, see the C/C++ section and the JavaScript section of the Component Model documentation.

There are no nested expressions.

WIT bindings

To define the interface for the plugins, we have to create a .wit file. The contents of this file are:

package docs:calculator;

interface host {}

world plugin {
    import host;

    export get-plugin-name: func() -> string;
    export evaluate: func(x: s32, y: s32) -> s32;
}

The WIT file defines a world that the plugin must implement, as well as any imports it can expect its host to provide. In this case, the host interface is empty, indicating that there is no functionality in the host that a plugin can call.

The world has two exports, indicating that the plugin must implement two functions: a get-plugin-name function that returns the name of the operation that this plugin implements, and an evaluate function that does computation specific to this plugin.

In the calculator code, we write:

bindgen!("plugin");

which uses a macro provided by Wasmtime that automatically runs the wit-bindgen tool to generate bindings for the world.

CalculatorState

We use the CalculatorState type to represent the global state of the program, which in this case is just a mapping from strings that represent operation names to PluginDescs. A PluginDesc represents the information needed in order to execute a plugin given arguments.

Loading plugins

The application takes a directory as a command-line argument, which is expected to contain plugins (as .wasm files). All plugins in the directory are loaded eagerly.

The load_plugins() function starts by calling the Wasmtime library's Engine::default() function to create an Engine:

#![allow(unused)]
fn main() {
let engine = Engine::default();
}

An Engine is an environment for executing WebAssembly programs. Only one Engine is needed regardless of how many plugins may be executed. For more details, see the Wasmtime crate documentation.

Next, it passes the engine to the Wasmtime library's Linker::new() function to create a Linker. A Linker is parameterized over a state type. In this application, we don't need per-plugin state (plugins implement pure functions), so the state type is () (the unit type).

#![allow(unused)]
fn main() {
let linker: Linker<()> = Linker::new(&engine);
}

As with the Engine, only one Linker is needed for the whole application. A Linker can be used to define functions in the host (in this case, the calculator application) that can be called by guests (in this case, plugins loaded by the calculator). We don't define any such functions in our host, so the linker is only used as an argument to the instantiate() function (which we'll see a little bit later).

The remaining code checks that the provided path for the plugins directory exists and is really a directory, and if so, calls load_plugin() on each file in the directory that has the .wasm extension:

#![allow(unused)]
fn main() {
    if !plugins_dir.is_dir() {
        anyhow::bail!("plugins directory does not exist");
    }

    for entry in fs::read_dir(plugins_dir)? {
        let path = entry?.path();
        if path.is_file() && path.extension().and_then(OsStr::to_str) == Some("wasm") {
            load_plugin(state, &engine, &linker, path)?;
        }
    }
}

Next let's look at the load_plugin() function, which loads a single plugin. The function begins by calling the Wasmtime library's Component::from_file() function, which takes an Engine and the name of a binary WebAssembly file.

#![allow(unused)]
fn main() {
let component = Component::from_file(engine, &path)?;
}

from_file() loads and compiles WebAssembly code and creates the in-memory representation of a component, which we assign to the variable component.

The next block of code creates the dynamic representation of the component, which has all the resources it needs and can have its functions called:

#![allow(unused)]
fn main() {
    let (plugin_name, plugin, store) = {
        let mut store = Store::new(engine, ());
        let plugin = Plugin::instantiate(&mut store, &component, linker)?;
        (plugin.call_get_plugin_name(&mut store)?, plugin, store)
    };
}

First, it calls the Wasmtime library's Store::new function to create a Store. A Store represents the state of a particular component. Unlike an Engine, it's specific to each unit of code and so it can't be re-used across different plugins. The Store type is parameterized with a state type T, and the third argument to Store::new must have type T. Since we don't use host state in this example, we pass in (), which has type (); so we get a Store<()> back.

Next, it calls the Plugin::instantiate() function, which was generated automatically by the bindgen!("plugin") macro. The function takes the Store we just created, the Component that represents the code for the plugin, and the Linker that was passed in to load_plugin(). instantiate() returns a Plugin. The Plugin type corresponds to the plugin world from our .wit file, and was also automatically generated by the bindgen! macro.

Now that we have a fully instantiated plugin, we can call its get-plugin-name function. The call_get_plugin_name method was generated by the bindgen! macro; notice that the name call_get_plugin_name is the same as get-plugin-name from the .wit file, but with underscores in place of hyphens, and is prefixed by call_. This method takes a Store, which in general allows use of host state by the implementation of the method in the plugin, though not in this case (since we have a Store<()>).

Finally, we update the calculator's state by associating the plugin name with a structure containing the plugin and store:

#![allow(unused)]
fn main() {
    state
        .plugin_descs
        .insert(plugin_name, PluginDesc { plugin, store });
}

Running the code

Finally, this line of code is responsible for actually evaluating the expression that was provided on the command line:

#![allow(unused)]
fn main() {
args.op.run(&mut state)
}

The BinaryOperation struct has a run method that looks like this:

#![allow(unused)]
fn main() {
    fn run(self, state: &mut CalculatorState) -> anyhow::Result<()> {
        let desc = lookup_plugin(state, self.op.as_ref())?;
        let result = desc.plugin.call_evaluate(&mut desc.store, self.x, self.y)?;
        println!("{}({}, {}) = {}", self.op, self.x, self.y, result);
        Ok(())
    }
}

First, it calls lookup_plugin, which simply looks up the operation name (self.op, which is just the name that was given on the command line) in the hash table that was created by load_plugins(). This returns a PluginDesc, which is defined as:

#![allow(unused)]
fn main() {
pub struct PluginDesc {
    pub plugin: Plugin,
    pub store: wasmtime::Store<()>,
}
}

Remember, the type Plugin corresponds to the plugin world and was generated automatically by the bindgen! macro.

The next line of code:

#![allow(unused)]
fn main() {
let result = desc.plugin.call_evaluate(&mut desc.store, self.x, self.y)?;
}

is the one that actually calls into the plugin to do the computation. The bindings generator guarantees that a Plugin has an evaluate method, which we call using call_evaluate (the name it was given by the bindings generator). Like call_get_plugin_name, it takes a Store as the first argument. The other two arguments are the ones given on the command line. result is a 32-bit integer (i32) because that's the return type of call_evaluate().

Finally, we print the result to stdout.

Writing the plugins

So far we've assumed that the plugins directory is populated with plugins for all the arithmetic operations we want. How do we actually write the plugins? The component model documentation documents how to generate WebAssembly components from various programming languages.

As part of this sample application, two plugins are provided, one in c-plugin/ (implementing the add operation), and one in js-plugin/ (implementing the subtract) operation. The build.rs script shows how the code is built.

Any number of plugins could be added, compiled from any language that has a toolchain with WebAssembly support, implementing any other arithmetic operations.

Wrapping up

This is a minimal example showing how to embed Wasmtime to create an application with dynamically loaded plugins. The application could be extended in various ways:

  • Allow nested expressions (like add(subtract(1, 2), 3))
  • Add floating-point operations
  • Add unary expressions (like sqrt(2))

The basic mechanism for loading plugins would still be the same, with only the application-specific logic changing.