Skip to content

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.

Terminal window
$ go run age-calculator.go 2000
println >> 21
log_i32 >> 21

Run 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.

examples/import-go/age-calculator_test.go
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 >> 21
log_i32 >> 21
`, stdout)
}

make test.examples runs this and every other example the same way.

  • 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:

examples/import-go/age-calculator.go
// ageCalculatorWasm was generated by the following:
//
// cd testdata; wat2wasm --debug-names age_calculator.wat
//
//go:embed testdata/age_calculator.wasm
var ageCalculatorWasm []byte

The 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:

examples/import-go/testdata/age_calculator.wat
(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.

Put the two halves next to each other. The guest side:

examples/import-go/testdata/age_calculator.wat
(import "env" "log_i32" (func $log (param i32)))
(import "env" "current_year" (func $year (result i32)))

And the Go that satisfies it:

examples/import-go/age-calculator.go
_, 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).

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.

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) HostFunctionBuilder
func HostFunc1[P1, R HostValue](b HostFunctionBuilder, fn func(context.Context, api.Module, P1) R) HostFunctionBuilder
func HostProc0(b HostFunctionBuilder, fn func(context.Context, api.Module)) HostFunctionBuilder
func HostProc1[P1 HostValue](b HostFunctionBuilder, fn func(context.Context, api.Module, P1)) HostFunctionBuilder

They 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.

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:

examples/allocation/rust/greet.go
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.

examples/import-go/age-calculator.go
// 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.

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 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 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:

internal/integration_test/engine/adhoc_test.go
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.

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.