WebAssembly & the browser

Coming from the web stack? Hale compiles to a self-contained .wasm plus a small .mjs loader — no Emscripten, no bundler. The same locus/bus/std::* program…

Coming from the web stack? Hale compiles to a self-contained .wasm plus a small .mjs loader — no Emscripten, no bundler. The same locus/bus/std::* program you run natively can run in the browser; you choose the target at build time. The browser APIs you can’t reimplement (fetch, WebSocket, WebGL, the DOM) come in as thin host functions, and Hale functions you want the page to call go out as exports.

Building for wasm

Section titled “Building for wasm”
Terminal window
hale build client/main.hl --target wasm32

This emits client/main.wasm (self-contained — the runtime is linked in, with no libc and no external runtime) and client/main.mjs (a loader that instantiates the module and wires the host functions). A program written for the browser can say so itself:

target wasm { }

With that declaration, hale build client/main.hl builds for wasm32 with no flag at all, and hale check and the editor judge the program as wasm32 — the same cells, the same refusals. It goes at the top level of the file. A target block inside a module { } is a parse error — it is a directive for the whole program, and putting it out of reach of everything that reads it would only look like it worked.

--target always wins over the host, and hale check takes it too: hale check --target wasm32 app.hl judges an undeclared program as the browser build will. A --target that contradicts the declaration — building a target wasm program for the host — is refused at the declaration rather than quietly building the other thing. And since nothing a wasm32 build emits runs here, hale run refuses a declared program, as it refuses --target wasm32.

The browser sandbox has no syscalls, so the parts of a program that need them are refused or do nothing there; What wasm32 can do lists each one. Reach the outside world through host functions instead.

The check finds every way your program reaches a refused namespace, not just a call spelled std::io::fs::…: building a std::io::tcp::Listener { … }, calling conn.recv(64) on a std::io::tcp::Stream, calling a library’s c::stamp() whose body calls std::process::pid(), or building a library’s lib::Kid { } whose params default (or on_failure handler) calls it. Each is refused where your code makes it, with the chain that leads to the namespace:

error: `std::process` is unavailable under `target wasm`: OS process control (`std::process`) isn't available in the browser — witness: `c::stamp` → `std::process::pid`

A helper you write yourself is refused once, inside it, not at every call. A type that only names a stdlib handle (a parameter typed std::io::tcp::Stream) is fine. A call through a local is followed to the function the local holds (let f = pid; f() needs what pid needs, and the refusal’s witness names f). A call through a function value the compiler can’t pin to one function — a function-typed parameter, a local holding a field, a call’s result or an if value, a local you reassign (a loop that reassigns one anywhere inside it counts for the whole loop) — needs what any function of its type that your program passes around as a value needs: those are the only functions it can reach. What the compiler cannot see through at all — a method on a value whose type it doesn’t know, or such a call in a library locus’s params default or on_failure handler — is refused under wasm32, since it can’t promise what that call needs.

The in-process typed bus — topic / bus { publish … } / bus { subscribe … } across loci — runs under wasm exactly as it does natively: a Subject <- payload is delivered to every matching subscriber’s handler in the same module, payload-copied through the synthesized wire codec. So the idiomatic locus + topic + bus shape is fully available client-side; the transports between processes are not (see the placement and bus table below).

The @form collections — @form(vec), @form(hashmap), and @form(ring_buffer) — run under wasm too; their runtime primitives use the target-pointer-width size_t ABI, so a push / set / get / len behaves identically to native.

What wasm32 can do

Section titled “What wasm32 can do”

What a target admits is stated once, in the compiler’s capability matrix, and this section is rendered from it: the same cells the compiler consults, with the reasons it gives.

The standard library. These namespaces are refused at typecheck, with error: `std::...` is unavailable under `target wasm`: <reason>:

