Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Practical WebAssembly Components with Wasmtime

A tutorial series on building a Rust plugin system using the WebAssembly Component Model and Wasmtime.

The Problem

Without the Component Model, passing data between a host and a Wasm plugin was manual and error-prone. You wrote bytes into Wasm memory with pointer arithmetic, reconstructed strings with from_raw_parts inside unsafe blocks, and serialized structured data to flat byte arrays. Every plugin needed its own memory management protocol. One wrong offset and you’re debugging memory corruption.

The Component Model fixes this. You define your interface in WIT (a language-agnostic interface definition), and wit-bindgen generates type-safe bindings on both sides. The host passes a Rust string. The plugin receives a Rust string. No serialization. No pointer arithmetic. No unsafe.

Overview

This series shows how to build a plugin architecture using Wasmtime and the Component Model. The Component Model handles all the plumbing — memory, strings, type safety — natively, so you focus on the interesting parts.

Parts

  1. Hello, Components — Define a WIT interface, write a plugin that takes a string and returns its length, and run it with Wasmtime
  2. Building a Plugin Host — Pass structured data (records, lists) across the boundary and build a host that loads multiple plugins
  3. Real World — An mdbook Preprocessor — Build a real mdbook preprocessor, add host imports, and see the full architecture
  4. Error Handling and Resources — Handle errors across the boundary with result<T, E>, pass stateful handles with WIT resource
  5. Composing Components — Wire multiple components together with wac, build pipelines where components call each other directly
  6. Building a Real Plugin System — Put it all together: discover, load, chain, and handle errors in a production-style plugin pipeline
  7. Testing Across the Boundary — Unit tests, integration tests, and contract tests for Wasm component systems — three levels that catch different bugs

Part 1: Hello, Components

WebAssembly started as a way to run code in the browser — a low-level virtual machine that takes linear memory and a handful of numeric types. That’s fine for C and C++, where everything is bytes anyway. But if you want to pass a string, a struct, or an error across the Wasm boundary, you’re on your own: manually write bytes into memory, pass a pointer, and hope you got the layout right.

The Component Model fixes this. It adds a type system to Wasm itself — strings, records, enums, variants, resources — and a standard way to pass them between modules and hosts. No pointer arithmetic. No unsafe. The types work — no manual plumbing required.

The type system does more than make host↔plugin calls nicer. It makes components composable — you can wire two components together through a shared WIT interface, and the type checker confirms they fit before you ever run them. That’s why it’s called a component model, not merely a better FFI. We’ll build up to composition in Parts 5 and 6; for now, let’s see the basics.

Here’s what that looks like in practice. Instead of reconstructing strings with from_raw_parts inside unsafe blocks, we skip the plumbing and write the logic directly. Let’s build a plugin that takes a string and returns its length — the only code we write is the actual logic.

Version compatibility. This tutorial uses wasmtime 29 and wit-bindgen 0.41. Both have seen significant releases since, but the concepts (WIT files, generated bindings, resources, composition) are stable — only the Rust API surface shifts. If you’re on a newer wasmtime, the two biggest changes happened at v30 (WasiView split into IoView + WasiView) and v36 (WasiCtxView return type, p2 linker). See the Migration Guide for before/after code. Each part also includes version-specific notes where things are most likely to break.

Check your setup before you start:

rustup target list --installed | grep wasip2   # should show wasm32-wasip2
wasm-tools --version                           # should print a version number

If the target is missing, rustup target add wasm32-wasip2 will get you there. If wasm-tools isn’t found, install it with cargo install wasm-tools (or your system’s package manager — the wasm-tools --version command above will confirm it works regardless of how you installed it). You’ll also need wasm-component-ld for linking — covered in the build step below (automatic with Rust 1.85+).

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. A WIT world defines that contract: what the component exports (functions the host can call) and imports (functions the host must provide).

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.

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 the wit/ directory, finds our world, and generates a Guest trait matching the exports we defined. We implement Guest for our type — that’s where the actual logic lives. Then export! registers our implementation as the component’s exports.

Without the Component Model, you’d need a separate _length wrapper function with unsafe pointer arithmetic. Here, we implement a trait.

Build the Plugin

Before we build, you need the WASI target and a component linker. The setup check at the top of this part covers both — if you ran those commands, you’re ready. One additional dependency: wasm-component-ld for linking components. If you’re using Rust 1.85 or later, this is handled automatically — cargo build --target wasm32-wasip2 pulls it in as a dependency. If you’re on an older toolchain:

cargo install wasm-component-ld

Now build:

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 the WIT file so wasmtime::component::bindgen! can generate typed bindings at compile time. The bindgen! macro takes a path argument that points to the WIT directory — we’ll use path: "../plugin/wit" to reference the plugin’s WIT directly, so there’s no need to copy the file.

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.

If you’re on wasmtime 30+, the WasiView trait split into IoView + WasiView. See the Migration Guide for the updated trait signatures. This tutorial’s code targets wasmtime 29.

Run it:

cargo run
# length: 34

What Just Happened

Without the Component Model, getting to this point required:

  1. Getting the module’s context
  2. Getting memory(0) from the context
  3. Getting a view of that memory
  4. Looping over memory cells to write string bytes
  5. Binding the _length function with raw type signatures
  6. Calling it with (ptr, len) as i32/u32
  7. 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.

Next: Part 2 — Passing Structured Data Across the Wasm Boundary — We can pass strings back and forth. But real plugins work with more than text — they work with structured data. Records, enums, and flags in WIT map to Rust structs, and the Component Model handles the serialization automatically. No unsafe, no pointer arithmetic, no praying the byte offsets are correct.

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):

  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 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.

Part 3: Real World — An mdbook Preprocessor

Let’s build an mdbook preprocessor as a Wasm plugin. The preprocessor’s job: find every occurrence of “WASM” and replace it with “Wasm” (because it’s not an acronym). One line of string replacement. Without the Component Model, that one line required a pipeline of serialization — the host had to serialize the entire Book struct to JSON, write those bytes into Wasm memory, have the plugin deserialize, modify, re-serialize, write the result back to memory, and then the host would read it all back out.

With the Component Model, we define the Book structure in WIT and pass it across the boundary as a native type. No JSON. No memory management. No serialization format to agree on.

The Interface

mkdir -p part-3/plugin/wit

Write part-3/plugin/wit/world.wit:

package example:plugin;

world example-plugin {
    record book-item {
        chapter-name: string,
        content: string,
    }

    record book {
        items: list<book-item>,
    }

    export process: func(book: book) -> book;
}

We’re simplifying mdbook’s Book type for this example. A real implementation would mirror the full mdbook data model in WIT, but the principle is the same — define your types inside the world, pass them across the boundary as structured data.

The Plugin

cd part-3/plugin
cargo init --lib

Cargo.toml:

[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 process(mut book: Book) -> Book {
        for item in &mut book.items {
            item.content = item.content.replace("WASM", "Wasm");
        }
        book
    }
}

export!(ExamplePlugin);
}

That’s the entire preprocessor. It receives a Book, iterates over the items, does a string replacement, and returns the modified Book. No deserialization. No memory management. No unsafe.

Build it:

cargo build --target wasm32-wasip2

The Host

The host loads the component, creates a Book with some test content, calls process, and prints the result.

cd part-3/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<()> {
    // Same setup as Parts 1 and 2: Engine, Linker, Store, Component.
    // We show the full setup code each time, so you don't need to
    // reference earlier parts for the boilerplate — but the concepts
    // (WIT, the Component Model, wit-bindgen) are introduced in
    // Parts 1 and 2.
    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)?;

    // Book and BookItem are generated by the bindgen! macro above.
    // They correspond to the `book` and `book-item` records in the WIT file.
    let book = Book {
        items: vec![
            BookItem {
                chapter_name: "Chapter 1".to_string(),
                content: "WASM is a technology. WASM stands for WebAssembly.".to_string(),
            },
            BookItem {
                chapter_name: "Chapter 2".to_string(),
                content: "Using WASM plugins is powerful.".to_string(),
            },
        ],
    };

    let result = plugin.call_process(&mut store, &book)?;

    for item in &result.items {
        println!("=== {} ===", item.chapter_name);
        println!("{}", item.content);
        println!();
    }

    Ok(())
}

Run it:

cargo run
# === Chapter 1 ===
# Wasm is a technology. Wasm stands for WebAssembly.
#
# === Chapter 2 ===
# Using Wasm plugins is powerful.

Every “WASM” has been replaced with “Wasm”. The plugin logic is three lines. The host passes structured data in and gets structured data back.

What About JSON-over-stdin?

Without the Component Model, a Wasm mdbook integration used stdin/stdout with JSON serialization because that was mdbook’s plugin protocol — and because Wasm couldn’t do file I/O. The Component Model doesn’t change the fact that mdbook’s protocol is JSON-over-stdin. What it does change is the experience inside the plugin boundary:

Without the Component Model, the plugin received raw bytes via pointer/length, manually deserialized JSON, modified the Book, re-serialized to JSON, and wrote the result back to memory. With the Component Model, the plugin receives a Book struct, modifies it, and returns it. The host can adapt to mdbook’s JSON protocol at the boundary — translate JSON → WIT types before calling the component, and WIT types → JSON after.

This is the key architectural insight: the Component Model gives you a clean interface inside the Wasm boundary, and you adapt to external protocols at the edges. Your plugin code stays simple and type-safe. The adaptation layer — JSON parsing, stdin/stdout, whatever — lives in the host, where it belongs. The host owns the boundary with the outside world; plugins see only the clean WIT interface and never need to know what protocol the host speaks beyond it.

Here’s what that adapter looks like. The host reads mdbook’s JSON from stdin, converts it to WIT types, calls the plugin, and writes the result back as JSON:

#![allow(unused)]
fn main() {
// The adapter lives in the host — the plugin never sees JSON.
let json: serde_json::Value = serde_json::from_reader(std::io::stdin())?;

// JSON → WIT types
let book = Book {
    items: json["sections"]
        .as_array()
        .unwrap_or(&vec![])
        .iter()
        .map(|section| BookItem {
            chapter_name: section["Chapter"]
                .as_str()
                .unwrap_or("")
                .to_string(),
            content: section["Content"]
                .as_str()
                .unwrap_or("")
                .to_string(),
        })
        .collect(),
};

// Call the plugin with WIT types — no JSON inside the boundary
let result = plugin.call_process(&mut store, &book)?;

// WIT types → JSON, write to stdout for mdbook
let output: serde_json::Value = serde_json::json!({
    "sections": result.items.iter().map(|item| serde_json::json!({
        "Chapter": item.chapter_name,
        "Content": item.content,
    })).collect::<Vec<_>>()
});
serde_json::to_writer(std::io::stdout(), &output)?;
}

The plugin still only does item.content.replace("WASM", "Wasm"). It has no idea what mdbook is, what JSON looks like, or that there’s a stdin/stdout protocol at all. That’s the point. The adapter is boring glue code. Your plugin is where the interesting logic lives.

If you’re building along: The adapter code uses serde_json — add serde_json = "1" to the host’s Cargo.toml if you want to compile it. The main example (the process call above) doesn’t need it.

Host Imports: Letting the Plugin Call the Host

So far, our plugins only export functions. But the Component Model also supports imports — functions the host provides that the plugin can call. This is useful for capabilities like logging, HTTP requests, or any host-provided service.

Add an import to the WIT file:

package example:plugin;

world example-plugin {
    import log: func(msg: string);

    record book-item {
        chapter-name: string,
        content: string,
    }

    record book {
        items: list<book-item>,
    }

    export process: func(book: book) -> book;
}

Notice the records are inside the world block, matching Part 1. This keeps the types scoped to our world and ensures wit-bindgen generates them correctly. (In newer WIT versions, you can also define types at the package level inside an interface block, but for this tutorial, keeping everything inside the world is simpler.)

Now the plugin can call log() to send messages to the host:

#![allow(unused)]
fn main() {
wit_bindgen::generate!({
    world: "example-plugin",
});

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    fn process(mut book: Book) -> Book {
        log(&format!("Processing {} items", book.items.len()));
        for item in &mut book.items {
            item.content = item.content.replace("WASM", "Wasm");
        }
        book
    }
}

export!(ExamplePlugin);
}

On the host side, you provide the log function through the Linker. When using bindgen!, the macro generates a ExamplePluginImports trait (or similar, depending on the world name) that the host must implement. For simple imports like log, you can also use func_wrap directly on the linker:

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

linker.instance("example/plugin/example-plugin")?.func_wrap("log", |mut store: wasmtime::StoreContextMut<State>, msg: String| {
    println!("[plugin] {msg}");
    Ok(())
})?;

let instance = linker.instantiate(&mut store, &component)?;
}

Why slashes and not colons? The WIT file uses a colon (package example:plugin;), but the wasmtime linker API uses a slash. Wit-bindgen and wasmtime both follow this convention: the colon separates the WIT namespace from the package name, and the slash is the linker’s equivalent. So example:plugin becomes example/plugin, and example:plugin/example-plugin becomes example/plugin/example-plugin. You’ll see this pattern in every part that uses linker.instance(...).

World-level imports have a different namespace. The log import is defined directly in the world example-plugin block, not inside an interface. In the component’s import namespace, world-level imports use the world name as an additional path segment: example:plugin/example-plugin/log. That’s why the func_wrap call uses "example/plugin/example-plugin" — the world name (example-plugin) is the extra segment. If you get an “export not found” or “instance not found” error, check the generated bindgen! output for the exact namespace — or use the generated trait approach (described below), which handles the namespace automatically.

Note: The exact func_wrap and instance API depends on your wasmtime version. In wasmtime 29, you access a namespaced import via linker.instance("package/name")?.func_wrap(...). Some versions use linker.root().func_wrap(namespace, name, ...) instead. If func_wrap doesn’t work for your setup, implement the generated trait instead — bindgen! creates a Host trait for imports that you can implement on your state type. Check the wasmtime docs for the recommended approach in your version.

The func_wrap approach works, but it has two downsides: the namespace string ("example/plugin/example-plugin") must match the WIT structure exactly, and the function signature in the closure isn’t compiler-checked against the WIT file. If the WIT changes the log function to take an enum instead of a string, func_wrap compiles fine — you discover the mismatch at runtime.

The generated trait approach is more robust. When bindgen! sees that the world has imports, it generates a Host trait with a method for each import. You implement this trait on your host state, and bindgen! handles the namespace and type marshalling automatically:

#![allow(unused)]
fn main() {
// bindgen! generates a Host trait with a method for each world import.
// The trait is nested inside the generated module — import it:
use example::plugin::example_plugin::Host;

impl Host for State {
    fn log(&mut self, msg: String) -> Result<(), wasmtime::Error> {
        println!("[plugin] {msg}");
        Ok(())
    }
}
}

Then use add_to_linker to register the implementation:

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

// This is the bindgen-generated method that:
// 1. Creates the correct namespace ("example/plugin/example-plugin")
// 2. Wraps each Host trait method in the right type marshalling
// 3. Registers everything with the linker
ExamplePlugin::add_to_linker(&mut linker, |state: &mut State| state)?;
}

add_to_linker does the same thing as the manual linker.instance(...)?.func_wrap(...) calls, but the compiler checks that your Host implementation matches the WIT. If the WIT changes and you forget to update the Host impl, you get a compile error — not a runtime mismatch. The func_wrap approach is fine for quick experiments, but the generated trait approach is what you want in a project you’re maintaining.

The Full Picture

Here’s what we built across all three parts:

PartWithout Component ModelWith Component Model
1 — String in, number outManual memory writes + from_raw_partsfn length(s: String) -> u32
2 — Structured databincode + pointer arithmetic + reserved memoryWIT records → Rust structs
3 — Real pluginJSON-over-stdin + manual deserializationWIT types at the boundary, adapt protocols at the edges

The Component Model doesn’t only make the code shorter — it makes it correct by construction. You can’t forget to write the return length to the right memory offset. You can’t get the pointer arithmetic wrong. You can’t have a mismatch between what the host serializes and what the plugin expects. The WIT file is the single source of truth, and wit-bindgen ensures both sides agree.

Going Further

