WebAssembly & the browser
Coming from the web stack? Hale compiles to a self-contained
.wasmplus a small.mjsloader — 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
hale build client/main.hl --target wasm32
This emits client/main.wasm (self-contained — a tiny bundled
libc, no external runtime) and client/main.mjs (a loader that
instantiates the module and wires the host functions). The program
declares the target so the typechecker can gate the parts of the
standard library that need syscalls:
target wasm { }
Under target wasm, the portable stdlib works as usual
(std::str, std::bytes, std::json, std::math, …), but the
POSIX-backed namespaces (std::io::tcp, std::process,
std::http, …) are rejected at typecheck — the browser sandbox has
no syscalls. Reach the outside world through host functions instead.
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. Only the cross-process / network transports
(shm_ring, unix, CONNECT-role bindings) are unavailable in the
sandbox — those need syscalls. So the idiomatic locus + topic + bus
shape is fully available client-side.
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.
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 */ },
}));
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
@export is a no-op. (There is also a lower-level @export fn for
free functions — same export, but stateless; see below.) Methods
may not be fallible (the host has no error channel), and the locus
must not define run() — the host drives it.
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.
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:
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:
- diagnostics keep the user’s line/col — the synthesized locus
borrows
main’s spans and the body is moved intact, so a type error on the user’s line 3 is reported on line 3 (a textual wrap would shift every following line); - it’s string/comment-safe — the real lexer found the body, so a
{or}inside a string literal or comment can’t mis-wrap it; - the
target wasmgate is injected too, so the syscall-backed stdlib (std::io::tcp,std::process, …) is rejected with a precise diagnostic, on untouched source.
It is wasm-only and opt-in: it requires --target wasm32 (there is
no native entry-inversion to wrap, so it errors on a native build), and
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
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();
@ffi("c") fn lotus_wasm_inbox() -> Bytes; // the bytes JS wrote
// inside the Client locus:
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;
}
}
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
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.