How to Pass Strings Between JavaScript and WebAssembly
In my first post on WebAssembly I dodged the classic “Hello, World!” example as I thought it might be a bit tricky. Little did I realise how hard it was going to be!
In this post I’m going to look at how to solve it. The code for the project can be found here.
The Problem
In JavaScript strings are encoded with utf-8. This format encodes characters that cannot be represented in the ascii character set with more than one byte.
// Convert a pointer from the wasm module to JavaScript string. convertToString (ptr, length) { try { // The pointer is a multi byte character array encoded with utf-8. const array = newUint8Array(this.memory.buffer, ptr, length) const decoder = newTextDecoder() const string = decoder.decode(array) return string } finally { // Free the memory sent to use from the WebAssembly instance. this.free(ptr) } }
// Convert a JavaScript string to a pointer to multi byte character array convertFromString(string) { // Encode the string in utf-8. const encoder = newTextEncoder() const bytes = encoder.encode(string) // Copy the string into memory allocated in the WebAssembly const ptr = this.malloc(bytes.byteLength) const buffer = newUint8Array(this.memory.buffer, ptr, bytes.byteLength + 1) buffer.set(bytes) return buffer } }
Note how malloc and free and used to manage the memory in the WebAssembly module.
The C Side
The C standard library provides support for multi byte characters [1], so I coded up a function to count the letters (not the bytes) of a string, and a function to log “Hello, World!” in english and mandarin.
This was a complete failure!
Looking harder at the examples I noticed I was missing a called to setlocale, so I added that to my C code. The final code looks as follows.
Then I ran the program and BOOM I get an error complaining that wasi_snapshot_preview1 is not defined. I knew what this meant (the libc implementation needed a WASI module to implement system calls), but I didn’t think I needed one, as I’m only using strings.
It turns out that the first thing setlocale does is check some environment variables. To do that it needs some wasi functions to call out of the wasm sandbox.
Implementing a Minimal WASI
After a consulting the WASI API documentation I decided I needed to implement environ_get and it’s companion environ_sizes_get. I put these in a class called Wasi and passed them to the module on instantiation using the wasi_snapshot_preview1 key as follows:
1 2 3
const res = awaitWebAssembly.instantiate(buf, { wasi_snapshot_preview1: wasi, })
Now when I ran the program I got no errors about wasi_snapshot_preview1, but I get a new error saying that proc_exit was missing. I found that in the docs, and it gets called to terminate the process. As I’ve got nothing to terminate I created a stub for it. My Wasi class looks as follows.
// An implementation of WASI which supports the minimum // required to use multi byte characters. classWasi { constructor (env) { this.env = env this.instance = null this.wasiMemoryManager = null }
// Initialise the instance from the WebAssembly. init = (instance) => { this.instance = instance this.wasiMemoryManager = newWasiMemoryManager( instance.exports.memory, instance.exports.malloc, instance.exports.free ) }
staticWASI_ESUCCESS = 0
// Get the environment variables. environ_get = (environ, environBuf) => { const encoder = newTextEncoder() const view = newDataView(this.wasiMemoryManager.memory.buffer)
// Create the Wasi instance passing in some environment variables. const wasi = newWasi({ "LANG": "en_GB.UTF-8", "TERM": "xterm" })
// Instantiate the wasm module. const res = awaitWebAssembly.instantiate(buf, { wasi_snapshot_preview1: wasi, env: { // This function is exported to the web assembly. consoleLog: function(ptr, length) { // This converts the pointer to a string and frees he memory. const string = wasi.wasiMemoryManager.convertToString(ptr, length) console.log(string) } } })
// Initialise the wasi instance wasi.init(res.instance)
return wasi }
module.exports = setupWasi
The Example
The last thing to do was write the example. Here it is:
// Get the functions exported from the WebAssembly const { countLetters, sayHelloWorld, sayHelloWorldInMandarin } = wasi.instance.exports
let buf1 = null try { const s1 = '犬 means dog' // Convert the JavaScript string into a pointer in the WebAssembly buf1 = wasi.wasiMemoryManager.convertFromString(s1) // The Chinese character will take up more than one byte. const l1 = countLetters(buf1.byteOffset) console.log(`expected length ${s1.length} got ${l1} byte length was ${buf1.byteLength}`) } finally { // Free the pointer we created in WebAssembly. wasi.wasiMemoryManager.free(buf1) }
// Should log "Hello, World!" to the console sayHelloWorld() sayHelloWorldInMandarin() }
main().then(() =>console.log('Done'))
And it works! Here’s the output.
1 2 3 4
expected length 11 got 11 byte length was 14 Hello World 你好,世界! Done
Thoughts
I realise node already has a WASI module, but I wanted to be able to run the code in a browser. I would have preferred to use wasmer-js, but I had problems getting it to work, and it felt like a bit of a sledgehammer for the problem I was trying to solve.