The host can load multiple components, each targeting the same WIT world — swap plugins by swapping the .wasm file. WIT supports package versions, so you can evolve your interface without breaking existing plugins. Components can use WASI 0.2 for filesystem, network, and other capabilities — the p2 in wasm32-wasip2 means “preview 2,” the second iteration of the WASI standard built on the Component Model. That’s why our plugins target wasm32-wasip2 instead of the older wasm32-wasi: preview 2 understands components natively, so the WASI plumbing works without an adapter layer. And the same WIT file can generate bindings for C, C++, Go, C#, Python, and more — your plugin interface isn’t locked to Rust.

The Component Model is what Wasm plugin systems should have been from the start. Now they can be.

Next: Part 4 — Result Types and Resources in WIT — Our plugins so far have been kind. length always succeeds. multiply always succeeds. Real plugins fail — the input is malformed, the network is down, the file doesn’t exist. We’ll add result types for typed error handling and resource types for state that lives across calls — two features that make the Component Model viable for real plugin systems.

Part 4: When Plugins Crash and Files Stay Open — Result Types and Resources in WIT

Our plugins so far have been kind. length always succeeds — every string has a length. process always succeeds — you can always replace “WASM” with “Wasm”. Real plugins aren’t so forgiving. A plugin that parses configuration might encounter invalid syntax. A plugin that fetches data might hit a network error. A plugin that processes images might run out of memory.

Without the Component Model, error handling across the Wasm boundary was a mess. You’d encode error codes as integers, or write error messages into a shared memory buffer and hope the host reads them before overwriting, or return a sentinel value and pray the caller checks it. Every plugin invented its own convention, and none of them were type-safe.

The Component Model has a better answer: WIT result types. They work like Rust’s Result — a value that’s either ok or err — and wit-bindgen generates the right Rust types on both sides. The host can’t forget to check for errors because the type system won’t let it.

And then there’s the harder problem: what if your plugin needs to hand the host a handle to something — an open file, a database connection, a running process? You can’t serialize a TCP connection into bytes and pass it across the Wasm boundary. That’s what WIT resources are for: typed handles that let the host and plugin share ownership of stateful things without either side needing to know the implementation details.

Two problems, two WIT features. result types make errors type-safe — the host must handle the error case, because the generated Rust code won’t compile otherwise. resource types make stateful handles possible — the host holds a typed token, the plugin holds the implementation, and the Component Model connects them. Without both, you can’t build a real plugin system.

Result Types

Let’s build a plugin that parses a simple configuration format. It’ll either return the parsed config or an error describing what went wrong.

Create the workspace:

mkdir -p part-4/plugin/wit

Write part-4/plugin/wit/world.wit:

package example:plugin;

world example-plugin {
    record config-entry {
        key: string,
        value: string,
    }

    record parse-error {
        line: u32,
        message: string,
    }

    export parse: func(input: string) -> result<list<config-entry>, parse-error>;
}

The result<list<config-entry>, parse-error> type says: parse either succeeds with a list of key-value pairs, or fails with a parse-error that tells you which line went wrong and why. The host doesn’t have to guess — the type system forces it to handle both cases.

The Plugin

cd part-4/plugin
cargo init --lib

Cargo.toml:

[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 parse(input: String) -> Result<Vec<ConfigEntry>, ParseError> {
        let mut entries = Vec::new();
        for (i, line) in input.lines().enumerate() {
            let line = line.trim();
            // Skip blank lines and comments
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            // Each valid line is: key = value
            let Some((key, value)) = line.split_once('=') else {
                return Err(ParseError {
                    line: (i + 1) as u32,
                    message: format!("expected 'key = value', got: {line}"),
                });
            };
            entries.push(ConfigEntry {
                key: key.trim().to_string(),
                value: value.trim().to_string(),
            });
        }
        Ok(entries)
    }
}

export!(ExamplePlugin);
}

Notice the return type: Result<Vec<ConfigEntry>, ParseError>. That’s a regular Rust Result. wit-bindgen maps WIT’s result<ok, err> directly to Rust’s Result<Ok, Err>. You write normal Rust error-handling code, and the Component Model takes care of passing it across the boundary.

Build it:

cargo build --target wasm32-wasip2

The Host

cd part-4/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)?;

    // --- Valid config ---
    let valid = "\
host = localhost
port = 8080
This is a comment
timeout = 30
";

    match plugin.call_parse(&mut store, valid)? {
        Ok(entries) => {
            println!("Parsed {} entries:", entries.len());
            for entry in &entries {
                println!("  {} = {}", entry.key, entry.value);
            }
        }
        Err(e) => {
            println!("Parse error on line {}: {}", e.line, e.message);
        }
    }

    // --- Invalid config ---
    let invalid = "\
host = localhost
this line has no equals sign
port = 8080
";

    match plugin.call_parse(&mut store, invalid)? {
        Ok(entries) => {
            println!("Parsed {} entries:", entries.len());
        }
        Err(e) => {
            println!("Parse error on line {}: {}", e.line, e.message);
        }
    }

    Ok(())
}

Run it:

cargo run
# Parsed 3 entries:
#   host = localhost
#   port = 8080
#   timeout = 30
# Parse error on line 2: expected 'key = value', got: this line has no equals sign

The host doesn’t check a return code. It doesn’t parse an error string. It gets a Result from call_parse, and Rust’s match expression forces it to handle both cases. If you forget the Err arm, the compiler tells you. That’s the Component Model working as advertised: the WIT type system becomes the Rust type system on both sides of the boundary.

result Without an Error Type

WIT also supports partial and bare result types. The full form is result<ok-type, error-type>, but you can leave either side out:

  • result (bare) → Result<(), ()> — “did it work or not” with no details on either side
  • result<T> (ok type only) → Result<T, ()> — a value on success, no diagnostic on failure
  • result<_, E> (error type only) → Result<(), E> — no meaningful return on success, but a specific error on failure

For example, a validation function that doesn’t return a value on success but has a typed error:

export validate: func(input: string) -> result<_, parse-error>;

The _ means “no ok-type” — the Rust side gets Result<(), ParseError>.

The bare result is the simplest case — you don’t need to say why it failed, only that it did.

In WIT:

export validate: func(input: string) -> result;

On the plugin side:

#![allow(unused)]
fn main() {
fn validate(input: String) -> Result<(), ()> {
    if input.is_empty() {
        Err(())  // Invalid, but we don't say why
    } else {
        Ok(())
    }
}
}

On the host side, you still have to handle both cases — Ok(()) means it worked, Err(()) means it didn’t.

When would you use this? A plugin that processes data might return result to signal success or failure without providing diagnostic details. It’s a simpler contract for simpler situations. Use result<T, E> when the host can meaningfully act on the error information. Use bare result when all the host needs to know is “try something else.”

Resources: Stateful Handles Across the Boundary

Result types handle the “something went wrong” case. But there’s another case the Component Model needs to solve: what if the plugin creates something stateful that the host needs to hold onto?

Consider a plugin that opens a connection to a database. The host calls connect(), the plugin opens the connection, and… then what? The host can’t hold a Rust Connection object — that lives in the plugin’s memory space. The host can’t receive a serialized connection — you can’t serialize a TCP socket. What the host needs is a handle: a token that says “I own connection #3” without knowing or caring what connection #3 actually is.

That’s what WIT resources are. A resource is a typed handle. The host holds the handle. The plugin holds the implementation. When the host calls a method on the handle, the Component Model routes it to the plugin’s implementation automatically.

Let’s build a plugin that manages a simple key-value store. The host can create a store, put values in it, and get values out — all through resource handles.

The WIT Interface

Write part-4/plugin/wit/world.wit:

package example:plugin;

world example-plugin {
    resource kv-store {
        constructor();

        get: func(key: string) -> option<string>;
        put: func(key: string, value: string);
        delete: func(key: string);
    }

    export open-store: func() -> kv-store;
}

A resource kv-store defines a handle type with methods. The constructor creates a new store. get, put, and delete operate on it. The host never sees the HashMap inside — it only sees the kv-store handle.

The option<string> return type on get is WIT’s equivalent of Rust’s Option<String> — the key might not exist. Like result, wit-bindgen maps option directly to Rust’s Option. The type system keeps you honest.

export open-store: func() -> kv-store is a top-level function that creates and returns a new kv-store handle. The host calls this to get a store, then calls methods on the store. We could use the resource’s constructor() directly instead of a factory function — but the factory function pattern is more general (it works even when construction can fail and return a result), so we’ll use it consistently throughout this tutorial.

⚠️ Hyphenated WIT names and the linker: Earlier drafts used create-store here and count-clean in Part 5. Hyphenated function names produce cabi_post_create-store symbols that can break the linker version script parser in some Rust toolchain versions. We renamed create-store to open-store and parse-config (in the companion code) to parse to avoid the issue. Part 5 keeps count-clean because renaming it would cascade through the .wac composition files — the wasm32-wasip2 target (the intended build target) compiles correctly. If you hit a linker error on a non-standard target, the fix is the same: rename to a single word or use underscores.

Companion code note: The runnable code in part-4/ uses an interface storage block to define the resource, which is the idiomatic pattern for multi-resource APIs. The WIT there looks like interface storage { resource kv-store { ... } } with export storage; instead of the inline resource + export open-store shown here. The generated host API differs slightly (the interface pattern adds a namespace level: plugin.storage().kv_store() vs plugin.call_open_store()), but the concept is identical — both give you a typed resource handle to call methods on. The inline pattern is shown here because it’s simpler to explain; the interface pattern is what you’d use in production.

The Plugin

⚠️ Name conflict with generated types: When you use the inline WIT pattern (resource kv-store { ... }), wit-bindgen generates a KvStore handle type for the resource. If you also name your implementation struct KvStore, you’ll get a compilation error — two types with the same name in the same scope. The code below uses KvStoreImpl to avoid this. If you use the interface pattern (as the companion code does), wit-bindgen namespaces the generated type and the conflict doesn’t arise.

src/lib.rs:

#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::collections::HashMap;

wit_bindgen::generate!({
    world: "example-plugin",
});

/// The actual data behind the kv-store resource.
/// Each host-side handle points to one of these.
struct KvStoreInner {
    data: HashMap<String, String>,
}

/// The resource implementation.
/// `GuestKvStore` is generated by wit-bindgen — it's the trait
/// that maps WIT resource methods to Rust methods.
///
/// We name this `KvStoreImpl` (not `KvStore`) because wit-bindgen
/// generates a `KvStore` type for the resource handle. Using the same
/// name would cause a compilation error.
pub struct KvStoreImpl {
    inner: RefCell<KvStoreInner>,
}

impl GuestKvStore for KvStoreImpl {
    fn new() -> Self {
        KvStoreImpl {
            inner: RefCell::new(KvStoreInner {
                data: HashMap::new(),
            }),
        }
    }

    fn get(&self, key: String) -> Option<String> {
        self.inner.borrow().data.get(&key).cloned()
    }

    fn put(&self, key: String, value: String) {
        self.inner.borrow_mut().data.insert(key, value);
    }

    fn delete(&self, key: String) {
        self.inner.borrow_mut().data.remove(&key);
    }
}

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    type KvStore = KvStoreImpl;

    fn open_store() -> KvStoreImpl {
        KvStoreImpl::new()
    }
}

export!(ExamplePlugin);
}

The type KvStore = KvStoreImpl; line tells wit-bindgen which Rust type implements the resource’s methods. When you define a resource in WIT, wit-bindgen generates a host-side type and a guest-side trait — and the world’s Guest trait needs to know which type fills the resource role. That’s the associated type. The plugin (guest) implements the trait. The host receives the generated type. The Component Model runtime connects them automatically: when the host calls kv_store.put("key", "value"), the runtime routes it to the plugin’s put method.

Resource methods take &self, not &mut self. This is a Component Model constraint — resource methods are individual function calls across the boundary, and the Component Model can’t track exclusive borrows across that boundary. If you need interior mutability (and you usually do), RefCell is the standard pattern. That’s why KvStoreInner is wrapped in RefCell<KvStoreInner> — the &self methods can still mutate by borrowing through the RefCell.

Note: The exact generated trait names and the export! macro usage depend on your wit-bindgen version. In 0.41+, resources generate Guest<ResourceName> traits. If your version uses different names, check the generated code in target/ for the exact trait signatures.

Build:

cargo build --target wasm32-wasip2

The Host

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)?;

    // Create a key-value store — this returns a resource handle
    let kv = plugin.call_open_store(&mut store)?;

    // Put some values
    kv.call_put(&mut store, "name", "wasm")?;
    kv.call_put(&mut store, "version", "2.0")?;

    // Get a value
    match kv.call_get(&mut store, "name")? {
        Some(value) => println!("name = {value}"),
        None => println!("name not found"),
    }

    // Get a missing value — option<string> means the host handles None
    match kv.call_get(&mut store, "missing")? {
        Some(value) => println!("missing = {value}"),
        None => println!("missing key not found"),
    }

    // Delete a value
    kv.call_delete(&mut store, "version")?;

    // Verify it's gone
    match kv.call_get(&mut store, "version")? {
        Some(value) => println!("version = {value}"),
        None => println!("version was deleted"),
    }

    Ok(())
}

Run it:

cargo run
# name = wasm
# missing key not found
# version was deleted

The host holds a KvStore handle. It calls methods on it — put, get, delete — like it’s a local object. But each call is crossing the Wasm boundary. The host doesn’t know or care that there’s a HashMap on the other side. It calls methods on a typed handle and gets typed results back — the same ergonomics as a local object, with the Component Model handling all the routing.

When the handle goes out of scope, the Component Model automatically calls the resource’s destructor on the plugin side. The HashMap gets dropped. No memory leaks. No manual cleanup. It works like Rust’s Drop trait, but across the Wasm boundary.

Note: The exact method names on the host-side resource type depend on your wasmtime version. In wasmtime 29+, generated resource handles have call_<method> methods. If your version uses different names, check the generated bindgen output.

Why Resources?

You might wonder: why not pass the whole store’s contents back and forth as a list<config-entry> or a map<string, string>? Two reasons.

Some state can’t be serialized. A key-value store that round-trips its entire contents on every call isn’t a key-value store — it’s a serialization problem. Real systems have state that accumulates over time. A database connection. A running process. An open file. You can’t serialize these things. You need a handle.

The handle is opaque. The host can’t peek inside the kv-store handle. It can’t accidentally corrupt the plugin’s internal state. The Component Model enforces this boundary — resource handles are not pointers, not indices, not IDs you can forge. They’re typed, validated, and managed by the runtime. This is the same principle as capability-based security: the host can only do what the handle’s interface allows.

What About Integer IDs?

If you’re a systems programmer, you might be thinking: “why not have the plugin return an integer ID, store the HashMap in a side table, and pass the ID back for each call?” You can do this — it’s the C approach to handles. But it has three problems the Component Model solves:

  1. No type safety. An integer ID can be passed to the wrong function. delete_connection(handle_42) compiles fine when you meant delete_store(handle_42). A resource handle is typed — kv_store.call_delete() only compiles if the handle is a KvStore.
  2. No automatic cleanup across the boundary. RAII works on the host side, but the guest can’t detect a forgotten close. Resource handles coordinate cleanup automatically — host-side drop triggers guest-side destruction.
  3. No capability enforcement. Integer IDs can be forged. A malicious host could guess handle_99 and access state it shouldn’t. Resource handles are opaque — the host can only use the methods the WIT interface defines. It can’t peek, forge, or bypass.

The cleanup problem is worth spelling out. With integer IDs, the host can wrap the ID in a struct that calls close on Drop — that’s Rust’s RAII pattern, and it works fine on the host side. But that cleanup is one-sided. The guest (Wasm plugin) has no way to know the host dropped the handle.

Two failure modes follow from this asymmetry:

  • Host forgets to call close. The guest can’t detect the leak. Its side table grows without bound.
  • Guest drops its side of the state. The host’s stale ID now points to nothing — a use-after-free with no type-system protection.

When the host’s resource handle goes out of scope, the Component Model automatically calls the destructor on the guest side, and it ensures the guest can’t access a destroyed resource. The cleanup is coordinated across the boundary — on both sides, not only the host’s.

Integer IDs work. They’re what you’d use without the Component Model. Resources are the type-safe, memory-safe, capability-safe replacement.

Combining Results and Resources

