Part 2: Passing Structured Data Across the Wasm Boundary — No Pointer Arithmetic Required
Without the Component Model, passing structured data across the Wasm boundary meant serializing to bytes, writing them into Wasm memory with pointer arithmetic, deserializing on the other side, then doing the whole thing in reverse for the return value. Here’s what that looked like:
#![allow(unused)]
fn main() {
// Without the Component Model: serialize, deserialize, pointer arithmetic
#[no_mangle]
pub fn _multiply(ptr: i32, len: u32) -> i32 {
let slice = unsafe {
::std::slice::from_raw_parts(ptr as _, len as _)
};
let pair = deserialize(slice).expect("Failed to deserialize tuple");
let updated = multiply(pair);
let ret = serialize(&updated).expect("Failed to serialize tuple");
let len = ret.len() as u32;
unsafe {
::std::ptr::write(1 as _, len);
}
ret.as_ptr() as _
}
}
With the Component Model, structured data is a first-class concept. You define your types in WIT, and wit-bindgen generates Rust structs that map to them. No serialization. No pointer arithmetic. No unsafe.
The generated structs implement Clone, Debug, and PartialEq by default — so you can compare them, print them, and clone them without writing any boilerplate. This makes testing straightforward: assert_eq!(result, expected_pair) works without any adapter code.
The Interface
Create the workspace:
mkdir -p part-2/plugin/wit
Write part-2/plugin/wit/world.wit:
package example:plugin;
world example-plugin {
record pair {
count: u8,
word: string,
}
export double: func(s: string) -> string;
export multiply: func(pair: pair) -> pair;
}
We define a record called pair inside the world block with a u8 count and a string word. (In wit-bindgen 0.41+, records must be inside the world or a separate interface — they can’t be at the package top level.)
The Plugin
cd part-2/plugin
cargo init --lib
Cargo.toml — same as Part 1:
[package]
name = "example-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.41"
src/lib.rs:
#![allow(unused)]
fn main() {
wit_bindgen::generate!({
world: "example-plugin",
});
struct ExamplePlugin;
impl Guest for ExamplePlugin {
fn double(s: String) -> String {
s.repeat(2)
}
fn multiply(pair: Pair) -> Pair {
// Repeat the word `count` times and return the result
// This demonstrates record passing — the logic itself isn't important
let repeated = pair.word.repeat(pair.count as usize);
Pair {
count: pair.count,
word: repeated,
}
}
}
export!(ExamplePlugin);
}
That’s the entire plugin. The Pair struct is generated by wit_bindgen::generate! from our WIT record definition. It has public fields, it implements Clone, Debug, and PartialEq, and it can be passed across the Wasm boundary without any serialization code on our part.
Build it:
cargo build --target wasm32-wasip2
The Host
cd part-2/host
cargo init
Cargo.toml:
[package]
name = "example-host"
version = "0.1.0"
edition = "2021"
[dependencies]
wasmtime = "29"
wasmtime-wasi = "29"
anyhow = "1"
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);
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)?;
// Test double
let doubled = plugin.call_double(&mut store, "supercalifragilisticexpialidocious")?;
println!("doubled: {doubled}");
// Test multiply
let pair = Pair {
count: 3,
word: "hello".to_string(),
};
let result = plugin.call_multiply(&mut store, &pair)?;
println!("count: {}, word: {}", result.count, result.word);
Ok(())
}
Run it:
cargo run
# doubled: supercalifragilisticexpialidocioussupercalifragilisticexpialidocious
# count: 3, word: hellohellohello
Compare and Contrast
Without the Component Model, the double function alone required ~20 lines of host code:
Without the Component Model (~20 lines):
- Get memory view
- Zero out first 4 bytes (reserved for return length)
- Write string bytes into memory starting at index 5
- Bind
_doublewith raw types(i32, u32) -> i32 - Call it, get back a pointer
- Read 4 bytes from index 1-4 to get the return length
- Convert those bytes to
u32 - Read the return string from memory using pointer + length
- Convert bytes to
String
Component Model host code (1 line):
#![allow(unused)]
fn main() {
let doubled = plugin.call_double(&mut store, "supercalifragilisticexpialidocious")?;
}
And for structured data, the old approach needed bincode on both sides. The Component Model uses WIT records that map directly to Rust structs — no serialization step needed. No serialize, no deserialize, no [0u8; 4] arrays for reading lengths out of raw memory.
Reference: WIT Types Beyond Records
The tutorial uses records, but WIT supports several other types. You won’t need these for the examples that follow — this section is here so you know what’s available when you encounter them in real WIT files.
// Enums
enum color {
red,
green,
blue,
}
// Variants (like Rust enums with data)
variant shape {
circle(f32),
rectangle(f32, f32),
}
// Lists
export sum: func(values: list<u32>) -> u32;
// Options (like Rust Option)
export find: func(name: string) -> option<u32>;
// Results (like Rust Result)
export parse: func(input: string) -> result<u32, string>;
// Flags (like bitflags)
flags permission {
read,
write,
execute,
}
// Maps (experimental — requires wasmtime 44+)
export word-freq: func(text: string) -> map<string, u32>;
All of these map to idiomatic Rust types through wit-bindgen. list<u32> becomes Vec<u32>, option<u32> becomes Option<u32>, result<u32, string> becomes Result<u32, String> on the plugin side. map<string, u32> becomes a map type (the exact Rust type depends on the wit-bindgen version — on wasmtime 44+ it generates a concrete map, on earlier versions the WIT parser rejects map as an unknown type). On the host side, calling a plugin function that returns result gives you Result<Result<T, E>, wasmtime::Error> — the outer Result is for Wasm traps (runtime errors that abort Wasm execution) and instantiation failures, the inner Result is for the WIT-level success/error. The host-side pattern for handling these nested results is covered when we build a real plugin system.
Next: Part 3 — Real World: An mdbook Preprocessor — We’ve been calling the plugin from the host. Now the plugin needs to call back into the host — and the host doesn’t speak Wasm, it speaks Rust. We’ll define WIT interfaces that go both directions, build a real mdbook preprocessor, and see why bidirectional communication is where the Component Model really shines.