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.
Instantiating
Section titled “Instantiating”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.
Calling exports
Section titled “Calling exports”A world-level export is called by name:
out, err := inst.Call(ctx, "run")fmt.Println(out[0].(string)) // run: func() -> stringAn 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))How values map
Section titled “How values map”| 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>Resources
Section titled “Resources”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.
Multi-module component graphs
Section titled “Multi-module component graphs”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.
Component imports
Section titled “Component imports”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.
Async exports
Section titled “Async exports”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.
Serving a component over HTTP
Section titled “Serving a component over HTTP”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.