Real plugin systems use both patterns together. A connect() function returns result<connection, connect-error>. A query() method on the connection returns result<rows, query-error>. The host handles errors at each step with the type system keeping it honest.

Here’s what that looks like in WIT:

record rows {
    values: list<list<string>>,
}

record query-error {
    message: string,
    code: u32,
}

record connect-error {
    message: string,
}

resource connection {
    query: func(sql: string) -> result<rows, query-error>;
    close: func();
}

export connect: func(url: string) -> result<connection, connect-error>;

A factory function (connect) creates the connection and returns result<connection, connect-error>. Methods on the connection also return result. The host handles errors at each step — the type system keeps it honest.

The Combined Plugin

The plugin combines both patterns: RefCell for interior mutability (from the resource section) and Result for error handling (from the result section). The ConnectionInner stores query results in a HashMap, and the connect factory function rejects bad URLs:

#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::collections::HashMap;

wit_bindgen::generate!({
    world: "example-plugin",
});

struct ConnectionInner {
    results: HashMap<String, Vec<Vec<String>>>,
}

pub struct ConnectionImpl {
    inner: RefCell<ConnectionInner>,
}

impl ConnectionImpl {
    fn new() -> Self {
        ConnectionImpl {
            inner: RefCell::new(ConnectionInner {
                results: HashMap::new(),
            }),
        }
    }
}

impl GuestConnection for ConnectionImpl {
    fn query(&self, sql: String) -> Result<Rows, QueryError> {
        self.inner
            .borrow()
            .results
            .get(&sql)
            .map(|rows| Rows { values: rows.clone() })
            .ok_or(QueryError {
                message: format!("no results for: {sql}"),
                code: 1,
            })
    }

    fn close(&self) {
        self.inner.borrow_mut().results.clear();
    }
}

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    type Connection = ConnectionImpl;

    fn connect(url: String) -> Result<ConnectionImpl, ConnectError> {
        if url.starts_with("db://") {
            let conn = ConnectionImpl::new();
            conn.inner.borrow_mut().results.insert(
                "SELECT * FROM users".to_string(),
                vec![vec!["alice".to_string(), "42".to_string()]],
            );
            Ok(conn)
        } else {
            Err(ConnectError {
                message: format!("invalid URL: {url}"),
            })
        }
    }
}

export!(ExamplePlugin);
}

The structure should feel familiar — it’s the RefCell pattern from the KvStore combined with the Result pattern from parse. The new thing is how they interact: connect() returns Result<ConnectionImpl, ConnectError>, so the host can’t get a Connection handle without handling the error case first. And query() on that handle also returns Result, so every operation is type-safe.

The Combined Host

On the host side, you handle errors at each step — the type system forces it:

#![allow(unused)]
fn main() {
// connect can fail — handle the error
let conn = match plugin.call_connect(&mut store, "db://mydb")? {
    Ok(conn) => conn,
    Err(e) => {
        println!("Connection failed: {}", e.message);
        return Ok(());
    }
};

// query can also fail — handle the error
match conn.call_query(&mut store, "SELECT * FROM users")? {
    Ok(rows) => {
        for row in &rows.values {
            println!("row: {:?}", row);
        }
    }
    Err(e) => {
        println!("Query failed (code {}): {}", e.code, e.message);
    }
}

conn.call_close(&mut store)?;
}

Two error-handling sites, two match expressions. You can’t forget to handle the connect error because conn only exists in the Ok arm. You can’t forget to handle the query error because Result forces you to match. This is the Component Model working as designed: the WIT type system becomes the Rust type system on both sides of the boundary, and the compiler catches what you miss.

Note on resource handle calls: The host calls methods directly on the conn resource handle (conn.call_query, conn.call_close) — the same pattern as store_handle.call_get() from the KvStore example earlier. If your WIT uses interface-style packaging, the calls look different (storage.connection().call_query()), but the inline WIT pattern used in this tutorial gives you the simpler direct style.

Note: Resource constructors that return result directly (e.g., constructor(url: string) -> result<connection, connect-error>) are supported in wit-bindgen 0.41+ and wasmtime 29+. Earlier versions may not support this — the factory function pattern above works everywhere.

The combination is powerful: resources give you stateful handles, result types give you type-safe error handling, and the Component Model makes them work together without any manual plumbing.

What We Built

PartProblemComponent Model Solution
1Pass a string into WasmWIT function, type-safe bindings
2Pass structured dataWIT records, generated Rust structs
3Real plugin + host importsFull mdbook preprocessor, import in WIT
4Error handling + stateful handlesresult<T, E>, option<T>, resource handles

The Component Model gives you the same type-safety guarantees inside the Wasm boundary that you have in regular Rust code. Result forces you to handle errors. Option forces you to handle absence. Resources give you typed handles with automatic cleanup. You don’t have to invent a convention for any of these — the WIT type system defines them, and wit-bindgen implements them on both sides.

Going Further

Resources have more to them than we showed here. Three things worth knowing:

Custom destructors. When a resource handle is dropped on the host side, the Component Model calls the destructor on the guest side. By default, this drops the Rust struct. If you need cleanup — closing a file, flushing a buffer, releasing a lock — implement a drop method on the guest-side resource. It runs automatically when the host’s handle goes out of scope.

Borrowed resources. WIT supports passing resource handles as method arguments. This lets one resource reference another — a session that owns a connection, or a transaction that borrows a database. The Component Model tracks the borrowing: the guest resource can’t be destroyed while it’s borrowed by another call.

Async resources. The Component Model’s async proposal includes streams and futures as built-in resource types. A stream<string> would deliver values progressively without blocking. A future<result> would resolve asynchronously. These are resource types under the hood — the same handle semantics, the same automatic cleanup, but with async plumbing. As of May 2026, wasmtime’s Component Model async support is still in development — the Component Model spec defines these types, but you can’t yet use them with the wasmtime version in this tutorial. Check the Component Model spec and the wasmtime repo for updates.

Next: Part 5 — Composing Components — Two plugins, one pipeline: the formatter normalizes whitespace, then the word counter tallies the result. You could wire them through the host — call one, get the output, pass it to the next. But that’s verbose. With wac compose, the two plugins talk to each other directly inside the same Wasm module. The host never sees the intermediate data. It’s Unix pipes, but type-safe.

Part 5: Wiring Components Together Without the Host in the Middle

So far, the host has been the middleman. The host loads a plugin, calls a function, gets a result. When the plugin needed something from the host, the host provided it via imports. Every communication path goes through the host.

But what if you have two plugins that need to talk to each other? A markdown renderer that calls a syntax highlighter. A formatter that calls a linter. A build pipeline where each stage is a separate component.

You could wire it all through the host — call plugin A, get the result, pass it to plugin B. That works. But it’s verbose, and it means the host has to understand every interaction between every plugin.

The Component Model has a better way: composition. You can wire components together directly, then present the composed result as a single component to the host. The host doesn’t need to know what’s inside.

The tool for this is wac — WebAssembly Composition. It takes .wasm files and a declaration of how they connect, and produces a single composed .wasm that combines them.

The Problem: A Text Processing Pipeline

Let’s build a concrete example. We have two components:

  1. A formatter — takes text, normalizes whitespace, returns cleaned text
  2. A word counter — takes text, returns the word count

Individually, they work fine. But what we really want is a single “clean and count” operation: format the text, then count the words. The host shouldn’t have to orchestrate this — it should call one function and get the count.

Host calls count-clean()
       │
       ▼
┌─────────────┐     text      ┌──────────────┐
│  Formatter  │ ───────────── │ Word Counter  │
│  (export)   │               │  (export)     │
└─────────────┘               └──────────────┘

With composition, the formatter’s output feeds directly into the word counter. The host sees a single component with one function.

Step 1: The Formatter Component

Create the project:

cargo new formatter --lib
cd formatter

Cargo.toml:

[package]
name = "formatter"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.41"

The WIT interface. Create wit/formatter.wit:

package example:formatter;

world formatter {
    export format: func(text: string) -> string;
}

src/lib.rs:

#![allow(unused)]
fn main() {
wit_bindgen::generate!();

use exports::example::formatter::Guest;

struct Formatter;

impl Guest for Formatter {
    fn format(text: String) -> String {
        // Collapse multiple spaces into one, trim leading/trailing whitespace
        text.split_whitespace()
            .collect::<Vec<_>>()
            .join(" ")
    }
}

export!(Formatter);
}

Build it:

cargo build --target wasm32-wasip2

The component is at target/wasm32-wasip2/debug/formatter.wasm.

Step 2: The Word Counter Component

cargo new word-counter --lib
cd word-counter

Cargo.toml: same as the formatter, but with name = "word-counter".

The WIT. Create wit/word-counter.wit:

package example:word-counter;

world word-counter {
    /// The word counter *imports* a format function.
    /// This is the key: it declares a dependency that composition will satisfy.
    import format: func(text: string) -> string;
    export count-clean: func(text: string) -> u32;
}

⚠️ Hyphenated WIT names and the linker: Hyphenated function names like count-clean produce cabi_post_count-clean symbols that can break the linker version script parser in some Rust toolchain versions. The wasm32-wasip2 target (the intended build target) compiles correctly. If you hit rust-lld: error: ... expected, but got cabi_post_... when building for a native target, rename the function to use underscores or a single word. (Part 4 renamed create-store to open-store to avoid this issue. We keep count-clean here because renaming it would cascade through the .wac composition files — the fix is the same if you need it.)

Notice the import. The word counter doesn’t know who will format the text — it only knows it needs a format function. Composition will plug the formatter’s format export into this import.

src/lib.rs:

#![allow(unused)]
fn main() {
wit_bindgen::generate!();

use exports::example::word_counter::Guest;

struct WordCounter;

impl Guest for WordCounter {
    fn count_clean(text: String) -> u32 {
        // Use the imported format function to clean the text first
        let cleaned = example::word_counter::format(&text);
        // Then count words
        cleaned.split_whitespace().count() as u32
    }
}

export!(WordCounter);
}

Why example::word_counter::format() and not example::formatter::format()? The import lives in the word counter’s WIT — it’s a dependency the word counter declares, not something the formatter provides at the Rust level. wit-bindgen generates the import under the importing world’s namespace, so the word counter calls example::word_counter::format(). The formatter’s implementation of format gets wired in later by wac — the Rust code doesn’t reference the formatter at all. This is the whole point of composition: the word counter depends on an interface (format: func(string) -> string), not on a specific component.

Build it:

cargo build --target wasm32-wasip2

Now we have two components. The formatter exports format. The word counter imports format and exports count-clean. The shapes fit together like puzzle pieces.

Step 3: Compose with wac

Install wac:

cargo install wac-cli

Version note: wac’s CLI and .wac syntax are still evolving. This tutorial was tested with 0.8.x0.10.x. The wac compose and wac plug commands and .wac syntax shown here work across these versions. If commands don’t work with a newer version, check the latest docs.

Now write the composition file. Create composition.wac:

package example:composition;

// Instantiate the formatter component
let fmt = new example:formatter {};

// Instantiate the word counter, plugging the formatter's
// "format" export into the word counter's "format" import
let counter = new example:word-counter {
    format: fmt.format,
};

// Export count-clean from the composed component
export counter.count-clean;

Read this top to bottom — it’s a wiring diagram in code:

  1. Create the formatter. It has no imports, so {} is empty.
  2. Create the word counter. Its format import is satisfied by the formatter’s format export.
  3. Export count-clean from the word counter as the composed component’s only export.

The result is a single component that takes text and returns a word count — the formatter is wired inside, invisible to the host.

Set up the dependencies directory. wac looks for components in deps/:

mkdir -p deps/example
cp formatter/target/wasm32-wasip2/debug/formatter.wasm deps/example/formatter.wasm
cp word-counter/target/wasm32-wasip2/debug/word_counter.wasm deps/example/word-counter.wasm

Important: The .wasm filename must match the package name in the WIT package directive, with hyphens replaced by underscores. example:formatterdeps/example/formatter.wasm. example:word-counterdeps/example/word_counter.wasm. The directory is the namespace; the filename (minus .wasm) is the package name with underscores for hyphens. Cargo already follows this convention — it replaces hyphens in crate names with underscores in the binary filename, so word-counter produces word_counter.wasm in the build output. wac expects the same underscore convention.

Compose:

wac compose -o composed.wasm composition.wac

If it succeeds, you now have composed.wasm — a single component that internally wires the formatter and word counter together.

Step 4: Run the Composed Component

The host doesn’t know anything about the formatter. It sees a single component that exports count-clean. To call it, we need a WIT file that describes the composed component’s interface — which amounts to the word counter’s exports, since the format import has been satisfied internally.

Create wit/composition.wit:

package example:composition;

world composition {
    export count-clean: func(text: string) -> u32;
}

This is the word counter’s world, minus the import format line. Composition satisfied that import, so the host doesn’t need to provide it.

Now the host code follows the same pattern as Parts 1–4 — generated bindings from this WIT:

use anyhow::Result;
use wasmtime::{Engine, Store};
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::{WasiCtxBuilder, WasiCtx, WasiView, ResourceTable};

wasmtime::component::bindgen!({
    world: "composition",
    path: "wit/composition.wit",
});

struct State {
    ctx: WasiCtx,
    table: ResourceTable,
}

impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.ctx }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

