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
.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
Section titled “Building for wasm”hale build client/main.hl --target wasm32This 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] csrcso 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.
- A program with
@exports and nofn main: admitted — an export-only module: the host drives it through its exports. --wrap-mainon a barefn main: admitted — a barefn mainis wrapped as the module’s@exportentry.- An
@export locusthat definesrun(): refused, with@export locus `L` must not define `run()` — a wasm singleton is host-driven via its `@export` methods, not a cooperative run loop.
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:
hale build snippet.hl --target wasm32 --wrap-mainWhen 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 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 itconst 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.)