Skip to content

Tools

WebAssembly

Brine programs compile to WebAssembly as well as to native executables, with the same checks and the same results. --target wasm makes a .wasm module for WASI runtimes, and brine run and brine test run it with Node.js. --target web makes a folder a browser opens: a page, a small loader (brine.js) and the module. Both need wasi-sdk. WebAssembly runs on one thread, so par constructs run in order (with the same result bits), and there are no processes, sockets or GPU.

Terminal window
brine run --target wasm collatz.br # build a .wasm module and run it with Node.js
brine build --target wasm collatz.br # collatz.wasm, for any WASI runtime
brine build --target web collatz.br -o site # site/: index.html, brine.js, program.wasm

WebAssembly builds compile the generated C with the clang and C library of wasi-sdk, not with your system C compiler. Download the archive for your platform, unpack it, and set WASI_SDK_PATH to its folder:

Terminal window
export WASI_SDK_PATH=~/tools/wasi-sdk-34.0-x86_64-linux

Brine looks for $WASI_SDK_PATH/bin/clang, then for /opt/wasi-sdk/bin/clang, so unpacking it to /opt/wasi-sdk also works. Without either, a WebAssembly build stops with E0702:

error[E0702]: no WebAssembly C toolchain found
= note: brine builds WebAssembly with the clang and C library of wasi-sdk
= help: download wasi-sdk from https://github.com/WebAssembly/wasi-sdk/releases and set WASI_SDK_PATH to its folder
= run `brine explain E0702` for more about this error
error: could not compile due to 1 error

To run modules, brine run --target wasm and brine test --target wasm also need Node.js on your PATH (Brine’s CI tests with Node 24), or another WASI runtime in BRINE_WASM_RUNNER. brine doctor shows what it found on its last line:

✓ WebAssembly C:/Users/you/tools/wasi-sdk-34.0-x86_64-windows\bin\clang.exe

Without wasi-sdk it reads:

- WebAssembly no wasi-sdk (optional, for `--target wasm`; set WASI_SDK_PATH)

WebAssembly is optional, so a missing wasi-sdk doesn’t make brine doctor fail.

This program uses par for a map and two reductions:

collatz.br
fn collatz_len(n: int) -> int:
var x = n
var steps = 0
while x != 1:
x = x / 2 if x % 2 == 0 else 3 * x + 1
steps += 1
return steps
fn main():
let a = args()
let n = int(a[0]) if len(a) > 0 else 1000
let lens = par [collatz_len(i) for i in 1..=n]
let total = par sum(float(l) for l in lens)
let longest = par max(l for l in lens)
print(f"{os.name()} {os.arch()}, n = {n}")
print(f"average steps: {total / float(n):.2f}, longest: {longest}")
Terminal window
brine run --target wasm collatz.br -- 1000000
Built collatz [dev, wasm] in 0.15s
Running C:/Users/you/AppData/Local/Temp/brine-cache/07c4ee958925d33a/dev-wasm/collatz.wasm
wasi wasm32, n = 1000000
average steps: 131.43, longest: 524

The native build prints the same numbers (windows x86_64, n = 1000000 on the first line). os.name() is "wasi" and os.arch() is "wasm32" in a WebAssembly build.

brine run runs the module with a small script around Node’s node:wasi module. The program gets its arguments and the environment, and the current folder is opened for it, so relative paths (read_file("data.txt"), write_file("out.txt", ...)) work as they do natively. Nothing outside the current folder is reachable: read_file("/etc/hosts") returns Err("cannot read /etc/hosts: No such file or directory"). The exit code is the program’s, 101 after a panic, as for a native run:

Built panic [dev, wasm] in 0.11s
Running C:/Users/you/AppData/Local/Temp/brine-cache/b2e1b9ea98c70c0b/dev-wasm/panic.wasm
panic: index 5 is out of bounds for an array of length 3
--> panic.br:4:11

A crash of the module itself (a WebAssembly trap) prints WebAssembly trap: ... and exits with 134. See Deep recursion for the common case.

brine test --target wasm builds the tests as one module and runs each test in its own Node.js process, as a native brine test does with executables. The output is the same:

Built tests [dev, wasm] in 0.15s
test double doubles ... ok
test double of zero ... FAILED
---- double of zero ----
panic: assertion failed: double(0) == 1
--> t.br:8:5
test result: FAILED. 1 passed; 1 failed; finished in 0.04s

brine build --target wasm writes a module for WASI preview 1 (wasm32-wasip1), so any WASI runtime can run it. To have brine run and brine test use one instead of Node.js, set BRINE_WASM_RUNNER to its command; Brine adds the module’s path and the program’s arguments after it:

Terminal window
BRINE_WASM_RUNNER="wasmtime run --dir=." brine run --target wasm collatz.br -- 1000000

The value is split at spaces (no quoting). Give the runtime access to the current folder (--dir=. for wasmtime) if the program reads or writes files. Without Node.js and without BRINE_WASM_RUNNER, brine run --target wasm builds the module and then stops:

