@napi-rs/cli 3.9.1 → 3.10.1

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,11 +1,16 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
1
4
  import ava, { type ExecutionContext } from 'ava'
2
5
  import { parseSync } from 'oxc-parser'
6
+ import ts from 'typescript'
3
7
 
4
8
  import { createCjsBinding, createEsmBinding } from '../templates/js-binding.js'
5
9
  import {
6
10
  createWasiBinding,
7
11
  createWasiBrowserBinding,
8
12
  createWasiDeferredBrowserBinding,
13
+ createWasiDeferredBrowserBindingTypeDef,
9
14
  } from '../templates/load-wasi-template.js'
10
15
  import {
11
16
  createWasiBrowserWorkerBinding,
@@ -14,6 +19,8 @@ import {
14
19
 
15
20
  const test = ava
16
21
 
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+
17
24
  // Snapshot tests for full template output
18
25
 
19
26
  test('createWasiBrowserBinding default', (t) => {
@@ -68,6 +75,63 @@ test('createWasiBrowserBinding threadless keeps sync init and no pool', (t) => {
68
75
  t.true(binding.includes('__emnapiInstantiateNapiModuleSync(__wasmFile'))
69
76
  })
70
77
 
78
+ test('threadless loaders embed the initial memory they are given', (t) => {
79
+ const unshared = `new WebAssembly.Memory({
80
+ initial: 1027,
81
+ maximum: 65536,
82
+ })`
83
+
84
+ const nodeLoader = createWasiBinding(
85
+ 'test',
86
+ '@scope/test',
87
+ 1027,
88
+ 65536,
89
+ false,
90
+ )
91
+ t.true(nodeLoader.includes(`const __wasmMemory = ${unshared}`))
92
+ t.false(nodeLoader.includes('shared: true'))
93
+
94
+ const browserLoader = createWasiBrowserBinding(
95
+ 'test-wasi',
96
+ 1027,
97
+ 65536,
98
+ false,
99
+ false,
100
+ false,
101
+ false,
102
+ false,
103
+ )
104
+ t.true(browserLoader.includes(`const __wasmMemory = ${unshared}`))
105
+ t.false(browserLoader.includes('shared: true'))
106
+
107
+ // The deferred loader publishes its descriptor instead of inlining it, and
108
+ // allocates from it in function scope (workerd bans global scope allocation).
109
+ const deferred = createWasiDeferredBrowserBinding('test', 1027, 65536)
110
+ t.true(
111
+ deferred.includes(`export const WASM_MEMORY = Object.freeze({
112
+ initialPages: 1027,
113
+ maximumPages: 65536,`),
114
+ )
115
+ t.true(
116
+ deferred.includes(` const __allocated = new WebAssembly.Memory({
117
+ initial:
118
+ __options != null && __options.initialMemoryPages !== undefined
119
+ ? __options.initialMemoryPages
120
+ : WASM_MEMORY.initialPages,`),
121
+ )
122
+ t.false(deferred.includes('shared: true'))
123
+
124
+ // the threaded loader still gets its own, shared descriptor
125
+ const threaded = createWasiBinding('test', '@scope/test', 16384, 65536, true)
126
+ t.true(
127
+ threaded.includes(`const __sharedMemory = new WebAssembly.Memory({
128
+ initial: 16384,
129
+ maximum: 65536,
130
+ shared: true,
131
+ })`),
132
+ )
133
+ })
134
+
71
135
  test('createWasiBrowserBinding with errorEvent', (t) => {
72
136
  t.snapshot(
73
137
  createWasiBrowserBinding(
@@ -96,6 +160,47 @@ test('createWasiBrowserBinding with errorEvent and fs', (t) => {
96
160
  )
97
161
  })
98
162
 
163
+ test('createWasiBrowserBinding with asyncRuntime hosts', (t) => {
164
+ t.snapshot(
165
+ createWasiBrowserBinding(
166
+ 'test-wasi',
167
+ 4000,
168
+ 65536,
169
+ false,
170
+ false,
171
+ false,
172
+ false,
173
+ false,
174
+ 'wasm32-wasip1',
175
+ true,
176
+ ),
177
+ )
178
+ })
179
+
180
+ // The deferred loader is the published `./workerd` entry and the only loader
181
+ // whose module namespace is part of the package's public API, so its bytes get
182
+ // the same snapshot treatment as the browser loaders'.
183
+ test('createWasiDeferredBrowserBinding default', (t) => {
184
+ t.snapshot(createWasiDeferredBrowserBinding('test-wasi'))
185
+ })
186
+
187
+ test('createWasiDeferredBrowserBinding with async-runtime hosts and buffer', (t) => {
188
+ t.snapshot(
189
+ createWasiDeferredBrowserBinding(
190
+ 'test-wasi',
191
+ 1027,
192
+ 65536,
193
+ true,
194
+ 'wasm32-wasip1',
195
+ true,
196
+ ),
197
+ )
198
+ })
199
+
200
+ test('createWasiDeferredBrowserBindingTypeDef', (t) => {
201
+ t.snapshot(createWasiDeferredBrowserBindingTypeDef('./test-wasi.wasip1.cjs'))
202
+ })
203
+
99
204
  test('createWasiBrowserWorkerBinding default', (t) => {
100
205
  t.snapshot(createWasiBrowserWorkerBinding(false, false))
101
206
  })
@@ -143,6 +248,36 @@ const browserBindingCases: Array<{
143
248
  args: ['test', 4000, 65536, false, true, false, true],
144
249
  },
145
250
  { name: 'all options', args: ['test', 4000, 65536, true, true, true, true] },
251
+ {
252
+ name: 'asyncRuntime',
253
+ args: [
254
+ 'test',
255
+ 4000,
256
+ 65536,
257
+ false,
258
+ false,
259
+ false,
260
+ false,
261
+ true,
262
+ 'wasm32-wasi',
263
+ true,
264
+ ],
265
+ },
266
+ {
267
+ name: 'all options + asyncRuntime',
268
+ args: [
269
+ 'test',
270
+ 4000,
271
+ 65536,
272
+ true,
273
+ true,
274
+ true,
275
+ true,
276
+ true,
277
+ 'wasm32-wasi',
278
+ true,
279
+ ],
280
+ },
146
281
  ]
147
282
 
148
283
  for (const { name, args } of browserBindingCases) {
@@ -221,6 +356,69 @@ for (const { name, code } of cjsBindingCases) {
221
356
  // emitted flavors it builds (node cjs threadless, deferred/workerd)
222
357
  // behaviorally; the other flavors are never instantiated by any test, so assert
223
358
  // the shape here as well.
359
+ // `napi.wasm.asyncRuntime` output. The seven-export / contract-v4 / liveness /
360
+ // rollback checks all live in `@napi-rs/async-runtime`; the loader only has to
361
+ // call it at the right moment and tear it down at the right moment.
362
+ const asyncRuntimeLoaderCases: Array<{
363
+ name: string
364
+ code: string
365
+ install: string
366
+ }> = [
367
+ {
368
+ name: 'node cjs',
369
+ code: createWasiBinding(
370
+ 'test',
371
+ '@scope/test',
372
+ 4000,
373
+ 65536,
374
+ true,
375
+ 'wasm32-wasi',
376
+ 'test',
377
+ true,
378
+ ),
379
+ install: "require('@napi-rs/async-runtime')",
380
+ },
381
+ {
382
+ name: 'node cjs threadless',
383
+ code: createWasiBinding(
384
+ 'test',
385
+ '@scope/test',
386
+ 4000,
387
+ 65536,
388
+ false,
389
+ 'wasm32-wasip1',
390
+ 'test',
391
+ true,
392
+ ),
393
+ install: "require('@napi-rs/async-runtime')",
394
+ },
395
+ {
396
+ name: 'browser esm',
397
+ code: createWasiBrowserBinding(
398
+ 'test',
399
+ 4000,
400
+ 65536,
401
+ false,
402
+ false,
403
+ false,
404
+ false,
405
+ true,
406
+ 'wasm32-wasi',
407
+ true,
408
+ ),
409
+ install: "from '@napi-rs/async-runtime'",
410
+ },
411
+ ]
412
+
413
+ const asyncRuntimeDeferredCode = createWasiDeferredBrowserBinding(
414
+ 'test',
415
+ 1024,
416
+ 65536,
417
+ false,
418
+ 'wasm32-wasip1',
419
+ true,
420
+ )
421
+
224
422
  const wasiLoaderCases: Array<{ name: string; code: string }> = [
225
423
  { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
226
424
  {
@@ -229,8 +427,498 @@ const wasiLoaderCases: Array<{ name: string; code: string }> = [
229
427
  },
230
428
  { name: 'browser esm', code: createWasiBrowserBinding('test') },
231
429
  { name: 'deferred/workerd', code: createWasiDeferredBrowserBinding('test') },
430
+ ...asyncRuntimeLoaderCases.map(({ name, code }) => ({
431
+ name: `${name} + asyncRuntime`,
432
+ code,
433
+ })),
434
+ {
435
+ name: 'deferred/workerd + asyncRuntime',
436
+ code: asyncRuntimeDeferredCode,
437
+ },
232
438
  ]
233
439
 
440
+ // The flag is off by default, so every existing generated loader keeps its
441
+ // current bytes when a project bumps the CLI. The six template snapshots are
442
+ // the byte-level proof; this is the cheap named regression net.
443
+ for (const { name, code } of [
444
+ { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
445
+ { name: 'browser esm', code: createWasiBrowserBinding('test') },
446
+ { name: 'deferred/workerd', code: createWasiDeferredBrowserBinding('test') },
447
+ ]) {
448
+ test(`asyncRuntime is off by default: ${name}`, (t) => {
449
+ t.false(code.includes('@napi-rs/async-runtime'))
450
+ t.false(code.includes('__disposeCurrentThreadHosts'))
451
+ t.false(code.includes('installCurrentThreadHosts'))
452
+ })
453
+ }
454
+
455
+ for (const { name, code, install } of asyncRuntimeLoaderCases) {
456
+ test(`asyncRuntime loader installs and tears down the hosts: ${name}`, (t) => {
457
+ assertValidJS(t, code, name)
458
+ t.true(code.includes(install))
459
+ // The package owns the seven-export / contract-v4 / liveness / rollback
460
+ // checks; the loader must not re-implement any of them.
461
+ t.true(code.includes('__installCurrentThreadHosts('))
462
+ t.false(code.includes('reserveCurrentThreadHostRegistration'))
463
+ t.false(code.includes('getCurrentThreadTaskHostContractVersion'))
464
+
465
+ // Install runs after the dispose symbol is published and INSIDE the try,
466
+ // so a mismatch throw reaches the existing rollback.
467
+ const installIndex = code.indexOf(
468
+ '__currentThreadHostsDisposer = __installCurrentThreadHosts(',
469
+ )
470
+ t.true(
471
+ installIndex > code.indexOf('__publishWasiDispose(__napiModule.exports)'),
472
+ )
473
+ t.true(
474
+ installIndex < code.indexOf('\n} catch (error) {\n', installIndex - 1),
475
+ )
476
+
477
+ // Teardown runs before the context is destroyed, on every path, because
478
+ // __destroyEmnapiContext is the single funnel dispose()/rollback/'exit'
479
+ // all reach.
480
+ const destroyBody = code.slice(
481
+ code.indexOf('function __destroyEmnapiContext() {'),
482
+ code.indexOf('function __terminateWasiWorkers() {'),
483
+ )
484
+ t.true(destroyBody.includes('__disposeCurrentThreadHosts()'))
485
+ t.true(
486
+ destroyBody.indexOf('__disposeCurrentThreadHosts()') <
487
+ destroyBody.indexOf('__emnapiContext.destroy()'),
488
+ )
489
+ // …and after the drain: __startWasiDisposal prepares + drains before it
490
+ // ever calls __continueWasiDisposal -> __destroyEmnapiContext.
491
+ t.false(destroyBody.includes('__drainWasmEnvCleanup'))
492
+ })
493
+ }
494
+
495
+ // `@emnapi/wasi-threads` records a worker exit as expected only when ITS thread
496
+ // manager performed the termination. A bare `worker.terminate()` reaches the
497
+ // manager's own 'exit' listener, which reports the exit as a worker failure and
498
+ // rethrows inside the emit — aborting the `once('exit')` that backs the
499
+ // terminate promise, so `dispose()` never settles.
500
+ for (const { name, code } of [
501
+ { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
502
+ { name: 'browser esm', code: createWasiBrowserBinding('test') },
503
+ ]) {
504
+ test(`pool workers are terminated through the thread manager: ${name}`, (t) => {
505
+ const start = code.indexOf('function __terminateWasiWorkers() {')
506
+ t.true(start > 0)
507
+ const body = code.slice(
508
+ start,
509
+ code.indexOf('function __finishWasiDisposal() {'),
510
+ )
511
+ const mark = body.indexOf('threadManager.terminateWorker(worker)')
512
+ const terminate = body.indexOf('result = worker.terminate()')
513
+ t.true(terminate > 0)
514
+ t.true(mark > 0, 'the termination has to be marked on the thread manager')
515
+ t.true(mark < terminate, 'and marked before the worker is terminated')
516
+ // Not `terminateAllThreads()`: it recreates the pool it just shut down.
517
+ t.false(body.includes('terminateAllThreads'))
518
+ // `terminateWorker` leaves a reporter behind that logs every message still
519
+ // queued on the port, which Node flushes on exit.
520
+ t.true(body.includes('worker.onmessage = undefined'))
521
+ // The manager is resolved through the helper, not read off `__napiModule`:
522
+ // the rollback runs on the one path where that binding was never assigned.
523
+ t.true(body.includes('const threadManager = __getWasiThreadManager()'))
524
+ t.false(body.includes('__napiModule.PThread'))
525
+ })
526
+ }
527
+
528
+ // The pool workers are unreferenced on purpose, and emnapi unreferences them
529
+ // again when one reports `async-thread-ready`, so a pending termination has no
530
+ // handle of its own to hold the loop open with.
531
+ for (const { name, code } of [
532
+ { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
533
+ { name: 'browser esm', code: createWasiBrowserBinding('test') },
534
+ ]) {
535
+ test(`a pending termination holds the event loop open: ${name}`, (t) => {
536
+ const body = code.slice(
537
+ code.indexOf('function __terminateWasiWorkers() {'),
538
+ code.indexOf('function __finishWasiDisposal() {'),
539
+ )
540
+ t.true(
541
+ body.includes(
542
+ '__keepEventLoopAliveUntil(Promise.all(pending)).then(finish)',
543
+ ),
544
+ 'the terminate promises have to be awaited under a keep-alive',
545
+ )
546
+ // …and the keep-alive is released the moment the work settles, so it can
547
+ // never outlive the disposal that asked for it.
548
+ const keepAlive = code.slice(
549
+ code.indexOf('function __keepEventLoopAliveUntil(work) {'),
550
+ )
551
+ t.true(keepAlive.indexOf('clearTimer(timer)') > 0)
552
+ t.true(keepAlive.indexOf('release()') < keepAlive.indexOf('return value'))
553
+ // Nothing puts the stubbed `ref` functions back: doing so is what raced
554
+ // emnapi's own unreference.
555
+ t.false(code.includes('__wasiWorkerRefRestorers'))
556
+ t.false(code.includes('__restoreWasiWorkerRef'))
557
+ })
558
+ }
559
+
560
+ test('the node loader keeps its pool workers unreferenced for life', (t) => {
561
+ const code = createWasiBinding('test', '@scope/test')
562
+ t.true(code.includes('worker[kPublicPort].ref = () => {}'))
563
+ t.true(code.includes('worker[kHandle].ref = () => {}'))
564
+ t.true(code.includes('worker.unref()'))
565
+ // An idle binding must not hold the process open, and disposal does not
566
+ // reverse that — `__keepEventLoopAliveUntil` covers the termination instead.
567
+ t.false(code.includes('.ref = publicPortRef'))
568
+ t.false(code.includes('.ref = handleRef'))
569
+ })
570
+
571
+ // `examples/custom-async-runtime` asserts a threadless loader never mentions
572
+ // `Worker`, so nothing in the *shared* prelude may name the class — comments
573
+ // included. That lane needs a wasm build to fail; this does not.
574
+ for (const { name, code } of [
575
+ {
576
+ name: 'node cjs threadless',
577
+ code: createWasiBinding('test', '@scope/test', 4000, 65536, false),
578
+ },
579
+ {
580
+ name: 'browser esm threadless',
581
+ code: createWasiBrowserBinding(
582
+ 'test',
583
+ 4000,
584
+ 65536,
585
+ false,
586
+ false,
587
+ false,
588
+ false,
589
+ false,
590
+ ),
591
+ },
592
+ { name: 'deferred/workerd', code: createWasiDeferredBrowserBinding('test') },
593
+ ]) {
594
+ test(`threadless loaders never name Worker: ${name}`, (t) => {
595
+ t.notRegex(code, /\bWorker\b/)
596
+ t.notRegex(code, /node:worker_threads/)
597
+ })
598
+ }
599
+
600
+ test('asyncRuntime deferred loader registers per instance', (t) => {
601
+ const code = asyncRuntimeDeferredCode
602
+ assertValidJS(t, code, 'deferred asyncRuntime')
603
+ // Per-instance helpers, NOT installCurrentThreadHosts: each instance owns
604
+ // its own env and needs an exact disposer, not a realm-global dedup.
605
+ t.true(code.includes('__registerWorkerdCurrentThreadTaskHost('))
606
+ t.true(code.includes('__registerWorkerdTimerHost('))
607
+ t.false(code.includes('installCurrentThreadHosts'))
608
+ // Task host first, timer host second; disposal is the reverse.
609
+ t.true(
610
+ code.indexOf('const __disposeTaskHost =') <
611
+ code.indexOf('const __disposeTimerHost ='),
612
+ )
613
+ const disposer = code.slice(code.indexOf('__disposeInstanceHosts = () => {'))
614
+ t.true(
615
+ disposer.indexOf('__disposeTimerHost()') <
616
+ disposer.indexOf('__disposeTaskHost()'),
617
+ )
618
+ // Hooked next to __prepareEnvCleanup, which every destroy path calls.
619
+ const managedDestroy = code.slice(
620
+ code.indexOf(' __prepareEnvCleanup?.()'),
621
+ )
622
+ t.true(
623
+ managedDestroy.indexOf('__disposeHosts?.()') <
624
+ managedDestroy.indexOf('__result = __emnapiContext.destroy()'),
625
+ )
626
+ })
627
+
628
+ // The deferred loader's module namespace is the published `./workerd` entry's
629
+ // public API. `WASM_MEMORY` and `getDeferredRuntimeStats` are new names in it,
630
+ // and `createInstance`'s option bag is the only way a host sizes an instance
631
+ // under a hard isolate cap.
632
+ test('deferred loader is syntactically valid in both host modes', (t) => {
633
+ assertValidJS(t, createWasiDeferredBrowserBinding('test'), 'deferred')
634
+ assertValidJS(
635
+ t,
636
+ createWasiDeferredBrowserBinding(
637
+ 'test',
638
+ 1027,
639
+ 65536,
640
+ true,
641
+ 'wasm32-wasip1',
642
+ true,
643
+ ),
644
+ 'deferred + hosts',
645
+ )
646
+ })
647
+
648
+ test('deferred loader publishes the memory floor it was configured with', (t) => {
649
+ const code = createWasiDeferredBrowserBinding('test', 1027, 40000)
650
+ t.true(code.includes('export const WASM_MEMORY = Object.freeze({'))
651
+ t.true(code.includes('initialPages: 1027'))
652
+ t.true(code.includes('maximumPages: 40000'))
653
+ t.true(code.includes('initialBytes: 1027 * 65536'))
654
+ t.true(code.includes('maximumBytes: 40000 * 65536'))
655
+ t.true(code.includes('export function getDeferredRuntimeStats()'))
656
+ // workerd bans allocation in global scope, so the single `new
657
+ // WebAssembly.Memory` stays inside the per-instance resolver.
658
+ t.is(code.split('new WebAssembly.Memory(').length - 1, 1)
659
+ t.true(
660
+ code.indexOf('function __resolveInstanceMemory(') <
661
+ code.indexOf('new WebAssembly.Memory('),
662
+ )
663
+ })
664
+
665
+ test('deferred loader claims caller memory exactly once and rejects shared memory', (t) => {
666
+ const code = createWasiDeferredBrowserBinding('test')
667
+ t.true(code.includes('const __claimedMemories = new WeakSet()'))
668
+ t.true(code.includes('__claimedMemories.add(__provided)'))
669
+ t.true(
670
+ code.includes('requires an unshared WebAssembly.Memory'),
671
+ 'a SharedArrayBuffer-backed memory must be rejected, not silently accepted',
672
+ )
673
+ t.true(
674
+ code.includes(
675
+ 'Pass either memory or initialMemoryPages/maximumMemoryPages, not both',
676
+ ),
677
+ )
678
+ // The claim is taken before instantiation can fail, so a failed attempt
679
+ // cannot hand the same half-written bytes to a second instance.
680
+ const resolver = code.slice(
681
+ code.indexOf('function __resolveInstanceMemory('),
682
+ code.indexOf('\n}\n', code.indexOf('function __resolveInstanceMemory(')),
683
+ )
684
+ t.true(resolver.includes('__claimedMemories.add(__provided)'))
685
+ // The loader-allocated Memory is claimed too: the handle publishes it as
686
+ // `instance.memory`, and two live instances on one linear memory each
687
+ // reinitialize the state the other is running on.
688
+ t.true(resolver.includes('__claimedMemories.add(__allocated)'))
689
+ })
690
+
691
+ test('deferred loader rejects a cross-realm memory before claiming it', (t) => {
692
+ const code = createWasiDeferredBrowserBinding('test')
693
+ const resolver = code.slice(
694
+ code.indexOf('function __resolveInstanceMemory('),
695
+ code.indexOf('\n}\n', code.indexOf('function __resolveInstanceMemory(')),
696
+ )
697
+ // The intrinsic getters accept a genuine Memory from any realm, but
698
+ // `WASI.setMemory` and emnapi identify one with a realm-local `instanceof`.
699
+ t.true(resolver.includes('if (!(__provided instanceof WebAssembly.Memory))'))
700
+ t.true(
701
+ resolver.includes(
702
+ 'memory must be a WebAssembly.Memory created in the same realm as this loader',
703
+ ),
704
+ )
705
+ // Every rejection must precede the claim, or a corrected retry would be
706
+ // refused as a reuse of a Memory that never ran anything.
707
+ t.true(
708
+ resolver.indexOf('__provided instanceof WebAssembly.Memory') <
709
+ resolver.indexOf('__claimedMemories.add(__provided)'),
710
+ )
711
+ t.true(
712
+ resolver.indexOf(
713
+ 'Pass either memory or initialMemoryPages/maximumMemoryPages, not both',
714
+ ) < resolver.indexOf('__claimedMemories.add(__provided)'),
715
+ )
716
+ })
717
+
718
+ test('deferred instance handle reports its memory and retires exactly once', (t) => {
719
+ const code = createWasiDeferredBrowserBinding('test')
720
+ const handleStart = code.indexOf(
721
+ ' return {\n exports: __napiModule.exports,',
722
+ )
723
+ t.true(
724
+ handleStart !== -1,
725
+ 'the instance handle must be returned as a literal',
726
+ )
727
+ const handle = code.slice(
728
+ handleStart,
729
+ code.indexOf(' } catch (error) {', handleStart),
730
+ )
731
+ t.true(handle.includes('get memory() {'))
732
+ t.true(handle.includes('get memoryBytes() {'))
733
+ t.true(handle.includes('get disposed() {'))
734
+ // The handle delegates to the coalescing wrapper, so the retirement
735
+ // bookkeeping lives in the one disposal body that wrapper runs.
736
+ t.true(handle.includes('dispose: __disposeInstance,'))
737
+ const disposalStart = code.indexOf(
738
+ ' const __runInstanceDisposal = async () => {',
739
+ )
740
+ t.true(disposalStart !== -1, 'the disposal body must be a named arrow')
741
+ const disposal = code.slice(
742
+ disposalStart,
743
+ code.indexOf('\n }\n', disposalStart),
744
+ )
745
+ // The counter moves only after the destroy resolves, so a dispose() that
746
+ // throws stays retryable without double-decrementing.
747
+ t.true(
748
+ disposal.indexOf('await (__beforeExitDestroy') <
749
+ disposal.indexOf('__liveInstances -= 1'),
750
+ )
751
+ t.true(disposal.includes('if (!__disposed) {'))
752
+ })
753
+
754
+ test('deferred loader keeps the singleton on the loader-owned memory', (t) => {
755
+ const code = createWasiDeferredBrowserBinding('test')
756
+ // `instantiate()` takes no option bag, so it must pass the options slot
757
+ // explicitly rather than shifting `__disposeDefaultInstance` into it.
758
+ t.true(
759
+ code.includes(`__createInstance(
760
+ __module,
761
+ undefined,
762
+ __disposeDefaultInstance,`),
763
+ )
764
+ t.true(code.includes('return __createInstance(__wasmInput, __options)'))
765
+ })
766
+
767
+ test('deferred loader pulls its hosts from the isolate-safe subpath', (t) => {
768
+ const code = createWasiDeferredBrowserBinding(
769
+ 'test',
770
+ 1024,
771
+ 65536,
772
+ false,
773
+ 'wasm32-wasip1',
774
+ true,
775
+ )
776
+ // The barrel (`index.cjs`) also requires `current-thread-hosts.cjs`, the
777
+ // Node-lane relay a worker bundle never runs and CJS cannot tree-shake.
778
+ t.true(code.includes("} from '@napi-rs/async-runtime/workerd'"))
779
+ t.false(code.includes("from '@napi-rs/async-runtime'\n"))
780
+ })
781
+
782
+ test('deferred loader type definition covers the new surface', (t) => {
783
+ const typeDef = createWasiDeferredBrowserBindingTypeDef('./test.wasip1.cjs')
784
+ // The two memory forms are separate interfaces, each declaring the other
785
+ // form's properties as `never`, so the combination `__resolveInstanceMemory`
786
+ // always throws on cannot be spelled by a typed caller.
787
+ t.true(typeDef.includes('export interface WasiCallerMemoryOptions {'))
788
+ t.true(typeDef.includes(' memory: WebAssembly.Memory'))
789
+ t.true(typeDef.includes(' initialMemoryPages?: never'))
790
+ t.true(typeDef.includes(' maximumMemoryPages?: never'))
791
+ t.true(typeDef.includes('export interface WasiAllocatedMemoryOptions {'))
792
+ t.true(typeDef.includes(' memory?: never'))
793
+ t.true(typeDef.includes(' initialMemoryPages?: number'))
794
+ t.true(typeDef.includes(' maximumMemoryPages?: number'))
795
+ t.true(
796
+ typeDef.includes(`export type WasiInstanceOptions =
797
+ | WasiCallerMemoryOptions
798
+ | WasiAllocatedMemoryOptions`),
799
+ )
800
+ t.false(typeDef.includes('export interface WasiInstanceOptions {'))
801
+ t.true(typeDef.includes('readonly memoryBytes: number'))
802
+ t.true(typeDef.includes('readonly disposed: boolean'))
803
+ t.true(typeDef.includes('export const WASM_MEMORY: Readonly<{'))
804
+ t.true(
805
+ typeDef.includes(
806
+ 'export function getDeferredRuntimeStats(): Readonly<WasiRuntimeStats>',
807
+ ),
808
+ )
809
+ t.true(
810
+ typeDef.includes(`export function createInstance(
811
+ wasmInput: WasiModuleInput,
812
+ options?: WasiInstanceOptions,
813
+ ): Promise<WasiInstance>`),
814
+ )
815
+ // `createWasiDeferredBindingTypeDef` rewrites this exact string when the
816
+ // project builds without type definitions.
817
+ t.true(typeDef.includes("typeof import('./test.wasip1.cjs')"))
818
+ })
819
+
820
+ const DEFERRED_TYPE_CHECK_OPTIONS: ts.CompilerOptions = {
821
+ strict: true,
822
+ noEmit: true,
823
+ // The generated `.d.ts` is the file under test, so it must not be skipped.
824
+ // Only the default lib is.
825
+ skipLibCheck: false,
826
+ skipDefaultLibCheck: true,
827
+ target: ts.ScriptTarget.ESNext,
828
+ module: ts.ModuleKind.ESNext,
829
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
830
+ }
831
+
832
+ /**
833
+ * A consumer module for the generated deferred `.d.ts`, wrapping the given
834
+ * `createInstance()` calls in the declarations they need.
835
+ */
836
+ const deferredConsumerSource = (calls: string) =>
837
+ `import { createInstance } from './loader.js'
838
+
839
+ declare const wasmModule: WebAssembly.Module
840
+ declare const memory: WebAssembly.Memory
841
+
842
+ ${calls}
843
+ `
844
+
845
+ /**
846
+ * TypeScript's verdict on such a consumer, as a list of diagnostic codes. The
847
+ * default lib still comes off disk through the real host; the generated
848
+ * typedef, the binding it imports, and the consumer are served from memory, so
849
+ * the check needs no temporary directory. Mirrors the semantic-check host in
850
+ * `build.spec.ts`.
851
+ */
852
+ const deferredConsumerDiagnostics = (calls: string) => {
853
+ const root = `${__dirname.replaceAll('\\', '/')}/__deferred_typecheck__`
854
+ const files = new Map([
855
+ [
856
+ `${root}/loader.d.ts`,
857
+ createWasiDeferredBrowserBindingTypeDef('./test.wasip1.cjs'),
858
+ ],
859
+ [`${root}/test.wasip1.d.cts`, 'export declare const binding: number\n'],
860
+ [`${root}/consumer.ts`, deferredConsumerSource(calls)],
861
+ ])
862
+ const host = ts.createCompilerHost(DEFERRED_TYPE_CHECK_OPTIONS, true)
863
+ const readRealSourceFile = host.getSourceFile.bind(host)
864
+ const realFileExists = host.fileExists.bind(host)
865
+ const realReadFile = host.readFile.bind(host)
866
+ const realDirectoryExists = host.directoryExists?.bind(host)
867
+ host.getSourceFile = (fileName, languageVersion, ...rest) => {
868
+ const virtual = files.get(fileName)
869
+ return virtual === undefined
870
+ ? readRealSourceFile(fileName, languageVersion, ...rest)
871
+ : ts.createSourceFile(fileName, virtual, languageVersion, true)
872
+ }
873
+ host.fileExists = (fileName) =>
874
+ files.has(fileName) || realFileExists(fileName)
875
+ host.readFile = (fileName) => files.get(fileName) ?? realReadFile(fileName)
876
+ if (realDirectoryExists) {
877
+ host.directoryExists = (directoryName) =>
878
+ directoryName === root || realDirectoryExists(directoryName)
879
+ }
880
+ const program = ts.createProgram({
881
+ rootNames: [`${root}/consumer.ts`],
882
+ options: DEFERRED_TYPE_CHECK_OPTIONS,
883
+ host,
884
+ })
885
+ return ts.getPreEmitDiagnostics(program).map((d) => `TS${d.code}`)
886
+ }
887
+
888
+ test('deferred createInstance() options type-check per memory form', (t) => {
889
+ // Every form the loader accepts at runtime. `{}` and an omitted argument
890
+ // must stay legal: the allocated form is all-optional.
891
+ t.deepEqual(
892
+ deferredConsumerDiagnostics(`void createInstance(wasmModule)
893
+ void createInstance(wasmModule, undefined)
894
+ void createInstance(wasmModule, {})
895
+ void createInstance(wasmModule, { memory })
896
+ void createInstance(wasmModule, { initialMemoryPages: 1 })
897
+ void createInstance(wasmModule, { initialMemoryPages: 1, maximumMemoryPages: 2 })`),
898
+ [],
899
+ )
900
+ })
901
+
902
+ test('deferred createInstance() rejects memory beside a page count', (t) => {
903
+ // `__resolveInstanceMemory` throws a TypeError on either mix, so neither
904
+ // may type-check. `memory` selects the caller form, whose page properties
905
+ // are `?: never` — i.e. `undefined` — so the checker rejects the offending
906
+ // property in place rather than the whole call: TS2322, "Type 'number' is not
907
+ // assignable to type 'undefined'".
908
+ t.deepEqual(
909
+ deferredConsumerDiagnostics(
910
+ 'void createInstance(wasmModule, { memory, initialMemoryPages: 1024 })',
911
+ ),
912
+ ['TS2322'],
913
+ )
914
+ t.deepEqual(
915
+ deferredConsumerDiagnostics(
916
+ 'void createInstance(wasmModule, { memory, maximumMemoryPages: 2048 })',
917
+ ),
918
+ ['TS2322'],
919
+ )
920
+ })
921
+
234
922
  test('Node WASI loader uses an accessible host root on Android', (t) => {
235
923
  const code = createWasiBinding('test', '@scope/test')
236
924
  assertValidJS(t, code, 'Node WASI loader')
@@ -317,6 +1005,264 @@ function initializationRollbackBody(code: string): string {
317
1005
  return code.slice(deferredStart, code.indexOf('throw error', deferredStart))
318
1006
  }
319
1007
 
1008
+ // The loaders order their own teardown barrier-then-destroy, but the emnapi
1009
+ // context is a live object: an embedder or test harness holding it, or emnapi's
1010
+ // own `beforeExit` auto-destroy on a host where `suppressDestroy()` is absent,
1011
+ // can call `Context.destroy()` directly. `destroy()` disables JavaScript calls
1012
+ // before it runs cleanup hooks, so a raw call discards the very settlements the
1013
+ // barrier exists to cancel and deliver. Own the ordering on the object: every
1014
+ // flavor shadows `destroy` once, at creation, before anything can reach it.
1015
+ const CONTEXT_DESTROY_WRAP_SIGNATURE =
1016
+ 'function __wrapEmnapiContextDestroyForSettlement('
1017
+
1018
+ // The shared barrier's own in-flight flag, and the probe the wrapper reads it
1019
+ // through. It cannot live in the wrapper: `dispose()` runs the barrier itself
1020
+ // and only then calls `destroy()`, so a wrapper-local flag would still be clear
1021
+ // while the barrier is running and would let a reentrant destroy through.
1022
+ const preparingBarrierGuards = {
1023
+ shared: {
1024
+ probe: '__isPreparingWasmEnvCleanup',
1025
+ snippets: [
1026
+ 'let __emnapiWasmEnvCleanupPreparing = false',
1027
+ `function __isPreparingWasmEnvCleanup() {
1028
+ return __emnapiWasmEnvCleanupPreparing
1029
+ }`,
1030
+ ` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
1031
+ return
1032
+ }`,
1033
+ ` __emnapiWasmEnvCleanupPreparing = true
1034
+ try {
1035
+ prepare()
1036
+ } finally {
1037
+ __emnapiWasmEnvCleanupPreparing = false
1038
+ }`,
1039
+ ],
1040
+ },
1041
+ deferred: {
1042
+ probe: '__isPreparingEnvCleanup',
1043
+ snippets: [
1044
+ 'let __wasmEnvCleanupPreparing = false',
1045
+ 'const __isPreparingEnvCleanup = () => __wasmEnvCleanupPreparing',
1046
+ ` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
1047
+ return
1048
+ }`,
1049
+ ` __wasmEnvCleanupPreparing = true
1050
+ try {
1051
+ __prepareWasmEnvCleanup()
1052
+ } finally {
1053
+ __wasmEnvCleanupPreparing = false
1054
+ }`,
1055
+ ],
1056
+ },
1057
+ } as const
1058
+
1059
+ // A nested `destroy()` must answer `undefined` without touching the real one:
1060
+ // no fallthrough, no deferral. `Context.destroy()` is typed `void`, so nothing
1061
+ // observable is lost, and the frame that started the barrier destroys the
1062
+ // moment it returns.
1063
+ const NESTED_DESTROY_NO_OP = ` if (isPreparingEnvCleanup?.()) {
1064
+ return
1065
+ }
1066
+ prepareEnvCleanup?.()`
1067
+
1068
+ const wrappedContextCreationCases: Array<{
1069
+ name: string
1070
+ code: string
1071
+ prepare: string
1072
+ guard: (typeof preparingBarrierGuards)[keyof typeof preparingBarrierGuards]
1073
+ }> = [
1074
+ {
1075
+ name: 'node cjs',
1076
+ code: createWasiBinding('test', '@scope/test'),
1077
+ prepare: '__prepareWasmEnvCleanup',
1078
+ guard: preparingBarrierGuards.shared,
1079
+ },
1080
+ {
1081
+ name: 'node cjs threadless',
1082
+ code: createWasiBinding('test', '@scope/test', 4000, 65536, false),
1083
+ prepare: '__prepareWasmEnvCleanup',
1084
+ guard: preparingBarrierGuards.shared,
1085
+ },
1086
+ {
1087
+ name: 'browser esm',
1088
+ code: createWasiBrowserBinding('test'),
1089
+ prepare: '__prepareWasmEnvCleanup',
1090
+ guard: preparingBarrierGuards.shared,
1091
+ },
1092
+ {
1093
+ name: 'deferred/workerd',
1094
+ code: createWasiDeferredBrowserBinding('test'),
1095
+ prepare: '__prepareEnvCleanup',
1096
+ guard: preparingBarrierGuards.deferred,
1097
+ },
1098
+ ]
1099
+
1100
+ for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
1101
+ test(`WASI loader runs the barrier on a raw context.destroy(): ${name}`, (t) => {
1102
+ assertValidJS(t, code, name)
1103
+ t.is(
1104
+ code.split(CONTEXT_DESTROY_WRAP_SIGNATURE).length - 1,
1105
+ 1,
1106
+ 'loader must define the destroy wrapper exactly once',
1107
+ )
1108
+ // No unwrapped context may escape: there is exactly one createContext call
1109
+ // and it is the wrapper's argument.
1110
+ t.is(
1111
+ code.split('__emnapiCreateContext({ autoDestroy: false })').length - 1,
1112
+ 1,
1113
+ 'loader must create exactly one emnapi context',
1114
+ )
1115
+ t.true(
1116
+ code
1117
+ .replace(/\s+/g, ' ')
1118
+ .includes(
1119
+ '__emnapiContext = __wrapEmnapiContextDestroyForSettlement( ' +
1120
+ `__emnapiCreateContext({ autoDestroy: false }), ${prepare}, ${guard.probe}, )`,
1121
+ ),
1122
+ 'the createContext result must be wrapped before anything can reach it',
1123
+ )
1124
+ // Ordering is the whole point: barrier first, real destroy second.
1125
+ const wrapperStart = code.indexOf(CONTEXT_DESTROY_WRAP_SIGNATURE)
1126
+ const wrapper = code.slice(
1127
+ wrapperStart,
1128
+ code.indexOf('\n}\n', wrapperStart),
1129
+ )
1130
+ t.true(
1131
+ wrapper.indexOf('prepareEnvCleanup?.()') <
1132
+ wrapper.indexOf('Reflect.apply(destroy, this, arguments)'),
1133
+ 'the barrier must run before the real destroy, while the env can still call into JavaScript',
1134
+ )
1135
+ })
1136
+
1137
+ // The barrier settles the promises it cancels *synchronously*, under a
1138
+ // lifecycle mutex the addon cannot acquire twice. A `promiseHooks.onSettled`
1139
+ // handler — or the `async_hooks` hook `AsyncLocalStorage` installs — that
1140
+ // calls `destroy()` therefore re-enters the wrapper from inside the barrier,
1141
+ // and a second trip into the export aborts the wasm instance outright. The
1142
+ // flag lives in the barrier, not in the wrapper, so the same guard covers the
1143
+ // `dispose()` path, where the barrier runs before `destroy()` is ever called.
1144
+ test(`WASI loader makes a destroy reentered from the barrier a no-op: ${name}`, (t) => {
1145
+ for (const snippet of guard.snippets) {
1146
+ t.is(
1147
+ code.split(snippet).length - 1,
1148
+ 1,
1149
+ `barrier must carry its in-flight guard exactly once: ${snippet}`,
1150
+ )
1151
+ }
1152
+ t.is(
1153
+ code.split(NESTED_DESTROY_NO_OP).length - 1,
1154
+ 1,
1155
+ 'a destroy reentered while the barrier is in flight must return without running the barrier or the real destroy',
1156
+ )
1157
+ const wrapperStart = code.indexOf(CONTEXT_DESTROY_WRAP_SIGNATURE)
1158
+ const wrapper = code.slice(
1159
+ wrapperStart,
1160
+ code.indexOf('\n}\n', wrapperStart),
1161
+ )
1162
+ t.true(
1163
+ wrapper.indexOf('isPreparingEnvCleanup?.()') <
1164
+ wrapper.indexOf('Reflect.apply(destroy, this, arguments)'),
1165
+ 'the reentry check must come before the real destroy',
1166
+ )
1167
+ })
1168
+ }
1169
+
1170
+ // The nested-destroy no-op above is only safe because the frame that started
1171
+ // the barrier destroys as soon as the barrier returns. The deferred loader's
1172
+ // instance `dispose()` is the one frame that does not: it runs the barrier and
1173
+ // then yields for the settlement drain. So a promise hook firing inside that
1174
+ // barrier can call the same instance's `dispose()` again, reach the context
1175
+ // destroyer while the outer frame is still parked in its drain, and have the
1176
+ // wrapper's no-op recorded as a completed destroy — after which the outer frame
1177
+ // skips the real one and the context is retained with its cleanup hooks unrun.
1178
+ // dispose() has to coalesce, the way the eager loaders' `__disposeWasiBinding`
1179
+ // does, and the memo has to be in place *before* the barrier runs.
1180
+ test('deferred WASI loader coalesces a reentrant instance dispose()', (t) => {
1181
+ const code = createWasiDeferredBrowserBinding('test')
1182
+ assertValidJS(t, code, 'deferred/workerd')
1183
+ t.is(
1184
+ code.split('let __instanceDisposePromise').length - 1,
1185
+ 1,
1186
+ 'the instance must carry exactly one disposal memo',
1187
+ )
1188
+ const disposeStart = code.indexOf('const __disposeInstance = () => {')
1189
+ t.true(disposeStart > 0, 'dispose() must go through a coalescing wrapper')
1190
+ const disposeWrapper = code.slice(
1191
+ disposeStart,
1192
+ code.indexOf('\n }\n', disposeStart),
1193
+ )
1194
+ t.true(
1195
+ disposeWrapper.includes(` if (__instanceDisposePromise) {
1196
+ return __instanceDisposePromise
1197
+ }`),
1198
+ 'a reentrant dispose() must join the disposal already running',
1199
+ )
1200
+ // The memo has to be published before the disposal body — and therefore
1201
+ // before the barrier — runs, or a hook that fires inside the barrier still
1202
+ // finds it unset and starts a second frame.
1203
+ t.true(
1204
+ disposeWrapper.indexOf('__instanceDisposePromise = __disposePromise') <
1205
+ disposeWrapper.indexOf('__runInstanceDisposal()'),
1206
+ 'the memo must be published before the disposal body runs',
1207
+ )
1208
+ // Still retryable: the drain can reject on its own (a host `setImmediate`
1209
+ // that throws), and dispose() has to be callable again after that.
1210
+ t.true(
1211
+ disposeWrapper.includes(` __instanceDisposePromise = undefined
1212
+ __rejectDispose(__error)`),
1213
+ 'a failed disposal must clear the memo so dispose() stays retryable',
1214
+ )
1215
+ // No second entry point into the disposal body: the instance exposes the
1216
+ // coalescing wrapper itself, not an inline method that re-runs it.
1217
+ t.true(
1218
+ code.includes(' dispose: __disposeInstance,'),
1219
+ 'the instance must expose the coalescing wrapper as its dispose()',
1220
+ )
1221
+ t.false(
1222
+ code.includes(' async dispose() {'),
1223
+ 'the instance must not carry a second inline dispose() method',
1224
+ )
1225
+ t.is(
1226
+ code.split('__prepareForDisposal()').length - 1,
1227
+ 2,
1228
+ 'only the disposal body and the initialization rollback may prepare for disposal',
1229
+ )
1230
+ })
1231
+
1232
+ // Belt and braces for any caller that still reaches the managed destroyer from
1233
+ // inside the barrier: a `Context.destroy()` the wrapper skipped must never be
1234
+ // recorded as a completed destroy, or every later destroy — the outer disposal
1235
+ // frame's and managed beforeExit cleanup's alike — is skipped with it.
1236
+ test('deferred WASI loader never records a skipped destroy as completed', (t) => {
1237
+ const code = createWasiDeferredBrowserBinding('test')
1238
+ const destroyStart = code.indexOf('const __destroy = (')
1239
+ t.true(destroyStart > 0, 'deferred loader must define a managed destroyer')
1240
+ const destroyer = code.slice(
1241
+ destroyStart,
1242
+ code.indexOf('\n const __destroyForModuleLifecycle', destroyStart),
1243
+ )
1244
+ t.true(
1245
+ destroyer.includes(` if (__isPreparingEnvCleanup?.()) {`),
1246
+ 'the managed destroyer must detect that the barrier is in flight',
1247
+ )
1248
+ const guardIndex = destroyer.indexOf('if (__isPreparingEnvCleanup?.()) {')
1249
+ const destroyIndex = destroyer.indexOf('__result = __emnapiContext.destroy()')
1250
+ t.true(
1251
+ guardIndex < destroyIndex,
1252
+ 'the in-flight check must come before the destroy it would skip',
1253
+ )
1254
+ t.true(
1255
+ destroyer
1256
+ .slice(guardIndex, destroyIndex)
1257
+ .includes(`throw __createLifecycleReentryError('dispose')`),
1258
+ 'a destroy the wrapper would skip must fail loudly instead of flagging the context disposed',
1259
+ )
1260
+ t.true(
1261
+ destroyIndex < destroyer.indexOf('__disposed = true'),
1262
+ 'nothing may be flagged disposed before the real destroy is attempted',
1263
+ )
1264
+ })
1265
+
320
1266
  test('createCjsBinding uses one statement dialect', (t) => {
321
1267
  const code = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0', [
322
1268
  'wasm32-wasi',
@@ -338,6 +1284,412 @@ test('createCjsBinding uses one statement dialect', (t) => {
338
1284
  )
339
1285
  })
340
1286
 
1287
+ test('the root CommonJS loader stamps before it aliases the addon', (t) => {
1288
+ // The guard is safe against an addon accessor — `hasOwnProperty` and a read —
1289
+ // but an assignment is not: a `#[napi(module_exports)]` hook can expose a
1290
+ // getter reporting the value about to be stamped and a setter that throws.
1291
+ // So the lexer-visible assignment has to land on the loader's own
1292
+ // `module.exports`, while it is still the original object.
1293
+ const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')
1294
+ assertValidJS(t, cjs, 'root cjs stamp before alias')
1295
+ const stamp = cjs.indexOf(ROOT_CJS_STAMP_CALL)
1296
+ t.true(stamp > -1, 'the root loader must stamp through the guard')
1297
+ t.true(
1298
+ stamp < cjs.indexOf('\nmodule.exports = nativeBinding'),
1299
+ 'the stamp must precede the alias, or it assigns onto the addon',
1300
+ )
1301
+ // the guard still stamps the object the loader hands out
1302
+ t.false(cjs.includes('__napiStampBindingTarget(module.exports,'))
1303
+ })
1304
+
1305
+ test('native loaders export the artifact that actually loaded', (t) => {
1306
+ const flavors = ['wasm32-wasi', 'wasm32-wasip1']
1307
+ const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0', flavors)
1308
+ assertValidJS(t, cjs, 'cjs binding target')
1309
+ t.true(cjs.includes("let __napiLoadedBindingTarget = 'native'"))
1310
+ t.true(cjs.includes(ROOT_CJS_STAMP_CALL))
1311
+ // one assignment per candidate: 2 flavors x (local loader + flavor package)
1312
+ for (const flavor of flavors) {
1313
+ t.is(
1314
+ cjs.split(`__napiLoadedBindingTarget = '${flavor}'`).length - 1,
1315
+ 2,
1316
+ `${flavor} must be recorded on both its local and package candidates`,
1317
+ )
1318
+ }
1319
+ // the target is never read back off the WASI module
1320
+ t.false(cjs.includes('wasiBinding.__napiBindingTarget'))
1321
+
1322
+ const esm = createEsmBinding('test', '@scope/test', ['sum'], '1.0.0', flavors)
1323
+ assertValidJS(t, esm, 'esm binding target')
1324
+ t.true(
1325
+ esm.includes(
1326
+ 'export const __napiBindingTarget = __napiLoadedBindingTarget',
1327
+ ),
1328
+ )
1329
+ // zero-ident packages take the `export default` branch and must keep it
1330
+ const esmNoIdents = createEsmBinding(
1331
+ 'test',
1332
+ '@scope/test',
1333
+ [],
1334
+ '1.0.0',
1335
+ flavors,
1336
+ )
1337
+ assertValidJS(t, esmNoIdents, 'esm binding target without idents')
1338
+ t.true(
1339
+ esmNoIdents.includes(
1340
+ 'export const __napiBindingTarget = __napiLoadedBindingTarget',
1341
+ ),
1342
+ )
1343
+ })
1344
+
1345
+ test('a napi export may not shadow __napiBindingTarget', (t) => {
1346
+ t.throws(
1347
+ () => createEsmBinding('test', '@scope/test', ['__napiBindingTarget']),
1348
+ {
1349
+ message: /reserved by the generated binding loader/,
1350
+ },
1351
+ )
1352
+ t.throws(
1353
+ () => createCjsBinding('test', '@scope/test', ['__napiBindingTarget']),
1354
+ {
1355
+ message: /reserved by the generated binding loader/,
1356
+ },
1357
+ )
1358
+ })
1359
+
1360
+ test('WASI loaders self-identify their flavor', (t) => {
1361
+ t.true(
1362
+ createWasiBinding('test', '@scope/test').includes(
1363
+ "const __napiBindingTarget = 'wasm32-wasi'",
1364
+ ),
1365
+ )
1366
+ t.true(
1367
+ createWasiBinding(
1368
+ 'test',
1369
+ '@scope/test',
1370
+ 4000,
1371
+ 65536,
1372
+ false,
1373
+ 'wasm32-wasip1',
1374
+ ).includes("const __napiBindingTarget = 'wasm32-wasip1'"),
1375
+ )
1376
+ t.true(
1377
+ createWasiBrowserBinding('test').includes(
1378
+ "export const __napiBindingTarget = 'wasm32-wasi'",
1379
+ ),
1380
+ )
1381
+ t.true(
1382
+ createWasiBrowserBinding(
1383
+ 'test',
1384
+ 4000,
1385
+ 65536,
1386
+ false,
1387
+ false,
1388
+ false,
1389
+ false,
1390
+ false,
1391
+ ).includes("export const __napiBindingTarget = 'wasm32-wasip1'"),
1392
+ )
1393
+ t.true(
1394
+ createWasiDeferredBrowserBinding('test').includes(
1395
+ "export const __napiBindingTarget = 'wasm32-wasip1'",
1396
+ ),
1397
+ )
1398
+ })
1399
+
1400
+ const STAMP_HELPER_DECL =
1401
+ 'function __napiStampBindingTarget(exportsObject, target) {'
1402
+ const WASI_STAMP_CALL =
1403
+ '__napiStampBindingTarget(__napiModule.exports, __napiBindingTarget)'
1404
+ // The CJS loaders assign the guard's return value instead of calling it as a
1405
+ // statement: `cjs-module-lexer` only reports `__napiBindingTarget` as a named
1406
+ // export when it can see `module.exports.<name> =`, and Node's CJS->ESM named
1407
+ // export detection is that lexer.
1408
+ const ROOT_CJS_STAMP_CALL =
1409
+ 'module.exports.__napiBindingTarget = __napiStampBindingTarget(nativeBinding, __napiLoadedBindingTarget)'
1410
+ // The node WASI loader stamps the emnapi exports object — the one the CommonJS
1411
+ // tail then aliases — while assigning through `module.exports` for the lexer.
1412
+ const WASI_CJS_STAMP_LINE = `module.exports.__napiBindingTarget = ${WASI_STAMP_CALL}`
1413
+ // the call inside the initialization try, not the function declaration
1414
+ const WASI_EXIT_LISTENER_CALL = '\n __registerWasiExitListener()'
1415
+ // nothing may write the marker onto a user-controlled exports object without
1416
+ // going through the guard, so an assignment is only legal when the guard call
1417
+ // is its right-hand side
1418
+ const UNGUARDED_MODULE_EXPORTS_STAMP =
1419
+ /module\.exports\.__napiBindingTarget = (?!__napiStampBindingTarget\()/
1420
+
1421
+ test('browser and deferred loaders carry the flavor on the binding they hand out', (t) => {
1422
+ // `export default __napiModule.exports` and `instantiate()` hand out the raw
1423
+ // emnapi exports object, which a named module export does not travel with.
1424
+ const browser = createWasiBrowserBinding('test')
1425
+ assertValidJS(t, browser, 'browser binding target on exports')
1426
+ t.true(browser.includes(WASI_STAMP_CALL))
1427
+ const deferred = createWasiDeferredBrowserBinding('test')
1428
+ assertValidJS(t, deferred, 'deferred binding target on exports')
1429
+ t.is(
1430
+ deferred.split(WASI_STAMP_CALL).length - 1,
1431
+ 1,
1432
+ 'every instance created by __createInstance must be marked exactly once',
1433
+ )
1434
+ // the marker is assigned before the instance escapes to the caller
1435
+ t.true(
1436
+ deferred.indexOf(WASI_STAMP_CALL) <
1437
+ deferred.indexOf('exports: __napiModule.exports'),
1438
+ )
1439
+ })
1440
+
1441
+ test('every mutating loader stamps the binding target through the guard', (t) => {
1442
+ const cases: Array<{ name: string; code: string; call?: string }> = [
1443
+ {
1444
+ name: 'root cjs',
1445
+ code: createCjsBinding('test', '@scope/test', ['sum'], '1.0.0'),
1446
+ call: ROOT_CJS_STAMP_CALL,
1447
+ },
1448
+ {
1449
+ name: 'wasi node cjs',
1450
+ code: createWasiBinding('test', '@scope/test'),
1451
+ call: WASI_CJS_STAMP_LINE,
1452
+ },
1453
+ {
1454
+ name: 'wasi browser esm',
1455
+ code: createWasiBrowserBinding('test'),
1456
+ call: WASI_STAMP_CALL,
1457
+ },
1458
+ {
1459
+ name: 'wasi deferred esm',
1460
+ code: createWasiDeferredBrowserBinding('test'),
1461
+ call: WASI_STAMP_CALL,
1462
+ },
1463
+ ]
1464
+ for (const { name, code, call } of cases) {
1465
+ assertValidJS(t, code, `${name} stamp guard`)
1466
+ t.is(
1467
+ code.split(STAMP_HELPER_DECL).length - 1,
1468
+ 1,
1469
+ `${name} must emit the guard exactly once`,
1470
+ )
1471
+ if (call) {
1472
+ t.is(
1473
+ code.split(call).length - 1,
1474
+ 1,
1475
+ `${name} must stamp exactly once, through the guard`,
1476
+ )
1477
+ }
1478
+ // [[Define]], not [[Set]]: an ordinary assignment walks the prototype
1479
+ // chain, so an inherited accessor on a user-controlled exports object could
1480
+ // swallow the marker or throw and fail an otherwise successful load
1481
+ t.true(
1482
+ code.includes(
1483
+ "Object.defineProperty(exportsObject, '__napiBindingTarget'",
1484
+ ),
1485
+ `${name} must define the marker as an own data property`,
1486
+ )
1487
+ t.false(
1488
+ code.includes('exportsObject.__napiBindingTarget = target'),
1489
+ `${name} must not stamp the marker through an ordinary assignment`,
1490
+ )
1491
+ // an addon's exports object is user-controlled: nothing may write the
1492
+ // marker onto it without going through the guard
1493
+ t.false(
1494
+ UNGUARDED_MODULE_EXPORTS_STAMP.test(code),
1495
+ `${name} must not assign the marker onto module.exports directly`,
1496
+ )
1497
+ t.false(
1498
+ code.includes('__napiModule.exports.__napiBindingTarget ='),
1499
+ `${name} must not assign the marker onto the emnapi exports directly`,
1500
+ )
1501
+ }
1502
+ })
1503
+
1504
+ test('the node WASI loader stamps inside the rollback boundary', (t) => {
1505
+ // Anything the guard throws — a conflicting `#[napi(module_exports)]` export,
1506
+ // or an addon accessor whose setter refuses the write — has to land in the
1507
+ // initialization `try`. From outside it the throw escapes with the emnapi
1508
+ // context built and the process 'exit' listener installed, so a failed
1509
+ // `require()` leaks an initialized WASI environment nothing can reach.
1510
+ const code = createWasiBinding('test', '@scope/test')
1511
+ assertValidJS(t, code, 'wasi node cjs rollback boundary')
1512
+ const initializationCatch = '\n} catch (error) {'
1513
+ t.is(
1514
+ code.split(initializationCatch).length - 1,
1515
+ 1,
1516
+ 'the top-level initialization catch must be unambiguous',
1517
+ )
1518
+ // exactly one, so nothing can stamp a second time outside the boundary
1519
+ t.is(code.split('module.exports.__napiBindingTarget =').length - 1, 1)
1520
+ const stamp = code.indexOf(WASI_CJS_STAMP_LINE)
1521
+ t.true(stamp > code.indexOf('__publishWasiDispose(__napiModule.exports)'))
1522
+ t.true(stamp < code.indexOf(WASI_EXIT_LISTENER_CALL))
1523
+ t.true(stamp < code.indexOf(initializationCatch))
1524
+ // and the rollback the catch runs is the one that tears the environment down
1525
+ t.true(code.includes('__runWasiInitializationRollback(rollback)'))
1526
+ })
1527
+
1528
+ /**
1529
+ * Every loader that stamps an addon-owned exports object, with the marker that
1530
+ * ends its initialization guard and the host installation that must precede the
1531
+ * stamp. A host install hands that same object to addon-provided registration
1532
+ * functions, which can put anything on it — including this marker — so a stamp
1533
+ * placed before them reads a state that is not final.
1534
+ */
1535
+ const HOST_INSTALL_ORDER_CASES = [
1536
+ {
1537
+ name: 'wasi node cjs',
1538
+ build: (asyncRuntime: boolean) =>
1539
+ createWasiBinding(
1540
+ 'test',
1541
+ '@scope/test',
1542
+ undefined,
1543
+ undefined,
1544
+ undefined,
1545
+ undefined,
1546
+ undefined,
1547
+ asyncRuntime,
1548
+ ),
1549
+ stamp: WASI_CJS_STAMP_LINE,
1550
+ hostInstall: '__installCurrentThreadHosts(',
1551
+ endOfGuard: WASI_EXIT_LISTENER_CALL,
1552
+ },
1553
+ {
1554
+ name: 'wasi browser esm',
1555
+ build: (asyncRuntime: boolean) =>
1556
+ createWasiBrowserBinding(
1557
+ 'test',
1558
+ undefined,
1559
+ undefined,
1560
+ undefined,
1561
+ undefined,
1562
+ undefined,
1563
+ undefined,
1564
+ undefined,
1565
+ undefined,
1566
+ asyncRuntime,
1567
+ ),
1568
+ stamp: WASI_STAMP_CALL,
1569
+ hostInstall: '__installCurrentThreadHosts(',
1570
+ endOfGuard: '\n} catch (error) {',
1571
+ },
1572
+ {
1573
+ name: 'wasi deferred esm',
1574
+ build: (asyncRuntime: boolean) =>
1575
+ createWasiDeferredBrowserBinding(
1576
+ 'test',
1577
+ undefined,
1578
+ undefined,
1579
+ undefined,
1580
+ undefined,
1581
+ asyncRuntime,
1582
+ ),
1583
+ stamp: WASI_STAMP_CALL,
1584
+ hostInstall: '__registerWorkerdCurrentThreadTaskHost(',
1585
+ endOfGuard: "__lifecycleState === 'pending'",
1586
+ },
1587
+ ] as const
1588
+
1589
+ for (const {
1590
+ name,
1591
+ build,
1592
+ stamp,
1593
+ hostInstall,
1594
+ endOfGuard,
1595
+ } of HOST_INSTALL_ORDER_CASES) {
1596
+ test(`${name} stamps the binding object after its host installation`, (t) => {
1597
+ const withHosts = build(true)
1598
+ assertValidJS(t, withHosts, `${name} asyncRuntime stamp order`)
1599
+ const hostInstallAt = withHosts.indexOf(hostInstall)
1600
+ t.true(hostInstallAt > -1, 'the asyncRuntime host install must be emitted')
1601
+ const stampAt = withHosts.indexOf(stamp)
1602
+ t.true(stampAt > hostInstallAt, 'the stamp must follow the host install')
1603
+ t.true(
1604
+ stampAt < withHosts.indexOf(endOfGuard),
1605
+ 'the stamp must stay inside the initialization guard',
1606
+ )
1607
+
1608
+ // and without an async runtime the stamp keeps that same place: last thing
1609
+ // before the guard closes, so nothing can reshape the object behind it
1610
+ const withoutHosts = build(false)
1611
+ assertValidJS(t, withoutHosts, `${name} stamp order`)
1612
+ t.is(withoutHosts.indexOf(hostInstall), -1)
1613
+ t.true(withoutHosts.indexOf(stamp) < withoutHosts.indexOf(endOfGuard))
1614
+ })
1615
+ }
1616
+
1617
+ test('the deferred loader marks the binding without requiring an extensible exports object', (t) => {
1618
+ const deferred = createWasiDeferredBrowserBinding('test')
1619
+ assertValidJS(t, deferred, 'deferred guarded stamp')
1620
+ // a `#[napi(module_exports)]` hook may have sealed or frozen this object
1621
+ t.true(deferred.includes('if (!Object.isExtensible(exportsObject)) {'))
1622
+ // and it may have claimed the name, which is a hard error, not a silent
1623
+ // overwrite — with a stable `code` to branch on
1624
+ t.true(
1625
+ deferred.includes(
1626
+ "Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget')",
1627
+ ),
1628
+ )
1629
+ t.true(deferred.includes("error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT'"))
1630
+ })
1631
+
1632
+ test('every emitted loader says what its own entry reports after a skipped stamp', (t) => {
1633
+ // The runtime is pinned by build.spec.ts (`a frozen addon keeps
1634
+ // __napiBindingTarget importable, just undefined`): both CommonJS entries
1635
+ // replace `module.exports` with the object the stamp was skipped on, so the
1636
+ // value is absent there, while the ESM loaders keep a module-level export.
1637
+ // The comment shipped inside every loader has to describe that split rather
1638
+ // than promise the module export survives everywhere.
1639
+ for (const [name, code] of [
1640
+ ['root cjs', createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')],
1641
+ ['wasi node cjs', createWasiBinding('test', '@scope/test')],
1642
+ ['wasi browser esm', createWasiBrowserBinding('test')],
1643
+ ['wasi deferred esm', createWasiDeferredBrowserBinding('test')],
1644
+ ] as const) {
1645
+ assertValidJS(t, code, `${name} skip contract`)
1646
+ t.false(
1647
+ code.includes('module export still reports it'),
1648
+ `${name} must not claim every entry still reports the target`,
1649
+ )
1650
+ t.true(
1651
+ code.includes('while the CommonJS entries hand back'),
1652
+ `${name} must say the CommonJS entries lose the value`,
1653
+ )
1654
+ }
1655
+
1656
+ // and the root CommonJS loader's own note about its lexer-visible assignment:
1657
+ // that assignment lands on the loader's own `module.exports`, so it always
1658
+ // succeeds and the alias on the next line is what discards it
1659
+ const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')
1660
+ t.false(
1661
+ cjs.includes('is a silent no-op'),
1662
+ 'the assignment is not a no-op; its target is extensible',
1663
+ )
1664
+ t.true(
1665
+ cjs.includes('The assignment itself always succeeds'),
1666
+ 'the root CommonJS loader must not call its own assignment a no-op',
1667
+ )
1668
+ t.true(
1669
+ cjs.includes('own, still extensible `module.exports`'),
1670
+ 'the root CommonJS loader must name its real assignment target',
1671
+ )
1672
+ })
1673
+
1674
+ test('NAPI_RS_NATIVE_LIBRARY_PATH keeps the flavor its override reports', (t) => {
1675
+ const adoption = `__napiLoadedBindingTarget =
1676
+ overrideBinding && typeof overrideBinding.__napiBindingTarget === 'string'
1677
+ ? overrideBinding.__napiBindingTarget
1678
+ : 'native'`
1679
+ for (const [name, code] of [
1680
+ ['cjs', createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')],
1681
+ ['esm', createEsmBinding('test', '@scope/test', ['sum'], '1.0.0')],
1682
+ ] as const) {
1683
+ assertValidJS(t, code, `${name} override binding target`)
1684
+ t.true(code.includes(adoption), `${name} must adopt the override's target`)
1685
+ // the override result must not be returned before it is inspected
1686
+ t.false(
1687
+ code.includes('return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)'),
1688
+ `${name} must bind the override before returning it`,
1689
+ )
1690
+ }
1691
+ })
1692
+
341
1693
  test('WASI worker template matches the CJS/ESM quote and semicolon dialect', (t) => {
342
1694
  t.true(WASI_WORKER_TEMPLATE.includes("import fs from 'node:fs'"))
343
1695
  t.false(WASI_WORKER_TEMPLATE.includes('from "node:fs"'))