I played around with generating a wasm
module with the component model for a plugin system which I'd like to design.
I'm just playing around if the tech stack works for my case.
Since the ecosystem is bleeding edge and moving fast, I don't know the idiomatic patterns for the stack yet.
This is why I'd like to get a review from the engineers here. If it works out I hope it clarifies stuff for other people also.
I started with following documentation in these repositories.
https://github.com/WebAssembly/component-model
https://github.com/bytecodealliance/wit-bindgen/
https://github.com/bytecodealliance/preview2-prototyping
https://github.com/bytecodealliance/wasmtime
https://github.com/bytecodealliance/wasm-tools
https://github.com/WebAssembly/component-model/blob/main/design/mvp/WIT.md
https://docs.wasmtime.dev
And the crate docs
https://docs.rs/wasmtime-wasi/10.0.1/wasmtime_wasi/
https://docs.rs/wasmtime/10.0.1/wasmtime/
Also reading source code from these two repositories helped
https://github.com/bytecodealliance/preview2-prototyping
https://github.com/bytecodealliance/wasmtime
About the adapters when compiling component model wasm
files I've used this source to retrieve them.
https://github.com/bytecodealliance/wasmtime/releases/tag/dev
I started with trying to generate bindings for a simple wit
file and get a component-model
compiled wasm
file.
To do this I've used a different crate since I suppose that the plugin writers would do the same.
Dependencies of the transformer
crate is as follows:
[package]
name = "transformer"
version = "0.1.0"
edition = "2021"
[dependencies]
wit-bindgen = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "8ceb28d" }
[lib]
crate-type = ['cdylib']
The wit
file (a simple one which do not deal with error handling or complex types yet)
// Transformer
//
// package-decl ::= 'package' id ':' id ('@' valid-semver)?
// <name-of-the-module>:<name-of-the-world>
package field33:transformer
// Transformer world
world transformer {
// Gets data transforms it and returns.
//
// Meant to be called from the host on demand.
export transform: func(input: string) -> string
}
lib.rs
(Here I wanted to test WASI and getting an input in then returning something)
use std::fs::File;
use std::io::prelude::*;
wit_bindgen::generate!("transformer");
pub struct MyTransformer;
impl Transformer for MyTransformer {
fn transform(input: String) -> String {
// format!("Transformed, {}!", input)
// Create a file
let mut file = File::create("test.txt").unwrap();
// Write to the file
file.write_all(b"Hello, WASI!").unwrap();
// Read the file
let mut file = File::open("test.txt").unwrap();
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
// Print the contents
format!("{input} {}", contents)
}
}
export_transformer!(MyTransformer);
This concludes the source of my plugin crate.
The compilation is done with the command:
cargo build --target wasm32-wasi && wasm-tools component new ../target/wasm32-wasi/debug/transformer.wasm -o transformer.wasm --adapt wasi_snapshot_preview1=./wasi_snapshot_preview1.reactor.wasm
wasi_snapshot_preview1.reactor.wasm
adapter is retrieved from https://github.com/bytecodealliance/wasmtime/releases/tag/dev
I do not yet know the differences between the command and reactor adapters but I've chosen the working one.
Now my module is successfully compiled I need to run it in a toy runtime.
Here I mostly followed the https://github.com/bytecodealliance/preview2-prototyping source code.
I initially had a problem with dependencies and needed to pin some stuff but I think this issue is pretty temporary if we think how fast the project moves.
Here are the dependencies for my toy runtime crate.
[dependencies]
wasmtime = { version = "=10.0.1", features = ["component-model"] }
# TODO: Follow updates
wasmtime-wasi = "=10.0.1"
cap-fs-ext = "=1.0.15"
cap-primitives = "=1.0.15"
cap-std = "=1.0.15"
anyhow = "1"
tokio = { version = "1.11.0", features = ["rt-multi-thread", "macros"] }
And the meat of it (main.rs
):
use anyhow::Result;
use wasmtime::component::*;
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::preview2::{wasi, Table, WasiCtx, WasiCtxBuilder, WasiView};
struct PluginCtx {
table: Table,
wasi: WasiCtx,
}
impl WasiView for PluginCtx {
fn table(&self) -> &Table {
&self.table
}
fn table_mut(&mut self) -> &mut Table {
&mut self.table
}
fn ctx(&self) -> &WasiCtx {
&self.wasi
}
fn ctx_mut(&mut self) -> &mut WasiCtx {
&mut self.wasi
}
}
#[tokio::main]
async fn main() -> Result<()> {
let dir = cap_std::fs::Dir::open_ambient_dir(".", cap_std::ambient_authority()).unwrap();
let wasi_ctx_builder = WasiCtxBuilder::new()
.inherit_stdio()
.push_preopened_dir(
dir,
wasmtime_wasi::preview2::DirPerms::all(),
wasmtime_wasi::preview2::FilePerms::all(),
".",
)
.set_args(&Vec::<String>::new());
let mut table: Table = Table::new();
let wasi = wasi_ctx_builder.build(&mut table)?;
let mut config = Config::default();
config.wasm_backtrace_details(wasmtime::WasmBacktraceDetails::Enable);
config.async_support(true);
config.wasm_component_model(true);
let engine = Engine::new(&config).unwrap();
let component = Component::from_file(&engine, "transformer/transformer.wasm")?;
let mut linker = Linker::new(&engine);
wasi::command::add_to_linker(&mut linker)?;
let mut store = Store::new(&engine, PluginCtx { table, wasi });
// let (wasi, instance) =
// wasi::command::Command::instantiate_async(&mut store, &component, &linker).await?;
// let result = wasi.call_run(&mut store).await.unwrap().unwrap();
let instance = linker.instantiate_async(&mut store, &component).await?;
let func = instance.get_func(&mut store, "transform").unwrap();
let mut results = vec![Val::String("".to_owned().into_boxed_str())];
let params = vec![Val::String("runtime".to_owned().into_boxed_str())];
func.call_async(&mut store, ¶ms, &mut results)
.await
.unwrap();
dbg!(results);
Ok(())
}
I'm receiving the result of
[rt/src/main.rs:68] results = [
String(
"runtime Hello, WASI!",
),
]
which shows that the function arguments and at least WASI functionality related to the file system works fine.
Where to go from here?
I would like to know if this pattern is one which is a good fundamental to build complexity over it?
Are there idiomatic or more smart patterns to achieve the same thing which I haven't realised yet?
What would you do better or would like to correct in this approach?
I haven't played with the PluginCtx
yet. I'm going to check that next.
If you feel like commenting on it I'd be greatful. If not that is also fine I know that everybody has a limited time.
Thanks in advance :pray:
Btw I've also tried using wasmtime::component::bindgen!
which works fine in the rt. :party_ball:
I don't have time to provide a detailed code review today (taking off for vacation at noon) but everything you want to be using from preview2-prototyping repo has found a new home in the wasmtime repo - readme in p2p shows where they ended up
the difference between a command and a reactor is whether you are expecting to export a func run() -> result
(wit syntax) via your Rust fn main()
- if you do, the command adapter takes care of hooking those up. otherwise, use the reactor adapter
commands are the appropriate choice if you have some existing command-line app you are porting to wasi: you instantiate the module, execute it once from the only export function run
, and then throw it away. reactors can be instantiated, then you can call any of their export functions any number of times.
so, more like a "library" than a CLI command
your transformer is already a reactor, it just only happens to have one export.
the remaining suggestion i have is to use wasmtime::component::bindgen! on your transformer package and then, instead of doing instance.get_func
and func.call_async
, you'll have something like Transformer::transform(&mut self, &mut Store, in: Vec<String>) -> Result<Vec<String>>
generated for you to use
Pat Hickey said:
I don't have time to provide a detailed code review today (taking off for vacation at noon) but everything you want to be using from preview2-prototyping repo has found a new home in the wasmtime repo - readme in p2p shows where they ended up
Great that you're taking a vacation! I hope it will be refreshing and well.
This is good info, I might have missed that thank you.
Pat Hickey said:
the remaining suggestion i have is to use wasmtime::component::bindgen! on your transformer package and then, instead of doing
instance.get_func
andfunc.call_async
, you'll have something likeTransformer::transform(&mut self, &mut Store, in: Vec<String>) -> Result<Vec<String>>
generated for you to use
Yes this is what I have ended up discovering!
It is much more convenient to do it like that.
Here is my checklist and where I'm at in it to see if this approach fits my use case.
Works:
:check: Create a simple plugin in Rust
:check: Access WASI api filesystem access from the plugin
:check: Export the main transformer function from the plugin to the runtime
:check: Call the plugin main transformer function from the runtime to receive results.
:check: Enable async support in the runtime so the functions from the plugin could be called as async functions.
:check: Generate a plugin from a wit file and implementations.
:check: Run a generated component model based plugin in the runtime.
Didn't try yet:
wasm32-wasi
it should work fine)Didn't fully understand how to do yet:
Thank you for your time and interest :pray:
What I have recently tried out:
Importing functions from the host is not difficult.
They can be sync and async.
A tuple of args and results could be defined.
They need to implement these traits and a lot of tuple types do so we don't need to bother.
Params: ComponentNamedList + Lift + 'static,
Return: ComponentNamedList + Lower + 'static,
We can get a handle to the global store of host to share context between imported functions.
A sync import may look like this.
pub fn sync_import(context: StoreContextMut<'_, PluginCtx>, params: (String,)) -> Result<(String, )> {
let ctx = context.data();
// A made up value to tests accessing the context.
let runtime_version = ctx.runtime_version();
// A function argument which is passed in from the guest.
let arg = params.0;
println!("[Naive plugin log - rt v{runtime_version}]: {arg}");
Ok(())
}
The async counter part may look like this
pub fn async_import<'a>(
_context: StoreContextMut<'a, PluginCtx>,
params: (String,),
) -> Box<dyn Future<Output = Result<(String,), anyhow::Error>> + Send + 'a> {
let arg_1 = params.0;
let future = async move {
Ok((format!(
"This is the result of a plugin calling function (async) on rt. arg_1: {arg_1}",
),))
};
Box::new(future)
}
Setting environment variables for the guest code through WASI is easy.
Once the WasiCtx
is built there is a vector of env vars are exposed.
One can push to it.
To expose the functions as imports through the mutable component linker one can call these methods.
linker.root().func_wrap
"my-sync-imported-func",
sync_import,
)?;
linker.root().func_wrap_async(
"my-async-imported-func",
async_import,
)?;
From the wit
file they can now be used as imports and the generated code in the guest calls the functions which I've defined. Voila.
I also tried a few libraries and they worked out of the box, probably anything which compiles to wasm32-wasi
works ok.
rio_turtle = "0.8"
rio_api = "0.8"
uuid = { version = "1", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
Next step is to try all these with a js based plugin with https://github.com/bytecodealliance/componentize-js
Last updated: Nov 26 2024 at 01:30 UTC