error: running WebAssembly needs Node.js (https://nodejs.org) or a WASI runtime in BRINE_WASM_RUNNER (say `wasmtime run --dir=.`)
Terminal window
brine build --target web collatz.br -o site
Built collatz [dev, web] in 0.15s
Output site
Open its index.html over HTTP, e.g. `python3 -m http.server -d site` and http://localhost:8000

The output is a folder you can put on any static host:

File What it is
program.wasm the program
brine.js the loader: the WASI calls a Brine program makes, for the browser (about 6 KB)
index.html a starter page that runs the program and shows what it prints, stderr in red, with a Run again button

Serve the folder over HTTP: browsers don’t load WebAssembly from file:// pages.

Terminal window
python3 -m http.server -d site # then open http://localhost:8000

Where the folder goes:

Build Output
project, no -o target/<profile>-web/<name>-web/ (target/dev-web/demo-web/)
single file, no -o <name>-web/ in the current directory (collatz-web/)
-o path path/

index.html is a starting point. Replace it with your own page: a rebuild writes program.wasm and brine.js again but leaves an existing index.html alone. Other files you add to the folder (scripts, styles, images) are left alone too. The build’s change stamp lives in the build cache, not in the folder, so the folder holds only what you deploy.

brine.js exports one function:

import { run } from "./brine.js";
const code = await run("program.wasm", {
args: ["1000000"],
env: { MODE: "fast" },
stdout: (text) => console.log(text),
stderr: (text) => console.error(text),
});
Argument Meaning
wasm the module: a URL (string or URL), a Response, the bytes, or a compiled WebAssembly.Module
options.args the program’s arguments, what args() returns (default: none)
options.env an object of environment variables, what getenv sees (default: none)
options.stdout called with text the program prints (default: console.log)
options.stderr called with text the program writes to stderr, panics included (default: console.error)

run resolves to the exit code: 0 when main returns normally, the code the program exits with (exit(code), or the value an int main returns), 1 when main returns Err, and 101 after a panic. The panic message arrives through stderr. If the module traps (for example, it runs out of stack), the promise rejects with the engine’s error instead.

Output arrives as text whenever the program flushes it, so one call can hold several lines, or part of one. Append it to what you have; don’t treat each call as a line.

To run a program more than once, compile it once and pass the WebAssembly.Module. Each run starts a fresh instance:

const module = await WebAssembly.compileStreaming(fetch("program.wasm"));
await run(module, { args: ["10"] });
await run(module, { args: ["20"] });

What the loader provides, and what it doesn’t:

  • No files. No folder is opened for the program, so opening any file fails: read_file("data.txt") returns Err("cannot read data.txt: No such file or directory"), write_file fails the same way, and file_exists is false. Pass input through args or env instead.
  • Empty standard input. Reading stdin gives end-of-file at once.
  • os.sleep spins. The loader has no way to pause a thread, so a sleep busy-waits until its time is up. It keeps the CPU busy and, on the main thread, freezes the page.
  • Clocks come from the browser’s performance.now() and Date.now().

run calls the module’s _start and returns only when the program has finished. Until then, the thread it runs on does nothing else. On a page’s main thread, that means no clicks, no scrolling and no repaints: in a test, a 10 ms timer didn’t fire once during a 0.5 s run. Anything that takes more than a moment, or sleeps, belongs in a Web Worker.

A worker that runs the program once per message and sends its output back:

site/worker.js
import { run } from "./brine.js";
// Each message runs the program once: postMessage({ args: [...] })
self.onmessage = async (e) => {
try {
const code = await run("program.wasm", {
args: e.data.args,
stdout: (text) => self.postMessage({ stdout: text }),
stderr: (text) => self.postMessage({ stderr: text }),
});
self.postMessage({ exit: code });
} catch (err) {
// a trap, such as running out of stack
self.postMessage({ error: String(err) });
}
};

A page that starts it. Save it as the folder’s index.html; rebuilds keep it:

site/index.html
<!doctype html>
<meta charset="utf-8">
<pre id="out"></pre>
<script type="module">
const out = document.getElementById("out");
const worker = new Worker("worker.js", { type: "module" });
worker.onmessage = (e) => {
const m = e.data;
if ("exit" in m) out.append(`[exit code ${m.exit}]\n`);
else out.append(m.stdout ?? m.stderr ?? m.error);
};
worker.postMessage({ args: ["1000000"] });
</script>

With collatz.br built into site/ and the folder served over HTTP, the page shows:

Output (Chrome)
wasi wasm32, n = 1000000
average steps: 131.43, longest: 524
[exit code 0]

The worker is a module worker (type: "module"), because brine.js is an ES module. "program.wasm" resolves against the worker script’s URL, so keep worker.js in the same folder.

The same source compiles for WebAssembly with no changes. Some things the platform doesn’t have, and each fails with a message that says so.

WebAssembly builds run on one thread. par for, par [...], par reductions and par(...) forks run in order on that thread. Because reductions combine in a fixed order that doesn’t depend on the thread count, the results are the same bits as a native run on any number of cores. Only the speed differs.

os.cpu_count() returns 1. --threads and BRINE_THREADS have no effect.

WebAssembly builds contain no GPU code, so every par construct runs on the CPU. With BRINE_GPU_LOG=1 the reason is printed:

brine gpu: gpu.br:3:14 par map n=1000000 -> cpu 0.734 ms (not GPU-eligible: WebAssembly has no GPU)
brine gpu: gpu.br:4:17 par reduction n=1000000 -> cpu 1.282 ms (not GPU-eligible: WebAssembly has no GPU)

A world program runs as a single rank: world.size() is 1, and collectives such as world.sum return the rank’s own value. --ranks is refused:

Terminal window
brine run --target wasm --ranks 2 world.br
error: `--ranks` needs native processes; WebAssembly programs run as one rank

A module started with BRINE_WORLD_SIZE above 1 stops with exit code 2:

brine: `world` programs can't run with more than one rank on WebAssembly

run, os.spawn and os.free_ports return Err values, which the program can handle like any other failure:

Err("cannot run git: WebAssembly programs can't start processes")
Err("WebAssembly programs have no network ports")

os.pid() returns 1.

Under brine run --target wasm (Node.js), the program can read and write files in the current folder and below, and nothing else. In the browser, there are no files.

The engine that runs WebAssembly (V8, in Node.js and Chrome) limits the call stack, and that limit is reached long before a native program’s. This function, called with 15,000 levels of recursion, traps under Node.js 24 where a native build doesn’t:

deep.br
fn depth(n: int) -> int:
if n == 0:
return 0
return 1 + depth(n - 1)
fn main():
print(depth(int(args()[0])))
Built deep [dev, wasm] in 0.11s
Running C:/Users/you/AppData/Local/Temp/brine-cache/b3a8a1c856a7f000/dev-wasm/deep.wasm
WebAssembly trap: Maximum call stack size exceeded

The exit code is 134. In the browser, run rejects with RangeError: Maximum call stack size exceeded. Rewrite deep recursion as a loop if it must run on WebAssembly.

Command Error
brine run --target web a web build runs in a browser: build it with `brine build --target web` and open its index.html
brine test --target web tests run with `--target wasm` (Node.js), not in a browser
brine build --target arm unknown target `arm` (use host, wasm or web)

brine debug has no --target: debugging is native only. Each of these exits with 2.

WebAssembly builds always go through the C backend: the native x86-64 and AArch64 backends write machine code, not WebAssembly. The profiles keep their C flags:

Profile Command C compile Output in a project
dev --target wasm -O2, split into units compiled in parallel target/dev-wasm/<name>.wasm
release --target wasm --release -O3, one unit target/release-wasm/<name>.wasm
debug --target wasm --debug -O0 -g target/debug-wasm/<name>.wasm

--target web uses the same flags, with -web folders (target/release-web/). A single file without -o builds <name>.wasm in the current directory. With --emit-c, the generated C is written next to the output, as for a native build.

Because every profile goes through C, a dev build for WebAssembly is slower to build than a native dev build on x86-64 or AArch64, but still takes a fraction of a second for a small program once the runtime is cached.

A module carries the Brine runtime, so even a program that only prints hi is about 400 KB (about 130 KB with gzip, which static hosts usually apply). collatz.br is 428 KB in a dev build and 429 KB in a release build: release makes the code faster, not smaller. A debug build is about 640 KB.

  • Through C and wasi-sdk. Brine generates the same C it generates for a native C build and compiles it, and the runtime (brine_rt.c), with wasi-sdk’s clang for wasm32-wasip1. The module gets an 8 MB stack for Brine code, like a native main thread. Everything before code generation is unchanged: the type checker and the prover see the same program, so a program that checks natively checks for WebAssembly.
  • The 64-bit memory layout on wasm32. wasm32 has 4-byte pointers, but Brine’s generated code and runtime lay memory out as on a 64-bit machine. Each pointer, in a struct field or an array element, takes an 8-byte slot, with the pointer in its low half. So the IR, the prover and the native backends don’t change, and on 64-bit platforms the code compiles exactly as before. int is 64 bits everywhere, and i128/u128 work as they do natively.
  • One thread in the runtime. Locks do nothing, worker threads are never started, and the thread pool has one thread, so every par construct runs on the calling thread.
  • Two loaders. brine run uses a short Node.js script around node:wasi, written to the build cache. --target web uses brine.js, which implements the WASI calls a Brine program makes for the browser. Calls it doesn’t implement (files, sockets) return an error code, which the program sees as an Err.

Threads on WebAssembly (wasi-threads and Web Workers, so par runs in parallel there too), files in the browser, and a playground page that runs the compiler itself in the browser are planned.

  • --target wasm builds a WASI module; brine run and brine test run it with Node.js, or with BRINE_WASM_RUNNER.
  • --target web builds a folder with index.html, brine.js and program.wasm. Rebuilds keep your index.html.
  • Both need wasi-sdk: set WASI_SDK_PATH.
  • run(wasm, { args, env, stdout, stderr }) resolves to the exit code (101 after a panic). Run long programs in a Web Worker.
  • One thread (par runs in order, same bits), one world rank, no processes, sockets or GPU, and no files in the browser.