Skip to content

dy/jz

Repository files navigation

jz logo

stability npm test

JZ (javascript zero) is minimal modern functional JS subset, compiling to WASM.

import jz from 'jz'

// Distance between two points
const { exports: { dist } } = jz`export let dist = (x, y) => (x*x + y*y) ** 0.5`
dist(3, 4) // 5

Why?

Write plain JS, compile to WASM – fast, portable and long-lasting.
JZ distills the modern functional core – the "good parts" (Crockford) – from legacy semantics, features overhead and perf quirks.

  • Static AOT – no runtime, no GC, no dynamic constructs.
  • Valid jz = valid js — test in browser, compile to wasm.
  • Minimal — output is close to hand-written WAT.
Good for Not for
Numeric / math compute UI / frontend
DSP / audio / bytebeats Backend / APIs
Parsing / transforms Async / I/O-heavy logic
WASM utilities JavaScript runtime

Inspired by porffor and piezo.

Usage

import jz, { compile } from 'jz'

// Compile, instantiate
const { exports: { add } } = jz('export let add = (a, b) => a + b')
add(2, 3)  // 5

// Compile only — returns raw WASM binary (no JS adaptation)
const wasm = compile('export let f = (x) => x * 2')
const mod = new WebAssembly.Module(wasm)
const inst = new WebAssembly.Instance(mod)

// Async WASM startup — jz source compilation is still synchronous
const asyncInst = compile('export let f = (x) => x * 2')
asyncInst.exports.f(21) // 42

CLI

npm install -g jz

# Compile
jz program.js # → program.wasm

# Evaluate
jz -e "1 + 2" # 3

# Show help
jz --help

Language

JZ is a strict functional JS subset. Built-in jzify transform extends support to legacy patterns.

┌────────────────────────────────────────────────────────────────────────┐
│ JZify                                                                  │
│   var  function  arguments  switch  new Foo()                          │
│   ==  !=  instanceof  undefined                                        │
│                                                                        │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ JZ                                                                 │ │
│ │   let/const  =>  ...xs  destructuring  import/export               │ │
│ │   if/else  for/while/do-while/of/in  break/continue                │ │
│ │   try/catch/finally  throw                                         │ │
│ │   operators  strings  booleans  numbers  arrays  objects  `${}`    │ │
│ │   Math  Number  String  Array  Object  JSON  RegExp  Symbol  null  │ │
│ │   ArrayBuffer  DataView  TypedArray  Map  Set                      │ │
│ │   console  setTimeout/setInterval  Date  performance               │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
Not supported
  async/await  Promise  function*  yield
  this  class  super  extends  delete  labels
  eval  Function  with  Proxy  Reflect  WeakMap  WeakSet
  dynamic import  DOM  fetch  Intl  Node APIs

FAQ

How to pass data between JS and WASM?

Numbers pass directly as f64, arrays of ≤ 8 elements return as plain JS arrays (multi-value). Strings, arrays, objects, and typed arrays are heap values — inst.memory provides read/write across the boundary:

const { exports, memory } = jz`
  export let greet = (s) => s.length
  export let sum = (a) => a.reduce((s, x) => s + x, 0)
  export let dist = (p) => (p.x * p.x + p.y * p.y) ** 0.5
  export let rgb = (c) => [c, c * 0.5, c * 0.2]
  export let process = (buf) => buf.map(x => x * 2)
`

// JS → WASM (write)
memory.String('hello')               // → string pointer
memory.Array([1, 2, 3])              // → array pointer
memory.Float64Array([1.0, 2.0])      // → typed array pointer
memory.Int32Array([10, 20, 30])      // all typed array constructors available

// ⚠ Objects: keys and order must match the jz source declaration.
// jz objects are fixed-layout schemas (like C structs), not dynamic key bags.
// If the jz source declares `{ x, y }`, you must pass `{ x, y }` in that order.
memory.Object({ x: 3, y: 4 })       // → object pointer

// Strings/arrays inside objects are auto-wrapped to pointers:
memory.Object({ name: 'jz', count: 3 })  // name auto-wrapped via memory.String

// Call with pointers
exports.greet(memory.String('hello'))          // 5
exports.sum(memory.Array([1, 2, 3]))           // 6
exports.dist(memory.Object({ x: 3, y: 4 }))   // 5

// direct JS array return
exports.rgb(100)      // [100, 50, 20]

// read pointer value
memory.read(exports.process(memory.Float64Array([1, 2, 3])))  // Float64Array [2, 4, 6]

Template interpolation handles most of this automatically — strings, arrays, numbers, and numeric objects are marshaled for you:

jz`export let f = () => ${'hello'}.length + ${[1,2,3]}[0] + ${{x: 5, y: 10}}.x`
How does template interpolation work?

Numbers and booleans inline directly into source. Strings, arrays, and objects are serialized as jz source literals and compiled at compile time — no post-instantiation allocation, no getter overhead:

jz`export let f = () => ${'hello'}.length`              // 5 — string compiled as literal
jz`export let f = () => ${[10, 20, 30]}[1]`             // 20 — array compiled as literal
jz`export let f = () => ${{name: 'jz', count: 3}}.count` // 3 — object compiled as literal

// Nested values work too
jz`export let f = () => ${{label: 'origin', x: 0, y: 0}}.label.length`  // 6

Functions are imported as host calls. Non-serializable values (host objects, class instances) fall back to post-instantiation getters automatically.

Does it support ES module imports?

Yes — standard ES import syntax is bundled at compile-time into a single WASM.

const { exports } = jz(
  'import { add } from "./math.jz"; export let f = (a, b) => add(a, b)',
  { modules: { './math.jz': 'export let add = (a, b) => a + b' } }
)

Transitive imports work (main → math → utils → …). Circular imports error at compile time. Output is always one WASM binary — no runtime resolution.

CLI resolves filesystem imports automatically.

jz main.jz -o main.wasm    # reads ./math.jz, ./utils.jz automatically

Browser: fetch sources yourself, pass via { modules }. The compiler stays synchronous and pure — no I/O.

// Transitive bundling — all merged into one WASM
const { exports } = jz(mainSrc, { modules: {
  './math.jz': 'import { sq } from "./utils.jz"; export let dist = (x, y) => (sq(x) + sq(y)) ** 0.5',
  // Fetch sources yourself, pass them in
  './utils.jz': await fetch('./util.jz').then(r => r.text())
} })
How do I pass values from the host to jz?

Any host namespace — functions, constants, custom objects — wires in via the imports option. jz extracts what's needed via Object.getOwnPropertyNames, so non-enumerable built-ins (Math.sin, Date.now) work automatically:

// Custom function
const { exports } = jz(
  'import { log } from "host"; export let f = (x) => { log(x); return x }',
  { imports: { host: { log: console.log } } }
)

// Whole namespace — sin, cos, sqrt, PI, etc. all auto-wired
const { exports } = jz(
  'import { sin, PI } from "math"; export let f = () => sin(PI / 2)',
  { imports: { math: Math } }
)

// Date static methods
const { exports } = jz(
  'import { now } from "date"; export let f = () => now()',
  { imports: { date: Date } }
)

// window / globalThis
const { exports } = jz(
  'import { parseInt } from "window"; export let f = () => parseInt("42")',
  { imports: { window: globalThis } }
)

For per-call data (numbers, strings, arrays, objects, typed arrays), see How to pass data between JS and WASM? above — pointers via memory.String/memory.Array/memory.Object or template interpolation.

Can two modules share data?

Yes — jz.memory() creates a shared memory that modules compile into. Schemas accumulate automatically, so objects created in one module are readable by another:

const memory = jz.memory()

const a = jz('export let make = () => { let o = {x: 10, y: 20}; return o }', { memory })
const b = jz('export let read = (o) => o.x + o.y', { memory })

// Object from module a, processed by module b — same memory, merged schemas
b.exports.read(a.exports.make())  // 30

// Read from JS too — memory knows all schemas
memory.read(a.exports.make())  // {x: 10, y: 20}

// Write from JS before any compilation
memory.String('hello')      // → NaN-boxed pointer
memory.Array([1, 2, 3])     // → NaN-boxed pointer

jz.memory() returns an actual WebAssembly.Memory (monkey-patched with .read(), .String(), .Array(), .Object(), .write(), etc). You can also pass an existing memory: jz.memory(new WebAssembly.Memory({ initial: 4 })) patches and returns the same object. Passing raw WebAssembly.Memory to { memory } auto-wraps it.

Modules sharing a memory share a single bump allocator — see How does memory work? below. Use .instance.exports for raw pointers, .exports for the JS-wrapped surface.

How does memory work? How do I reset it?

jz uses a bump allocator: every heap value (string, array, object, typed array) bumps a single pointer forward. No free list, no GC, no per-object header overhead beyond [len][cap]. Bytes 0–1023 are reserved (data segment + heap-pointer slot at byte 1020); the heap starts at byte 1024 and grows the WASM memory automatically when full.

This means memory is never reclaimed implicitly — long-running programs that allocate per call will grow without bound. The fix is to reset the heap pointer between independent batches:

const { exports, memory } = jz`
  export let process = (n) => {
    let xs = []
    for (let i = 0; i < n; i++) xs.push(i * 2)
    return xs.reduce((s, x) => s + x, 0)
  }
`

for (let i = 0; i < 1000; i++) {
  const sum = exports.process(100)   // allocates an array each call
  memory.reset()                     // drop everything; heap ptr → 1024
}

After memory.reset() all previously returned pointers are invalid — read what you need first, then reset.

For finer control, allocate manually: memory.alloc(bytes) returns a raw offset using the same bump pointer. Pure scalar modules (no strings/arrays/objects) are compiled without the allocator at all — no _alloc, no _clear, no memory section.

Non-JS hosts (wasmtime, wasmer, deno, EdgeJS, embedded WASM) get the same allocator via two exports:

(func $_alloc (param $bytes i32) (result i32))   ;; returns heap offset
(func $_clear)                                    ;; rewinds heap pointer to 1024

memory.reset() and memory.alloc() are JS-side aliases for these. Headers vary by type: strings store [len:i32] + utf8 bytes (offset = _alloc(4+n) + 4); arrays / typed arrays / objects store [len:i32, cap:i32] + payload (offset = _alloc(8+bytes) + 8). The pointer crossing the WASM boundary is the f64 NaN-box 0x7FF8 << 48 | type << 47 | aux << 32 | offset — see src/host.js for type codes and the canonical encoders. Call _clear() between batches to reclaim. Strip both with compile(code, { runtimeExports: false }) if you only call functions and never marshal heap values across the boundary.

How do I run compiled WASM outside the browser?
jz program.js -o program.wasm

# Run with any WASM runtime
wasmtime program.wasm     # WASI support built in
wasmer run program.wasm
deno run program.wasm

Pure numeric modules have no imports and instantiate with standard WebAssembly.Module / WebAssembly.Instance, which is the right shape for JS hosts such as EdgeJS. Compile once at startup or build time, then reuse the module; do not compile JZ source per request.

Two host modes select how runtime services lower:

jz.compile(code)                      // host: 'js' (default) — env.* imports
jz.compile(code, { host: 'wasi' })    // wasi_snapshot_preview1.* imports

host: 'js' (default) — console.log/Date.now/performance.now import from env.* and the JS host (jz() runtime) wires them automatically. Host-side stringification means jz drops __ftoa/__write_*/__to_str from the binary.

host: 'wasi'console.log compiles to WASI fd_write, clocks to clock_time_get. Output runs natively on wasmtime/wasmer/deno. In JS hosts, the small jz/wasi polyfill is auto-applied; pass { write(fd, text) {…} } to capture stdout/stderr. host: 'wasi' errors at compile time if a program would emit env.__ext_* (dynamic dispatch into the JS host) — annotate the receiver or stay on host: 'js'.

What host features are supported?
JS API host: 'js' (default) host: 'wasi'
console.log() env.print(val: i64, fd: i32, sep: i32) — host stringifies WASI fd_write (fd=1), space-separated, newline appended
console.warn/error same, fd=2 WASI fd_write (fd=2)
Date.now() env.now(0) -> f64 (epoch ms) clock_time_get (realtime)
performance.now() env.now(1) -> f64 (monotonic ms) clock_time_get (monotonic)
setTimeout/clearTimeout env.setTimeout(cb, delay, repeat) -> f64 / env.clearTimeout(id) -> f64 — host schedules; fires via exported __invoke_closure WASM timer queue + __timer_tick (or blocking __timer_loop on wasmtime)
setInterval/clearInterval same env.setTimeout (repeat=1) / env.clearTimeout WASM timer queue + __timer_tick
dynamic obj.method() env.__ext_call (JS resolves) error at compile time

The compiled .wasm uses at most one import namespace:

  • none — pure scalar/compute modules. Instantiate directly with standard WebAssembly APIs.
  • env — JS-host services (default). Auto-wired by the jz() runtime.
  • wasi_snapshot_preview1 — standard WASI Preview 1. Run natively on wasmtime/wasmer/deno.
How do I add custom operators / extend the stdlib?

jz's emitter table (ctx.core.emit) maps AST operators → WASM IR generators. Module files in module/ register handlers on it. To add your own:

import { emitter } from './src/emit.js'
import { typed } from './src/ir.js'

// Register a custom operator: my.double(x) → x * 2
emitter['my.double'] = (x) => {
  return ['f64.mul', ['f64.const', 2], typed(x, 'f64')]
}

The naming convention follows the AST path: Math.sinmath.sin, arr.push.push, typed variants like .f64:push. See any file in module/ for the full pattern — each exports a function that receives ctx and registers emitters, stdlib, globals, or helpers.

Inside a runtime module, import directly from the layer you need:

import { emit } from '../src/emit.js'
import { asF64, temp } from '../src/ir.js'
import { valTypeOf, VAL } from '../src/analyze.js'
Can I compile jz to C?

Yes, via wasm2c or w2c2:

jz program.js -o program.wasm
wasm2c program.wasm -o program.c
cc program.c -o program

Benchmark

jz Node Porffor AS WAT C Go Zig Rust NumPy
biquad 4.48ms
4.1kB
8.94ms
3.2kB
6.37ms
1.9kB
6.45ms
767 B
5.30ms 8.91ms
fma
5.06ms 5.28ms 3.12s
tokenizer 0.06ms
1.7kB
0.12ms
1.4kB
0.46ms
2.6kB
0.05ms
1.5kB
0.08ms
344 B
0.14ms 0.07ms 0.12ms 0.12ms 5.15ms
mat4 2.86ms
1.8kB
8.17ms
1.1kB
86.46ms
2.3kB
6.49ms
1.5kB
7.83ms
353 B
2.60ms 11.61ms 2.60ms 0.80ms 311.06ms
aos 1.09ms
2.3kB
1.30ms
1.1kB
1.34ms
2.2kB
1.07ms
481 B
1.20ms 0.91ms 0.91ms 1.20ms 2.57ms
bitwise 3.45ms
1.2kB
3.74ms
1005 B
8.66ms
1.5kB
4.86ms
355 B
1.30ms 5.20ms 4.15ms 1.30ms 14.72ms
poly 0.73ms
1.2kB
1.52ms
1014 B
0.72ms
1.3kB
0.81ms
359 B
0.57ms 0.79ms 0.89ms 0.63ms 0.60ms
callback 0.03ms
1.5kB
0.60ms
828 B
1.03ms
1.9kB
0.24ms
267 B
0.08ms 0.23ms 0.01ms 0.12ms 1.78ms
json 0.13ms
2.9kB
0.29ms
923 B
0.02ms 1.04ms <0.01ms 0.03ms 1.17ms
watr 0.98ms
166.1kB
1.43ms
2.6kB

Numbers from node bench/bench.mjs on Apple Silicon.

Alternatives

  • porffor — ahead-of-time JS→WASM compiler targeting full TC39 semantics. Implements the spec progressively (test262). Where jz restricts the language for performance, porffor aims for completeness.
  • assemblyscript — TypeScript-subset compiling to WASM — small, performant output, but requires type annotations.
  • jawsm — JS→WASM compiler in Rust. Compiles standard JS with a runtime that provides GC and closures in WASM.

Build with

  • subscript — JS parser. Minimal, extensible, builds the exact AST jz needs without a full ES parser. Jessie subset keeps the grammar small and deterministic.
  • watr — WAT to WASM compiler. Handles binary encoding, validation, and peephole optimization. jz emits WAT text, watr turns it into a valid .wasm binary.

MIT •

About

JS→WASM

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors