Compile real Julia functions to WebAssembly that runs in any modern browser or Node.js. No runtime, no LLVM. Inspired by dart2wasm (Dart's WasmGC compiler for Flutter Web).
Julia has a 4-stage compiler pipeline: parsing, lowering, type inference, and codegen. WasmTarget replaces the last stage — instead of emitting native machine code via LLVM, it emits WasmGC bytecode.
Julia source → Julia compiler (parse, lower, infer) → Fully typed IR → WasmTarget → .wasm
Julia's compiler does the hard work — parsing, macro expansion, type inference, optimization. WasmTarget gets fully type-inferred IR and translates it. A function reaches Wasm through one of three paths:
-
Direct compilation. The function's own typed IR — arithmetic, control flow, loops, structs, tuples, closures, try/catch — translates statement-by-statement to Wasm instructions. This is how your code compiles, and how most of Base compiles too, because Julia inlines aggressively: a call like
sum(v)usually arrives already flattened into plain loops inside the caller's IR. -
Closed-world trim collection (the default discovery). WasmTarget feeds your entry points to the same closed-world collection machinery that powers
juliac --trimupstream (Compiler.typeinf_ext_toplevel/CompilationQueue— see JuliaLang/julia#62087, where this strategy is laid out). The compiler walks every reachable:invokein a single consistent inference world and hands back(CodeInstance, CodeInfo)pairs for the whole call graph; WasmTarget compiles each one as its own Wasm function and links the calls. Nothing is hand-curated:Statistics.quantile,sort!internals, Dict hashing, string search — the entire reachable world is collected the way the compiler itself sees it. The previous curated-whitelist discovery remains available viacompile_multi(...; discovery=:legacy). -
Method overlays (~100 methods). For Base methods whose real implementation can't translate — they reach into GC internals,
ccallinto libjulia/libc, use pointer arithmetic, or rely on lookup tables WasmGC can't address — WasmTarget ships replacement implementations via Julia'sOverlayMethodTable, the same mechanism CUDA.jl and AMDGPU.jl use. Overlays are resolved during inference — including inside the trim collection — so codegen never sees the original. They are semantically faithful substitutes, e.g.Base.Math.pow_bodyis re-implemented as the same compensated power-by-squaring algorithm (bit-identical results), andreinterpretbecomes a directCore.bitcast.
Where overlays currently live, by area:
| Area | Examples |
|---|---|
| Array mutation | push!, pop!, insert!, deleteat!, splice!, append!, copy, filter — WasmGC arrays are fixed-size, so growth is reallocate-and-copy |
| Strings | split, join, replace, strip family, repeat, reverse, cmp, string(::Float64) (Ryu shortest-round-trip, reimplemented) |
| Math tails | sinh/cosh/tanh/asin, hypot, mod/rem(::Float64), pow_body, Math.table_unpack (memory-addressed tables → computed) |
| Bit reinterpretation | reinterpret between same-width primitives → Core.bitcast; shifts on BitInteger (Julia over-shift semantics) |
| Reductions | reduce/foldl/maximum/minimum/argmax/argmin/count on Vector — flat-IR loop forms |
| Dict/Set | Dict tuple constructor, delete!, union! |
Everything not listed compiles from its real Base implementation. The split is verified continuously — see the coverage matrix below.
using WasmTarget
function add(a::Int32, b::Int32)::Int32
return a + b
end
wasm_bytes = compile(add, (Int32, Int32))
write("add.wasm", wasm_bytes)import fs from "node:fs";
const bytes = fs.readFileSync("add.wasm");
const { instance } = await WebAssembly.instantiate(bytes);
console.log(instance.exports.add(5, 3)); // → 8Pure numeric kernels compile to import-free modules — no server, no bundler,
no imports object. Modules that touch print/show or string interop do import
the standardized wasm:js-string builtins and a small io module; instantiate
those with WebAssembly.instantiate(bytes, imports, { builtins: ['js-string'] })
— see Soundness & Testing for the full embedder one-liner.
Multi-function modules with closures and real Base functions:
f_sort(v::Vector{Int64}) = sort(v, rev=true)
f_filter(v::Vector{Int64}) = filter(iseven, v)
f_map(v::Vector{Int64}) = map(x -> x * 2, v)
bytes = compile_multi([
(f_sort, (Vector{Int64},)),
(f_filter, (Vector{Int64},)),
(f_map, (Vector{Int64},)),
])WasmTarget compiles real, third-party Julia — not just toy kernels — to interactive WebAssembly that runs entirely client-side:
- Snapshot.jl turns reactive Pluto notebooks into self-contained WasmGC "islands." The featured-notebook gallery — image processing, 2-D convolution, Mandelbrot/Julia fractals, dithering, Newton's method — recomputes live as you move the sliders, with no Julia server. → Live gallery
- WasmMakie.jl compiles a Makie-style plotting API (
lines!,scatter!,image!,heatmap!) to an HTML canvas through WasmTarget. The WasmTarget docs homepage stitches it together with the SciML stack: a live Lorenz attractor solved bySimpleDiffEqover aStaticArraysstate and drawn by WasmMakie, re-solving on every slider move — all in Wasm. - Therapy.jl is a web framework that compiles
@islandcomponents at build time.
These double as integration tests: every release is re-checked against the full featured-notebook corpus, so "compiles real Julia" stays true rather than aspirational.
Coverage is tracked by a differential fuzzer, not a hand-maintained list. The fuzzer holds a catalogue of ~590 Base operation signatures across these areas:
| Area | What's covered |
|---|---|
| Numeric | abs, sign, clamp, min/max, div/mod/rem/divrem, gcd/lcm, predicates (iseven, isnan, …), typemin/typemax, checked arithmetic, 8/16/32/64/128-bit widths |
| Math | trig/hyperbolic/inverse families, exp/log families, sqrt/cbrt/hypot, rounding, ^ (float and integer, correctly rounded), Float32 and Float64 |
| Strings | indexing, search (contains, findnext, …), case transforms, split/join/replace, padding, string(::Int)/string(::Float64) round-trips, Char predicates |
| Collections | sort, map/filter/reduce/mapreduce, sum/prod/extrema, any/all/count, unique, accumulate/cumsum, findmax/argmax |
| Array mutation | push!/pop!/pushfirst!/popfirst!, insert!/deleteat!/splice!, append!/prepend!, fill!/empty!/resize!, mutation parity checked against native |
| Dict/Set | construction, setindex!/getindex/get, haskey/in, delete!/pop!, Set ops, with Int/String/Float keys |
| Iterators | collect, enumerate, zip, pairs, Iterators.take/drop/filter/map/flatten, ranges |
| Control flow | nested if/else, while loops with accumulators, try/catch/finally (including nested chains), early returns, closures over all of the above |
Every signature's status lives in test/fuzz/COVERAGE.md, regenerated from fuzzing runs: an entry is pass only when it appears in at least one randomly-generated program whose Wasm output matched native Julia exactly — value, thrown-ness, and argument mutations. Current matrix: all 588 entries pass, with 0 silent divergences — every known unsupported construct fails loudly (a compile error or a trap), never miscompiles. The ledger in test/fuzz/failures/ holds 240+ caught-and-shrunk divergence postmortems, each a self-reproducing case that auto-closes when fixed. A bounded discovery_differential() additionally cross-checks the trim and legacy pipelines against each other on generated programs.
Stdlib (and now SciML-library) support ships as zero-dependency package extensions (weakdeps) — loading the library activates the extension, nothing is required otherwise. Every supported name is one of two things, never asserted:
- (A) compiled from its real implementation and confirmed by a differential sweep (Wasm vs native, the same tolerance/bit-exact oracle as core); or
- (B) rerouted through a bit-exact
@overlaywhen the real implementation reaches code WasmGC can't lower (BLAS/LAPACKccalls, SIMD intrinsics, dimension-reduction machinery) — a semantically identical substitute, proven equivalent before it ships.
Support is tracked the same way Base is: a grounded percentage over the full names(Stdlib) surface (out-of-scope = genuinely non-Wasm, e.g. host entropy / packed BLAS forms), regenerated from differential runs into test/fuzz/STDLIB_COVERAGE.md.
| Stdlib | In-scope support | Highlights | Notes |
|---|---|---|---|
Statistics |
100% | mean/var/std/cor/median/quantile + in-place mean!/median!/quantile! |
bit-exact vs native, both Julia versions |
LinearAlgebra |
97% | det/inv/\/norm/dot/cross, factorization objects lu/cholesky/eigen/svd (+ eigvals/svdvals/pinv/cond), structured types (Diagonal/Symmetric/Triangular/…), in-place mul!/ldiv!/rdiv!/kron!/triu!/… |
factorizations hand-rolled (LU / cyclic-Jacobi / one-sided-Jacobi) where BLAS/LAPACK can't lower, reconstruction-verified; qr/schur/lq/general & complex eigen out of scope |
Dates |
96% | construction (Date/DateTime/Time), arithmetic, accessors, conversions (datetime2unix↔unix2datetime, …2julian/…2rata), dayname/monthname, adjusters (tonext/toprev/tofirst/tolast), string rendering |
format (the DateFormat DSL) and canonicalize pending; now/today need host time |
Random |
100% (Julia ≤1.12) | seeded Xoshiro: rand/randn/randexp, randperm/randcycle/shuffle (+ !-variants), seed!, randsubseq/randsubseq!, randstring |
the seeded-RNG differential is a valid oracle on ≤1.12; on 1.13-rc1 Xoshiro seeding was reworked and the stream is platform-unstable, so the suite is gated there. Out of scope: rand!/randn!/randexp! array fills (8-lane SIMD llvmcall), bitrand (BitVector), OS entropy |
SparseArrays |
100% | SparseMatrixCSC construction, nnz/nonzeros/rowvals/findnz/nzrange, reductions, sparse·vector/sparse·sparse (matmul), +/-, transpose/permute, spdiagm/spzeros/hcat/vcat/blockdiag, dropzeros!/droptol!/fkeep! — plus multi-op combos (A*B+Cᵀ, …) to prove composition |
unlocked by registering SparseMatrixCSC as a real struct (not WT's array layout) + textbook-CSC ext overlays; sparse-direct \/factorizations out of scope (SuiteSparse C library), sprand/sprandn (RNG consumption diverges) |
ForwardDiff (SciML) |
100% | forward-mode autodiff in the browser: derivative/gradient/jacobian/hessian (+ in-place ! forms) — EXACT derivatives, no finite differences. Combos verified: ‖∇f‖, J·x, a 2×2 Newton step J⁻¹F, Rosenbrock gradient/Hessian |
derivative compiles from the real impl; gradient/jacobian/hessian are overlays reusing the single-partial Dual seed one direction at a time (bit-identical to native's Partials{N}, since forward-mode partials never cross slots). Unlocked by a narrow core fix registering Dual/Partials as real structs (fixes array-of-<:Number-struct literals generally). The Config/Chunk preallocation API is out of scope (cyclic-Method @generated seeder; the standard API covers the same results) |
StaticArrays (SciML) |
100% | the SVector surface: construction (positional/tuple/converting-eltype, all N incl. the single-element vector), getindex, iterate/destructure, reductions (sum/prod/maximum/minimum), arithmetic/dot/broadcast |
SVector{N,T} is an NTuple-backed struct, not a heap array — unlocked by registering :SArray as a real struct (the SparseMatrixCSC/Dual lever) + overlaying construct_type to the identity for already-parameterized types (WT's interpreter can't fold its type-level adapt_size/adapt_eltype/typeintersect machinery with concrete-eval off). SMatrix/MArray out of scope |
SimpleDiffEq (SciML) |
100% | solve ODEs in a frozen Wasm module: every fixed-step solver — SimpleEuler/SimpleRK4/SimpleTsit5/LoopEuler/LoopRK4 — over scalar, Vector- and SVector-state systems (decay, logistic, harmonic oscillator, Lotka–Volterra, nonlinear pendulum, Lorenz), incl. parameterized ODEProblem(f,u0,tspan,p) |
the SciMLBase abstraction (ODEProblem/ODEFunction/solve) is cleared by three pure levers: a curated type-level concrete-eval fold (apply_type/isinplace-type-param/…), a concrete ODEFunction construction overlay (bypassing isinplace method-arity reflection), and solve → DiffEqBase.__solve (bypassing the kwarg-Pairs machinery); solution types are registered as real structs. SimpleTsit5's SVector Butcher tableau rides on the StaticArrays support above. Adaptive SimpleATsit5 and Vector-typed p out of scope |
Three SciML libraries now run the same way the stdlibs do — autodiff, static arrays, and a full ODE solver, each in a frozen, offline Wasm module. The docs homepage closes the loop: a live Lorenz attractor whose ODE is re-solved by SimpleRK4 over an SVector{3} state and re-drawn by WasmMakie on every slider move — the whole solve-and-plot loop compiled to WebAssembly, no server. Two things make all of this cheap. The trim collection compiles things like quantile (which needs sort! internals, kwarg bodies, and Core.kwcall) with zero special-casing. And the differential oracle is tolerance-aware, so a hand-rolled factorization — or an @muladd step that fuses differently than native — still validates as correct. Per-library ledgers (what's verified, what's overlaid, what's out-of-scope and why) live in test/fuzz/FINDINGS.md.
| Feature | Status |
|---|---|
| Integer arithmetic (8/16/32/64/128-bit, Julia wrap/over-shift semantics) | Working |
Floating point (32/64-bit, IEEE 754, correctly-rounded ^) |
Working |
| Control flow (if/else, while, for) | Working |
| Structs (mutable and immutable) | Working |
| Tuples and NamedTuples | Working |
| Arrays (Vector, Matrix) | Working |
| Strings (UTF-8) | Working |
| Closures (including closures over Dicts/Vectors, passed to higher-order functions) | Working |
Exceptions: try/catch/finally, nested chains, catchable Base errors (BoundsError, DivideError, DomainError, OverflowError, InexactError, …) |
Working |
| Union{Nothing, T} and small unions | Working |
| Multi-function modules | Working |
| JS interop (externref) | Working |
| Dict / Set | Working |
| Splatting (f(args...)) | Working |
| Keyword arguments | Working |
Constructs whose inferred type is abstract (requiring runtime type dispatch) are not supported and trap or raise a compile error:
- Heterogeneous-key
Dictliterals —Dict(Int32(0) => 0, some_int64 => 0)promotes throughdict_with_eltype, inferring an unparameterizedDict. Promote keys explicitly so all pairs share one concrete type. - Mixed
Char/String/SubStringvarargs beyond two arguments — the vararg tuple's elements widen to aUnion; two-argument combinations are covered by concrete overlay specializations. - Matrix literals of tuples (
[(a,b) (c,d); …]) — thehvncatmachinery currently recurses in compilation; build withMatrix{T}(undef, m, n)and explicit stores instead.
| Julia Type | WebAssembly Type |
|---|---|
Int32, UInt32, Bool |
i32 |
Int64, UInt64 |
i64 |
Float32 |
f32 |
Float64 |
f64 |
String |
WasmGC packed (array (mut i8)) (UTF-8 bytes; array.get_u widens to i32 on the stack) |
| User structs | WasmGC struct |
Vector{T} |
WasmGC struct{array_ref, size} |
Dict{K,V} |
WasmGC struct (hash table) |
JSValue |
externref |
WasmTarget aims to be correct-or-loud, never silently wrong.
Codegen correctness is unconditional. There is no permissive builder mode: invalid instructions, fabricated replacement values, and wrong-value fallbacks are rejected. Unsupported live operations are recorded as diagnostics and lower only to a validating trap, never to a plausible value. Julia exceptions compile to catchable Wasm exceptions through the shared exception tag.
compile(f, (T,)) # typed-builder validity is unconditional
compile(f, (T,); validate=true) # plus an independent wasm-tools cross-checkAuthor pre-flight (optional). Because WasmTarget rejects type-unstable / boxed /
dynamically-dispatched code rather than guessing, the fastest way to know a function
is in-subset before compiling is to check it for type stability and dynamic
dispatch with the standard Julia tooling — JET.jl
(@report_call), AllocCheck.jl
(@check_allocs flags object creation and dynamic dispatch), or
DispatchDoctor.jl (@stable).
WasmTarget ships none of this machinery itself; these are author-side linters that
make code "stricter" in exactly the way the compiler wants.
validate=true (default). Every compiled module is checked with
wasm-tools validate; a reject raises WasmValidationError rather than handing
back malformed bytes.
discovery=:trim (default). Callee discovery uses the upstream closed-world
trim collection; pass discovery=:legacy for the previous curated-whitelist
walker. Because the trim collection compiles the full reachable world
(including print/show paths), emitted modules may import the standardized
wasm:js-string builtins and a small io module — embedders should instantiate
with WebAssembly.instantiate(bytes, imports, { builtins: ['js-string'] }) and
may stub the io functions (write_string, write_int, write_float,
write_bool, write_newline, write_nothing).
Differential fuzzing. test/fuzz/ generates well-typed random compositions of
Base functions — expressions, statements, loops, try/catch, closures, structs — and
checks each against native Julia (native is both oracle and validity filter):
same value, same throw, same argument mutations, bit-exact across a Node.js bridge.
Findings are auto-shrunk to a minimal reproducer, persisted to a
Supposition.jl corpus (replayed
first on every run as a regression ratchet), and documented as self-reproducing
"gap" files that auto-close when fixed. A bounded pass runs in CI; deep exploration
runs standalone:
julia --project=test/fuzz test/fuzz/run.jl sweep # parallel discovery (time-boxed)
julia --project=test/fuzz test/fuzz/run.jl verify # re-check open gaps, auto-close fixed
julia --project=test/fuzz test/fuzz/run.jl coverage # regenerate COVERAGE.md- Julia 1.12 or 1.13 (required — the typed-IR format is version-specific, so each minor line is supported explicitly; both run in CI)
- Node.js 20+ for testing (WasmGC support)
wasm-toolsfor validation (cargo install wasm-tools)
using Pkg
Pkg.add("WasmTarget")Apache License 2.0 — see LICENSE.md