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!