Skip to content

Composing components

Twelve components sit in examples/compose/: for each of six languages, one that exports the wazy:compose/greeter interface and one that imports it and exports run. A composer welds any consumer to any provider — a C consumer to a Rust provider, a Python consumer to a TinyGo one — and hands back a single .wasm.

Weigh that against implementing greeter in Go, which is what Bring your own WIT did. There the two halves are joined in your process, by your code, at your version. Composed, the join is a name inside the binary: the halves are written in different languages, built by different toolchains, shipped by different people, and the artifact that reaches you is already whole.

  1. Compose the two halves. Every language directory holds both, so start with a pair that needs no host configuration at all:

    Terminal window
    cd examples/compose/c
    wasm-tools compose consumer.wasm -d provider.wasm -o self.wasm
  2. Look at what came out. The greeter import is gone — it was satisfied by the provider — and one export is left:

    Terminal window
    wasm-tools component wit self.wasm
    world root {
    export run: func() -> list<string>;
    }
  3. Instantiate it and call that export. Built -mexec-model=reactor, the provider imports nothing and the consumer imports nothing but the greeter, so composing them leaves no import at all and the pair needs no options:

    r := wazy.NewRuntime(ctx)
    inst, err := component.Instantiate(ctx, r, componentWasm)
    out, err := inst.Call(ctx, "run")

    run returns three strings: Hello, wazy #42! (from C), a (via C), and empty-len=0.

Now change one word. The consumer stays C; the provider comes from another directory:

Terminal window
wasm-tools compose consumer.wasm -d ../rust/provider.wasm -o pair.wasm

The same run now answers (from Rust) and (via Rust), because the text was never in the consumer — every byte of it crossed the interface. That is the whole point of the example, and it holds for all 36 consumer/provider pairs the six languages make.

One file, byte-identical in all six directories:

examples/compose/rust/greeter.wit
package wazy:compose@0.1.0;
interface greeter {
record visitor {
name: string,
id: u32,
}
greet: func(who: visitor) -> string;
greet-all: func(names: list<string>) -> list<string>;
}
world provider {
export greeter;
}
world consumer {
import greeter;
export run: func() -> list<string>;
}

The types are chosen to stress the place where two guest allocators meet: a record carrying a string, and list<string> travelling in both directions — including the empty list, which every implementation here deliberately leaves un-special-cased so a zero-length round trip is really tested.

  • Directoryexamples/compose/
    • Directoryc/
      • greeter.wit the contract — the same bytes in every directory
      • provider.c
      • consumer.c
      • provider.wasm
      • consumer.wasm
      • README.md build recipe and exact toolchain versions
    • Directorygo/ standard Go, hand-written Canonical ABI in abi/
      • …
    • Directoryjavascript/ jco + componentize-js
      • …
    • Directorypython/ componentize-py
      • …
    • Directoryrust/ cargo component
      • …
    • Directorytinygo/ TinyGo with generated bindings in gen/
      • …

All twelve .wasm are committed, from 14 KB of C to 18 MB of CPython. The directory is a nested Go module on purpose — a nested module is excluded from the parent’s zip, so go get github.com/samyfodil/wazy does not download any of them.

Each provider answers Hello, <name> #<id>! (from <language>) and <name> (via <language>). The language is named in the answer so a composed pair says out loud which side executed.

examples/compose/rust/provider/src/lib.rs
9 collapsed lines
//! `wazy:compose/provider` — the provider half of the cross-language
//! composition matrix, in Rust.
//!
//! Exports `wazy:compose/greeter`. Every string it produces names Rust, so a
//! composed consumer written in another language proves which side ran.
#[allow(warnings)]
mod bindings;
use bindings::exports::wazy::compose::greeter::{Guest, Visitor};
struct Component;
impl Guest for Component {
/// `Hello, <name> #<id>! (from Rust)`
fn greet(who: Visitor) -> String {
format!("Hello, {} #{}! (from Rust)", who.name, who.id)
}
/// One `<name> (via Rust)` per input, in order.
///
/// An empty input yields an empty output — the empty-list path is part of
/// the contract under test, so it is deliberately *not* special-cased.
fn greet_all(names: Vec<String>) -> Vec<String> {
names
.into_iter()
.map(|name| format!("{name} (via Rust)"))
.collect()
}
}
bindings::export!(Component with_types_in bindings);
Terminal window
cargo component build --release --target wasm32-wasip2
cp target/wasm32-wasip2/release/provider.wasm provider.wasm
cp target/wasm32-wasip2/release/consumer.wasm consumer.wasm

The same cargo component build as Building a component, pointed at two crates instead of one; */src/bindings.rs is regenerated from greeter.wit each time and is not committed. 49 837 bytes for the provider — the smallest of the five toolchains that link a language runtime.

All six consumers do the same three things, and none of them contains a provider’s text:

examples/compose/rust/consumer/src/lib.rs
14 collapsed lines
//! `wazy:compose/consumer` — the consumer half, in Rust.
//!
//! Imports `wazy:compose/greeter` and exports `run`. Nothing the provider says
//! is hardcoded here: every element of the returned list is text that crossed
//! the Canonical ABI, so a bad lift or lower shows up in the output instead of
//! being masked by a local constant.
#[allow(warnings)]
mod bindings;
use bindings::wazy::compose::greeter::{self, Visitor};
use bindings::Guest;
struct Component;
impl Guest for Component {
fn run() -> Vec<String> {
// 1. record-with-string argument, string result.
let first = greeter::greet(&Visitor {
name: "wazy".to_string(),
id: 42,
});
// 2. list<string> in, list<string> out — take element 0.
let all = greeter::greet_all(&["a".to_string(), "b".to_string()]);
let second = match all.into_iter().next() {
Some(s) => s,
// Report the wrong answer rather than hiding it.
None => "greet-all([a, b]) returned 0 elements".to_string(),
};
// 3. the empty-list path: report the real length, whatever it is.
let empty = greeter::greet_all(&[]);
let third = format!("empty-len={}", empty.len());
vec![first, second, third]
}
}
bindings::export!(Component with_types_in bindings);

The third element is the one to watch. It reports the length greet-all([]) actually returned rather than the length it was supposed to return, so a provider that mishandles the empty list is caught in the output instead of passing silently.

A composed artifact is instantiated like any other component. Nothing about the composition reaches the Go API — you do not register the greeter, and there is no option that names it:

inst, err := component.Instantiate(ctx, r, componentWasm)
if err != nil {
return err
}
defer inst.Close(ctx)

WASI is the exception, and it is not the composition’s doing. wasm-tools compose satisfies what one half imports from what the other exports; whatever is left over — the WASI both halves’ language runtimes reach for while starting up — stays an import of the composed component for the host to fill in:

examples/compose/javascript/README.md
r := wazy.NewRuntime(ctx)
inst, err := component.Instantiate(ctx, r, wasm, wasip2.With(wasip2.Config{})...)

Only the C pair escapes it, for the reasons the previous page sets out language by language. A zero-value wasip2.Config{} covers every other pair: no stdio, no filesystem, no sockets — see the WASI guide for what each field opens up.

The Python pair needs the same runtime feature its single-component sibling did — componentize-py’s CPython initializes a global with i32.add, which the default core feature set rejects:

examples/compose/python/README.md
r := wazy.NewRuntimeWithConfig(ctx, wazy.NewRuntimeConfig().
WithCoreFeatures(api.CoreFeaturesV2|api.CoreFeatureExtendedConst))

wazy does not flatten a composition. It reads the graph the composer wrote and instantiates the nested components in order, linking them as it goes. Every composer emits the same three lines:

internal/component/instance/compose_alias_test.go
(instance $p (instantiate $Provider))
(alias export $p "X" (instance $g))
(instance $c (instantiate $Consumer (with "Y" (instance $g))))

The provider is instantiated first. The middle line is where composition lives: the consumer does not need the provider instance, it needs the sub-instance the provider exports, reached by alias export. That alias takes a slot of its own in the component-instance index space, and the consumer’s instantiate-arg names that slot.

To wire it, wazy resolves the arg through the instance index space — definition, import, alias or export — projects the named export off the already-live provider, and binds its member functions under the arg’s name. Names on the two sides are independent, which is the subtlety: X is the provider’s name for the interface it exports and Y is the consumer’s name for the interface it imports. Composers happen to make them equal; nothing in the binary format requires it, since subtyping is checked on the instance type, never on the export name.

After that the consumer’s lowered import calls straight into the provider instance. No host function sits in between, and resources cross with their identity intact: a handle the provider mints is transferred by representation into the consumer’s own table, and the consumer’s resource.drop — on its own local type index — runs the provider’s destructor. That holds when a composed component is itself used as the provider of a further composition, because ownership is chased to the component that actually defined the interface, at any depth.

Shapes wazy cannot wire fail during instantiation, with the arg and the index named, rather than mis-binding and surfacing later as a corrupt-looking error. TestGraph_ComposedInstanceAlias, TestComposeTwoLevelCarriesResourceIdentity and TestGraph_InstanceAliasFailsLoudOnShapesItCannotWire in internal/component/instance/ are the committed proofs of each half of that.

  • The composer does not matter. wasm-tools compose (now deprecated) and wac plug emit the same alias-and-arg shape, and wazy takes either. Pick whichever is installed.
  • WASI does not compose away. A composed pair is import-free only if neither half wants anything the other does not export, which in practice means both were built the way the C halves were — -mexec-model=reactor, no stdio. Every other toolchain here brings its runtime’s WASI along, and the composed artifact inherits it.
  • The composed artifact is not committed. c/ and rust/ list self.wasm in their .gitignore; the twelve halves are what the repository carries. Compose the pair you want, when you want it.
  • The per-language READMEs predate the runtime work. Their “known gap” sections describe a composed component failing to instantiate. That gap was closed by the same change that added these examples — composing them is what surfaced it — and all 36 pairs now instantiate and return the provider’s own text across the boundary. The JavaScript and Python files carry a second stale claim, that their .wasm was too large to commit; both are in the tree. The build recipes and the toolchain versions are current.

This is the last of the example walkthroughs. Components and the Component Model is the same ground as reference rather than tutorial, and Bring your own WIT is the other way to satisfy an import — in Go, in your process, instead of with a second component. If you want to see how much of the specification this rests on, conformance has the numbers.

The sources for all six languages, with their exact toolchain versions and verification transcripts, are in examples/compose/.