Adding two numbers
Eighty lines of Go that hand two integers to a function compiled from a different Go program, and
print what comes back. The guest arrives as a byte slice; the host knows one name, add, and a
signature made of integers. Everything between those two facts is this page, and there is less of
it than the file’s comment count suggests.
Run it
Section titled “Run it”cd examples/basicgo run add.go 7 97 + 9 = 16Nothing to install. add.wasm is committed, and wazy is pure Go, so the only toolchain involved is
the one you already have. add_test.go runs main with those same arguments and compares stdout,
which means make test.examples — what CI runs whenever anything under examples/ changes —
exercises the whole path: embed, instantiate, call.
Directoryexamples/basic
- add.go the host, and this page’s subject
- add_test.go pins
7 + 9 = 16 - README.md
Directorytestdata
- add.go the guest, ten lines
- add.wasm 104 KB, committed
The guest
Section titled “The guest”package main
//go:wasmexport addfunc add(x, y uint32) uint32 { return x + y}
// main is required for the `wasi` target, even if it isn't used.// See https://wazero.io/languages/tinygo/#why-do-i-have-to-define-mainfunc main() {}//go:wasmexport add is the entire interface. The name in the directive is the name the host looks
up later, verbatim; without the directive the function is unreachable from outside and the linker
drops it. uint32 in and out becomes i32 in and out — core WebAssembly has four numeric types and
no others, which is why this example adds integers rather than concatenating strings.
TinyGo builds it:
(cd testdata; tinygo build -buildmode=c-shared -target=wasip1 -o add.wasm add.go)make build.examples.tinygo adds -scheduler=none --no-debug, and CI pins TinyGo 0.39.0. The
result is 104 KB of Wasm for a three-line function: TinyGo links its runtime, its allocator and its
WASI shims into every guest, and that fixed cost dominates anything this small.
-buildmode=c-shared decides the shape of the output, and it is the flag this example depends
on. The committed binary exports memory, the allocator TinyGo needs for it (malloc, free,
calloc, realloc), the asyncify hooks, add itself — and, in place of _start, an
_initialize. Hold on to that last one; the host has to know it.
The host
Section titled “The host”14 collapsed lines
package main
import ( "context" _ "embed" "errors" "flag" "fmt" "log" "strconv"
"github.com/samyfodil/wazy" "github.com/samyfodil/wazy/imports/wasi_snapshot_preview1")
// addWasm was generated by the following://// cd testdata; tinygo build -o add.wasm -target=wasi add.go////go:embed testdata/add.wasmvar addWasm []byte
// main is an example of how to extend a Go application with an addition// function defined in WebAssembly.//// Since addWasm was compiled with TinyGo's `wasi` target, we need to configure// WASI host imports.func main() { // Parse positional arguments. flag.Parse()
// Choose the context to use for function calls. ctx := context.Background()
// Create a new WebAssembly Runtime. r := wazy.NewRuntime(ctx) defer r.Close(ctx) // This closes everything this Runtime created.
// Instantiate WASI, which implements host functions needed for TinyGo to // implement `panic`. wasi_snapshot_preview1.MustInstantiate(ctx, r)
// Instantiate the guest Wasm into the same runtime. It exports the `add` // function, implemented in WebAssembly. mod, err := r.InstantiateWithConfig(ctx, addWasm, wazy.NewModuleConfig().WithStartFunctions("_initialize")) if err != nil { log.Panicf("failed to instantiate module: %v", err) }Why WASI, for a function that only adds. The import section of add.wasm names two functions
from the wasi_snapshot_preview1 module: fd_write and random_get. TinyGo’s runtime keeps them
for any wasip1 build — one to write a panic message out, one to source random bytes — and add
reaches neither. Imports are still resolved when a module is instantiated, not when a function is
called, so leaving out MustInstantiate means the guest never loads at all:
InstantiateWithConfig fails with module[wasi_snapshot_preview1] not instantiated, an error that
names nothing you wrote. Registration is per runtime, happens once, and
costs nothing at call time.
Why _initialize. ModuleConfig calls _start after instantiation by default, which is right
for a command — a guest built to run once and exit. -buildmode=c-shared produced a reactor
instead: it initializes, then sits still and waits to be called, and its entry point is
_initialize. Naming it here is what runs TinyGo’s package initialization, sets up its heap and
leaves the instance alive. defer r.Close(ctx) then tears down the runtime, the compiled code and
this instance together — one close for everything the runtime created.
// Call the `add` function and print the results to the console. add := mod.ExportedFunction("add") results, err := add.Call(ctx, x, y) if err != nil { log.Panicf("failed to call add: %v", err) }
fmt.Printf("%d + %d = %d\n", x, y, results[0])ExportedFunction is a lookup by the exact string the guest exported, and it returns nil when
there is no such export — a typo in the name surfaces as a nil dereference on the next line, not as
an error you can wrap, so check it if the name comes from anywhere but a literal. x and y come
from readTwoArgs, which is strconv.ParseUint and nothing more. The error from Call covers both
halves of the boundary: a trap inside the guest (an unreachable, a bad memory access) and a guest
that exited.
What trips people up
Section titled “What trips people up”A missing start function is skipped, not reported
Section titled “A missing start function is skipped, not reported”WithStartFunctions documents both halves of this: it defaults to "_start", and a start function
that doesn’t exist is skipped. So dropping WithStartFunctions("_initialize") from this example
does not fail. It hands you a module whose runtime was never initialized. For add, which touches
neither the heap nor a global, that may go unnoticed; for a guest that allocates, it goes wrong
later and somewhere else. The reverse case is just as quiet: point a command module at
_initialize and its _start never runs.
Commands and reactors also differ after instantiation. A command’s _start runs to completion
during instantiation and usually exits the module, so calling its exports afterwards fails. If you
want to call the guest repeatedly, build a reactor.
Results are slots, not types
Section titled “Results are slots, not types”Every WebAssembly parameter and result crosses as a 64-bit slot, and a function may return more than
one — hence []uint64 rather than a single value. add returns an i32, which lands zero-extended
in results[0] and prints with %d untouched. Other types need the api package’s encoders:
api.DecodeI32 for a signed integer, api.EncodeF64 and api.DecodeF64 for floats, whose slots
hold the IEEE-754 bit pattern rather than a number you can read.
The slot width is not a type check. Call validates the count of parameters against the
function’s signature and nothing else, so readTwoArgs parsing 64-bit values against an i32 guest
signature is on you: pass more than an i32 holds and the guest adds something else. Call also
allocates the slice it returns; a hot loop should use CallWithStack, which writes into a slice you
own.
The guest’s own error messages go nowhere
Section titled “The guest’s own error messages go nowhere”ModuleConfig defaults stdout and stderr to io.Discard, deliberately — “This does not default to
os.Stdout as that both violates sandboxing and prevents concurrent modules.” A guest that panics
therefore writes its explanation into the void and hands you back a bare exit or trap error. Before
debugging one of those, give it somewhere to write with WithStdout and WithStderr. The same
default applies to the filesystem, the clock and the network: see
Sandboxing and limits.
You now have the whole core-module protocol: integers in, integers out, memory addressed by number.
The next rung is passing a string, which means agreeing with the guest about who allocates and who
frees — that is
Strings across the boundary, the same program built three times over from TinyGo,
Rust and Zig. If you would rather stop hand-marshalling entirely,
running components is the same Call with the types kept, and the runtime doing
the marshalling for you.