Part 2: Structured Data

In the original series, Part 2 was where things got complicated. Passing a string back from the plugin required returning a pointer, reserving memory for the return length, and reading bytes back out. Passing a (u8, String) tuple required bincode serialization, writing serialized bytes into WASM memory, deserializing on the plugin side, re-serializing the result, writing the length to a fixed memory offset, and then doing the whole thing in reverse on the host side.

The plugin code looked like this:

#![allow(unused)]
fn main() {
// Original: 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 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 {
        let repeated = pair.word.repeat(pair.count as usize);
        let new_count = pair.count.wrapping_mul(repeated.len() as u8);
        Pair {
            count: new_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 and Debug, 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: 15, word: hellohellohello

Compare and Contrast

Let's look at what the original Part 2 required for the double function alone:

Original host code (~20 lines):

  1. Get memory view
  2. Zero out first 4 bytes (reserved for return length)
  3. Write string bytes into memory starting at index 5
  4. Bind _double with raw types (i32, u32) -> i32
  5. Call it, get back a pointer
  6. Read 4 bytes from index 1-4 to get the return length
  7. Convert those bytes to u32
  8. Read the return string from memory using pointer + length
  9. 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 original needed bincode on both sides. The Component Model just uses WIT records that map directly to Rust structs. No serialize, no deserialize, no [0u8; 4] arrays for reading lengths out of raw memory.

WIT Types Beyond Records

WIT supports more than just records. Here's a quick tour of what's available:

// 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,
}

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>. You write normal Rust on both sides.

What's Next

In Part 3, we'll build a real-world plugin: an mdbook preprocessor. The original Part 4 showed how to wire up a WASM plugin to mdbook's preprocessor system using the Wasmer-based approach. We'll do the same thing with Components, and see how the Component Model handles the full lifecycle of a real plugin.