Refused Why Instead
std::io::tcp raw TCP sockets don’t exist in the browser; use a WebSocket bus adapter (ws://) for networking a WebSocket bus adapter (ws://)
std::io::udp raw UDP sockets don’t exist in the browser (no raw UDP in the browser)
std::io::tls raw TLS isn’t available; the browser performs TLS transparently for wss:// / https:// the browser does TLS transparently for wss:// / https://
std::io::fs, std::io::file filesystem access isn’t available in the browser sandbox; use fetch (via an @ffi("js") host import) or a bus message fetch via an @ffi("js") host import, or a bus message
std::io::h2 the HTTP/2 server is nghttp2 and a TCP listener, neither of which the wasm link reaches a WebSocket bus adapter (ws://), or an @ffi("js") host import
std::io::stdin, std::io::stdout raw terminal I/O isn’t available; use println(...) (the loader routes it to the host console) println(...) (the loader routes it to the host console)
std::term terminal control (std::term) isn’t available in the browser (no terminal in the browser)
std::process OS process control (std::process) isn’t available in the browser (no OS process control)
std::http the std::http server is built on raw TCP and isn’t available in the browser (server is built on raw TCP)
std::env the browser has no process environment: the shim’s inline getenv returns NULL configuration handed in through an @ffi("js") host import or an @export fn’s arguments
std::io::mirror a shared-memory ring is mapped with shm_open and mmap, which the browser sandbox does not have (no shared memory in the browser)
std::io::sockopt socket options are syscalls the browser sandbox does not have (no sockets in the browser)
std::io::unix AF_UNIX sockets are syscalls the browser sandbox does not have a WebSocket bus adapter (ws://), or an @ffi("js") host import
std::ring a shared-memory ring is mapped with shm_open and mmap, which the browser sandbox does not have (no shared memory in the browser)
std::time the browser module has no clock of its own: the shim’s inline clock_gettime writes zero and sleep’s clock_nanosleep is an import stubbed to 0, so a read is always the epoch and a sleep never waits a host clock (performance.now, Date.now) or timer through an @ffi("js") host import
std::ts the tree-sitter parser is a native static library the wasm link never reaches parsing on the host, through an @ffi("js") host import

Every other namespace is available: std::api, std::bus, std::bytes, std::bytes::builder, std::cli, std::compress, std::crypto, std::decimal, std::diag, std::io, std::iter, std::json, std::lang, std::log, std::math, std::metrics, std::name, std::os, std::rand, std::regex, std::secret, std::shm, std::source, std::str, std::tagged, std::tar, std::test, std::text, std::text::base64, std::yaml.

Placement and the bus.

Construct Under wasm32 Why
a pinned placement refused: placement entry `<field>`: `pinned` is not available under `target wasm` — a pinned locus owns a thread of its own, and the wasm32 module runs on its host's one thread (the loader stubs `pthread_create` with `() => 0`); place it `cooperative` (pool `main`) the wasm32 module runs on its host’s one thread (the loader stubs pthread_create with () => 0)
cooperative(pool = X), X other than main refused: placement entry `<field>`: a cooperative pool other than `main` is not available under `target wasm` — its workers are threads, and the wasm32 module runs on its host's one thread (the loader stubs `pthread_create` with `() => 0`); place it `cooperative` (pool `main`) the wasm32 module runs on its host’s one thread (the loader stubs pthread_create with () => 0)
where async_io refused: placement entry `<field>`: `async_io` pools aren't supported on wasm32 — use a cooperative pool on `main` (drop `where async_io` and the pool). (the runtime's LOTUS_HAVE_ASYNC_IO is 1 over epoll and eventfd imports the loader stubs with `() => 0`, and the pool's workers are threads the module does not have.) the runtime’s LOTUS_HAVE_ASYNC_IO is 1 over epoll and eventfd imports the loader stubs with () => 0, and the pool’s workers are threads the module does not have
a unix(...) binding refused: bindings entry `<Topic>`: this transport is not available under `target wasm` — the browser sandbox has no AF_UNIX sockets; keep the topic in-process, or reach the host through an `@ffi("js")` host import the browser sandbox has no AF_UNIX sockets
a shm_ring(...) binding refused: bindings entry `<Topic>`: this transport is not available under `target wasm` — the browser sandbox has no shared memory to map; keep the topic in-process, or reach the host through an `@ffi("js")` host import the browser sandbox has no shared memory to map
an adapter binding (T: MyAdapter { ... }) refused: bindings entry `<Topic>`: this transport is not available under `target wasm` — an adapter's instance runs on a thread of its own, which the wasm32 module does not have; keep the topic in-process, or reach the host through an `@ffi("js")` host import an adapter’s instance runs on a thread of its own, which the wasm32 module does not have
or wait on an on_full: fail topic admitted the publisher spins on its own pump (the 1 ms nap is the shim’s inline no-op) until the queue empties, or wait-abort ends the wait

Linking. [ffi] link is refused:

[ffi] link = [...] cannot be satisfied on wasm32 — there are no system dynamic libraries to link against. Provide the code as [ffi] csrc so it can be compiled into the module, or gate the dependency out of the wasm build.

Exports and the entry. The module exports a wrapper per @export fn, and for an @export locus _hale_start and a wrapper per method that is not fallible, beside its fixed exports: main (when defined), __heap_base, memory (when defined), lotus_wasm_alloc, lotus_wasm_set_inbox.

Calling the host: @ffi("js")

Section titled “Calling the host: @ffi("js")”

@ffi("js") is the wasm sibling of @ffi("c"): it declares a function the JavaScript loader provides.

target wasm { }
@ffi("js") fn console_log(msg: String);
@ffi("js") fn draw_line(x1: Float, y1: Float, z1: Float,
x2: Float, y2: Float, z2: Float);

Marshalling: Float and Int both arrive as a plain JS number — an @ffi("js") Int crosses as f64, not a BigInt, so your host handler gets a number with no Number(x) step, and an Int-returning import takes a plain number back. (The one caveat is f64’s range: Ints beyond 2^53 lose precision across this boundary — send those as a String/Bytes payload. And this applies to @ffi("js") only; @ffi("c") keeps i64.) String/Bytes arrive as a pointer the loader reads out of wasm memory. The loader ships a built-in console_log and the libm set (so std::math just works); your page supplies the rest through run(glue):

import { run } from "./main.mjs";
const inst = await run((h) => ({
draw_line: (x1,y1,z1,x2,y2,z2) => { /* push to a WebGL buffer */ },
}));

Only the wasm32 loader can supply a js import, so a native build of a program that declares one is refused at the declaration — even if nothing calls it. Keep browser imports in the browser build.

If you instantiate the module yourself instead of through the generated loader, your imports object has to supply every env import the module declares (WebAssembly.Module.imports(mod) lists them): the console writers and libm set the loader ships, your @ffi("js") functions, and dprintf and fflush, which the generated loader stubs with () => 0. The runtime’s thread and socket calls are not linked into a wasm32 module, so they are not among them.

The loader has no stderr writer yet: eprintln lowers to dprintf, so under the generated loader it prints nothing, and a closure violation no on_failure absorbs stops the module (a trap) without its message. Use println or a console_log for diagnostics you need to see.

Letting the host call you: @export + the app locus

Section titled “Letting the host call you: @export + the app locus”

To run a game loop or react to network messages, the host needs to call into Hale. The browser-client shape is an @export locus — the persistent “app” of your program:

@export locus Client {
params { sx: Float = 0.0; sy: Float = 0.0; ready: Bool = false; }
birth() { }
fn on_message() { /* parse an inbound frame, update fields */ }
fn frame() { /* render from the fields */ }
}

Each fn method becomes a wasm export the page calls by name (inst.exports.frame()). State lives in the locus’s fields and persists across calls — on_message() writes self.sx, frame() reads it, just like a native locus. On the native target an @export locus is an ordinary locus. (There is also a lower-level @export fn for free functions — same export, but stateless; see below. On a native target an @export fn becomes an unmangled C symbol instead, for C code that calls back into Hale.) A fallible method stays internal and is not exported (the host has no error channel), and the locus must not define run() — the host drives it.

The run-model: entry inversion

Section titled “The run-model: entry inversion”

A native program blocks in main. A browser program can’t — it must return to the event loop so the page stays responsive. So a program built with @export runs inverted: there is no main, and the host drives the exports (typically frame() once per requestAnimationFrame).

The compiler synthesizes an exported _hale_start() that sets up a persistent program arena and instantiates your @export locus (running birth). The loader calls it once at startup; after that the page drives the methods:

const inst = await run(glue); // _hale_start ran here (Client is alive)
function tick() {
inst.exports.frame();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

A program made of @export declarations needs no fn main at all — as long as it’s built for wasm32. Checked or built for the host, it has no entry point, and the check says so at its first @export: declare target wasm { }, or pass --target wasm32.

Quick wasm from a bare fn main: --wrap-main

Section titled “Quick wasm from a bare fn main: --wrap-main”

A wasm program needs an @export entry — but a script, a tutorial snippet, or anything pasted into the browser playground is just a fn main. The --wrap-main build flag bridges that gap:

Terminal window
hale build snippet.hl --target wasm32 --wrap-main

When the program has a top-level fn main() and no @export entry, --wrap-main synthesizes — on the parsed AST, before typecheck — the equivalent of:

target wasm { }
@export locus __Main { birth() { <main's body> } }

so main’s body runs once at _hale_start, exactly as it would run once natively. Because it works on the AST, not the source text:

It is wasm-only and opt-in: it requires a wasm32 build — --target wasm32 (or wasm32-unknown-unknown), or a target wasm { } the source itself declares — since there is no native entry-inversion to wrap; on a native build it errors. The declaration it injects doesn’t count: hale build --wrap-main snippet.hl alone is still refused. It’s never implied — a normal wasm program may legitimately keep a bare fn main exported as main. If the program already declares an @export entry, --wrap-main leaves it untouched (prefer-explicit). This is the one flag the browser playground passes so it can hand the compiler raw user source and surface errors on the exact line.

Inbound messages

Section titled “Inbound messages”

The page hands network bytes to Hale through the inbox: write them into wasm memory, publish the length, then call a method.

// JS: hand a WebSocket frame to Hale, then notify it
const bytes = new TextEncoder().encode(ev.data);
const ptr = inst.exports.lotus_wasm_alloc(bytes.length);
new Uint8Array(inst.exports.memory.buffer).set(bytes, ptr);
inst.exports.lotus_wasm_set_inbox(bytes.length);
inst.exports.on_message();
target wasm { }
@ffi("c") fn lotus_wasm_inbox() -> Bytes; // the bytes JS wrote
@export locus Client {
params { sx: Float = 0.0; sy: Float = 0.0; ready: Bool = false; }
fn on_message() {
let msg = lotus_wasm_inbox();
if len(msg) > 0 {
let s = std::str::from_bytes(msg);
// ... std::json parse, then store into self.* ...
self.ready = true;
}
}
fn frame() { /* render from the fields */ }
}

This is the full pattern for a browser client: the page owns the transport (fetch / WebSocket) and the GL context; the @export locus parses the protocol with std::json, holds the game state in its fields, runs the camera, and emits geometry — the same code shape it would have natively.

Lower-level: @export fn + the state cell

Section titled “Lower-level: @export fn + the state cell”

If you don’t want a locus, you can export free functions (@export fn frame()). These are stateless — each call’s allocations are released on return — so cross-call state must be parked in the runtime state cell, packed into Bytes:

@ffi("c") fn lotus_wasm_state_set(b: Bytes);
@ffi("c") fn lotus_wasm_state_get() -> Bytes;

The @export locus model is preferred for anything with state; the state cell exists for the free-fn path and for hand-rolled layouts.

See spec/ffi.md § WASM host interface for the exact marshalling and diagnostic rules.

C from a package

Section titled “C from a package”

If a package ships C alongside its Hale — a [ffi] csrc list in its hale.toml — those sources are compiled for wasm32 and linked into your module, the same as the runtime’s own C.

Two things are different here from a native build, and both will stop you rather than surprise you later.

The wasm build is freestanding. There is no libc sysroot: the runtime compiles against a small forward-declared shim, not wasi-sdk. A C file that #includes <string.h> builds natively and does not build for wasm. You get a build error naming the file.

link = [...] doesn’t exist here. It is an error, not a silent omission; What wasm32 can do gives its wording and the alternative.

One footgun worth knowing before you write the C. Hale’s Int is 64-bit, so the declaration must be long long:

long long tsa_answer(long long n) { return n * 6; } /* right */
int tsa_answer(int n) { return n * 6; } /* links, then traps */

The second one links and then fails at the call with a wasm signature_mismatch. That’s abrupt, but it is the good outcome: on a native target the same mismatch quietly truncates instead.

(Before Hale 0.13, package csrc was skipped for wasm entirely and those symbols became stubs returning 0 — a build that looked fine and a program that silently did nothing. If you have a workaround supplying them from JS, it can go.)