fn main() -> Result<()> {
    let engine = Engine::default();
    let mut store = Store::new(
        &engine,
        State {
            ctx: WasiCtxBuilder::new().build(),
            table: ResourceTable::new(),
        },
    );

    let component = Component::from_file(&engine, "composed.wasm")?;
    let mut linker = Linker::new(&engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;
    let instance = linker.instantiate(&mut store, &component)?;

    let bindings = Composition::new(&mut store, &instance)?;

    // Call count-clean — the formatter runs inside the composition
    let result = bindings.call_count_clean(&mut store, "  hello   world  ")?;
    println!("Word count: {result}"); // Word count: 2

    Ok(())
}

Note: The generated Composition type and call_count_clean method come from the bindgen! macro using composition.wit. The exact method names depend on your wasmtime version — count-clean in WIT becomes count_clean in Rust (hyphens map to underscores). The concept is the same as Parts 1–4: generated bindings give you type-safe calls.

What Just Happened

Let’s trace the call:

Host calls count-clean("  hello   world  ")
  │
  ▼
Word Counter receives the text
  │
  ▼
Word Counter calls format("  hello   world  ")
  │  ← This call stays inside the Wasm boundary!
  ▼
Formatter returns "hello world"
  │
  ▼
Word Counter counts ["hello", "world"] → 2
  │
  ▼
Host receives 2

The format call never goes through the host — it stays inside the composed Wasm module. Composition wired the two components together at the Wasm level — the formatter’s export is connected directly to the word counter’s import.

This is the key insight: composition makes the boundary between components invisible to the host. The host loads one .wasm file, calls one function, gets one result. Internally, that function might call other functions in other components, but the host doesn’t know or care.

The wac plug Shortcut

For simple compositions where one component’s exports satisfy another’s imports, wac plug is a shortcut:

wac plug word-counter.wasm --plug formatter.wasm -o composed.wasm

This automatically matches exports to imports by name and type. If formatter.wasm exports format: func(text: string) -> string and word-counter.wasm imports format: func(text: string) -> string, wac plug connects them.

Use wac plug for two-component compositions. Use wac compose with a .wac file when you have more components, when you need to control which export connects to which import, or when you want to selectively expose exports.

A More Complex Composition: Three Components

The real power of composition shows when you have a pipeline of more than two components. Let’s add a third: a validator that checks if the input text is non-empty before formatting.

wit/validator.wit:

package example:validator;

world validator {
    export validate: func(text: string) -> option<string>;
}

The validator’s implementation:

#![allow(unused)]
fn main() {
wit_bindgen::generate!();

use exports::example::validator::Guest;

struct Validator;

impl Guest for Validator {
    fn validate(text: String) -> Option<String> {
        if text.trim().is_empty() {
            None
        } else {
            Some(text)
        }
    }
}

export!(Validator);
}

Build it:

cargo build --target wasm32-wasip2

Now update the word counter to use both imports:

package example:word-counter;

world word-counter {
    import validate: func(text: string) -> option<string>;
    import format: func(text: string) -> string;
    export count-clean: func(text: string) -> option<u32>;
}

Rebuild the word counter with the updated WIT:

cd word-counter && cargo build --target wasm32-wasip2

If you skip this step, the old .wasm (with import format only) won’t match the new WIT, and wac compose will fail.

Also copy the validator to your deps/ directory:

cp validator/target/wasm32-wasip2/debug/validator.wasm deps/example/validator.wasm

And re-copy the updated word counter:

cp word-counter/target/wasm32-wasip2/debug/word_counter.wasm deps/example/word-counter.wasm

count-clean now returns option<u32>None if validation fails, Some(count) otherwise. The return type changed because validate returns option<string>, and the ? operator on that line means count_clean can return None early. Rust’s type system enforces this: if you use ? with an Option, the containing function must return Option too.

#![allow(unused)]
fn main() {
fn count_clean(text: String) -> Option<u32> {
    let validated = example::word_counter::validate(&text)?;
    let cleaned = example::word_counter::format(&validated);
    Some(cleaned.split_whitespace().count() as u32)
}
}

The composition:

package example:composition;

let v = new example:validator {};
let fmt = new example:formatter {};
let counter = new example:word-counter {
    validate: v.validate,
    format: fmt.format,
};

export counter.count-clean;

Three components, one export. The host calls count-clean, the validator checks the input, the formatter cleans it, the word counter counts it. None of these components know about each other — they only know about the functions they import and export. Composition wires them together.

If you’re building along: The three-component composition changes count-clean’s return type from u32 to option<u32> — the host code from Step 4 needs two updates. First, update composition.wit to match:

export count-clean: func(text: string) -> option<u32>;

Second, the host code changes. Before, the return type was u32:

#![allow(unused)]
fn main() {
let result: u32 = bindings.call_count_clean(&mut store, &text)?;
println!("Word count: {result}");
}

After the composition change, the return type is Option<u32>:

#![allow(unused)]
fn main() {
let result: Option<u32> = bindings.call_count_clean(&mut store, &text)?;
match result {
    Some(count) => println!("Word count: {count}"),
    None => eprintln!("Validation failed"),
}
}

The type changed — you’ll need to handle the None case (validation failed) instead of printing the count directly.

Host calls count-clean(text)
  │
  ▼
Word Counter
  ├── calls validate(text) → Validator
  │     returns Some(text) or None
  ├── if Some: calls format(text) → Formatter
  │     returns cleaned text
  └── counts words in cleaned text
        returns Some(count) or None

Why This Matters

Without composition, the host has to load each component separately, call each function in the right order, pass data between them manually, and know the dependency graph. With composition, the host loads one .wasm file and calls one function. That’s it.

The dependency graph is encoded in the composition, not in the host code. If you add a new stage to the pipeline, you change the .wac file and recompose — the host doesn’t change.

This is the same principle as Unix pipes, but type-safe. wac ensures that the types line up — you can’t plug a format: func(string) -> string export into a validate: func(u32) -> bool import. The composition fails at build time, not at runtime.

So when should you use composition vs. host-mediated chaining? Composition is the right call when the pipeline is static — the stages are known at build time and the host shouldn’t need to change when you add or remove stages. Host-mediated chaining (which we’ll build in Part 6) is the right call when the pipeline is dynamic — the host discovers plugins at runtime, the order might change, or you need to handle errors from individual stages independently. Most real systems use both: static sub-pipelines are composed with wac, and the host orchestrates the top-level pipeline.

Implicit Imports: Leaving Gaps for the Host

Sometimes you want the host to provide some imports and composition to wire the rest. The ... syntax in wac handles this:

package example:composition;

let fmt = new example:formatter {};

// The word counter imports both "format" and "log".
// We provide "format" from the formatter.
// The "..." means: import anything we didn't provide from the host.
let counter = new example:word-counter {
    format: fmt.format,
    ...
};

export counter.count-clean;

The composed component will still import log from the host. The formatter is wired internally, but the logging function comes from outside. This lets you compose most of the pipeline while leaving host-specific functionality (like logging, configuration, or filesystem access) for the host to provide at runtime.

From the host’s perspective, the composed component’s interface looks like this:

package example:composed;

world composed-world {
    export count-clean: func(text: string) -> option<u32>;
    import log: func(msg: string);
}

One export (count-clean), one import (log). The formatter and its format function are gone from the interface — they’re internal wiring now. The host calls count-clean and provides log. That’s the whole contract. Composition turned two components with overlapping interfaces into one component with a simpler interface.

On the host side, satisfying the log import works the same way as host imports in Part 3 — define a function on the linker before instantiation:

#![allow(unused)]
fn main() {
// The composed component imports log: func(msg: string)
linker.instance("example/composed/composed-world")?.func_wrap("log", |mut caller, msg: String| {
    println!("[log] {msg}");
})?;
}

The namespace (example/composed/composed-world) comes from the WIT package and world names — the same colon-to-slash convention from Part 3. The WIT example:composed becomes example/composed in the linker, and the world name adds another path segment. We use linker.instance(...)?.func_wrap(...) to access the namespaced import — the same pattern as Part 3. The host provides log the same way it did there, but now the composed component is simpler: one export, one import, and the formatter is hidden inside.

What We Built

PartConceptKey Feature
1Hello, ComponentsBasic string passing across the boundary
2Passing Structured DataRecords, generated Rust structs, no serialization
3Real World mdbook PreprocessorHost imports, import in WIT
4Error Handling & Resourcesresult types, stateful resources, RefCell
5Composing Componentswac, wiring components together, implicit imports
6Real Plugin SystemDiscovery, loading, chaining, error handling, resources

Going Further

wac can resolve components from a Warg registry instead of local files, letting you compose published, versioned components. A .wac file can choose which exports to expose — compose five components and only export one function, keeping the rest as internal implementation details. And if A needs B and B needs A (a cycle), introduce a shared interface that both import, and let the composition provide the implementation from a third component. The WIT looks like this:

interface shared-types {
    record event { payload: string }
}

Both components import shared-types in their WIT, and a third component (or the host) provides the implementation. Neither A nor B depends on the other — they both depend on the shared interface, and composition wires them together.

Note: wac and the Component Model’s composition support are evolving. The CLI commands and .wac syntax may change between versions — this tutorial was tested with wac 0.8.x–0.10.x (see the version note in Step 3). Check the wac repository for the current API.

Next: Part 6 — A Real Plugin System — We’ve built the pieces — strings, structs, errors, resources, composition. Now let’s build something real: a plugin system that discovers, loads, and chains multiple Wasm plugins at runtime. This is the payoff chapter, and it’s where all the pieces finally click together.

Part 6: A Plugin System That Recovers When Plugins Don’t

You’ve built the pieces. A plugin that takes a string and returns a length. A host that loads it. Error handling so plugins can fail gracefully. Resources for stateful handles. Composition for wiring plugins together.

Now let’s build something real.

We’re going to build a plugin system for a text processing pipeline. The host application is a simple tool that reads text, runs it through a chain of plugins, and outputs the result. Plugins can transform text, validate it, or extract information. The host discovers plugins, loads them, handles errors, and chains results.

This is the architecture that motivated the entire series. Without the Component Model, every one of those tasks — passing strings, handling failures, maintaining state across calls — required pointer arithmetic, manual memory management, and unsafe code. With the Component Model, it’s type-safe Rust all the way down.

What We’re Building

A command-line tool called pipeline that:

  1. Discovers plugin components in a directory
  2. Loads each plugin and checks its interface
  3. Runs text through a chain of plugins, passing results between them
  4. Handles errors from any plugin without crashing
  5. Composes plugins that depend on each other

The host doesn’t know what the plugins do at compile time. It knows they implement a text-transform interface — they take a string and return a result. That’s the contract. Everything else is up to the plugin.

Step 1: Define the Interface

Every plugin system starts with a contract. Ours is simple: a plugin takes text and returns either transformed text or an error.

// wit/text-transform.wit
package example:text-transform;

interface text-transform {
    /// A plugin that transforms text.
    transform: func(input: string) -> result<string, transform-error>;
}

world text-transform {
    export text-transform;
}

The result<string, transform-error> return type means plugins can fail. A plugin that validates input might reject it. A plugin that calls an external service might time out. The host must handle both cases.

The error type:

interface text-transform {
    variant transform-error {
        invalid-input(message: string),
        processing-failed(message: string),
    }

    transform: func(input: string) -> result<string, transform-error>;
}

A variant with named cases, each carrying a message. This gives the host structured error information — not “something went wrong” but “the input was invalid because…”

Step 2: Build Three Plugins

We need plugins that do real work to test the system. Three is enough to demonstrate discovery, chaining, and error handling.

Plugin 1: Normalizer

Strips extra whitespace and normalizes line endings. Never fails — every string can be normalized.

// wit/text-transform.wit — same as above
#![allow(unused)]
fn main() {
// src/lib.rs
wit_bindgen::generate!({ path: "wit" });

use exports::example::text_transform::text_transform::{TransformError, Guest};

struct Normalizer;

impl Guest for Normalizer {
    fn transform(input: String) -> Result<String, TransformError> {
        let normalized: String = input
            .lines()
            .map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
            .filter(|line| !line.is_empty())
            .collect::<Vec<_>>()
            .join("\n");
        Ok(normalized)
    }
}

export!(Normalizer);
}

Plugin 2: Word Counter

Counts words and returns a summary string. Never fails.

#![allow(unused)]
fn main() {
struct WordCounter;

impl Guest for WordCounter {
    fn transform(input: String) -> Result<String, TransformError> {
        let word_count = input.split_whitespace().count();
        let line_count = input.lines().count();
        let char_count = input.chars().count();
        Ok(format!("Words: {word_count}, Lines: {line_count}, Chars: {char_count}\n\n{input}"))
    }
}

export!(WordCounter);
}

Plugin 3: Validator

Rejects text that’s too short or contains forbidden words. This plugin can fail — that’s the point.

#![allow(unused)]
fn main() {
struct Validator;

impl Guest for Validator {
    fn transform(input: String) -> Result<String, TransformError> {
        if input.trim().is_empty() {
            return Err(TransformError::InvalidInput(
                "Input is empty after trimming".to_string(),
            ));
        }
        if input.split_whitespace().count() < 3 {
            return Err(TransformError::InvalidInput(
                "Input must contain at least 3 words".to_string(),
            ));
        }
        // Check for forbidden words
        let forbidden = ["spam", "abuse"];
        for word in input.split_whitespace() {
            if forbidden.contains(&word.to_lowercase().as_str()) {
                return Err(TransformError::ProcessingFailed(
                    format!("Forbidden word detected: {word}"),
                ));
            }
        }
        Ok(input)
    }
}

export!(Validator);
}

Build each one:

cargo build --target wasm32-wasip2

Copy the .wasm files to a plugins directory:

mkdir -p plugins
cp target/wasm32-wasip2/debug/normalizer.wasm plugins/
cp target/wasm32-wasip2/debug/word_counter.wasm plugins/
cp target/wasm32-wasip2/debug/validator.wasm plugins/

Step 3: Build the Host

The host is the orchestrator. It discovers plugins, loads them, and chains their output.

One thing you’ll notice: the host state changed. Parts 1–5 used WasiCtx + ResourceTable with a WasiView impl. Here we use WasiP1Ctx instead:

// Parts 1–5: two fields + WasiView impl
struct State {
    ctx: WasiCtx,
    table: ResourceTable,
}
impl WasiView for State { ... }

// Part 6+: one field, with a WasiView impl that delegates to WasiP1Ctx
struct HostState {
    wasi: WasiP1Ctx,
}
impl WasiView for HostState {
    fn ctx(&mut self) -> &mut WasiCtx { self.wasi.ctx() }
    fn table(&mut self) -> &mut ResourceTable { self.wasi.table() }
}

WasiP1Ctx is the WASI preview1 compatibility layer — it bundles the context and resource table into a single type. If you don’t need separate access to the resource table (and most hosts don’t), this is less boilerplate — one field instead of two, with a WasiView impl that delegates to WasiP1Ctx’s methods. Both approaches work identically from the plugin’s perspective. We’ll use WasiP1Ctx from here on — if you need fine-grained WASI configuration for resources, the two-field approach from earlier parts still works.

#![allow(unused)]
fn main() {
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use wasmtime::component::{Component, Linker};
use wasmtime::{Engine, Store};
use wasmtime_wasi::preview1::WasiP1Ctx;
use wasmtime_wasi::{WasiCtxBuilder, WasiView, WasiCtx, ResourceTable};

// Generated bindings for the text-transform interface.
// The `TextTransform` type and `TransformError` enum are generated by `bindgen!`.
// The struct name comes from the `world` declaration — `world text-transform`
// generates `TextTransform`. If the name doesn't match, add `world: "text-transform"`
// to the bindgen! call to be explicit.
wasmtime::component::bindgen!({
    path: "wit/text-transform.wit",
});

struct HostState {
    wasi: WasiP1Ctx,
}

// WasiP1Ctx implements WasiView, but the component-model add_to_linker_sync
// function needs WasiView on the *store data type* (HostState), not on
// WasiP1Ctx directly. So we delegate:
impl WasiView for HostState {
    fn ctx(&mut self) -> &mut WasiCtx { self.wasi.ctx() }
    fn table(&mut self) -> &mut ResourceTable { self.wasi.table() }
}

// WasiP1Ctx bundles WasiCtx + ResourceTable (see above for the comparison)

fn create_engine() -> Result<Engine> {
    // Engine::default() enables the component model automatically in
    // wasmtime 29+. If you need custom configuration (memory limits,
    // fuel metering), use Config::new() + Engine::new(&config).
    // Note: Engine is the same wasmtime::Engine for both core WASM and the
    // component model — there's no wasmtime::component::Engine. The component
    // model uses the same engine instance.
    Ok(Engine::default())
}

fn create_store(engine: &Engine) -> Result<Store<HostState>> {
    let wasi = WasiCtxBuilder::new()
        .build_p1();  // build_p1() is the preview1 counterpart to .build()
                      // — it returns a WasiP1Ctx instead of a WasiCtx
    Ok(Store::new(engine, HostState { wasi }))
}
}

Discovering Plugins

The host scans a directory for .wasm files. This is the simplest discovery mechanism — in a real system you might use a manifest file, a registry, or a configuration file.

#![allow(unused)]
fn main() {
fn discover_plugins(plugin_dir: &Path) -> Result<Vec<PathBuf>> {
    let mut plugins = Vec::new();
    for entry in fs::read_dir(plugin_dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().map_or(false, |ext| ext == "wasm") {
            plugins.push(path);
        }
    }
    // Sort for deterministic ordering — a real system would use a manifest
    plugins.sort();
    Ok(plugins)
}
}

Loading a Plugin

Loading means: create the engine, instantiate the component, and get a typed handle to the transform function.

#![allow(unused)]
fn main() {
fn load_plugin(
    engine: &Engine,
    path: &Path,
) -> Result<(Store<HostState>, TextTransform)> {
    let component = Component::from_file(engine, path)
        .with_context(|| format!("Failed to load component from {:?}", path))?;

    let mut linker = Linker::new(engine);
    // Add WASI to the linker so plugins can use println!, file I/O, etc.
    // We use the top-level wasmtime_wasi::add_to_linker_sync (not the
    // preview1 version) because we have a component Linker. The preview1
    // version takes a core WASM linker — the types don't match.
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;
    let mut store = create_store(engine)?;

    let instance = linker.instantiate(&mut store, &component)
        .with_context(|| format!("Failed to instantiate {:?}", path))?;

    let bindings = TextTransform::new(&mut store, &instance)
        .with_context(|| format!("Component {:?} doesn't export text-transform", path))?;

    Ok((store, bindings))
}
}

The TextTransform::new call does two things: it checks that the component exports the text-transform interface, and it gives us a typed handle. If a .wasm file in the plugins directory doesn’t implement the interface, the load fails with a clear error message. The host skips it and continues — one bad plugin doesn’t take down the system.

Running the Pipeline

The pipeline runs text through each plugin in sequence. Each plugin’s output becomes the next plugin’s input. If any plugin fails, the pipeline stops and reports the error.

#![allow(unused)]
fn main() {
use anyhow::{Result, anyhow};

fn run_pipeline(
    engine: &Engine,
    plugin_paths: &[PathBuf],
    input: &str,
) -> Result<String> {
    let mut text = input.to_string();
    let mut processed_by = Vec::new();

    for path in plugin_paths {
        let name = path.file_stem()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();

        let (mut store, bindings) = match load_plugin(engine, path) {
            Ok(result) => result,
            Err(e) => {
                eprintln!("⚠ Skipping {:?}: {}", path, e);
                continue;  // Skip broken plugins, don't crash
            }
        };

        match bindings.call_transform(&mut store, &text) {
            Ok(Ok(transformed)) => {
                println!("✓ {} — {} chars → {} chars", name, text.len(), transformed.len());
                text = transformed;
                processed_by.push(name);
            }
            Ok(Err(e)) => {
                // The plugin rejected the input
                match e {
                    TransformError::InvalidInput(msg) => {
                        eprintln!("✗ {} rejected input: {}", name, msg);
                    }
                    TransformError::ProcessingFailed(msg) => {
                        eprintln!("✗ {} failed: {}", name, msg);
                    }
                }
                return Err(anyhow!(
                    "Pipeline stopped at {} — input rejected", name
                ));
            }
            Err(e) => {
                // Wasm-level error (trap, out of memory, etc.)
                eprintln!("✗ {} crashed: {}", name, e);
                return Err(anyhow!(
                    "Pipeline stopped at {} — plugin crashed", name
                ));
            }
        }
    }

    println!("Processed by: {}", processed_by.join(" → "));
    Ok(text)
}
}

Three outcomes for each plugin:

  1. Success — output flows to the next plugin
  2. Application error — the plugin returned Err, the pipeline stops with a structured message
  3. Wasm error — the plugin trapped (crashed), the pipeline stops with a system error

This is the result type from Part 4 doing real work. The host doesn’t need to parse error strings or check exit codes — it gets a typed TransformError variant.

Putting It All Together

fn main() -> Result<()> {
    let plugin_dir = PathBuf::from(
        std::env::args().nth(1).unwrap_or_else(|| "plugins".to_string())
    );

    if !plugin_dir.exists() {
        eprintln!("Plugin directory {:?} not found", plugin_dir);
        std::process::exit(1);
    }

    let engine = create_engine()?;
    let plugins = discover_plugins(&plugin_dir)?;

    if plugins.is_empty() {
        eprintln!("No plugins found in {:?}", plugin_dir);
        std::process::exit(1);
    }

    println!("Found {} plugin(s):", plugins.len());
    for p in &plugins {
        println!("  - {}", p.file_stem().unwrap_or_default().to_string_lossy());
    }
    println!();

    // Read input from stdin
    let input = std::io::read_to_string(std::io::stdin())
        .context("Failed to read input from stdin")?;

    let output = run_pipeline(&engine, &plugins, &input)?;
    print!("{}", output);

    Ok(())
}

Step 4: Run It

Create some test input:

echo "  hello   world   this   is   a   test  " | cargo run -- plugins

Output:

Found 3 plugin(s):
  - normalizer
  - validator
  - word_counter

✓ normalizer — 40 chars → 26 chars
✓ validator — 26 chars → 26 chars
✓ word_counter — 26 chars → 57 chars
Processed by: normalizer → validator → word_counter
Words: 6, Lines: 1, Chars: 26
hello world this is a test

The normalizer strips whitespace, the validator checks word count (6 words — passes the 3-word minimum), and the word counter adds statistics.

Now test with invalid input:

echo "hi" | cargo run -- plugins

Output:

Found 3 plugin(s):
  - normalizer
  - validator
  - word_counter

✓ normalizer — 2 chars → 2 chars
✗ validator rejected input: Input must contain at least 3 words
Error: Pipeline stopped at validator — input rejected

The validator catches the short input and the pipeline stops. The word counter never runs — it would have produced misleading statistics on invalid input.

Step 5: Add a Resource Plugin

Remember resources from Part 4? They’re for stateful handles. Let’s add a plugin that maintains a running word count across multiple inputs.

First, extend the WIT interface with a resource.

Important: Adding running-counter to the text-transform interface is a breaking change — every plugin that implements text-transform must now also implement running-counter. Our normalizer, word counter, and validator don’t. In practice, you’d define a separate interface for the resource (e.g., interface counter { resource running-counter { ... } }) or make the resource optional. We’re extending the same interface here to keep the example simple.

If you’re building along and update the WIT file, the existing plugins will fail to compile — export!(Normalizer) will complain that GuestRunningCounter is unimplemented. You can either add a no-op implementation to each plugin (fn new() -> Self { unimplemented!() } and friends — they’ll never be called since those plugins aren’t loaded for the resource path), or skip rebuilding the Step 3 plugins after this WIT change. The running-counter resource only matters for the new plugin shown below.

interface text-transform {
    variant transform-error {
        invalid-input(message: string),
        processing-failed(message: string),
    }

    transform: func(input: string) -> result<string, transform-error>;

    /// A running word counter that accumulates stats across calls.
    resource running-counter {
        constructor();
        count: func(input: string) -> string;
        total-words: func() -> u32;
    }
}

world text-transform {
    export text-transform;
}

The plugin implementation:

#![allow(unused)]
fn main() {
use std::cell::RefCell;
use exports::example::text_transform::text_transform::{Guest, GuestRunningCounter, TransformError};

/// We name this `RunningCounterImpl` (not `RunningCounter`) because wit-bindgen
/// generates a `RunningCounter` type for the resource handle — the same naming
/// conflict we saw in Part 4 with `KvStoreImpl`. Using the same name would
/// cause a compilation error.
struct RunningCounterImpl {
    total: RefCell<u32>,
}

// The world exports the entire text-transform interface, so the Guest trait
// requires implementing both `transform` and the `running-counter` resource.
// This plugin exists for the resource — the `transform` method is a pass-through.
impl Guest for RunningCounterImpl {
    // The associated type tells wit-bindgen which Rust type implements the resource's methods.
    // Same pattern as Part 4's `type KvStore = KvStoreImpl;`.
    type RunningCounter = RunningCounterImpl;

    fn transform(input: String) -> Result<String, TransformError> {
        // This plugin is used via its resource, not the transform function.
        // A real system would put the resource in a separate interface.
        Ok(input)
    }
}

impl GuestRunningCounter for RunningCounterImpl {
    fn new() -> Self {
        RunningCounterImpl {
            total: RefCell::new(0),
        }
    }

    fn count(&self, input: String) -> String {
        let words = input.split_whitespace().count() as u32;
        *self.total.borrow_mut() += words;
        let total = *self.total.borrow();
        format!("This: {words}, Running total: {total}\n\n{input}")
    }

    fn total_words(&self) -> u32 {
        *self.total.borrow()
    }
}

export!(RunningCounterImpl);
}

RefCell for interior mutability — resource methods take &self, as we discussed in Part 4. The Component Model can’t track exclusive borrows across the Wasm boundary, so RefCell is the standard pattern.

Why does RunningCounterImpl implement Guest too? The WIT world exports the entire text-transform interface — both transform and running-counter. wit-bindgen generates a Guest trait that requires implementing transform and an associated type for the resource, plus a separate GuestRunningCounter trait for the resource methods. A component must implement both. Our transform is a no-op pass-through — this plugin exists for the resource, not the function. As the note at the top of this section explains, a real system would put the resource in its own interface to avoid this coupling.

A note on the output format: Both WordCounter::transform and RunningCounter::count prepend statistics to the input text. If you chain them in a pipeline — normalize, then word-count, then running-count — you’d get doubled statistics (the word count output becomes the running counter’s input). In a real system, you’d return structured data (the text plus the stats separately) instead of concatenating everything into one string. We’re concatenating everything into one string to keep the focus on the resource pattern, not the output format.

On the host side, the resource handle is generated by wit-bindgen:

#![allow(unused)]
fn main() {
// After loading the plugin with TextTransform::new(...)
let counter = bindings.running_counter()(&mut store)
    .context("Plugin doesn't export running-counter")?;

// Count words in multiple inputs
let result1 = counter.call_count(&mut store, "hello world")?;
let result2 = counter.call_count(&mut store, "foo bar baz")?;

// Get the running total
let total = counter.call_total_words(&mut store)?;
println!("Total words across all calls: {total}");  // → 5
}

The resource handle is tied to the store’s lifetime. When the store is dropped, the Component Model automatically calls the resource’s destructor on the plugin side. No manual cleanup.

What We Built

Let’s step back and see what the Component Model gave us:

ProblemWithout the Component ModelWith the Component Model
Pass strings across WasmAllocate Wasm memory, write bytes, pass pointer, read backfunc(input: string) -> string
Handle plugin errorsCheck return codes or parse error stringsresult<string, transform-error> with typed variants
Stateful plugin handlesRaw pointer + manual lifecycle managementresource running-counter with automatic cleanup
Chain pluginsHost mediates every call, passing output to next inputHost chains plugins; composition via wac compose is an alternative (Part 5)
Discover plugins at runtimeParse Wasm exports manuallyGenerated bindings check the interface for you

Five problems, five solutions, zero unsafe code on the host side.

What This Means

Typed bindings are the difference between “it works” and “it’s maintainable.” Raw export_index lookups and stringly-typed function calls will get you through a demo. Generated bindings — TextTransform::new, bindings.call_transform — turn the WIT contract into Rust types the compiler checks for you.

result types make error handling part of the contract, not an afterthought. The WIT file says result<string, transform-error>. The generated Rust code returns Result<String, TransformError>. You can’t forget to handle the error case — the type system won’t let you.

Resources give plugins state without giving them unsafe. The RunningCounterImpl holds a RefCell<u32> behind &self methods. The host gets a handle it can call methods on. Neither side writes unsafe pointer arithmetic. The Component Model handles the lifetime and cleanup.

File-based discovery is the starting point, not the endpoint. Scanning a directory for .wasm files works for local tools. A real plugin system needs a manifest, a registry, or at minimum a configurable pipeline order — something that says which plugins to load and in what order.

Store-per-plugin means resources don’t persist across pipeline stages. Our run_pipeline creates a new store for each plugin and drops it at the end of the loop. A RunningCounter created in one iteration is gone by the next. A real system keeps stores alive for the plugin’s lifetime.

Design Decisions

A few choices worth discussing, because you’ll face them in any real plugin system.

File-based Discovery

Scanning a directory for .wasm files is the simplest discovery mechanism. It works for local tools. For distributed systems, you’d want a manifest file (plugins.json or plugins.toml) that lists plugins by name and version, not file path alone. Or a registry like Warg that resolves plugin names to versioned components. Or a configuration file that specifies the pipeline order, rather than relying on filesystem sort order.

Pipeline Order

We process plugins in alphabetical order by filename. This is deterministic but not necessarily correct. In our demo, alphabetical sort happens to run the validator before the word counter — but that’s coincidence, not design. Rename the validator to z_validator and the order breaks. A real system needs intentional ordering, not filename luck. Manifest-based ordering lists plugins in the order they should run. Dependency declaration lets each plugin declare what it needs (like WIT import), and the host resolves the order. Or the user specifies the order on the command line or in a config file.

Error Strategy

We stop the pipeline on the first error. An alternative is error collection — run all plugins, collect all errors, and report them together. This is better for validation (you want to see all the problems, not only the first one). You could implement it by changing run_pipeline to continue on Err and collect errors into a Vec<TransformError>.

Plugin Isolation

Each plugin gets its own Store. This gives you memory isolation (a plugin can’t access another plugin’s memory), error isolation (a trap in one plugin doesn’t crash the host or other plugins), and resource isolation (each plugin’s resources are cleaned up when its store is dropped). This is the right default. In a production system, you might want to share a store between plugins for performance (avoids copying data between stores), but that trades isolation for speed.

Going Further

  • Hot reloading: Watch the plugins directory for changes and reload plugins without restarting the host. The Component Model makes this safe — instantiate a new component and swap the handle. The core pattern is simple:

    #![allow(unused)]
    fn main() {
    fn reload_plugin(&mut self, name: &str) -> Result<()> {
        let wasm_path = self.plugin_dir.join(format!("{name}.wasm"));
        let component = Component::from_file(&self.engine, &wasm_path)?;
        let mut store = self.create_store();
        let instance = self.linker.instantiate(&mut store, &component)?;
        // Note: This sketch assumes PluginHost has a shared `linker` field.
        // The load_plugin code earlier in this part creates a new Linker per
        // call — you'd need to restructure PluginHost to hold a shared Linker
        // and reuse it across loads.
        let bindings = TextTransform::new(&mut store, &instance)?;
        // Swap: the old store and bindings are dropped,
        // the new ones take their place in the HashMap.
        self.plugins.insert(name.to_string(), (store, bindings));
        Ok(())
    }
    }

    The old (store, bindings) is dropped when the new one is inserted into the HashMap. Any in-flight calls on the old handles will complete before the drop — Wasm stores are single-threaded, so there’s no data race. Resources held by the old store get their destructors called on drop.

  • Plugin permissions. Use WASI’s capability-based security to limit what plugins can do — a text transform plugin probably doesn’t need filesystem access.

  • Async processing. The Component Model’s async proposal (streams, futures) would let plugins process large inputs without buffering everything in memory.

  • Versioning. WIT package versioning ensures the host and plugins agree on the interface — a plugin built for text-transform@0.2.0 might not work with a host expecting 0.1.0.

Note: The Component Model’s async support and versioning features are still evolving. As of May 2026, wasmtime’s Component Model async support is still in development — check the Component Model specification and the wasmtime repo for the current status.

Next: Part 7 — Testing Across the Boundary — A plugin system is only as trustworthy as its tests. But testing Wasm components is different from testing normal Rust — you’re testing across a boundary that involves serialization, code generation, and a runtime. We’ll write contract tests that verify WIT expectations, integration tests that load the actual .wasm, and pipeline tests that exercise the full chain.

Part 7: Testing Across the Boundary — When the Compiler Can Only Check One Side at a Time

Part 6 gave us a working plugin system. Discovery, loading, execution, error handling, composition — all wired up and running. But we tested it by hand. We ran the host, looked at the output, and said “looks right.”

That doesn’t scale. When you have ten plugins, each with their own error types and edge cases, you need automated tests. And testing WASM components is different from testing normal Rust code, because you’re testing across a boundary — the plugin compiles to WASM, the host runs it through wasmtime, and the only contract between them is a WIT file.

This part covers three levels of testing. Unit tests catch logic errors in the plugin before it becomes WASM. Integration tests catch boundary mismatches by loading actual components through wasmtime. Contract tests catch interface drift by verifying the WIT file matches what both sides expect. Each level catches different bugs. You need all three.

The Testing Problem

Here’s the thing about component plugins: they have two implementations of the same interface. The plugin implements the WIT interface in Rust that compiles to WASM. The host generates Rust bindings from the same WIT file and calls through wasmtime. If either side gets the interface wrong, you get runtime errors — usually mysterious ones like “export not found” or “type mismatch in func.call.”

Normal Rust code doesn’t have this problem. If you change a function signature, the compiler tells you. But when the plugin and the host compile separately against the same WIT file, the compiler can’t check both sides at once. You need tests that actually load and call the component.

And there’s another problem: the plugin compiles to wasm32-wasip2. You can’t run cargo test on a cdylib (the C dynamic library crate type from Part 1) that targets WASM. The test runner runs on your host architecture (x86_64 or aarch64), not inside a WASM runtime. So you need to split your code: the logic that doesn’t depend on WASM gets tested normally, and the WASM boundary gets tested via the host.

Level 1: Unit Testing the Plugin

The key insight: separate your plugin logic from the Wasm boundary.

Here’s what most tutorials show — the Guest implementation doing everything:

#![allow(unused)]
fn main() {
// plugin/src/lib.rs
wit_bindgen::generate!({
    path: "../wit",
    world: "text-transform",
});
// The generate! macro creates the Guest trait, the export! macro,
// and the binding types. Import from the generated exports module —
// the path matches the WIT package name (example:text-transform):
use exports::example::text_transform::text_transform::{Guest, TransformError};

struct TextTransform;

impl Guest for TextTransform {
    fn transform(input: String) -> Result<String, TransformError> {
        // All the logic in here — hard to test directly
        if input.is_empty() {
            return Err(TransformError::InvalidInput("empty string".to_string()));
        }
        if input.len() > 10_000_000 {
            return Err(TransformError::ProcessingFailed(
                "input exceeds maximum length".to_string(),
            ));
        }
        Ok(input.to_uppercase())
    }
}

export!(TextTransform);
}

You can’t cargo test this. It’s a cdylib targeting wasm32-wasip2. The export! macro and Guest trait are WASM-specific. But the logic — checking for empty input and converting to uppercase — is pure Rust that doesn’t need WASM at all.

The fix: extract the logic into a separate module.

#![allow(unused)]
fn main() {
// plugin/src/lib.rs
mod transform;

wit_bindgen::generate!({
    path: "../wit",
    world: "text-transform",
});
use exports::example::text_transform::text_transform::{Guest, TransformError};

struct TextTransform;

impl Guest for TextTransform {
    fn transform(input: String) -> Result<String, TransformError> {
        transform::transform(input)  // Thin wrapper
    }
}

export!(TextTransform);
}
#![allow(unused)]
fn main() {
// plugin/src/transform.rs
use crate::exports::example::text_transform::text_transform::TransformError;

const MAX_INPUT_LEN: usize = 10_000_000;

pub fn transform(input: String) -> Result<String, TransformError> {
    if input.is_empty() {
        return Err(TransformError::InvalidInput("empty string".to_string()));
    }
    if input.len() > MAX_INPUT_LEN {
        return Err(TransformError::ProcessingFailed(
            format!("input exceeds maximum length of {} bytes", MAX_INPUT_LEN),
        ));
    }
    Ok(input.to_uppercase())
}
}

Now you can test the logic directly:

#![allow(unused)]
fn main() {
// plugin/src/transform.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn uppercase_transform() {
        assert_eq!(transform("hello".into()).unwrap(), "HELLO");
    }

    #[test]
    fn empty_input_is_error() {
        assert!(matches!(transform("".into()), Err(TransformError::InvalidInput(_))));
    }

    #[test]
    fn preserves_non_ascii() {
        assert_eq!(transform("café".into()).unwrap(), "CAFÉ");
    }

    #[test]
    fn whitespace_preserved() {
        assert_eq!(transform("hello world".into()).unwrap(), "HELLO WORLD");
    }
}
}

