Skip to main content

Command Palette

Search for a command to run...

GoloScript and WebAssembly Support with Wazero

v0.0.0-alpha.4

Updated
9 min read

We're talking a bit about WASM for the new GoloScript release (v0.0.0-alpha.4)

https://codeberg.org/TypeUnsafe/golo-script/releases

We've been talking about WASM (WebAssembly) for a few years now. WASI (WebAssembly System Interface) is a standard that allows WASM modules to interact with the host system (files, network, etc.) in a secure and portable way. And all of this isn't progressing very quickly (I'm talking about WASI). And with each new preview, I have the impression that everything becomes more complicated, that compatibility between tools is broken, etc. In real life, we mostly see projects using WASI preview 1, and not the later versions.

So, Preview 1 (wasi_snapshot_preview1) remains today the most widespread anchor point in runtimes (Wasmtime, legacy Wasmer, wazero, etc.). It's seen by many as a sort of POC that has become a de facto standard, even if it's not officially WASI 1.0.

And as far as I'm concerned, this is more than sufficient for my needs: creating plugins for applications (mainly Go).

It turns out that there's a WASM runtime written in Go, named wazero. It's simple to use, integrates well with Go applications, and supports WASI preview 1. So it's the perfect choice for adding WASM support to "my little scripting language Golo".

But let's first see how this works with a "classic" Go application (let's remember that GoloScript is written in Go) and a WASM module written in Go and compiled with TinyGo.

At first, there's a "slight" entry barrier to make the Go host and the WASM module communicate. This is what we'll see in this very simple example.

The Technical "Constraint" to Overcome

WebAssembly cannot directly manipulate complex types like Go strings. It only understands numbers (integers, floats). To exchange complex data, we use shared memory.

WebAssembly Linear Memory

Each WebAssembly module has a linear memory: a large byte array accessible by both the host (the application that "calls" the functions of the WASM modules - in my case, this will be GoloScript) and the guest (the wasm module that contains the function(s)).

The host and guest can read and write to this shared memory. However, they must agree on the addresses (offsets) where the data is stored, as well as the size of the exchanged data.

Therefore, a "simple" protocol must be followed to exchange data:

Communication Protocol: 3 Functions

The WASM module (guest) exposes functions that take pointers (offsets in linear memory) and sizes (lengths) as parameters.

For example, to exchange a string (the host calls the hello function of the guest), we use a 3-function protocol:

  1. hello(ptr, size) - Main Function

Receives a pointer and size, processes the string, stores the result.

  1. getResultPtr() - Get Pointer

Returns the memory address where the result begins.

  1. getResultLen() - Get Size

Returns the length in bytes of the result.

The host (Go) writes the input data to a specific address in linear memory, then calls the WASM module function passing the address and size of the data. After execution, the WASM module writes the results to another address in linear memory, which the host can then read.

Step by Step: Host Code

Step 1: Prepare Input

name := "Bob Morane"
nameBytes := []byte(name)
namePtr := uint32(1024)  // Safe arbitrary address

Step 2: Write to WASM Memory

if !mod.Memory().Write(namePtr, nameBytes) {
    log.Fatal("Failed to write to WASM memory")
}

The host writes directly into the WASM module's memory.

Step 3: Call Guest Function

_, err = helloFn.Call(ctx, uint64(namePtr), uint64(len(nameBytes)))

We pass two numbers:

  • ptr: where the string is located (1024)

  • size: how many bytes to read (10)

Step 4: Get Result Pointer

ptrResults, err := getResultPtrFn.Call(ctx)
resultPtr := uint32(ptrResults[0])

Step 5: Get Result Size

lenResults, err := getResultLenFn.Call(ctx)
resultLen := uint32(lenResults[0])

Step 6: Read Result from Memory

resultBytes, ok := mod.Memory().Read(resultPtr, resultLen)
result := string(resultBytes)

Step by Step: Guest Code

hello Function - Processing

func hello(ptr, size uint32) {
    // 1. Convert ptr and size to Go slice
    inputBytes := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)

    // 2. Create Go string
    name := string(inputBytes)

    // 3. Process
    result := "Hello, " + name + "!!!"

    // 4. Store in global variable
    resultBuffer = []byte(result)
}

getResultPtr Function - Return Address

func getResultPtr() uint32 {
    if len(resultBuffer) == 0 {
        return 0
    }
    return uint32(uintptr(unsafe.Pointer(&resultBuffer[0])))
}

This function returns the memory address of the first byte of the result.

getResultLen Function - Return Size

func getResultLen() uint32 {
    return uint32(len(resultBuffer))
}

Simple: returns how many bytes the result contains.

Here's a complete diagram of the data flow between the Go host and the guest WASM module:

Complete Data Flow Diagram

And Wazero will allow us to implement this very simply. I won't detail how to do it here, but having implemented it in GoloScript, I'll explain how to load a WASM module and call its functions.

The WebAssembly Module in TinyGo

Here's the complete code of the WASM module written in Go and compiled with TinyGo, where we find the 3 functions explained above:

package main

import (
    "unsafe"
)

// Buffer to store the result
var resultBuffer []byte

// hello reads a string from memory, processes it, and stores the result
//
//export hello
func hello(ptr, size uint32) {
    // Read the input string from memory
    inputBytes := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
    name := string(inputBytes)

    // Create the response message manually (without fmt to simplify)
    prefix := "Hello, "
    suffix := "!!!"
    result := prefix + name + suffix

    // Store in the global buffer
    resultBuffer = []byte(result)
}

// getResultPtr returns the pointer to the result
//
//export getResultPtr
func getResultPtr() uint32 {
    if len(resultBuffer) == 0 {
        return 0
    }
    return uint32(uintptr(unsafe.Pointer(&resultBuffer[0])))
}

// getResultLen returns the length of the result
//
//export getResultLen
func getResultLen() uint32 {
    return uint32(len(resultBuffer))
}

// main is required but will not be called directly
func main() {}

Compiling the Guest Module

cd guest
tinygo build -o guest.wasm -target=wasi guest.go

We'll then get a guest.wasm file that we can load from the Go host (in this case GoloScript) with Wazero.

Usage in GoloScript

wasm.golo

module examples.wasm.StringExample

function main = |args| {

  # Load the WASM module (TinyGo compiled)
  let runner = wasmLoad("./guest.wasm")

  # Check if the 'hello' function exists
  if wasmHasFunction(runner, "hello") {
    println("✓ Function 'hello' found in module")

    # Call the hello function with different names
    let names = ["🌸 Alice", "🤗 Bob", "🥸 Charlie", "🤓 GoloScript User"]

    foreach name in names {
      let result = wasmCallString(runner, "hello", name)
      println("  → " + result)
    }
  } else {
    println("✗ Function 'hello' not found")
  }
}

Run the Golo script:

golo wasm.golo

You should see the following output:

✓ Function 'hello' found in module
  → Hello, 🌸 Alice!!!
  → Hello, 🤗 Bob!!!
  → Hello, 🥸 Charlie!!!
  → Hello, 🤓 GoloScript User!!!

You'll notice that on the GoloScript side, calling the wasmCallString function is very simple: you pass the name and receive the response. All the complexity of managing shared memory and pointers is hidden in this utility function.

Last but not least: host functions

Wazero also allows defining host functions that the WASM module can call. This allows extending the module's capabilities by providing it with external services.

In the case of GoloScript, I've implemented only one host function for now, which is a handler with which the WASM module can send messages to the GoloScript host, and the host can send a response back to the module. It's therefore possible to activate different processing depending on the message sent.

Here's an example, first of all the guest WASM module:

package main
// tinygo build -o main.wasm -target=wasi main.go
import (
    "unsafe"
)

// Declaration of the host function imported from "env"
//
//export hostCallStringHandler
func hostCallStringHandler(ptr, length uint32) uint32

//export hostGetResultPtr
func hostGetResultPtr() uint32

//export hostGetResultLen
func hostGetResultLen() uint32

// Global variables to store the result
var (
  resultData []byte
  resultPtr  uintptr
  resultLen  int
)

// processWithHost calls the host function with a string
// and retrieves the result
//
//export processWithHost
func processWithHost(ptr, length uint32) {
  // Read the input from memory
  input := ptrToString(ptr, length)

  // Prepare the message to send to the host
  message := "👋 [WASM] you send me this: " + input

  // Convert to bytes and get ptr/len
  messageBytes := []byte(message)
  messagePtr := uintptr(unsafe.Pointer(&messageBytes[0]))
  messageLen := uint32(len(messageBytes))

  // Call the host function
  hostCallStringHandler(uint32(messagePtr), messageLen)

  // Retrieve the result from the host
  hostPtr := hostGetResultPtr()
  hostLen := hostGetResultLen()

  // Read the result from memory
  hostResult := ptrToString(hostPtr, hostLen)

  // Store the final result
  resultData = []byte("Result from host: " + hostResult)
  resultPtr = uintptr(unsafe.Pointer(&resultData[0]))
  resultLen = len(resultData)
}

// getResultPtr returns the pointer to the result
//
//export getResultPtr
func getResultPtr() uint32 {
  return uint32(resultPtr)
}

// getResultLen returns the length of the result
//
//export getResultLen
func getResultLen() uint32 {
  return uint32(resultLen)
}

// ptrToString converts a pointer and length to a string
func ptrToString(ptr uint32, length uint32) string {
  return unsafe.String((*byte)(unsafe.Pointer(uintptr(ptr))), length)
}

func main() {
  // Required for TinyGo but will not be executed
}

And here's the GoloScript host code which is much simpler:

wasm-host-function.golo

module examples.wasm.HostFunction

function main = |args| {

  # Load the WASM module
  let runner = wasmLoad("./guest.wasm")

  # Create a handler function that will be called from WASM
  let myHandler = |input| {
    println("🔵 [HOST] receives: " + input)
    return "🎉 tada!"
  }

  # Register the handler (always uses "default" as the handler name)
  wasmRegisterStringHandler(runner, myHandler)

  # Call the WASM function that will call our handler

  foreach n in [1..3] {
    println("➡️ Calling WASM function (iteration " + str(n) + ")...")
    let response = wasmCallString(runner, "processWithHost", "Hello from GoloScript! (iteration " + str(n) + ")")
    println("🟣 [HOST] got response from WASM: " + response)
  }

  # Clean up
  wasmClose(runner)
}

Run the Golo script:

golo wasm-host-function.golo

You should see the following output:

➡️ Calling WASM function (iteration 1)...
🔵 [HOST] receives: 👋 [WASM] you send me this: Hello from GoloScript! (iteration 1)
🟣 [HOST] got response from WASM: Result from host: 🎉 tada!
➡️ Calling WASM function (iteration 2)...
🔵 [HOST] receives: 👋 [WASM] you send me this: Hello from GoloScript! (iteration 2)
🟣 [HOST] got response from WASM: Result from host: 🎉 tada!
➡️ Calling WASM function (iteration 3)...
🔵 [HOST] receives: 👋 [WASM] you send me this: Hello from GoloScript! (iteration 3)
🟣 [HOST] got response from WASM: Result from host: 🎉 tada!

That's it for this somewhat complex article, but I hope you've understood the basics of communication between GoloScript and a WASM module.

This release also includes support for the OpenAI API, but I'll tell you more about that later