Chapter 15

The JavaScript API

Everything a browser or Node offers for WebAssembly hangs off one global object, WebAssembly. This chapter goes through it: compiling and instantiating, looking inside a compiled module, the objects that represent memories, tables and globals, and the exact rules for converting values between JavaScript and WebAssembly.

#Compiling and instantiating15.1

There are two steps, which you can take separately or together:

Function Takes Gives
WebAssembly.validate(bytes) bytes true or false
WebAssembly.compile(bytes) bytes a promise of a Module
WebAssembly.compileStreaming(response) a fetch response a promise of a Module
WebAssembly.instantiate(bytes, imports) bytes a promise of { module, instance }
WebAssembly.instantiate(module, imports) a Module a promise of an Instance
WebAssembly.instantiateStreaming(response, imports) a fetch response a promise of { module, instance }
new WebAssembly.Module(bytes) bytes a Module, synchronously
new WebAssembly.Instance(module, imports) a Module an Instance, synchronously

Note the two shapes of instantiate: given bytes, it resolves to an object holding both the module and the instance; given a module, it resolves to just the instance. This trips everybody up once.

In a browser, use the streaming functions. They compile the module while it is still downloading, so for a large module, compilation is mostly finished by the time the last byte arrives. They need the server to send the file as application/wasm. The synchronous constructors block the thread while compiling, and Chrome refuses to compile large modules synchronously on the main thread, so keep them for small modules, for workers, and for Node scripts like the ones in this book.

A compiled Module can be sent to a worker with postMessage, and the worker can instantiate it without compiling it again. That is how a game engine running scripts on worker threads avoids paying for compilation on every thread.

#Looking inside a module15.2

A compiled module can be asked what it imports and exports:

(module
  (import "env" "log" (func (param i32)))
  (import "env" "memory" (memory 1))
  (global (export "version") i32 (i32.const 3))
  (table (export "callbacks") 4 funcref)
  (func (export "update") (param f32)))
import fs from 'node:fs';

const module = new WebAssembly.Module(fs.readFileSync('main.wasm'));
console.log(WebAssembly.Module.imports(module));
console.log(WebAssembly.Module.exports(module));

[
  { module: 'env', name: 'log', kind: 'function' },
  { module: 'env', name: 'memory', kind: 'memory' }
]
[
  { name: 'version', kind: 'global' },
  { name: 'callbacks', kind: 'table' },
  { name: 'update', kind: 'function' }
]

A host can use this to build the imports object a module needs, or to check before instantiating that a module only asks for imports the engine is willing to give it. That is useful when the modules come from somewhere you do not fully trust, such as user-written mods for a game. The type reflection proposal adds the full types of imports and exports to this listing, but it is not yet available everywhere.

#Exported functions15.3

Each exported function is a JavaScript function. Its length is its number of parameters, and its name is its function index, as a string. Calling it converts the arguments to WebAssembly values, runs the code, and converts the results back, by the rules in section 15.5.

The same WebAssembly function, exported twice, or exported by one instance and imported and re-exported by another, is the same JavaScript function object. So functions can be compared with ===, and used as keys in a Map.

#Imported functions15.4

A JavaScript function imported into a module is called with arguments converted from WebAssembly values, and its return value is converted to the import's declared result type. The conversions are forgiving in the way JavaScript usually is:

(module
  (import "env" "get" (func $get (param i32) (result i32)))
  (import "env" "print_i32" (func $print (param i32)))
  (func (export "main")
    (call $print (call $get (i32.const 0)))
    (call $print (call $get (i32.const 1)))
    (call $print (call $get (i32.const 2)))
    (call $print (call $get (i32.const 3)))
    (call $print (call $get (i32.const 4)))))
import fs from 'node:fs';

const values = [3.7, '12', 2 ** 32 + 5, undefined, -1.5];
const env = { get: (i) => values[i], print_i32: (x) => console.log(x) };
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'), { env });
instance.exports.main();

3
12
5
0
-1

The string '12' became 12, undefined became 0, 2³² + 5 wrapped to 5, and the fractions were truncated towards zero. This is JavaScript's ToInt32, the same conversion as x | 0. It never fails, which means a host function that accidentally returns the wrong thing will not be caught at the boundary.

If a JavaScript import throws, the exception passes straight through the WebAssembly frames between it and the nearest JavaScript caller, which can catch it. The WebAssembly code in between simply stops. Chapter 20 shows how WebAssembly code can catch it instead.

#Converting values15.5

Here are the rules for each type, in both directions:

Type WebAssembly → JavaScript JavaScript → WebAssembly
i32 a Number, signed ToInt32: truncate, then wrap modulo 2³²
i64 a BigInt, signed ToBigInt64: must be a BigInt (or a string or boolean); a Number is a TypeError
f32 a Number, exactly ToNumber, then round to single precision
f64 a Number, exactly ToNumber
v128 TypeError TypeError
externref the original value, or null any value
funcref a function, or null an exported WebAssembly function, or null
GC references an opaque object, or null (see Chapter 22)

Two rows deserve emphasis. An i64 crosses as a BigInt, which is exact but slower than a Number, so an API used many times a frame should prefer i32 and f64 parameters. And a v128 cannot cross at all: a function with a v128 parameter or result can be called only from WebAssembly.

NaNs may lose their payload bits when they cross into JavaScript, which represents every NaN the same way. A module that cares about NaN payloads, such as a language that boxes values in NaNs, must not let them cross as floats; it can reinterpret them as integers first.

#Memory, Table and Global objects15.6

The other kinds of import and export are objects with constructors of their own, so a host can create them before any module exists:

const memory = new WebAssembly.Memory({ initial: 16, maximum: 1024 });
const table = new WebAssembly.Table({ initial: 8, element: 'anyfunc' });
const frame = new WebAssembly.Global({ value: 'i32', mutable: true }, 0);

A Memory has a buffer, which is replaced when the memory grows (Chapter 9), and a grow(pages) method that grows it from JavaScript. A Table has get, set, grow and length. A Global has value.

Creating these in JavaScript and importing them into a module, rather than having the module define and export them, means the host controls their sizes, and can share them between several modules or instances. Memory shared with workers must be created this way, with shared: true (Chapter 23).

#Errors15.7

The API reports three kinds of failure, each its own class:

Error Thrown when
WebAssembly.CompileError the bytes are malformed or do not validate
WebAssembly.LinkError an import is missing or has the wrong type
WebAssembly.RuntimeError the code traps (Chapter 17)

Stack overflow is a RangeError, and out-of-memory during instantiation is a RangeError as well. Exceptions thrown by imported functions are whatever was thrown. The WebAssembly.Exception class, for exceptions thrown by WebAssembly code, is the subject of Chapter 20.

#A minimal loader15.8

Putting it together, here is the shape of a loader for a game engine running in a browser, the counterpart of the book's own host:

const memory = new WebAssembly.Memory({ initial: 256 });   // 16 MiB to start

const imports = {
  env: {
    memory,
    print: (ptr, len) => console.log(readString(ptr, len)),
    now: () => performance.now(),
  },
};

const { instance } = await WebAssembly.instantiateStreaming(fetch('game.wasm'), imports);
instance.exports._initialize?.();

function frame(time) {
  instance.exports.frame(time);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

function readString(ptr, len) {
  return new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, len));
}

The memory is created by the host and imported, so the host can read strings out of it from the very first call. Initialisation is an explicit export, called after instantiation, for the reasons Chapter 8 gave. And the frame loop is driven from JavaScript, since only JavaScript can ask the browser for animation frames.

#What you have seen15.9

WebAssembly.compile and instantiate, preferably in their streaming forms, turn bytes into modules and instances. Modules can be inspected for their imports and exports, and sent to workers. Exported functions are JavaScript functions; imported JavaScript functions accept anything, converted by JavaScript's usual rules, with i64 as BigInt and v128 not allowed. Memories, tables and globals have JavaScript objects of their own, and failures are reported as CompileError, LinkError or RuntimeError.

The next chapter is about passing more than single numbers across the boundary.