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.
brine run --target wasm collatz.br # build a .wasm module and run it with Node.jsbrine build --target wasm collatz.br # collatz.wasm, for any WASI runtimebrine build --target web collatz.br -o site # site/: index.html, brine.js, program.wasmWebAssembly 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:
export WASI_SDK_PATH=~/tools/wasi-sdk-34.0-x86_64-linuxBrine 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 errorerror: could not compile due to 1 errorTo 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.exeWithout 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.
Run a program with –target wasm
Section titled “Run a program with –target wasm”This program uses par for a map and two reductions:
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}")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.wasmwasi wasm32, n = 1000000average steps: 131.43, longest: 524The 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:11A 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.15stest double doubles ... oktest 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.04sOther WASI runtimes
Section titled “Other WASI runtimes”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:
BRINE_WASM_RUNNER="wasmtime run --dir=." brine run --target wasm collatz.br -- 1000000The 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=.`)Build for the browser with –target web
Section titled “Build for the browser with –target web”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:8000The 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.
python3 -m http.server -d site # then open http://localhost:8000Where 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/ |
Rebuilding keeps your page
Section titled “Rebuilding keeps your page”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.
The run function
Section titled “The run function”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")returnsErr("cannot read data.txt: No such file or directory"),write_filefails the same way, andfile_existsisfalse. Pass input throughargsorenvinstead. - Empty standard input. Reading stdin gives end-of-file at once.
os.sleepspins. 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()andDate.now().
Run it in a Web Worker
Section titled “Run it in a Web Worker”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:
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:
<!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:
wasi wasm32, n = 1000000average 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.
Limits
Section titled “Limits”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.
One thread
Section titled “One thread”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.
No GPU
Section titled “No GPU”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)One world rank
Section titled “One world rank”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:
brine run --target wasm --ranks 2 world.brerror: `--ranks` needs native processes; WebAssembly programs run as one rankA 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 WebAssemblyNo processes or sockets
Section titled “No processes or sockets”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.
Deep recursion
Section titled “Deep recursion”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:
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 exceededThe 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.
Commands
Section titled “Commands”| 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.
Profiles and size
Section titled “Profiles and size”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.
How it works
Section titled “How it works”- 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 forwasm32-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.
intis 64 bits everywhere, andi128/u128work 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
parconstruct runs on the calling thread. - Two loaders.
brine runuses a short Node.js script aroundnode:wasi, written to the build cache.--target webusesbrine.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 anErr.
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.
Summary
Section titled “Summary”--target wasmbuilds a WASI module;brine runandbrine testrun it with Node.js, or withBRINE_WASM_RUNNER.--target webbuilds a folder withindex.html,brine.jsandprogram.wasm. Rebuilds keep yourindex.html.- Both need wasi-sdk: set
WASI_SDK_PATH. run(wasm, { args, env, stdout, stderr })resolves to the exit code (101after a panic). Run long programs in a Web Worker.- One thread (
parruns in order, same bits), oneworldrank, no processes, sockets or GPU, and no files in the browser.