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.
@@ -1,22 +1,118 @@
1
1
  /**
2
- * Interop runtime JS WASM value marshaling.
2
+ * jz/interophost-side boundary codec.
3
3
  *
4
- * NaN-boxing encoder/decoder, bump allocation, schema transport, host-object
5
- * handling. This is the *package* runtime that runs after compilation it is
6
- * kept separate from the *compiler core* (index.js jz.compile) so the minimal
7
- * "pure scalar" story remains clean.
4
+ * Importable as `jz/interop` without pulling the compiler, parser, or watr —
5
+ * use this to run prebuilt jz wasm from a host that doesn't need to compile.
6
+ * Sole external dependency: `./wasi.js`.
7
+ *
8
+ * Marshals NaN-boxed `f64` values across the boundary: bump-allocated heap
9
+ * blobs (strings, arrays, typed arrays, objects), schema transport for
10
+ * fixed-shape objects, host-object externrefs.
8
11
  *
9
12
  * Exports:
10
13
  * UNDEF_NAN, NULL_NAN, coerce — null/undefined sentinels
14
+ * i64ToF64, f64ToI64 — bit-cast across the i64 boundary
11
15
  * ptr / offset / type / aux — NaN-boxed pointer codec
12
- * memory(src) — enhance a WebAssembly.Memory
13
- * wrap(memSrc, inst?) — adapt WASM exports to JS calling convention
14
- * instantiate(compile, code, opts?) compile + instantiate + wrap
16
+ * memory(src) — enhance a WebAssembly.Memory with read/write/String/Array/…
17
+ * wrap(memSrc, inst?) — adapt raw wasm exports to JS calling convention
18
+ * instantiate(wasm, opts?) instantiate prebuilt wasm bytes + wrap
19
+ *
20
+ * One boundary codec per binary: a jz wasm picks its host shape at compile
21
+ * time (`opts.host`). There is no runtime "driver sniff" — the host loading
22
+ * the binary knows which variant it asked for.
15
23
  *
16
- * @module runtime
24
+ * @module jz/interop
17
25
  */
18
26
 
19
- import { wasi } from '../wasi.js'
27
+ import { wasi } from './wasi.js'
28
+
29
+ // ── WASI linking ────────────────────────────────────────────────────────────
30
+
31
+ const linkWasi = (mod, opts) => {
32
+ const needsWasi = WebAssembly.Module.imports(mod).some(i => i.module === 'wasi_snapshot_preview1')
33
+ return { needsWasi, wasiImports: needsWasi ? wasi(opts) : null }
34
+ }
35
+
36
+ const envFuncNames = (mod) =>
37
+ new Set(WebAssembly.Module.imports(mod)
38
+ .filter(i => i.module === 'env' && i.kind === 'function').map(i => i.name))
39
+
40
+ // ── Allocator wiring ────────────────────────────────────────────────────────
41
+ // Heap pointer: the exported `$__heap` global when the module has one (non-shared
42
+ // memory), else memory[1020] (shared memory — globals are per-instance, so
43
+ // threads must share a pointer cell in linear memory). 8-byte aligned bump on
44
+ // the JS side; wasm `_alloc` takes over if exported.
45
+
46
+ const HEAP_PTR_ADDR = 1020
47
+ const HEAP_START = 1024
48
+
49
+ const makeJsAllocator = (mem, heapGlobal) => {
50
+ const dv = () => new DataView(mem.buffer)
51
+ const getPtr = heapGlobal ? () => heapGlobal.value : () => dv().getInt32(HEAP_PTR_ADDR, true)
52
+ const setPtr = heapGlobal ? v => { heapGlobal.value = v } : v => dv().setInt32(HEAP_PTR_ADDR, v, true)
53
+ // Rewind target: the global's post-static-init value, else the fixed start.
54
+ const base = heapGlobal ? heapGlobal.value : HEAP_START
55
+ const alloc = (bytes) => {
56
+ const aligned = (getPtr() + 7) & ~7
57
+ const next = aligned + bytes
58
+ if (next > mem.buffer.byteLength)
59
+ mem.grow(Math.ceil((next - mem.buffer.byteLength) / 65536))
60
+ setPtr(next)
61
+ return aligned
62
+ }
63
+ const reset = () => setPtr(base)
64
+ // The global is initialized by wasm at module load; only the memory cell needs
65
+ // a JS-side nudge in case it underflows the heap start.
66
+ const initHeapPtr = () => {
67
+ if (heapGlobal) return
68
+ const d = dv()
69
+ if (d.getInt32(HEAP_PTR_ADDR, true) < HEAP_START) d.setInt32(HEAP_PTR_ADDR, HEAP_START, true)
70
+ }
71
+ return { alloc, reset, initHeapPtr }
72
+ }
73
+
74
+ // ── Custom-section reading ──────────────────────────────────────────────────
75
+
76
+ const customSection = (mod, name) => {
77
+ const secs = WebAssembly.Module.customSections(mod, name)
78
+ return secs.length ? new Uint8Array(secs[0]) : null
79
+ }
80
+
81
+ const sectionReader = (bytes) => {
82
+ const td = new TextDecoder()
83
+ let i = 0
84
+ return {
85
+ pos: () => i,
86
+ seek: (p) => { i = p },
87
+ eof: () => i >= bytes.length,
88
+ u8: () => bytes[i++],
89
+ varint: () => {
90
+ let r = 0, s = 0
91
+ // eslint-disable-next-line no-constant-condition
92
+ while (true) {
93
+ const x = bytes[i++]
94
+ r |= (x & 0x7F) << s
95
+ if (!(x & 0x80)) return r
96
+ s += 7
97
+ }
98
+ },
99
+ str: (n) => { const s = td.decode(bytes.subarray(i, i + n)); i += n; return s },
100
+ bytes: (n) => { const r = bytes.subarray(i, i + n); i += n; return r },
101
+ }
102
+ }
103
+
104
+ const attachTimers = (inst) => {
105
+ if (!inst.exports.__timer_tick) return
106
+ const tick = inst.exports.__timer_tick
107
+ let hadTimers = false
108
+ const id = setInterval(() => {
109
+ const remaining = tick()
110
+ if (remaining > 0) hadTimers = true
111
+ if (hadTimers && remaining <= 0) clearInterval(id)
112
+ }, 1)
113
+ }
114
+
115
+ // ── NaN-box codec ───────────────────────────────────────────────────────────
20
116
 
21
117
  // NaN-boxing encode/decode — shared 8-byte scratch buffer
22
118
  const _buf = new ArrayBuffer(8), _u32 = new Uint32Array(_buf), _f64 = new Float64Array(_buf)
@@ -37,6 +133,11 @@ export const f64ToI64 = _bi64.f64ToI64
37
133
  // Distinct from 0, JS NaN (payload=0), and all pointers.
38
134
  _u32[1] = 0x7FF80001; _u32[0] = 0; export const NULL_NAN = _f64[0]
39
135
  _u32[1] = 0x7FF80002; _u32[0] = 0; export const UNDEF_NAN = _f64[0]
136
+ // Boxed-boolean atoms (type=0, offset=0): aux=4 → false, aux=5 → true. The
137
+ // low aux bit is the truth value; only exported scalars cross as these — the
138
+ // inner 0/1 number carrier never escapes the wasm boundary.
139
+ _u32[1] = 0x7FF80004; _u32[0] = 0; export const FALSE_NAN = _f64[0]
140
+ _u32[1] = 0x7FF80005; _u32[0] = 0; export const TRUE_NAN = _f64[0]
40
141
 
41
142
  // Coerce JS null/undefined → NaN-boxed sentinels for WASM boundary
42
143
  export const coerce = v => v === null ? NULL_NAN : v === undefined ? UNDEF_NAN : v
@@ -48,6 +149,8 @@ const decode = v => {
48
149
  if (_u32[0] !== 0) return v
49
150
  if (_u32[1] === 0x7FF80001) return null
50
151
  if (_u32[1] === 0x7FF80002) return undefined
152
+ if (_u32[1] === 0x7FF80004) return false
153
+ if (_u32[1] === 0x7FF80005) return true
51
154
  return v
52
155
  }
53
156
 
@@ -111,58 +214,44 @@ export const memory = (src) => {
111
214
 
112
215
  const dv = () => new DataView(mem.buffer)
113
216
 
114
- // JS-side bump allocator (heap ptr at byte 1020, same convention as WASM)
115
- const jsAlloc = (bytes) => {
116
- let d = dv(), p = d.getInt32(1020, true)
117
- const aligned = (p + 7) & ~7 // 8-byte align
118
- const next = aligned + bytes
119
- if (next > mem.buffer.byteLength) {
120
- mem.grow(Math.ceil((next - mem.buffer.byteLength) / 65536))
121
- d = dv() // buffer was detached by grow
122
- }
123
- d.setInt32(1020, next, true)
124
- return aligned
125
- }
126
-
127
- // Use WASM allocator if available, else JS-side bump
217
+ // Allocator scaffold: bumps the exported `$__heap` global (or memory[1020] for
218
+ // shared memory). Wasm `_alloc` takes over when exported; `_clear`/jsReset rewinds.
219
+ const { alloc: jsAlloc, reset: jsReset, initHeapPtr } = makeJsAllocator(mem, wasmExports?.__heap)
128
220
  let alloc = wasmExports?._alloc || jsAlloc
129
- // JS-side reset: rewind the bump pointer at byte 1020. Only used when no WASM
130
- // _clear is present (otherwise the WASM global / shared slot is authoritative).
131
- const jsReset = () => dv().setInt32(1020, 1024, true)
132
-
133
- // Initialize heap pointer if not yet set
134
- const initDv = dv()
135
- if (initDv.getInt32(1020, true) < 1024) initDv.setInt32(1020, 1024, true)
221
+ initHeapPtr()
136
222
 
137
- // Write header (len + cap), return data offset
223
+ // Write 16-byte header matching WASM `__alloc_hdr`:
224
+ // [propsPtr@+0(i64=0), len@+8, cap@+12], return data offset (raw+16).
225
+ // Read paths (ARRAY at off-8/-4, BUFFER at off-8) and the propsPtr slot at
226
+ // off-16 then work uniformly on JS- and WASM-allocated values.
138
227
  const hdr = (len, cap, bytes) => {
139
- const raw = alloc(8 + bytes)
228
+ const raw = alloc(16 + bytes)
140
229
  const m = dv()
141
- m.setInt32(raw, len, true)
142
- m.setInt32(raw + 4, cap, true)
143
- return raw + 8
230
+ m.setBigInt64(raw, 0n, true)
231
+ m.setInt32(raw + 8, len, true)
232
+ m.setInt32(raw + 12, cap, true)
233
+ return raw + 16
144
234
  }
145
235
 
146
- // Read schemas from module custom section, merge into memory.schemas
236
+ // Read schemas from module custom section, merge into memory.schemas. Schema
237
+ // entries are { type, payload } where type=0 means null (computed/missing
238
+ // key), type=1 means nested [null, name] (synthetic shape), else a UTF-8
239
+ // length-prefixed property name. Section format is varint-prefixed list.
147
240
  let schemas = mem.schemas || []
148
- if (mod) {
149
- const secs = WebAssembly.Module.customSections(mod, 'jz:schema')
150
- if (secs.length) {
151
- const b = new Uint8Array(secs[0]), td = new TextDecoder()
152
- let i = 0
153
- const varint = () => { let r = 0, s = 0; while (1) { const x = b[i++]; r |= (x & 0x7F) << s; if (!(x & 0x80)) return r; s += 7 } }
154
- const dec = () => {
155
- const t = b[i++]
156
- if (t === 0) return null
157
- if (t === 1) return [null, dec()]
158
- const n = varint(), s = td.decode(b.subarray(i, i + n)); i += n; return s
159
- }
160
- const nS = varint(), newSchemas = []
161
- for (let j = 0; j < nS; j++) { const k = varint(), props = []; for (let p = 0; p < k; p++) props.push(dec()); newSchemas.push(props) }
162
- for (const s of newSchemas) {
163
- const key = s.join(',')
164
- if (!schemas.some(existing => existing.join(',') === key)) schemas.push(s)
165
- }
241
+ const schemaBytes = mod && customSection(mod, 'jz:schema')
242
+ if (schemaBytes) {
243
+ const r = sectionReader(schemaBytes)
244
+ const dec = () => {
245
+ const t = r.u8()
246
+ if (t === 0) return null
247
+ if (t === 1) return [null, dec()]
248
+ return r.str(r.varint())
249
+ }
250
+ const nS = r.varint(), newSchemas = []
251
+ for (let j = 0; j < nS; j++) { const k = r.varint(), props = []; for (let p = 0; p < k; p++) props.push(dec()); newSchemas.push(props) }
252
+ for (const s of newSchemas) {
253
+ const key = s.join(',')
254
+ if (!schemas.some(existing => existing.join(',') === key)) schemas.push(s)
166
255
  }
167
256
  }
168
257
 
@@ -277,6 +366,8 @@ export const memory = (src) => {
277
366
  if (t === 0 && off === 0) {
278
367
  if (a === 1) return null
279
368
  if (a === 2) return undefined
369
+ if (a === 4) return false
370
+ if (a === 5) return true
280
371
  }
281
372
  if (t === 11 && this._extMap) return this._extMap[off]
282
373
  if (t === 1) { // ARRAY
@@ -414,10 +505,11 @@ export const wrap = (memSrc, inst) => {
414
505
  const restFuncs = new Map()
415
506
  const mod = inst ? memSrc : memSrc.module || memSrc
416
507
  const realInst = inst || memSrc.instance || memSrc
417
- const restSecs = WebAssembly.Module.customSections(mod, 'jz:rest')
418
- if (restSecs.length) {
508
+ const td = new TextDecoder()
509
+ const restBytes = customSection(mod, 'jz:rest')
510
+ if (restBytes) {
419
511
  try {
420
- for (const entry of JSON.parse(new TextDecoder().decode(restSecs[0])))
512
+ for (const entry of JSON.parse(td.decode(restBytes)))
421
513
  restFuncs.set(typeof entry === 'string' ? entry : entry.name, typeof entry === 'string' ? 0 : entry.fixed)
422
514
  } catch (e) { /* ignore */ }
423
515
  }
@@ -427,11 +519,29 @@ export const wrap = (memSrc, inst) => {
427
519
  // reinterprets f64↔BigInt only at those positions (see
428
520
  // synthesizeBoundaryWrappers).
429
521
  const i64Exp = new Map(), EMPTY_SET = new Set()
430
- const i64Secs = WebAssembly.Module.customSections(mod, 'jz:i64exp')
431
- if (i64Secs.length) {
432
- try { for (const e of JSON.parse(new TextDecoder().decode(i64Secs[0]))) i64Exp.set(e.name, { p: new Set(e.p), r: e.r }) }
522
+ const i64Bytes = customSection(mod, 'jz:i64exp')
523
+ if (i64Bytes) {
524
+ try { for (const e of JSON.parse(td.decode(i64Bytes))) i64Exp.set(e.name, { p: new Set(e.p), r: e.r }) }
433
525
  catch { /* ignore */ }
434
526
  }
527
+ // externref-param exports: positions where the wasm side takes an externref
528
+ // (e.g. jsstring boundary opt-in). JS values at these positions pass through
529
+ // unchanged — no `mem.wrapVal` (would NaN-box into f64, defeating the point).
530
+ // `def` (optional) maps idx → default-string for jsstring params whose
531
+ // default substitution happens JS-side (the wasm side never sees null).
532
+ const extExp = new Map()
533
+ const extBytes = customSection(mod, 'jz:extparam')
534
+ if (extBytes) {
535
+ try {
536
+ for (const e of JSON.parse(td.decode(extBytes))) {
537
+ const idx = new Set(e.p)
538
+ // Hang the defaults off the Set as a property so call-sites that only
539
+ // check membership stay unchanged; the slow path reads `extInfo.def`.
540
+ if (e.d) idx.def = new Map(Object.entries(e.d).map(([k, v]) => [Number(k), v]))
541
+ extExp.set(e.name, idx)
542
+ }
543
+ } catch { /* ignore */ }
544
+ }
435
545
 
436
546
  const mem = memory(memSrc)
437
547
  const lastErrBits = realInst.exports.__jz_last_err_bits
@@ -453,17 +563,36 @@ export const wrap = (memSrc, inst) => {
453
563
  // indices; r = result is i64. Numeric (f64) positions pass through unchanged.
454
564
  const adaptArgs = (a, p) => p.size === 0 ? a : a.map((x, i) => p.has(i) ? f64ToI64(x) : x)
455
565
  const adaptRet = (ret, r) => r ? i64ToF64(ret) : ret
566
+ // Externref positions skip mem.wrapVal — the raw JS value (string, object,
567
+ // …) flows through as externref. mem.wrapVal would NaN-box it.
568
+ const wrapArg = (ext, i, x) => ext?.has(i) ? x : mem.wrapVal(x)
456
569
 
457
570
  // Arity-specialized wrapper: rest-spread + .map() + .apply() costs ~85ns/call
458
571
  // on hot loops (mandelbrot benchmark: 51ms wrapped vs 35ms direct over 200K
459
572
  // calls). Generating positional `function(a0, a1, ...)` via Function lets V8
460
573
  // fully inline the WASM call. Falls back to the spread-form wrapper if the
461
574
  // Function constructor is unavailable (CSP) or arity is unusually large.
462
- const makeFastWrapper = (fn, len, p, r, decode_, wrap_) => {
575
+ // `ext` is a Set of externref param positions those slots skip wrap/coerce
576
+ // and pass the JS value directly (jsstring carrier: JS string → externref).
577
+ // `ext.def` (optional Map idx→string) carries jsstring-literal defaults that
578
+ // are substituted JS-side when the caller passes `undefined`.
579
+ const makeFastWrapper = (fn, len, p, r, decode_, wrap_, ext) => {
463
580
  const params = [], wrapped = []
581
+ const defs = ext?.def
582
+ const defArgs = [], defNames = []
464
583
  for (let i = 0; i < len; i++) {
465
584
  const a = `a${i}`
466
585
  params.push(a)
586
+ if (ext?.has(i)) {
587
+ if (defs?.has(i)) {
588
+ const dn = `def${i}`
589
+ defNames.push(dn); defArgs.push(defs.get(i))
590
+ wrapped.push(`(${a} === undefined ? ${dn} : ${a})`)
591
+ } else {
592
+ wrapped.push(a)
593
+ }
594
+ continue
595
+ }
467
596
  const w = wrap_ ? `wrap_(${a})` : `coerce(${a})`
468
597
  wrapped.push(p.has(i) ? `f64ToI64(${w})` : w)
469
598
  }
@@ -472,8 +601,8 @@ export const wrap = (memSrc, inst) => {
472
601
  const body = `return function(${params.join(',')}) {\n` +
473
602
  ` try { return decode_(${retExpr}) } catch (e) { decodeThrown(e) }\n` +
474
603
  `}`
475
- return new Function('fn', 'wrap_', 'coerce', 'decode_', 'f64ToI64', 'i64ToF64', 'decodeThrown', body)(
476
- fn, wrap_, coerce, decode_, f64ToI64, i64ToF64, decodeThrown)
604
+ return new Function('fn', 'wrap_', 'coerce', 'decode_', 'f64ToI64', 'i64ToF64', 'decodeThrown', ...defNames, body)(
605
+ fn, wrap_, coerce, decode_, f64ToI64, i64ToF64, decodeThrown, ...defArgs)
477
606
  }
478
607
 
479
608
  // Pure scalar module (no memory): pass f64 values directly, no marshaling
@@ -482,15 +611,19 @@ export const wrap = (memSrc, inst) => {
482
611
  if (typeof fn !== 'function') { exports[name] = fn; continue }
483
612
  const sig = i64Exp.get(name)
484
613
  const p = sig?.p || EMPTY_SET, r = sig?.r || 0
614
+ const ext = extExp.get(name)
485
615
  const len = fn.length
486
616
  try {
487
- exports[name] = makeFastWrapper(fn, len, p, r, decode, null)
617
+ exports[name] = makeFastWrapper(fn, len, p, r, decode, null, ext)
488
618
  continue
489
619
  } catch { /* CSP fallback */ }
490
620
  exports[name] = (...args) => {
491
621
  while (args.length < len) args.push(undefined)
492
622
  try {
493
- const wasmArgs = adaptArgs(args.map(coerce), p)
623
+ const wasmArgs = adaptArgs(args.map((x, i) => {
624
+ if (!ext?.has(i)) return coerce(x)
625
+ return x === undefined && ext.def?.has(i) ? ext.def.get(i) : x
626
+ }), p)
494
627
  return decode(adaptRet(fn(...wasmArgs), r))
495
628
  } catch (e) { decodeThrown(e) }
496
629
  }
@@ -504,8 +637,12 @@ export const wrap = (memSrc, inst) => {
504
637
  const fixed = restFuncs.get(name)
505
638
  const sig = i64Exp.get(name)
506
639
  const p = sig?.p || EMPTY_SET, r = sig?.r || 0
640
+ const ext = extExp.get(name)
507
641
  exports[name] = (...args) => {
508
- const a = args.slice(0, fixed).map(x => mem.wrapVal(x))
642
+ const a = args.slice(0, fixed).map((x, i) => {
643
+ if (!ext?.has(i)) return mem.wrapVal(x)
644
+ return x === undefined && ext.def?.has(i) ? ext.def.get(i) : x
645
+ })
509
646
  while (a.length < fixed) a.push(UNDEF_NAN)
510
647
  a.push(mem.Array(args.slice(fixed)))
511
648
  try {
@@ -518,15 +655,19 @@ export const wrap = (memSrc, inst) => {
518
655
  } else if (typeof fn === 'function') {
519
656
  const sig = i64Exp.get(name)
520
657
  const p = sig?.p || EMPTY_SET, r = sig?.r || 0
658
+ const ext = extExp.get(name)
521
659
  const len = fn.length
522
660
  try {
523
- exports[name] = makeFastWrapper(fn, len, p, r, memRead, memWrapVal)
661
+ exports[name] = makeFastWrapper(fn, len, p, r, memRead, memWrapVal, ext)
524
662
  continue
525
663
  } catch { /* CSP fallback */ }
526
664
  exports[name] = (...args) => {
527
665
  while (args.length < len) args.push(undefined)
528
666
  try {
529
- const boxed = args.map(x => mem.wrapVal(x))
667
+ const boxed = args.map((x, i) => {
668
+ if (!ext?.has(i)) return mem.wrapVal(x)
669
+ return x === undefined && ext.def?.has(i) ? ext.def.get(i) : x
670
+ })
530
671
  const ret = fn.apply(null, adaptArgs(boxed, p))
531
672
  return mem.read(adaptRet(ret, r))
532
673
  } catch (error) {
@@ -573,8 +714,7 @@ const prepareInterop = (opts) => {
573
714
  // imports them (host: 'js' mode lowering in module/console.js). Caller-provided
574
715
  // opts.imports.env entries take precedence.
575
716
  const installDefaultEnvImports = (mod, imports, state) => {
576
- const envFns = new Set(WebAssembly.Module.imports(mod)
577
- .filter(i => i.module === 'env' && i.kind === 'function').map(i => i.name))
717
+ const envFns = envFuncNames(mod)
578
718
  if (!envFns.size) return
579
719
  if (!imports.env) imports.env = {}
580
720
  if (envFns.has('print') && !imports.env.print) {
@@ -647,11 +787,61 @@ const installDefaultEnvImports = (mod, imports, state) => {
647
787
  }
648
788
  }
649
789
 
790
+ // JS-side polyfills for `wasm:js-string` builtins. Used when the engine does
791
+ // NOT honor `new WebAssembly.Module(buf, { builtins: ['js-string'] })` — older
792
+ // V8, Hermes, JSC pre-18.4, etc. With native builtins the engine inlines
793
+ // these calls to direct string accesses; with the polyfill each call is a
794
+ // wasm→JS hop (still correct, just no boundary win).
795
+ const JSS_POLYFILL = {
796
+ length: (s) => s.length,
797
+ charCodeAt: (s, i) => s.charCodeAt(i),
798
+ }
799
+
800
+ // Probe once: does this engine honor the `{ builtins: ['js-string'] }` option
801
+ // on WebAssembly.Module? Compiles a tiny module that imports a wasm:js-string
802
+ // fn; if instantiation succeeds with no imports object, native is available.
803
+ let jssNativeProbed = false
804
+ let jssNativeSupported = false
805
+ const jssProbeNative = () => {
806
+ if (jssNativeProbed) return jssNativeSupported
807
+ jssNativeProbed = true
808
+ try {
809
+ // Minimal module: (module (import "wasm:js-string" "length" (func (param externref) (result i32))))
810
+ const bytes = new Uint8Array([
811
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // header
812
+ 0x01, 0x06, 0x01, 0x60, 0x01, 0x6f, 0x01, 0x7f, // type: (externref)→i32
813
+ 0x02, 0x18, 0x01, // import section
814
+ 0x0f, ...new TextEncoder().encode('wasm:js-string'), // mod name
815
+ 0x06, ...new TextEncoder().encode('length'), // name
816
+ 0x00, 0x00, // kind=func, type=0
817
+ ])
818
+ const mod = new WebAssembly.Module(bytes, { builtins: ['js-string'] })
819
+ new WebAssembly.Instance(mod, {})
820
+ jssNativeSupported = true
821
+ } catch {
822
+ jssNativeSupported = false
823
+ }
824
+ return jssNativeSupported
825
+ }
826
+
650
827
  const buildImports = (mod, opts, state) => {
651
- const needsWasi = WebAssembly.Module.imports(mod).some(i => i.module === 'wasi_snapshot_preview1')
652
- const imports = needsWasi ? wasi(opts) : {}
828
+ const { needsWasi, wasiImports } = linkWasi(mod, opts)
829
+ const imports = wasiImports || {}
653
830
  if (opts._interp) imports.env = { ...imports.env, ...opts._interp }
654
831
 
832
+ // `wasm:js-string` polyfills — only attach when native builtins aren't honored
833
+ // by this engine. With `{ builtins: ['js-string'] }` the import slots are
834
+ // already filled by the engine; supplying a JS function would error or just
835
+ // be ignored. Without native support, polyfill the names this module imports.
836
+ if (!jssProbeNative()) {
837
+ for (const imp of WebAssembly.Module.imports(mod)) {
838
+ if (imp.module === 'wasm:js-string' && JSS_POLYFILL[imp.name]) {
839
+ if (!imports['wasm:js-string']) imports['wasm:js-string'] = {}
840
+ imports['wasm:js-string'][imp.name] = JSS_POLYFILL[imp.name]
841
+ }
842
+ }
843
+ }
844
+
655
845
  // Host imports: decode NaN-boxed args for JS and wrap JS returns back into jz
656
846
  // values. Args/return ride i64 across the boundary (Step 2c) so V8 cannot
657
847
  // canonicalize the NaN payload — convert BigInt↔f64 via reinterpret bits.
@@ -705,16 +895,8 @@ const finishInstantiation = (mod, inst, imports, needsWasi, opts, state) => {
705
895
  // Trampoline used by env.setTimeout/clearTimeout to fire scheduled closures.
706
896
  state.invoke = inst.exports.__invoke_closure || null
707
897
 
708
- // Drive WASM timer queue via JS scheduling (non-blocking)
709
- if (inst.exports.__timer_tick) {
710
- const tick = inst.exports.__timer_tick
711
- let hadTimers = false
712
- const id = setInterval(() => {
713
- const remaining = tick()
714
- if (remaining > 0) hadTimers = true
715
- if (hadTimers && remaining <= 0) clearInterval(id)
716
- }, 1)
717
- }
898
+ // Drive WASM timer queue via JS scheduling (non-blocking, no-op if absent).
899
+ attachTimers(inst)
718
900
 
719
901
  // For shared memory, resolve memory from import; for own memory, from export.
720
902
  const rawMemory = opts.memory instanceof WebAssembly.Memory ? opts.memory : inst.exports.memory
@@ -726,14 +908,35 @@ const finishInstantiation = (mod, inst, imports, needsWasi, opts, state) => {
726
908
  }
727
909
 
728
910
  /**
729
- * Compile, instantiate, and wrap exports (with WASI + rest-param support).
730
- * `compile` is the jz.compile function (injected to avoid importing the compiler core).
911
+ * Instantiate prebuilt jz wasm and wrap exports (WASI imports, rest-params,
912
+ * host-object externrefs, default env.print/now wiring, optional shared memory).
913
+ *
914
+ * Compile-and-instantiate is the caller's job — pass already-compiled bytes:
915
+ * import { instantiate } from 'jz/interop'
916
+ * const { exports, memory } = instantiate(wasmBytes)
917
+ *
918
+ * @param {Uint8Array|ArrayBuffer|WebAssembly.Module} wasm prebuilt wasm
919
+ * @param {object} [opts] host options: imports, memory, _interp, host-shape flags
920
+ * @returns {{ exports, memory, instance, module }}
731
921
  */
732
- export const instantiate = (compile, code, opts = {}) => {
922
+ export const instantiate = (wasm, opts = {}) => {
733
923
  const state = prepareInterop(opts)
734
- const wasm = compile(code, opts)
735
924
  opts.extMap = state.extMap
736
- const mod = new WebAssembly.Module(wasm)
925
+ // Prefer native `wasm:js-string` builtins when the engine honors the option.
926
+ // The option is silently accepted by V8 17+/Safari 18.4+; older engines that
927
+ // don't recognize it either throw or ignore it — try-fallback handles both.
928
+ let mod
929
+ if (wasm instanceof WebAssembly.Module) {
930
+ mod = wasm
931
+ } else if (jssProbeNative()) {
932
+ try {
933
+ mod = new WebAssembly.Module(wasm, { builtins: ['js-string'] })
934
+ } catch {
935
+ mod = new WebAssembly.Module(wasm)
936
+ }
937
+ } else {
938
+ mod = new WebAssembly.Module(wasm)
939
+ }
737
940
  const { imports, needsWasi } = buildImports(mod, opts, state)
738
941
  const hasImports = Object.keys(imports).some(k => k !== '_setMemory')
739
942
  const inst = new WebAssembly.Instance(mod, hasImports ? imports : undefined)