Running WASM compiled as an ES6 Module with Emscripten in Node
Introduction
Emscripten is a compiler that can generate WebAssembly and JavaScript from C/C++ source files. One of the flags that Emscripten provides is the EXPORT_ES6 flag. ES6 Modules are a modern approach to organizing imports and exports in JavaScript.
WebAssembly's most obvious use cases lie in the browser. There are however numerous reasons why running WebAssembly server side is appealing, mainly due to its performance and portability. It's also quite practical to test functionality in Node before deploying to the browser.
This blog post will walk through compiling a library with simple C functions into an ES6 Module, and then running/calling this ES6 Module in Node.
This tutorial is relevant because the Emscripten devs main priority is getting WebAssembly working well in the browser, and they treat running the same WebAssembly code in Node as an afterthought. I don't fault them for this, as from what I gather it's mainly 3-5 devs actively working on Emscripten and they can't solve all the world's problems. However for this reasons there are things that don't work as you might expect and Emscripten generated ES6 Modules are one!
Getting Started
Lets start by compiling a C library increment.c
containing a single function that returns the result of incrementing
a number.
int increment(int a) {
return a + 1;
}
We can now compile this to a WASM module increment.wasm
and a JavaScript file increment.mjs
. The JavaScript
wraps the low level details of calling the WASM module. To do this we run the following command.
emcc increment.c -o increment.mjs \
-s EXPORT_ES6=1
-s MODULARIZE=1
-s EXPORT_NAME=loadWASM
-s EXPORTED_FUNCTIONS="[_increment]"
Just a heads up: We give all the JavaScript files we use in this .mjs
file extensions. If you don't do this you may run into
issues. Alternatively you can simply add "type" : "module"
to a package.json
, in which case you can leave your JavaScript files
as .js
.
The EXPORT_ES6
flag allows us to compile as an ES6 Module. MODULARIZE
must always be used in conjunction with the
EXPORT_ES6
flag. EXPORT_NAME
just allows us to set the name of our Module. Finally EXPORTED_FUNCTIONS
is needed
for specifying the compiled C functions we want to call from JavaScript. In our case this is just increment
. The leading underscore is an Emscripten naming quirk.
With this we should be able to call this ES6 Module with the increment function by using the following test.mjs
file.
import loadWASM from './increment.mjs';
const wasm_module = await loadWASM();
console.log(wasm_module._increment(12));
And then we can simply run
node test.mjs
And then everything works... Yeah no. Remember what I said about Node being an afterthought. Its times like this when that
comes into play. What actually happened when we ran node test.mjs
is this.
file:///Users/philiplassen/CS/test/emscripten/increment.mjs:162
scriptDirectory = __dirname + '/';
^
ReferenceError: __dirname is not defined in ES module scope
at file:///Users/philiplassen/CS/test/emscripten/increment.mjs:162:5
at file:///Users/philiplassen/CS/test/emscripten/test.mjs:12:27
at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
at async Loader.import (node:internal/modules/esm/loader:178:24)
at async Object.loadESM (node:internal/process/esm_loader:68:5)
The root of this problem is that many elements of the generated ES6 Module aren't actually ES6 compliant. When run in the browser
they are glossed over. An example of this is that the generated code calls __dirname
, which as the error message quite clearly
indicates is not defined in ES Modules. Once we solve this issue (WHICH WE WILL!), we also run into the issue that JavaScript
generated by Emscripten uses require
, which also isn't permitted by ES6 Modules.
As it turns out a hack to all the problems stopping us from getting working ES6 Modules is only a few lines. However if it wasn't
for this comment left by Surma
I probably would never have solved this issue. He basically suggests overriding __dirname
and require
. The only modification that I needed
to make to his suggestion was to cut off the beginning of the path because it weirdly prepends file://
to the beginning of the file path. It took
unnervingly long to figure out that tidbit.
So putting that all together, all we need are the following 4 lines.
import { dirname } from 'path';
import { createRequire } from 'module';
globalThis.__dirname = dirname(import.meta.url).substring(7);
globalThis.require = createRequire(import.meta.url);
With these 4 lines our final test.js
file looks like this.
import { dirname } from 'path';
import { createRequire } from 'module';
globalThis.__dirname = dirname(import.meta.url).substring(7);
globalThis.require = createRequire(import.meta.url);
import loadWASM from './increment.mjs';
const wasm_module = await loadWASM();
console.log(wasm_module._increment(12));
And running this we get.
$ node test.mjs
13
And just like that everything works! It's not pretty, but it works god dammit!