A year and change ago, I wrote a blog post and gave a talk at Brooklyn.js about the "glue" layer needed to make interoperability between WebAssembly and a JavaScript host environment as seamless and pleasant to use as possible.

I did this both as an explainer to others, and an exercise for myself, because — and I don't know how much this resonates with anyone else — I have trouble using a framework without knowing how it magics away complexity.

After looking at the resulting glue code that wasm-bindgen generates, though, I'm not sure I trust anything anymore.


Let's take a look at the boilerplate it generates for a hello world! application in a Node.js target.

This is the Rust file, which is pretty straightforward: it has a single export greet which takes no arguments, and a single import alert which takes a string and returns no value (so it produces a side effect in JavaScript land). It's as "hello world" as you can get.

extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, World!");
}

And here are the pair of JavaScript files that it generates: package.js, which is the file that the end user imports, and package_bg.js, which is the file that actually compiles and instantiates the WebAssembly module.

I snipped some code in package.js for brevity, between lines 1 and 3, including the definition of getStringFromWasm0.

let wasm;

module.exports.greet = function() {
  wasm.greet();
}

module.exports.__wbg_alert_5c1dd6cac225a5d8 = function(arg0, arg1) {
  alert(getStringFromWasm0(arg0, arg1));
};

wasm = require('./package_bg');
const path = require('path').join(__dirname, 'package_bg.wasm');
const bytes = require('fs').readFileSync(path);
let imports = {};
imports['./package.js'] = require('./package.js');

const wasmModule = new WebAssembly.Module(bytes);
const wasmInstance = new WebAssembly.Instance(wasmModule, imports);
module.exports = wasmInstance.exports;

Now, the subject of this post — what I refer to when I say "Thanks, I hate it" — isn't anything about WebAssembly or wasm-bindgen. It's about Node.js. wasm-bindgen's generated code just happens to (ab)use it.

Here is what I find maddening about this pair of JavaScript shims.

  1. Both files require() each other. What? How?
  2. package.js, the file that's intended to be the "ergonomic" API for end users, exports both greet and the mangled __wbg_alert_5c1dd6cac225a5d8 function?
  3. It's not even clear how the WebAssembly module gets its imported alert function, which is required for its instantiation.

Let's dissect this already.


On line 1 of package.js, we declare the wasm variable, which will eventually be the WebAssembly module. We know this because on line 3 we export the greet shim, which in turn calls an export of wasm.

let wasm;

module.exports.greet = function() {
  wasm.greet();
}

Then we also export a function that looks a lot like a mangled WebAssembly import, which makes no sense because this is the file for the end user's API. Let's put a pin in this and move on.

module.exports.__wbg_alert_5c1dd6cac225a5d8 = function(arg0, arg1) {
  alert(getStringFromWasm0(arg0, arg1));
};

Then only after declaring end user exports do we assign wasm to the export of package_bg.js, presumably the actual WebAssembly module.

But the WebAssembly module's necessary import function seems to be in here, but the module was instantiated in package_bg.js.


package_bg.js looks exactly how you'd expect a Node.js loaded for WebAssembly to look: it fs.readFileSyncs the binary, then does a synchronous compile and instantiation before exporting the module's own exports for the JavaScript glue code to use.

But there is some weird tucked in there: on line 4, the WebAssembly imports object is supplied with the exports of package.js, the end user's API.

imports['./package.js'] = require('./package.js');

If you are watching closely, you might know that the only place this file is being required from is in package.js, which at this time is still being evaluated, frozen at line 11:

wasm = require('./package_bg');

So in package_bg.js, at line 4, both JavaScript files are still in the process of being evaluated, but both partially-evaluated files are still evaluated just enough to be required as imports for each other. Madness!

We can prove this is what's going on by simply moving some lines around in package.js. Observe:

let wasm;

module.exports.greet = function() {
  wasm.greet();
}

wasm = require('./package_bg');

module.exports.__wbg_alert_5c1dd6cac225a5d8 = function(arg0, arg1) {
  alert(getStringFromWasm0(arg0, arg1));
};

In this version, package.js requires package_bg.js before it declares the mangled alert import function to be an export, so when package_bg.js requires it in turn, it does not provide all of the WebAssembly module's necessary import functions, and instantiation fails on line 7.

This is all, and I'm going to use a nuanced technical term here, fucking bonkers.

This is not a callout post. Node.js appears to handle circular require() using mind-bending eldritch magicks, and wasm-bindgen takes advantage of it. I have committed far worse crimes. I do wonder if it could be rewritten in a way that makes it easier for the laycoder to understand without knowing the ins and outs of Node.js's module loader, but that's neither here nor there.


There's one additional bit of weirdness that does rub me the wrong way, and it is specific to wasm-bindgen.

The API exported by package.js — the one the end user is supposed to interact with — still contains the mangled __wbg_alert_5c1dd6cac225a5d8(arg0, arg1) function.

This function is meant to be invoked from within the WebAssembly module, and only with arguments corresponding to a pointer and length of a UTF-8 string allocated by the WebAssembly module. By exposing it in the end user API, it also exposes a way to read arbitrary garbage out of anywhere in it's memory.

It feels unnecessarily unsafe, forbidden double-underscore function prefix notwithstanding. The point of autogenerating glue code is so that people using the JavaScript API don't need to care about pointers. Why do this?