Run these with cargo test — they compile for your host architecture, not Wasm. They’re fast, they’re normal Rust tests, and they catch logic bugs before you even build the component.

What to Test at This Level

Test happy paths — does transform produce the right output for valid inputs? Test error paths — does it return the right error variant for invalid inputs? Test edge cases — empty strings, very long strings, Unicode, special characters. And test invariants — if transform claims to be idempotent (transform(transform(x)) == transform(x)), test that.

What NOT to Test at This Level

Don’t test Wasm-specific behavior — memory limits, trap handling, import resolution. Don’t test host interaction — how the host calls the function, how errors propagate across the boundary. Don’t test WIT conformance — whether the function signature matches what the WIT file says.

Those are integration tests. We’ll get to them.

Level 2: Integration Testing the Host

Integration tests load an actual .wasm component and call it through wasmtime. This is where you find out if the host and the plugin actually agree on the interface.

The pattern: build a test plugin, load it, call it, check the result.

#![allow(unused)]
fn main() {
// host/tests/integration_test.rs
use anyhow::Result;
use wasmtime::*;
use wasmtime::component::Linker;
use wasmtime_wasi::preview1::WasiP1Ctx;
use wasmtime_wasi::{WasiCtxBuilder, WasiView, WasiCtx, ResourceTable};

// Generate typed bindings from the WIT file.
// This macro must be at module level — it generates types at compile time.
// The exact generated type names depend on your WIT package name
// and interface — check the macro's output if names don't match.
wasmtime::component::bindgen!({ path: "wit/text-transform.wit" });

struct HostState {
    wasi: WasiP1Ctx,
}

// Using the same WasiP1Ctx pattern from Part 6 — the preview1
// compatibility layer bundles WasiCtx and ResourceTable into
// one type. Both patterns work; WasiP1Ctx is one field instead of
// two. The WasiView impl delegates to WasiP1Ctx's methods,
// keeping the linker call simple.
impl WasiView for HostState {
    fn ctx(&mut self) -> &mut WasiCtx { self.wasi.ctx() }
    fn table(&mut self) -> &mut ResourceTable { self.wasi.table() }
}

/// Helper: load a component from the build directory
fn load_component(engine: &Engine, name: &str) -> Result<Component> {
    let path = format!("../target/wasm32-wasip2/release/{}.wasm", name);
    Ok(Component::from_file(engine, &path)?)
}

/// Helper: create a component-mode store with WASI support
fn create_store(engine: &Engine) -> Result<(Store<HostState>, Linker<HostState>)> {
    let mut linker = Linker::new(engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let wasi = WasiCtxBuilder::new().build_p1();
    let state = HostState { wasi };
    let store = Store::new(engine, state);

    Ok((store, linker))
}

#[test]
fn load_and_call_normalizer() -> Result<()> {
    let engine = Engine::default();
    let component = load_component(&engine, "normalizer")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "  hello  world  ")?;
    assert_eq!(result?, "hello world");

    Ok(())
}

#[test]
fn load_and_call_validator_invalid_input() -> Result<()> {
    let engine = Engine::default();
    let component = load_component(&engine, "validator")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "")?;
    assert!(result.is_err(), "empty string should fail validation");

    Ok(())
}

