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:
- A formatter — takes text, normalizes whitespace, returns cleaned text
- 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-cleanproducecabi_post_count-cleansymbols that can break the linker version script parser in some Rust toolchain versions. Thewasm32-wasip2target (the intended build target) compiles correctly. If you hitrust-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 renamedcreate-storetoopen-storeto avoid this issue. We keepcount-cleanhere because renaming it would cascade through the.waccomposition 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 notexample::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 callsexample::word_counter::format(). The formatter’s implementation offormatgets wired in later bywac— 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
.wacsyntax are still evolving. This tutorial was tested with0.8.x–0.10.x. Thewac composeandwac plugcommands and.wacsyntax 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:
- Create the formatter. It has no imports, so
{}is empty. - Create the word counter. Its
formatimport is satisfied by the formatter’sformatexport. - Export
count-cleanfrom 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
.wasmfilename must match the package name in the WITpackagedirective, with hyphens replaced by underscores.example:formatter→deps/example/formatter.wasm.example:word-counter→deps/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, soword-counterproducesword_counter.wasmin 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
Compositiontype andcall_count_cleanmethod come from thebindgen!macro usingcomposition.wit. The exact method names depend on yourwasmtimeversion —count-cleanin WIT becomescount_cleanin 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 fromu32tooption<u32>— the host code from Step 4 needs two updates. First, updatecomposition.witto 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
Nonecase (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
| Part | Concept | Key Feature |
|---|---|---|
| 1 | Hello, Components | Basic string passing across the boundary |
| 2 | Passing Structured Data | Records, generated Rust structs, no serialization |
| 3 | Real World mdbook Preprocessor | Host imports, import in WIT |
| 4 | Error Handling & Resources | result types, stateful resources, RefCell |
| 5 | Composing Components | wac, wiring components together, implicit imports |
| 6 | Real Plugin System | Discovery, 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:
wacand the Component Model’s composition support are evolving. The CLI commands and.wacsyntax 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.