jz 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
 
4
4
 
5
- ## ![stability](https://img.shields.io/badge/stability-experimental-black) [![npm](https://img.shields.io/npm/v/jz?color=gray)](http://npmjs.org/jz) [![test](https://github.com/dy/jz/actions/workflows/test.yml/badge.svg)](https://github.com/dy/jz/actions/workflows/test.yml)
5
+ ## ![stability](https://img.shields.io/badge/stability-experimental-black) [![npm](https://img.shields.io/npm/v/jz?color=black)](http://npmjs.org/jz) [![test](https://github.com/dy/jz/actions/workflows/test.yml/badge.svg)](https://github.com/dy/jz/actions/workflows/test.yml) [![test262](https://github.com/dy/jz/actions/workflows/test262.yml/badge.svg)](https://github.com/dy/jz/actions/workflows/test262.yml) [![bench](https://github.com/dy/jz/actions/workflows/bench.yml/badge.svg)](https://github.com/dy/jz/actions/workflows/bench.yml)
6
6
 
7
7
 
8
- **JZ** (_javascript zero_) is **minimal modern functional JS subset**, compiling to WASM.<br/>
8
+ **JZ** (_javascript zero_) is a **minimal modern functional JS subset** that compiles to WASM.<br>
9
9
 
10
10
  ```js
11
11
  import jz from 'jz'
@@ -17,12 +17,12 @@ dist(3, 4) // 5
17
17
 
18
18
  ## Why?
19
19
 
20
- **Write plain JS, compile to WASM** fast, portable and long-lasting.<br>
21
- JZ distills the modern functional core the "good parts" ([Crockford](https://www.youtube.com/watch?v=_DKkVvOt6dk)) from legacy semantics, features overhead and perf quirks.
20
+ **Write plain JS, compile to WASM** fast, portable and long-lasting.<br>
21
+ JZ distills modern JS to its functional core the "good parts" ([Crockford](https://www.youtube.com/watch?v=_DKkVvOt6dk)) without the legacy semantics, feature bloat, and perf quirks.
22
22
 
23
23
  * **Static AOT** – no runtime, no GC, no dynamic constructs.
24
24
  * **Valid jz = valid js** — test in browser, compile to wasm.
25
- * **Minimal** — output is close to hand-written WAT.
25
+ * **Minimal** — output is close to hand-written WAT; CI gates it to stay at least as small and fast as AssemblyScript and Porffor on the bench corpus ([CONTRIBUTING](CONTRIBUTING.md#performance--size-invariant)).
26
26
  <!-- * **Realtime** — compiles faster than `eval`, useful for live-coding and REPL. -->
27
27
 
28
28
  | Good for | Not for |
@@ -38,6 +38,8 @@ JZ distills the modern functional core – the "good parts" ([Crockford](https:/
38
38
 
39
39
  ## Usage
40
40
 
41
+ `npm install jz`
42
+
41
43
  ```js
42
44
  import jz, { compile } from 'jz'
43
45
 
@@ -63,16 +65,16 @@ Options are passed as `jz(source, opts)` or `compile(source, opts)`. Common ones
63
65
 
64
66
  | Option | Use |
65
67
  |---|---|
66
- | `jzify: true` | Accept broader JS patterns such as `var`, `function`, `switch`, `arguments`, `==`, and `undefined` by lowering them to the JZ subset. |
68
+ | `jzify: true` | Accept broader JS patterns such as `var`, `function`, `switch`, `arguments`, `==`, `undefined`, and `class` (see *Not supported* below for the class subset) by lowering them to the JZ subset. The CLI auto-enables this for `.js` files. |
67
69
  | `modules: { specifier: source }` | Bundle static ES imports into one WASM module. CLI import resolution does this from files automatically. |
68
70
  | `imports: { mod: host }` | Wire host namespaces/functions used by `import { fn } from "mod"`; functions may be plain JS functions or `{ fn, returns }` specs. |
69
71
  | `memory` | Pass `memory: N` to create owned memory with `N` initial pages, or pass `memory: jz.memory()` / `WebAssembly.Memory` to share memory across modules. |
70
72
  | `host: 'js' \| 'wasi'` | Select runtime-service lowering. Default `js` uses small `env.*` imports auto-wired by `jz()`; `wasi` emits WASI Preview 1 imports for wasmtime/wasmer/deno. |
71
- | `optimize` | `false`/`0` disables optimization, `1` keeps cheap size passes, `true`/`2` is the default, `3` enables aggressive experimental passes. String aliases `'size'` (unroll/vectorize off, tight scalar caps — smallest wasm), `'balanced'` (= default), `'speed'` (full unroll + SIMD). Object form overrides individual passes/knobs (and accepts `level:` as a number or alias base). |
73
+ | `optimize` | `false`/`0` disables optimization, `1` keeps cheap size passes, `true`/`2` is the default (every stable jz pass + full watr), `3` adds larger array/hash initial caps and inlines `f64.const` over mutable globals (trades size for speed). String aliases `'size'` (unroll/vectorize off, tight scalar caps — smallest wasm), `'balanced'` (= default), `'speed'` (full unroll + SIMD). Object form overrides individual passes/knobs (and accepts `level:` as a number or alias base). |
72
74
  | `strict: true` | Reject dynamic fallbacks such as unknown receiver method calls, `obj[k]`, and `for-in` instead of emitting JS-host dynamic dispatch. |
73
75
  | `alloc: false` | Omit raw allocator exports like `_alloc`/`_clear` when compiling standalone WASM that never marshals heap values across the host boundary. |
74
76
  | `wat: true` | `compile()` returns WAT text instead of a WASM binary. |
75
- | `profile` | Pass a mutable sink to collect compile-stage timings; set `profile.names = true` to also emit a WASM `name` section for profiler/debugger symbolication. `profileNames` remains as a legacy alias. |
77
+ | `profile` | Pass a mutable sink to collect compile-stage timings; set `profile.names = true` to also emit a WASM `name` section for profiler/debugger symbolication. |
76
78
 
77
79
  </details>
78
80
 
@@ -86,8 +88,7 @@ jz program.js # → program.wasm
86
88
  jz program.js --wat # → program.wat
87
89
  jz program.js -o out.wasm # custom output (- for stdout)
88
90
 
89
- # Optimization level: -O0 off, -O1 size-only, -O2 default, -O3 aggressive
90
- # aliases: -Os/--optimize size, -Ob/balanced, -Of/speed
91
+ # Optimization level: -O0 off, -O1 size, -O2 balanced, -O3 speed
91
92
  jz program.js -O3
92
93
 
93
94
  # Runtime-service lowering: js (default) or wasi
@@ -100,7 +101,6 @@ jz -e "1 + 2" # 3
100
101
  jz --help
101
102
  ```
102
103
 
103
- Other flags: `--strict` (no auto-`jzify`, reject dynamic fallbacks), `--jzify` (transform JS → jz, no compile), `--no-alloc` (omit `_alloc`/`_clear`), `--names` (emit wasm `name` section), `--resolve` (Node.js bare-specifier resolution), `--imports <file.json>` (host import specs).
104
104
 
105
105
  ## Language
106
106
 
@@ -110,7 +110,8 @@ JZ is a strict functional JS subset. Built-in `jzify` transform extends support
110
110
  ┌────────────────────────────────────────────────────────────────────────┐
111
111
  │ JZify │
112
112
  │ var function arguments switch new Foo() │
113
- == != instanceof undefined
113
+ | class new this extends super static #private
114
+ │ == != instanceof undefined |
114
115
  │ │
115
116
  │ ┌────────────────────────────────────────────────────────────────────┐ │
116
117
  │ │ JZ │ │
@@ -125,403 +126,421 @@ JZ is a strict functional JS subset. Built-in `jzify` transform extends support
125
126
  └────────────────────────────────────────────────────────────────────────┘
126
127
  Not supported
127
128
  async/await Promise function* yield
128
- this class super extends delete labels
129
- eval Function with Proxy Reflect WeakMap WeakSet
130
- dynamic import DOM fetch Intl Node APIs
129
+ delete labels eval Function with
130
+ Proxy Reflect WeakMap WeakSet
131
+ import() DOM fetch Intl Node APIs
131
132
  ```
133
+
134
+ `jzify` covers most class syntax — fields, methods, `new`, `this`, `extends`, `super.method()`, `#private`, constant computed member names. Getters/setters, bare `super.x` reads, and dynamic computed names are rejected with a clear message.
135
+
132
136
  ## FAQ
133
137
 
134
138
  <details>
135
- <summary><strong>How to pass data between JS and WASM?</strong></summary>
139
+ <summary><strong>Where does jz differ from JavaScript?</strong></summary>
136
140
 
137
141
  <br>
138
142
 
143
+ `Valid jz = valid JS` means jz source always parses and runs as JS — but jz compiles to *static* WASM, so a handful of behaviors diverge from V8. These are deliberate trades, not unfinished corners: each one is what keeps the output close to hand-written WAT. `--wat` shows exactly what was emitted. (For what's out of scope entirely — `eval`, `async`, `Proxy`, … — see the *Not supported* box above; for moving values across the boundary, see [Interop](#interop).)
139
144
 
140
- 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:
141
-
142
- > [!WARNING] jz objects are fixed-layout schemas (like C structs), not dynamic key bags.
143
- > `memory.Object({ x: 3, y: 4 })` expects the same key order as the jz source `{ x, y }`.
144
- > `{ y: 4, x: 3 }` with reversed keys will produce wrong values.
145
-
146
- ```js
147
- const { exports, memory } = jz`
148
- export let greet = (s) => s.length
149
- export let sum = (a) => a.reduce((s, x) => s + x, 0)
150
- export let dist = (p) => (p.x * p.x + p.y * p.y) ** 0.5
151
- export let rgb = (c) => [c, c * 0.5, c * 0.2]
152
- export let process = (buf) => buf.map(x => x * 2)
153
- `
154
-
155
- // JS → WASM (write)
156
- memory.String('hello') // → string pointer
157
- memory.Array([1, 2, 3]) // → array pointer
158
- memory.Float64Array([1.0, 2.0]) // → typed array pointer
159
- memory.Int32Array([10, 20, 30]) // all typed array constructors available
145
+ - **A boolean can surface as `1`/`0` at the host boundary.** `typeof`, `String`, `JSON.stringify`, and a directly-returned comparison all hand back a real boolean but a boolean produced by value-preserving `&&`/`||`, or read bare from an untyped container, crosses as the numeric carrier `1`/`0`.
146
+ - **Objects are fixed-layout schemas** — key set and order fixed at the literal; `delete` is rejected; `memory.Object({…})` must match the source key order.
147
+ - **Errors are untagged** `throw` carries a value, not a typed `Error`; `e instanceof TypeError` does not discriminate.
148
+ - **`Set`/`Map` iterate slot order**, not insertion order.
149
+ - **Memory is not reclaimed automatically** see *How does memory work?* below.
160
150
 
161
- // Objects: keys and order must match the jz source declaration.
162
- // jz objects are fixed-layout schemas (like C structs), not dynamic key bags.
163
- // If the jz source declares `{ x, y }`, you must pass `{ x, y }` in that order.
164
- memory.Object({ x: 3, y: 4 }) // → object pointer
151
+ For full TC39 conformance use [porffor](https://github.com/CanadaHonk/porffor); jz trades completeness for low-level numeric performance by design.
165
152
 
166
- // Strings/arrays inside objects are auto-wrapped to pointers:
167
- memory.Object({ name: 'jz', count: 3 }) // name auto-wrapped via memory.String
153
+ </details>
168
154
 
169
- // Call with pointers
170
- exports.greet(memory.String('hello')) // 5
171
- exports.sum(memory.Array([1, 2, 3])) // 6
172
- exports.dist(memory.Object({ x: 3, y: 4 })) // 5
155
+ <details>
156
+ <summary><strong>Can I use npm packages or existing JS libraries?</strong></summary>
173
157
 
174
- // direct JS array return
175
- exports.rgb(100) // [100, 50, 20]
158
+ <br>
176
159
 
177
- // read pointer value
178
- memory.read(exports.process(memory.Float64Array([1, 2, 3]))) // Float64Array [2, 4, 6]
179
- ```
160
+ Only code that fits the jz subset. There's no runtime, so packages touching the DOM, `async`/`Promise`, the network, or Node APIs won't compile — but pure numeric/algorithmic source does.
180
161
 
181
- Template interpolation handles most of this automatically — strings, arrays, numbers, and numeric objects are marshaled for you:
162
+ - **Relative imports** (`./dep.js`) bundle at compile time.
163
+ - **Bare specifiers** (`import { x } from "pkg"`) resolve through Node module resolution only with the `--resolve` CLI flag, or by passing the source yourself via `{ modules }`. The package's source still has to be valid jz.
182
164
 
183
- ```js
184
- jz`export let f = () => ${'hello'}.length + ${[1,2,3]}[0] + ${{x: 5, y: 10}}.x`
185
- ```
165
+ jz is for compiling *your* numeric/DSP/parser code, not for running the npm ecosystem.
186
166
 
187
- <!--
188
- ### How does everything fit in f64?
167
+ </details>
189
168
 
190
- All values are IEEE 754 f64 (at WASM boundary). Integers up to 2^53 are exact. Heap types use [NaN-boxing](https://nachtimwald.com/2019/11/06/nan-boxing/): quiet NaN (`0x7FF8`) + 51-bit payload `[type:4][aux:15][offset:32]`.
169
+ <details>
170
+ <summary><strong>Can I use import/export to split code?</strong></summary>
191
171
 
192
- | Type | Code | Payload | Example |
193
- |------|------|---------|---------|
194
- | Number | — | regular f64 | `3.14`, `42`, `NaN` |
195
- | Null | 0 | reserved pattern | `null` (distinct from `0` and `NaN`) |
196
- | Array | 1 | aux=length, offset=heap | `[1, 2, 3]` |
197
- | ArrayBuffer | 2 | offset=heap | `new ArrayBuffer(16)` |
198
- | TypedArray | 3 | aux=elemType, offset=heap | `new Float64Array(n)` |
199
- | String | 4 | offset=heap | `"hello world"` (>4 chars) |
200
- | SSO String | 5 | aux=packed chars | `"hi"` (<=4 ASCII chars, zero alloc) |
201
- | Object | 6 | aux=schemaId, offset=heap | `{x: 1, y: 2}` |
202
- | Hash | 7 | offset=heap | dynamic string-keyed objects |
203
- | Set | 8 | offset=heap | `new Set()` |
204
- | Map | 9 | offset=heap | `new Map()` |
205
- | Closure | 10 | aux=funcIdx, offset=env | `x => x + captured` |
206
- | External | 11 | offset=hostMap index | JS host object references |
172
+ <br>
207
173
 
208
- **Why NaN-boxing?** used by LuaJIT, JavaScriptCore, SpiderMonkey. The alternatives tagged unions (OCaml, Haskell), pointer tagging (V8 Smis), or separate type+value pairs all require branching at call boundaries or multi-word passing. NaN-boxing fits any value in one 64-bit word: one calling convention, one memory layout, one comparison instruction.
174
+ Yes. Standard `import`/`export` syntax is bundled at compile time into a single WASMno runtime module resolution.
209
175
 
210
- **The f64 tradeoff**: f64 arithmetic is ~1.2x slower than i32 for pure integer work on most architectures. jz mitigates this — `analyzeLocals` preserves i32 for loop counters, bitwise ops, and comparisons, so the penalty only applies to mixed-type parameters. The gain: zero interop cost at the JS↔WASM boundary (f64 is WASM's native JS-compatible type), no marshaling, no boxing/unboxing. For jz's target workloads (DSP, typed arrays, math), f64 is the natural type anyway.
176
+ ```js
177
+ const { exports } = jz(
178
+ 'import { add } from "./math.jz"; export let f = (a, b) => add(a, b)',
179
+ { modules: { './math.jz': 'export let add = (a, b) => a + b' } }
180
+ )
181
+ ```
211
182
 
212
- **NaN preservation**: IEEE 754 defines 2^52 1 distinct NaN bit patterns. WASM preserves NaN payload bits through arithmetic (spec requires `nondeterministic_nan`), and JS engines canonicalize only on certain operations (`Math.fround`, structured clone). jz uses quiet NaNs (`0x7FF8` prefix) which survive all standard paths. The 51 payload bits encode type (4), aux metadata (15), and heap offset (32) enough for 4GB addressable memory and 12 type codes.
213
- -->
183
+ Transitive imports work (main math utils …); circular imports error at compile time. The **CLI** resolves filesystem imports automatically (`jz main.jz -o main.wasm` reads `./math.jz` etc.). In the **browser**, fetch sources yourself and pass them via `{ modules }`the compiler stays synchronous and pure, no I/O.
214
184
 
215
185
  </details>
216
186
 
217
187
  <details>
218
- <summary><strong>How does template interpolation work?</strong></summary>
188
+ <summary><strong>How does memory work? How do I reset it?</strong></summary>
219
189
 
220
190
  <br>
221
191
 
222
- 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:
192
+ jz uses a **bump allocator**: every heap value (string, array, object, typed array) bumps a single pointer forward no free list, no GC. The heap starts at byte 1024 and grows the WASM memory automatically when full.
223
193
 
224
- ```js
225
- jz`export let f = () => ${'hello'}.length` // 5 — string compiled as literal
226
- jz`export let f = () => ${[10, 20, 30]}[1]` // 20 — array compiled as literal
227
- jz`export let f = () => ${{name: 'jz', count: 3}}.count` // 3 — object compiled as literal
194
+ So **memory is never reclaimed implicitly** — a long-running program that allocates per call grows without bound. Reset the heap pointer between independent batches:
228
195
 
229
- // Nested values work too
230
- jz`export let f = () => ${{label: 'origin', x: 0, y: 0}}.label.length` // 6
196
+ ```js
197
+ for (let i = 0; i < 1000; i++) {
198
+ const sum = exports.process(100) // allocates an array each call
199
+ memory.reset() // drop everything; heap ptr → 1024
200
+ }
231
201
  ```
232
202
 
233
- Functions are imported as host calls. Non-serializable values (host objects, class instances) fall back to post-instantiation getters automatically.
203
+ After `memory.reset()` all previously returned pointers are invalid read what you need first, then reset. For finer control, `memory.alloc(bytes)` returns a raw offset on the same pointer. Pure scalar modules (no heap values) compile without the allocator at all. The low-level export/encoding contract is in [Interop](#interop).
234
204
 
235
205
  </details>
236
206
 
237
207
  <details>
238
- <summary><strong>Does it support ES module imports?</strong></summary>
208
+ <summary><strong>How do I see and control inferred types?</strong></summary>
239
209
 
240
210
  <br>
241
211
 
242
- Yesstandard ES `import` syntax is bundled at compile-time into a single WASM.
212
+ Inference is mechanical and visible not the hidden, fragile, coercive thing the "explicit > implicit" reflex assumes. It reads the same signals a human reader does: literals, operators (`x | 0` i32), member access (`s.length` → string), `typeof` guards, and assignment flow. The chosen types appear in `--wat`; ambiguous cases fall back to NaN-boxed **f64** a safe default, never a wrong type.
243
213
 
244
- ```js
245
- const { exports } = jz(
246
- 'import { add } from "./math.jz"; export let f = (a, b) => add(a, b)',
247
- { modules: { './math.jz': 'export let add = (a, b) => a + b' } }
248
- )
249
- ```
214
+ So there's nothing to annotate. Type annotations bundle two jobs into one syntax: hinting storage to the compiler (`let x: number` — which only duplicates what `x | 0` already tells inference) and documenting contracts at boundaries (a *docs* concern, not a *language* one). jz keeps the split clean — inference handles storage, and **valid jz = valid JS** means no parallel type system to learn. To pin a type, write code that implies it: `x | 0` keeps `x` an i32; an `s = ''` default declares a string param. (JSDoc `@type` is planned as an advisory hint, not yet enforced.) Annotations never make code faster; they only sharpen what inference already sees.
250
215
 
251
- Transitive imports work (main → math → utils → …). Circular imports error at compile time. Output is always one WASM binary — no runtime resolution.
216
+ </details>
252
217
 
253
- **CLI** resolves filesystem imports automatically.
218
+ <details>
219
+ <summary><strong>Is it production-ready?</strong></summary>
254
220
 
255
- ```sh
256
- jz main.jz -o main.wasm # reads ./math.jz, ./utils.jz automatically
257
- ```
221
+ <br>
258
222
 
259
- **Browser**: fetch sources yourself, pass via `{ modules }`. The compiler stays synchronous and pure no I/O.
223
+ It's **experimental** (`0.4.0`) the compiler API and option names may still change. What's stable is the *output*: jz emits deterministic WASM, gated on every push by test262, a differential fuzzer (`test/differential.js` runs jz-compiled wasm against the same source as plain JS), and the size/speed bench.
260
224
 
261
- ```js
262
- // Transitive bundling — all merged into one WASM
263
- const { exports } = jz(mainSrc, { modules: {
264
- './math.jz': 'import { sq } from "./utils.jz"; export let dist = (x, y) => (sq(x) + sq(y)) ** 0.5',
265
- // Fetch sources yourself, pass them in
266
- './utils.jz': await fetch('./util.jz').then(r => r.text())
267
- } })
268
- ```
225
+ The robust way to depend on jz today: compile to `.wasm` at build time, commit the binary, and load it through the dependency-free [`jz/interop`](#interop) bridge — your app then rides on the WASM, not on the compiler's evolving API.
269
226
 
270
227
  </details>
271
228
 
272
229
  <details>
273
- <summary><strong>How do I pass values from the host to jz?</strong></summary>
230
+ <summary><strong>Why jz over Porffor or AssemblyScript?</strong></summary>
274
231
 
275
232
  <br>
276
233
 
277
- 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:
234
+ Pick jz for plain JS that fits the subset and tiny, native-fast numeric/DSP WASM. For full TC39, a typed TypeScript dialect, or running standard JS unchanged, see the [Alternatives](#alternatives) decision table (porffor / AssemblyScript / jawsm).
278
235
 
279
- ```js
280
- // Custom function
281
- const { exports } = jz(
282
- 'import { log } from "host"; export let f = (x) => { log(x); return x }',
283
- { imports: { host: { log: console.log } } }
284
- )
236
+ </details>
285
237
 
286
- // Whole namespace — sin, cos, sqrt, PI, etc. all auto-wired
287
- const { exports } = jz(
288
- 'import { sin, PI } from "math"; export let f = () => sin(PI / 2)',
289
- { imports: { math: Math } }
290
- )
238
+ <details>
239
+ <summary><strong>Can I compile jz to C?</strong></summary>
291
240
 
292
- // Date static methods
293
- const { exports } = jz(
294
- 'import { now } from "date"; export let f = () => now()',
295
- { imports: { date: Date } }
296
- )
241
+ <br>
297
242
 
298
- // window / globalThis
299
- const { exports } = jz(
300
- 'import { parseInt } from "window"; export let f = () => parseInt("42")',
301
- { imports: { window: globalThis } }
302
- )
243
+ Yes, via [wasm2c](https://github.com/WebAssembly/wabt/blob/main/wasm2c) or [w2c2](https://github.com/turbolent/w2c2):
244
+
245
+ ```sh
246
+ jz program.js -o program.wasm
247
+ wasm-opt -O3 program.wasm -o program.opt.wasm # trims redundant locals/loads first
248
+ wasm2c program.opt.wasm -o program.c
249
+ cc -O3 program.c -o program
303
250
  ```
304
251
 
305
- 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.
252
+ The full native pipeline (jz `wasm-opt -O3` `wasm2c` → `clang -O3 -flto` + PGO) lands within a few percent of hand-tuned C — beating V8 on 19 of 21 bench cases on an M4 Max. Details and the regression gate live in [`scripts/native/README.md`](scripts/native/README.md).
306
253
 
307
254
  </details>
308
255
 
309
256
  <details>
310
- <summary><strong>Can two modules share data?</strong></summary>
257
+ <summary><strong>Can I add my own operators or stdlib methods?</strong></summary>
311
258
 
312
259
  <br>
313
260
 
314
- Yes — `jz.memory()` creates a shared memory that modules compile into. Schemas accumulate automatically, so objects created in one module are readable by another:
261
+ Yes — jz's emitter table (`ctx.core.emit`) maps AST operators to WASM IR generators, and the whole stdlib is just modules registering on it. Adding one is the same move the built-ins make:
315
262
 
316
263
  ```js
317
- const memory = jz.memory()
264
+ import { emitter } from './src/emit.js'
265
+ import { typed } from './src/ir.js'
318
266
 
319
- const a = jz('export let make = () => { let o = {x: 10, y: 20}; return o }', { memory })
320
- const b = jz('export let read = (o) => o.x + o.y', { memory })
267
+ // my.double(x) x * 2
268
+ emitter['my.double'] = (x) => ['f64.mul', ['f64.const', 2], typed(x, 'f64')]
269
+ ```
270
+
271
+ Handler names follow the AST path: `Math.sin` → `math.sin`, `arr.push` → `.push`, typed variants like `.f64:push`. Any file in [`module/`](module/) is a worked template — each receives `ctx` and registers emitters, stdlib, globals, or helpers. See [CONTRIBUTING.md](CONTRIBUTING.md) for the pipeline.
321
272
 
322
- // Object from module a, processed by module b — same memory, merged schemas
323
- b.exports.read(a.exports.make()) // 30
273
+ </details>
324
274
 
325
- // Read from JS too — memory knows all schemas
326
- memory.read(a.exports.make()) // {x: 10, y: 20}
327
275
 
328
- // Write from JS before any compilation
329
- memory.String('hello') // → NaN-boxed pointer
330
- memory.Array([1, 2, 3]) // → NaN-boxed pointer
331
- ```
276
+ ## Benchmark
332
277
 
333
- `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.
278
+ | | jz | [Node](https://nodejs.org/) | [Porffor](https://github.com/CanadaHonk/porffor) | [AS](https://github.com/AssemblyScript/assemblyscript) | WAT | C | [Go](https://go.dev/) | [Zig](https://ziglang.org/) | [Rust](https://www.rust-lang.org/) | [NumPy](https://numpy.org/) |
279
+ |---|---|---|---|---|---|---|---|---|---|---|
280
+ | [biquad](bench/biquad/biquad.js) | 6.50ms<br>3.4kB | 12.35ms<br>3.2kB | fails | 9.03ms<br>1.9kB | 6.49ms<br>767 B | 5.30ms | 8.96ms<br>fma | 5.04ms | 5.27ms | 3.09s |
281
+ | [mat4](bench/mat4/mat4.js) | 2.74ms<br>3.3kB | 11.96ms<br>1.2kB | 88.68ms<br>2.4kB<br>diff | 9.32ms<br>1.6kB | 8.12ms<br>414 B | 2.76ms | 12.51ms | 2.74ms | 1.78ms | 389.44ms |
282
+ | [poly](bench/poly/poly.js) | 0.37ms<br>1.2kB | 2.32ms<br>1014 B | fails | 1.15ms<br>1.3kB | 0.81ms<br>359 B | 0.52ms | 0.80ms | 0.80ms | 0.57ms | 0.61ms |
283
+ | [bitwise](bench/bitwise/bitwise.js) | 1.40ms<br>1.2kB | 5.32ms<br>1005 B | fails | 12.13ms<br>1.5kB | 4.88ms<br>355 B | 1.30ms | 5.23ms | 4.16ms | 1.30ms | 14.77ms |
284
+ | [tokenizer](bench/tokenizer/tokenizer.js) | 0.10ms<br>1.7kB | 0.21ms<br>2.0kB | 0.41ms<br>3.2kB | 0.08ms<br>1.6kB | 0.10ms<br>344 B | 0.13ms | 0.08ms | 0.14ms | 0.12ms | 5.13ms |
285
+ | [callback](bench/callback/callback.js) | 0.03ms<br>1.4kB | 0.88ms<br>828 B | fails | 1.49ms<br>1.9kB | 0.25ms<br>267 B | 0.10ms | 0.20ms | 0.01ms | 0.09ms | 1.81ms |
286
+ | [aos](bench/aos/aos.js) | 1.62ms<br>1.8kB | 1.82ms<br>1.1kB | fails | 1.91ms<br>2.2kB | 1.07ms<br>481 B | 1.20ms | 0.90ms | 0.90ms | 1.20ms | 2.55ms |
287
+ | [mandelbrot](bench/mandelbrot/mandelbrot.js) | 12.55ms<br>1.0kB | 13.80ms<br>1.8kB | 13.47ms<br>3.0kB | 12.42ms<br>1.3kB | — | 12.26ms | 12.46ms | 12.31ms | 12.23ms | — |
288
+ | [json](bench/json/json.js) | 0.23ms<br>7.7kB | 0.38ms<br>1.2kB | fails | — | — | 0.21ms | 1.17ms | 0.69ms | 0.68ms | 1.20ms |
289
+ | [sort](bench/sort/sort.js) | 5.96ms<br>1.6kB | 11.13ms<br>1.6kB | fails | 10.22ms<br>1.9kB | — | 8.85ms | 10.36ms | 8.84ms | 9.37ms | 5.05ms |
290
+ | [crc32](bench/crc32/crc32.js) | 12.12ms<br>1.2kB | 13.43ms<br>1.8kB | 80.76ms<br>3.1kB | 12.19ms<br>1.4kB | — | 10.69ms | 9.30ms | 9.45ms | 9.38ms | 0.24ms |
291
+ | [watr](bench/watr/watr.js) | 1.56ms<br>144.4kB | 1.45ms<br>2.6kB | fails | — | — | — | — | — | — | — |
334
292
 
335
- 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.
336
293
 
294
+ _Per-case median speed / wasm size from `node bench/bench.mjs` on Apple Silicon (arm64); the **geomean** row is the gated cross-case jz/target ratio from `test/bench.js`._
295
+
296
+ Geomean size: jz **0.86× AS**. jz wasm runs at `clang -O3` speed — native-C parity at geomean 0.96× — and `test/bench.js` gates every figure so a regression fails CI.
337
297
 
338
- </details>
339
298
 
340
299
  <details>
341
- <summary><strong>How does memory work? How do I reset it?</strong></summary>
300
+ <summary><strong>Optimizations</strong></summary>
342
301
 
343
302
  <br>
303
+ High-impact summary behind the benchmark table, not an exhaustive list.
344
304
 
345
- 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.
305
+ | Optimization | Effect |
306
+ |---|---|
307
+ | Escape scalar replacement | Removes short-lived object/array literals before allocation. |
308
+ | Stack rest-param scalarization | Fixed-arity internal calls avoid heap rest arrays. |
309
+ | Scoped arena rewind | Safely rewinds allocations in functions proven not to return or persist heap values. |
310
+ | Host-service import lowering | `host: 'js'` lowers console, clocks, and timers to small `env.*` imports instead of pulling WASI/string formatting into normal JS-host builds. |
311
+ | Static and shaped runtime JSON specialization | Constant `JSON.parse` sources fold to fresh slot trees; stable `let` JSON sources use a generated runtime parser for the inferred shape. |
312
+ | Typed-array specialization and address fusion | Monomorphic/bimorphic typed-array paths skip generic index dispatch and fuse repeated address bases/offsets in hot loops. |
313
+ | Integer/value-type narrowing | Keeps bitwise, `Math.imul`, `charCodeAt`, loop counters, and internal narrowed returns on raw i32/f64 paths instead of generic boxed-value helpers. |
314
+ | SIMD lane-local vectorization | Beats V8 on bitwise and keeps scalar feedback loops such as biquad untouched. |
315
+ | Small constant loop unroll | Required for biquad and mat4 speed; size cost is pinned. |
316
+ | OBJECT-only ternary type propagation | Keeps bimorphic object reads on typed dynamic dispatch without broad type-risk. |
317
+ | Benchmark checksum helper inlining | Avoids pulling generic ToNumber/string conversion into typed-array checksum binaries; mandelbrot drops from ~5.0kB to ~1.2kB. |
346
318
 
347
- 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:
319
+ `npm run test:bench` pins every claimed V8 win, AssemblyScript win/tie, and wasm size budget. Mandelbrot is pinned as a V8 win and AssemblyScript tie, not an AS win. Unclaimed rows stay visible as todo gaps without weakening the asserted wins.
348
320
 
349
- ```js
350
- const { exports, memory } = jz`
351
- export let process = (n) => {
352
- let xs = []
353
- for (let i = 0; i < n; i++) xs.push(i * 2)
354
- return xs.reduce((s, x) => s + x, 0)
355
- }
356
- `
321
+ #### Making array loops vectorize
357
322
 
358
- for (let i = 0; i < 1000; i++) {
359
- const sum = exports.process(100) // allocates an array each call
360
- memory.reset() // drop everything; heap ptr → 1024
361
- }
362
- ```
323
+ The lane-local vectorizer (on at default `optimize: 2`) lifts inner loops of shape `for (let i=0;i<N;i++) arr[i] = f(arr[i], …)` to SIMD-128 when the body is lane-pure (the k-th output depends only on the k-th inputs).
363
324
 
364
- After `memory.reset()` all previously returned pointers are invalid read what you need first, then reset.
325
+ **Lifts:** in-place maps (`a[i] = a[i] * 2`), cross-array maps (`b[i] = a[i] * k + c`), **structure-of-arrays** (`zs[i] = xs[i]*a + ys[i]*b`, up to 4 base pointers), and reductions (`s += a[i]`, `h ^= a[i]`, `|`, `&`).
365
326
 
366
- 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.
327
+ **Doesn't lift:** **array-of-structures** (interleaved `a[i*3]`, `a[i*3+1]` stride exceeds lane width; split into one typed array per field), loop-carried scalars (`s ^= s << 13`), stencils (`a[i] = a[i] + a[i-1]`), unbounded loops, mixed lane types in one body.
367
328
 
368
- **Non-JS hosts** (wasmtime, wasmer, deno, EdgeJS, embedded WASM) get the same allocator via two exports:
329
+ Check with `--wat`: a successful lift adds a `$__simd_loop<N>` block ahead of the scalar tail. No block means the recognizer bailed — usually a loop-carried local or a non-`(base + i<<K)` address.
369
330
 
370
- ```
371
- (func $_alloc (param $bytes i32) (result i32)) ;; returns heap offset
372
- (func $_clear) ;; rewinds heap pointer to 1024
373
- ```
331
+ </details>
374
332
 
375
- `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`](src/host.js) for type codes and the canonical encoders. Call `_clear()` between batches to reclaim. Strip both with `compile(code, { alloc: false })` if you only call functions and never marshal heap values across the boundary.
376
333
 
377
- </details>
334
+ ## Interop
378
335
 
379
- <details>
380
- <summary><strong>How do I run compiled WASM outside the browser?</strong></summary>
336
+ How values cross the JS↔WASM boundary, and how to ship and run the compiled `.wasm`. The mental model is simple: numbers pass straight through, and anything heap-allocated — strings, arrays, objects — crosses as a pointer the `memory` helper reads and writes for you. Under the hood that pointer is a **NaN-boxed `f64`** into a bump-allocated heap, one boundary codec per binary, fixed at compile time.
381
337
 
382
- <br>
338
+ ### Passing data in and out
383
339
 
384
- ```sh
385
- jz program.js -o program.wasm
340
+ Arrays of ≤ 8 elements come back as plain JS arrays (WASM multi-value); everything else stays heap-resident behind a pointer.
386
341
 
387
- # Run with any WASM runtime
388
- wasmtime program.wasm # WASI support built in
389
- wasmer run program.wasm
390
- deno run program.wasm
342
+ ```js
343
+ const { exports, memory } = jz`
344
+ export let greet = (s) => s.length
345
+ export let dist = (p) => (p.x * p.x + p.y * p.y) ** 0.5
346
+ export let rgb = (c) => [c, c * 0.5, c * 0.2]
347
+ export let process = (buf) => buf.map(x => x * 2)
348
+ `
349
+
350
+ // JS → WASM (write)
351
+ memory.String('hello') // → string pointer
352
+ memory.Array([1, 2, 3]) // → array pointer
353
+ memory.Float64Array([1, 2]) // → typed array pointer (all TypedArray ctors available)
354
+ memory.Object({ x: 3, y: 4 }) // → object pointer (see warning)
355
+
356
+ // Call with pointers
357
+ exports.greet(memory.String('hello')) // 5
358
+ exports.dist(memory.Object({ x: 3, y: 4 })) // 5
359
+ exports.rgb(100) // [100, 50, 20] — direct JS array return
360
+ memory.read(exports.process(memory.Float64Array([1, 2, 3]))) // Float64Array [2, 4, 6]
391
361
  ```
392
362
 
393
- Pure numeric modules have no imports and instantiate with standard
394
- `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.
363
+ > [!WARNING] jz objects are fixed-layout schemas (like C structs), not dynamic key bags.
364
+ > `memory.Object({ x: 3, y: 4 })` must use the same key order as the jz source `{ x, y }` reversed keys produce wrong values. Strings/arrays inside objects are auto-wrapped to pointers.
365
+
366
+ ### Template interpolation
395
367
 
396
- Two host modes select how runtime services lower:
368
+ Interpolated values are baked into the source at compile time — no post-instantiation allocation, no getter overhead. Numbers and booleans inline directly; strings, arrays, and objects compile as jz literals:
397
369
 
398
370
  ```js
399
- jz.compile(code) // host: 'js' (default) env.* imports
400
- jz.compile(code, { host: 'wasi' }) // wasi_snapshot_preview1.* imports
371
+ jz`export let f = () => ${'hello'}.length` // 5
372
+ jz`export let f = () => ${[10, 20, 30]}[1]` // 20
373
+ jz`export let f = () => ${{name: 'jz', count: 3}}.count` // 3
401
374
  ```
402
375
 
403
- `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.
376
+ Functions are imported as host calls. Non-serializable values (host objects, class instances) fall back to post-instantiation getters automatically.
404
377
 
405
- `host: 'wasi'` `console.log` compiles to WASI `fd_write`, clocks to
406
- `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'`.
378
+ ### Calling host functions
407
379
 
408
- </details>
380
+ Any host namespace — functions, constants, custom objects — wires in via the `imports` option. jz extracts names via `Object.getOwnPropertyNames`, so non-enumerable built-ins (`Math.sin`, `Date.now`) work automatically:
409
381
 
410
- <details>
411
- <summary><strong>What host features are supported?</strong></summary>
382
+ ```js
383
+ // Custom function
384
+ jz('import { log } from "host"; export let f = (x) => { log(x); return x }',
385
+ { imports: { host: { log: console.log } } })
412
386
 
413
- <br>
387
+ // Whole namespace — sin, cos, PI, … all auto-wired
388
+ jz('import { sin, PI } from "math"; export let f = () => sin(PI / 2)',
389
+ { imports: { math: Math } })
390
+
391
+ // globalThis works too
392
+ jz('import { parseInt } from "window"; export let f = () => parseInt("42")',
393
+ { imports: { window: globalThis } })
394
+ ```
395
+
396
+ ### Host features & runtime services
397
+
398
+ Two host modes select how runtime services lower. `host: 'js'` (default) imports small `env.*` services that `jz()` auto-wires; `host: 'wasi'` emits WASI Preview 1 for wasmtime/wasmer/deno.
414
399
 
415
400
  | JS API | `host: 'js'` (default) | `host: 'wasi'` |
416
401
  |---|---|---|
417
- | `console.log()` | `env.print(val: i64, fd: i32, sep: i32)` — host stringifies | WASI `fd_write` (fd=1), space-separated, newline appended |
402
+ | `console.log()` | `env.print(val, fd, sep)` — host stringifies | WASI `fd_write` (fd=1), space-separated, newline |
418
403
  | `console.warn`/`error` | same, fd=2 | WASI `fd_write` (fd=2) |
419
- | `Date.now()` | `env.now(0) -> f64` (epoch ms) | `clock_time_get` (realtime) |
420
- | `performance.now()` | `env.now(1) -> f64` (monotonic ms) | `clock_time_get` (monotonic) |
421
- | `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) |
422
- | `setInterval`/`clearInterval` | same `env.setTimeout` (repeat=1) / `env.clearTimeout` | WASM timer queue + `__timer_tick` |
404
+ | `Date.now()` | `env.now(0) f64` (epoch ms) | `clock_time_get` (realtime) |
405
+ | `performance.now()` | `env.now(1) f64` (monotonic ms) | `clock_time_get` (monotonic) |
406
+ | `setTimeout`/`setInterval` | `env.setTimeout(cb, delay, repeat)` — host schedules; fires via `__invoke_closure` | WASM timer queue + `__timer_tick` |
423
407
  | dynamic `obj.method()` | `env.__ext_call` (JS resolves) | error at compile time |
424
408
 
425
- The compiled `.wasm` uses at most one import namespace:
409
+ The compiled `.wasm` carries at most one import namespace — none, `env`, or `wasi_snapshot_preview1` — matching the mode above. `host: 'gc'` is reserved for a planned wasm-gc backend and errors today; pair `host: 'wasi'` with `strict: true` to also fail dynamic `obj[k]`/unknown-receiver calls at compile time.
426
410
 
427
- - none pure scalar/compute modules. Instantiate directly with standard WebAssembly APIs.
428
- - `env` — JS-host services (default). Auto-wired by the `jz()` runtime.
429
- - `wasi_snapshot_preview1` — standard WASI Preview 1. Run natively on wasmtime/wasmer/deno.
411
+ ### Sharing memory across modules
430
412
 
431
- </details>
413
+ `jz.memory()` creates a shared memory that modules compile into. Schemas accumulate, so objects created in one module are readable by another:
432
414
 
433
- <details>
434
- <summary><strong>How do I add custom operators / extend the stdlib?</strong></summary>
415
+ ```js
416
+ const memory = jz.memory()
417
+ const a = jz('export let make = () => { let o = {x: 10, y: 20}; return o }', { memory })
418
+ const b = jz('export let read = (o) => o.x + o.y', { memory })
435
419
 
436
- <br>
420
+ b.exports.read(a.exports.make()) // 30 — same memory, merged schemas
421
+ memory.read(a.exports.make()) // {x: 10, y: 20} — JS reads it too
422
+ ```
423
+
424
+ `jz.memory()` returns a real `WebAssembly.Memory` patched with `.read()`/`.String()`/`.Array()`/`.Object()`/`.write()`. Pass an existing one to wrap it: `jz.memory(new WebAssembly.Memory({ initial: 4 }))`. Modules sharing a memory share one bump allocator. Use `.instance.exports` for raw pointers, `.exports` for the JS-wrapped surface.
425
+
426
+ ### Shipping & running the `.wasm`
437
427
 
438
- 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:
428
+ Compile once, then run the binary anywhere.
429
+
430
+ **JS host, no compiler.** `jz/interop` is a dependency-free bridge (only `wasi.js`) that knows the value encoding, so bundlers tree-shake the compiler, parser, and watr out entirely:
439
431
 
440
432
  ```js
441
- import { emitter } from './src/emit.js'
442
- import { typed } from './src/ir.js'
433
+ import { instantiate } from 'jz/interop'
434
+ import wasmBytes from './program.wasm' // bundler-specific; or fetch(...)
443
435
 
444
- // Register a custom operator: my.double(x) → x * 2
445
- emitter['my.double'] = (x) => {
446
- return ['f64.mul', ['f64.const', 2], typed(x, 'f64')]
447
- }
436
+ const { exports, memory } = instantiate(wasmBytes)
437
+ exports.greet(memory.String('hello')) // marshal works exactly as at compile time
448
438
  ```
449
439
 
450
- The naming convention follows the AST path: `Math.sin` `math.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.
440
+ `instantiate(wasm, opts?)` accepts `Uint8Array`, `ArrayBuffer`, or a prebuilt `WebAssembly.Module` and returns the same `{ exports, memory, instance, module }` shape as the `jz(src)` tag same `memory.String/Array/Object/...` constructors, same `memory.read(ptr)` decoder.
451
441
 
452
- Inside a runtime module, import directly from the layer you need:
442
+ **Native runtimes.** Compile with `host: 'wasi'` and run on any WASM runtime:
453
443
 
454
- ```js
455
- import { emit } from '../src/emit.js'
456
- import { asF64, temp } from '../src/ir.js'
457
- import { valTypeOf, VAL } from '../src/analyze.js'
444
+ ```sh
445
+ jz program.js --host wasi -o program.wasm
446
+ wasmtime program.wasm # also `wasmer run` / `deno run`
458
447
  ```
459
448
 
460
- </details>
449
+ Pure numeric modules have no imports and instantiate with standard `WebAssembly.Module`/`Instance` — the right shape for JS hosts such as EdgeJS. Compile at startup or build time and reuse the module; don't compile jz source per request.
450
+
451
+ **Memory ABI (non-JS hosts).** The allocator is exposed as two exports:
452
+
453
+ ```
454
+ (func $_alloc (param $bytes i32) (result i32)) ;; returns heap offset
455
+ (func $_clear) ;; rewinds heap pointer to 1024
456
+ ```
457
+
458
+ `memory.alloc()`/`memory.reset()` are JS 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 boundary pointer is the f64 NaN-box `0x7FF8 << 48 | type << 47 | aux << 32 | offset` — see [`src/host.js`](src/host.js) for type codes and the canonical encoders. Strip both exports with `compile(code, { alloc: false })` if you only call functions and never marshal heap values across the boundary.
461
459
 
462
460
  <details>
463
- <summary><strong>Can I compile jz to C?</strong></summary>
461
+ <summary><strong>Zero-copy strings</strong></summary>
464
462
 
465
463
  <br>
466
464
 
467
- Yes, via [wasm2c](https://github.com/WebAssembly/wabt/blob/main/wasm2c) or [w2c2](https://github.com/turbolent/w2c2):
465
+ Strings have two boundary carriers; the compiler picks per export-param:
468
466
 
469
- ```sh
470
- jz program.js -o program.wasm
471
- wasm2c program.wasm -o program.c
472
- cc program.c -o program
467
+ | carrier | when | what crosses | per-call cost |
468
+ |---|---|---|---|
469
+ | **f64 / SSO** (default) | every param unless the narrower can prove it is used purely as a string | a NaN-boxed `f64` → UTF-8 bytes in linear memory; ≤4 ASCII chars inline in the NaN payload (SSO) | one `_alloc` + memcpy |
470
+ | **externref / `wasm:js-string`** | param uses only `.length`/bounded `.charCodeAt(i)`, isn't reassigned/captured/escaped, *and* has either a `.charCodeAt` use, call-site STRING evidence, or a `s = ''` default | the JS string itself, by reference | **zero** — lowers to [`wasm:js-string`](https://github.com/WebAssembly/js-string-builtins/blob/main/proposals/js-string-builtins/Overview.md) builtins the engine inlines |
471
+
472
+ ```js
473
+ const { exports } = jz`
474
+ // Opt-in fires: .charCodeAt in a bounded loop discriminates string.
475
+ export let sum = (s) => { let n = 0; for (let i = 0; i < s.length; i++) n += s.charCodeAt(i); return n }
476
+ // Opt-in fires: 's = ""' default declares string intent.
477
+ export let len = (s = '') => s.length
478
+ // Opt-in declines: '+' isn't a builtin; param escapes into the f64 op.
479
+ export let label = (s) => s + ' (ok)'
480
+ `
481
+ exports.sum('hello') // 532 — JS string passed by reference
482
+ exports.len() // 0 — default substituted JS-side
483
+ exports.label('test') // 'test (ok)' — memory-backed string, as before
473
484
  ```
474
- </details>
475
485
 
486
+ **Why `.length`-only doesn't flip.** `.length` also reads arrays and typed arrays, so keeping it on f64 preserves that tolerant polymorphism — flipping would trap on non-strings. **Why bounded loops matter.** `wasm:js-string.charCodeAt` **traps** out of range where JS returns `NaN`, so the narrower proves `i < s.length` before flipping.
476
487
 
477
- ## Benchmark
488
+ Native `wasm:js-string` lands in V8 17+ (Chrome 134+, Node 25+ via the `{ builtins: ['js-string'] }` Module option), Safari 18.4+, Firefox behind a flag. `jz/interop` probes the engine and either passes the option for native inlining or attaches a JS polyfill — either way the boundary string-copy is saved. Opt out with `optimize: { jsstring: false }`. Bench: `node bench/jsstring/bench-jsstring.mjs`.
478
489
 
479
- | | jz | [Node](https://nodejs.org/) | [Porffor](https://github.com/CanadaHonk/porffor) | [AS](https://github.com/AssemblyScript/assemblyscript) | WAT | C | [Go](https://go.dev/) | [Zig](https://ziglang.org/) | [Rust](https://www.rust-lang.org/) | [NumPy](https://numpy.org/) |
480
- |---|---|---|---|---|---|---|---|---|---|---|
481
- | [biquad](bench/biquad/biquad.js) | 4.63ms<br>4.0kB | 8.68ms<br>3.2kB | fails | 6.59ms<br>1.9kB | 6.45ms<br>767 B | 5.30ms | 8.91ms<br>fma | 5.06ms | 5.28ms | 3.12s |
482
- | [tokenizer](bench/tokenizer/tokenizer.js) | 0.07ms<br>1.8kB | 0.12ms<br>1.4kB | 0.34ms<br>2.6kB | 0.05ms<br>1.5kB | 0.08ms<br>344 B | 0.14ms | 0.07ms | 0.12ms | 0.12ms | 5.15ms |
483
- | [mat4](bench/mat4/mat4.js) | 2.12ms<br>3.7kB | 11.80ms<br>1.2kB | 88.54ms<br>2.4kB<br>diff | 9.21ms<br>1.6kB | 8.06ms<br>414 B | 2.73ms | 11.93ms | 2.73ms | 1.77ms | 387.60ms |
484
- | [aos](bench/aos/aos.js) | 1.11ms<br>2.3kB | 1.26ms<br>1.1kB | fails | 1.33ms<br>2.2kB | 1.07ms<br>481 B | 1.20ms | 0.91ms | 0.91ms | 1.20ms | 2.57ms |
485
- | [mandelbrot](bench/mandelbrot/mandelbrot.js) | 8.02ms<br>1.2kB | 9.06ms<br>1.8kB | 9.71ms<br>3.0kB | 8.00ms<br>1.3kB | — | 8.31ms | 8.80ms | 7.83ms | 8.52ms | — |
486
- | [bitwise](bench/bitwise/bitwise.js) | 0.98ms<br>1.3kB | 3.76ms<br>1005 B | fails | 8.79ms<br>1.5kB | 4.86ms<br>355 B | 1.30ms | 5.20ms | 4.15ms | 1.30ms | 14.72ms |
487
- | [poly](bench/poly/poly.js) | 0.27ms<br>1.4kB | 1.62ms<br>1014 B | fails | 0.73ms<br>1.3kB | 0.81ms<br>359 B | 0.57ms | 0.79ms | 0.89ms | 0.63ms | 0.60ms |
488
- | [callback](bench/callback/callback.js) | 0.03ms<br>1.6kB | 0.69ms<br>828 B | fails | 1.04ms<br>1.9kB | 0.24ms<br>267 B | 0.08ms | 0.23ms | 0.01ms | 0.12ms | 1.78ms |
489
- | [json](bench/json/json.js) | 0.25ms<br>10.9kB | 0.36ms<br>1.2kB | fails | — | — | 0.25ms | 1.16ms | 0.64ms | 0.65ms | 1.20ms |
490
- | [watr](bench/watr/watr.js) | 1.04ms<br>169.8kB | 1.05ms<br>2.6kB | fails | — | — | — | — | — | — | — |
491
-
492
- _Numbers from `node bench/bench.mjs` on Apple Silicon. Porffor cells were refreshed with `porf` 0.61.13; `fails` means the latest Porffor compiler/runtime did not complete that benchmark._
490
+ </details>
493
491
 
494
492
  <details>
495
- <summary><strong>Optimizations</strong></summary>
493
+ <summary><strong>Custom sections</strong></summary>
496
494
 
497
495
  <br>
498
- High-impact summary behind the benchmark table, not an exhaustive list.
499
496
 
500
- | Optimization | Effect |
497
+ jz embeds four small WebAssembly custom sections so the JS interop layer can wire boundary ABIs without re-parsing the source. They're inert for non-JS hosts (wasmtime/wasmer ignore unknown customs); `interop.js` reads them once at instantiate-time. You don't need to touch them — they're documented so external tools (linkers, custom loaders, devtools) can read them safely.
498
+
499
+ | Section | Purpose |
501
500
  |---|---|
502
- | Escape scalar replacement | Removes short-lived object/array literals before allocation. |
503
- | Stack rest-param scalarization | Fixed-arity internal calls avoid heap rest arrays. |
504
- | Scoped arena rewind | Safely rewinds allocations in functions proven not to return or persist heap values. |
505
- | Host-service import lowering | `host: 'js'` lowers console, clocks, and timers to small `env.*` imports instead of pulling WASI/string formatting into normal JS-host builds. |
506
- | Static and shaped runtime JSON specialization | Constant `JSON.parse` sources fold to fresh slot trees; stable `let` JSON sources use a generated runtime parser for the inferred shape. |
507
- | Typed-array specialization and address fusion | Monomorphic/bimorphic typed-array paths skip generic index dispatch and fuse repeated address bases/offsets in hot loops. |
508
- | Integer/value-type narrowing | Keeps bitwise, `Math.imul`, `charCodeAt`, loop counters, and internal narrowed returns on raw i32/f64 paths instead of generic boxed-value helpers. |
509
- | SIMD lane-local vectorization | Beats V8 on bitwise and keeps scalar feedback loops such as biquad untouched. |
510
- | Small constant loop unroll | Required for biquad and mat4 speed; size cost is pinned. |
511
- | OBJECT-only ternary type propagation | Keeps bimorphic object reads on typed dynamic dispatch without broad type-risk. |
512
- | Benchmark checksum helper inlining | Avoids pulling generic ToNumber/string conversion into typed-array checksum binaries; mandelbrot drops from ~5.0kB to ~1.2kB. |
501
+ | `jz:schema` | Object schemas for exported records JS rehydrates plain objects from boundary writes without per-call shape inference. |
502
+ | `jz:rest` | Per-export rest-parameter info (`{ name, fixed }`) tells JS how many fixed args precede the rest array so the wrapper packs the tail correctly (covers aliased re-exports). |
503
+ | `jz:i64exp` | Per-export i64-ABI map marks slots where pointers cross as i64 (dodging V8's NaN canonicalization) instead of f64. |
504
+ | `jz:extparam` | Per-export externref-param positions args that skip NaN-boxing (the jsstring carrier writes here), with `d` carrying `= ''` defaults. |
513
505
 
514
- `npm run test:bench-pin` pins every claimed V8 win, AssemblyScript win/tie, and wasm size budget. Mandelbrot is pinned as a V8 win and AssemblyScript tie, not an AS win. Unclaimed rows stay visible as todo gaps without weakening the asserted wins.
506
+ Names are stable; binary layouts are not re-derive from the latest `interop.js` if you parse them yourself.
515
507
 
516
508
  </details>
517
509
 
518
510
 
511
+ ## Examples
512
+
513
+ Runnable browser demos in [`examples/`](examples/) — each compiles a `.js` kernel to WASM and shares a typed array with a canvas (the memory-sharing pattern from [Interop](#interop)):
514
+
515
+ * [game-of-life](examples/game-of-life/) — Conway's Life writing the cell grid straight into shared pixel memory.
516
+ * [interference](examples/interference/) — two-source wave interference field rendered per frame.
517
+ * [mandelbrot](examples/mandelbrot/) — escape-time fractal with a precomputed color table.
518
+
519
+ Each folder has a `build.mjs` and an `index.html` — build, then open the page.
520
+
521
+
519
522
  ## Alternatives
520
523
 
521
524
  * [porffor](https://github.com/CanadaHonk/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.
522
525
  * [assemblyscript](https://github.com/AssemblyScript/assemblyscript) — TypeScript-subset compiling to WASM — small, performant output, but requires type annotations.
523
526
  * [jawsm](https://github.com/drogus/jawsm) — JS→WASM compiler in Rust. Compiles standard JS with a runtime that provides GC and closures in WASM.
524
527
 
528
+ <details>
529
+ <summary><strong>Which one to choose?</strong></summary>
530
+
531
+ <br>
532
+
533
+ | Pick | When |
534
+ |---|---|
535
+ | **jz** | You write plain JS, want tiny WASM and native-class numeric/DSP speed, and your code fits the subset. |
536
+ | **porffor** | You need full TC39 / spec completeness. |
537
+ | **AssemblyScript** | You're comfortable writing a typed TypeScript dialect for explicit low-level control. |
538
+ | **jawsm** | You need to run standard JS *unchanged*, with GC and closures provided by a bundled WASM runtime. |
539
+
540
+ The axis is completeness vs. cost: jz restricts the language to emit a runtime-free, native-speed binary; the others spend size/runtime to cover more of JS.
541
+
542
+ </details>
543
+
525
544
  ## Build with
526
545
 
527
546
  * [subscript](https://github.com/dy/subscript) — JS parser. Minimal, extensible, builds the exact AST jz needs without a full ES parser. Jessie subset keeps the grammar small and deterministic.