#[test]
fn load_and_call_word_counter() -> Result<()> {
    let engine = Engine::default();
    let component = load_component(&engine, "word_counter")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "hello world")?;
    // Word counter prepends statistics to the input (from Part 6).
    // A targeted assertion checks the important part — the count —
    // without making the test brittle about exact formatting.
    assert!(result?.starts_with("Words: 2"), "word counter should report 2 words");

    Ok(())
}
}

The Build Dependency

Integration tests need compiled .wasm components. This means your test workflow is:

  1. Build the plugins: cd plugin && cargo build --target wasm32-wasip2 --release
  2. Run the host tests: cd host && cargo test

If the plugins aren’t built, the tests fail with “file not found.” This is a common source of CI failures — the test runner can’t build Wasm components on its own.

You can automate this with a build script:

// host/build.rs
use std::process::Command;

fn main() {
    // Build the plugin for Wasm
    let status = Command::new("cargo")
        .args(["build", "--target", "wasm32-wasip2", "--release"])
        .current_dir("../plugin")
        .status()
        .expect("failed to run cargo build for plugin");

    assert!(status.success(), "plugin build failed");

    // Tell Cargo to re-run if the plugin source changes.
    // Note: rerun-if-changed requires a file path, not a directory.
    // Listing individual files ensures rebuilds happen when source changes.
    println!("cargo:rerun-if-changed=../plugin/src/lib.rs");
    println!("cargo:rerun-if-changed=../plugin/src/transform.rs");
    println!("cargo:rerun-if-changed=wit/text-transform.wit");
    // Note: watch the host's WIT copy, not the plugin's. The bindgen!
    // macro reads wit/text-transform.wit — if someone edits that file
    // without touching ../plugin/wit/world.wit, the build script must
    // still trigger a rebuild so the generated bindings stay fresh.
}

Note: Build scripts run before the main crate compiles. This works, but it makes cargo test slower because it rebuilds the plugin every time. In CI, consider building the plugins in a separate step and caching the .wasm files.

What to Test at This Level

Test that the plugin loads without error — if the WIT interface changed and the plugin wasn’t rebuilt, you find out here. Test that happy path calls work — the host can call the plugin and get the right result. Test that error propagation works — the host receives the right error variant from the plugin. Test that multiple plugins load — the host can load and call more than one component. And test store isolation — a trap in one plugin doesn’t affect others.

What NOT to Test at This Level

Don’t test plugin logic — that’s what unit tests are for. Don’t duplicate them. Don’t test WASM internal details — don’t test wasmtime’s implementation. Test your code. Don’t test performance — integration tests with real WASM instantiation are too slow for benchmarks. Use criterion separately.

Level 3: Contract Testing

Contract testing answers a question that neither unit tests nor integration tests can: “does the WIT file actually describe what both sides expect?”

Here’s the scenario that contract tests catch: you update the WIT file to add a new field to a record, rebuild the host (which picks up the new WIT), but forget to rebuild the plugin. The host expects Pair { word: string, count: u32, language: string } but the plugin still produces Pair { word: string, count: u32 }. Integration tests might catch this — or they might not, if the host code doesn’t access the new field in the test.

Contract tests check the WIT file itself: the types, the function signatures, the resource definitions. They ensure that the WIT is consistent and complete.

Checking the WIT Directly

The simplest contract test: parse the WIT file and verify it declares what you expect.

#![allow(unused)]
fn main() {
// tests/contract_test.rs
use anyhow::Result;

/// Verify the WIT file declares the expected interface.
///
/// This catches silent WIT drift — when the WIT file changes
/// without updating the host or plugin code (or vice versa).
#[test]
fn text_transform_wit_has_expected_functions() -> Result<()> {
    let wit_source = std::fs::read_to_string("wit/text-transform.wit")?;

    // The interface must declare a transform function
    assert!(
        wit_source.contains("transform"),
        "WIT must declare 'transform' function"
    );

    // The interface must declare the error type
    assert!(
        wit_source.contains("variant transform-error"),
        "WIT must declare 'transform-error' variant"
    );

    // The error must have the expected cases
    assert!(
        wit_source.contains("invalid-input"),
        "transform-error must have 'invalid-input' case"
    );
    assert!(
        wit_source.contains("processing-failed"),
        "transform-error must have 'processing-failed' case"
    );

    Ok(())
}
}

This is a weak form of contract testing — it checks that the WIT file contains certain strings. It won’t catch type signature changes (like transform changing from func(text: string) -> string to func(text: string) -> result<string, transform-error>). For that, you need to actually parse the WIT.

Parsing the WIT with wit-parser

The wit-parser crate parses WIT files into a structured representation you can assert against:

# Add to host/Cargo.toml [dev-dependencies]
wit-parser = "0.221"  # Matches wasmtime 29; check crates.io for the latest
#![allow(unused)]
fn main() {
// tests/contract_test.rs
//
// Written against wit-parser 0.221 (pulled in by wasmtime 29). The crate is pre-1.0;
// if you're on a different version, the Arena access patterns or
// WorldItem field names may differ, but the structure is the same:
// parse WIT → find the World → walk exports → assert structure.
use anyhow::{Result, anyhow};
use wit_parser::{Resolve, WorldItem};

#[test]
fn text_transform_interface_matches_contract() -> Result<()> {
    let mut resolve = Resolve::new();
    // push_path returns (PackageId, PackageSourceMap). We only need the id.
    let (pkg_id, _) = resolve.push_path("wit/text-transform.wit")?;

    // Navigate from the package to its world.
    // resolve.packages is an Arena<Package> — index with pkg_id.
    // Package.worlds is a HashMap<String, WorldId>.
    let pkg = &resolve.packages[pkg_id];
    let world_id = pkg.worlds.values().next()
        .copied()
        .ok_or_else(|| anyhow!("no world found in WIT package"))?;
    let world = &resolve.worlds[world_id];

    // world.exports maps Names to WorldItem variants:
    //   Function(Function) — a direct function export
    //   Interface { id: InterfaceId, .. } — an interface export
    //   Type { id: TypeId, .. } — a type export

    // Check that the world exports a "transform" function.
    // The function could be a direct world export or inside an interface.
    let has_transform = world.exports.values().any(|item| {
        match item {
            WorldItem::Function(f) => f.name == "transform",
            WorldItem::Interface { id, .. } => {
                // Check if the interface contains a "transform" function
                resolve.interfaces[*id].functions.values()
                    .any(|f| f.name == "transform")
            }
            _ => false,
        }
    });
    assert!(has_transform, "world must export 'transform'");

    // For more specific checks — like verifying a function's parameter
    // types — dig into the Function struct's params and result fields,
    // or into resolve.types using the TypeIds from WorldItem::Type.

    Ok(())
}
}

wit-parser API stability: The wit-parser crate is pre-1.0, so types and methods change between versions. The code above was written against wit-parser 0.221 — the version pulled in by wasmtime 29 (verified via cargo generate-lockfile with wasmtime = "29"). Field names and method signatures may differ between versions, but the structure is the same: Resolve::push_pathresolve.packages[id]pkg.worldsresolve.worlds[id]world.exports. Check the wit-parser docs for your version’s API.

What Contract Tests Catch

Contract tests catch missing exports — the WIT doesn’t declare a function the host expects. They catch type mismatches — the WIT declares result<string, error> but the host code assumes string. They catch breaking changes — a record field was removed or renamed. And they catch stale builds — the WIT changed but the plugin wasn’t rebuilt (caught when integration tests fail because the compiled component doesn’t match the new WIT).

Contract tests are especially valuable in a monorepo where multiple teams edit the WIT file. Without them, a WIT change can silently break consumers who haven’t updated their code yet.

A Test Matrix

Here’s how the three levels map to different failure modes:

Failure ModeUnit TestIntegration TestContract Test
Plugin logic error✅ Caught✅ Caught❌ Missed
Plugin not rebuilt after WIT change❌ Missed✅ Caught⚠️ Partial
Host expects different type than WIT declares❌ Missed✅ Caught✅ Caught
WIT missing expected export❌ Missed❌ Missed✅ Caught
Error variant not handled by host❌ Missed✅ Caught❌ Missed
Resource lifetime issue❌ Missed✅ Caught❌ Missed

Notice the gaps: no single level catches everything. A resource lifetime issue (the store is dropped while the host still holds a resource handle) is only caught by integration tests. A missing WIT export is only caught by contract tests. You need all three.

The ⚠️ on “Plugin not rebuilt after WIT change” deserves an explanation. Contract tests verify that the WIT file matches expectations — but they don’t load the compiled .wasm. If someone updates the WIT and also updates the contract tests, the stale plugin still passes contract tests because contract tests never instantiate it. Integration tests catch this because they load the actual component and wasmtime rejects the mismatch between the new WIT bindings and the old binary. Contract tests are “partial” because they only catch the case where the WIT changed but the test expectations didn’t — a narrower failure mode.

Testing Error Paths

Error paths are the most important thing to test in a plugin system, because they’re the paths that fail in production. Happy paths usually work. Error paths often don’t.

Plugin-Side Error Tests

Every error variant your WIT defines should have at least one test:

#![allow(unused)]
fn main() {
// plugin/src/transform.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_string_returns_invalid_input() {
        let err = transform("".into()).unwrap_err();
        assert!(matches!(err, TransformError::InvalidInput(_)));
    }

    #[test]
    fn too_long_returns_processing_failed() {
        let long_input = "x".repeat(10_000_001);
        let err = transform(long_input.into()).unwrap_err();
        assert!(matches!(err, TransformError::ProcessingFailed(_)));
    }
}
}

Host-Side Error Handling Tests

Test that the host handles each error variant correctly:

#![allow(unused)]
fn main() {
// host/tests/integration_test.rs
#[test]
fn validator_rejects_empty_string() -> Result<()> {
    let engine = Engine::default();
    let component = load_component(&engine, "validator")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "")?;
    match result {
        Err(TransformError::InvalidInput(msg)) => {
            assert!(!msg.is_empty(), "error message should be descriptive");
        }
        Err(other) => panic!("wrong error variant: {:?}", other),
        Ok(_) => panic!("empty string should not succeed"),
    }
    Ok(())
}

#[test]
fn host_continues_after_plugin_error() -> Result<()> {
    let mut pipeline = Pipeline::new(&engine)?;

    // Validator rejects empty string
    pipeline.add_plugin("validator")?;

    // But the pipeline should be able to load and run other plugins
    // after the validator fails
    let results = pipeline.run("")?;
    assert!(results.has_errors(), "should have error from validator");
    // The pipeline itself didn't crash
    Ok(())
}
}

Note: TransformError is the error variant we defined in our WIT file in Part 6 (the text-transform interface). The exact type name comes from the bindgen! macro — check the generated code if the name doesn’t match.

Testing Traps

A Wasm trap is the worst-case scenario: the plugin panicked, hit a stack overflow, or divided by zero. The host needs to handle this without crashing.

