Wazero Cookbook - Part Two: Host functions

My journey to free WASM from my browser

The Host Functions are functions defined inside the host program. From the WASM module perspective, a host function can be imported and called by the WASM module. A host function is like a superpower given by the host program to the WASM module.

So, today, we will learn how to develop "host functions" for the WASM modules with Wazero.

Requirements

You need to install the following:

And it's not mandatory, but it can help if you start from zero. You can read these former blog posts:

And now, it's time to start.

Chapter 3: Add a Print function

If you update the code of the WASM module from Chapter 2: "Create the new WASM module" to display a message from the WASM module by adding this line fmt.Println("🎃" + message) (see below):

//export hello
func hello(valuePosition *uint32, length uint32) uint64 {

    // read the memory to get the parameter
    valueBytes := readBufferFromMemory(valuePosition, length)

    message := "Hello " + string(valueBytes)

    fmt.Println("🎃" + message)

    // copy the value to memory
    posSizePairValue := copyBufferToMemory([]byte(message))

    // return the position and size
    return posSizePairValue
}

When executing the module, you will never see the "pumpkin" message 😮. In fact, it's normal; according to the WASI specification, a wasm module is minimal. So, by default, it cannot display information in a terminal (it's why fmt.Println("🎃" + message) does not work). But we can create a Print(something) function into the host (the program calling the wasm module) and allow the wasm module to call this Print function. And it will be our first host function.

Create a new host application.

I will reuse the code of Chapter 2: "Create the host application" to create the new host application.

On the host side:

  • We write a PrintString function.

  • We export the PrintString function to the WASM runtime with an "export name": hostPrintString (you can use the same name, but prefix the export name with host or something else helps me with the readability).

On the WASM module side*:

  • We import the hostPrintString function

  • We use the hostPrintString function to print a message to the terminal from the WASM module.

👋 Every time the WASM module calls the hostPrintString function, it will call the PrintString function of the host.

👋 the hostPrintString function will take as arguments the memory position and size of the data. Then, it will read the data from the WASM memory buffer (👀 have a look at Chapter 2: "Copy data to the WASM memory buffer").

Define the PrintString host function

First, we will use the GoModuleFunc api of Wazero, which is a convenience for defining an inlined function (== host function). And this is the source code of PrintString:

var PrintString = api.GoModuleFunc(func(ctx context.Context, module api.Module, stack []uint64) {

    // Get the position and size 
    // of the returned value 1️⃣
    position := uint32(stack[0])
    length := uint32(stack[1])

    // Read the memory 2️⃣
    buffer, ok := module.Memory().Read(position, length)
    if !ok {
        log.Panicf("Memory.Read(%d, %d) out of range", position, length)
    }
    // Display the message 3️⃣
    fmt.Println(string(buffer))
})
- `stack []uint64` is used to pass the arguments and the 
   returned value of the function.
- 1️⃣ when `PrintString` is called by the **WASM module** 
  with `hostPrintString`, the arguments are the memory
  position and size of the returned value.
- 2️⃣ the host can read the memory buffer thanks the position
  and size of the returned value.
- 3️⃣ the host can display the message.

Export PrintString as hostPrintString

To define the host function and make it available for the host, we will use the wazero.HostModuleBuilder, like this:

builder := runtime.NewHostModuleBuilder("env")

builder.NewFunctionBuilder().
    WithGoModuleFunction(PrintString, // 1️⃣
        []api.ValueType{
            api.ValueTypeI32, // 2️⃣ string position
            api.ValueTypeI32, // 3️⃣ string length
        }, 
        []api.ValueType{api.ValueTypeI32}).
    Export("hostPrintString") // 4️⃣

_, err := builder.Instantiate(ctx) // 5️⃣
if err != nil {
    log.Panicln("Error with env module and host function(s):", err)
}
- 1️⃣+4️⃣ `PrintString` is exported as `hostPrintString`
- 2️⃣ the type of the 1st element of `stack []uint64`
- 3️⃣ the type of the 2nd element of `stack []uint64`
- 5️⃣ compile and instantiate the module

The entire source code looks like this:

// Package main of the host application
package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/tetratelabs/wazero"
    "github.com/tetratelabs/wazero/api"
    "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)

// PrintString : print a string to the console
var PrintString = api.GoModuleFunc(func(ctx context.Context, module api.Module, stack []uint64) {

    position := uint32(stack[0])
    length := uint32(stack[1])

    buffer, ok := module.Memory().Read(position, length)
    if !ok {
        log.Panicf("Memory.Read(%d, %d) out of range", position, length)
    }
    fmt.Println(string(buffer))
})

