Skip to content

Components and the Component Model

Embedding a core module means fn.Call(ctx, ptr, len), decoding a pair of i32s out of the guest’s memory, and both halves hand-agreeing on layout and on who frees what.

A component is a .wasm that carries its own typed interface. wazy runs them as components — genuine wasm32-wasip2 binaries from rustc and wasm-tools, not flattened to core modules — so string, records, lists, option and result cross as Go values.

import "github.com/samyfodil/wazy/component"
inst, err := component.Instantiate(ctx, r, componentWasm)
if err != nil {
return err
}
defer inst.Close(ctx)

component.Instantiate takes the same wazy.Runtime a core module uses, plus a variadic list of component.Option. With no options the component gets nothing: no WASI, no host imports, no filesystem. Add capabilities explicitly — WASI 0.2 is one line, custom host imports are a WIT file away.

A world-level export is called by name:

out, err := inst.Call(ctx, "run")
fmt.Println(out[0].(string)) // run: func() -> string

An interface export needs the interface name too:

res, err := inst.CallExport(ctx, "component:adder/calc", "add", uint32(2), uint32(3))

A wasi:cli/command component’s entry point is a versioned interface export:

_, err := inst.Call(ctx, "wasi:cli/run@0.2.3#run")

An interface nested in an exported instance — an instance exported whole whose component exports interfaces of its own — is named by the path of export names leading to it, joined by #:

res, err := inst.CallExport(ctx, "outer#component:adder/calc", "add", uint32(2), uint32(3))
WIT Go
bool bool
u8, u16, u32 uint32 — the narrow widths widen
s8, s16, s32 int32 — the narrow widths widen
u64, s64 uint64, int64
f32, f64 float32, float64
char rune
string string
list<T> []T for a fixed-width primitive T, []component.Value otherwise
record []component.Value in declared field order — not a map
tuple<A, B> []component.Value
option<T> nil for none, or the inner value for some
result<T, E> component.ResultValue{IsErr, Payload} — not a Go error
variant component.VariantValue{Disc, Payload}, Disc the 0-based case index
enum uint32, the case index
flags uint32 bitset, LSB = the first declared label
own<R>, borrow<R> uint32, the handle’s host representation

Three of those rows are where hand-written host code usually goes wrong.

Narrow integers arrive widened. A u8 is a uint32 and an s16 is an int32; there is no uint8 anywhere in the value vocabulary. Handing back a uint8(5) for a u8 fails at store time with unsupported type uint8, not at compile time.

A record is a slice, not a map. Its Go shape is []component.Value in declared field order, with one entry per field — the ABI transmits order and types, never names, so there is nothing for a key to match on. A map[string]any is rejected outright: storeRecord: expected []Value.

A result is a value, not a Go error. component.ResultValue{IsErr: true, Payload: …} is an ordinary value the guest receives and handles. Returning a Go error from a host function is a different thing entirely — it traps the guest call. Use the error return for “this call cannot proceed”, and a ResultValue for a failure the WIT declared.

A typed list<T> lifts to []T directly, so list<u32> is a []uint32 and not a []any of boxed uint32s. Lists of anything wider — strings, records, nested lists — lift as []component.Value. component.ListOf[T](v) returns ([]T, error) from either shape, so a host function can ask for the slice it wants in one step:

names, err := component.ListOf[string](args[0]) // list<string>
sizes, err := component.ListOf[uint32](args[1]) // list<u32>

A resource is a handle to an object one side owns. own<R> transfers ownership; borrow<R> lends it for the duration of a call. wazy tracks the lifetime and calls the destructor when the last owner drops it.

Host-owned resources are declared with a tag and a destructor:

const bucketTag = 1
opts := []component.Option{
component.WithResourceTag("example:store/kv", "bucket", bucketTag),
component.WithHostResourceDtor(bucketTag, func(ctx context.Context, rep uint32) error {
return closeBucket(rep)
}),
}

A host that drives an exported interface mints the handles it passes in with inst.ResourcesFor(iface): the handle table of the component that defines the interface, which is not the root’s when the root only re-exposes it — a composition’s inner component, or a supplied one.

A real wasm32-wasip2 binary is not one module. It is a graph: nested instances, a core module per compilation unit, the wasi_snapshot_preview1 adapter, canonical lowering for every host import. wazy instantiates the whole graph — which is why a rustc wasi:cli/command runs end to end and prints hello world with no wasm-tools preprocessing on your side.

With a compile cache, re-instantiating that entire graph costs 350.5 µs.

A component can import a component: (import "x" (component ...)). It is a definition, not an instance — the importer instantiates it itself, with arguments of its own. wac compose --import-dependencies emits one per dependency it does not embed. Supply the binary with WithComponentImport:

inst, err := component.Instantiate(ctx, r, composedWasm,
component.WithComponentImport("unlocked-dep=<wazy:provider>", providerWasm),
component.WithComponentImport("unlocked-dep=<wazy:consumer>", consumerWasm))

Before anything runs, each supplied component is checked against the type its import declares, by the Component Model’s subtyping rules: it may import less and export more, what it does import and export must match, and its resources are lined up with the declared ones. A mismatch fails Instantiate with the reason — missing import named …, type mismatch for export …. Every instantiation’s arguments are checked against what the instantiated component imports the same way, whether or not anything was supplied — core modules included: a module passed or imported where a module type is declared is checked by core subtyping, in wasm-tools’ words. With a compile cache, the supplied component’s decode and compilation are cached, and a check that passed is not repeated.

Core modules and components are values like any other: a nested component can import a core module or a component its parent passes it, and a component can export one or alias one out of an instance it instantiated. At the root, only a component import can be supplied.

Wasmtime refuses any component import of the root component; wazy runs one given WithComponentImport and otherwise refuses it as not supplied.

An async export is called exactly like a synchronous one — the scheduler runs underneath and Call returns when the task completes:

out, err := inst.Call(ctx, "run-async")

CallAsync returns a pending call another goroutine resolves. See Async.

A component exporting wasi:http/incoming-handler becomes an http.Handler:

mux.Handle("/plugin/", http.StripPrefix("/plugin", wasip2.Handler(inst)))

Somebody else’s handler, as a route on your mux. A warm one answers in 3.37 µs and 45 allocations.