webassembly - passing string between zig and javascript

When working with webassembly (wasm) we only have numeric values.

This means that if we need to handle strings, we need to handle them in raw binary data format using TypedArray & ArrayBuffer.

If we want to pass complex data types between the web assembly (wasm) and the host JavaScript environment, we pass the memory pointer to shared memory and a length data.

In this blog post we will learn how to pass a string between zig & JS host environment and vice versa.

For this blog post, we are using zig to compile into webassembly. we are not using any additional library or any compiler options so, we have to write the glue code to communicate between the wasm and JS.

Passing string from wasm to JS

First we will look at passing a string from wasm to JS.

We will start by defining an external function called print, which will be provide the functionality to output the string based on the host environment.

Next, we will define a simple function that defines a string (slice), and call the print function.

// string_from_wasm.zig
extern fn print(message: [*]const u8, length: u64) void;

export fn hello() void {
    const message: []const u8 = "Hello from Zig!";
    print(message.ptr, message.len);
}

Now let’s compile this into wasm.

zig build-exe src/string_from_wasm.zig -target wasm32-freestanding -fno-entry --export=hello

Since we are using wasm32-freestanding as the target, we need to provide the print function when loading the Wasm module with Node.js.

To define a print function, we first need to decode the string using the arguments passed, the memory pointer, and the length of the string.

// string_from_wasm.mjs
import { readFile } from "node:fs/promises"

const source = await readFile("./string_from_wasm.wasm");
const typedArray = new Uint8Array(source);

const waResult = await WebAssembly.instantiate(typedArray, {
  env: {
    print: (pointer, length) => { 
      const slice = new Uint8Array(mem.buffer, pointer, Number(length))
      const message = new TextDecoder().decode(slice);
      console.log("Message from Zig : ", message); 
    }
  }
});

let mem = waResult.instance.exports.memory;
const { hello } = waResult.instance.exports;

hello();

Now, Let run this code using nodejs.

node string_from_wasm.mjs
# Message from Zig :  Hello from Zig!

Passing string from JS to wasm

Let’s look at passing a string from JS to wasm.

Since we are managing the memory manually, we need to export functions to allocate and free the memory.

For the purpose of this blog post, we will use the std.heap.wasm_allocator from zig.

# string_to_wasm.zig
const std = @import("std");
const allocator = std.heap.wasm_allocator;

extern fn print(message: [*]const u8, length: u64) void;

export fn allocUint8(length: u32) [*]const u8 {
    const slice = allocator.alloc(u8, length) catch
        @panic("failed to allocate memory");
    return slice.ptr;
}

export fn free(pointer: [*:0]u8) void {
    allocator.free(std.mem.span(pointer));
}

export fn helloName(name: [*:0]const u8) void {
    const nam = std.mem.span(name);
    
    const message: []const u8 = std.fmt.allocPrint(allocator, "Hello {s}", .{nam}) catch unreachable;
    print(message.ptr, message.len);
}

Let’s compile this to wasm

zig build-exe src/string_to_wasm.zig -target wasm32-freestanding -fno-entry --export=helloName --export=allocUint8 --export=free

Now, in JS, we can use allocUint8 to allocate memory and pass the pointer of a null-terminated string to the helloName function. Later, we can use free to free up the allocated memory.

// string_to_wasm.mjs
import { readFile } from "node:fs/promises"

const source = await readFile("./string_to_wasm.wasm");
const typedArray = new Uint8Array(source);

const waResult = await WebAssembly.instantiate(typedArray, {
  env: {
    print: (pointer, length) => { 
      const slice = new Uint8Array(mem.buffer, pointer, Number(length))
      const message = new TextDecoder().decode(slice);
      console.log("Message from Zig : ", message); 
    }
  }
});

const stringToPointer = (string) => {
  const buffer = new TextEncoder().encode(string);
  const pointer = allocUint8(buffer.length + 1); // ask Zig to allocate memory
  const slice = new Uint8Array(
    mem.buffer, // memory exported from Zig
    pointer,
    buffer.length + 1
  );
  slice.set(buffer);
  slice[buffer.length] = 0; // null byte to null-terminate the string
  return pointer;
};


let mem = waResult.instance.exports.memory;
const { helloName, allocUint8, free } = waResult.instance.exports;

const str = stringToPointer("JS-String")

helloName(str);
free(str)

Now, when we run this code using nodejs.

node string_to_wasm.mjs
# Message from Zig :  Hello JS-String

Some Helpful links

Hope this is helpful.

Versions of Language/packages used in this post.

Library/Language Version
Zig 0.14
Node 23.10
If you find my work helpful, You can buy me a coffee.