jz 0.4.0 → 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 +275 -255
- package/cli.js +8 -51
- package/index.js +166 -31
- package/{src/host.js → interop.js} +291 -88
- package/module/array.js +181 -71
- package/module/collection.js +222 -8
- package/module/console.js +1 -1
- package/module/core.js +251 -118
- package/module/date.js +9 -5
- package/module/function.js +11 -1
- package/module/json.js +361 -55
- package/module/math.js +551 -128
- package/module/number.js +390 -227
- package/module/object.js +252 -34
- package/module/regex.js +123 -16
- package/module/schema.js +15 -1
- package/module/string.js +540 -96
- package/module/timer.js +1 -1
- package/module/typedarray.js +674 -57
- package/package.json +10 -7
- 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 +1870 -485
- package/src/assemble.js +27 -12
- package/src/autoload.js +13 -1
- package/src/compile.js +446 -179
- package/src/ctx.js +85 -18
- package/src/emit.js +662 -196
- package/src/infer.js +548 -0
- package/src/ir.js +291 -23
- package/src/jzify.js +786 -94
- package/src/narrow.js +433 -251
- package/src/optimize.js +126 -122
- package/src/plan.js +703 -34
- package/src/prepare.js +822 -150
- 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/src/fuse.js +0 -159
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,8 +17,8 @@ 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.
|
|
@@ -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
|
|
|
@@ -108,7 +110,8 @@ JZ is a strict functional JS subset. Built-in `jzify` transform extends support
|
|
|
108
110
|
┌────────────────────────────────────────────────────────────────────────┐
|
|
109
111
|
│ JZify │
|
|
110
112
|
│ var function arguments switch new Foo() │
|
|
111
|
-
|
|
113
|
+
| class new this extends super static #private │
|
|
114
|
+
│ == != instanceof undefined |
|
|
112
115
|
│ │
|
|
113
116
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
|
114
117
|
│ │ JZ │ │
|
|
@@ -123,404 +126,421 @@ JZ is a strict functional JS subset. Built-in `jzify` transform extends support
|
|
|
123
126
|
└────────────────────────────────────────────────────────────────────────┘
|
|
124
127
|
Not supported
|
|
125
128
|
async/await Promise function* yield
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
delete labels eval Function with
|
|
130
|
+
Proxy Reflect WeakMap WeakSet
|
|
131
|
+
import() DOM fetch Intl Node APIs
|
|
129
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
|
+
|
|
130
136
|
## FAQ
|
|
131
137
|
|
|
132
138
|
<details>
|
|
133
|
-
<summary><strong>
|
|
139
|
+
<summary><strong>Where does jz differ from JavaScript?</strong></summary>
|
|
134
140
|
|
|
135
141
|
<br>
|
|
136
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).)
|
|
137
144
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
```js
|
|
145
|
-
const { exports, memory } = jz`
|
|
146
|
-
export let greet = (s) => s.length
|
|
147
|
-
export let sum = (a) => a.reduce((s, x) => s + x, 0)
|
|
148
|
-
export let dist = (p) => (p.x * p.x + p.y * p.y) ** 0.5
|
|
149
|
-
export let rgb = (c) => [c, c * 0.5, c * 0.2]
|
|
150
|
-
export let process = (buf) => buf.map(x => x * 2)
|
|
151
|
-
`
|
|
152
|
-
|
|
153
|
-
// JS → WASM (write)
|
|
154
|
-
memory.String('hello') // → string pointer
|
|
155
|
-
memory.Array([1, 2, 3]) // → array pointer
|
|
156
|
-
memory.Float64Array([1.0, 2.0]) // → typed array pointer
|
|
157
|
-
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.
|
|
158
150
|
|
|
159
|
-
|
|
160
|
-
// jz objects are fixed-layout schemas (like C structs), not dynamic key bags.
|
|
161
|
-
// If the jz source declares `{ x, y }`, you must pass `{ x, y }` in that order.
|
|
162
|
-
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.
|
|
163
152
|
|
|
164
|
-
|
|
165
|
-
memory.Object({ name: 'jz', count: 3 }) // name auto-wrapped via memory.String
|
|
153
|
+
</details>
|
|
166
154
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
exports.sum(memory.Array([1, 2, 3])) // 6
|
|
170
|
-
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>
|
|
171
157
|
|
|
172
|
-
|
|
173
|
-
exports.rgb(100) // [100, 50, 20]
|
|
158
|
+
<br>
|
|
174
159
|
|
|
175
|
-
|
|
176
|
-
memory.read(exports.process(memory.Float64Array([1, 2, 3]))) // Float64Array [2, 4, 6]
|
|
177
|
-
```
|
|
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.
|
|
178
161
|
|
|
179
|
-
|
|
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.
|
|
180
164
|
|
|
181
|
-
|
|
182
|
-
jz`export let f = () => ${'hello'}.length + ${[1,2,3]}[0] + ${{x: 5, y: 10}}.x`
|
|
183
|
-
```
|
|
165
|
+
jz is for compiling *your* numeric/DSP/parser code, not for running the npm ecosystem.
|
|
184
166
|
|
|
185
|
-
|
|
186
|
-
### How does everything fit in f64?
|
|
167
|
+
</details>
|
|
187
168
|
|
|
188
|
-
|
|
169
|
+
<details>
|
|
170
|
+
<summary><strong>Can I use import/export to split code?</strong></summary>
|
|
189
171
|
|
|
190
|
-
|
|
191
|
-
|------|------|---------|---------|
|
|
192
|
-
| Number | — | regular f64 | `3.14`, `42`, `NaN` |
|
|
193
|
-
| Null | 0 | reserved pattern | `null` (distinct from `0` and `NaN`) |
|
|
194
|
-
| Array | 1 | aux=length, offset=heap | `[1, 2, 3]` |
|
|
195
|
-
| ArrayBuffer | 2 | offset=heap | `new ArrayBuffer(16)` |
|
|
196
|
-
| TypedArray | 3 | aux=elemType, offset=heap | `new Float64Array(n)` |
|
|
197
|
-
| String | 4 | offset=heap | `"hello world"` (>4 chars) |
|
|
198
|
-
| SSO String | 5 | aux=packed chars | `"hi"` (<=4 ASCII chars, zero alloc) |
|
|
199
|
-
| Object | 6 | aux=schemaId, offset=heap | `{x: 1, y: 2}` |
|
|
200
|
-
| Hash | 7 | offset=heap | dynamic string-keyed objects |
|
|
201
|
-
| Set | 8 | offset=heap | `new Set()` |
|
|
202
|
-
| Map | 9 | offset=heap | `new Map()` |
|
|
203
|
-
| Closure | 10 | aux=funcIdx, offset=env | `x => x + captured` |
|
|
204
|
-
| External | 11 | offset=hostMap index | JS host object references |
|
|
172
|
+
<br>
|
|
205
173
|
|
|
206
|
-
|
|
174
|
+
Yes. Standard `import`/`export` syntax is bundled at compile time into a single WASM — no runtime module resolution.
|
|
207
175
|
|
|
208
|
-
|
|
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
|
+
```
|
|
209
182
|
|
|
210
|
-
|
|
211
|
-
-->
|
|
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.
|
|
212
184
|
|
|
213
185
|
</details>
|
|
214
186
|
|
|
215
187
|
<details>
|
|
216
|
-
<summary><strong>How does
|
|
188
|
+
<summary><strong>How does memory work? How do I reset it?</strong></summary>
|
|
217
189
|
|
|
218
190
|
<br>
|
|
219
191
|
|
|
220
|
-
|
|
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.
|
|
221
193
|
|
|
222
|
-
|
|
223
|
-
jz`export let f = () => ${'hello'}.length` // 5 — string compiled as literal
|
|
224
|
-
jz`export let f = () => ${[10, 20, 30]}[1]` // 20 — array compiled as literal
|
|
225
|
-
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:
|
|
226
195
|
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
201
|
```
|
|
230
202
|
|
|
231
|
-
|
|
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).
|
|
232
204
|
|
|
233
205
|
</details>
|
|
234
206
|
|
|
235
207
|
<details>
|
|
236
|
-
<summary><strong>
|
|
208
|
+
<summary><strong>How do I see and control inferred types?</strong></summary>
|
|
237
209
|
|
|
238
210
|
<br>
|
|
239
211
|
|
|
240
|
-
|
|
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.
|
|
241
213
|
|
|
242
|
-
|
|
243
|
-
const { exports } = jz(
|
|
244
|
-
'import { add } from "./math.jz"; export let f = (a, b) => add(a, b)',
|
|
245
|
-
{ modules: { './math.jz': 'export let add = (a, b) => a + b' } }
|
|
246
|
-
)
|
|
247
|
-
```
|
|
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.
|
|
248
215
|
|
|
249
|
-
|
|
216
|
+
</details>
|
|
250
217
|
|
|
251
|
-
|
|
218
|
+
<details>
|
|
219
|
+
<summary><strong>Is it production-ready?</strong></summary>
|
|
252
220
|
|
|
253
|
-
|
|
254
|
-
jz main.jz -o main.wasm # reads ./math.jz, ./utils.jz automatically
|
|
255
|
-
```
|
|
221
|
+
<br>
|
|
256
222
|
|
|
257
|
-
**
|
|
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.
|
|
258
224
|
|
|
259
|
-
|
|
260
|
-
// Transitive bundling — all merged into one WASM
|
|
261
|
-
const { exports } = jz(mainSrc, { modules: {
|
|
262
|
-
'./math.jz': 'import { sq } from "./utils.jz"; export let dist = (x, y) => (sq(x) + sq(y)) ** 0.5',
|
|
263
|
-
// Fetch sources yourself, pass them in
|
|
264
|
-
'./utils.jz': await fetch('./util.jz').then(r => r.text())
|
|
265
|
-
} })
|
|
266
|
-
```
|
|
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.
|
|
267
226
|
|
|
268
227
|
</details>
|
|
269
228
|
|
|
270
229
|
<details>
|
|
271
|
-
<summary><strong>
|
|
230
|
+
<summary><strong>Why jz over Porffor or AssemblyScript?</strong></summary>
|
|
272
231
|
|
|
273
232
|
<br>
|
|
274
233
|
|
|
275
|
-
|
|
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).
|
|
276
235
|
|
|
277
|
-
|
|
278
|
-
// Custom function
|
|
279
|
-
const { exports } = jz(
|
|
280
|
-
'import { log } from "host"; export let f = (x) => { log(x); return x }',
|
|
281
|
-
{ imports: { host: { log: console.log } } }
|
|
282
|
-
)
|
|
236
|
+
</details>
|
|
283
237
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
'import { sin, PI } from "math"; export let f = () => sin(PI / 2)',
|
|
287
|
-
{ imports: { math: Math } }
|
|
288
|
-
)
|
|
238
|
+
<details>
|
|
239
|
+
<summary><strong>Can I compile jz to C?</strong></summary>
|
|
289
240
|
|
|
290
|
-
|
|
291
|
-
const { exports } = jz(
|
|
292
|
-
'import { now } from "date"; export let f = () => now()',
|
|
293
|
-
{ imports: { date: Date } }
|
|
294
|
-
)
|
|
241
|
+
<br>
|
|
295
242
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
301
250
|
```
|
|
302
251
|
|
|
303
|
-
|
|
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).
|
|
304
253
|
|
|
305
254
|
</details>
|
|
306
255
|
|
|
307
256
|
<details>
|
|
308
|
-
<summary><strong>Can
|
|
257
|
+
<summary><strong>Can I add my own operators or stdlib methods?</strong></summary>
|
|
309
258
|
|
|
310
259
|
<br>
|
|
311
260
|
|
|
312
|
-
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:
|
|
313
262
|
|
|
314
263
|
```js
|
|
315
|
-
|
|
264
|
+
import { emitter } from './src/emit.js'
|
|
265
|
+
import { typed } from './src/ir.js'
|
|
316
266
|
|
|
317
|
-
|
|
318
|
-
|
|
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.
|
|
319
272
|
|
|
320
|
-
|
|
321
|
-
b.exports.read(a.exports.make()) // 30
|
|
273
|
+
</details>
|
|
322
274
|
|
|
323
|
-
// Read from JS too — memory knows all schemas
|
|
324
|
-
memory.read(a.exports.make()) // {x: 10, y: 20}
|
|
325
275
|
|
|
326
|
-
|
|
327
|
-
memory.String('hello') // → NaN-boxed pointer
|
|
328
|
-
memory.Array([1, 2, 3]) // → NaN-boxed pointer
|
|
329
|
-
```
|
|
276
|
+
## Benchmark
|
|
330
277
|
|
|
331
|
-
|
|
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 | — | — | — | — | — | — | — |
|
|
332
292
|
|
|
333
|
-
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.
|
|
334
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.
|
|
335
297
|
|
|
336
|
-
</details>
|
|
337
298
|
|
|
338
299
|
<details>
|
|
339
|
-
<summary><strong>
|
|
300
|
+
<summary><strong>Optimizations</strong></summary>
|
|
340
301
|
|
|
341
302
|
<br>
|
|
303
|
+
High-impact summary behind the benchmark table, not an exhaustive list.
|
|
342
304
|
|
|
343
|
-
|
|
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. |
|
|
344
318
|
|
|
345
|
-
|
|
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.
|
|
346
320
|
|
|
347
|
-
|
|
348
|
-
const { exports, memory } = jz`
|
|
349
|
-
export let process = (n) => {
|
|
350
|
-
let xs = []
|
|
351
|
-
for (let i = 0; i < n; i++) xs.push(i * 2)
|
|
352
|
-
return xs.reduce((s, x) => s + x, 0)
|
|
353
|
-
}
|
|
354
|
-
`
|
|
321
|
+
#### Making array loops vectorize
|
|
355
322
|
|
|
356
|
-
for (let i
|
|
357
|
-
const sum = exports.process(100) // allocates an array each call
|
|
358
|
-
memory.reset() // drop everything; heap ptr → 1024
|
|
359
|
-
}
|
|
360
|
-
```
|
|
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).
|
|
361
324
|
|
|
362
|
-
|
|
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]`, `|`, `&`).
|
|
363
326
|
|
|
364
|
-
|
|
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.
|
|
365
328
|
|
|
366
|
-
|
|
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.
|
|
367
330
|
|
|
368
|
-
|
|
369
|
-
(func $_alloc (param $bytes i32) (result i32)) ;; returns heap offset
|
|
370
|
-
(func $_clear) ;; rewinds heap pointer to 1024
|
|
371
|
-
```
|
|
331
|
+
</details>
|
|
372
332
|
|
|
373
|
-
`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.
|
|
374
333
|
|
|
375
|
-
|
|
334
|
+
## Interop
|
|
376
335
|
|
|
377
|
-
|
|
378
|
-
<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.
|
|
379
337
|
|
|
380
|
-
|
|
338
|
+
### Passing data in and out
|
|
381
339
|
|
|
382
|
-
|
|
383
|
-
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.
|
|
384
341
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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]
|
|
389
361
|
```
|
|
390
362
|
|
|
391
|
-
|
|
392
|
-
`
|
|
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
|
|
393
367
|
|
|
394
|
-
|
|
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:
|
|
395
369
|
|
|
396
370
|
```js
|
|
397
|
-
jz
|
|
398
|
-
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
|
|
399
374
|
```
|
|
400
375
|
|
|
401
|
-
|
|
376
|
+
Functions are imported as host calls. Non-serializable values (host objects, class instances) fall back to post-instantiation getters automatically.
|
|
402
377
|
|
|
403
|
-
|
|
404
|
-
`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
|
|
405
379
|
|
|
406
|
-
|
|
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:
|
|
407
381
|
|
|
408
|
-
|
|
409
|
-
|
|
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 } } })
|
|
410
386
|
|
|
411
|
-
|
|
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.
|
|
412
399
|
|
|
413
400
|
| JS API | `host: 'js'` (default) | `host: 'wasi'` |
|
|
414
401
|
|---|---|---|
|
|
415
|
-
| `console.log()` | `env.print(val
|
|
402
|
+
| `console.log()` | `env.print(val, fd, sep)` — host stringifies | WASI `fd_write` (fd=1), space-separated, newline |
|
|
416
403
|
| `console.warn`/`error` | same, fd=2 | WASI `fd_write` (fd=2) |
|
|
417
|
-
| `Date.now()` | `env.now(0)
|
|
418
|
-
| `performance.now()` | `env.now(1)
|
|
419
|
-
| `setTimeout`/`
|
|
420
|
-
| `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` |
|
|
421
407
|
| dynamic `obj.method()` | `env.__ext_call` (JS resolves) | error at compile time |
|
|
422
408
|
|
|
423
|
-
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.
|
|
424
410
|
|
|
425
|
-
|
|
426
|
-
- `env` — JS-host services (default). Auto-wired by the `jz()` runtime.
|
|
427
|
-
- `wasi_snapshot_preview1` — standard WASI Preview 1. Run natively on wasmtime/wasmer/deno.
|
|
411
|
+
### Sharing memory across modules
|
|
428
412
|
|
|
429
|
-
|
|
413
|
+
`jz.memory()` creates a shared memory that modules compile into. Schemas accumulate, so objects created in one module are readable by another:
|
|
430
414
|
|
|
431
|
-
|
|
432
|
-
|
|
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 })
|
|
433
419
|
|
|
434
|
-
|
|
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`
|
|
435
427
|
|
|
436
|
-
|
|
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:
|
|
437
431
|
|
|
438
432
|
```js
|
|
439
|
-
import {
|
|
440
|
-
import
|
|
433
|
+
import { instantiate } from 'jz/interop'
|
|
434
|
+
import wasmBytes from './program.wasm' // bundler-specific; or fetch(...)
|
|
441
435
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
return ['f64.mul', ['f64.const', 2], typed(x, 'f64')]
|
|
445
|
-
}
|
|
436
|
+
const { exports, memory } = instantiate(wasmBytes)
|
|
437
|
+
exports.greet(memory.String('hello')) // marshal works exactly as at compile time
|
|
446
438
|
```
|
|
447
439
|
|
|
448
|
-
|
|
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.
|
|
449
441
|
|
|
450
|
-
|
|
442
|
+
**Native runtimes.** Compile with `host: 'wasi'` and run on any WASM runtime:
|
|
451
443
|
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
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`
|
|
456
447
|
```
|
|
457
448
|
|
|
458
|
-
|
|
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.
|
|
459
459
|
|
|
460
460
|
<details>
|
|
461
|
-
<summary><strong>
|
|
461
|
+
<summary><strong>Zero-copy strings</strong></summary>
|
|
462
462
|
|
|
463
463
|
<br>
|
|
464
464
|
|
|
465
|
-
|
|
465
|
+
Strings have two boundary carriers; the compiler picks per export-param:
|
|
466
466
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
471
484
|
```
|
|
472
|
-
</details>
|
|
473
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.
|
|
474
487
|
|
|
475
|
-
|
|
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`.
|
|
476
489
|
|
|
477
|
-
|
|
478
|
-
|---|---|---|---|---|---|---|---|---|---|---|
|
|
479
|
-
| [biquad](bench/biquad/biquad.js) | 6.40ms<br>4.1kB | 12.02ms<br>3.2kB | fails | 8.88ms<br>1.9kB | 6.44ms<br>767 B | 5.30ms | 8.96ms<br>fma | 5.04ms | 5.27ms | 3.09s |
|
|
480
|
-
| [tokenizer](bench/tokenizer/tokenizer.js) | 0.10ms<br>1.9kB | 0.20ms<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 |
|
|
481
|
-
| [mat4](bench/mat4/mat4.js) | 2.12ms<br>3.0kB | 11.90ms<br>1.2kB | 88.68ms<br>2.4kB<br>diff | 9.44ms<br>1.6kB | 8.10ms<br>414 B | 2.76ms | 12.51ms | 2.74ms | 1.78ms | 389.44ms |
|
|
482
|
-
| [aos](bench/aos/aos.js) | 1.61ms<br>2.0kB | 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 |
|
|
483
|
-
| [mandelbrot](bench/mandelbrot/mandelbrot.js) | 12.24ms<br>1.2kB | 13.53ms<br>1.8kB | 13.47ms<br>3.0kB | 12.23ms<br>1.3kB | — | 12.26ms | 12.46ms | 12.31ms | 12.23ms | — |
|
|
484
|
-
| [bitwise](bench/bitwise/bitwise.js) | 1.62ms<br>1.3kB | 5.25ms<br>1005 B | fails | 11.97ms<br>1.5kB | 4.95ms<br>355 B | 1.30ms | 5.23ms | 4.16ms | 1.30ms | 14.77ms |
|
|
485
|
-
| [poly](bench/poly/poly.js) | 0.37ms<br>1.4kB | 2.29ms<br>1014 B | fails | 1.13ms<br>1.3kB | 0.81ms<br>359 B | 0.52ms | 0.80ms | 0.80ms | 0.57ms | 0.61ms |
|
|
486
|
-
| [callback](bench/callback/callback.js) | 0.05ms<br>1.5kB | 0.86ms<br>828 B | fails | 1.48ms<br>1.9kB | 0.25ms<br>267 B | 0.10ms | 0.20ms | 0.01ms | 0.09ms | 1.81ms |
|
|
487
|
-
| [json](bench/json/json.js) | 0.23ms<br>10.9kB | 0.37ms<br>1.2kB | fails | — | — | 0.21ms | 1.17ms | 0.69ms | 0.68ms | 1.20ms |
|
|
488
|
-
| [sort](bench/sort/sort.js) | 8.44ms<br>1.9kB | 9.98ms<br>1.6kB | fails | 10.18ms<br>1.9kB | — | 8.85ms | 10.36ms | 8.84ms | 9.37ms | 5.05ms |
|
|
489
|
-
| [crc32](bench/crc32/crc32.js) | 11.96ms<br>1.5kB | 13.35ms<br>1.8kB | 80.76ms<br>3.1kB | 12.04ms<br>1.4kB | — | 10.69ms | 9.30ms | 9.45ms | 9.38ms | 0.24ms |
|
|
490
|
-
|
|
491
|
-
_Numbers from `node bench/bench.mjs` on Apple Silicon._ `optimize: 'size'|'speed'|'balanced'` provides a size/speed tradeoff leverage.
|
|
490
|
+
</details>
|
|
492
491
|
|
|
493
492
|
<details>
|
|
494
|
-
<summary><strong>
|
|
493
|
+
<summary><strong>Custom sections</strong></summary>
|
|
495
494
|
|
|
496
495
|
<br>
|
|
497
|
-
High-impact summary behind the benchmark table, not an exhaustive list.
|
|
498
496
|
|
|
499
|
-
|
|
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 |
|
|
500
500
|
|---|---|
|
|
501
|
-
|
|
|
502
|
-
|
|
|
503
|
-
|
|
|
504
|
-
|
|
|
505
|
-
| 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. |
|
|
506
|
-
| Typed-array specialization and address fusion | Monomorphic/bimorphic typed-array paths skip generic index dispatch and fuse repeated address bases/offsets in hot loops. |
|
|
507
|
-
| 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. |
|
|
508
|
-
| SIMD lane-local vectorization | Beats V8 on bitwise and keeps scalar feedback loops such as biquad untouched. |
|
|
509
|
-
| Small constant loop unroll | Required for biquad and mat4 speed; size cost is pinned. |
|
|
510
|
-
| OBJECT-only ternary type propagation | Keeps bimorphic object reads on typed dynamic dispatch without broad type-risk. |
|
|
511
|
-
| 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. |
|
|
512
505
|
|
|
513
|
-
|
|
506
|
+
Names are stable; binary layouts are not — re-derive from the latest `interop.js` if you parse them yourself.
|
|
514
507
|
|
|
515
508
|
</details>
|
|
516
509
|
|
|
517
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
|
+
|
|
518
522
|
## Alternatives
|
|
519
523
|
|
|
520
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.
|
|
521
525
|
* [assemblyscript](https://github.com/AssemblyScript/assemblyscript) — TypeScript-subset compiling to WASM — small, performant output, but requires type annotations.
|
|
522
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.
|
|
523
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
|
+
|
|
524
544
|
## Build with
|
|
525
545
|
|
|
526
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.
|