Compile Haxe
to WebAssembly.
Ash compiles HashLink bytecode to WebAssembly. Use ash to build the module, run it in a terminal, or serve it to a browser. Browser builds can run Haxe threads in parallel using Web Workers. For single-threaded programs under ash run, native libraries can be built as separate .wasm modules.
The Ash release includes LLVM, a wasm linker, the wasm runtime, and the browser host, which is built into the binary. You also need Haxe to compile your source to bytecode.
Haxe threads run on Web Workers with shared memory. A browser benchmark with four threads measured a 3.01× speedup over the serial version.
Build native libraries as dylink.0 side modules. They share the program's memory and collector and use the existing @:hlNative and DEFINE_PRIM interfaces.
Install Ash
Use the standard Ash installer. It places the wasm runtime directories (wasm32-wasip1/ and wasm32-wasip1-threads/) beside the ash binary; the browser host is built into ash itself.
$ curl -fsSL https://ash.rayzor.tech/install.sh | sh
On Windows, in PowerShell: irm https://ash.rayzor.tech/install.ps1 | iex. You also need Haxe to compile the program to bytecode.
ls ~/.ash/bin/wasm32-wasip1 should list ash_runtime.o. If it does not, the install predates wasm packaging: run the install command again.Compile a Haxe program
This example uses the standard Haxe threading API. It starts four threads, gives each one a computation, and collects the results through a Deque.
import sys.thread.Deque; import sys.thread.Thread; class Main { static function work(seed:Int):Int { var x = seed; for (i in 0...50_000_000) x = (x * 1103515245 + 12345) & 0x7fffffff; return x; } static function main() { Sys.println("Hello from Haxe, compiled to wasm by ash"); var start = Sys.time(); var done = new Deque<Int>(); for (t in 0...4) Thread.create(() -> done.add(work(t))); var results = [for (_ in 0...4) done.pop(true)]; Sys.println('4 threads: $results in ${Math.round((Sys.time() - start) * 1000)}ms'); } }
$ haxe -main Main -hl main.hl
The same main.hl runs natively with ash main.hl. Every wasm build below starts from it.
Build and run the module
$ ash --build main.wasm --target wasm32-wasip1 main.hl $ ash run main.wasm Hello from Haxe, compiled to wasm by ash 4 threads: [1104406912,1614248833,2124090754,486449027] in 604ms
Ash compiles the bytecode through AIR and LLVM to a wasm32 object, then links the module with its own linker. The module exports main and imports services such as clocks, output, and fiber suspension. A host instantiates the module and provides those services.
The Wasmtime host built into ash. Arguments after the module path go to the program. Use --dir PATH to allow access to a directory in addition to the working directory.
A WASI shim provides the host services, and Workers run the threads. --build generates the page; see browser setup.
docs/wasm/host-abi.md lists the required imports. ash wasm main.wasm groups imports by provider. Use ash wasm --validate to check host compatibility.
setjmp into it, so every module carries exnref. It is supported from Chrome 137, Firefox 131 and Safari 18.4.Configure parallel threads
The build above runs the four thread bodies sequentially. Parallel execution with blocking calls requires two features:
- Agents, from
--target wasm32-wasip1-threads. Each agent runs an instance of the module with shared memory. In a browser, an agent is a Web Worker; underash run, it is an OS thread. - Fibers, from
ASH_WASM_FIBERS=1at build time. Fibers let calls such asDeque.pop(true), lock waits, andSys.sleepsuspend and resume. Each agent has its own fiber state and scheduler, so a blocked fiber does not stop other agents.
$ ASH_WASM_FIBERS=1 ash --build main.wasm --target wasm32-wasip1-threads main.hl $ ash run main.wasm
| Build | Threads run | A thread that blocks |
|---|---|---|
wasm32-wasip1 | one at a time, each body to completion | cannot wait on another thread: the program hangs |
wasm32-wasip1 + fibers | taking turns on one agent | suspends; the others run |
wasm32-wasip1-threads + fibers | in parallel, one agent each | suspends; the others run |
The runtime requests an agent for each thread. With fibers enabled, a thread created when all agents are busy runs on the main scheduler instead. It shares execution time with the main thread until it finishes.
Run in a browser
A wasm build also writes a page beside the module. ash serve serves a directory on localhost with the two headers a threaded module needs.
$ mkdir -p site $ ASH_WASM_FIBERS=1 ash --build site/main.wasm --target wasm32-wasip1-threads main.hl $ ash serve site serving /home/you/site at http://127.0.0.1:8731/
Open the local address to see the output. Threads use available Workers, with fiber scheduling as the fallback. Use ash serve --port N to change the port and add ?arg=value to the URL to pass an argument through Sys.args().
Generated files
| File | Runs on | Does |
|---|---|---|
main.wasm | The program. | |
index.html | the page's main thread | Starts worker.js and displays its output. The program runs in a Worker so a blocking Haxe main loop does not freeze the tab. Ash overwrites this file on each build if its first comment marks it as generated. Remove that comment to keep a custom page. |
worker.js | a module Worker | Fetches the module and calls run, handing each thread to an agent over the port the page gave it. |
thread.js | one Worker per thread, started by the page | Instantiates the module with shared memory and enters at wasi_thread_start. The page starts these, not worker.js, because some browsers (Chrome on Android) cannot start a Worker inside a Worker; one that fails to start is left out and its threads run on the main scheduler. |
display.js | a Worker that owns the canvas | Starts when the program presents its first frame. Drawing runs in a separate Worker because the program's main loop can block its Worker's event loop. |
ash_browser.js, ash_browser_bg.wasm | The host: WASI from the browser (output to console, clocks from performance.now, randomness from crypto, no filesystem) and Workers for threads. |
Deploy to a web server
Upload the generated directory and configure the server to send these headers on every response. Threaded builds need them for shared memory. Single-threaded builds do not need the headers. Serve either build over HTTP(S); opening the page through file:// will not work.
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp
Worker startup and scheduling
- Workers start before the program. Worker startup requires the parent to return to its event loop. A synchronous wasm call can block that loop while waiting for a thread, so creating Workers on demand can deadlock.
worker.jsstarts the pool in advance, with one Worker per core minus one. - Busy pools fall back to fibers. If no Worker is idle, the thread runs as a fiber on the main scheduler. The page reports which scheduler each thread uses.
- Build with
ASH_WASM_FIBERS=1. Fibers allow blocking threads to yield and let the main scheduler run threads that have no available Worker.
Use a custom page
Keep worker.js, thread.js, display.js and the host files, and send messages to worker.js from your page:
const worker = new Worker("./worker.js", { type: "module" }); worker.onmessage = ({ data }) => { // kind: "out" | "err" | "meta" for text, "display" for a framebuffer, // "done" with { status, trapped } when the program ends. if (data.kind === "done") return console.log("exited", data.status, data.trapped ?? ""); if (data.text) console.log(data.text); }; worker.postMessage({ module: "./main.wasm", args: ["main.wasm"], display: false });
args becomes Sys.args(), argv[0] included. display: true enables frame output. See the generated index.html for the code that passes frames to display.js.
Browser runtime support
- Output goes to the console and page, one line at a time. Thread output is forwarded through
worker.jsand appears after the main program returns, when that Worker can process messages. Sys.sleepusesAtomics.waitin the Worker. Without cross-origin isolation, it returns immediately.- Filesystem: file access is unavailable. Opening a file returns a file-not-found error.
- Sockets: client connections use WebSocket.
- Drawing: threads can write to a shared framebuffer, which
display.jsrenders at the display refresh rate. Messages contain buffer metadata; the pixels stay in shared memory. examples/browser has a demo.
Write an HDLL as a wasm side module
For wasm builds, compile each native library to a dylink.0 side module and place its .wasm file beside the program. The side module imports the program's memory, function table, and runtime functions. This keeps its allocations on the heap scanned by the collector. The Haxe declarations stay the same.
The Haxe side
class Hello { @:hlNative("hello", "greet") static function greet(name:String):hl.Bytes return null; @:hlNative("hello", "fib") static function fib(n:Int):Int return 0; static function main() { Sys.println(@:privateAccess String.fromUCS2(greet("wasm"))); Sys.println('fib(30) = ${fib(30)}'); } }
@:hlNative("hello", "greet") resolves to the hlp_greet export in hello.wasm. The filename supplies the library name. Resolver names use hlp_ followed by the primitive name, without a library prefix.
The Rust side
The crate depends on hl_abi, which declares HashLink's C ABI (the #[repr(C)] layouts, the runtime functions and define_prim!) without defining runtime symbols.
[package] name = "hello" version = "0.1.0" edition = "2021" [lib] crate-type = ["staticlib"] [dependencies] hl_abi = { git = "https://github.com/rayzor-blade/hl_abi" }
use hl_abi::{define_prim, hlp_alloc_bytes, vbyte, vstring}; // Allocate through the program's malloc, not a second allocator of our own. #[global_allocator] static ALLOCATOR: hl_abi::ProgramAllocator = hl_abi::ProgramAllocator; /// `fib(n:Int):Int` pub extern "C" fn hello_fib(n: i32) -> i32 { let (mut a, mut b) = (0i32, 1i32); for _ in 0..n { (a, b) = (b, a.wrapping_add(b)); } a } /// `greet(name:String):hl.Bytes` -- "Hello, <name>!" as NUL-terminated UTF-16. pub unsafe extern "C" fn hello_greet(name: *mut vstring) -> *mut vbyte { let name = std::slice::from_raw_parts((*name).bytes, (*name).length as usize); let text: Vec<u16> = "Hello, ".encode_utf16() .chain(name.iter().copied()) .chain("!\0".encode_utf16()) .collect(); let out = hlp_alloc_bytes((text.len() * 2) as i32); std::ptr::copy_nonoverlapping(text.as_ptr(), out as *mut u16, text.len()); out } define_prim!(hlp_fib, hello_fib, "Pi_i"); define_prim!(hlp_greet, hello_greet, "POBi__B");
- The program's allocator. Rust's default wasm allocator uses its own arena.
ProgramAllocatorforwards allocations to the program'smallocso both use the same allocator. - Strings are UTF-16. A Haxe
Stringarrives as avstring, whoselengthcounts UTF-16 units. Text goes back as NUL-terminated UTF-16 inhl.Bytesallocated by the collector (hlp_alloc_bytes), andString.fromUCS2reads it. - GC ownership. Allocate objects retained by Haxe through the runtime's allocators. The collector cannot see a GC pointer stored only in the library's private memory.
Build it
Side modules require position-independent code. Rebuild Rust's std with -Z build-std on nightly; the prebuilt version from rustup is not position-independent. Install rust-src and the wasm32-wasip1 target for that toolchain, then link with its rust-lld:
$ cd hello $ RUSTFLAGS="-C relocation-model=pic -C target-feature=+mutable-globals" \ cargo +nightly build --release --target wasm32-wasip1 -Z build-std=std,panic_abort $ LLD="$(rustc +nightly --print sysroot)/lib/rustlib/$(rustc +nightly -vV | sed -n 's/^host: //p')/bin/rust-lld" $ "$LLD" -flavor wasm --experimental-pic -shared --no-entry \ --gc-sections --no-export-dynamic --unresolved-symbols=import-dynamic \ --export=hlp_fib --export=hlp_greet \ --whole-archive target/wasm32-wasip1/release/libhello.a --no-whole-archive \ -o hello.wasm
--exportnames each resolver.--no-export-dynamickeeps every other Rust symbol out of the export list, which lets--gc-sectionsdrop what the resolvers do not reach.--whole-archivebecause a-sharedlink imports undefined symbols instead of pulling archive members.--unresolved-symbols=import-dynamiccovers data as well as functions: Rust'sstdtakes the address oferrno.
Add the library before building the program
Place the side module in the output directory before running ash --build. Ash reads its imports and exports the required functions from the main module. Rebuild the program whenever you add a library or change its imports.
$ haxe -main Hello -hl hello.hl $ mkdir -p out && cp hello/hello.wasm out/ $ ash --build out/app.wasm --target wasm32-wasip1 hello.hl [ash] 1 native library beside the output will be loaded at run time, so this module exports the memory, table and 11 runtime functions they import. $ ash run out/app.wasm [ash] loaded native library: hello Hello, wasm! fib(30) = 832040
Libraries can use the libc functions provided by the runtime. The build reports any missing functions. If a library is absent, calling one of its primitives raises a "not loaded" error. For C libraries and linking details, see docs/wasm/hdlls.md.
ash run in single-threaded wasm32-wasip1 programs. Calling a side-module primitive in the browser raises an error. Rust libraries targeting wasm32-wasip1-threads cannot yet link as side modules.Current limitations
- Side modules: currently supported only in single-threaded programs under
ash run. Browser and threaded builds cannot load them yet. - Native
.hdllfiles: rebuild these libraries as wasm side modules. fmtcompression and hashing. Eight Haxe suite cases need those primitives; every other in-scope case passes on wasm.- Binary size: a hello-world module is roughly two megabytes, mostly runtime code. The linker removes unreachable code but retains functions referenced by data.
Working example: examples/browser · Reference: docs/wasm
← Back to Ash