WASM traps are returned as Err from the wasmtime call — they are not Rust panics. You don’t need catch_unwind. The trap is caught by wasmtime and surfaced as an error result:

#![allow(unused)]
fn main() {
#[test]
fn host_survives_trap() -> Result<()> {
    let engine = Engine::default();
    // Load a plugin that calls unreachable!() (traps on every call)
    let component = load_component(&engine, "trap-plugin")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "hello");

    // The trap is returned as Err — the host process is fine
    assert!(result.is_err(), "Wasm trap should return Err, not panic");

    // The host can continue using the store for other plugins
    Ok(())
}
}

Note: Creating a plugin that intentionally traps requires writing raw WASM or using a plugin that calls unreachable!(). In practice, you’d test this with a specially-built test fixture, not a normal plugin. The key assertion is that the host doesn’t panic — the trap is caught by wasmtime and returned as an Err.

Testing the Pipeline

Part 6’s pipeline chains multiple plugins together. Testing it means testing the interaction between plugins — the host passes one plugin’s output to the next plugin’s input. The tests below use a Pipeline struct with methods like add_plugin, run, executed_count (number of plugins that actually ran), has_errors, has_skips, and succeeded_count — the same run_pipeline logic from Part 6, wrapped in a struct. If you’re building along, implement the Pipeline methods yourself, or adapt the tests to call run_pipeline directly.

#![allow(unused)]
fn main() {
#[test]
fn pipeline_chains_plugins_in_order() -> Result<()> {
    let mut pipeline = Pipeline::new(&engine)?;
    pipeline.add_plugin("normalizer")?;  // Trims whitespace
    pipeline.add_plugin("word-counter")?;  // Counts words

    let results = pipeline.run("  hello   world  ")?;

    // The normalizer should have cleaned the whitespace first,
    // then the word counter counts "hello world" (2 words)
    assert_eq!(results.executed_count(), 2);
    assert!(results.all_succeeded());
    Ok(())
}

#[test]
fn pipeline_stops_on_first_error() -> Result<()> {
    let mut pipeline = Pipeline::new(&engine)?;
    pipeline.add_plugin("validator")?;  // Rejects empty
    pipeline.add_plugin("normalizer")?;

    let results = pipeline.run("")?;

    // Validator rejects, normalizer never runs
    assert!(results.has_errors());
    assert_eq!(results.executed_count(), 1, "should stop after first error");
    Ok(())
}

#[test]
fn pipeline_skips_broken_plugin() -> Result<()> {
    let mut pipeline = Pipeline::new(&engine)?;
    pipeline.add_plugin("normalizer")?;
    pipeline.add_plugin("nonexistent")?;  // Not a real plugin
    pipeline.add_plugin("word-counter")?;

    let results = pipeline.run("hello world")?;

    // The broken plugin is skipped, the others still run
    assert!(results.has_skips(), "broken plugin should be skipped");
    assert_eq!(results.succeeded_count(), 2, "other plugins should succeed");
    Ok(())
}
}

A CI Workflow

Here’s a GitHub Actions workflow that runs all three levels:

name: Test Plugin System

on: [push, pull_request]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-wasip2
      - name: Unit test plugin
        run: cd plugin && cargo test
      - name: Unit test host
        run: cd host && cargo test --lib

  integration-test:
    runs-on: ubuntu-latest
    needs: unit-test
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-wasip2
      - name: Build plugins
        run: cd plugin && cargo build --target wasm32-wasip2 --release
      - name: Integration tests
        run: cd host && cargo test --test integration_test
        # We run only integration tests here — contract tests have their
        # own job, and unit tests run in the unit-test job. Each job maps to
        # exactly one testing level, with no overlap.

  contract-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Contract tests
        run: cd host && cargo test --test contract_test

The key detail: integration tests run after unit tests and require built plugins. Contract tests run independently — they only need the WIT file, not the compiled Wasm.

The Testing Mental Model

Think of it as three walls of defense:

   Plugin source         WIT file           Host code
        │                    │                   │
        ▼                    ▼                   ▼
   ┌─────────┐        ┌──────────┐       ┌──────────┐
   │  Unit    │        │ Contract │       │   Unit   │
   │  Tests   │        │  Tests   │       │   Tests  │
   └────┬─────┘        └────┬─────┘       └────┬─────┘
        │                    │                   │
        │    compiled Wasm   │  generated code   │
        ▼                    ▼                   ▼
   ┌──────────────────────────────────────────────────┐
   │              Integration Tests                    │
   │     (host + plugin + wasmtime + WIT together)    │
   └──────────────────────────────────────────────────┘

Unit tests are fast and catch logic errors. Contract tests catch interface drift. Integration tests catch everything else — but they’re slow and require built artifacts. The faster a test runs, the more often you’ll run it. Run unit tests on every save. Run integration tests before you merge. Run contract tests when the WIT changes.


Going Further

This is the last part of the tutorial. You now have a complete component-based plugin system: discovery, loading, execution, error handling, composition, a real-world pipeline, and three levels of testing. Here’s where to take it next.

Fuzz testing the boundary. Property-based testing with proptest or arbitrary can generate random inputs and verify invariants: the plugin never crashes the host, error types are always propagated correctly, and round-trip serialization (host → plugin → host) preserves data. Fuzz testing is especially good at finding edge cases in string handling and Unicode.

Performance testing. Integration tests tell you if things work, not how fast they are. Use criterion benchmarks to measure Wasm instantiation time, call overhead, and data transfer costs. The Component Model’s canonical ABI is evolving zero-copy optimizations for buffer transfers — when available in your toolchain version, benchmark with and without them to see the difference.

Plugin sandboxing. WASI’s capability-based security can restrict what plugins access: no filesystem, no network, only the functions the host provides. Configure WASI contexts per-plugin with allowlists. This is the main security advantage of the Component Model over native plugins — use it.

Async components. The Component Model’s async proposal (streams, futures) would let plugins process large inputs without buffering everything in memory. A text transform that processes a 10MB file doesn’t need 10MB of WASM memory — it can stream chunks. As of May 2026, wasmtime’s Component Model async support is still in development — check the Component Model spec and the wasmtime repo for updates.

Publishing and distributing plugins. A production plugin system needs a way to find and install plugins. Warg and other WASM registries let you publish components with version constraints. The host can resolve dependencies and download plugins at startup, instead of requiring them in a local directory. Warg integrates with wasm-pkg-tools — the same .wasm artifacts you’ve been building with cargo build --target wasm32-wasip2 can be published directly.

Multiple languages. The Component Model is language-agnostic. Your host is Rust, but plugins could be Go, Python, or JavaScript — any language with a wit-bindgen generator. Add a Python plugin that uses componentize-py or a Go plugin that uses wit-bindgen-go. The host code doesn’t change at all.


Seven parts ago, we started with a single function that took a string and returned its length. Now you can define typed interfaces, pass structured data, handle errors, manage stateful resources, compose components, build real plugin systems, and test all of it. The Component Model did the heavy lifting — not only by removing unsafe pointer arithmetic, but by giving the type system enough information to check both sides of the boundary at once.

The payoff isn’t the plumbing. It’s what you can build when the plumbing disappears.

Wasmtime Version Migration Guide

This tutorial’s code targets wasmtime 29 and wit-bindgen 0.41. If you’re using a newer version, the concepts (WIT files, generated bindings, resources, composition) are the same — but some Rust APIs have changed. This guide covers the significant migrations.

The largest API changes happened at wasmtime 30 (WasiView split), wasmtime 36 (WasiCtxView return type), and wasmtime 40 (sync/async separation for component imports). Versions beyond v40 have been more incremental for the Component Model APIs — the bindgen! macro, Component::new(), Linker, Engine, and Store APIs have remained largely stable. Wasmtime 44 added experimental map<K, V> support. Wasmtime 45 added Component reflection APIs and a copying GC collector, and raised the minimum Rust version to 1.93.0. As of May 2026, the latest release is wasmtime 45.

Wasmtime 30: WasiView Split

wasmtime 30 split the WasiView trait into two separate traits: IoView and WasiView.

Before (wasmtime 29):

#![allow(unused)]
fn main() {
impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}
}

After (wasmtime 30+):

#![allow(unused)]
fn main() {
impl IoView for State {
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
}
}

Both traits must be implemented. IoView provides the resource table; WasiView provides the WASI context.

Wasmtime 36: WasiCtxView and p2 Linker

wasmtime 36 changed WasiView::ctx() to return WasiCtxView<'_> instead of &mut WasiCtx, removed WasiView::table(), and moved add_to_linker_sync to wasmtime_wasi::p2::add_to_linker_sync.

Before (wasmtime 29–35):

#![allow(unused)]
fn main() {
impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

wasmtime_wasi::add_to_linker_sync(&mut linker)?;
}

After (wasmtime 36+):

#![allow(unused)]
fn main() {
impl WasiView for State {
    fn ctx(&mut self) -> WasiCtxView<'_> {
        WasiCtxView { ctx: &mut self.wasi, table: &mut self.table }
    }
}

wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
}

The WasiCtxView struct bundles both the WASI context and the resource table into a single return value. The exact field layout is not a stable public API — check the wasmtime docs for the current construction pattern if the above doesn’t compile.

Wasmtime 39: Component Model Async Enabled by Default

wasmtime 39 enabled the component-model-async feature by default, making [Typed]Func::call_async available without a feature flag. On wasmtime 36–38, the feature existed but was off by default and required enabling the component-model-async Cargo feature.

Async support is still evolving. The Component Model spec defines streams and futures, and wasmtime is implementing them incrementally — but the APIs may change between releases. If you’re building async plugin systems on wasmtime 39+, check the Component Model spec and the wasmtime repo for the current status.

This doesn’t affect the tutorial’s synchronous examples.

Wasmtime 40: Sync Component Imports Removed

wasmtime 40 removed the ability to implement component imports with async host functions and call them synchronously from guest code. In wasmtime 29–39, you could write async host-side implementations and the runtime would bridge them for synchronous guest calls. In v40+, the sync and async worlds are strictly separated: if the host uses async, the guest must also use async.

This matters if you’re using the Component Model for host→guest calls. If your project has async host functions implementing component imports (e.g., an async get_config that the guest calls), you have two options on wasmtime 40+:

  1. Make the guest async too. Enable the component-model-async Cargo feature (on by default in v39+), add async to the guest function signatures, and call through call_async instead of call.
  2. Keep the host synchronous. If your host function doesn’t actually need async, implement it as a plain sync function — no async keyword — and the guest can call it synchronously as before.

The tutorial’s examples are fully synchronous (host and guest), so this change doesn’t affect them. If you’re extending the examples with async host operations — say, a plugin that fetches data from a network resource — you’ll need to go all-in on async on wasmtime 40+.

Before (wasmtime 29–39):

#![allow(unused)]
fn main() {
// Host: async implementation
async fn host_fetch(&mut self, url: String) -> String {
    reqwest::get(&url).await.text().await.unwrap()
}

// Guest: synchronous call — the runtime bridges automatically
let data = host_fetch("https://example.com");
}

After (wasmtime 40+):

#![allow(unused)]
fn main() {
// Host: async implementation (same)
async fn host_fetch(&mut self, url: String) -> String {
    reqwest::get(&url).await.text().await.unwrap()
}

// Guest: must also be async — call through call_async, not call
let data = bindings.call_host_fetch_async(&mut store, "https://example.com").await?;
// The guest function signature gets the `async` keyword, and the host
// must use call_async instead of call. You can't mix sync and async.
}

Wasmtime 44: Experimental map<K, V> Support

wasmtime 44 added experimental support for the map<K, V> type in the Component Model. If your WIT files use map<string, u32> or similar map types, wasmtime 44+ can compile and instantiate them. Earlier versions will reject WIT files that use map.

This doesn’t affect the tutorial’s code — none of the examples use map types — but if you’re extending the examples with key-value interfaces, map in WIT is now an option on wasmtime 44+. On earlier versions (including the wasmtime 29 this tutorial targets), the WIT parser will reject map as an unknown type with an error like unknown type 'map' in WIT file.

Wasmtime 45: Component Reflection, Copying GC, Rust 1.93

wasmtime 45 (released 2026-05-21) added Component reflection APIs (inspect the compiled in-memory view of component instructions, similar to what Module already offered) and an initial copying garbage collector. It also raised the minimum Rust version to 1.93.0.

The tutorial code itself doesn’t need changes, but you’ll need Rust 1.93.0 or later to compile wasmtime 45. If you’re on an older toolchain, run rustup update before upgrading. The synchronous Component Model APIs (bindgen!, Linker, Component::new, Store) are unchanged — the reflection and GC additions are opt-in features.

One forward-looking note: wasmtime 45’s CLI warns that wasi-common and wasi-threads are slated for removal in wasmtime 47. If you’re using those crates directly (the tutorial uses wasmtime-wasi, not wasi-common), plan to migrate before upgrading to 47.

wit-bindgen Version Changes

The generated trait and type names have changed across wit-bindgen versions. If you’re using a version beyond 0.41 and the generated code doesn’t match the tutorial, check the wit-bindgen docs for the current trait signatures — the patterns are the same, but names may differ.

The tutorial targets wit-bindgen 0.41. As of May 2026, the latest release is wit-bindgen 0.57. The version gap is significant: 0.42+ reorganized the generated module structure (trait names, import paths, and the Guest vs GuestTraits split changed). The wit_bindgen::generate! macro still works the same way, and the export! macro still registers your implementation — but the exact trait you implement and the import path to reach it may differ. If you’re on wit-bindgen 0.50+, check the wit-bindgen changelog for migration steps.

WasiP1Ctx: A Simpler Alternative

Parts 1–5 of this tutorial use WasiCtx + ResourceTable + WasiView as separate fields. Part 6 and Part 7 use WasiP1Ctx — a convenience type from wasmtime_wasi::preview1 that bundles both into a single field:

#![allow(unused)]
fn main() {
use wasmtime_wasi::preview1::WasiP1Ctx;

struct HostState {
    wasi: WasiP1Ctx,
}

wasmtime_wasi::preview1::add_to_linker_sync(&mut linker, |state: &mut HostState| &mut state.wasi)?;
}

Use WasiP1Ctx when you don’t need separate access to the resource table (most hosts don’t). Use WasiCtx + ResourceTable when you need to pass the resource table to other wasmtime APIs directly. Both approaches work identically from the plugin’s perspective.

Staying Current

REVIEW.md — WebAssembly Component Tutorial

Reviewed by Esme, 2026-04-21

Overall

This is strong work. The progression from Part 1 through Part 3 is well-structured, the before/after comparisons with the original series are compelling, and the writing voice is clear and conversational without being sloppy. The “plumbing goes away” thesis is well-argued. Below are the issues I found.


Part 1

[clarity] The sentence “Think of it as a language-agnostic interface definition — like a .proto file, but for WASM.” is great. More of this kind of analogy, please.

[suggestion] The section “The Interface” jumps straight into creating directories and writing a WIT file without explaining what a “world” is in WIT terminology. You say “our plugin exports a function called length” but the concept of a world is new to the reader at this point. A one-sentence explanation — “A WIT world defines the contract: what the component exports and imports” — would help.

[clarity] The bindgen! path in the host is path: "../plugin/wit" but the README text says “Copy or symlink it” into a local wit/ directory. These are contradictory — the code uses a relative path to the plugin’s wit directory, not a local copy. If the intent is to use the relative path (which the code does), the copy/symlink instructions are unnecessary and confusing. Pick one approach.

[style] “we just… implement a trait” — the ellipsis is a bit informal for the tone. Minor.


Part 2

[error] The expected output for multiply is wrong. The README says: — [fixed] ✅ — Multiply function now returns unchanged count; output matches code.

# count: 15, word: hellohellohello

But the code computes new_count = pair.count.wrapping_mul(repeated.len() as u8) = 3 * 15 = 45. The output should be count: 45, word: hellohellohello. Either the expected output or the multiply logic needs fixing.

[clarity] The multiply function’s purpose is unclear. It takes a (count, word) pair, repeats the word count times, then multiplies count by the length of the repeated string. This is confusing as a teaching example — the reader is trying to understand data passing, not puzzle through arithmetic. A simpler multiply (e.g., repeat the word count times and return count + 1) would keep the focus on the WIT record passing, which is the point of Part 2.

