GoloScript and WebAssembly Support with Wazero
v0.0.0-alpha.4
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:
hello(ptr, size)- Main Function
Receives a pointer and size, processes the string, stores the result.
getResultPtr()- Get Pointer
Returns the memory address where the result begins.
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