WASI, Communication between Node.js and WASM modules with the WASM buffer memory

Photo by Tim Johnson on Unsplash

WASI, Communication between Node.js and WASM modules with the WASM buffer memory

My journey to free WASM from my browser

This blog post explains how to use the WASM buffer memory to communicate between the host application and the WebAssembly module.

The previous blog post is WASI, first steps.

Requirements

You need to install the following:

Why this blog post?

👋 this paragraph is long to read.

When a simple "Hello World" is not trivial

When you want to call a function of a WASM module from a host application using a WASI Runtime SDK, some simple things, like using a string as a function parameter and/or as a return value of the same function, are not trivial.

If you need an Introduction to WASI, have a look at this blog post: WASI, first steps

Usually, you could think that it's easy to use strings with functions:

In fact, you cannot:

Because right now, you can only use numbers with the functions of a WASM module:

The WASI specifications are evolving, and in the near (I hope) future, this kind of limitation will be removed. Of course, there is a workaround.

The WASM memory buffer is your friend.

The instance of a WASM module "shares" a memory buffer with the host. This WASM memory buffer is a way to pass data between the WASM module and the host application (host runtime).

So, here is the main principle on how to exchange data (a string) between the WASM module and the host application:

1- Copy the string to the WASM memory buffer

  • The host copies the value of the string to the memory and gets the position (pos) and the size (size) of the string in the WASM memory buffer.

  • As we can only use numbers with the WASM functions, the host calls the WASM Hello function with the position (pos) of the string in memory and the size (size) of the string as parameters.

2- Read the string from the memory buffer

  • The Hello WASM function, with the position (pos)and the size (size) can read the value of the string (name) in the memory buffer.

  • And create a return value (msg).

3- Write the returned value to the WASM memory buffer

  • To make the string value (msg) available for the host, first, we need to copy it to the memory buffer.

  • Then the Hello function will need to return the position and the string size to the host.

  • But we can return only one value!

4- Put two values into only one value

To return the position and the string size into only one value, the solution is to use the bit-shifting method.

What is bit shifting, and how to use it?

If we use the binary representations of the position and the size of the string:

  1. We can "shift left" (<<) the bits of the position (pos) by pushing zeros (32 zeros) to the right and keep the bits of the position to the left.

  2. Then, we can apply a "OR" (|) with the size (size) to put its binary representation to the right.

  3. Then we can return only one value to the host.

5- Decode the position and the size from only one value

To decode the returned value (v) of the Hello function into two values, the position (p) and the size (s) of the string, we need to use the bit shifting and masking method. And then, the host will be able to read the value of the string in the WASM memory buffer.

What is bit masking, and how to use it?
  1. The host needs the position (pos) and the size (size) of the string to read its value in memory. Then, the host needs to extract the two values from the value returned by the Hello function.

  2. Then, we can "shift right" (>>) the bits of the returned value with 32 zeros to "extract" the position (pos).

  3. Then, we can apply a "AND mask" (& mask) of 32 ones to extract the size (size).

Now, the host can read the memory buffer.

Fine! Enough theory; let's move on to practice.

Calling a WASM function from Node.js with numbers

