Part 3: Real World — An mdbook Preprocessor

In the original Part 4, we built an mdbook preprocessor as a WASM plugin. The preprocessor's job was simple: find every occurrence of "WASM" and replace it with "Wasm" (because it's not an acronym). What wasn't simple was the plumbing — 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<()> {
    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)?;

    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 the Original's JSON-over-stdin Approach?

The original 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:

  • Original: 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.
  • Component Model: The plugin receives a Book struct. It modifies it. It 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.

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;

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

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

world example-plugin {
    import log: func(msg: string);
    export process: func(book: book) -> book;
}

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:

#![allow(unused)]
fn main() {
let mut linker = Linker::new(&engine);
linker.root().func_wrap("log", |mut store, msg: String| {
    println!("[plugin] {msg}");
    Ok(())
})?;

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

The bindgen! macro generates a trait that the linker expects, but for simple cases, func_wrap works directly. Either way, the host controls what the plugin can access — no ambient authority, no surprise filesystem access.

The Full Picture

Here's what we built across all three parts:

PartOriginalComponent 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 just 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

  • Multiple plugins: The host can load multiple components, each targeting the same WIT world. Swap plugins by swapping the .wasm file.
  • Versioning: WIT supports package versions. Evolve your interface without breaking existing plugins.
  • WASI: Components can use WASI 0.2 for filesystem, network, and other capabilities — no adapter needed when targeting wasm32-wasip2.
  • Other languages: 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.