Skip to content

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;
}
}

Pass --target wasm32 to hale build or declare target wasm { } at the top level of your source file:

Terminal window
hale build client/main.hl --target wasm32

This 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.

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).

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 whose params default or on_failure handler 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, if expressions, or locals reassigned in a loop) require that every function of that signature passed as a value in the program is valid on wasm32.
  • 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.
  • In-process bus: topic, bus { publish ... }, and bus { subscribe ... } work identically to native builds within the WebAssembly module.
  • @form collections: @form(vec), @form(hashmap), and @form(ring_buffer) use the target-pointer-width size_t ABI and behave identically to native builds.

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] 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.

  • A program with @exports and no fn main: admitted — an export-only module: the host drives it through its exports.
  • --wrap-main on a bare fn main: admitted — a bare fn main is wrapped as the module’s @export entry.
  • An @export locus that defines run(): 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);
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 */
},
}));
  • 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 .wasm module directly instead of using the generated .mjs loader, your env imports object must provide every symbol listed by WebAssembly.Module.imports(mod): the console writers, the libm math symbols, your @ffi("js") functions, and dprintf / fflush (stubbed as () => 0 by the default loader). Thread and socket symbols are not linked into wasm32 modules.
  • Console output (println vs. eprintln): The generated loader routes println to the browser console, but stubs dprintf (() => 0). As a result, eprintln produces no output and unhandled closure violations trap without printing a message. Always use println or @ffi("js") fn console_log for 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:

  1. The compiler synthesizes _hale_start(), which initializes a persistent program arena and instantiates the @export locus (running its birth() hook). The .mjs loader invokes _hale_start() automatically during await run(glue).
  2. Every non-fallible fn member is exported on inst.exports (e.g., inst.exports.frame()). fallible methods remain internal because JavaScript callers have no error-addressing channel.
  3. Fields declared in params persist across host invocations.
  4. Defining run() on an @export locus is rejected at compile time because execution is driven externally by the host:
const inst = await run(glue); // _hale_start() runs here; Client is born
function 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.

For scripts, single-file examples, or browser playground snippets written with a standard fn main() and no @export declaration, pass --wrap-main:

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 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 wasm capability 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.

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 inbox
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();

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() { }
}

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:

  1. 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 on wasm32 with an error naming the file.
  2. No dynamic linking ([ffi] link): [ffi] link = [...] is rejected on wasm32 because browser modules cannot link system shared libraries. Ship all required C implementations in csrc.
  3. 64-bit Int (long long): Hale’s Int is always 64-bit (i64). On wasm32, int is 32-bit while long long is 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.