The WebAssembly Backend: Compiling to a Sandbox

WebAssembly is the most constrained of the four targets: a stack machine with one flat array of memory, no malloc, no function pointers, and no way to touch the outside world on its own. Each thing it lacks, the compiler supplies — a bump allocator, a function table for closures, host imports for I/O. And that last constraint turns out to rhyme with the language's own idea of capabilities: in a sandbox, authority to affect the world must be handed in.

merebackendswebassemblysandboxcapabilitieslanguage-design

Three backends down, all held to the same output. The fourth has been present in every comparison but never examined on its own, and it is the strangest target of the set: WebAssembly. Where C is a language for people and LLVM IR is a form for compilers, Wasm is an instruction set for a sandbox — a machine deliberately stripped of nearly everything, so it can run untrusted code safely inside a web page. Compiling to it is the clearest illustration of the theme running through the whole part: whatever the target doesn’t give you, the compiler has to build.

A machine that lacks almost everything

Four absences define the shape of the Wasm backend, and each one is a thing the C and LLVM targets quietly provided.

There are no expressions and no registers — Wasm is a stack machine. You push 200, push 100, and an add instruction pops both and pushes the result. Code is emitted as a sequence of stack operations rather than nested expressions or SSA registers.

There is no allocator. Wasm gives you one flat array of bytes — linear memory, a single page of 64KB to start, grown as needed — and nothing else. So every structured value lives at an offset into that array, handed out by bumping a global pointer. A tuple is its elements at successive offsets; a record is its fields in declaration order; a variant is a tag and, if it carries one, a payload right after. A region is just a save and restore of the bump pointer. The whole memory model from Part II is rebuilt on top of one growable byte array.

There are no function pointers. Wasm will not let you take the address of a function. Instead, functions live in a function table and are referred to by their index in it. This changes what a closure is. On C and LLVM a closure was a two-word pair of a function pointer and an environment pointer; on Wasm it becomes a pair of an offset into linear memory (for the environment) and an index into the function table (for the code) — the same idea, but with “pointer” split in two because Wasm keeps code and data in separate worlds. Calling it is a call_indirect through the table. A plain top-level function gets a small adapter registered in the table so it, too, can be named by index and passed as a value.

And there is no I/O — no printing, no files, nothing that reaches outside the sandbox. That last absence is the most important, and it gets its own section.

The one thing Wasm makes easier

It is not all subtraction. Wasm hands back a simplification the other two backends never had: every value is a single 4-byte word. Scalars are that word directly; structured values are that word used as a memory offset. Because everything is uniformly one word wide, the backend needs no per-type layout — the monomorphization that C and LLVM had to do, emitting a separate struct for each concrete instantiation of a polymorphic type, simply isn’t necessary. One Vec implementation handles vectors of any element type; a polymorphic variant needs no specialization. The target that takes away the most also, in this one respect, asks for the least. It is a useful reminder that “more constrained” is not a single axis — each target is hard in its own places and easy in others.

The sandbox, and capabilities by necessity

The absence that matters most is I/O. A Wasm module, on its own, is a pure calculation: it can compute, but it cannot print a line, read a file, or make a network call, because it has no instructions for any of that. Anything that touches the outside world has to be a host import — a function the surrounding environment provides. Mere’s print compiles to a call to an imported puts, and the host — Node.js in testing, a browser elsewhere — receives the call, reads the string out of the module’s linear memory, and does the actual output. Reading and writing files work the same way, when the host chooses to offer them.

This is worth pausing on, because it echoes something the language decided for itself, several parts ago and for entirely different reasons. Part III built an effect system on capability passing: a function can only affect the world through authority it was explicitly handed as a value, with no ambient globals to reach for. WebAssembly enforces exactly that discipline at the level of the machine — a module has no ambient power at all, and every effect it can cause is something the host had to grant it through the import table. The language makes authority explicit by design; the sandbox makes it explicit by necessity. A program that wants to touch the world must be given the means to, whether the gate is Mere’s type system or Wasm’s import list. Two entirely separate lines of reasoning — one about verifiable programs, one about running untrusted code safely — arrive at the same shape, and compiling Mere to Wasm is where they meet.

What it unlocks

With the Wasm backend in place, the same program that ran natively through C and LLVM now also runs inside a browser, or any sandboxed Wasm host, and — verified the same way as the others, through wat2wasm and a host runner and a byte-for-byte diff — produces the identical output. That portability is not an end in itself; it is the thing that later lets the language’s own tools live on a web page, which a subsequent part will build on directly.

Four execution paths now exist for one language, feature-matched and held to identical bytes: an interpreter for the reference, C for reach, LLVM for optimization, Wasm for the sandbox. Code generation is, at this point, a solved problem for ordinary programs. What it can’t yet do is talk to code written in other languages — call a C function, or let JavaScript call back into a Mere closure running in the browser. Next: the foreign function interface, and the two very different problems of calling out to a native library and being called into from a host.

← Back to Mere: Building a Language