Node.js comes with the WASI API, providing an implementation of the WebAssembly System Interface specification (it's a preview). That means Node.js can execute and interact with WASM modules.

Write a WASM function

We will first write a WASM module with an add function that takes two numbers and returns the sum. Create a new GoLang module and a hello.go file (in a directory):

go mod init function/hello
touch hello.go

This is the content of the hello.go file:

package main

func main() {}

//export add
func add(a int, b int) int {
    return a + b
}

👋 Important: to make the add function callable from the host, we need to add the export add comment above the function.

Build and run the function

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

wasmer hello.wasm --invoke add 12 30 # 42

Call the add function from Node.js

Loading and instantiating a WASM module and calling the function from JavaScript is pretty simple. Let's create a new file named index.mjs with the below content:

"use strict";

import * as fs from 'fs'
import { WASI } from 'wasi'

const wasi = new WASI()
const importObject = { wasi_snapshot_preview1: wasi.wasiImport };

(async () => {
  const wasm = await WebAssembly.compile(fs.readFileSync("./function/hello.wasm")); // 1️⃣
  const instance = await WebAssembly.instantiate(wasm, importObject); // 2️⃣

  wasi.start(instance); // 3️⃣

  const sum = instance.exports.add(20, 22); // 4️⃣

  console.log("🤖 sum:", sum); // 5️⃣

})()
### Remarks:

- 1️⃣ Load the WASM module.
- 2️⃣ Instantiate the WASM module.
- 3️⃣ Start the WASM module instance.
- 4️⃣ All the exported WASM functions are available on the `exports` field of the instance like any other JavaScript functions. So, we can call the `add` function with 2 parameters.
- 5️⃣ Print the result of the `add` function.

And to execute the Node.js program, type the following command:

node --experimental-wasi-unstable-preview1 --no-warnings index.mjs

You should see this output:

🤖 sum: 42

The complete source code is here: https://gitlab.com/wasmkitchen/wasi-nodejs-step-0

Now let's move on to something more fun.

Calling a WASM function from Node.js with strings

Write a new WASM function

We will write a WASM module with a helloWorld function that takes two numbers (the position and the size of the string in memory passed by the host) and returns a single value (containing the size and the position of the returned message in memory). Create a new GoLang module and a hello.go file (in a directory):

go mod init function/hello
touch hello.go

This is the content of the hello.go file:

package main

import (
    "unsafe"
)

// main is required for TinyGo to compile to Wasm.
func main() {}

//export helloWorld
func helloWorld(bufferPosition *uint32, length int) uint64 { // 1️⃣

    nameBytes := readBufferFromMemory(bufferPosition, length) // 2️⃣

    message := "👋 Hello World " + string(nameBytes) + " 🌍"

    return copyBufferToMemory([]byte(message)) // 3️⃣
}

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

// copyBufferToMemory copies a buffer to the WebAssembly memory buffer
func copyBufferToMemory(buffer []byte) uint64 { // 5️⃣
    bufferPtr := &buffer[0]
    unsafePtr := uintptr(unsafe.Pointer(bufferPtr))

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

    return (uint64(ptr) << uint64(32)) | uint64(size) // 6️⃣
}
### Remarks:

- 1️⃣ The `helloWorld` function takes 2 parameters and returns a single value.
- 2️⃣ Read the buffer memory at the `bufferPosition` address.
- 3️⃣ Copy the buffer to the WebAssembly memory buffer.
- 4️⃣ `readBufferFromMemory` returns a buffer from the WebAssembly memory buffer.
- 5️⃣ `copyBufferToMemory` copies a buffer to the WebAssembly memory buffer and returns its memory position and size into only one value.
- 6️⃣ "pack" the position and the size into a single value. **Rember the bit `shift left or` operation**.

Build the function

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

Call the helloWorld function from Node.js

Let's create a new file named index.mjs with the below content:

"use strict";

import * as fs from 'fs'
import { WASI } from 'wasi'

const wasi = new WASI()
const importObject = { wasi_snapshot_preview1: wasi.wasiImport };


(async () => {
  const wasm = await WebAssembly.compile(fs.readFileSync("./function/hello.wasm"));

  const instance = await WebAssembly.instantiate(wasm, importObject);

  wasi.start(instance);

  // 🖐 Prepare the string parameter
  const stringParameter = "Bob Morane 🤗";
  const bytes = new TextEncoder("utf8").encode(stringParameter); // 1️⃣

  // The TinyGo `malloc` is automatically exported
  const ptr = instance.exports.malloc(bytes.length); // 2️⃣
  const mem = new Uint8Array( // 3️⃣
    instance.exports.memory.buffer, ptr, bytes.length
  );
  mem.set(bytes); // 4️⃣

  // Call the `helloWorld` TinyGo function
  // Get a kind of pair of values
  const helloWorldPointerSize = instance.exports.helloWorld(ptr, bytes.length); // 5️⃣

  const memory = instance.exports.memory;
  const completeBufferFromMemory = new Uint8Array(memory.buffer); // 6️⃣

  const MASK = (2n**32n)-1n; // 7️⃣

  // Extract the values of the pair (using the mask)
  const ptrPosition = Number(helloWorldPointerSize >> BigInt(32)); // 8️⃣
  const stringSize = Number(helloWorldPointerSize & MASK); // 9️⃣

  console.log("🤖 Position:", ptrPosition);
  console.log("🤖 Size:", stringSize);

  // Extract the string from the memory buffer
  const extractedBuffer = completeBufferFromMemory.slice( // 1️⃣0️⃣
    ptrPosition, ptrPosition+stringSize
  );

  console.log("🤖 extractedBuffer:", extractedBuffer) // 1️⃣1️⃣

  // Decode the buffer
  const str = new TextDecoder("utf8").decode(extractedBuffer) // 1️⃣2️⃣

  console.log(str)

})()
### Remarks:

- 1️⃣ Cast the string parameter to a `Uint8Array`.
- 2️⃣ Reserve memory space for the value (`malloc` is automatically exported at build time - this is a TinyGo particularity). The result is the **position** in memory
- 3️⃣ Access to the memory buffer.
- 4️⃣ Copy the `Uint8Array` to the WebAssembly memory buffer.
- 5️⃣ Call the `helloWorld` function with the **position** and **size/length** of the string as parameters, and get a single value as result containing the position and the size of the returned value.
- 6️⃣ Create a `Uint8Array` from the memory buffer.
- 7️⃣ Define a MASK (of `32 ones`) to extract the **position** and **size** of the string.
- 8️⃣ Do a `shift right` bit operation to extract the **position** of the string.
- 9️⃣ Do a `AND MASK` bit operation to extract the **size** of the string.
- 1️⃣0️⃣ Extract the part of the buffer containing the string.
- 1️⃣1️⃣ Print the buffer.
- 1️⃣2️⃣ Decode the buffer to get the string.
- 1️⃣3️⃣ Print the string.

And to execute the Node.js program, type the following command:

node --experimental-wasi-unstable-preview1 --no-warnings index.mjs

You should see this output:

🤖 Position: 66368
🤖 Size: 37
🤖 extractedBuffer: Uint8Array(37) [
  240, 159, 145, 139,  32,  72, 101, 108,
  108, 111,  32,  87, 111, 114, 108, 100,
   32,  66, 111,  98,  32,  77, 111, 114,
   97, 110, 101,  32, 240, 159, 164, 151,
   32, 240, 159, 140, 141
]
👋 Hello World Bob Morane 🤗 🌍

The complete source code is here: https://gitlab.com/wasmkitchen/wasi-nodejs

🎉 Thanks for reading! It was a little bit long for only some strings 😆 but I hope you enjoyed it. In the next blog post, we'll see another way to communicate with the WASM module.

Some very good blog posts that help me a lot: