Inspiration
Modern JavaScript runtimes are astonishingly capable — and also astonishingly heavy. V8, SpiderMonkey, and the engines behind Node, Deno, and Bun are decades of JIT craftsmanship, speculative optimization, and deoptimization machinery. That complexity is justified for long-lived servers and browsers, but it is a poor fit for every place we want JavaScript: short-lived CLI tools, sandboxed plugins, edge workers, teaching engines, and experimental language runtimes.
We were inspired by a different path: treat JavaScript like a real programming language with a real compiler, not only like a host for a mega-JIT. Ahead-of-time compilation to WebAssembly promised three things at once:
- Predictable execution — compile once, run without warm-up cliffs.
- Hard sandboxing — linear memory and capability boundaries instead of “please don’t touch that global.”
- Portable artifacts — a
.wasmmodule that can move with the code, not only a process that happens to embed V8.
We also learned from engines and runtimes we admire without copying their architecture: Deno and V8 for startup snapshots, OpenJDK-style collectors for GC vocabulary (mark-sweep, G1, ZGC), Node for the module and host surface people actually need (vm, async_hooks, inspector), and SWC for a production-grade parser that already understands TypeScript. The bet was simple and slightly stubborn: can a from-scratch AOT pipeline reach real ECMAScript semantics without becoming another V8?
What it does
wjsm is an AOT JavaScript/TypeScript runtime. It does not interpret JS bytecode and it does not embed V8. Instead it:
- Parses JS/TS with
swc_core - Lowers the AST into a custom SSA-style IR with scope analysis, hoisting, and TDZ
- Compiles that IR to WebAssembly with
wasm-encoder - Executes the module with
wasmtimeplus a host runtime for objects, GC, timers, promises, Node-ish APIs, and debugging
In practice you can:
wjsm run app.ts
wjsm build app.ts -o app.wasm
wjsm check app.ts
wjsm dump-ir app.ts
wjsm dump-wat app.ts
wjsm eval "1 + 2"
Under the hood, every JS value is a NaN-boxed i64, objects live behind stable handles, and the runtime can switch pluggable GC algorithms (mark-sweep, g1, zgc). Cold start is accelerated by a build-time embedded primordial snapshot: the bootstrap heap for Object.prototype, Array.prototype, and friends is restored instead of recreated from scratch every process launch.
Beyond “hello console.log”, wjsm has grown into a serious language/runtime stack:
- Control flow, classes, closures, exceptions, iterators, async/await-shaped execution
- ESM/CJS bundling and package resolution
- Streams / fetch-shaped host surfaces
node:vmmulti-realm executionnode:async_hooks- Chrome DevTools Protocol-style
--inspect/--inspect-brk - test262-oriented conformance work and large fixture/snapshot suites
The north star is not “a toy subset that demos well.” The project rule is blunt: ECMAScript semantics are the source of truth; partial implementations do not ship.
How we built it
We built wjsm as a linear compiler pipeline with one clear owner per stage:
source
→ wjsm-parser (swc_core → AST)
→ wjsm-semantic (AST → IR: scopes, TDZ, hoisting)
→ wjsm-module (optional multi-file graph / bundling)
→ wjsm-backend-wasm (IR → WASM bytes)
→ wjsm-runtime (wasmtime + host imports + GC + Node surfaces)
→ wjsm-cli
Compiler core
wjsm-iris dependency-free and owns the IR vocabulary: modules, functions, basic blocks, instructions, constants, and value encoding.wjsm-semanticuses two-phase lowering:- Pre-declare — hoist
var, registerlet/constinto the scope tree, model TDZ - Lower — walk the AST and emit IR only after names and scopes are settled
This is how we keep hoisting and temporal dead zone from leaking into ad-hoc backend special cases.
- Pre-declare — hoist
wjsm-backend-wasmturns IR into a WASM contract: imported host functions, linear memory, globals, support helpers (obj_new,obj_get,arr_new, …), and an exportedmain().
Value representation
All JS values ride in one machine word:
[ \text{value} = \begin{cases} \text{raw } f64 & \text{if not a quiet NaN box}\ \text{BOX_BASE} \lor (\text{tag} \ll 32) \lor \text{payload} & \text{otherwise} \end{cases} ]
Tags distinguish strings, objects, arrays, closures, symbols, proxies, bigints, and more. Numbers fall through as ordinary IEEE-754 doubles when they are not quiet-NaN-boxed. That keeps arithmetic fast while still packing the dynamic type system into WASM i64.
Runtime and systems work
The runtime is where “compiler project” becomes “engine project”:
- Host import registry for builtins and Node-compatible surfaces
- Handle-based heap so moving collectors can relocate objects without rewriting every JS reference slot
- Pluggable GC v2: shared lifecycle hooks, barrier entry points, and three support-module flavors so mark-sweep does not pay G1/ZGC barrier costs on the fast path
- Startup snapshot + build-time embedded runtime: capture the post-bootstrap primordial heap once at build time, verify ABI hash at restore, skip repeated bootstrap
- Async scheduler, promises, streams, and multi-realm
vmwith shared-store constraints - Inspector/CDP via statement safepoints and wasmtime guest debugging
Engineering discipline mattered as much as architecture: fixture snapshots for IR and stdout, nextest matrices, ADRs for load-bearing boundaries, and a hard preference for fixing semantics at the owning layer (parse / lower / compile / runtime) instead of sprinkling temporary logs through production code.
Challenges we ran into
1. ECMAScript is a specification, not a vibe
Almost every “simple” feature has a sharp edge: TDZ, var hoisting, arguments, eval scopes, super, Proxy invariants, iterator closing, async completion order. The painful lesson was that passing a happy-path fixture is not the same as implementing the feature. We repeatedly had to stop, open the spec, compare real engines, and rebuild the owner path instead of papering over symptoms.
2. AOT + dynamic language tension
JavaScript expects late binding, shape changes, eval, and multi-realm object graphs. WASM wants structured control, explicit imports, and a closed module boundary. Bridging that meant inventing durable contracts:
- NaN-boxed handles instead of raw pointers
- scope records and environment chains in IR/runtime, not “just locals”
- support modules for hot object/array primitives
- eval and
vmas first-class runtime modes, not afterthoughts
3. Moving GC on a handle/table machine
Once we wanted G1/ZGC-class behavior, “objects never move” stopped being a free invariant. The hard part was not the collector algorithm sketch — it was the blast radius: every host side table, TypedArray view, WeakRef, barrier write, and temporary raw pointer had to be re-audited. We split the invariant into:
- stable identity = handle
- pointer owner = object table entry
- writes go through barrier-aware access paths
Anything that held a raw pointer across an allocation or safepoint became a correctness bug waiting to happen.
4. Startup cost vs. correctness
Primordial bootstrap is expensive, but snapshotting the wrong thing is worse. We had to define a strict snapshot boundary: capture only relocatable primordial heap state, never timers/promises/streams/user objects; hash ABI inputs so a changed tag, string table, or native callable discriminant forces cold start instead of silent corruption.
5. Node compatibility without becoming Node
Users need require, package resolution, vm, async hooks, and inspector. Each one pulls a surprising amount of host state. The challenge was keeping one source of truth (for example a single store-wide async-hooks owner shared by all realms) instead of growing realm-local copies that drift and break GC or scheduling invariants.
6. Debuggability of a multi-layer pipeline
When console.log prints the wrong value, the bug may live in lowering, codegen, host boxing, or GC healing. We invested in stage isolation (--stage parse|lower|compile), IR/WAT dumps, semantic snapshots, and CDP inspect hooks so failures can be pinned to a layer with evidence — because “it feels like a runtime bug” is usually a lie told by an earlier stage.
Accomplishments that we're proud of
- A complete AOT pipeline that actually runs real JS/TS, not only arithmetic demos: parse → IR → WASM → host runtime.
- A clean crate boundary with a zero-dependency IR crate and single-purpose public APIs per stage.
- Spec-first culture encoded in the repo: no stubs as “done,” unreachable code stays valid, early errors are real diagnostics.
- NaN-boxing + handle heap as a coherent value ABI across compiler and runtime.
- Pluggable GC architecture with mark-sweep, G1, and ZGC selectable through one runtime contract and algorithm-specific support modules.
- Build-time embedded startup snapshot that makes process launch pay less for the same primordial universe every time.
- Serious Node-facing surfaces: modules/package resolution,
node:vmmulti-realm,async_hooks, and DevTools-style inspect. - An evidence-driven workflow: IR snapshots, end-to-end fixtures, ADRs, and layer-pinning debugging instead of folklore.
What we learned
We learned that building a JavaScript engine is less about any single clever trick and more about keeping contracts honest as the system grows.
- IR is a product surface. Once backend code starts inspecting AST shapes again, abstractions are already leaking. A stable IR dump format became one of our best tests and design tools.
- Semantics want owners. TDZ belongs in lowering. Barriers belong in heap access. Snapshot ABI belongs in a hashable format crate. When ownership is vague, bugs become cross-cutting and unfixable.
- Handles beat raw pointers as soon as relocation, multi-realm remapping, or host side tables enter the chat.
- Performance work without a contract is cosplay. GC names like “ZGC” only mean something if pause, relocation, barrier, and allocation paths are measurable and constrained.
- Compatibility is a compiler problem and a runtime problem. Package resolution,
eval, andvmeach force you to decide what is compile-time closed and what must remain dynamic. - Tooling is part of the language implementation.
dump-ir,dump-wat, fixture snapshots, and inspect mode are not extras; they are how a multi-stage engine stays navigable.
Most of all, we learned humility in front of the ECMAScript specification. The spec is dense because the language is dense. Shortcuts almost always reappear later as correctness debt with interest.
What's next for wjsm
wjsm’s next chapter is about turning a strong compiler/runtime foundation into a high-assurance, high-performance JS platform:
- Deeper ECMAScript and test262 coverage — keep closing semantic gaps until “supported” means “spec-complete,” not “works on our fixtures.”
- GC performance that earns the names — especially ZGC-style incremental marking/relocation and allocation paths that compete on pause and throughput, not only on architecture diagrams.
- Faster startup and steadier peak performance — richer snapshots, better support-module specialization, engine pooling, and fewer rebuild/runtime cliffs.
- Stronger Node/web interop — more complete module/package behavior, streams/fetch maturity, native addon boundaries, and production-facing host APIs.
- Better human debugging — richer CDP coverage, clearer trap/diagnostics, and tighter correlation from source → IR → WASM → host event.
- Optional alternate backends — the IR was designed so a future JIT or non-wasmtime execution engine can share the same lowered program model.
- Hardening for real workloads — sandbox policy, multi-tenant isolation, and packaging so AOT-to-WASM JavaScript is not only correct, but operable.
The long-term ambition is unchanged: a JavaScript/TypeScript runtime you can reason about — compiled ahead of time, sandboxed by construction, faithful to the language, and fast enough that the architectural purity is not an excuse.
If traditional engines are cities grown over decades, wjsm is an attempt to survey the same terrain and build a cleaner highway: narrower in places, stricter in others, and deliberately engineered so each mile of progress stays owned, testable, and replaceable.
Built With
- javascript
- npm
- rust
- tokio
- typescript
- wasm
Log in or sign up for Devpost to join the conversation.