@napi-rs/cli 3.9.0 → 3.10.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.
Files changed (39) hide show
  1. package/README.md +17 -4
  2. package/dist/cli.js +10359 -8729
  3. package/dist/index.cjs +10381 -8751
  4. package/dist/index.d.cts +83 -16
  5. package/dist/index.d.ts +83 -16
  6. package/dist/index.js +10359 -8729
  7. package/docs/wasi.md +318 -4
  8. package/package.json +5 -6
  9. package/src/api/__tests__/__snapshots__/templates.spec.ts.md +4003 -53
  10. package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
  11. package/src/api/__tests__/build-regressions.spec.ts +210 -3
  12. package/src/api/__tests__/build.spec.ts +2333 -3
  13. package/src/api/__tests__/create-npm-dirs.spec.ts +105 -3
  14. package/src/api/__tests__/pre-publish.spec.ts +153 -1
  15. package/src/api/__tests__/templates.spec.ts +1309 -1
  16. package/src/api/build.ts +756 -30
  17. package/src/api/create-npm-dirs.ts +23 -10
  18. package/src/api/new.ts +13 -18
  19. package/src/api/pre-publish.ts +34 -11
  20. package/src/api/rename.ts +10 -21
  21. package/src/api/templates/binding-target.ts +176 -0
  22. package/src/api/templates/index.ts +1 -0
  23. package/src/api/templates/js-binding.ts +54 -10
  24. package/src/api/templates/load-wasi-template.ts +661 -58
  25. package/src/api/templates/wasi-worker-template.ts +36 -31
  26. package/src/commands/build.ts +1 -1
  27. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.md +18 -28
  28. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.snap +0 -0
  29. package/src/utils/__tests__/misc.spec.ts +4 -0
  30. package/src/utils/__tests__/reconciliation.spec.ts +676 -0
  31. package/src/utils/__tests__/serialize.spec.ts +55 -0
  32. package/src/utils/__tests__/target.spec.ts +221 -0
  33. package/src/utils/__tests__/typegen.spec.ts +115 -0
  34. package/src/utils/config.ts +50 -0
  35. package/src/utils/index.ts +1 -0
  36. package/src/utils/misc.ts +351 -79
  37. package/src/utils/serialize.ts +47 -0
  38. package/src/utils/target.ts +150 -1
  39. package/src/utils/typegen.ts +608 -42
@@ -1,16 +1,26 @@
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
- import { createWasiBrowserWorkerBinding } from '../templates/wasi-worker-template.js'
15
+ import {
16
+ createWasiBrowserWorkerBinding,
17
+ WASI_WORKER_TEMPLATE,
18
+ } from '../templates/wasi-worker-template.js'
11
19
 
12
20
  const test = ava
13
21
 
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+
14
24
  // Snapshot tests for full template output
15
25
 
