Calling Go from Wasm
WebAssembly cannot read a clock and it cannot print. The age calculator in examples/import-go
needs both, so it declares them as imports and Go supplies them: a host module named env that
exports current_year and log_i32. The whole contract between the two halves is three things —
a module name, an export name, and a signature. Nothing else about the Go side is visible to the
guest, and nothing else about the guest is visible to Go.
Run it
Section titled “Run it”$ go run age-calculator.go 2000println >> 21log_i32 >> 21Run that from examples/import-go. The 21 is not stale arithmetic: current_year returns
time.Now().Year() unless CURRENT_YEAR is set in the environment, which is how the test pins
the answer without needing an edit every January.
func Test_main(t *testing.T) { // Set ENV to ensure this test doesn't need maintenance every year. t.Setenv("CURRENT_YEAR", "2021")
stdout, _ := maintester.TestMain(t, main, "age-calculator", "2000") require.Equal(t, `println >> 21log_i32 >> 21`, stdout)}make test.examples runs this and every other example the same way.
What the guest asks for
Section titled “What the guest asks for”Directoryexamples/import-go/
- age-calculator.go
- age-calculator_test.go
Directorytestdata/
- age_calculator.wat
- age_calculator.wasm committed, and embedded by the host
The .wasm is checked in, so nothing needs installing to run the example. It came from the text
format, with the command recorded above the embed:
// ageCalculatorWasm was generated by the following://// cd testdata; wat2wasm --debug-names age_calculator.wat////go:embed testdata/age_calculator.wasmvar ageCalculatorWasm []byteThe text format is here to be read — wazy takes the binary, and every other example in this section uses a real toolchain instead. Two imports, two exports, twenty-five lines:
(module $age-calculator
;; In WebAssembly, you don't import an entire module, rather each function. ;; This imports the functions and gives them names which are easier to read ;; than the alternative (zero-based index). ;; ;; Note: Importing unused functions is not an error in WebAssembly. (import "env" "log_i32" (func $log (param i32))) (import "env" "current_year" (func $year (result i32)))
;; get_age looks up the current year and subtracts the input from it. (func $get_age (export "get_age") (param $year_born i32) (result i32) call $year ;; stack: [$year.result] local.get $year_born ;; stack: [$year.result, $year_born] i32.sub ;; stack: [$year.result-$year_born] )
;; log_age calls $log with the result of $get_age (func (export "log_age") (param $year_born i32) ;; stack: [] local.get $year_born ;; stack: [$year_born] call $get_age ;; stack: [$get_age.result] call $log ;; stack: [] ))A module does not import another module; it imports each function separately, and $log and
$year are local aliases so the body reads better than call 0. Look at log_age: it calls
get_age, which calls back into Go for the year, and then calls into Go again to print. The host
is re-entered twice inside a single guest call, which is the normal shape once a guest does real
work.
What the host provides
Section titled “What the host provides”Put the two halves next to each other. The guest side:
(import "env" "log_i32" (func $log (param i32))) (import "env" "current_year" (func $year (result i32)))And the Go that satisfies it:
_, err := r.NewHostModuleBuilder("env"). NewFunctionBuilder(). WithGoFunction(api.GoFunc(func(_ context.Context, stack []uint64) { fmt.Println("log_i32 >>", uint32(stack[0])) }), []api.ValueType{api.ValueTypeI32}, nil). Export("log_i32"). NewFunctionBuilder(). WithGoFunction(api.GoFunc(func(_ context.Context, stack []uint64) { if envYear, err := strconv.ParseUint(os.Getenv("CURRENT_YEAR"), 10, 64); err == nil { stack[0] = uint64(uint32(envYear)) // Allow env-override to prevent annual test maintenance! return } stack[0] = uint64(uint32(time.Now().Year())) }), nil, []api.ValueType{api.ValueTypeI32}). Export("current_year"). Instantiate(ctx)Three things line up, and only three. NewHostModuleBuilder("env") supplies the module name;
env is conventional for ad-hoc host functions and means nothing to the runtime, so any name
works as long as both sides agree on it. Export("log_i32") supplies the export name — the Go
closures here are anonymous, and even a named Go function would not be visible under its Go name.
The two []api.ValueType arguments supply the signature, parameters first and results second,
which is how log_i32 becomes (param i32) and current_year becomes (result i32).
Reading and writing the raw stack
Section titled “Reading and writing the raw stack”api.GoFunc hands you stack []uint64, sized to the larger of the parameter and result counts.
Parameters arrive in it and results are written back over it, which is why current_year declares
no parameters and still writes stack[0] — the slice was sized for its one result. Slots follow
the encoding on api.ValueType, hence uint64(uint32(...)) going in and uint32(stack[0])
coming out; api.EncodeU32 and api.DecodeU32 spell the same conversions when you would rather
not do them by hand.
Typed helpers, when the shape fits
Section titled “Typed helpers, when the shape fits”The raw stack is the general form. When every parameter and result is numeric and the arity is
fixed at compile time, the typed helpers in host_typed.go derive the WebAssembly signature from
Go’s types instead, with no reflection and no per-call allocation:
func HostFunc0[R HostValue](b HostFunctionBuilder, fn func(context.Context, api.Module) R) HostFunctionBuilderfunc HostFunc1[P1, R HostValue](b HostFunctionBuilder, fn func(context.Context, api.Module, P1) R) HostFunctionBuilderfunc HostProc0(b HostFunctionBuilder, fn func(context.Context, api.Module)) HostFunctionBuilderfunc HostProc1[P1 HostValue](b HostFunctionBuilder, fn func(context.Context, api.Module, P1)) HostFunctionBuilderThey run to sixteen parameters — HostFunc0 through HostFunc16 for one result, HostProc0
through HostProc16 for none. Each takes the builder as its first argument and returns it, so
Export still chains. This is the addition example from the HostFunc0 documentation:
wazy.HostFunc2(builder, func(ctx context.Context, mod api.Module, x, y uint32) uint32 { return x + y}).Export("add")log_i32 and current_year are exactly HostProc1[uint32] and HostFunc0[uint32] in shape. The
one constraint is that the type arguments must be the predeclared types: uint32, int32,
uint64, int64, float32, float64, and uintptr for externref. A defined type such as
type Pages uint32 is not one of them and will not compile as a type argument — take a uint32
and convert inside the function body. Reach back for WithGoFunction or WithGoModuleFunction
when you need something the helpers cannot say: more than sixteen parameters, more than one
result, or control over the stack itself.
What the module parameter is for
Section titled “What the module parameter is for”Every typed helper takes (ctx context.Context, mod api.Module, ...). That mod is the
importing module — the guest that called you — and it is how a host function reaches the
caller’s linear memory. Strings across the boundary uses it to turn a pointer and a
length into a Go string:
func logString(ctx context.Context, m api.Module, offset, byteCount uint32) { buf, ok := m.Memory().Read(offset, byteCount) if !ok { log.Panicf("Memory.Read(%d, %d) out of range", offset, byteCount) } fmt.Println(string(buf))}What comes back from mod.Memory() is sandboxed guest memory, not arbitrary memory in your
process; that page has the rest of the story — view versus copy, and what invalidates a view. The age calculator passes nothing but i32s, so neither of its
functions needs the caller at all, which is why it uses WithGoFunction, the form that omits the
api.Module parameter entirely.
Instantiating and calling
Section titled “Instantiating and calling” // Instantiate a WebAssembly module named "age-calculator" that imports // functions defined in "env". // // Note: The import syntax in both Text and Binary format is the same // regardless of if the function was defined in Go or WebAssembly. ageCalculator, err := r.Instantiate(ctx, ageCalculatorWasm) if err != nil { log.Panicln(err) }
// Read the birthYear from the arguments to main birthYear, err := strconv.ParseUint(os.Args[1], 10, 64) if err != nil { log.Panicf("invalid arg %v: %v", os.Args[1], err) }
// First, try calling the "get_age" function and printing to the console externally. results, err := ageCalculator.ExportedFunction("get_age").Call(ctx, birthYear) if err != nil { log.Panicln(err) } fmt.Println("println >>", results[0])
// First, try calling the "log_age" function and printing to the console externally. _, err = ageCalculator.ExportedFunction("log_age").Call(ctx, birthYear) if err != nil { log.Panicln(err) }r.Instantiate resolves the guest’s imports against the modules the runtime already holds, so by
the time this line runs, env has to be one of them. The two calls that follow show the same
computation from both ends: get_age returns the number to Go, and log_age prints it from
inside the guest by calling back out. That ordering is why the transcript prints println before
log_i32.
Sharp edges
Section titled “Sharp edges”The host module goes first
Section titled “The host module goes first”Imports resolve at instantiation, by name, against what is already registered. Instantiate the
guest before env and you get module[env] not instantiated — a plain error out of
r.Instantiate, not a surprise at first call.
The signature is the whole contract
Section titled “The signature is the whole contract”The alias $log is a comment as far as linking is concerned. What must match is the quoted module
name, the quoted export name, and the type. Give log_i32 an i64 parameter on one side only and
instantiation fails with import func[env.log_i32]: signature mismatch followed by both types.
That is a good failure: it happens once, up front, instead of quietly mangling a value per call.
A panic in Go traps the guest
Section titled “A panic in Go traps the guest”A host function that panics does not unwind into your program. wazy recovers it, turns it into a
guest trap, and returns it from the Call that entered the guest, with the wasm stack trace
attached. The runtime’s own test pins the exact text, four frames deep:
func testUnreachable(t *testing.T, r wazy.Runtime) { callUnreachable := func() { panic("panic in host function") }
13 collapsed lines
_, err := r.NewHostModuleBuilder("host"). NewFunctionBuilder(). WithGoFunction(api.GoFunc(func(context.Context, []uint64) { callUnreachable() }), nil, nil). Export("cause_unreachable"). Instantiate(testCtx) require.NoError(t, err)
module, err := r.Instantiate(testCtx, unreachableWasm) require.NoError(t, err) defer func() { require.NoError(t, module.Close(testCtx)) }()
_, err = module.ExportedFunction("main").Call(testCtx) exp := `panic in host function (recovered by wazy)wasm stack trace: host.cause_unreachable() .two() .one() .main()` require.Equal(t, exp, err.Error())}Panic with an error and it is wrapped, so errors.Is and errors.As still reach it through the
trap. This makes panic a defensible way to report a host-side failure the guest cannot represent —
an out-of-range memory read, say — as long as you accept that it ends the guest call.
Only numbers cross
Section titled “Only numbers cross”Parameters and results are limited to the WebAssembly numeric types. No string, no slice, no struct at this boundary — passing one means agreeing on an offset and a length in guest memory, and on who frees it. That is the subject of Strings across the boundary, and the reason the Component Model exists.
One result per function is a WebAssembly 1.0 limit that later versions lifted, and Multiple results works through both ways out of it — a pointer the caller passes in, and the multi-value proposal that returns two numbers outright. When you are ready for the general form of everything on this page, the host functions guide covers the builder, the typed helpers, guest memory and what registering a host module costs.