Part 1: Hello, Components
In the original series, Part 1 got us to the point where we could pass a string into a WASM plugin and get its length back. It required manually writing bytes into WASM memory, passing a pointer and length, and reconstructing the string with from_raw_parts inside an unsafe block.
With the Component Model, we skip all of that. Let's build the same thing — a plugin that takes a string and returns its length — but this time, the only code we write is the actual logic.
The Interface
The Component Model uses WIT files to define the contract between host and plugin. Think of it as a language-agnostic interface definition — like a .proto file, but for WASM.
Create a workspace for this part:
mkdir -p part-1/plugin/wit
Write the WIT file at part-1/plugin/wit/world.wit:
package example:plugin;
world example-plugin {
export length: func(s: string) -> u32;
}
This says: our plugin exports a function called length that takes a string and returns a u32. That's the whole interface. No pointers, no memory layout conventions, no "write the length to byte index 1" handshake.
Note: In wit-bindgen 0.41+, type definitions like record must be placed inside the world block, not at the top level of the package. Top-level types need to be in separate interface files.
The Plugin
Create the plugin project:
cd part-1/plugin
cargo init --lib
Update Cargo.toml:
[package]
name = "example-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.41"
The cdylib crate type tells cargo to produce a C dynamic library, which is what the WASM target expects. And wit-bindgen generates the bindings from our WIT file.
Now the actual code — src/lib.rs:
#![allow(unused)] fn main() { wit_bindgen::generate!({ world: "example-plugin", }); struct ExamplePlugin; impl Guest for ExamplePlugin { fn length(s: String) -> u32 { s.len() as u32 } } export!(ExamplePlugin); }
Let's break this down:
wit_bindgen::generate!reads thewit/directory, finds our world, and generates aGuesttrait matching the exports we defined.- We implement
Guestfor our type — this is where the actual logic lives. export!registers our implementation as the component's exports.
Compare this to the original plugin code, which needed a separate _length wrapper function with unsafe pointer arithmetic. Here, we just... implement a trait.
Build it:
cargo build --target wasm32-wasip2
The output is at target/wasm32-wasip2/debug/example_plugin.wasm. This is a full component — not a core WASM module. You can inspect it:
wasm-tools component wit target/wasm32-wasip2/debug/example_plugin.wasm
You should see the same world definition we wrote in our WIT file.
The Host
Now let's build a host that loads and runs this component.
cd part-1/host
cargo init
Cargo.toml:
[package]
name = "example-host"
version = "0.1.0"
edition = "2021"
[dependencies]
wasmtime = "29"
wasmtime-wasi = "29"
anyhow = "1"
The host also needs a copy of the WIT file so wasmtime::component::bindgen! can generate typed bindings. Copy or symlink it:
mkdir -p wit
cp ../plugin/wit/world.wit wit/
Now src/main.rs:
use anyhow::Result; use wasmtime::{Engine, Store}; use wasmtime::component::{Component, Linker}; use wasmtime_wasi::{WasiCtxBuilder, WasiCtx, WasiView, ResourceTable}; wasmtime::component::bindgen!({ world: "example-plugin", path: "../plugin/wit", }); struct State { wasi: WasiCtx, table: ResourceTable, } impl WasiView for State { fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi } fn table(&mut self) -> &mut ResourceTable { &mut self.table } } fn main() -> Result<()> { let engine = Engine::default(); let mut linker = Linker::new(&engine); // Add WASI support — the plugin targets wasm32-wasip2 which requires WASI wasmtime_wasi::add_to_linker_sync(&mut linker)?; let state = State { wasi: WasiCtxBuilder::new().build(), table: ResourceTable::new(), }; let mut store = Store::new(&engine, state); let bytes = std::fs::read("../plugin/target/wasm32-wasip2/debug/example_plugin.wasm")?; let component = Component::new(&engine, bytes)?; let instance = linker.instantiate(&mut store, &component)?; let plugin = ExamplePlugin::new(&mut store, &instance)?; let result = plugin.call_length(&mut store, "supercalifragilisticexpialidocious")?; println!("length: {result}"); Ok(()) }
The WasiView trait connects WASI context to the store. Since our plugin targets wasm32-wasip2, it needs WASI available — even though our plugin doesn't use any WASI features directly, the component runtime requires the WASI plumbing to be present.
Run it:
cargo run
# length: 34
What Just Happened
In the original Part 1, getting to this point required:
- Getting the module's context
- Getting memory(0) from the context
- Getting a view of that memory
- Looping over memory cells to write string bytes
- Binding the
_lengthfunction with raw type signatures - Calling it with
(ptr, len)as i32/u32 - Reconstructing the result
Now it's:
#![allow(unused)] fn main() { plugin.call_length(&mut store, "supercalifragilisticexpialidocious")?; }
The Component Model and wit-bindgen handled all the memory management. We described our interface in WIT, and both sides got generated, type-safe bindings. The host passes a Rust string. The plugin receives a Rust string. The result is a Rust u32.
That's the entire point of this rewrite: the plumbing goes away.
What's Next
In Part 2, we'll pass structured data — tuples, records, and lists — across the boundary. In the original series, this required bincode serialization, reserved memory slots for return lengths, and more pointer arithmetic. With Components, it just means adding fields to a WIT record.