Ashash
WebAssembly

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.

Included tools

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.

Parallel threads

Haxe threads run on Web Workers with shared memory. A browser benchmark with four threads measured a 3.01× speedup over the serial version.

Native libraries

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.

01 · Install

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.

Check the install. ls ~/.ash/bin/wasm32-wasip1 should list ash_runtime.o. If it does not, the install predates wasm packaging: run the install command again.
02 · A Haxe program

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.

Main.hx
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.

03 · Build & run

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.

ash run

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.

The browser

A WASI shim provides the host services, and Workers run the threads. --build generates the page; see browser setup.

Custom host

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.

Exceptions use the standard wasm exception-handling proposal. Ash lowers setjmp into it, so every module carries exnref. It is supported from Chrome 137, Firefox 131 and Safari 18.4.
04 · Threads

Configure parallel threads

The build above runs the four thread bodies sequentially. Parallel execution with blocking calls requires two features:

$ ASH_WASM_FIBERS=1 ash --build main.wasm --target wasm32-wasip1-threads main.hl
$ ash run main.wasm
BuildThreads runA thread that blocks
wasm32-wasip1one at a time, each body to completioncannot wait on another thread: the program hangs
wasm32-wasip1 + fiberstaking turns on one agentsuspends; the others run
wasm32-wasip1-threads + fibersin parallel, one agent eachsuspends; 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.

05 · The browser

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

FileRuns onDoes
main.wasmThe program.
index.htmlthe page's main threadStarts 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.jsa module WorkerFetches the module and calls run, handing each thread to an agent over the port the page gave it.
thread.jsone Worker per thread, started by the pageInstantiates 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.jsa Worker that owns the canvasStarts 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.wasmThe 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

Use a custom page

Keep worker.js, thread.js, display.js and the host files, and send messages to worker.js from your page:

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

06 · A native library in Rust

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

Hello.hx
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.

hello/Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["staticlib"]

[dependencies]
hl_abi = { git = "https://github.com/rayzor-blade/hl_abi" }
hello/src/lib.rs
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");

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

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.

Host restrictions. Side modules currently load only under 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.
07 · Limits

Current limitations

Working example: examples/browser · Reference: docs/wasm

← Back to Ash