WebAssembly & the Browser
Hale compiles directly to a self-contained .wasm module and an ES module loader (.mjs) without requiring Emscripten, wasi-sdk, or external bundlers. The in-process locus model, typed message bus, and @form collections run in the browser unchanged, while @ffi("js") imports and @export declarations bridge browser APIs (fetch, WebSocket, WebGL, DOM) and the JavaScript event loop.
target wasm { }@ffi("js") fn console_log(msg: String);
@export locus Client { params { ticks: Int = 0; } birth() { console_log("client initialized"); } fn frame() { self.ticks = self.ticks + 1; }}Building for wasm32
Section titled “Building for wasm32”Pass --target wasm32 to hale build or declare target wasm { } at the top level of your source file:
hale build client/main.hl --target wasm32This produces two files:
client/main.wasm: A self-contained WebAssembly binary with the Hale arena runtime linked in and zero external libc dependencies.client/main.mjs: A JavaScript ES module loader that instantiates the WebAssembly module and binds host imports.
Declaring target wasm { } in Source
Section titled “Declaring target wasm { } in Source”Adding target wasm { } at the top level of a file marks the program as a WebAssembly target permanently:
| Command | Behavior with target wasm { } |
|---|---|
hale build client/main.hl |
Compiles for wasm32 automatically without --target wasm32. |
hale check client/main.hl |
Validates the program against the wasm32 capability matrix. |
hale build --target native ... |
Rejected at compile time (contradicts the in-source target wasm directive). |
hale run client/main.hl |
Rejected (wasm32 binaries execute in a WebAssembly host, not directly on the OS). |
Transitive Capability Checking
Section titled “Transitive Capability Checking”Because the browser sandbox provides no POSIX syscalls, OS-dependent standard library modules are rejected at compile time (see What wasm32 can do). hale check traces reachability across the entire call graph and reports a witness chain for any disallowed capability:
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`- Direct and indirect calls: Constructing a refused locus (
std::io::tcp::Listener { ... }), calling a method on a handle (conn.recv(64)), calling an imported library function, or instantiating a library locus whoseparamsdefault oron_failurehandler reaches a refused namespace all fail with a full witness path. - Function values: Local function bindings (
let f = pid; f()) trace to the bound target. Calls through dynamic function values (parameters, struct fields, call results,ifexpressions, or locals reassigned in a loop) require that every function of that signature passed as a value in the program is valid onwasm32. - Type-only references: Naming a handle type in a signature (such as a parameter typed
std::io::tcp::Stream) without invoking its methods is permitted.
Supported Runtime Features on wasm32
Section titled “Supported Runtime Features on wasm32”- In-process bus:
topic,bus { publish ... }, andbus { subscribe ... }work identically to native builds within the WebAssembly module. @formcollections:@form(vec),@form(hashmap), and@form(ring_buffer)use the target-pointer-widthsize_tABI and behave identically to native builds.
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::hex, std::io, std::iter, std::json, std::json::Value, std::lang, std::log, std::math, std::metrics, std::name, std::observe, 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::url, 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 JavaScript Host Functions (@ffi("js"))
Section titled “Calling JavaScript Host Functions (@ffi("js"))”Use @ffi("js") on a bodiless top-level function to declare an import supplied by the JavaScript host environment:
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);Type Marshalling Across @ffi("js")
Section titled “Type Marshalling Across @ffi("js")”| Hale Type | JavaScript Representation | Notes |
|---|---|---|
Float |
number (f64) |
Passed directly by value. |
Int |
number (f64, not BigInt) |
Crosses as an IEEE-754 number so JS handlers require no Number(x) cast. Values exceeding $2^{53}$ lose precision; pass 64-bit IDs as String or Bytes. (@ffi("c") retains i64.) |
String / Bytes |
Pointer into linear memory | Automatically decoded/encoded by the generated .mjs loader. |
The generated .mjs loader provides console_log and the libm math functions (std::math) out of the box. Supply your own @ffi("js") imports by passing a factory to run(glue):
import { run } from "./main.mjs";const inst = await run((h) => ({ draw_line: (x1, y1, z1, x2, y2, z2) => { /* push vertices to WebGL buffer */ },}));Host Loader & Instantiation Notes
Section titled “Host Loader & Instantiation Notes”- Wasm-only: Declaring an
@ffi("js")function in a native build is rejected at compile time, even if the function is never called. - Custom instantiation: If you instantiate the
.wasmmodule directly instead of using the generated.mjsloader, yourenvimports object must provide every symbol listed byWebAssembly.Module.imports(mod): the console writers, thelibmmath symbols, your@ffi("js")functions, anddprintf/fflush(stubbed as() => 0by the default loader). Thread and socket symbols are not linked intowasm32modules. - Console output (
printlnvs.eprintln): The generated loader routesprintlnto the browser console, but stubsdprintf(() => 0). As a result,eprintlnproduces no output and unhandled closure violations trap without printing a message. Always useprintlnor@ffi("js") fn console_logfor browser diagnostics.
Exporting to JavaScript (@export locus and Entry Inversion)
Section titled “Exporting to JavaScript (@export locus and Entry Inversion)”Because browser applications cannot block the main thread inside a main() loop, interactive WebAssembly modules use entry inversion: the module exports a stateful @export locus whose methods are driven by browser callbacks (such as requestAnimationFrame or WebSocket listeners):
@export locus Client { params { sx: Float = 0.0; sy: Float = 0.0; ready: Bool = false; } birth() { } fn on_message() { /* parse inbound frame, update self.* */ } fn frame() { /* render current state */ }}When compiling an @export locus for wasm32:
- The compiler synthesizes
_hale_start(), which initializes a persistent program arena and instantiates the@export locus(running itsbirth()hook). The.mjsloader invokes_hale_start()automatically duringawait run(glue). - Every non-
falliblefnmember is exported oninst.exports(e.g.,inst.exports.frame()).falliblemethods remain internal because JavaScript callers have no error-addressing channel. - Fields declared in
paramspersist across host invocations. - Defining
run()on an@export locusis rejected at compile time because execution is driven externally by the host:
const inst = await run(glue); // _hale_start() runs here; Client is bornfunction tick() { inst.exports.frame(); requestAnimationFrame(tick);}requestAnimationFrame(tick);An @export-based program requires no fn main() when compiled with target wasm { } or --target wasm32 (checking or building it for a native host without fn main() is rejected). On a native target, an @export locus behaves as a standard locus.
Wrapping a Bare fn main (--wrap-main)
Section titled “Wrapping a Bare fn main (--wrap-main)”For scripts, single-file examples, or browser playground snippets written with a standard fn main() and no @export declaration, pass --wrap-main:
hale build snippet.hl --target wasm32 --wrap-mainWhen the program has a top-level fn main() and no @export entry, --wrap-main transforms the parsed AST prior to typechecking into the equivalent of:
target wasm { }@export locus __Main { birth() { <main's body> } }Because --wrap-main operates on the AST rather than text:
- Compiler diagnostics preserve exact original line and column numbers.
- String literals and comments containing braces (
{or}) cannot corrupt the wrapper. - The
target wasmcapability gate is injected automatically, rejecting unsupported OS modules (std::io::tcp,std::process, etc.) with accurate source spans.
--wrap-main requires an explicit wasm target (--target wasm32, --target wasm32-unknown-unknown, or an in-source target wasm { } declaration) and is rejected on native builds. If the source already declares an @export entry, --wrap-main leaves the program unchanged.
Passing Inbound Bytes (lotus_wasm_inbox)
Section titled “Passing Inbound Bytes (lotus_wasm_inbox)”To pass binary payloads or WebSocket frames from JavaScript into a Hale @export locus, allocate a buffer in linear memory via lotus_wasm_alloc, copy the bytes in, set the inbox length with lotus_wasm_set_inbox, and invoke your exported handler:
// JavaScript host: copy WebSocket payload into wasm inboxconst 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();On the Hale side, read the buffer with lotus_wasm_inbox():
target wasm { }@ffi("c") fn lotus_wasm_inbox() -> Bytes;
@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); // Parse with std::json and update self.* fields self.ready = true; } } fn frame() { }}Stateless Function Exports (@export fn)
Section titled “Stateless Function Exports (@export fn)”If you do not need a locus, annotate free functions with @export fn:
@ffi("c") fn lotus_wasm_state_set(b: Bytes);@ffi("c") fn lotus_wasm_state_get() -> Bytes;An @export fn is stateless—its scratch allocations are reclaimed as soon as the function returns. To persist raw bytes across @export fn calls without an @export locus, use lotus_wasm_state_set and lotus_wasm_state_get (or prefer @export locus for structured state). On native builds, @export fn emits an unmangled C symbol callable from external C code. See spec/ffi.md § WASM host interface for full ABI details.
Compiling Package C Sources ([ffi] csrc) on wasm32
Section titled “Compiling Package C Sources ([ffi] csrc) on wasm32”When an imported package lists C files under [ffi] csrc in its hale.toml, hale build --target wasm32 compiles those .c files for wasm32 and links them into your .wasm module alongside the runtime.
Keep three wasm32 C constraints in mind:
- Freestanding environment (no libc sysroot): Wasm builds compile against a minimal forward-declared runtime shim rather than
wasi-sdk. C files that#include <string.h>or other system headers will fail to compile onwasm32with an error naming the file. - No dynamic linking (
[ffi] link):[ffi] link = [...]is rejected onwasm32because browser modules cannot link system shared libraries. Ship all required C implementations incsrc. - 64-bit
Int(long long): Hale’sIntis always 64-bit (i64). Onwasm32,intis 32-bit whilelong longis 64-bit:
long long tsa_answer(long long n) { return n * 6; } /* Correct: i64 -> i64 */int tsa_answer(int n) { return n * 6; } /* Traps with signature_mismatch */Declaring a parameter or return type as int instead of long long (or int64_t) links but traps at runtime with a WebAssembly signature_mismatch error.