Skip to content

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.

Terminal window
cd examples/basic
go run add.go 7 9
7 + 9 = 16

Nothing 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
examples/basic/testdata/add.go
package main
//go:wasmexport add
func 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-main
func 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:

Terminal window
(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.

examples/basic/add.go
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.wasm
var 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.

examples/basic/add.go
// 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.

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.

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.