JavaScript Interop with WebAssembly

In my last post I provided an introduction to WebAssembly — what is it, why do we care, and what does it look like? In this post, I’d like to explore a little bit of the inner workings of how we can communicate between Rust (wasm) and JavaScript. As I mentioned in the last post, WebAssembly is neither JavaScript nor some strongly-typed dialect. It is a standalone, compiled, portable binary. How you send data into and get data out of that binary involves some subtle nuances about how WebAssembly works.

First, let’s recap from the previous post. We wrote a function that looks like this:

#[no_mangle]
pub extern fn add_one(a: u32) -> u32 {
a + 1
}

We were able to invoke this function from JavaScript with:

wasm_module.instance.exports.add_one(2)

What happened offstage is that all of the extern functions in our Rust module were added to the exports of the WebAssembly module we instantiate either from an array buffer or, the preferred method, from a stream. There’s no magic here, there’s just a lot of work being done by WebAssembly.instantiate.

This function knows what the binary format of a wasm module looks like, and so it can troll through it looking for exports. In the case of the add_one function, we get a function definition that looks like this in wasm:

(func $add_one (export "add_one") (type $t0) (param $p0 i32) (result i32)

This is the text representation of wasm (you’ll see this referred to as wat in most documentation and samples) and clearly shows that we’re exporting the add_one function.

When I was first diving into this stuff, I was pretty excited at this point. The idea that I could, from JavaScript, invoke a function compiled within the safety and power of Rust, was pretty compelling. My mind raced with all the possibilities and my schedule screamed in horror at the number of potential side projects this might create.

What irked me is that most of the “hello world” samples stop here. They don’t show you how to go the other way — how do you invoke a JavaScript function from inside wasm/Rust? This is pretty straightforward, but requires a little more work.

First, lets create a JavaScript function we want to call:

function logit() {
console.log('this was invoked by Rust/wasm, written in JS');
}

The next thing we need to do is give Rust some kind of prototype for this function so the wasm compiler knows how to make an external function call. To do this, we’ll use the extern block syntax:

extern "C" {
fn logit();
}

Now that we’ve got a signature for this function, we can invoke it from inside our add_one function:

#[no_mangle]
pub extern fn add_one(a: u32) -> u32 {
logit();
a + 1
}

This is where things start to get a little complicated. This code doesn’t compile. It doesn’t compile because invoking an extern function is inherently unsafe. Any function that invokes an unsafe function must be marked as unsafe:

#[no_mangle]
pub unsafe extern fn add_one(a: u32) -> u32 {
logit();
a + 1
}

Now we could potentially compile our wasm module, fire up a web server, and launch it. However, we’d see an error in the console. That error boils down to the fact that we have externs that must be satisfied by imports that were not supplied to WebAssembly.instantiate. Put another way, we need to provide a binding from the Rust logit extern declaration to the JavaScript concrete logit implementation. While module exports were magically dealt with by the compiler, satisfying externs going into WebAssembly is a manual process.

Here’s how we can manually supply the logit import to our WebAssembly module instantiation (index.html):

<html>
<head>
<script>

function logit() {
console.log('this was invoked by Rust, written in JS');
}

let imports = {logit};

fetch('wasm_project.gc.wasm')
.then(r => r.arrayBuffer() )
.then(r => WebAssembly.instantiate(r, { env: imports }))
.then(wasm_module => {
alert(`2 + 1 = ${wasm_module.instance.exports.add_one(2)}`);
});
</script>
</head>
<body></body>
</html>

Here we’re passing a JavaScript object as the second parameter to the instantiate function. This object has an env field that contains the list of function imports. Every function that we want to be callable from within our Rust WebAssembly code needs to be explicitly included in this import list.

Now when we run this application (I just ran python3 -m http.server from the root of my Cargo project) I can see the log message in my JavaScript developer console:

JavaScript Developer Console — WebAssembly invoking JavaScript

Now we’re finally able to see how control can flow in both directions — from JS to WebAssembly and from WebAssembly to JS. We would be well within our rights to start exclaiming that this new thing is a web development utopia, but there are some details we need to cover before we get too excited.

If you look around the web at some of the starter tutorials for WebAssembly, you’ll notice that most of them involve this “add one” functionality. What most of them don’t make clear enough is that this is because you can only have numeric function parameters in WebAssembly interop calls. This is directly related to the stack-based nature of the wasm VM and its use of linear memory. If you want to pass and return strings, something we take for granted in nearly all languages, you have to jump through some hoops. For obvious reasons, we don’t want to push and pop strings of arbitrary length on the stack. This would remove all performance benefits of wasm and probably cripple any application of even the simplest of designs.

So what do we do? We use a WebAssembly concept called tables. A table, as the name implies, contains a table of elements of a particular type. To get a string into a function in WebAssembly, we could add that string to a table and then invoke the function with the index of that string in the table. On the other side of the interop call, we would then look up the string in the table based on the provided index. We can use this pattern to return “stack unfriendly” values as well.

Doing this for anything but the simplest of “hello world” samples is going to get very tedious very quickly.

This is the moment in a college professor’s lecture where they pause, gesture to the 37 full whiteboards of inscrutable formulas, and say, “but there’s an easier way to do it…”

There are libraries that have been written for Rust that will use code generation and macros to handle the low-level mechanics of using tables and other WebAssembly primitives to make interop look more seamless. However, I wanted to make sure that I covered what’s really happening at the lowest levels so that when I get to blogging about these libraries, you’ll know why we want to use them, what problems those libraries solve, and conceptually how those libraries are solving those problems.

Lastly, before I wrap up this blog post I want to leave you with a caveat — Nothing is going to verify that you haven’t created circular invocations. The compiler won’t stop your Rust code from invoking a JavaScript function that in turn invokes the original calling Rust function and spirals out of control until a crash. Treating the functions exported by JavaScript and those exported by your Rust code as strict APIs with clear boundaries will do wonders for the readability and maintainability of your WebAssembly projects.

Stay tuned for more wasm goodness when I start talking about some of the libraries and packages that abstract away some of the plumbing I discussed in this post.