[suggestion] The “WIT Types Beyond Records” section is a nice reference but feels like it belongs in an appendix or a sidebar. It breaks the flow of the tutorial — you’ve just shown structured data working, and then you list a bunch of types you don’t use. Consider moving it to the end as a “Reference” or integrating the types into the examples.

[error] The WIT type result<u32, string> maps to Result<u32, String> in Rust only for the guest (plugin) side. On the host side via wasmtime bindgen, results are represented differently — typically as a struct with ok/err fields or as Result<T, E> depending on the bindgen configuration. This should be noted if you’re claiming direct Result mapping. — [fixed] ✅ (clarified host-side gets Result<Result<T, E>, wasmtime::Error> with forward reference to Part 4)


Part 3

[error] The “Host Imports” section shows adding import log: func(msg: string); to the WIT file and using log() in the plugin, but the actual part-3/plugin/wit/world.wit and part-3/plugin/src/lib.rs don’t include this. If this is meant to be an incremental addition shown in the tutorial, that’s fine — but it should be clearly marked as “here’s how you’d extend it” rather than presented as something the reader has already built. The current framing reads like the code the reader already wrote should have log in it. — [fixed] ✅ — Section now reads as incremental extension (“Add an import to the WIT file”) rather than pre-existing code.

[error] The host import linker.root().func_wrap("log", ...) is likely incorrect. With wasmtime’s component bindgen, host imports need to be registered on the specific interface/world namespace, not on the root. The correct API would be something like linker.root().instance_wrap(...) or using the generated ExamplePluginAddToLinker trait. The func_wrap call as shown probably won’t compile with wasmtime 29’s component API. This needs verification against the actual wasmtime 29 API. — [fixed] ✅ — Changed to linker.instance("example/plugin")?.func_wrap("log", ...) with version-note about alternative APIs.

[clarity] The section “What About the Original’s JSON-over-stdin Approach?” is excellent — it addresses the question the reader is definitely thinking. Good placement.

[clarity] The sentence “The adaptation layer — JSON parsing, stdin/stdout, whatever — lives in the host, where it belongs” is a key architectural point that could use a bit more unpacking. Why does it belong in the host? Because the host owns the boundary with the outside world. One more sentence would nail this.

[suggestion] The “Going Further” section lists multiple plugins, versioning, WASI, and other languages. These are good teasers but “WASI” deserves slightly more than a bullet point — it’s directly relevant because the reader is targeting wasm32-wasip2, and they might wonder what “p2” means and why it matters.


Cross-Cutting

[error] The wasmtime version “29” should be verified against the current release. Wasmtime’s API has changed significantly between versions, and the component model APIs in particular have been evolving. If the reader tries wasmtime 29 and the APIs have changed, every code example breaks. Consider pinning to a specific minor version (e.g., "29.0" or whatever is current) and noting the version explicitly. — [fixed] ✅ — MIGRATION.md documents wasmtime 29→30→36→44 API changes. Tutorial targets v29 as documented baseline.

[error] The wit-bindgen version “0.41” should similarly be verified. The wit_bindgen::generate! syntax and the Guest trait generation have changed across versions. — [fixed] ✅ — wit-bindgen 0.41 was a real release. MIGRATION.md notes name changes across versions.

[suggestion] All three parts have near-identical host boilerplate (Engine, Store, read bytes, Component::new, Linker, instantiate, get plugin). By Part 3, this is repetitive. Consider extracting a helper function or at least acknowledging the repetition and explaining why you’re showing it each time (e.g., “We’ll show the full setup each time so each part is self-contained”).

[style] The comparison tables are effective. The “That’s it.” single-line paragraphs after showing simplified code land well — don’t overuse them but they work here.

[suggestion] There’s no mention of error handling on the plugin side. What happens if the plugin panics? What if process gets a malformed book? A brief note about how panics propagate (or don’t) across the component boundary would be valuable for a “real world” tutorial.


Part 7: Testing Component-Based Plugin Systems

Reviewed 2026-04-25 (commits d6bc93a, a656f3f)

[error] The bindings! macro is shown inside test function bodies (e.g., load_and_call_normalizer). The wasmtime_wasi::bindings! macro (or wasmtime::component::bindgen!) generates types at the module level — it cannot be called inside a function. Anyone copy-pasting this code gets a compilation error. The note at the bottom of the code block acknowledges this (“In a real project, you’d call it once at the top of the file”), but the example itself is wrong. Show the macro at module level and the TextTransform usage in the function body. — [fixed] ✅ (f406763) — bindings! moved to module level. Tests use TextTransform directly.

[error] The integration test code mixes core WASM and component model types. Linker::new(engine) creates a wasmtime::Linker<HostState> (core WASM linker), but Component::from_file() returns a wasmtime::component::Component (component model type). For component instantiation, you need wasmtime::component::Linker::new(engine). Similarly, preview1::add_to_linker_sync expects a component linker. As written, the code won’t compile because the types are from different APIs. — [fixed] ✅ (f406763) — Changed import to use wasmtime::component::Linker. create_store now returns a component-mode Linker<HostState>.

[error] The host_survives_trap test uses std::panic::catch_unwind to catch WASM traps, but WASM traps in wasmtime are returned as Err from function calls — they are not Rust panics. The note at the bottom of the code block correctly states (“the trap is caught by wasmtime and returned as an Err”), but the code contradicts the note. The test should show calling the function and checking that the result is Err, not wrapping the call in catch_unwind. — [fixed] ✅ (f406763) — Replaced catch_unwind with direct Err check. Added explanation that WASM traps are returned as errors, not panics.

[error] In the build script example, println!("cargo:rerun-if-changed=../plugin/src") points to a directory. rerun-if-changed only works with files, not directories. The build script will never re-run when plugin source files change. Use a file glob or list specific files, or at minimum document this limitation. — [fixed] ✅ (f406763) — Changed to list specific files (lib.rs, transform.rs, world.wit). Added comment explaining that rerun-if-changed requires file paths.

[error] The “Testing Error Paths” section uses call_transform(&engine, "validator", "") but this helper function is never defined. The earlier integration tests do the full setup inline. The reader can’t run this code as-is. Either define the helper or use the same inline pattern. — [fixed] ✅ (f406763) — Replaced undefined call_transform with inline setup matching the earlier test pattern. Added note connecting TransformError back to Part 4’s WIT definition.

[error] “WASMO internal details” in the Level 2 “What NOT to Test” list — typo for “WASM” or “wasmtime”. — [fixed] ✅ (f406763) — Fixed to “WASM internal details”.

[clarity] The TransformError type appears in code examples without introduction within Part 7. A reader arriving at Part 7 (or re-reading it) might not remember that it’s defined in the WIT file from Part 6. A brief note like “TransformError is the error variant we defined in our WIT file back in Part 4” would anchor it.

[clarity] The Pipeline type and its methods (add_plugin, run, executed_count, has_errors, has_skips, succeeded_count) are used in the “Testing the Pipeline” section without definition. These come from Part 6’s implementation. A one-line note connecting them (“using the Pipeline type we built in Part 6”) would help the reader who’s skimming.

[clarity] The wit-parser contract test example (text_transform_interface_matches_contract) uses API patterns that likely won’t compile as shown: resolve.packages[pkg] with index access on what’s likely a HashMap<PackageId, Package>, and WorldItem::Function(f) if f.item.name == "transform" where the WorldItem enum doesn’t have this structure in any published wit-parser version. The note says “API is version-sensitive” but the code is more than version-sensitive — it appears to be pseudocode. Be explicit: “This is approximate — the exact API depends on your wit-parser version. See the docs.” Or better, verify against a specific version and show working code.

[clarity] The wit-parser version "0.220" seems unusually high. Current wit-parser versions follow the wasmtime release cycle (around 0.200+ for recent wasmtime releases). Verify this is a real version number or note it as a placeholder.

[style] The opening is strong — no “What You’ll Learn” list, just straight into the problem. The three-walls-of-defense mental model and the test matrix table are exactly the right way to make this concrete. The table is a genuine use of a grid (comparing failure modes across test levels), not a lazy list.

[suggestion] The Pipeline test examples assume a stop-on-first-error behavior (pipeline_stops_on_first_error) and a skip-broken-plugin behavior (pipeline_skips_broken_plugin). These are contradictory — does the pipeline stop or skip? If both are valid behaviors under different configurations, say so. If one test is wrong, fix the assertion.

[suggestion] The CI workflow has integration-test depending on unit-test via needs: unit-test, but this means integration tests won’t run if any unit test fails. That’s a reasonable CI choice, but it means you can’t see integration failures and unit failures in the same run. Consider making them independent (parallel) jobs — the build step in the integration job already ensures the plugin compiles.

[suggestion] The “What NOT to Test” lists at each level are genuinely useful — they prevent the reader from over-testing at the wrong level. Good instinct. In particular, “Don’t duplicate unit test logic in integration tests” is advice a lot of people need to hear.

What’s Working in Part 7

  • The three-level testing framework (unit → integration → contract) is the right decomposition. Each level is explained clearly with what it catches and what it misses.
  • The “separate logic from WASM boundary” pattern for unit testing is the key insight of the chapter, and it’s presented with a concrete before/after code example. Exactly right.
  • The test matrix table is excellent — it makes the gaps between levels visible at a glance.
  • The “Testing Error Paths” section is important and well-placed. Testing error paths is the thing everyone skips and the thing that matters most.
  • The CI workflow is practical and honest about the build dependency problem.
  • The writing voice stays consistent — direct, practical, no fluff.

What’s Working

  • The before/after framing is the strongest part of this tutorial. Every section earns its place by showing concrete code that was replaced.
  • The progression from simple → structured → real-world is clean and natural.
  • The writing voice is confident without being condescending. Honest about simplifications.
  • The WIT file is used as the single source of truth throughout — good architectural discipline.

2026-05-04 09:45 UTC Review — Two Content Commits

Two new content commits since last review (da22e54).

5e3c56d — Fix: clarify host-side result<> representation (Esme review error #2)

Changed src/02-plugin-host/README.md line 254: the WIT types→Rust mapping now correctly distinguishes guest vs host side. result<u32, string> becomes Result<u32, String> on the plugin side only. On the host side, calling a component function that returns result gives you Result<Result<T, E>, wasmtime::Error> — the outer Result for Wasm traps and instantiation failures, the inner Result for the WIT-level success/error. Part 4 gets a forward reference for the host-side pattern.

This is factually correct. The double-Result layering is exactly what wasmtime’s component bindgen produces — the outer Result covers infrastructure failures (traps, instantiation errors) that are orthogonal to the WIT-level result type. A reader following along will encounter this when they write the host code in Part 4. The clarification prevents the “why do I have two Results?” confusion that would otherwise surface there. Good fix.

Also updated src/review.md to mark the remaining [error] items as addressed.

No issues found.

eeef121 — Clarity: add WIT snippet for circular dependency resolution in Part 5

Added a concrete WIT example to the “Going Further” section of src/05-composing-components/README.md. The paragraph about cyclic dependencies (“if A needs B and B needs A”) now has a code block:

interface shared-types {
    record event { payload: string }
}

Followed by: “Both components import shared-types in their WIT, and a third component (or the host) provides the implementation. Neither A nor B depends on the other — they both depend on the shared interface, and composition wires them together.”

This is a useful addition. The “Going Further” section previously described the cyclic dependency pattern in prose only — the WIT snippet makes it concrete. The shared-types interface pattern (dependency inversion) is the standard way to break cycles in the component model. The explanation correctly identifies that composition, not the individual components, provides the wiring. Minor note: the WIT snippet defines a record event inside the shared interface, but the paragraph only discusses the interface-level pattern (A and B both import it). The event record is illustrative — it shows the interface has content — but a reader might wonder how event relates to the A/B cycle. This doesn’t need fixing; the example is clear enough.

No issues found.

2026-05-07 19:47 UTC Review — Ada commit a6e91ad

a6e91ad — Fix: Part 5 table descriptions (Parts 2-3 were wrong), Part 7 TransformError reference (was Part 6, not Part 4)

Two corrections in one commit:

  1. Part 5 table (line 485-486): Changed “Part 2: Building a Plugin Host” → “Part 2: Passing Structured Data” and “Part 3: Bidirectional Communication” → “Part 3: Real World mdbook Preprocessor”. Both now match the actual chapter titles. Verified against src/02-plugin-host/README.md (“Passing Structured Data Across the Wasm Boundary”) and src/03-advanced/README.md (“Real World — An mdbook Preprocessor”). Correct. ✅

  2. Part 7 TransformError note (line 467): Changed “back in Part 4” → “in Part 6 (the text-transform interface)”. Verified — TransformError is defined in Part 6’s 06-real-world-plugin-system. The old reference to Part 4 was wrong; Part 4 covers error handling and resources generically. Correct fix. ✅

This also addresses the [clarity] item from the Part 7 review about TransformError appearing without sufficient context. The new text now correctly identifies both where (Part 6) and what (the text-transform interface). Mark as addressed.

No issues found.

Status

  • 0 outstanding [error] items
  • 0 new items from this review
  • Remaining [clarity] and [suggestion] items from Part 7 review — TransformError reference now addressed; remaining items (Pipeline type introduction, wit-parser pseudocode, skip-vs-stop contradiction) unchanged, low priority

2026-05-09 04:45 UTC — Cron: no new content commits

Last content commit was Ada’s 5742b74 (clarity fixes in Part 7). The 3 [clarity] items from the deep re-read are marked [fixed] in commit 94f8896. No new commits from Lola since. All 8/8 crates clean, mdbook clean.

Status

2026-05-12 07:15 UTC — Cron: deep re-read of Part 7 (Testing Components)

No new Lola/Ada content commits since 06:17 UTC. Used idle time to re-read Part 7 (653 lines) in full.

[error] The too_long_returns_processing_failed test in “Testing Error Paths” (plugin-side error tests) asserts that transform("x".repeat(10_000_001).into()) returns Err(TransformError::ProcessingFailed(_)). But the shown transform function only checks for empty input — it has no length limit. A 10-million-character string would be uppercased successfully, returning Ok(...), not Err(ProcessingFailed). The test would fail at runtime against the shown implementation. — [fixed] ✅ (2026-05-12) — Added MAX_INPUT_LEN = 10_000_000 constant and length check to both impl Guest and transform::transform versions. Test now matches implementation.

Everything else in Part 7 is clean. The three-level testing framework, the test matrix, the CI workflow, the trap handling, the Pipeline tests — all structurally sound. The wit-parser version note, the rerun-if-changed comments, the bindgen! module-level placement — all previously flagged issues remain fixed.

Status

  • 0 outstanding [error] items
  • 0 outstanding [clarity] items (low-priority suggestions remain, not blocking)

2026-05-17 04:53 UTC — Deep re-read of Part 6 (Real-World Plugin System)

No new Lola/Ada commits since last review. Used idle time for a thorough re-read of src/06-real-world-plugin-system/README.md (613 lines).

[error] preview1::add_to_linker_sync called with wasmtime::component::Linker — type mismatch — [fixed] ✅ (5ab51a6)

Replaced wasmtime_wasi::preview1::add_to_linker_sync with wasmtime_wasi::add_to_linker_sync in the load_plugin function. Added WasiView impl for HostState that delegates to self.wasi (WasiP1Ctx). Added use wasmtime_wasi::{WasiView, WasiCtx, ResourceTable} to imports. Updated the comparison snippet and prose to reflect the WasiView impl (changed “no WasiView impl needed” to “with a WasiView impl that delegates to WasiP1Ctx”).

[clarity] create_store uses Engine but the Engine type is never clarified — [fixed] ✅ (5ab51a6)

Added note in create_engine comment: “Engine is the same wasmtime::Engine for both core WASM and the component model — there’s no wasmtime::component::Engine. The component model uses the same engine instance.”

[clarity] build_p1() not shown as a method on WasiCtxBuilder — [fixed] ✅ (5ab51a6)

Added inline comment on build_p1() call: “build_p1() is the preview1 counterpart to .build() — it returns a WasiP1Ctx instead of a WasiCtx.”

Status

  • 0 outstanding [error] items
  • 0 outstanding [clarity] items ✅ (low-priority suggestions remain)