func main() {
    // Choose the context to use for function calls.
    ctx := context.Background()

    // Create a new WebAssembly Runtime.
    runtime := wazero.NewRuntime(ctx)

    // This closes everything this Runtime created.
    defer runtime.Close(ctx) 

    // START: Host functions
    builder := runtime.NewHostModuleBuilder("env")

    // hostPrintString
    builder.NewFunctionBuilder().
        WithGoModuleFunction(PrintString, 
            []api.ValueType{
                api.ValueTypeI32, // string position
                api.ValueTypeI32, // string length
            }, 
            []api.ValueType{api.ValueTypeI32}).
        Export("hostPrintString")

    _, err := builder.Instantiate(ctx)
    if err != nil {
        log.Panicln("Error with env module and host function(s):", err)
    }
    // END: Host functions

    // Instantiate WASI
    wasi_snapshot_preview1.MustInstantiate(ctx, runtime)

    // Load the WebAssembly module
    wasmPath := "../function/hello.wasm"
    helloWasm, err := os.ReadFile(wasmPath)

    if err != nil {
        log.Panicln(err)
    }

    mod, err := runtime.Instantiate(ctx, helloWasm)
    if err != nil {
        log.Panicln(err)
    }

    // Get the reference to the WebAssembly function: "hello"
    helloFunction := mod.ExportedFunction("hello")

    // Function parameter
    name := "Bob Morane"
    nameSize := uint64(len(name))

    // These function are exported by TinyGo
    malloc := mod.ExportedFunction("malloc")
    free := mod.ExportedFunction("free")

    // Allocate Memory for "Bob Morane"
    results, err := malloc.Call(ctx, nameSize)
    if err != nil {
        log.Panicln(err)
    }
    namePosition := results[0]


    defer free.Call(ctx, namePosition)

    // Copy "Bob Morane" to memory
    if !mod.Memory().Write(uint32(namePosition), []byte(name)) {
        log.Panicf("out of range of memory size")
    }

    // Now, we can call "hello" with the position 
    // and the size of "Bob Morane"
    // the result type is []uint64
    result, err := helloFunction.Call(ctx, namePosition, nameSize)
    if err != nil {
        log.Panicln(err)
    }

    // Extract the position and size of the returned value
    valuePosition := uint32(result[0] >> 32)
    valueSize := uint32(result[0])

    // Read the value from the memory
    if bytes, ok := mod.Memory().Read(valuePosition, valueSize); !ok {
        log.Panicf("out of range of memory size")
    } else {
        fmt.Println("Returned value :", string(bytes))
    }
}

You can build the host application with the following command:

go build

Create a new WASM module

I will reuse the code of Chapter 2: "Create the new WASM module" to create the new host application.

First, I need to make a reference to the "exported" hostPrintString function:

//export hostPrintString
func hostPrintString(pos, sisze uint32) uint32

🖐🖐🖐 Yes, I know, it's "strange" to use export here, but in this specific case, actually, //export hostPrintString means that the module imports the host function. And the second line allows providing the signature of the function.

Then, I create a helper to use the hostPrintString host function:

func Print(message string) {
    // Copy the message to 
    // the WASM memory buffer
    buffer := []byte(message)
    bufferPtr := &buffer[0]
    unsafePtr := uintptr(unsafe.Pointer(bufferPtr))

    // Get the memory position and size
    // of the message
    pos := uint32(unsafePtr)
    size := uint32(len(buffer))

    // Call the host function
    hostPrintString(pos, size)
}

And now, I can print messages from my WASM module like that:

Print("👋 from the module: " + message)

The entire source code looks like that:

package main

import "unsafe"

func main () {}

//export hostPrintString
func hostPrintString(pos, sisze uint32) uint32

// Print a string
func Print(message string) {
    buffer := []byte(message)
    bufferPtr := &buffer[0]
    unsafePtr := uintptr(unsafe.Pointer(bufferPtr))

    pos := uint32(unsafePtr)
    size := uint32(len(buffer))

    hostPrintString(pos, size)
}

//export hello
func hello(valuePosition *uint32, length uint32) uint64 {

    // read the memory to get the parameter
    valueBytes := readBufferFromMemory(valuePosition, length)

    message := "Hello " + string(valueBytes)

    Print("👋 from the module: " + message)

    // copy the value to memory
    posSizePairValue := copyBufferToMemory([]byte(message))

    // return the position and size
    return posSizePairValue

}

// readBufferFromMemory returns a buffer from WebAssembly
func readBufferFromMemory(bufferPosition *uint32, length uint32) []byte {
    subjectBuffer := make([]byte, length)
    pointer := uintptr(unsafe.Pointer(bufferPosition))
    for i := 0; i < int(length); i++ {
        s := *(*int32)(unsafe.Pointer(pointer + uintptr(i)))
        subjectBuffer[i] = byte(s)
    }
    return subjectBuffer
}

// copyBufferToMemory returns a single value 
// (a kind of pair with position and length)
func copyBufferToMemory(buffer []byte) uint64 {
    bufferPtr := &buffer[0]
    unsafePtr := uintptr(unsafe.Pointer(bufferPtr))

    ptr := uint32(unsafePtr)
    size := uint32(len(buffer))

    return (uint64(ptr) << uint64(32)) | uint64(size)
}

You can build the WASM module with the following command:

tinygo build -o hello.wasm \
  -scheduler=none --no-debug \
  -target wasi ./hello.go

Now, launch the host application with the following command:

./host # or `go run main.go`

Then, you should get this result:

👋 from the module: Hello Bob Morane
Returned value : Hello Bob Morane

🎉 And that's it. Thanks for reading this second part of Wazero recipes! I hope you enjoyed it. In the next blog post, we'll see how to implement a new host function to make HTTP requests, and always with Wazero.

All examples are available here: https://github.com/wasm-university/wazero-step-by-step/tree/main/chapter-03