Skip to content

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:

  • LoadComponent returns a *Component whose exports you call with ComponentValues.
  • ComponentConfig.Run runs a component that exports wasi: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

examples/component/main.go
// 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/:

examples/component/output.txt
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 empty list<u32> and an empty list<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.Is against ErrUnsupported is 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 new Component.
  • Close what you load. Close is idempotent. A call after it returns ErrComponentClosed.
  • ComponentConfig's nil streams discard output. The second p3hello run printed nothing anywhere.

Limits you will hit

  • Only u32, string, list and record cross. A function taking or returning bool, u64, s32, f64, char, variant, enum, option, result, flags or a resource handle is refused by name at the call, as ErrUnsupported.
  • 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-environment and wasi:filesystem/preopens.get-directories both return empty lists. Clocks, random, sockets and http are not provided. ComponentConfig has no Env or Preopens.
  • Calls into one Component run 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's thread.spawn-ref is refused by name, and thread.spawn-indirect is refused at decode.
  • From the CLI, a component takes its arguments after --, and refuses --dir, --scratch, --env and a function name by name (since v0.12.0, #1128): see the CLI reference.