16
26
  test('createWasiBrowserBinding default', (t) => {
@@ -65,6 +75,63 @@ test('createWasiBrowserBinding threadless keeps sync init and no pool', (t) => {
65
75
  t.true(binding.includes('__emnapiInstantiateNapiModuleSync(__wasmFile'))
66
76
  })
67
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
+
68
135
  test('createWasiBrowserBinding with errorEvent', (t) => {
69
136
  t.snapshot(
70
137
  createWasiBrowserBinding(
@@ -93,6 +160,47 @@ test('createWasiBrowserBinding with errorEvent and fs', (t) => {
93
160
  )
94
161
  })
95
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
+
96
204
  test('createWasiBrowserWorkerBinding default', (t) => {
97
205
  t.snapshot(createWasiBrowserWorkerBinding(false, false))
98
206
  })
@@ -140,6 +248,36 @@ const browserBindingCases: Array<{
140
248
  args: ['test', 4000, 65536, false, true, false, true],
141
249
  },
142
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
+ },
143
281
  ]
144
282
 
145
283
  for (const { name, args } of browserBindingCases) {
@@ -218,6 +356,69 @@ for (const { name, code } of cjsBindingCases) {
218
356
  // emitted flavors it builds (node cjs threadless, deferred/workerd)
219
357
  // behaviorally; the other flavors are never instantiated by any test, so assert
220
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
+
221
422
  const wasiLoaderCases: Array<{ name: string; code: string }> = [
222
423
  { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
223
424
  {
@@ -226,8 +427,423 @@ const wasiLoaderCases: Array<{ name: string; code: string }> = [
226
427
  },
227
428
  { name: 'browser esm', code: createWasiBrowserBinding('test') },
228
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
+ },
229
438
  ]
230
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
+ test('asyncRuntime deferred loader registers per instance', (t) => {
496
+ const code = asyncRuntimeDeferredCode
497
+ assertValidJS(t, code, 'deferred asyncRuntime')
498
+ // Per-instance helpers, NOT installCurrentThreadHosts: each instance owns
499
+ // its own env and needs an exact disposer, not a realm-global dedup.
500
+ t.true(code.includes('__registerWorkerdCurrentThreadTaskHost('))
501
+ t.true(code.includes('__registerWorkerdTimerHost('))
502
+ t.false(code.includes('installCurrentThreadHosts'))
503
+ // Task host first, timer host second; disposal is the reverse.
504
+ t.true(
505
+ code.indexOf('const __disposeTaskHost =') <
506
+ code.indexOf('const __disposeTimerHost ='),
507
+ )
508
+ const disposer = code.slice(code.indexOf('__disposeInstanceHosts = () => {'))
509
+ t.true(
510
+ disposer.indexOf('__disposeTimerHost()') <
511
+ disposer.indexOf('__disposeTaskHost()'),
512
+ )
513
+ // Hooked next to __prepareEnvCleanup, which every destroy path calls.
514
+ const managedDestroy = code.slice(
515
+ code.indexOf(' __prepareEnvCleanup?.()'),
516
+ )
517
+ t.true(
518
+ managedDestroy.indexOf('__disposeHosts?.()') <
519
+ managedDestroy.indexOf('__result = __emnapiContext.destroy()'),
520
+ )
521
+ })
522
+
523
+ // The deferred loader's module namespace is the published `./workerd` entry's
524
+ // public API. `WASM_MEMORY` and `getDeferredRuntimeStats` are new names in it,
525
+ // and `createInstance`'s option bag is the only way a host sizes an instance
526
+ // under a hard isolate cap.
527
+ test('deferred loader is syntactically valid in both host modes', (t) => {
528
+ assertValidJS(t, createWasiDeferredBrowserBinding('test'), 'deferred')
529
+ assertValidJS(
530
+ t,
531
+ createWasiDeferredBrowserBinding(
532
+ 'test',
533
+ 1027,
534
+ 65536,
535
+ true,
536
+ 'wasm32-wasip1',
537
+ true,
538
+ ),
539
+ 'deferred + hosts',
540
+ )
541
+ })
542
+
543
+ test('deferred loader publishes the memory floor it was configured with', (t) => {
544
+ const code = createWasiDeferredBrowserBinding('test', 1027, 40000)
545
+ t.true(code.includes('export const WASM_MEMORY = Object.freeze({'))
546
+ t.true(code.includes('initialPages: 1027'))
547
+ t.true(code.includes('maximumPages: 40000'))
548
+ t.true(code.includes('initialBytes: 1027 * 65536'))
549
+ t.true(code.includes('maximumBytes: 40000 * 65536'))
550
+ t.true(code.includes('export function getDeferredRuntimeStats()'))
551
+ // workerd bans allocation in global scope, so the single `new
552
+ // WebAssembly.Memory` stays inside the per-instance resolver.
553
+ t.is(code.split('new WebAssembly.Memory(').length - 1, 1)
554
+ t.true(
555
+ code.indexOf('function __resolveInstanceMemory(') <
556
+ code.indexOf('new WebAssembly.Memory('),
557
+ )
558
+ })
559
+
560
+ test('deferred loader claims caller memory exactly once and rejects shared memory', (t) => {
561
+ const code = createWasiDeferredBrowserBinding('test')
562
+ t.true(code.includes('const __claimedMemories = new WeakSet()'))
563
+ t.true(code.includes('__claimedMemories.add(__provided)'))
564
+ t.true(
565
+ code.includes('requires an unshared WebAssembly.Memory'),
566
+ 'a SharedArrayBuffer-backed memory must be rejected, not silently accepted',
567
+ )
568
+ t.true(
569
+ code.includes(
570
+ 'Pass either memory or initialMemoryPages/maximumMemoryPages, not both',
571
+ ),
572
+ )
573
+ // The claim is taken before instantiation can fail, so a failed attempt
574
+ // cannot hand the same half-written bytes to a second instance.
575
+ const resolver = code.slice(
576
+ code.indexOf('function __resolveInstanceMemory('),
577
+ code.indexOf('\n}\n', code.indexOf('function __resolveInstanceMemory(')),
578
+ )
579
+ t.true(resolver.includes('__claimedMemories.add(__provided)'))
580
+ // The loader-allocated Memory is claimed too: the handle publishes it as
581
+ // `instance.memory`, and two live instances on one linear memory each
582
+ // reinitialize the state the other is running on.
583
+ t.true(resolver.includes('__claimedMemories.add(__allocated)'))
584
+ })
585
+
586
+ test('deferred loader rejects a cross-realm memory before claiming it', (t) => {
587
+ const code = createWasiDeferredBrowserBinding('test')
588
+ const resolver = code.slice(
589
+ code.indexOf('function __resolveInstanceMemory('),
590
+ code.indexOf('\n}\n', code.indexOf('function __resolveInstanceMemory(')),
591
+ )
592
+ // The intrinsic getters accept a genuine Memory from any realm, but
593
+ // `WASI.setMemory` and emnapi identify one with a realm-local `instanceof`.
594
+ t.true(resolver.includes('if (!(__provided instanceof WebAssembly.Memory))'))
595
+ t.true(
596
+ resolver.includes(
597
+ 'memory must be a WebAssembly.Memory created in the same realm as this loader',
598
+ ),
599
+ )
600
+ // Every rejection must precede the claim, or a corrected retry would be
601
+ // refused as a reuse of a Memory that never ran anything.
602
+ t.true(
603
+ resolver.indexOf('__provided instanceof WebAssembly.Memory') <
604
+ resolver.indexOf('__claimedMemories.add(__provided)'),
605
+ )
606
+ t.true(
607
+ resolver.indexOf(
608
+ 'Pass either memory or initialMemoryPages/maximumMemoryPages, not both',
609
+ ) < resolver.indexOf('__claimedMemories.add(__provided)'),
610
+ )
611
+ })
612
+
613
+ test('deferred instance handle reports its memory and retires exactly once', (t) => {
614
+ const code = createWasiDeferredBrowserBinding('test')
615
+ const handleStart = code.indexOf(
616
+ ' return {\n exports: __napiModule.exports,',
617
+ )
618
+ t.true(
619
+ handleStart !== -1,
620
+ 'the instance handle must be returned as a literal',
621
+ )
622
+ const handle = code.slice(
623
+ handleStart,
624
+ code.indexOf(' } catch (error) {', handleStart),
625
+ )
626
+ t.true(handle.includes('get memory() {'))
627
+ t.true(handle.includes('get memoryBytes() {'))
628
+ t.true(handle.includes('get disposed() {'))
629
+ // The handle delegates to the coalescing wrapper, so the retirement
630
+ // bookkeeping lives in the one disposal body that wrapper runs.
631
+ t.true(handle.includes('dispose: __disposeInstance,'))
632
+ const disposalStart = code.indexOf(
633
+ ' const __runInstanceDisposal = async () => {',
634
+ )
635
+ t.true(disposalStart !== -1, 'the disposal body must be a named arrow')
636
+ const disposal = code.slice(
637
+ disposalStart,
638
+ code.indexOf('\n }\n', disposalStart),
639
+ )
640
+ // The counter moves only after the destroy resolves, so a dispose() that
641
+ // throws stays retryable without double-decrementing.
642
+ t.true(
643
+ disposal.indexOf('await (__beforeExitDestroy') <
644
+ disposal.indexOf('__liveInstances -= 1'),
645
+ )
646
+ t.true(disposal.includes('if (!__disposed) {'))
647
+ })
648
+
649
+ test('deferred loader keeps the singleton on the loader-owned memory', (t) => {
650
+ const code = createWasiDeferredBrowserBinding('test')
651
+ // `instantiate()` takes no option bag, so it must pass the options slot
652
+ // explicitly rather than shifting `__disposeDefaultInstance` into it.
653
+ t.true(
654
+ code.includes(`__createInstance(
655
+ __module,
656
+ undefined,
657
+ __disposeDefaultInstance,`),
658
+ )
659
+ t.true(code.includes('return __createInstance(__wasmInput, __options)'))
660
+ })
661
+
662
+ test('deferred loader pulls its hosts from the isolate-safe subpath', (t) => {
663
+ const code = createWasiDeferredBrowserBinding(
664
+ 'test',
665
+ 1024,
666
+ 65536,
667
+ false,
668
+ 'wasm32-wasip1',
669
+ true,
670
+ )
671
+ // The barrel (`index.cjs`) also requires `current-thread-hosts.cjs`, the
672
+ // Node-lane relay a worker bundle never runs and CJS cannot tree-shake.
673
+ t.true(code.includes("} from '@napi-rs/async-runtime/workerd'"))
674
+ t.false(code.includes("from '@napi-rs/async-runtime'\n"))
675
+ })
676
+
677
+ test('deferred loader type definition covers the new surface', (t) => {
678
+ const typeDef = createWasiDeferredBrowserBindingTypeDef('./test.wasip1.cjs')
679
+ // The two memory forms are separate interfaces, each declaring the other
680
+ // form's properties as `never`, so the combination `__resolveInstanceMemory`
681
+ // always throws on cannot be spelled by a typed caller.
682
+ t.true(typeDef.includes('export interface WasiCallerMemoryOptions {'))
683
+ t.true(typeDef.includes(' memory: WebAssembly.Memory'))
684
+ t.true(typeDef.includes(' initialMemoryPages?: never'))
685
+ t.true(typeDef.includes(' maximumMemoryPages?: never'))
686
+ t.true(typeDef.includes('export interface WasiAllocatedMemoryOptions {'))
687
+ t.true(typeDef.includes(' memory?: never'))
688
+ t.true(typeDef.includes(' initialMemoryPages?: number'))
689
+ t.true(typeDef.includes(' maximumMemoryPages?: number'))
690
+ t.true(
691
+ typeDef.includes(`export type WasiInstanceOptions =
692
+ | WasiCallerMemoryOptions
693
+ | WasiAllocatedMemoryOptions`),
694
+ )
695
+ t.false(typeDef.includes('export interface WasiInstanceOptions {'))
696
+ t.true(typeDef.includes('readonly memoryBytes: number'))
697
+ t.true(typeDef.includes('readonly disposed: boolean'))
698
+ t.true(typeDef.includes('export const WASM_MEMORY: Readonly<{'))
699
+ t.true(
700
+ typeDef.includes(
701
+ 'export function getDeferredRuntimeStats(): Readonly<WasiRuntimeStats>',
702
+ ),
703
+ )
704
+ t.true(
705
+ typeDef.includes(`export function createInstance(
706
+ wasmInput: WasiModuleInput,
707
+ options?: WasiInstanceOptions,
708
+ ): Promise<WasiInstance>`),
709
+ )
710
+ // `createWasiDeferredBindingTypeDef` rewrites this exact string when the
711
+ // project builds without type definitions.
712
+ t.true(typeDef.includes("typeof import('./test.wasip1.cjs')"))
713
+ })
714
+
715
+ const DEFERRED_TYPE_CHECK_OPTIONS: ts.CompilerOptions = {
716
+ strict: true,
717
+ noEmit: true,
718
+ // The generated `.d.ts` is the file under test, so it must not be skipped.
719
+ // Only the default lib is.
720
+ skipLibCheck: false,
721
+ skipDefaultLibCheck: true,
722
+ target: ts.ScriptTarget.ESNext,
723
+ module: ts.ModuleKind.ESNext,
724
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
725
+ }
726
+
727
+ /**
728
+ * A consumer module for the generated deferred `.d.ts`, wrapping the given
729
+ * `createInstance()` calls in the declarations they need.
730
+ */
731
+ const deferredConsumerSource = (calls: string) =>
732
+ `import { createInstance } from './loader.js'
733
+
734
+ declare const wasmModule: WebAssembly.Module
735
+ declare const memory: WebAssembly.Memory
736
+
737
+ ${calls}
738
+ `
739
+
740
+ /**
741
+ * TypeScript's verdict on such a consumer, as a list of diagnostic codes. The
742
+ * default lib still comes off disk through the real host; the generated
743
+ * typedef, the binding it imports, and the consumer are served from memory, so
744
+ * the check needs no temporary directory. Mirrors the semantic-check host in
745
+ * `build.spec.ts`.
746
+ */
747
+ const deferredConsumerDiagnostics = (calls: string) => {
748
+ const root = `${__dirname.replaceAll('\\', '/')}/__deferred_typecheck__`
749
+ const files = new Map([
750
+ [
751
+ `${root}/loader.d.ts`,
752
+ createWasiDeferredBrowserBindingTypeDef('./test.wasip1.cjs'),
753
+ ],
754
+ [`${root}/test.wasip1.d.cts`, 'export declare const binding: number\n'],
755
+ [`${root}/consumer.ts`, deferredConsumerSource(calls)],
756
+ ])
757
+ const host = ts.createCompilerHost(DEFERRED_TYPE_CHECK_OPTIONS, true)
758
+ const readRealSourceFile = host.getSourceFile.bind(host)
759
+ const realFileExists = host.fileExists.bind(host)
760
+ const realReadFile = host.readFile.bind(host)
761
+ const realDirectoryExists = host.directoryExists?.bind(host)
762
+ host.getSourceFile = (fileName, languageVersion, ...rest) => {
763
+ const virtual = files.get(fileName)
764
+ return virtual === undefined
765
+ ? readRealSourceFile(fileName, languageVersion, ...rest)
766
+ : ts.createSourceFile(fileName, virtual, languageVersion, true)
767
+ }
768
+ host.fileExists = (fileName) =>
769
+ files.has(fileName) || realFileExists(fileName)
770
+ host.readFile = (fileName) => files.get(fileName) ?? realReadFile(fileName)
771
+ if (realDirectoryExists) {
772
+ host.directoryExists = (directoryName) =>
773
+ directoryName === root || realDirectoryExists(directoryName)
774
+ }
775
+ const program = ts.createProgram({
776
+ rootNames: [`${root}/consumer.ts`],
777
+ options: DEFERRED_TYPE_CHECK_OPTIONS,
778
+ host,
779
+ })
780
+ return ts.getPreEmitDiagnostics(program).map((d) => `TS${d.code}`)
781
+ }
782
+
783
+ test('deferred createInstance() options type-check per memory form', (t) => {
784
+ // Every form the loader accepts at runtime. `{}` and an omitted argument
785
+ // must stay legal: the allocated form is all-optional.
786
+ t.deepEqual(
787
+ deferredConsumerDiagnostics(`void createInstance(wasmModule)
788
+ void createInstance(wasmModule, undefined)
789
+ void createInstance(wasmModule, {})
790
+ void createInstance(wasmModule, { memory })
791
+ void createInstance(wasmModule, { initialMemoryPages: 1 })
792
+ void createInstance(wasmModule, { initialMemoryPages: 1, maximumMemoryPages: 2 })`),
793
+ [],
794
+ )
795
+ })
796
+
797
+ test('deferred createInstance() rejects memory beside a page count', (t) => {
798
+ // `__resolveInstanceMemory` throws a TypeError on either mix, so neither
799
+ // may type-check. `memory` selects the caller form, whose page properties
800
+ // are `?: never` — i.e. `undefined` — so the checker rejects the offending
801
+ // property in place rather than the whole call: TS2322, "Type 'number' is not
802
+ // assignable to type 'undefined'".
803
+ t.deepEqual(
804
+ deferredConsumerDiagnostics(
805
+ 'void createInstance(wasmModule, { memory, initialMemoryPages: 1024 })',
806
+ ),
807
+ ['TS2322'],
808
+ )
809
+ t.deepEqual(
810
+ deferredConsumerDiagnostics(
811
+ 'void createInstance(wasmModule, { memory, maximumMemoryPages: 2048 })',
812
+ ),
813
+ ['TS2322'],
814
+ )
815
+ })
816
+
817
+ test('Node WASI loader uses an accessible host root on Android', (t) => {
818
+ const code = createWasiBinding('test', '@scope/test')
819
+ assertValidJS(t, code, 'Node WASI loader')
820
+ t.true(code.includes('const __cwd = process.cwd()'))
821
+ t.true(code.includes("process.platform === 'android' ? __cwd : __rootDir"))
822
+ t.true(code.includes('[__rootDir]: __hostRoot'))
823
+ t.true(code.includes('[__hostRoot]: __hostRoot'))
824
+ t.true(
825
+ code.includes('workerData: { hostRoot: __hostRoot, rootDir: __rootDir }'),
826
+ )
827
+ t.false(code.includes('[__rootDir]: __rootDir'))
828
+ })
829
+
830
+ test('Node WASI worker uses an accessible host root on Android', (t) => {
831
+ assertValidJS(t, WASI_WORKER_TEMPLATE, 'Node WASI worker')
832
+ t.true(
833
+ WASI_WORKER_TEMPLATE.includes(
834
+ "workerData && typeof workerData.rootDir === 'string' && workerData.rootDir",
835
+ ),
836
+ )
837
+ t.true(
838
+ WASI_WORKER_TEMPLATE.includes(
839
+ "workerData && typeof workerData.hostRoot === 'string' && workerData.hostRoot",
840
+ ),
841
+ )
842
+ t.true(WASI_WORKER_TEMPLATE.includes('[__rootDir]: __hostRoot'))
843
+ t.true(WASI_WORKER_TEMPLATE.includes('[__hostRoot]: __hostRoot'))
844
+ t.false(WASI_WORKER_TEMPLATE.includes('[__rootDir]: __rootDir'))
845
+ })
846
+
231
847
  for (const { name, code } of wasiLoaderCases) {
232
848
  test(`WASI loader waits for queued settlements before destroy: ${name}`, (t) => {
233
849
  t.true(
@@ -284,6 +900,698 @@ function initializationRollbackBody(code: string): string {
284
900
  return code.slice(deferredStart, code.indexOf('throw error', deferredStart))
285
901
  }
286
902
 
903
+ // The loaders order their own teardown barrier-then-destroy, but the emnapi
904
+ // context is a live object: an embedder or test harness holding it, or emnapi's
905
+ // own `beforeExit` auto-destroy on a host where `suppressDestroy()` is absent,
906
+ // can call `Context.destroy()` directly. `destroy()` disables JavaScript calls
907
+ // before it runs cleanup hooks, so a raw call discards the very settlements the
908
+ // barrier exists to cancel and deliver. Own the ordering on the object: every
909
+ // flavor shadows `destroy` once, at creation, before anything can reach it.
910
+ const CONTEXT_DESTROY_WRAP_SIGNATURE =
911
+ 'function __wrapEmnapiContextDestroyForSettlement('
912
+
913
+ // The shared barrier's own in-flight flag, and the probe the wrapper reads it
914
+ // through. It cannot live in the wrapper: `dispose()` runs the barrier itself
915
+ // and only then calls `destroy()`, so a wrapper-local flag would still be clear
916
+ // while the barrier is running and would let a reentrant destroy through.
917
+ const preparingBarrierGuards = {
918
+ shared: {
919
+ probe: '__isPreparingWasmEnvCleanup',
920
+ snippets: [
921
+ 'let __emnapiWasmEnvCleanupPreparing = false',
922
+ `function __isPreparingWasmEnvCleanup() {
923
+ return __emnapiWasmEnvCleanupPreparing
924
+ }`,
925
+ ` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
926
+ return
927
+ }`,
928
+ ` __emnapiWasmEnvCleanupPreparing = true
929
+ try {
930
+ prepare()
931
+ } finally {
932
+ __emnapiWasmEnvCleanupPreparing = false
933
+ }`,
934
+ ],
935
+ },
936
+ deferred: {
937
+ probe: '__isPreparingEnvCleanup',
938
+ snippets: [
939
+ 'let __wasmEnvCleanupPreparing = false',
940
+ 'const __isPreparingEnvCleanup = () => __wasmEnvCleanupPreparing',
941
+ ` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
942
+ return
943
+ }`,
944
+ ` __wasmEnvCleanupPreparing = true
945
+ try {
946
+ __prepareWasmEnvCleanup()
947
+ } finally {
948
+ __wasmEnvCleanupPreparing = false
949
+ }`,
950
+ ],
951
+ },
952
+ } as const
953
+
954
+ // A nested `destroy()` must answer `undefined` without touching the real one:
955
+ // no fallthrough, no deferral. `Context.destroy()` is typed `void`, so nothing
956
+ // observable is lost, and the frame that started the barrier destroys the
957
+ // moment it returns.
958
+ const NESTED_DESTROY_NO_OP = ` if (isPreparingEnvCleanup?.()) {
959
+ return
960
+ }
961
+ prepareEnvCleanup?.()`
962
+
963
+ const wrappedContextCreationCases: Array<{
964
+ name: string
965
+ code: string
966
+ prepare: string
967
+ guard: (typeof preparingBarrierGuards)[keyof typeof preparingBarrierGuards]
968
+ }> = [
969
+ {
970
+ name: 'node cjs',
971
+ code: createWasiBinding('test', '@scope/test'),
972
+ prepare: '__prepareWasmEnvCleanup',
973
+ guard: preparingBarrierGuards.shared,
974
+ },
975
+ {
976
+ name: 'node cjs threadless',
977
+ code: createWasiBinding('test', '@scope/test', 4000, 65536, false),
978
+ prepare: '__prepareWasmEnvCleanup',
979
+ guard: preparingBarrierGuards.shared,
980
+ },
981
+ {
982
+ name: 'browser esm',
983
+ code: createWasiBrowserBinding('test'),
984
+ prepare: '__prepareWasmEnvCleanup',
985
+ guard: preparingBarrierGuards.shared,
986
+ },
987
+ {
988
+ name: 'deferred/workerd',
989
+ code: createWasiDeferredBrowserBinding('test'),
990
+ prepare: '__prepareEnvCleanup',
991
+ guard: preparingBarrierGuards.deferred,
992
+ },
993
+ ]
994
+
995
+ for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
996
+ test(`WASI loader runs the barrier on a raw context.destroy(): ${name}`, (t) => {
997
+ assertValidJS(t, code, name)
998
+ t.is(
999
+ code.split(CONTEXT_DESTROY_WRAP_SIGNATURE).length - 1,
1000
+ 1,
1001
+ 'loader must define the destroy wrapper exactly once',
1002
+ )
1003
+ // No unwrapped context may escape: there is exactly one createContext call
1004
+ // and it is the wrapper's argument.
1005
+ t.is(
1006
+ code.split('__emnapiCreateContext({ autoDestroy: false })').length - 1,
1007
+ 1,
1008
+ 'loader must create exactly one emnapi context',
1009
+ )
1010
+ t.true(
1011
+ code
1012
+ .replace(/\s+/g, ' ')
1013
+ .includes(
1014
+ '__emnapiContext = __wrapEmnapiContextDestroyForSettlement( ' +
1015
+ `__emnapiCreateContext({ autoDestroy: false }), ${prepare}, ${guard.probe}, )`,
1016
+ ),
1017
+ 'the createContext result must be wrapped before anything can reach it',
1018
+ )
1019
+ // Ordering is the whole point: barrier first, real destroy second.
1020
+ const wrapperStart = code.indexOf(CONTEXT_DESTROY_WRAP_SIGNATURE)
1021
+ const wrapper = code.slice(
1022
+ wrapperStart,
1023
+ code.indexOf('\n}\n', wrapperStart),
1024
+ )
1025
+ t.true(
1026
+ wrapper.indexOf('prepareEnvCleanup?.()') <
1027
+ wrapper.indexOf('Reflect.apply(destroy, this, arguments)'),
1028
+ 'the barrier must run before the real destroy, while the env can still call into JavaScript',
1029
+ )
1030
+ })
1031
+
1032
+ // The barrier settles the promises it cancels *synchronously*, under a
1033
+ // lifecycle mutex the addon cannot acquire twice. A `promiseHooks.onSettled`
1034
+ // handler — or the `async_hooks` hook `AsyncLocalStorage` installs — that
1035
+ // calls `destroy()` therefore re-enters the wrapper from inside the barrier,
1036
+ // and a second trip into the export aborts the wasm instance outright. The
1037
+ // flag lives in the barrier, not in the wrapper, so the same guard covers the
1038
+ // `dispose()` path, where the barrier runs before `destroy()` is ever called.
1039
+ test(`WASI loader makes a destroy reentered from the barrier a no-op: ${name}`, (t) => {
1040
+ for (const snippet of guard.snippets) {
1041
+ t.is(
1042
+ code.split(snippet).length - 1,
1043
+ 1,
1044
+ `barrier must carry its in-flight guard exactly once: ${snippet}`,
1045
+ )
1046
+ }
1047
+ t.is(
1048
+ code.split(NESTED_DESTROY_NO_OP).length - 1,
1049
+ 1,
1050
+ 'a destroy reentered while the barrier is in flight must return without running the barrier or the real destroy',
1051
+ )
1052
+ const wrapperStart = code.indexOf(CONTEXT_DESTROY_WRAP_SIGNATURE)
1053
+ const wrapper = code.slice(
1054
+ wrapperStart,
1055
+ code.indexOf('\n}\n', wrapperStart),
1056
+ )
1057
+ t.true(
1058
+ wrapper.indexOf('isPreparingEnvCleanup?.()') <
1059
+ wrapper.indexOf('Reflect.apply(destroy, this, arguments)'),
1060
+ 'the reentry check must come before the real destroy',
1061
+ )
1062
+ })
1063
+ }
1064
+
1065
+ // The nested-destroy no-op above is only safe because the frame that started
1066
+ // the barrier destroys as soon as the barrier returns. The deferred loader's
1067
+ // instance `dispose()` is the one frame that does not: it runs the barrier and
1068
+ // then yields for the settlement drain. So a promise hook firing inside that
1069
+ // barrier can call the same instance's `dispose()` again, reach the context
1070
+ // destroyer while the outer frame is still parked in its drain, and have the
1071
+ // wrapper's no-op recorded as a completed destroy — after which the outer frame
1072
+ // skips the real one and the context is retained with its cleanup hooks unrun.
1073
+ // dispose() has to coalesce, the way the eager loaders' `__disposeWasiBinding`
1074
+ // does, and the memo has to be in place *before* the barrier runs.
1075
+ test('deferred WASI loader coalesces a reentrant instance dispose()', (t) => {
1076
+ const code = createWasiDeferredBrowserBinding('test')
1077
+ assertValidJS(t, code, 'deferred/workerd')
1078
+ t.is(
1079
+ code.split('let __instanceDisposePromise').length - 1,
1080
+ 1,
1081
+ 'the instance must carry exactly one disposal memo',
1082
+ )
1083
+ const disposeStart = code.indexOf('const __disposeInstance = () => {')
1084
+ t.true(disposeStart > 0, 'dispose() must go through a coalescing wrapper')
1085
+ const disposeWrapper = code.slice(
1086
+ disposeStart,
1087
+ code.indexOf('\n }\n', disposeStart),
1088
+ )
1089
+ t.true(
1090
+ disposeWrapper.includes(` if (__instanceDisposePromise) {
1091
+ return __instanceDisposePromise
1092
+ }`),
1093
+ 'a reentrant dispose() must join the disposal already running',
1094
+ )
1095
+ // The memo has to be published before the disposal body — and therefore
1096
+ // before the barrier — runs, or a hook that fires inside the barrier still
1097
+ // finds it unset and starts a second frame.
1098
+ t.true(
1099
+ disposeWrapper.indexOf('__instanceDisposePromise = __disposePromise') <
1100
+ disposeWrapper.indexOf('__runInstanceDisposal()'),
1101
+ 'the memo must be published before the disposal body runs',
1102
+ )
1103
+ // Still retryable: the drain can reject on its own (a host `setImmediate`
1104
+ // that throws), and dispose() has to be callable again after that.
1105
+ t.true(
1106
+ disposeWrapper.includes(` __instanceDisposePromise = undefined
1107
+ __rejectDispose(__error)`),
1108
+ 'a failed disposal must clear the memo so dispose() stays retryable',
1109
+ )
1110
+ // No second entry point into the disposal body: the instance exposes the
1111
+ // coalescing wrapper itself, not an inline method that re-runs it.
1112
+ t.true(
1113
+ code.includes(' dispose: __disposeInstance,'),
1114
+ 'the instance must expose the coalescing wrapper as its dispose()',
1115
+ )
1116
+ t.false(
1117
+ code.includes(' async dispose() {'),
1118
+ 'the instance must not carry a second inline dispose() method',
1119
+ )
1120
+ t.is(
1121
+ code.split('__prepareForDisposal()').length - 1,
1122
+ 2,
1123
+ 'only the disposal body and the initialization rollback may prepare for disposal',
1124
+ )
1125
+ })
1126
+
1127
+ // Belt and braces for any caller that still reaches the managed destroyer from
1128
+ // inside the barrier: a `Context.destroy()` the wrapper skipped must never be
1129
+ // recorded as a completed destroy, or every later destroy — the outer disposal
1130
+ // frame's and managed beforeExit cleanup's alike — is skipped with it.
1131
+ test('deferred WASI loader never records a skipped destroy as completed', (t) => {
1132
+ const code = createWasiDeferredBrowserBinding('test')
1133
+ const destroyStart = code.indexOf('const __destroy = (')
1134
+ t.true(destroyStart > 0, 'deferred loader must define a managed destroyer')
1135
+ const destroyer = code.slice(
1136
+ destroyStart,
1137
+ code.indexOf('\n const __destroyForModuleLifecycle', destroyStart),
1138
+ )
1139
+ t.true(
1140
+ destroyer.includes(` if (__isPreparingEnvCleanup?.()) {`),
1141
+ 'the managed destroyer must detect that the barrier is in flight',
1142
+ )
1143
+ const guardIndex = destroyer.indexOf('if (__isPreparingEnvCleanup?.()) {')
1144
+ const destroyIndex = destroyer.indexOf('__result = __emnapiContext.destroy()')
1145
+ t.true(
1146
+ guardIndex < destroyIndex,
1147
+ 'the in-flight check must come before the destroy it would skip',
1148
+ )
1149
+ t.true(
1150
+ destroyer
1151
+ .slice(guardIndex, destroyIndex)
1152
+ .includes(`throw __createLifecycleReentryError('dispose')`),
1153
+ 'a destroy the wrapper would skip must fail loudly instead of flagging the context disposed',
1154
+ )
1155
+ t.true(
1156
+ destroyIndex < destroyer.indexOf('__disposed = true'),
1157
+ 'nothing may be flagged disposed before the real destroy is attempted',
1158
+ )
1159
+ })
1160
+
1161
+ test('createCjsBinding uses one statement dialect', (t) => {
1162
+ const code = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0', [
1163
+ 'wasm32-wasi',
1164
+ ])
1165
+ t.false(
1166
+ code.includes('NAPI_RS_NATIVE_LIBRARY_PATH);'),
1167
+ 'native library require must not use a leftover semicolon',
1168
+ )
1169
+ t.true(code.includes("const __napiWasiFlavors = ['wasm32-wasi']"))
1170
+ t.false(code.includes('"wasm32-wasi"'))
1171
+ t.true(code.includes("return require('./test.win32-x64-gnu.node')"))
1172
+ const win32Gnu = code.slice(
1173
+ code.indexOf("process.arch === 'x64'"),
1174
+ code.indexOf("process.arch === 'ia32'"),
1175
+ )
1176
+ t.true(
1177
+ win32Gnu.includes(" return require('./test.win32-x64-gnu.node')"),
1178
+ 'win32-x64 gnu local require must be indented inside try',
1179
+ )
1180
+ })
1181
+
1182
+ test('the root CommonJS loader stamps before it aliases the addon', (t) => {
1183
+ // The guard is safe against an addon accessor — `hasOwnProperty` and a read —
1184
+ // but an assignment is not: a `#[napi(module_exports)]` hook can expose a
1185
+ // getter reporting the value about to be stamped and a setter that throws.
1186
+ // So the lexer-visible assignment has to land on the loader's own
1187
+ // `module.exports`, while it is still the original object.
1188
+ const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')
1189
+ assertValidJS(t, cjs, 'root cjs stamp before alias')
1190
+ const stamp = cjs.indexOf(ROOT_CJS_STAMP_CALL)
1191
+ t.true(stamp > -1, 'the root loader must stamp through the guard')
1192
+ t.true(
1193
+ stamp < cjs.indexOf('\nmodule.exports = nativeBinding'),
1194
+ 'the stamp must precede the alias, or it assigns onto the addon',
1195
+ )
1196
+ // the guard still stamps the object the loader hands out
1197
+ t.false(cjs.includes('__napiStampBindingTarget(module.exports,'))
1198
+ })
1199
+
1200
+ test('native loaders export the artifact that actually loaded', (t) => {
1201
+ const flavors = ['wasm32-wasi', 'wasm32-wasip1']
1202
+ const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0', flavors)
1203
+ assertValidJS(t, cjs, 'cjs binding target')
1204
+ t.true(cjs.includes("let __napiLoadedBindingTarget = 'native'"))
1205
+ t.true(cjs.includes(ROOT_CJS_STAMP_CALL))
1206
+ // one assignment per candidate: 2 flavors x (local loader + flavor package)
1207
+ for (const flavor of flavors) {
1208
+ t.is(
1209
+ cjs.split(`__napiLoadedBindingTarget = '${flavor}'`).length - 1,
1210
+ 2,
1211
+ `${flavor} must be recorded on both its local and package candidates`,
1212
+ )
1213
+ }
1214
+ // the target is never read back off the WASI module
1215
+ t.false(cjs.includes('wasiBinding.__napiBindingTarget'))
1216
+
1217
+ const esm = createEsmBinding('test', '@scope/test', ['sum'], '1.0.0', flavors)
1218
+ assertValidJS(t, esm, 'esm binding target')
1219
+ t.true(
1220
+ esm.includes(
1221
+ 'export const __napiBindingTarget = __napiLoadedBindingTarget',
1222
+ ),
1223
+ )
1224
+ // zero-ident packages take the `export default` branch and must keep it
1225
+ const esmNoIdents = createEsmBinding(
1226
+ 'test',
1227
+ '@scope/test',
1228
+ [],
1229
+ '1.0.0',
1230
+ flavors,
1231
+ )
1232
+ assertValidJS(t, esmNoIdents, 'esm binding target without idents')
1233
+ t.true(
1234
+ esmNoIdents.includes(
1235
+ 'export const __napiBindingTarget = __napiLoadedBindingTarget',
1236
+ ),
1237
+ )
1238
+ })
1239
+
1240
+ test('a napi export may not shadow __napiBindingTarget', (t) => {
1241
+ t.throws(
1242
+ () => createEsmBinding('test', '@scope/test', ['__napiBindingTarget']),
1243
+ {
1244
+ message: /reserved by the generated binding loader/,
1245
+ },
1246
+ )
1247
+ t.throws(
1248
+ () => createCjsBinding('test', '@scope/test', ['__napiBindingTarget']),
1249
+ {
1250
+ message: /reserved by the generated binding loader/,
1251
+ },
1252
+ )
1253
+ })
1254
+
1255
+ test('WASI loaders self-identify their flavor', (t) => {
1256
+ t.true(
1257
+ createWasiBinding('test', '@scope/test').includes(
1258
+ "const __napiBindingTarget = 'wasm32-wasi'",
1259
+ ),
1260
+ )
1261
+ t.true(
1262
+ createWasiBinding(
1263
+ 'test',
1264
+ '@scope/test',
1265
+ 4000,
1266
+ 65536,
1267
+ false,
1268
+ 'wasm32-wasip1',
1269
+ ).includes("const __napiBindingTarget = 'wasm32-wasip1'"),
1270
+ )
1271
+ t.true(
1272
+ createWasiBrowserBinding('test').includes(
1273
+ "export const __napiBindingTarget = 'wasm32-wasi'",
1274
+ ),
1275
+ )
1276
+ t.true(
1277
+ createWasiBrowserBinding(
1278
+ 'test',
1279
+ 4000,
1280
+ 65536,
1281
+ false,
1282
+ false,
1283
+ false,
1284
+ false,
1285
+ false,
1286
+ ).includes("export const __napiBindingTarget = 'wasm32-wasip1'"),
1287
+ )
1288
+ t.true(
1289
+ createWasiDeferredBrowserBinding('test').includes(
1290
+ "export const __napiBindingTarget = 'wasm32-wasip1'",
1291
+ ),
1292
+ )
1293
+ })
1294
+
1295
+ const STAMP_HELPER_DECL =
1296
+ 'function __napiStampBindingTarget(exportsObject, target) {'
1297
+ const WASI_STAMP_CALL =
1298
+ '__napiStampBindingTarget(__napiModule.exports, __napiBindingTarget)'
1299
+ // The CJS loaders assign the guard's return value instead of calling it as a
1300
+ // statement: `cjs-module-lexer` only reports `__napiBindingTarget` as a named
1301
+ // export when it can see `module.exports.<name> =`, and Node's CJS->ESM named
1302
+ // export detection is that lexer.
1303
+ const ROOT_CJS_STAMP_CALL =
1304
+ 'module.exports.__napiBindingTarget = __napiStampBindingTarget(nativeBinding, __napiLoadedBindingTarget)'
1305
+ // The node WASI loader stamps the emnapi exports object — the one the CommonJS
1306
+ // tail then aliases — while assigning through `module.exports` for the lexer.
1307
+ const WASI_CJS_STAMP_LINE = `module.exports.__napiBindingTarget = ${WASI_STAMP_CALL}`
1308
+ // the call inside the initialization try, not the function declaration
1309
+ const WASI_EXIT_LISTENER_CALL = '\n __registerWasiExitListener()'
1310
+ // nothing may write the marker onto a user-controlled exports object without
1311
+ // going through the guard, so an assignment is only legal when the guard call
1312
+ // is its right-hand side
1313
+ const UNGUARDED_MODULE_EXPORTS_STAMP =
1314
+ /module\.exports\.__napiBindingTarget = (?!__napiStampBindingTarget\()/
1315
+
1316
+ test('browser and deferred loaders carry the flavor on the binding they hand out', (t) => {
1317
+ // `export default __napiModule.exports` and `instantiate()` hand out the raw
1318
+ // emnapi exports object, which a named module export does not travel with.
1319
+ const browser = createWasiBrowserBinding('test')
1320
+ assertValidJS(t, browser, 'browser binding target on exports')
1321
+ t.true(browser.includes(WASI_STAMP_CALL))
1322
+ const deferred = createWasiDeferredBrowserBinding('test')
1323
+ assertValidJS(t, deferred, 'deferred binding target on exports')
1324
+ t.is(
1325
+ deferred.split(WASI_STAMP_CALL).length - 1,
1326
+ 1,
1327
+ 'every instance created by __createInstance must be marked exactly once',
1328
+ )
1329
+ // the marker is assigned before the instance escapes to the caller
1330
+ t.true(
1331
+ deferred.indexOf(WASI_STAMP_CALL) <
1332
+ deferred.indexOf('exports: __napiModule.exports'),
1333
+ )
1334
+ })
1335
+
1336
+ test('every mutating loader stamps the binding target through the guard', (t) => {
1337
+ const cases: Array<{ name: string; code: string; call?: string }> = [
1338
+ {
1339
+ name: 'root cjs',
1340
+ code: createCjsBinding('test', '@scope/test', ['sum'], '1.0.0'),
1341
+ call: ROOT_CJS_STAMP_CALL,
1342
+ },
1343
+ {
1344
+ name: 'wasi node cjs',
1345
+ code: createWasiBinding('test', '@scope/test'),
1346
+ call: WASI_CJS_STAMP_LINE,
1347
+ },
1348
+ {
1349
+ name: 'wasi browser esm',
1350
+ code: createWasiBrowserBinding('test'),
1351
+ call: WASI_STAMP_CALL,
1352
+ },
1353
+ {
1354
+ name: 'wasi deferred esm',
1355
+ code: createWasiDeferredBrowserBinding('test'),
1356
+ call: WASI_STAMP_CALL,
1357
+ },
1358
+ ]
1359
+ for (const { name, code, call } of cases) {
1360
+ assertValidJS(t, code, `${name} stamp guard`)
1361
+ t.is(
1362
+ code.split(STAMP_HELPER_DECL).length - 1,
1363
+ 1,
1364
+ `${name} must emit the guard exactly once`,
1365
+ )
1366
+ if (call) {
1367
+ t.is(
1368
+ code.split(call).length - 1,
1369
+ 1,
1370
+ `${name} must stamp exactly once, through the guard`,
1371
+ )
1372
+ }
1373
+ // [[Define]], not [[Set]]: an ordinary assignment walks the prototype
1374
+ // chain, so an inherited accessor on a user-controlled exports object could
1375
+ // swallow the marker or throw and fail an otherwise successful load
1376
+ t.true(
1377
+ code.includes(
1378
+ "Object.defineProperty(exportsObject, '__napiBindingTarget'",
1379
+ ),
1380
+ `${name} must define the marker as an own data property`,
1381
+ )
1382
+ t.false(
1383
+ code.includes('exportsObject.__napiBindingTarget = target'),
1384
+ `${name} must not stamp the marker through an ordinary assignment`,
1385
+ )
1386
+ // an addon's exports object is user-controlled: nothing may write the
1387
+ // marker onto it without going through the guard
1388
+ t.false(
1389
+ UNGUARDED_MODULE_EXPORTS_STAMP.test(code),
1390
+ `${name} must not assign the marker onto module.exports directly`,
1391
+ )
1392
+ t.false(
1393
+ code.includes('__napiModule.exports.__napiBindingTarget ='),
1394
+ `${name} must not assign the marker onto the emnapi exports directly`,
1395
+ )
1396
+ }
1397
+ })
1398
+
1399
+ test('the node WASI loader stamps inside the rollback boundary', (t) => {
1400
+ // Anything the guard throws — a conflicting `#[napi(module_exports)]` export,
1401
+ // or an addon accessor whose setter refuses the write — has to land in the
1402
+ // initialization `try`. From outside it the throw escapes with the emnapi
1403
+ // context built and the process 'exit' listener installed, so a failed
1404
+ // `require()` leaks an initialized WASI environment nothing can reach.
1405
+ const code = createWasiBinding('test', '@scope/test')
1406
+ assertValidJS(t, code, 'wasi node cjs rollback boundary')
1407
+ const initializationCatch = '\n} catch (error) {'
1408
+ t.is(
1409
+ code.split(initializationCatch).length - 1,
1410
+ 1,
1411
+ 'the top-level initialization catch must be unambiguous',
1412
+ )
1413
+ // exactly one, so nothing can stamp a second time outside the boundary
1414
+ t.is(code.split('module.exports.__napiBindingTarget =').length - 1, 1)
1415
+ const stamp = code.indexOf(WASI_CJS_STAMP_LINE)
1416
+ t.true(stamp > code.indexOf('__publishWasiDispose(__napiModule.exports)'))
1417
+ t.true(stamp < code.indexOf(WASI_EXIT_LISTENER_CALL))
1418
+ t.true(stamp < code.indexOf(initializationCatch))
1419
+ // and the rollback the catch runs is the one that tears the environment down
1420
+ t.true(code.includes('__runWasiInitializationRollback(rollback)'))
1421
+ })
1422
+
1423
+ /**
1424
+ * Every loader that stamps an addon-owned exports object, with the marker that
1425
+ * ends its initialization guard and the host installation that must precede the
1426
+ * stamp. A host install hands that same object to addon-provided registration
1427
+ * functions, which can put anything on it — including this marker — so a stamp
1428
+ * placed before them reads a state that is not final.
1429
+ */
1430
+ const HOST_INSTALL_ORDER_CASES = [
1431
+ {
1432
+ name: 'wasi node cjs',
1433
+ build: (asyncRuntime: boolean) =>
1434
+ createWasiBinding(
1435
+ 'test',
1436
+ '@scope/test',
1437
+ undefined,
1438
+ undefined,
1439
+ undefined,
1440
+ undefined,
1441
+ undefined,
1442
+ asyncRuntime,
1443
+ ),
1444
+ stamp: WASI_CJS_STAMP_LINE,
1445
+ hostInstall: '__installCurrentThreadHosts(',
1446
+ endOfGuard: WASI_EXIT_LISTENER_CALL,
1447
+ },
1448
+ {
1449
+ name: 'wasi browser esm',
1450
+ build: (asyncRuntime: boolean) =>
1451
+ createWasiBrowserBinding(
1452
+ 'test',
1453
+ undefined,
1454
+ undefined,
1455
+ undefined,
1456
+ undefined,
1457
+ undefined,
1458
+ undefined,
1459
+ undefined,
1460
+ undefined,
1461
+ asyncRuntime,
1462
+ ),
1463
+ stamp: WASI_STAMP_CALL,
1464
+ hostInstall: '__installCurrentThreadHosts(',
1465
+ endOfGuard: '\n} catch (error) {',
1466
+ },
1467
+ {
1468
+ name: 'wasi deferred esm',
1469
+ build: (asyncRuntime: boolean) =>
1470
+ createWasiDeferredBrowserBinding(
1471
+ 'test',
1472
+ undefined,
1473
+ undefined,
1474
+ undefined,
1475
+ undefined,
1476
+ asyncRuntime,
1477
+ ),
1478
+ stamp: WASI_STAMP_CALL,
1479
+ hostInstall: '__registerWorkerdCurrentThreadTaskHost(',
1480
+ endOfGuard: "__lifecycleState === 'pending'",
1481
+ },
1482
+ ] as const
1483
+
1484
+ for (const {
1485
+ name,
1486
+ build,
1487
+ stamp,
1488
+ hostInstall,
1489
+ endOfGuard,
1490
+ } of HOST_INSTALL_ORDER_CASES) {
1491
+ test(`${name} stamps the binding object after its host installation`, (t) => {
1492
+ const withHosts = build(true)
1493
+ assertValidJS(t, withHosts, `${name} asyncRuntime stamp order`)
1494
+ const hostInstallAt = withHosts.indexOf(hostInstall)
1495
+ t.true(hostInstallAt > -1, 'the asyncRuntime host install must be emitted')
1496
+ const stampAt = withHosts.indexOf(stamp)
1497
+ t.true(stampAt > hostInstallAt, 'the stamp must follow the host install')
1498
+ t.true(
1499
+ stampAt < withHosts.indexOf(endOfGuard),
1500
+ 'the stamp must stay inside the initialization guard',
1501
+ )
1502
+
1503
+ // and without an async runtime the stamp keeps that same place: last thing
1504
+ // before the guard closes, so nothing can reshape the object behind it
1505
+ const withoutHosts = build(false)
1506
+ assertValidJS(t, withoutHosts, `${name} stamp order`)
1507
+ t.is(withoutHosts.indexOf(hostInstall), -1)
1508
+ t.true(withoutHosts.indexOf(stamp) < withoutHosts.indexOf(endOfGuard))
1509
+ })
1510
+ }
1511
+
1512
+ test('the deferred loader marks the binding without requiring an extensible exports object', (t) => {
1513
+ const deferred = createWasiDeferredBrowserBinding('test')
1514
+ assertValidJS(t, deferred, 'deferred guarded stamp')
1515
+ // a `#[napi(module_exports)]` hook may have sealed or frozen this object
1516
+ t.true(deferred.includes('if (!Object.isExtensible(exportsObject)) {'))
1517
+ // and it may have claimed the name, which is a hard error, not a silent
1518
+ // overwrite — with a stable `code` to branch on
1519
+ t.true(
1520
+ deferred.includes(
1521
+ "Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget')",
1522
+ ),
1523
+ )
1524
+ t.true(deferred.includes("error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT'"))
1525
+ })
1526
+
1527
+ test('every emitted loader says what its own entry reports after a skipped stamp', (t) => {
1528
+ // The runtime is pinned by build.spec.ts (`a frozen addon keeps
1529
+ // __napiBindingTarget importable, just undefined`): both CommonJS entries
1530
+ // replace `module.exports` with the object the stamp was skipped on, so the
1531
+ // value is absent there, while the ESM loaders keep a module-level export.
1532
+ // The comment shipped inside every loader has to describe that split rather
1533
+ // than promise the module export survives everywhere.
1534
+ for (const [name, code] of [
1535
+ ['root cjs', createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')],
1536
+ ['wasi node cjs', createWasiBinding('test', '@scope/test')],
1537
+ ['wasi browser esm', createWasiBrowserBinding('test')],
1538
+ ['wasi deferred esm', createWasiDeferredBrowserBinding('test')],
1539
+ ] as const) {
1540
+ assertValidJS(t, code, `${name} skip contract`)
1541
+ t.false(
1542
+ code.includes('module export still reports it'),
1543
+ `${name} must not claim every entry still reports the target`,
1544
+ )
1545
+ t.true(
1546
+ code.includes('while the CommonJS entries hand back'),
1547
+ `${name} must say the CommonJS entries lose the value`,
1548
+ )
1549
+ }
1550
+
1551
+ // and the root CommonJS loader's own note about its lexer-visible assignment:
1552
+ // that assignment lands on the loader's own `module.exports`, so it always
1553
+ // succeeds and the alias on the next line is what discards it
1554
+ const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')
1555
+ t.false(
1556
+ cjs.includes('is a silent no-op'),
1557
+ 'the assignment is not a no-op; its target is extensible',
1558
+ )
1559
+ t.true(
1560
+ cjs.includes('The assignment itself always succeeds'),
1561
+ 'the root CommonJS loader must not call its own assignment a no-op',
1562
+ )
1563
+ t.true(
1564
+ cjs.includes('own, still extensible `module.exports`'),
1565
+ 'the root CommonJS loader must name its real assignment target',
1566
+ )
1567
+ })
1568
+
1569
+ test('NAPI_RS_NATIVE_LIBRARY_PATH keeps the flavor its override reports', (t) => {
1570
+ const adoption = `__napiLoadedBindingTarget =
1571
+ overrideBinding && typeof overrideBinding.__napiBindingTarget === 'string'
1572
+ ? overrideBinding.__napiBindingTarget
1573
+ : 'native'`
1574
+ for (const [name, code] of [
1575
+ ['cjs', createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')],
1576
+ ['esm', createEsmBinding('test', '@scope/test', ['sum'], '1.0.0')],
1577
+ ] as const) {
1578
+ assertValidJS(t, code, `${name} override binding target`)
1579
+ t.true(code.includes(adoption), `${name} must adopt the override's target`)
1580
+ // the override result must not be returned before it is inspected
1581
+ t.false(
1582
+ code.includes('return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)'),
1583
+ `${name} must bind the override before returning it`,
1584
+ )
1585
+ }
1586
+ })
1587
+
1588
+ test('WASI worker template matches the CJS/ESM quote and semicolon dialect', (t) => {
1589
+ t.true(WASI_WORKER_TEMPLATE.includes("import fs from 'node:fs'"))
1590
+ t.false(WASI_WORKER_TEMPLATE.includes('from "node:fs"'))
1591
+ t.false(WASI_WORKER_TEMPLATE.includes("from 'node:fs';"))
1592
+ t.true(WASI_WORKER_TEMPLATE.includes('memory: wasmMemory,'))
1593
+ })
1594
+
287
1595
  test('createEsmBinding is Node 12 compatible', (t) => {
288
1596
  const code = createEsmBinding('test', '@scope/test', ['sum'])
289
1597
  assertValidJS(t, code, 'esm')