Tutorial 3: load a component and call typed exports¶
Work in progress
This page is a draft. It describes Burroughs as of its latest release and may change. Where it disagrees with the engine's own documentation or CHANGELOG, those win.
The program and its output are this site's examples/component/ (main.go, output.txt). It uses fixtures
committed in the Burroughs repository under internal/component/testdata/; CI fetches them at the latest release
tag into examples/component/testdata/ (the list is examples/fixtures.txt), runs the program with go run ., and
fails if the output differs from what is shown here. To run it yourself, copy those files from the engine repository
at the tag into testdata/ beside main.go.
A component is a WebAssembly artifact whose exports have WIT types (u32, string, list<T>, records, and so
on) rather than core i32s. Burroughs runs components by default (gate:components, since 2026-09-11) and gives you
two ways in:
LoadComponentreturns a*Componentwhose exports you call withComponentValues.ComponentConfig.Runruns a component that exportswasi:cli/run, the component-world equivalent of_start.
The fixtures¶
| Fixture | Exports used | Signature (from its .wat) |
|---|---|---|
record-synth.wasm |
addrec, makerec |
addrec: func(p: record { a: u32, b: u32 }) -> u32, makerec: func() -> record { name: string, n: u32 } |
string-echo.wasm |
run-sync |
func(s: string) -> u32, the length of the string after a round trip through a child component |
string-arg-realloc-synth.wasm |
sum-list |
func async (xs: list<u32>) -> u32, an async-lifted export (gate:async, on by default) |
trap-then-call-plain.wasm |
run, ok |
run traps (unreachable). ok returns 7. |
p3hello.wasm |
wasi:cli/run |
prints Hello, world! (a wasi:cli@0.2.3 world) |
Each fixture's .wat source sits beside it (except p3hello, whose .wit is there instead), if you want to read
what the guest does.
The program¶
// Command component loads four of the Burroughs repository's committed component
// fixtures and calls their typed exports, then runs a wasi:cli/run component.
// The fixtures are fetched into testdata/ from the engine repository at the
// release tag (internal/component/testdata/); scripts/run-examples.sh does it.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"os"
"github.com/scttfrdmn/burroughs"
)
const fixtures = "testdata/"
func load(name string) *burroughs.Component {
wasm, err := os.ReadFile(fixtures + name)
if err != nil {
log.Fatal(err)
}
if ok, err := burroughs.IsComponent(wasm); !ok {
log.Fatalf("%s is not a component: %v", name, err)
}
c, err := burroughs.LoadComponent(wasm)
if err != nil {
log.Fatal(err)
}
return c
}
func main() {
ctx := context.Background()
// 1. A record argument, a u32 result.
rc := load("record-synth.wasm")
defer rc.Close()
fmt.Println("record-synth exports:", rc.Exports())
pair, err := burroughs.ComponentTypeRecord(
burroughs.ComponentField{Name: "a", Type: burroughs.ComponentTypeU32()},
burroughs.ComponentField{Name: "b", Type: burroughs.ComponentTypeU32()},
)
if err != nil {
log.Fatal(err)
}
rec, err := burroughs.ComponentRecord(pair, map[string]burroughs.ComponentValue{
"a": burroughs.ComponentU32(10),
"b": burroughs.ComponentU32(32),
})
if err != nil {
log.Fatal(err)
}
res, err := rc.Call(ctx, "addrec", rec)
if err != nil {
log.Fatal(err)
}
sum, ok := res[0].U32()
fmt.Println("addrec({a:10, b:32}) =", sum, ok, "rendered", res[0])
// A record result: read its fields by name.
res, err = rc.Call(ctx, "makerec")
if err != nil {
log.Fatal(err)
}
name, _ := res[0].Field("name")
nfield, _ := res[0].Field("n")
text, _ := name.Str()
nval, _ := nfield.U32()
fmt.Printf("makerec() = {name: %q, n: %d} (String() of the name field: %v)\n", text, nval, name)
// Construction-time refusals.
_, err = burroughs.ComponentRecord(pair, map[string]burroughs.ComponentValue{"a": burroughs.ComponentU32(1)})
fmt.Println("missing field:", err)
_, err = burroughs.ComponentString("\xff")
fmt.Println("invalid UTF-8:", err)
_, err = rc.Call(ctx, "addrec", burroughs.ComponentU32(1))
fmt.Println("wrong argument type at the call:", err)
// 2. A string argument.
sc := load("string-echo.wasm")
defer sc.Close()
fmt.Println("string-echo exports:", sc.Exports())
s, err := burroughs.ComponentString("héllo, wörld")
if err != nil {
log.Fatal(err)
}
res, err = sc.Call(ctx, "run-sync", s)
if err != nil {
log.Fatal(err)
}
n, _ := res[0].U32()
fmt.Println("run-sync(\"héllo, wörld\") =", n, "(bytes of UTF-8 the guest saw echoed)")
// 2b. A list<u32> argument.
lc := load("string-arg-realloc-synth.wasm")
defer lc.Close()
fmt.Println("string-arg-realloc-synth exports:", lc.Exports())
xs, err := burroughs.ComponentList(burroughs.ComponentTypeU32(),
burroughs.ComponentU32(1), burroughs.ComponentU32(2), burroughs.ComponentU32(39))
if err != nil {
log.Fatal(err)
}
res, err = lc.Call(ctx, "sum-list", xs)
if err != nil {
log.Fatal(err)
}
total, _ := res[0].U32()
fmt.Println("sum-list([1, 2, 39]) =", total)
_, err = burroughs.ComponentList(burroughs.ComponentTypeU32(), s)
fmt.Println("a string in a list<u32>:", err)
// 3. A trap locks the instance.
tc := load("trap-then-call-plain.wasm")
fmt.Println("trap-then-call-plain exports:", tc.Exports())
_, err = tc.Call(ctx, "run")
var trap *burroughs.Trap
fmt.Println("run:", err, "| is *Trap:", errors.As(err, &trap))
_, err = tc.Call(ctx, "ok")
fmt.Println("ok after the trap:", err)
fmt.Println(" errors.Is ErrInstanceLocked:", errors.Is(err, burroughs.ErrInstanceLocked),
"| wraps the *Trap:", errors.As(err, &trap))
fmt.Println("Close:", tc.Close(), "| second Close:", tc.Close())
_, err = tc.Call(ctx, "ok")
fmt.Println("call after Close:", err, errors.Is(err, burroughs.ErrComponentClosed))
// 4. A wasi:cli/run component, with its stdout captured.
wasm, err := os.ReadFile(fixtures + "p3hello.wasm")
if err != nil {
log.Fatal(err)
}
var out bytes.Buffer
code, err := burroughs.ComponentConfig{Stdout: &out}.Run(wasm)
fmt.Printf("p3hello: exit %d, err %v, stdout %q\n", code, err, out.String())
// The zero configuration discards output.
code, err = burroughs.ComponentConfig{}.Run(wasm)
fmt.Printf("p3hello with zero config: exit %d, err %v (stdout discarded)\n", code, err)
}
Output¶
Run with go run . in examples/component/:
record-synth exports: [addrec makerec peek]
addrec({a:10, b:32}) = 42 true rendered u32(42)
makerec() = {name: "burroughs", n: 42} (String() of the name field: string("burroughs"))
missing field: canon: record field "b" is missing; a record's fields must all be supplied, because no WIT value means "absent"
invalid UTF-8: a component string must be valid UTF-8, and this one is not; the Canonical ABI's string is UTF-8 while a Go string is an arbitrary byte sequence, so the two differ exactly here
wrong argument type at the call: component: export "addrec" parameter "p" is declared record{a: u32, b: u32} but the value given is u32; the types must match structurally, so a list of the wrong element type is refused here rather than written at the wrong stride (a caller error, as above; #1129)
string-echo exports: [run-async run-sync]
run-sync("héllo, wörld") = 14 (bytes of UTF-8 the guest saw echoed)
string-arg-realloc-synth exports: [echo echo-misaligned echo-trap peek sum-list sum-list-misaligned]
sum-list([1, 2, 39]) = 42
a string in a list<u32>: list element 0 is string, but the list's element type is u32; the types must match structurally, so a list of the wrong element type is refused here rather than lowered at the wrong stride
trap-then-call-plain exports: [ok run]
run: trap: unreachable | is *Trap: true
ok after the trap: burroughs: the component instance is locked by an earlier trap: trap: unreachable
errors.Is ErrInstanceLocked: true | wraps the *Trap: true
Close: <nil> | second Close: <nil>
call after Close: burroughs: the component is closed: "ok" was not called true
p3hello: exit 0, err <nil>, stdout "Hello, world!\n"
p3hello with zero config: exit 0, err <nil> (stdout discarded)
Fixed in v0.12.0 (#1129)
The line wrong argument type at the call shows a mistyped Component.Call argument refused as the caller's
error: the message names the parameter and both types, and it matches no sentinel, as a mistyped
Instance.Call argument does. In v0.11.0 it was classed ErrUnsupported, wrongly blaming the engine. This
note said the line would change when the fix shipped and that this site's CI would flag it, and both happened.
What to take from it¶
- Types are part of the value. Compound values need a
ComponentType, because an emptylist<u32>and an emptylist<string>are different values. Most mistakes are refused when you build the value, before any call. - A record is built from a map, and its field order comes from the type. Declare the fields in the order the component's WIT does.
- A type mismatch at the call is your error, not the engine's. It matches no sentinel, so
errors.IsagainstErrUnsupportedis false, and the message says which parameter and which types. - Read string contents with
Str().String()is a debug rendering:string("burroughs"). - A trap locks the whole component instance. The next call returns
ErrInstanceLocked, which wraps the same*Trap. Core modules do not do this. To recover, load a newComponent. - Close what you load.
Closeis idempotent. A call after it returnsErrComponentClosed. ComponentConfig's nil streams discard output. The secondp3hellorun printed nothing anywhere.
Limits you will hit¶
- Only
u32,string,listandrecordcross. A function taking or returningbool,u64,s32,f64,char,variant,enum,option,result,flagsor a resource handle is refused by name at the call, asErrUnsupported. - The WASI 0.2 host is partial. The component host provides stdout and stderr streams, stdin reads, arguments,
exit, and error debug strings.wasi:cli/environment.get-environmentandwasi:filesystem/preopens.get-directoriesboth return empty lists. Clocks, random, sockets and http are not provided.ComponentConfighas noEnvorPreopens. - Calls into one
Componentrun one at a time, interleaving only where a call suspends. Load several components for parallelism. - Component-model threads (
gate:cm-threads, phase 1 of the shared-everything-threads proposal) start through these paths, cooperatively, one at a time per instance. The proposal'sthread.spawn-refis refused by name, andthread.spawn-indirectis refused at decode. - From the CLI, a component takes its arguments after
--, and refuses--dir,--scratch,--envand a function name by name (sincev0.12.0, #1128): see the CLI reference.