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

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.