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 +277 -258
- package/cli.js +10 -52
- package/index.js +181 -29
- package/{src/host.js → interop.js} +291 -88
- package/module/array.js +195 -76
- package/module/collection.js +249 -20
- package/module/console.js +1 -1
- package/module/core.js +267 -119
- package/module/date.js +9 -5
- package/module/function.js +11 -1
- package/module/json.js +367 -61
- package/module/math.js +568 -128
- package/module/number.js +390 -227
- package/module/object.js +352 -39
- package/module/regex.js +123 -16
- package/module/schema.js +15 -1
- package/module/string.js +555 -96
- package/module/timer.js +1 -1
- package/module/typedarray.js +674 -57
- package/package.json +12 -6
- package/src/abi/array.js +89 -0
- package/src/abi/index.js +35 -0
- package/src/abi/number.js +137 -0
- package/src/abi/object.js +57 -0
- package/src/abi/string.js +529 -0
- package/src/analyze.js +1872 -487
- package/src/assemble.js +58 -20
- package/src/autoload.js +13 -1
- package/src/codegen.js +177 -0
- package/src/compile.js +462 -186
- package/src/ctx.js +85 -18
- package/src/emit.js +668 -197
- package/src/infer.js +548 -0
- package/src/ir.js +302 -27
- package/src/jzify.js +898 -259
- package/src/narrow.js +455 -246
- package/src/optimize.js +263 -99
- package/src/plan.js +713 -35
- package/src/prepare.js +818 -293
- package/src/resolve.js +85 -0
- package/src/vectorize.js +99 -18
- package/src/ast.js +0 -160
- package/src/auto-config.js +0 -120
package/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
##  [ [](http://npmjs.org/jz) [](https://github.com/dy/jz/actions/workflows/test.yml) [](https://github.com/dy/jz/actions/workflows/test262.yml) [](https://github.com/dy/jz/actions/workflows/bench.yml)
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
**JZ** (_javascript zero_) is **minimal modern functional JS subset
|
|
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**
|
|
21
|
-
JZ distills
|
|
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 `
|
|
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`
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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>
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
memory.Object({ name: 'jz', count: 3 }) // name auto-wrapped via memory.String
|
|
153
|
+
</details>
|
|
168
154
|
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
exports.rgb(100) // [100, 50, 20]
|
|
158
|
+
<br>
|
|
176
159
|
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
+
<details>
|
|
170
|
+
<summary><strong>Can I use import/export to split code?</strong></summary>
|
|
191
171
|
|
|
192
|
-
|
|
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
|
-
|
|
174
|
+
Yes. Standard `import`/`export` syntax is bundled at compile time into a single WASM — no runtime module resolution.
|
|
209
175
|
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
188
|
+
<summary><strong>How does memory work? How do I reset it?</strong></summary>
|
|
219
189
|
|
|
220
190
|
<br>
|
|
221
191
|
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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>
|
|
208
|
+
<summary><strong>How do I see and control inferred types?</strong></summary>
|
|
239
209
|
|
|
240
210
|
<br>
|
|
241
211
|
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
</details>
|
|
252
217
|
|
|
253
|
-
|
|
218
|
+
<details>
|
|
219
|
+
<summary><strong>Is it production-ready?</strong></summary>
|
|
254
220
|
|
|
255
|
-
|
|
256
|
-
jz main.jz -o main.wasm # reads ./math.jz, ./utils.jz automatically
|
|
257
|
-
```
|
|
221
|
+
<br>
|
|
258
222
|
|
|
259
|
-
**
|
|
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
|
-
|
|
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>
|
|
230
|
+
<summary><strong>Why jz over Porffor or AssemblyScript?</strong></summary>
|
|
274
231
|
|
|
275
232
|
<br>
|
|
276
233
|
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
293
|
-
const { exports } = jz(
|
|
294
|
-
'import { now } from "date"; export let f = () => now()',
|
|
295
|
-
{ imports: { date: Date } }
|
|
296
|
-
)
|
|
241
|
+
<br>
|
|
297
242
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
257
|
+
<summary><strong>Can I add my own operators or stdlib methods?</strong></summary>
|
|
311
258
|
|
|
312
259
|
<br>
|
|
313
260
|
|
|
314
|
-
Yes — `
|
|
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
|
-
|
|
264
|
+
import { emitter } from './src/emit.js'
|
|
265
|
+
import { typed } from './src/ir.js'
|
|
318
266
|
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
memory.String('hello') // → NaN-boxed pointer
|
|
330
|
-
memory.Array([1, 2, 3]) // → NaN-boxed pointer
|
|
331
|
-
```
|
|
276
|
+
## Benchmark
|
|
332
277
|
|
|
333
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
+
## Interop
|
|
378
335
|
|
|
379
|
-
|
|
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
|
-
|
|
338
|
+
### Passing data in and out
|
|
383
339
|
|
|
384
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
394
|
-
`
|
|
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
|
-
|
|
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
|
|
400
|
-
jz
|
|
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
|
-
|
|
376
|
+
Functions are imported as host calls. Non-serializable values (host objects, class instances) fall back to post-instantiation getters automatically.
|
|
404
377
|
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
420
|
-
| `performance.now()` | `env.now(1)
|
|
421
|
-
| `setTimeout`/`
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
442
|
-
import
|
|
433
|
+
import { instantiate } from 'jz/interop'
|
|
434
|
+
import wasmBytes from './program.wasm' // bundler-specific; or fetch(...)
|
|
443
435
|
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
+
**Native runtimes.** Compile with `host: 'wasi'` and run on any WASM runtime:
|
|
453
443
|
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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>
|
|
461
|
+
<summary><strong>Zero-copy strings</strong></summary>
|
|
464
462
|
|
|
465
463
|
<br>
|
|
466
464
|
|
|
467
|
-
|
|
465
|
+
Strings have two boundary carriers; the compiler picks per export-param:
|
|
468
466
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
|
503
|
-
|
|
|
504
|
-
|
|
|
505
|
-
|
|
|
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
|
-
|
|
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.
|