@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
@@ -5,7 +5,7 @@ import {
5
5
  rmSync,
6
6
  writeFileSync,
7
7
  } from 'node:fs'
8
- import { exec } from 'node:child_process'
8
+ import { exec, spawnSync } from 'node:child_process'
9
9
  import {
10
10
  copyFile,
11
11
  mkdir,
@@ -19,20 +19,29 @@ import { tmpdir } from 'node:os'
19
19
  import { join, dirname, resolve } from 'node:path'
20
20
  import { join as posixJoin, sep as posixSep } from 'node:path/posix'
21
21
  import { sep as win32Sep } from 'node:path/win32'
22
- import { fileURLToPath } from 'node:url'
22
+ import { fileURLToPath, pathToFileURL } from 'node:url'
23
23
 
24
24
  import ava, { type ExecutionContext, type TestFn } from 'ava'
25
+ import { parseSync } from 'oxc-parser'
26
+ import ts from 'typescript'
25
27
 
26
28
  import {
29
+ bindingTargetDeclarationPredicate,
27
30
  buildProject,
31
+ checkAsyncRuntimeHostContract,
32
+ EMNAPI_WASI_SDK_34_LINK_DIR,
33
+ ensureBindingTargetDeclaration,
28
34
  generateTypeDef,
29
35
  napiCrossToolchainEnvs,
36
+ prepareWasiBindingTypeDef,
30
37
  resolveBuildFormat,
38
+ resolveWasmMemory,
39
+ selectEmnapiLinkDir,
31
40
  validateCrossCompileFlags,
32
41
  validateNapiCrossSupport,
33
42
  writeJsBinding,
34
43
  } from '../build.js'
35
- import { getSystemDefaultTarget } from '../../utils/index.js'
44
+ import { getSystemDefaultTarget, scanExportedName } from '../../utils/index.js'
36
45
 
37
46
  const __dirname = dirname(fileURLToPath(import.meta.url))
38
47
  const repoRoot = resolve(__dirname, '../../../..')
@@ -244,6 +253,1963 @@ test('writeJsBinding uses the explicit format independently of the filename', as
244
253
  t.regex(commonjs, /module\.exports\.sum = nativeBinding\.sum/)
245
254
  t.regex(esm, /export \{ sum \}/)
246
255
  t.regex(legacyEsm, /export \{ sum \}/)
256
+ t.regex(
257
+ commonjs,
258
+ /module\.exports\.__napiBindingTarget = __napiStampBindingTarget\(nativeBinding, __napiLoadedBindingTarget\)/,
259
+ )
260
+ t.regex(esm, /export const __napiBindingTarget = __napiLoadedBindingTarget/)
261
+ t.regex(
262
+ legacyEsm,
263
+ /export const __napiBindingTarget = __napiLoadedBindingTarget/,
264
+ )
265
+ })
266
+
267
+ test('writeJsBinding rejects a napi export named __napiBindingTarget', async (t) => {
268
+ await t.throwsAsync(
269
+ writeJsBinding({
270
+ platform: true,
271
+ idents: ['__napiBindingTarget'],
272
+ binaryName: 'build-integration',
273
+ packageName: 'build-integration',
274
+ version: '0.1.0',
275
+ outputDir: t.context.projectDir,
276
+ }),
277
+ { message: /reserved by the generated binding loader/ },
278
+ )
279
+ })
280
+
281
+ test('the generated loader reports the flavor its library-path override loaded', async (t) => {
282
+ const { projectDir } = t.context
283
+ const overridePath = join(projectDir, 'fake-wasip1.cjs')
284
+ const plainPath = join(projectDir, 'fake-native.cjs')
285
+ await Promise.all([
286
+ // a stand-in for a generated WASI loader: it reports its own flavor
287
+ writeFile(
288
+ overridePath,
289
+ `module.exports = { sum: (a, b) => a + b }\nmodule.exports.__napiBindingTarget = 'wasm32-wasip1'\n`,
290
+ ),
291
+ writeFile(plainPath, `module.exports = { sum: (a, b) => a + b }\n`),
292
+ ])
293
+ await writeJsBinding({
294
+ platform: true,
295
+ idents: ['sum'],
296
+ binaryName: 'build-integration',
297
+ packageName: 'build-integration',
298
+ version: '0.1.0',
299
+ outputDir: projectDir,
300
+ })
301
+ const rootPath = join(projectDir, 'index.js')
302
+
303
+ const probe = (libraryPath: string) => {
304
+ const result = spawnSync(
305
+ process.execPath,
306
+ [
307
+ '-e',
308
+ // require the override FIRST: the root loader aliases it, so an
309
+ // unconditional assignment would rewrite the override's own marker
310
+ `const override = require(${JSON.stringify(libraryPath)})
311
+ const root = require(${JSON.stringify(rootPath)})
312
+ console.log(
313
+ JSON.stringify({
314
+ root: root.__napiBindingTarget,
315
+ override: override.__napiBindingTarget,
316
+ aliased: root === override,
317
+ sum: root.sum(1, 2),
318
+ }),
319
+ )`,
320
+ ],
321
+ {
322
+ encoding: 'utf8',
323
+ env: { ...process.env, NAPI_RS_NATIVE_LIBRARY_PATH: libraryPath },
324
+ },
325
+ )
326
+ t.is(result.status, 0, `${result.stdout}\n${result.stderr}`)
327
+ return JSON.parse(result.stdout)
328
+ }
329
+
330
+ t.deepEqual(probe(overridePath), {
331
+ root: 'wasm32-wasip1',
332
+ override: 'wasm32-wasip1',
333
+ aliased: true,
334
+ sum: 3,
335
+ })
336
+ t.deepEqual(probe(plainPath), {
337
+ root: 'native',
338
+ override: 'native',
339
+ aliased: true,
340
+ sum: 3,
341
+ })
342
+ })
343
+
344
+ // A stand-in for a `#[napi(module_exports)]` hook: it attaches names to the
345
+ // addon's exports object imperatively, so napi-rs type generation never sees
346
+ // them and `assertBindingTargetIdentFree` cannot either. Only the loader can.
347
+ const writeFakePlatformPackage = async (projectDir: string, source: string) => {
348
+ const { platformArchABI } = getSystemDefaultTarget()
349
+ const packageDir = join(
350
+ projectDir,
351
+ 'node_modules',
352
+ `build-integration-${platformArchABI}`,
353
+ )
354
+ await mkdir(packageDir, { recursive: true })
355
+ await Promise.all([
356
+ writeFile(
357
+ join(packageDir, 'package.json'),
358
+ `{"name":"build-integration-${platformArchABI}","version":"0.1.0","main":"index.js"}\n`,
359
+ ),
360
+ writeFile(join(packageDir, 'index.js'), source),
361
+ ])
362
+ }
363
+
364
+ const requireRootLoaderInChild = (rootPath: string, body: string) =>
365
+ spawnSync(
366
+ process.execPath,
367
+ ['-e', `const binding = require(${JSON.stringify(rootPath)})\n${body}`],
368
+ { encoding: 'utf8' },
369
+ )
370
+
371
+ test('a module_exports hook may not claim __napiBindingTarget', async (t) => {
372
+ const { projectDir } = t.context
373
+ await writeFakePlatformPackage(
374
+ projectDir,
375
+ `const exportsObject = { sum: (a, b) => a + b }
376
+ exportsObject.__napiBindingTarget = 'addon-owned-value'
377
+ module.exports = exportsObject
378
+ `,
379
+ )
380
+ await writeJsBinding({
381
+ platform: true,
382
+ idents: ['sum'],
383
+ binaryName: 'build-integration',
384
+ packageName: 'build-integration',
385
+ version: '0.1.0',
386
+ outputDir: projectDir,
387
+ })
388
+
389
+ const result = requireRootLoaderInChild(
390
+ join(projectDir, 'index.js'),
391
+ `console.log(binding.__napiBindingTarget)`,
392
+ )
393
+ t.not(result.status, 0, `${result.stdout}\n${result.stderr}`)
394
+ t.regex(result.stderr, /reserved by the generated binding loader/)
395
+ t.regex(result.stderr, /ERR_NAPI_BINDING_TARGET_CONFLICT/)
396
+ })
397
+
398
+ test('a zero-ident package still rejects a claimed binding target', async (t) => {
399
+ const { projectDir } = t.context
400
+ // the `!enableTypeDef` shape: no type-def metadata at all, so the build-time
401
+ // assertion has an empty list to check and the loader is the only guard left
402
+ await writeFakePlatformPackage(
403
+ projectDir,
404
+ `const exportsObject = { sum: (a, b) => a + b }
405
+ exportsObject.__napiBindingTarget = 'addon-owned-value'
406
+ module.exports = exportsObject
407
+ `,
408
+ )
409
+ await writeJsBinding({
410
+ platform: true,
411
+ idents: [],
412
+ wasiFlavors: ['wasm32-wasi'],
413
+ binaryName: 'build-integration',
414
+ packageName: 'build-integration',
415
+ version: '0.1.0',
416
+ outputDir: projectDir,
417
+ })
418
+
419
+ const result = requireRootLoaderInChild(
420
+ join(projectDir, 'index.js'),
421
+ `console.log(binding.__napiBindingTarget)`,
422
+ )
423
+ t.not(result.status, 0, `${result.stdout}\n${result.stderr}`)
424
+ t.regex(result.stderr, /ERR_NAPI_BINDING_TARGET_CONFLICT/)
425
+ })
426
+
427
+ test('a frozen addon loads without the binding target stamp', async (t) => {
428
+ const { projectDir } = t.context
429
+ // `Object::freeze` in a `#[napi(module_exports)]` hook. Reporting the
430
+ // artifact is metadata; it must never fail an otherwise successful load.
431
+ await writeFakePlatformPackage(
432
+ projectDir,
433
+ `module.exports = Object.freeze({ sum: (a, b) => a + b })\n`,
434
+ )
435
+ await writeJsBinding({
436
+ platform: true,
437
+ idents: ['sum'],
438
+ binaryName: 'build-integration',
439
+ packageName: 'build-integration',
440
+ version: '0.1.0',
441
+ outputDir: projectDir,
442
+ })
443
+
444
+ const result = requireRootLoaderInChild(
445
+ join(projectDir, 'index.js'),
446
+ `console.log(
447
+ JSON.stringify({
448
+ sum: binding.sum(1, 2),
449
+ target: binding.__napiBindingTarget ?? null,
450
+ }),
451
+ )`,
452
+ )
453
+ t.is(result.status, 0, `${result.stdout}\n${result.stderr}`)
454
+ t.deepEqual(JSON.parse(result.stdout), { sum: 3, target: null })
455
+ })
456
+
457
+ // Node's CJS -> ESM named export detection is `cjs-module-lexer`, a static
458
+ // scanner: it reports `__napiBindingTarget` only when it can see
459
+ // `module.exports.__napiBindingTarget =` in the source. A bare guard call is
460
+ // invisible to it, and the import then fails to link at all.
461
+ const importBindingTargetInChild = (rootPath: string) =>
462
+ spawnSync(
463
+ process.execPath,
464
+ [
465
+ '--input-type=module',
466
+ '-e',
467
+ `import { __napiBindingTarget } from ${JSON.stringify(
468
+ pathToFileURL(rootPath).href,
469
+ )}
470
+ console.log(JSON.stringify(__napiBindingTarget))`,
471
+ ],
472
+ { encoding: 'utf8' },
473
+ )
474
+
475
+ test('the CommonJS loader exposes __napiBindingTarget as an ESM named export', async (t) => {
476
+ const { projectDir } = t.context
477
+ await writeFakePlatformPackage(
478
+ projectDir,
479
+ `module.exports = { sum: (a, b) => a + b }\n`,
480
+ )
481
+ await writeJsBinding({
482
+ platform: true,
483
+ idents: ['sum'],
484
+ binaryName: 'build-integration',
485
+ packageName: 'build-integration',
486
+ version: '0.1.0',
487
+ outputDir: projectDir,
488
+ })
489
+
490
+ const result = importBindingTargetInChild(join(projectDir, 'index.js'))
491
+ t.is(result.status, 0, `${result.stdout}\n${result.stderr}`)
492
+ t.is(result.stdout.trim(), '"native"')
493
+ })
494
+
495
+ test('a frozen addon keeps __napiBindingTarget importable, just undefined', async (t) => {
496
+ const { projectDir } = t.context
497
+ // The lexer is static, so the name links either way; the runtime skip is what
498
+ // leaves it undefined. A named import that throws `SyntaxError` at link time
499
+ // would be a much louder break than a missing value.
500
+ await writeFakePlatformPackage(
501
+ projectDir,
502
+ `module.exports = Object.freeze({ sum: (a, b) => a + b })\n`,
503
+ )
504
+ await writeJsBinding({
505
+ platform: true,
506
+ idents: ['sum'],
507
+ binaryName: 'build-integration',
508
+ packageName: 'build-integration',
509
+ version: '0.1.0',
510
+ outputDir: projectDir,
511
+ })
512
+
513
+ const result = importBindingTargetInChild(join(projectDir, 'index.js'))
514
+ t.is(result.status, 0, `${result.stdout}\n${result.stderr}`)
515
+ t.is(result.stdout.trim(), 'undefined')
516
+ })
517
+
518
+ // `Object.create(proto)` in a `#[napi(module_exports)]` hook — napi-rs itself
519
+ // reaches `Object.setPrototypeOf` off the global, so the prototype an addon's
520
+ // exports object carries is not beyond an addon's reach. `hasOwnProperty` does
521
+ // not see an inherited accessor, so an ordinary assignment would reach its
522
+ // setter.
523
+ const inheritedBindingTargetAccessor = (setterBody: string) =>
524
+ `const proto = {}
525
+ Object.defineProperty(proto, '__napiBindingTarget', {
526
+ get() {
527
+ return undefined
528
+ },
529
+ set() {
530
+ ${setterBody}
531
+ },
532
+ configurable: true,
533
+ })
534
+ const exportsObject = Object.create(proto)
535
+ exportsObject.sum = (a, b) => a + b
536
+ module.exports = exportsObject
537
+ `
538
+
539
+ const REPORT_BINDING_TARGET = `console.log(
540
+ JSON.stringify({
541
+ sum: binding.sum(1, 2),
542
+ target: binding.__napiBindingTarget ?? null,
543
+ own: Object.prototype.hasOwnProperty.call(binding, '__napiBindingTarget'),
544
+ }),
545
+ )`
546
+
547
+ test('an addon whose prototype carries __napiBindingTarget still loads and reports its target', async (t) => {
548
+ const { projectDir } = t.context
549
+ // The throwing half: an inherited setter that refuses the write would kill an
550
+ // otherwise successful load at the stamp — the same regression the frozen
551
+ // skip above exists to prevent.
552
+ await writeFakePlatformPackage(
553
+ projectDir,
554
+ inheritedBindingTargetAccessor(`throw new Error('addon setter refused')`),
555
+ )
556
+ await writeJsBinding({
557
+ platform: true,
558
+ idents: ['sum'],
559
+ binaryName: 'build-integration',
560
+ packageName: 'build-integration',
561
+ version: '0.1.0',
562
+ outputDir: projectDir,
563
+ })
564
+
565
+ const result = requireRootLoaderInChild(
566
+ join(projectDir, 'index.js'),
567
+ REPORT_BINDING_TARGET,
568
+ )
569
+ t.is(result.status, 0, `${result.stdout}\n${result.stderr}`)
570
+ t.deepEqual(JSON.parse(result.stdout), {
571
+ sum: 3,
572
+ target: 'native',
573
+ own: true,
574
+ })
575
+ })
576
+
577
+ test('an inherited setter does not swallow the binding target', async (t) => {
578
+ const { projectDir } = t.context
579
+ // The absorbing half: the setter accepts the write and creates nothing, so
580
+ // both `require(...).__napiBindingTarget` and the ESM named import resolve to
581
+ // `undefined` while the generated `.d.ts` promises a literal.
582
+ await writeFakePlatformPackage(projectDir, inheritedBindingTargetAccessor(''))
583
+ await writeJsBinding({
584
+ platform: true,
585
+ idents: ['sum'],
586
+ binaryName: 'build-integration',
587
+ packageName: 'build-integration',
588
+ version: '0.1.0',
589
+ outputDir: projectDir,
590
+ })
591
+
592
+ const required = requireRootLoaderInChild(
593
+ join(projectDir, 'index.js'),
594
+ REPORT_BINDING_TARGET,
595
+ )
596
+ t.is(required.status, 0, `${required.stdout}\n${required.stderr}`)
597
+ t.deepEqual(JSON.parse(required.stdout), {
598
+ sum: 3,
599
+ target: 'native',
600
+ own: true,
601
+ })
602
+
603
+ // and the value the lexer-linked named import reads is the one on that same
604
+ // object, so the stamp has to land as an own property for the import to work
605
+ const imported = importBindingTargetInChild(join(projectDir, 'index.js'))
606
+ t.is(imported.status, 0, `${imported.stdout}\n${imported.stderr}`)
607
+ t.is(imported.stdout.trim(), '"native"')
608
+ })
609
+
610
+ test('an exotic binding object never fails the load', async (t) => {
611
+ const { projectDir } = t.context
612
+ // A `Proxy` whose `defineProperty` trap refuses is the one shape the stamp
613
+ // cannot satisfy. Today's assignment is a sloppy-mode no-op there and a bare
614
+ // `Object.defineProperty` would throw, so the skip is what keeps the rule the
615
+ // frozen case states: metadata never fails an otherwise successful load.
616
+ await writeFakePlatformPackage(
617
+ projectDir,
618
+ `module.exports = new Proxy(
619
+ { sum: (a, b) => a + b },
620
+ { defineProperty: () => false },
621
+ )
622
+ `,
623
+ )
624
+ await writeJsBinding({
625
+ platform: true,
626
+ idents: ['sum'],
627
+ binaryName: 'build-integration',
628
+ packageName: 'build-integration',
629
+ version: '0.1.0',
630
+ outputDir: projectDir,
631
+ })
632
+
633
+ const result = requireRootLoaderInChild(
634
+ join(projectDir, 'index.js'),
635
+ REPORT_BINDING_TARGET,
636
+ )
637
+ t.is(result.status, 0, `${result.stdout}\n${result.stderr}`)
638
+ t.deepEqual(JSON.parse(result.stdout), {
639
+ sum: 3,
640
+ target: null,
641
+ own: false,
642
+ })
643
+ })
644
+
645
+ const bindingTargetDeclarationOf = (source: string) =>
646
+ source
647
+ .split('\n')
648
+ .find((line) => line.startsWith('export declare const __napiBindingTarget'))
649
+
650
+ test('the declared binding target covers every loadable artifact', async (t) => {
651
+ const { projectDir, typeDefDir } = t.context
652
+ await writeFile(
653
+ join(typeDefDir, 'sum.type'),
654
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
655
+ )
656
+
657
+ const { dts } = await generateTypeDef({
658
+ typeDefDir,
659
+ cwd: projectDir,
660
+ declareBindingTarget: true,
661
+ })
662
+
663
+ // Nothing here declares a WASI target, yet NAPI_RS_NATIVE_LIBRARY_PATH may
664
+ // still point the loader at a generated WASI loader, which the root entry
665
+ // then reports. A union of only the configured targets would make TypeScript
666
+ // reject those comparisons.
667
+ t.is(
668
+ bindingTargetDeclarationOf(dts),
669
+ "export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'",
670
+ )
671
+
672
+ const { dts: withoutLoader } = await generateTypeDef({
673
+ typeDefDir,
674
+ cwd: projectDir,
675
+ })
676
+
677
+ t.false(withoutLoader.includes('__napiBindingTarget'))
678
+ })
679
+
680
+ test('a preserved WASI declaration gains the binding target declaration', async (t) => {
681
+ const { projectDir, typeDefDir } = t.context
682
+ await writeFile(
683
+ join(typeDefDir, 'sum.type'),
684
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
685
+ )
686
+ const { dts } = await generateTypeDef({
687
+ typeDefDir,
688
+ cwd: projectDir,
689
+ declareBindingTarget: true,
690
+ })
691
+
692
+ // what a WASI build before `__napiBindingTarget` left on disk
693
+ const preserved = `/* auto-generated by NAPI-RS */
694
+
695
+ export declare function sum(a: number, b: number): number
696
+ `
697
+ const refreshed = ensureBindingTargetDeclaration(preserved)
698
+
699
+ t.true(refreshed.startsWith(preserved))
700
+ t.is(bindingTargetDeclarationOf(refreshed), bindingTargetDeclarationOf(dts))
701
+ // a second build must not append a second declaration
702
+ t.is(ensureBindingTargetDeclaration(refreshed), refreshed)
703
+ // an already declared file is left untouched wherever it declares it
704
+ t.is(ensureBindingTargetDeclaration(dts), dts)
705
+
706
+ // a declaration file without type generation exports by assignment, which
707
+ // cannot carry a named export declaration
708
+ const exportAssignment = `declare const binding: Record<string, unknown>
709
+ export = binding
710
+ `
711
+ t.is(ensureBindingTargetDeclaration(exportAssignment), exportAssignment)
712
+ })
713
+
714
+ test('a WASI flavor declaration names only that flavor', async (t) => {
715
+ const { projectDir, typeDefDir } = t.context
716
+ await writeFile(
717
+ join(typeDefDir, 'sum.type'),
718
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
719
+ )
720
+ const { dts } = await generateTypeDef({
721
+ typeDefDir,
722
+ cwd: projectDir,
723
+ declareBindingTarget: true,
724
+ })
725
+
726
+ // a flavor's loaders report a compile-time-fixed identity, so the union the
727
+ // root entry inherits from `NAPI_RS_NATIVE_LIBRARY_PATH` is unreachable here
728
+ const threaded = ensureBindingTargetDeclaration(dts, 'wasm32-wasi')
729
+ t.is(
730
+ bindingTargetDeclarationOf(threaded),
731
+ "export declare const __napiBindingTarget: 'wasm32-wasi'",
732
+ )
733
+ const threadless = ensureBindingTargetDeclaration(dts, 'wasm32-wasip1')
734
+ t.is(
735
+ bindingTargetDeclarationOf(threadless),
736
+ "export declare const __napiBindingTarget: 'wasm32-wasip1'",
737
+ )
738
+ // narrowing replaces the inherited union instead of declaring the name twice
739
+ t.is(threaded.split('__napiBindingTarget').length - 1, 1)
740
+
741
+ // a second build must not append a second declaration
742
+ t.is(ensureBindingTargetDeclaration(threadless, 'wasm32-wasip1'), threadless)
743
+
744
+ // a preserved file that never declared it gains the flavor literal, not the
745
+ // union
746
+ const preserved = `/* auto-generated by NAPI-RS */
747
+
748
+ export declare function sum(a: number, b: number): number
749
+ `
750
+ t.is(
751
+ bindingTargetDeclarationOf(
752
+ ensureBindingTargetDeclaration(preserved, 'wasm32-wasi'),
753
+ ),
754
+ "export declare const __napiBindingTarget: 'wasm32-wasi'",
755
+ )
756
+
757
+ // the root entry keeps the union (see `NAPI_RS_NATIVE_LIBRARY_PATH`)
758
+ t.is(ensureBindingTargetDeclaration(dts), dts)
759
+
760
+ // a build without `napi-derive`'s `type-def` feature exports by assignment,
761
+ // which cannot carry a named export declaration
762
+ const exportAssignment = `declare const binding: Record<string, unknown>
763
+ export = binding
764
+ `
765
+ t.is(
766
+ ensureBindingTargetDeclaration(exportAssignment, 'wasm32-wasip1'),
767
+ exportAssignment,
768
+ )
769
+ })
770
+
771
+ test('a mention of __napiBindingTarget is not a declaration of it', (t) => {
772
+ // napi-derive copies `js_doc` into the `.d.ts` verbatim, so a doc comment can
773
+ // name the export without declaring it
774
+ const mentionOnly = `/* auto-generated by NAPI-RS */
775
+
776
+ /**
777
+ * Mirrors the loader's \`__napiBindingTarget\` export.
778
+ */
779
+ export declare function bindingTarget(): string
780
+ `
781
+ t.is(
782
+ bindingTargetDeclarationOf(
783
+ ensureBindingTargetDeclaration(mentionOnly, 'wasm32-wasip1'),
784
+ ),
785
+ "export declare const __napiBindingTarget: 'wasm32-wasip1'",
786
+ )
787
+
788
+ // `assertBindingTargetIdentFree` only rejects the exact name, so a longer
789
+ // identifier is a legal export and not this declaration
790
+ const longerIdent = `/* auto-generated by NAPI-RS */
791
+
792
+ export declare const __napiBindingTargetInfo: string
793
+ `
794
+ const refreshed = ensureBindingTargetDeclaration(longerIdent, 'wasm32-wasi')
795
+ t.true(refreshed.startsWith(longerIdent))
796
+ t.true(
797
+ refreshed.includes(
798
+ "export declare const __napiBindingTarget: 'wasm32-wasi'\n",
799
+ ),
800
+ )
801
+ })
802
+
803
+ test('a fresh WASI declaration narrows the inherited root union', async (t) => {
804
+ const { projectDir, typeDefDir } = t.context
805
+ await writeFile(
806
+ join(typeDefDir, 'sum.type'),
807
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
808
+ )
809
+ const { dts } = await generateTypeDef({
810
+ typeDefDir,
811
+ cwd: projectDir,
812
+ declareBindingTarget: true,
813
+ })
814
+
815
+ // the fresh path derives the flavor declaration from the root `index.d.ts`,
816
+ // which carries the union
817
+ const prepared = prepareWasiBindingTypeDef(
818
+ dts,
819
+ join(projectDir, 'index.d.ts'),
820
+ join(projectDir, 'pkg.wasip1.d.cts'),
821
+ false,
822
+ )
823
+ t.is(
824
+ bindingTargetDeclarationOf(
825
+ ensureBindingTargetDeclaration(prepared, 'wasm32-wasip1'),
826
+ ),
827
+ "export declare const __napiBindingTarget: 'wasm32-wasip1'",
828
+ )
829
+ })
830
+
831
+ const ROOT_BINDING_TARGET_DECLARATION =
832
+ "export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'"
833
+
834
+ test('a crate without type defs still declares the binding target', async (t) => {
835
+ const { projectDir, typeDefDir } = t.context
836
+
837
+ // A crate can register every export from a `#[napi(module_exports)]` hook and
838
+ // emit no `.type` file at all. A loader is still written for it, and that
839
+ // loader still exports `__napiBindingTarget`.
840
+ const { exports, dts, dtsWithTypeImports } = await generateTypeDef({
841
+ typeDefDir,
842
+ cwd: projectDir,
843
+ declareBindingTarget: true,
844
+ })
845
+
846
+ t.is(exports.length, 0)
847
+ t.is(bindingTargetDeclarationOf(dts), ROOT_BINDING_TARGET_DECLARATION)
848
+ t.true(dts.startsWith('/* auto-generated by NAPI-RS */'))
849
+ t.is(dtsWithTypeImports, dts)
850
+ })
851
+
852
+ test('a missing type def directory still declares the binding target', async (t) => {
853
+ const { projectDir, typeDefDir } = t.context
854
+
855
+ const { exports, dts, dtsWithTypeImports } = await generateTypeDef({
856
+ typeDefDir: join(typeDefDir, 'missing'),
857
+ cwd: projectDir,
858
+ declareBindingTarget: true,
859
+ })
860
+
861
+ t.is(exports.length, 0)
862
+ t.is(bindingTargetDeclarationOf(dts), ROOT_BINDING_TARGET_DECLARATION)
863
+ t.true(dts.startsWith('/* auto-generated by NAPI-RS */'))
864
+ t.is(dtsWithTypeImports, dts)
865
+ })
866
+
867
+ test('a build that emits no loader declares nothing without type defs', async (t) => {
868
+ const { projectDir, typeDefDir } = t.context
869
+
870
+ const empty = await generateTypeDef({ typeDefDir, cwd: projectDir })
871
+ t.is(empty.dts, '')
872
+ t.is(empty.dtsWithTypeImports, '')
873
+
874
+ const missing = await generateTypeDef({
875
+ typeDefDir: join(typeDefDir, 'missing'),
876
+ cwd: projectDir,
877
+ })
878
+ t.is(missing.dts, '')
879
+ t.is(missing.dtsWithTypeImports, '')
880
+ })
881
+
882
+ test('the declaration-only type def honours the header options', async (t) => {
883
+ const { projectDir, typeDefDir } = t.context
884
+
885
+ const { dts: headerless } = await generateTypeDef({
886
+ typeDefDir,
887
+ cwd: projectDir,
888
+ declareBindingTarget: true,
889
+ noDtsHeader: true,
890
+ })
891
+ t.false(headerless.includes('/* auto-generated by NAPI-RS */'))
892
+ t.is(bindingTargetDeclarationOf(headerless), ROOT_BINDING_TARGET_DECLARATION)
893
+
894
+ const { dts: custom } = await generateTypeDef({
895
+ typeDefDir,
896
+ cwd: projectDir,
897
+ declareBindingTarget: true,
898
+ dtsHeader: '/* custom */\n',
899
+ })
900
+ t.true(custom.startsWith('/* custom */\n'))
901
+ t.is(bindingTargetDeclarationOf(custom), ROOT_BINDING_TARGET_DECLARATION)
902
+ })
903
+
904
+ const countBindingTargetDeclarations = (source: string) =>
905
+ source
906
+ .split('\n')
907
+ .filter((line) =>
908
+ line.startsWith('export declare const __napiBindingTarget'),
909
+ ).length
910
+
911
+ test('a header that already declares the binding target is not declared twice', async (t) => {
912
+ const { projectDir, typeDefDir } = t.context
913
+
914
+ // a project that worked around loaders predating the export by declaring it
915
+ // in its own header; a second declaration beside it is a TS2451 redeclaration
916
+ const headerDeclaration =
917
+ "export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'"
918
+ const dtsHeader = `/* auto-generated by NAPI-RS */
919
+
920
+ ${headerDeclaration}
921
+ `
922
+
923
+ await writeFile(
924
+ join(typeDefDir, 'sum.type'),
925
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
926
+ )
927
+
928
+ const { dts, dtsWithTypeImports, header } = await generateTypeDef({
929
+ typeDefDir,
930
+ cwd: projectDir,
931
+ declareBindingTarget: true,
932
+ dtsHeader,
933
+ })
934
+ t.is(countBindingTargetDeclarations(dts), 1)
935
+ t.is(countBindingTargetDeclarations(dtsWithTypeImports), 1)
936
+ t.is(bindingTargetDeclarationOf(dts), headerDeclaration)
937
+ // the rendered header comes back, so the WASI writer can tell a declaration
938
+ // the header owns from one this build appended
939
+ t.is(header, dtsHeader)
940
+
941
+ // the same header with no `.type` files at all: the declaration-only path
942
+ const emptyTypeDefDir = join(projectDir, 'empty-type-defs')
943
+ await mkdir(emptyTypeDefDir, { recursive: true })
944
+ const { dts: declarationOnly } = await generateTypeDef({
945
+ typeDefDir: emptyTypeDefDir,
946
+ cwd: projectDir,
947
+ declareBindingTarget: true,
948
+ dtsHeader,
949
+ })
950
+ t.is(countBindingTargetDeclarations(declarationOnly), 1)
951
+
952
+ // every route a header reaches the build through
953
+ const headerFile = 'napi-header.d.ts'
954
+ await writeFile(join(projectDir, headerFile), dtsHeader)
955
+ const { dts: fromFile } = await generateTypeDef({
956
+ typeDefDir,
957
+ cwd: projectDir,
958
+ declareBindingTarget: true,
959
+ dtsHeaderFile: headerFile,
960
+ })
961
+ t.is(countBindingTargetDeclarations(fromFile), 1)
962
+
963
+ const { dts: fromConfig } = await generateTypeDef({
964
+ typeDefDir,
965
+ cwd: projectDir,
966
+ declareBindingTarget: true,
967
+ configDtsHeader: dtsHeader,
968
+ })
969
+ t.is(countBindingTargetDeclarations(fromConfig), 1)
970
+
971
+ // the WASI flavor file derived from that root declaration keeps the one
972
+ // declaration instead of gaining a second with a conflicting type
973
+ const wasi = ensureBindingTargetDeclaration(
974
+ prepareWasiBindingTypeDef(
975
+ dts,
976
+ join(projectDir, 'index.d.ts'),
977
+ join(projectDir, 'pkg.wasip1.d.cts'),
978
+ false,
979
+ ),
980
+ 'wasm32-wasip1',
981
+ dtsHeader,
982
+ )
983
+ t.is(countBindingTargetDeclarations(wasi), 1)
984
+ t.is(bindingTargetDeclarationOf(wasi), headerDeclaration)
985
+
986
+ // a header that declares nothing still gains the union
987
+ const { dts: generated, header: defaultHeader } = await generateTypeDef({
988
+ typeDefDir,
989
+ cwd: projectDir,
990
+ declareBindingTarget: true,
991
+ })
992
+ t.is(bindingTargetDeclarationOf(generated), ROOT_BINDING_TARGET_DECLARATION)
993
+ t.is(countBindingTargetDeclarations(generated), 1)
994
+ t.true(defaultHeader.startsWith('/* auto-generated by NAPI-RS */'))
995
+
996
+ const { header: headerless } = await generateTypeDef({
997
+ typeDefDir,
998
+ cwd: projectDir,
999
+ declareBindingTarget: true,
1000
+ noDtsHeader: true,
1001
+ })
1002
+ t.is(headerless, '')
1003
+ })
1004
+
1005
+ test('an export-assignment header is left alone without type defs', async (t) => {
1006
+ const { tmpDir, projectDir, typeDefDir } = t.context
1007
+
1008
+ // What a package whose header describes the whole module by assignment ships
1009
+ // — the form a build without `napi-derive`'s `type-def` feature emits. An
1010
+ // export assignment cannot sit beside a named export (TS2309), so the
1011
+ // declaration this build would otherwise append has nowhere to go, and the
1012
+ // export it describes is unreachable through `export =` anyway. The same
1013
+ // rule `ensureBindingTargetDeclaration` applies to the derived WASI file.
1014
+ //
1015
+ // Kept out of the export-form table below: every row there is generated with
1016
+ // a `.type` file, and the generated body is itself a named export, so an
1017
+ // `export =` row would carry the header's own TS2309 rather than this
1018
+ // branch's — and that table's consumer imports a named export the form has
1019
+ // none of.
1020
+ const dtsHeader = `declare const binding: { foo(): number }
1021
+ export = binding
1022
+ `
1023
+
1024
+ const { exports, dts, dtsWithTypeImports, header } = await generateTypeDef({
1025
+ typeDefDir,
1026
+ cwd: projectDir,
1027
+ declareBindingTarget: true,
1028
+ dtsHeader,
1029
+ })
1030
+
1031
+ t.is(exports.length, 0)
1032
+ t.is(header, dtsHeader)
1033
+ t.true(dts.startsWith(dtsHeader))
1034
+ t.is(dts.trim(), dtsHeader.trim())
1035
+ t.deepEqual(exportedBindingTargetTypes(dts), [])
1036
+ t.is(countBindingTargetDeclarations(dts), 0)
1037
+ t.is(dtsWithTypeImports, dts)
1038
+
1039
+ const directory = join(tmpDir, 'export-assignment-header')
1040
+ const flavor = ensureBindingTargetDeclaration(
1041
+ prepareWasiBindingTypeDef(
1042
+ dts,
1043
+ join(directory, 'index.d.ts'),
1044
+ join(directory, 'flavor.d.cts'),
1045
+ false,
1046
+ ),
1047
+ 'wasm32-wasip1',
1048
+ header,
1049
+ )
1050
+ t.true(flavor.startsWith(dtsHeader))
1051
+ t.deepEqual(exportedBindingTargetTypes(flavor), [])
1052
+
1053
+ // TypeScript's own verdict: what this build wrote is worth exactly what the
1054
+ // header alone is worth, which is nothing at all.
1055
+ const codes = await semanticDiagnosticCodes(directory, {
1056
+ 'baseline.d.ts': dtsHeader,
1057
+ 'index.d.ts': dts,
1058
+ 'flavor.d.cts': flavor,
1059
+ })
1060
+ t.deepEqual(codes['baseline.d.ts'], [])
1061
+ t.deepEqual(codes['index.d.ts'], [])
1062
+ t.deepEqual(codes['flavor.d.cts'], [])
1063
+ })
1064
+
1065
+ test('a stale generated binding target declaration is refreshed, a custom one is preserved', (t) => {
1066
+ const preamble = '/* auto-generated by NAPI-RS */\n'
1067
+
1068
+ // what an earlier version of this branch wrote, before `wasm32-wasip1`
1069
+ const stale = `${preamble}
1070
+ /** The artifact this loader loaded. */
1071
+ export declare const __napiBindingTarget: 'native' | 'wasm32-wasi'
1072
+
1073
+ export declare function sum(a: number, b: number): number
1074
+ `
1075
+ const refreshedRoot = ensureBindingTargetDeclaration(stale)
1076
+ t.is(
1077
+ bindingTargetDeclarationOf(refreshedRoot),
1078
+ ROOT_BINDING_TARGET_DECLARATION,
1079
+ )
1080
+ t.is(countBindingTargetDeclarations(refreshedRoot), 1)
1081
+ // the stale block's doc comment is replaced with it, not stranded above it
1082
+ t.false(refreshedRoot.includes('The artifact this loader loaded.'))
1083
+
1084
+ const refreshedFlavor = ensureBindingTargetDeclaration(stale, 'wasm32-wasip1')
1085
+ t.is(
1086
+ bindingTargetDeclarationOf(refreshedFlavor),
1087
+ "export declare const __napiBindingTarget: 'wasm32-wasip1'",
1088
+ )
1089
+ t.is(countBindingTargetDeclarations(refreshedFlavor), 1)
1090
+
1091
+ // a file kept from a build of the other flavor now types this one
1092
+ const wrongFlavor = `${preamble}
1093
+ /** The WASI flavor this loader instantiates. */
1094
+ export declare const __napiBindingTarget: 'wasm32-wasi'
1095
+ `
1096
+ t.is(
1097
+ bindingTargetDeclarationOf(
1098
+ ensureBindingTargetDeclaration(wrongFlavor, 'wasm32-wasip1'),
1099
+ ),
1100
+ "export declare const __napiBindingTarget: 'wasm32-wasip1'",
1101
+ )
1102
+
1103
+ // a type this CLI could never have written belongs to whoever wrote it
1104
+ const custom = `${preamble}
1105
+ export declare const __napiBindingTarget: string
1106
+ `
1107
+ t.is(ensureBindingTargetDeclaration(custom, 'wasm32-wasip1'), custom)
1108
+ t.is(ensureBindingTargetDeclaration(custom), custom)
1109
+
1110
+ // and neither is a declaration the build's own header owns, even though it
1111
+ // is spelled out of the literals this CLI writes
1112
+ const headerOwned = `${preamble}
1113
+ export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'
1114
+ `
1115
+ t.is(
1116
+ ensureBindingTargetDeclaration(headerOwned, 'wasm32-wasip1', headerOwned),
1117
+ headerOwned,
1118
+ )
1119
+
1120
+ // refreshing is a fixed point
1121
+ t.is(
1122
+ ensureBindingTargetDeclaration(refreshedFlavor, 'wasm32-wasip1'),
1123
+ refreshedFlavor,
1124
+ )
1125
+ t.is(ensureBindingTargetDeclaration(refreshedRoot), refreshedRoot)
1126
+ })
1127
+
1128
+ const ROOT_BINDING_TARGET_TYPE = "'native' | 'wasm32-wasi' | 'wasm32-wasip1'"
1129
+
1130
+ /**
1131
+ * The type of every `__napiBindingTarget` a consumer of `source` can import,
1132
+ * in source order, read back with `oxc-parser` — a different parser from the
1133
+ * one `build.ts` uses, so these assertions cannot agree with the code by
1134
+ * sharing its idea of what a declaration is.
1135
+ */
1136
+ const exportedBindingTargetTypes = (source: string) => {
1137
+ const { program } = parseSync('index.d.ts', source)
1138
+ const types: string[] = []
1139
+ for (const node of program.body) {
1140
+ if (
1141
+ node.type !== 'ExportNamedDeclaration' ||
1142
+ node.declaration?.type !== 'VariableDeclaration'
1143
+ ) {
1144
+ continue
1145
+ }
1146
+ for (const declaration of node.declaration.declarations) {
1147
+ if (
1148
+ declaration.id.type !== 'Identifier' ||
1149
+ declaration.id.name !== '__napiBindingTarget'
1150
+ ) {
1151
+ continue
1152
+ }
1153
+ const annotation = declaration.id.typeAnnotation
1154
+ types.push(
1155
+ annotation
1156
+ ? source.slice(
1157
+ annotation.typeAnnotation.start,
1158
+ annotation.typeAnnotation.end,
1159
+ )
1160
+ : '',
1161
+ )
1162
+ }
1163
+ }
1164
+ return types
1165
+ }
1166
+
1167
+ test('a header-owned binding target declaration survives the WASI transforms', async (t) => {
1168
+ const { projectDir, typeDefDir } = t.context
1169
+ await writeFile(
1170
+ join(typeDefDir, 'sum.type'),
1171
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
1172
+ )
1173
+
1174
+ // A header that declares the export itself and imports its own types
1175
+ // relatively. `--dts types/index.d.ts` puts the derived `.d.cts` in another
1176
+ // directory, so `prepareWasiBindingTypeDef` rebases that specifier and the
1177
+ // header stops being a literal prefix of the file derived from it — the
1178
+ // declaration it owns must still be recognised as its own.
1179
+ const rebasedHeader = `/* auto-generated by NAPI-RS */
1180
+
1181
+ import type { Thing } from './thing.js'
1182
+
1183
+ ${ROOT_BINDING_TARGET_DECLARATION}
1184
+ export declare function useThing(thing: Thing): void
1185
+ `
1186
+ const { dts: rebasedRoot } = await generateTypeDef({
1187
+ typeDefDir,
1188
+ cwd: projectDir,
1189
+ declareBindingTarget: true,
1190
+ dtsHeader: rebasedHeader,
1191
+ })
1192
+ const rebased = ensureBindingTargetDeclaration(
1193
+ prepareWasiBindingTypeDef(
1194
+ rebasedRoot,
1195
+ join(projectDir, 'types', 'index.d.ts'),
1196
+ join(projectDir, 'pkg.wasip1.d.cts'),
1197
+ false,
1198
+ ),
1199
+ 'wasm32-wasip1',
1200
+ rebasedHeader,
1201
+ )
1202
+ t.true(rebased.includes("from './types/thing.js'"))
1203
+ t.deepEqual(exportedBindingTargetTypes(rebased), [ROOT_BINDING_TARGET_TYPE])
1204
+
1205
+ // The threadless flavor drops `node:stream/web` type imports so the DOM
1206
+ // globals can take over, which moves the header's declaration just the same.
1207
+ const streamHeader = `/* auto-generated by NAPI-RS */
1208
+
1209
+ import type { ReadableStream } from 'node:stream/web'
1210
+
1211
+ ${ROOT_BINDING_TARGET_DECLARATION}
1212
+ export declare function readAll(stream: ReadableStream): void
1213
+ `
1214
+ const { dts: streamRoot } = await generateTypeDef({
1215
+ typeDefDir,
1216
+ cwd: projectDir,
1217
+ declareBindingTarget: true,
1218
+ dtsHeader: streamHeader,
1219
+ })
1220
+ const threadless = ensureBindingTargetDeclaration(
1221
+ prepareWasiBindingTypeDef(
1222
+ streamRoot,
1223
+ join(projectDir, 'index.d.ts'),
1224
+ join(projectDir, 'pkg.wasip1.d.cts'),
1225
+ false,
1226
+ ),
1227
+ 'wasm32-wasip1',
1228
+ streamHeader,
1229
+ )
1230
+ t.false(threadless.includes('node:stream/web'))
1231
+ t.deepEqual(exportedBindingTargetTypes(threadless), [
1232
+ ROOT_BINDING_TARGET_TYPE,
1233
+ ])
1234
+ })
1235
+
1236
+ test('only a top-level export declares the binding target', async (t) => {
1237
+ const { projectDir, typeDefDir } = t.context
1238
+ await writeFile(
1239
+ join(typeDefDir, 'sum.type'),
1240
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
1241
+ )
1242
+
1243
+ // A commented-out example of the declaration exports nothing, and a member
1244
+ // of a `declare namespace` or `declare module` block is an export of that
1245
+ // block, not of the file. Taking any of them for the declaration leaves the
1246
+ // generated one unwritten and `import { __napiBindingTarget }` at TS2305.
1247
+ const headers = {
1248
+ 'a commented-out example': `/* auto-generated by NAPI-RS */
1249
+ /* Example:
1250
+ ${ROOT_BINDING_TARGET_DECLARATION}
1251
+ */
1252
+ `,
1253
+ 'a nested namespace member': `/* auto-generated by NAPI-RS */
1254
+ declare namespace Legacy {
1255
+ export const __napiBindingTarget: string
1256
+ }
1257
+ `,
1258
+ 'a module augmentation member': `/* auto-generated by NAPI-RS */
1259
+ declare module 'legacy' {
1260
+ export const __napiBindingTarget: string
1261
+ }
1262
+ `,
1263
+ }
1264
+
1265
+ for (const [what, dtsHeader] of Object.entries(headers)) {
1266
+ const { dts } = await generateTypeDef({
1267
+ typeDefDir,
1268
+ cwd: projectDir,
1269
+ declareBindingTarget: true,
1270
+ dtsHeader,
1271
+ })
1272
+ t.deepEqual(
1273
+ exportedBindingTargetTypes(dts),
1274
+ [ROOT_BINDING_TARGET_TYPE],
1275
+ `${what} still owes the root declaration`,
1276
+ )
1277
+
1278
+ const wasi = ensureBindingTargetDeclaration(
1279
+ prepareWasiBindingTypeDef(
1280
+ dts,
1281
+ join(projectDir, 'index.d.ts'),
1282
+ join(projectDir, 'pkg.wasip1.d.cts'),
1283
+ false,
1284
+ ),
1285
+ 'wasm32-wasip1',
1286
+ dtsHeader,
1287
+ )
1288
+ t.deepEqual(
1289
+ exportedBindingTargetTypes(wasi),
1290
+ ["'wasm32-wasip1'"],
1291
+ `${what} still owes the flavor declaration`,
1292
+ )
1293
+ // whatever the header said is left exactly as it wrote it
1294
+ t.true(wasi.startsWith(dtsHeader), what)
1295
+ }
1296
+
1297
+ // The refresh is held to the same rule: a commented-out example of a
1298
+ // declaration this CLI writes is not a declaration to rewrite.
1299
+ const commentedStale = `/* auto-generated by NAPI-RS */
1300
+
1301
+ /* an earlier build wrote:
1302
+ export declare const __napiBindingTarget: 'native' | 'wasm32-wasi'
1303
+ */
1304
+
1305
+ export declare function sum(a: number, b: number): number
1306
+ `
1307
+ const refreshed = ensureBindingTargetDeclaration(
1308
+ commentedStale,
1309
+ 'wasm32-wasip1',
1310
+ )
1311
+ t.true(refreshed.startsWith(commentedStale))
1312
+ t.deepEqual(exportedBindingTargetTypes(refreshed), ["'wasm32-wasip1'"])
1313
+ })
1314
+
1315
+ /**
1316
+ * Syntax errors in a generated declaration file, read back with `oxc-parser`.
1317
+ * A refresh splices text into a file the CLI did not write all of, so what it
1318
+ * leaves behind has to still parse.
1319
+ */
1320
+ const declarationDiagnostics = (source: string) =>
1321
+ parseSync('index.d.ts', source).errors.map((error) => error.message)
1322
+
1323
+ test('a multi-declarator binding target statement is never rewritten', async (t) => {
1324
+ const { projectDir, typeDefDir } = t.context
1325
+
1326
+ // A statement that declares more names than this one is not one this CLI
1327
+ // wrote — it never writes a sibling — and the block a refresh replaces spans
1328
+ // them, so narrowing it would drop `keepMe` and hand every consumer of that
1329
+ // name a TS2305.
1330
+ const preserved = `/* auto-generated by NAPI-RS */
1331
+
1332
+ export declare const __napiBindingTarget: 'native', keepMe: number
1333
+
1334
+ export declare function sum(a: number, b: number): number
1335
+ `
1336
+ t.is(ensureBindingTargetDeclaration(preserved, 'wasm32-wasip1'), preserved)
1337
+ t.is(ensureBindingTargetDeclaration(preserved), preserved)
1338
+ // it still counts as declared, so nothing is appended beside it either
1339
+ t.deepEqual(exportedBindingTargetTypes(preserved), ["'native'"])
1340
+
1341
+ // A header that declares a sibling whose type carries a relative inline
1342
+ // import. `prepareWasiBindingTypeDef` rebases that specifier, so the
1343
+ // statement stops reading as the header wrote it — the declaration itself
1344
+ // still does, which is what decides ownership.
1345
+ await writeFile(
1346
+ join(typeDefDir, 'sum.type'),
1347
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
1348
+ )
1349
+ const dtsHeader = `/* auto-generated by NAPI-RS */
1350
+
1351
+ export declare const __napiBindingTarget: ${ROOT_BINDING_TARGET_TYPE},
1352
+ thing: import('./thing.js').Thing
1353
+ `
1354
+ const { dts } = await generateTypeDef({
1355
+ typeDefDir,
1356
+ cwd: projectDir,
1357
+ declareBindingTarget: true,
1358
+ dtsHeader,
1359
+ })
1360
+ const wasi = ensureBindingTargetDeclaration(
1361
+ prepareWasiBindingTypeDef(
1362
+ dts,
1363
+ join(projectDir, 'types', 'index.d.ts'),
1364
+ join(projectDir, 'pkg.wasip1.d.cts'),
1365
+ false,
1366
+ ),
1367
+ 'wasm32-wasip1',
1368
+ dtsHeader,
1369
+ )
1370
+ t.true(wasi.includes("thing: import('./types/thing.js').Thing"))
1371
+ t.deepEqual(exportedBindingTargetTypes(wasi), [ROOT_BINDING_TARGET_TYPE])
1372
+ t.deepEqual(declarationDiagnostics(wasi), [])
1373
+ })
1374
+
1375
+ test('a refreshed binding target declaration keeps the statement terminator', (t) => {
1376
+ // The replaced span ends past the statement's own `;`, so a replacement that
1377
+ // drops it runs whatever followed on the same line into the declaration.
1378
+ const sameLine = `/* auto-generated by NAPI-RS */
1379
+
1380
+ export declare const __napiBindingTarget: 'native'; export declare function keepMe(): void;
1381
+ `
1382
+ const refreshed = ensureBindingTargetDeclaration(sameLine, 'wasm32-wasip1')
1383
+ t.deepEqual(declarationDiagnostics(refreshed), [])
1384
+ t.deepEqual(exportedBindingTargetTypes(refreshed), ["'wasm32-wasip1'"])
1385
+ t.true(refreshed.includes('export declare function keepMe(): void;'))
1386
+
1387
+ // and a declaration with no doc comment above it keeps its terminator too
1388
+ const ownLine = `/* auto-generated by NAPI-RS */
1389
+
1390
+ export declare const __napiBindingTarget: 'native';
1391
+ export declare function keepMe(): void;
1392
+ `
1393
+ const refreshedOwnLine = ensureBindingTargetDeclaration(
1394
+ ownLine,
1395
+ 'wasm32-wasip1',
1396
+ )
1397
+ t.deepEqual(declarationDiagnostics(refreshedOwnLine), [])
1398
+ t.deepEqual(exportedBindingTargetTypes(refreshedOwnLine), ["'wasm32-wasip1'"])
1399
+ t.true(
1400
+ refreshedOwnLine.includes(
1401
+ "/** The WASI flavor this loader instantiates. */\nexport declare const __napiBindingTarget: 'wasm32-wasip1';\n",
1402
+ ),
1403
+ )
1404
+ })
1405
+
1406
+ test('a header that re-exports the binding target under the name owns it', async (t) => {
1407
+ const { projectDir, typeDefDir } = t.context
1408
+ await writeFile(
1409
+ join(typeDefDir, 'sum.type'),
1410
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
1411
+ )
1412
+
1413
+ // Declaring the name and exporting it are two statements in these headers,
1414
+ // so there is no `export declare const` to find — but a consumer imports the
1415
+ // name all the same, and a second export of it is a TS2323/TS2484 conflict.
1416
+ // Neither form leaves a declaration this build could rewrite, so both are
1417
+ // preserved exactly as written.
1418
+ const headers = {
1419
+ 'an aliased re-export': `/* auto-generated by NAPI-RS */
1420
+
1421
+ declare const target: ${ROOT_BINDING_TARGET_TYPE}
1422
+ export { target as __napiBindingTarget }
1423
+ `,
1424
+ 'a plain re-export': `/* auto-generated by NAPI-RS */
1425
+
1426
+ declare const __napiBindingTarget: ${ROOT_BINDING_TARGET_TYPE}
1427
+ export { __napiBindingTarget }
1428
+ `,
1429
+ }
1430
+
1431
+ for (const [what, dtsHeader] of Object.entries(headers)) {
1432
+ const { dts } = await generateTypeDef({
1433
+ typeDefDir,
1434
+ cwd: projectDir,
1435
+ declareBindingTarget: true,
1436
+ dtsHeader,
1437
+ })
1438
+ t.true(dts.startsWith(dtsHeader), what)
1439
+ t.deepEqual(exportedBindingTargetTypes(dts), [], what)
1440
+ t.false(dts.includes('export declare const __napiBindingTarget'), what)
1441
+ t.deepEqual(declarationDiagnostics(dts), [], what)
1442
+
1443
+ // and the flavor file derived from it gains nothing either
1444
+ const wasi = ensureBindingTargetDeclaration(
1445
+ prepareWasiBindingTypeDef(
1446
+ dts,
1447
+ join(projectDir, 'index.d.ts'),
1448
+ join(projectDir, 'pkg.wasip1.d.cts'),
1449
+ false,
1450
+ ),
1451
+ 'wasm32-wasip1',
1452
+ dtsHeader,
1453
+ )
1454
+ t.false(wasi.includes('export declare const __napiBindingTarget'), what)
1455
+ t.deepEqual(declarationDiagnostics(wasi), [], what)
1456
+ }
1457
+ })
1458
+
1459
+ test('a commented-out export assignment is not an export assignment', (t) => {
1460
+ // A declaration file that really exports by assignment carries no named
1461
+ // export, so nothing is declared in it — but documentation showing that form
1462
+ // inside a block comment must not make an ordinary file look like one.
1463
+ const commented = `/* auto-generated by NAPI-RS */
1464
+ /*
1465
+ export = binding
1466
+ */
1467
+
1468
+ /** The artifact this loader loaded. */
1469
+ export declare const __napiBindingTarget: ${ROOT_BINDING_TARGET_TYPE}
1470
+
1471
+ export declare function sum(a: number, b: number): number
1472
+ `
1473
+ const refreshed = ensureBindingTargetDeclaration(commented, 'wasm32-wasip1')
1474
+ t.deepEqual(exportedBindingTargetTypes(refreshed), ["'wasm32-wasip1'"])
1475
+ t.true(refreshed.includes('export = binding'))
1476
+ t.deepEqual(declarationDiagnostics(refreshed), [])
1477
+
1478
+ // the same comment in a file that declares nothing still owes a declaration
1479
+ const undeclared = `/* auto-generated by NAPI-RS */
1480
+ /*
1481
+ export = binding
1482
+ */
1483
+
1484
+ export declare function sum(a: number, b: number): number
1485
+ `
1486
+ t.deepEqual(
1487
+ exportedBindingTargetTypes(
1488
+ ensureBindingTargetDeclaration(undeclared, 'wasm32-wasip1'),
1489
+ ),
1490
+ ["'wasm32-wasip1'"],
1491
+ )
1492
+
1493
+ // a real top-level export assignment is still left alone
1494
+ const exportAssignment = `declare const binding: Record<string, unknown>
1495
+ export = binding
1496
+ `
1497
+ t.is(
1498
+ ensureBindingTargetDeclaration(exportAssignment, 'wasm32-wasip1'),
1499
+ exportAssignment,
1500
+ )
1501
+ // `export default` is the same node with a different flag and is not one
1502
+ const exportDefault = `/* auto-generated by NAPI-RS */
1503
+
1504
+ declare const binding: Record<string, unknown>
1505
+ export default binding
1506
+ `
1507
+ t.deepEqual(
1508
+ exportedBindingTargetTypes(
1509
+ ensureBindingTargetDeclaration(exportDefault, 'wasm32-wasip1'),
1510
+ ),
1511
+ ["'wasm32-wasip1'"],
1512
+ )
1513
+ })
1514
+
1515
+ const SEMANTIC_CHECK_OPTIONS: ts.CompilerOptions = {
1516
+ strict: true,
1517
+ noEmit: true,
1518
+ // `.d.ts` files are the ones under test, and `skipLibCheck` would skip every
1519
+ // one of them — including this file. Only the default lib is skipped.
1520
+ skipLibCheck: false,
1521
+ skipDefaultLibCheck: true,
1522
+ target: ts.ScriptTarget.ESNext,
1523
+ module: ts.ModuleKind.ESNext,
1524
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1525
+ }
1526
+
1527
+ // One host for every check, so the default lib is read and parsed once instead
1528
+ // of once per row.
1529
+ const semanticCheckHost = (() => {
1530
+ const host = ts.createCompilerHost(SEMANTIC_CHECK_OPTIONS, true)
1531
+ const readSourceFile = host.getSourceFile.bind(host)
1532
+ const libraryFiles = new Map<string, ts.SourceFile | undefined>()
1533
+ host.getSourceFile = (fileName, ...rest) => {
1534
+ if (!fileName.includes('/typescript/lib/')) {
1535
+ return readSourceFile(fileName, ...rest)
1536
+ }
1537
+ if (!libraryFiles.has(fileName)) {
1538
+ libraryFiles.set(fileName, readSourceFile(fileName, ...rest))
1539
+ }
1540
+ return libraryFiles.get(fileName)
1541
+ }
1542
+ return host
1543
+ })()
1544
+
1545
+ /**
1546
+ * The module the re-export rows below import from. It exports the binding
1547
+ * target name itself, so `export { __napiBindingTarget } from './other.js'`
1548
+ * has something to re-export.
1549
+ */
1550
+ const SEMANTIC_CHECK_SIBLING = `export declare const thing: 'native'
1551
+ export type Thing = string
1552
+ export declare const __napiBindingTarget: 'native'
1553
+ `
1554
+
1555
+ /**
1556
+ * TypeScript's own verdict on a set of declaration files written side by side,
1557
+ * as a list of codes per file. Every conflict an extra `__napiBindingTarget`
1558
+ * export causes — TS2300, TS2323, TS2440, TS2567 — is semantic, so
1559
+ * `oxc-parser`'s syntax diagnostics cannot see any of them, and so is the
1560
+ * TS2693 a consumer gets when the export it imports turns out to be a type.
1561
+ */
1562
+ const semanticDiagnosticCodes = async (
1563
+ directory: string,
1564
+ files: Record<string, string>,
1565
+ ) => {
1566
+ await mkdir(directory, { recursive: true })
1567
+ await writeFile(join(directory, 'other.d.ts'), SEMANTIC_CHECK_SIBLING)
1568
+ await Promise.all(
1569
+ Object.entries(files).map(([name, source]) =>
1570
+ writeFile(join(directory, name), source),
1571
+ ),
1572
+ )
1573
+ const program = ts.createProgram({
1574
+ rootNames: Object.keys(files).map((name) => join(directory, name)),
1575
+ options: SEMANTIC_CHECK_OPTIONS,
1576
+ host: semanticCheckHost,
1577
+ })
1578
+
1579
+ const diagnostics = ts.getPreEmitDiagnostics(program)
1580
+ return Object.fromEntries(
1581
+ Object.keys(files).map((name) => [
1582
+ name,
1583
+ diagnostics
1584
+ .filter(
1585
+ (diagnostic) =>
1586
+ diagnostic.file?.fileName ===
1587
+ join(directory, name).replaceAll(win32Sep, '/'),
1588
+ )
1589
+ .map((diagnostic) => `TS${diagnostic.code}`),
1590
+ ]),
1591
+ )
1592
+ }
1593
+
1594
+ /**
1595
+ * `__napiBindingTarget` with its final `t` written as a unicode escape. A
1596
+ * TypeScript identifier may spell any character that way, and the checker
1597
+ * resolves it to the same name — a search of the source text does not.
1598
+ */
1599
+ const ESCAPED_BINDING_TARGET = '__napiBindingTarge\\u0074'
1600
+
1601
+ /** The same name, spelled plainly. */
1602
+ const BINDING_TARGET_NAME = '__napiBindingTarget'
1603
+
1604
+ /** What makes a header with no export of its own a module. */
1605
+ const LOCAL_MODULE_MARKER = 'export declare const marker: number'
1606
+
1607
+ /** A namespace the rows below alias a member out of. */
1608
+ const LEGACY_NAMESPACE = `declare namespace Legacy {
1609
+ const thing: 'native'
1610
+ }
1611
+ `
1612
+
1613
+ /**
1614
+ * Every shape a `--dts-header` can export `__napiBindingTarget` through, and
1615
+ * whether that shape claims the name.
1616
+ *
1617
+ * `owns: true` leaves no room for a generated `export declare const` beside it
1618
+ * — TypeScript rejects the pair — so nothing is appended and the header comes
1619
+ * through untouched. `owns: false` is a declaration that lives only in type
1620
+ * space: the const merges with it, and suppressing it there would take typed
1621
+ * access to a real runtime export away (TS2693 at every value use) while
1622
+ * preventing no collision at all.
1623
+ *
1624
+ * `hasType` rows name a type as well, so the consumer below also uses the
1625
+ * import as one.
1626
+ */
1627
+ const BINDING_TARGET_EXPORT_FORMS: Array<{
1628
+ label: string
1629
+ body: string
1630
+ owns: boolean
1631
+ hasType?: boolean
1632
+ /**
1633
+ * What the consumer of the generated files should report. Defaults to the
1634
+ * header's own verdict when the header owns the name and to nothing when the
1635
+ * declaration is written; a row sets it only where the header alone is not a
1636
+ * comparable module.
1637
+ */
1638
+ consumer?: 'baseline' | 'clean'
1639
+ }> = [
1640
+ {
1641
+ label: 'export declare const',
1642
+ body: "export declare const __napiBindingTarget: 'native'",
1643
+ owns: true,
1644
+ },
1645
+ {
1646
+ label: 'export declare let',
1647
+ body: "export declare let __napiBindingTarget: 'native'",
1648
+ owns: true,
1649
+ },
1650
+ {
1651
+ label: 'export declare var',
1652
+ body: "export declare var __napiBindingTarget: 'native'",
1653
+ owns: true,
1654
+ },
1655
+ {
1656
+ label: 'a multi-declarator statement',
1657
+ body: "export declare const __napiBindingTarget: 'native', keepMe: number",
1658
+ owns: true,
1659
+ },
1660
+ {
1661
+ label: 'export declare function',
1662
+ body: 'export declare function __napiBindingTarget(): void',
1663
+ owns: true,
1664
+ },
1665
+ {
1666
+ label: 'export declare class',
1667
+ body: 'export declare class __napiBindingTarget {}',
1668
+ owns: true,
1669
+ hasType: true,
1670
+ },
1671
+ {
1672
+ label: 'export declare abstract class',
1673
+ body: 'export declare abstract class __napiBindingTarget {}',
1674
+ owns: true,
1675
+ hasType: true,
1676
+ },
1677
+ {
1678
+ label: 'export declare enum',
1679
+ body: 'export declare enum __napiBindingTarget { A }',
1680
+ owns: true,
1681
+ hasType: true,
1682
+ },
1683
+ {
1684
+ label: 'an instantiated namespace',
1685
+ body: 'export declare namespace __napiBindingTarget { const a: number }',
1686
+ owns: true,
1687
+ },
1688
+ {
1689
+ label: 'an instantiated module',
1690
+ body: 'export declare module __napiBindingTarget { const a: number }',
1691
+ owns: true,
1692
+ },
1693
+ {
1694
+ label: 'a namespace instantiated by a nested one',
1695
+ body: 'export declare namespace __napiBindingTarget {\n namespace Inner {\n const a: number\n }\n}',
1696
+ owns: true,
1697
+ },
1698
+ {
1699
+ label: 'a dotted instantiated namespace',
1700
+ body: 'export declare namespace __napiBindingTarget.Inner { const a: number }',
1701
+ owns: true,
1702
+ },
1703
+ // A namespace is instantiated by an aliased member too, which is exactly
1704
+ // what a hand-written walk over the body kept missing.
1705
+ {
1706
+ label: 'a namespace instantiated by an export import',
1707
+ body: `${LEGACY_NAMESPACE}export declare namespace __napiBindingTarget {\n export import thing = Legacy.thing\n}`,
1708
+ owns: true,
1709
+ },
1710
+ {
1711
+ label: 'a namespace instantiated by an export clause',
1712
+ body: `${LEGACY_NAMESPACE}export declare namespace __napiBindingTarget {\n export { Legacy }\n}`,
1713
+ owns: true,
1714
+ },
1715
+ {
1716
+ label: 'a namespace instantiated by a nested alias',
1717
+ body: `${LEGACY_NAMESPACE}export declare namespace __napiBindingTarget {\n namespace Inner {\n export import thing = Legacy.thing\n }\n}`,
1718
+ owns: true,
1719
+ },
1720
+ {
1721
+ label: 'export import name =',
1722
+ body: "declare namespace Legacy {\n const thing: 'native'\n}\nexport import __napiBindingTarget = Legacy.thing",
1723
+ owns: true,
1724
+ },
1725
+ {
1726
+ label: 'export { local as name }',
1727
+ body: "declare const target: 'native'\nexport { target as __napiBindingTarget }",
1728
+ owns: true,
1729
+ },
1730
+ {
1731
+ label: 'export { name }',
1732
+ body: "declare const __napiBindingTarget: 'native'\nexport { __napiBindingTarget }",
1733
+ owns: true,
1734
+ },
1735
+ {
1736
+ label: 'export { local as name } from',
1737
+ body: "export { thing as __napiBindingTarget } from './other.js'",
1738
+ owns: true,
1739
+ },
1740
+ {
1741
+ label: 'export { name } from',
1742
+ body: "export { __napiBindingTarget } from './other.js'",
1743
+ owns: true,
1744
+ },
1745
+ {
1746
+ label: 'export type { local as name }',
1747
+ body: 'declare type Target = string\nexport type { Target as __napiBindingTarget }',
1748
+ owns: true,
1749
+ },
1750
+ {
1751
+ label: 'export type { local as name } from',
1752
+ body: "export type { Thing as __napiBindingTarget } from './other.js'",
1753
+ owns: true,
1754
+ },
1755
+ {
1756
+ label: 'export * as name from',
1757
+ body: "export * as __napiBindingTarget from './other.js'",
1758
+ owns: true,
1759
+ },
1760
+ {
1761
+ label: 'export type * as name from',
1762
+ body: "export type * as __napiBindingTarget from './other.js'",
1763
+ owns: true,
1764
+ },
1765
+ // type space only: the const merges, and the consumer keeps its value
1766
+ {
1767
+ label: 'export type',
1768
+ body: 'export type __napiBindingTarget = string',
1769
+ owns: false,
1770
+ hasType: true,
1771
+ },
1772
+ {
1773
+ label: 'export interface',
1774
+ body: 'export interface __napiBindingTarget { a: number }',
1775
+ owns: false,
1776
+ hasType: true,
1777
+ },
1778
+ {
1779
+ label: 'a namespace of interfaces',
1780
+ body: 'export declare namespace __napiBindingTarget {\n interface I {\n a: number\n }\n}',
1781
+ owns: false,
1782
+ },
1783
+ {
1784
+ label: 'a namespace of type aliases',
1785
+ body: 'export declare namespace __napiBindingTarget {\n type T = string\n}',
1786
+ owns: false,
1787
+ },
1788
+ {
1789
+ label: 'an empty namespace',
1790
+ body: 'export declare namespace __napiBindingTarget {}',
1791
+ owns: false,
1792
+ },
1793
+ {
1794
+ label: 'a namespace whose nested one is type-only',
1795
+ body: 'export declare namespace __napiBindingTarget {\n namespace Inner {\n interface I {\n a: number\n }\n }\n}',
1796
+ owns: false,
1797
+ },
1798
+ // the control for the three rows above: an alias a namespace keeps to itself
1799
+ // instantiates nothing
1800
+ {
1801
+ label: 'a namespace with an unexported import',
1802
+ body: `${LEGACY_NAMESPACE}export declare namespace __napiBindingTarget {\n import thing = Legacy.thing\n}`,
1803
+ owns: false,
1804
+ },
1805
+ // An escaped spelling is the same name to TypeScript, whatever a text search
1806
+ // of the header says.
1807
+ {
1808
+ label: 'an escaped spelling',
1809
+ body: `export declare const ${ESCAPED_BINDING_TARGET}: 'native'`,
1810
+ owns: true,
1811
+ },
1812
+ {
1813
+ label: 'an escaped export alias',
1814
+ body: `declare const target: 'native'\nexport { target as ${ESCAPED_BINDING_TARGET} }`,
1815
+ owns: true,
1816
+ },
1817
+ {
1818
+ label: 'an escaped local binding',
1819
+ body: `declare const ${ESCAPED_BINDING_TARGET}: 'native'\n${LOCAL_MODULE_MARKER}`,
1820
+ owns: true,
1821
+ },
1822
+ // A string-literal export name is not an identifier, so it spells the same
1823
+ // name through the string escapes too — and a string may be broken across
1824
+ // lines with a backslash-newline continuation that no `\u` search finds.
1825
+ {
1826
+ label: 'a hexadecimal escape in a string export name',
1827
+ body: `declare const target: 'native'\nexport { target as "__napiBindingTarg\\x65t" }`,
1828
+ owns: true,
1829
+ },
1830
+ {
1831
+ label: 'a string export name continued across lines',
1832
+ body: `declare const target: 'native'\nexport { target as "__napiBindingTarge\\\nt" }`,
1833
+ owns: true,
1834
+ },
1835
+ // Not every binding that collides is an export. An ambient declaration file
1836
+ // puts most top-level declarations in its export table by itself, but not
1837
+ // all of them, and a file that is not a module has no export table at all.
1838
+ {
1839
+ label: 'a local value binding',
1840
+ body: `declare const __napiBindingTarget: 'native'\n${LOCAL_MODULE_MARKER}`,
1841
+ owns: true,
1842
+ },
1843
+ {
1844
+ label: 'a local function binding',
1845
+ body: `declare function __napiBindingTarget(): void\n${LOCAL_MODULE_MARKER}`,
1846
+ owns: true,
1847
+ },
1848
+ {
1849
+ label: 'a local import alias',
1850
+ body: `${LEGACY_NAMESPACE}import __napiBindingTarget = Legacy.thing\n${LOCAL_MODULE_MARKER}`,
1851
+ owns: true,
1852
+ },
1853
+ {
1854
+ // A header that is not a module at all. Its `declare const` is a global
1855
+ // the generated export would redeclare (TS2451, TS2395), so nothing is
1856
+ // written — and the generated body then makes the file a module, which by
1857
+ // the ambient declaration file rule turns that same `declare const` into
1858
+ // the export a consumer reads. So the consumer compiles here where it
1859
+ // cannot even import from the header alone (TS2306).
1860
+ label: 'a script header claiming the name',
1861
+ body: "declare const __napiBindingTarget: 'native'",
1862
+ owns: true,
1863
+ consumer: 'clean',
1864
+ },
1865
+ {
1866
+ label: 'a local type',
1867
+ body: `type __napiBindingTarget = string\n${LOCAL_MODULE_MARKER}`,
1868
+ owns: false,
1869
+ hasType: true,
1870
+ },
1871
+ {
1872
+ label: 'a local interface',
1873
+ body: `interface __napiBindingTarget {\n a: number\n}\n${LOCAL_MODULE_MARKER}`,
1874
+ owns: false,
1875
+ hasType: true,
1876
+ },
1877
+ // The global scope is visible from every file, so a name a header declares
1878
+ // into it comes back from a scope lookup while colliding with nothing the
1879
+ // file itself adds: an export of the name shadows the global instead of
1880
+ // redeclaring it, and a consumer importing the name needs that export
1881
+ // written or it gets TS2305.
1882
+ {
1883
+ label: 'a global augmentation of the name',
1884
+ body: 'export {}\ndeclare global {\n var __napiBindingTarget: string\n}',
1885
+ owns: false,
1886
+ },
1887
+ {
1888
+ label: 'a UMD namespace export of the name',
1889
+ body: `export as namespace __napiBindingTarget\n${LOCAL_MODULE_MARKER}`,
1890
+ owns: false,
1891
+ },
1892
+ {
1893
+ label: 'an export of another name',
1894
+ body: "export declare const __napiBindingTargetInfo: 'native'",
1895
+ owns: false,
1896
+ },
1897
+ {
1898
+ label: 'export default',
1899
+ body: 'declare const binding: Record<string, unknown>\nexport default binding',
1900
+ owns: false,
1901
+ },
1902
+ ]
1903
+
1904
+ test('every top-level export of the binding target name owns it', async (t) => {
1905
+ const { tmpDir, projectDir, typeDefDir } = t.context
1906
+ await writeFile(
1907
+ join(typeDefDir, 'sum.type'),
1908
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
1909
+ )
1910
+
1911
+ let row = 0
1912
+ for (const {
1913
+ label,
1914
+ body,
1915
+ owns,
1916
+ hasType,
1917
+ consumer: expectedConsumer = owns ? 'baseline' : 'clean',
1918
+ } of BINDING_TARGET_EXPORT_FORMS) {
1919
+ const dtsHeader = `/* auto-generated by NAPI-RS */\n\n${body}\n`
1920
+ const { dts } = await generateTypeDef({
1921
+ typeDefDir,
1922
+ cwd: projectDir,
1923
+ declareBindingTarget: true,
1924
+ dtsHeader,
1925
+ })
1926
+ const directory = join(tmpDir, `export-form-${row++}`)
1927
+ const flavor = ensureBindingTargetDeclaration(
1928
+ prepareWasiBindingTypeDef(
1929
+ dts,
1930
+ join(directory, 'index.d.ts'),
1931
+ join(directory, 'flavor.d.cts'),
1932
+ false,
1933
+ ),
1934
+ 'wasm32-wasip1',
1935
+ dtsHeader,
1936
+ )
1937
+
1938
+ // the header comes through byte for byte, whatever the build appended
1939
+ t.true(dts.startsWith(dtsHeader), label)
1940
+
1941
+ // What a package consumer writes: import the name and use it as the value
1942
+ // the loader really exports.
1943
+ const consumer = (module: string) =>
1944
+ `import { __napiBindingTarget } from '${module}'\n` +
1945
+ `export const value: string = __napiBindingTarget\n` +
1946
+ (hasType ? `export type Named = __napiBindingTarget\n` : '')
1947
+
1948
+ const codes = await semanticDiagnosticCodes(directory, {
1949
+ 'baseline.d.ts': dtsHeader,
1950
+ 'baseline-consumer.ts': consumer('./baseline.js'),
1951
+ 'index.d.ts': dts,
1952
+ 'index-consumer.ts': consumer('./index.js'),
1953
+ 'flavor.d.cts': flavor,
1954
+ 'flavor-consumer.ts': consumer('./flavor.cjs'),
1955
+ })
1956
+
1957
+ // TypeScript's verdict on the declaration files: whatever the header
1958
+ // itself is worth, the generated root declaration and the flavor
1959
+ // declaration derived from it are worth exactly the same — the build
1960
+ // introduced no semantic diagnostic. Compared against the header rather
1961
+ // than against nothing, because a header may carry one of its own (the
1962
+ // deprecated `module` keyword is TS1540).
1963
+ t.deepEqual(codes['index.d.ts'], codes['baseline.d.ts'], label)
1964
+ t.deepEqual(codes['flavor.d.cts'], codes['baseline.d.ts'], label)
1965
+
1966
+ if (owns) {
1967
+ // nothing added: the file exports exactly what the header exported
1968
+ t.deepEqual(
1969
+ exportedBindingTargetTypes(dts),
1970
+ exportedBindingTargetTypes(dtsHeader),
1971
+ label,
1972
+ )
1973
+ t.true(flavor.startsWith(dtsHeader), label)
1974
+ t.deepEqual(
1975
+ exportedBindingTargetTypes(flavor),
1976
+ exportedBindingTargetTypes(dtsHeader),
1977
+ label,
1978
+ )
1979
+ } else {
1980
+ // the declaration is written, and a consumer can use it as a value —
1981
+ // which is the whole point of writing it
1982
+ t.deepEqual(
1983
+ exportedBindingTargetTypes(dts),
1984
+ [ROOT_BINDING_TARGET_TYPE],
1985
+ label,
1986
+ )
1987
+ t.deepEqual(
1988
+ exportedBindingTargetTypes(flavor),
1989
+ ["'wasm32-wasip1'"],
1990
+ label,
1991
+ )
1992
+ }
1993
+
1994
+ // What the consumer sees: either exactly what the header alone would give
1995
+ // it — including the failure a type-only re-export clause already causes —
1996
+ // or nothing at all where the declaration was written for it.
1997
+ const expected =
1998
+ expectedConsumer === 'baseline' ? codes['baseline-consumer.ts'] : []
1999
+ t.deepEqual(codes['index-consumer.ts'], expected, label)
2000
+ t.deepEqual(codes['flavor-consumer.ts'], expected, label)
2001
+ }
2002
+ })
2003
+
2004
+ test('a header that cannot spell the binding target skips the checker', (t) => {
2005
+ // Binding the file is what answers ownership, and almost no header needs it
2006
+ // asked: one that contains neither the name nor a backslash anywhere cannot
2007
+ // spell it, and the parse alone settles the question. `checked` reports
2008
+ // whether that shortcut applied, so this holds it in place by observation
2009
+ // rather than by timing anything.
2010
+ const scan = (body: string) =>
2011
+ scanExportedName(
2012
+ `/* auto-generated by NAPI-RS */\n\n${body}\n`,
2013
+ BINDING_TARGET_NAME,
2014
+ )
2015
+
2016
+ const free = scan('export declare function sum(a: number, b: number): number')
2017
+ t.false(free.checked)
2018
+ t.false(free.ownsName)
2019
+
2020
+ const literal = scan(`export declare const ${BINDING_TARGET_NAME}: 'native'`)
2021
+ t.true(literal.checked)
2022
+ t.true(literal.ownsName)
2023
+
2024
+ // the same name written so that no search of the source text finds it
2025
+ const escaped = scan(
2026
+ `export declare const ${ESCAPED_BINDING_TARGET}: 'native'`,
2027
+ )
2028
+ t.true(escaped.checked)
2029
+ t.true(escaped.ownsName)
2030
+
2031
+ // a string-literal export name spells the same name through the string
2032
+ // escapes, which a search for `\u` alone would have walked straight past
2033
+ const hexEscaped = scan(
2034
+ `declare const target: 'native'\nexport { target as "__napiBindingTarg\\x65t" }`,
2035
+ )
2036
+ t.true(hexEscaped.checked)
2037
+ t.true(hexEscaped.ownsName)
2038
+
2039
+ // and so does one broken across lines with a backslash-newline continuation
2040
+ const continued = scan(
2041
+ `declare const target: 'native'\nexport { target as "__napiBindingTarge\\\nt" }`,
2042
+ )
2043
+ t.true(continued.checked)
2044
+ t.true(continued.ownsName)
2045
+
2046
+ // any backslash gives up the shortcut, whatever it turns out to spell:
2047
+ // whether one could have built the name is exactly the question the parse
2048
+ // cannot answer, so it is not guessed at here
2049
+ const unrelatedEscape = scan("export declare const nam\\u0065: 'native'")
2050
+ t.true(unrelatedEscape.checked)
2051
+ t.false(unrelatedEscape.ownsName)
2052
+
2053
+ const unrelatedBackslash = scan("export declare const pattern: '\\\\d+'")
2054
+ t.true(unrelatedBackslash.checked)
2055
+ t.false(unrelatedBackslash.ownsName)
2056
+ })
2057
+
2058
+ test('a WASI flavor without type defs declares its own binding target', async (t) => {
2059
+ const { projectDir, typeDefDir } = t.context
2060
+
2061
+ const { dts } = await generateTypeDef({
2062
+ typeDefDir,
2063
+ cwd: projectDir,
2064
+ declareBindingTarget: true,
2065
+ })
2066
+
2067
+ const prepared = prepareWasiBindingTypeDef(
2068
+ dts,
2069
+ join(projectDir, 'index.d.ts'),
2070
+ join(projectDir, 'pkg.wasip1.d.cts'),
2071
+ false,
2072
+ )
2073
+ t.is(
2074
+ bindingTargetDeclarationOf(
2075
+ ensureBindingTargetDeclaration(prepared, 'wasm32-wasip1'),
2076
+ ),
2077
+ "export declare const __napiBindingTarget: 'wasm32-wasip1'",
2078
+ )
2079
+ })
2080
+
2081
+ test('the binding target declaration follows the loaders a build emits', (t) => {
2082
+ // Each row is a build shape, named by what reaches the CLI. `expected` is
2083
+ // whether any loader carrying `__napiBindingTarget` is written for it.
2084
+ const shapes = [
2085
+ {
2086
+ name: 'no --platform, a WASI flavor configured but never built',
2087
+ rootLoaderCandidate: false,
2088
+ hasWasiFallback: true,
2089
+ emitsWasiLoader: false,
2090
+ exports: ['sum'],
2091
+ expected: false,
2092
+ },
2093
+ {
2094
+ name: 'no --platform, no WASI flavor',
2095
+ rootLoaderCandidate: false,
2096
+ hasWasiFallback: false,
2097
+ emitsWasiLoader: false,
2098
+ exports: ['sum'],
2099
+ expected: false,
2100
+ },
2101
+ {
2102
+ name: '--platform with a WASI flavor configured',
2103
+ rootLoaderCandidate: true,
2104
+ hasWasiFallback: true,
2105
+ emitsWasiLoader: false,
2106
+ exports: ['sum'],
2107
+ expected: true,
2108
+ },
2109
+ {
2110
+ name: '--platform --no-js-binding',
2111
+ rootLoaderCandidate: false,
2112
+ hasWasiFallback: true,
2113
+ emitsWasiLoader: false,
2114
+ exports: ['sum'],
2115
+ expected: false,
2116
+ },
2117
+ {
2118
+ name: '--platform with type defs but zero runtime exports',
2119
+ rootLoaderCandidate: true,
2120
+ hasWasiFallback: false,
2121
+ emitsWasiLoader: false,
2122
+ exports: [],
2123
+ expected: false,
2124
+ },
2125
+ {
2126
+ name: 'no --platform, zero runtime exports',
2127
+ rootLoaderCandidate: false,
2128
+ hasWasiFallback: true,
2129
+ emitsWasiLoader: false,
2130
+ exports: [],
2131
+ expected: false,
2132
+ },
2133
+ {
2134
+ name: 'a native build over WASI loaders an earlier build left behind',
2135
+ rootLoaderCandidate: true,
2136
+ hasWasiFallback: true,
2137
+ emitsWasiLoader: true,
2138
+ exports: ['sum'],
2139
+ expected: true,
2140
+ },
2141
+ {
2142
+ name: '--target wasm32-wasip1 without --platform',
2143
+ rootLoaderCandidate: false,
2144
+ hasWasiFallback: true,
2145
+ emitsWasiLoader: true,
2146
+ exports: [],
2147
+ expected: true,
2148
+ },
2149
+ {
2150
+ name: 'a plain native-only package',
2151
+ rootLoaderCandidate: true,
2152
+ hasWasiFallback: false,
2153
+ emitsWasiLoader: false,
2154
+ exports: ['sum'],
2155
+ expected: true,
2156
+ },
2157
+ ] as const
2158
+
2159
+ for (const shape of shapes) {
2160
+ const declares = bindingTargetDeclarationPredicate(shape)
2161
+ t.is(declares(shape.exports), shape.expected, shape.name)
2162
+ }
2163
+ })
2164
+
2165
+ test('the binding target reservation asks the same predicate as the declaration', (t) => {
2166
+ // `Builder.generateTypeDef` reserves `__napiBindingTarget` exactly when it
2167
+ // declares it, so the reservation inherits these answers. The predicate reads
2168
+ // only the *length* of the export list, never the names in it — which is what
2169
+ // lets one decision stand for both.
2170
+ const claimed = ['__napiBindingTarget']
2171
+ const shapes = [
2172
+ {
2173
+ name: 'a plain `napi build` reserves nothing',
2174
+ rootLoaderCandidate: false,
2175
+ hasWasiFallback: false,
2176
+ emitsWasiLoader: false,
2177
+ exports: claimed,
2178
+ expected: false,
2179
+ },
2180
+ {
2181
+ name: '`--platform --no-js` reserves nothing',
2182
+ rootLoaderCandidate: false,
2183
+ hasWasiFallback: true,
2184
+ emitsWasiLoader: false,
2185
+ exports: claimed,
2186
+ expected: false,
2187
+ },
2188
+ {
2189
+ name: '`--platform` writes a root loader, so the name is reserved',
2190
+ rootLoaderCandidate: true,
2191
+ hasWasiFallback: false,
2192
+ emitsWasiLoader: false,
2193
+ exports: claimed,
2194
+ expected: true,
2195
+ },
2196
+ {
2197
+ name: 'a regenerated WASI loader set reserves the name on its own',
2198
+ rootLoaderCandidate: false,
2199
+ hasWasiFallback: false,
2200
+ emitsWasiLoader: true,
2201
+ exports: [],
2202
+ expected: true,
2203
+ },
2204
+ ] as const
2205
+
2206
+ for (const shape of shapes) {
2207
+ t.is(
2208
+ bindingTargetDeclarationPredicate(shape)(shape.exports),
2209
+ shape.expected,
2210
+ shape.name,
2211
+ )
2212
+ }
247
2213
  })
248
2214
 
249
2215
  test('resolveBuildFormat handles defaults, aliases, and conflicts', (t) => {
@@ -287,6 +2253,95 @@ test('resolveBuildFormat handles defaults, aliases, and conflicts', (t) => {
287
2253
  }
288
2254
  })
289
2255
 
2256
+ const ASYNC_RUNTIME_HOST_EXPORTS = [
2257
+ 'getCurrentThreadTaskHostContractVersion',
2258
+ 'isCurrentThreadHostRegistrationActive',
2259
+ 'registerCurrentThreadTaskHost',
2260
+ 'registerTimerHost',
2261
+ 'reserveCurrentThreadHostRegistration',
2262
+ 'unregisterCurrentThreadTaskHost',
2263
+ 'unregisterTimerHost',
2264
+ ]
2265
+
2266
+ test('checkAsyncRuntimeHostContract reports the exports a binding is missing', (t) => {
2267
+ const { error, warning } = checkAsyncRuntimeHostContract({
2268
+ idents: ['plus100', 'registerTimerHost'],
2269
+ asyncRuntime: true,
2270
+ typeDefAvailable: true,
2271
+ packageName: 'missing-host-exports',
2272
+ })
2273
+
2274
+ t.is(warning, undefined)
2275
+ t.truthy(error)
2276
+ t.regex(error!, /missing-host-exports/)
2277
+ for (const name of ASYNC_RUNTIME_HOST_EXPORTS) {
2278
+ if (name === 'registerTimerHost') {
2279
+ t.notRegex(error!, new RegExp(`Missing:[^.]*\\b${name}\\b`))
2280
+ } else {
2281
+ t.regex(error!, new RegExp(`Missing:[^.]*\\b${name}\\b`))
2282
+ }
2283
+ }
2284
+ })
2285
+
2286
+ test('checkAsyncRuntimeHostContract warns when the contract is exported but the flag is off', (t) => {
2287
+ const { error, warning } = checkAsyncRuntimeHostContract({
2288
+ idents: [...ASYNC_RUNTIME_HOST_EXPORTS, 'plus100'],
2289
+ asyncRuntime: false,
2290
+ typeDefAvailable: true,
2291
+ packageName: 'flag-is-off',
2292
+ })
2293
+
2294
+ t.is(error, undefined)
2295
+ t.truthy(warning)
2296
+ t.regex(warning!, /napi\.wasm\.asyncRuntime is not enabled/)
2297
+ })
2298
+
2299
+ test('checkAsyncRuntimeHostContract skips the check without type-def metadata', (t) => {
2300
+ const { error, warning } = checkAsyncRuntimeHostContract({
2301
+ idents: [],
2302
+ asyncRuntime: true,
2303
+ typeDefAvailable: false,
2304
+ packageName: 'no-type-def',
2305
+ })
2306
+
2307
+ t.is(error, undefined)
2308
+ t.truthy(warning)
2309
+ t.regex(warning!, /type-def/)
2310
+ t.regex(warning!, /ERR_NAPI_ASYNC_RUNTIME_BINDING_MISMATCH/)
2311
+
2312
+ t.deepEqual(
2313
+ checkAsyncRuntimeHostContract({
2314
+ idents: [],
2315
+ asyncRuntime: false,
2316
+ typeDefAvailable: false,
2317
+ packageName: 'no-type-def',
2318
+ }),
2319
+ {},
2320
+ )
2321
+ })
2322
+
2323
+ test('checkAsyncRuntimeHostContract stays silent when the flag matches the exports', (t) => {
2324
+ t.deepEqual(
2325
+ checkAsyncRuntimeHostContract({
2326
+ idents: [...ASYNC_RUNTIME_HOST_EXPORTS, 'plus100'],
2327
+ asyncRuntime: true,
2328
+ typeDefAvailable: true,
2329
+ packageName: 'all-good',
2330
+ }),
2331
+ {},
2332
+ )
2333
+
2334
+ t.deepEqual(
2335
+ checkAsyncRuntimeHostContract({
2336
+ idents: ['plus100'],
2337
+ asyncRuntime: false,
2338
+ typeDefAvailable: true,
2339
+ packageName: 'all-good',
2340
+ }),
2341
+ {},
2342
+ )
2343
+ })
2344
+
290
2345
  test('generateTypeDef preserves deterministic file order', async (t) => {
291
2346
  const { projectDir, typeDefDir } = t.context
292
2347
 
@@ -1108,3 +3163,278 @@ const isNapiCrossUnsupportedHost =
1108
3163
  )
1109
3164
  },
1110
3165
  )
3166
+
3167
+ async function createEmnapiLibDir(root: string, dirNames: string[]) {
3168
+ const emnapiLibDir = join(root, 'emnapi', 'lib')
3169
+ for (const dirName of dirNames) {
3170
+ await mkdir(join(emnapiLibDir, dirName), { recursive: true })
3171
+ }
3172
+ return emnapiLibDir
3173
+ }
3174
+
3175
+ async function createWasiSdkDir(root: string, version: string) {
3176
+ const wasiSdkPath = join(root, `wasi-sdk-${version}`)
3177
+ await mkdir(wasiSdkPath, { recursive: true })
3178
+ await writeFile(join(wasiSdkPath, 'VERSION'), `${version}\n`)
3179
+ return wasiSdkPath
3180
+ }
3181
+
3182
+ async function createWasiLibcArchive(
3183
+ projectDir: string,
3184
+ abi: 'legacy' | 'new',
3185
+ ) {
3186
+ const libcPath = join(projectDir, `libc-${abi}.a`)
3187
+ // Only the archive member names matter: wasi-libc moved the futex helpers
3188
+ // into `futex.c` when it dropped the `int op` parameter, and kept `__wait.c`
3189
+ // for other symbols.
3190
+ const members =
3191
+ abi === 'new'
3192
+ ? ['__wait.c.obj', 'futex.c.obj']
3193
+ : ['__wait.c.obj', '__wasilibc_busywait.c.obj']
3194
+ await writeFile(libcPath, `!<arch>\n${members.join('\n')}\n`)
3195
+ return libcPath
3196
+ }
3197
+
3198
+ test('selects the wasi-sdk 34 emnapi archives for wasi-sdk >= 34', async (t) => {
3199
+ const { projectDir } = t.context
3200
+ const emnapiLibDir = await createEmnapiLibDir(projectDir, [
3201
+ 'wasm32-wasip1',
3202
+ 'wasm32-wasip1-threads',
3203
+ EMNAPI_WASI_SDK_34_LINK_DIR,
3204
+ ])
3205
+ const wasiSdkPath = await createWasiSdkDir(projectDir, '34.0')
3206
+
3207
+ t.deepEqual(
3208
+ selectEmnapiLinkDir(
3209
+ emnapiLibDir,
3210
+ 'wasm32-wasip1-threads',
3211
+ true,
3212
+ wasiSdkPath,
3213
+ ),
3214
+ {
3215
+ linkDirName: EMNAPI_WASI_SDK_34_LINK_DIR,
3216
+ wasiSdkMajor: 34,
3217
+ needsWasiSdk34: true,
3218
+ },
3219
+ )
3220
+ })
3221
+
3222
+ test('keeps the legacy emnapi archives for wasi-sdk <= 33', async (t) => {
3223
+ const { projectDir } = t.context
3224
+ const emnapiLibDir = await createEmnapiLibDir(projectDir, [
3225
+ 'wasm32-wasip1-threads',
3226
+ EMNAPI_WASI_SDK_34_LINK_DIR,
3227
+ ])
3228
+ const wasiSdkPath = await createWasiSdkDir(projectDir, '33.0')
3229
+
3230
+ t.deepEqual(
3231
+ selectEmnapiLinkDir(
3232
+ emnapiLibDir,
3233
+ 'wasm32-wasip1-threads',
3234
+ true,
3235
+ wasiSdkPath,
3236
+ ),
3237
+ {
3238
+ linkDirName: 'wasm32-wasip1-threads',
3239
+ wasiSdkMajor: 33,
3240
+ needsWasiSdk34: false,
3241
+ },
3242
+ )
3243
+ })
3244
+
3245
+ test('keeps the legacy emnapi archives when the wasi-libc ABI is unknown', async (t) => {
3246
+ const { projectDir } = t.context
3247
+ const emnapiLibDir = await createEmnapiLibDir(projectDir, [
3248
+ 'wasm32-wasip1-threads',
3249
+ EMNAPI_WASI_SDK_34_LINK_DIR,
3250
+ ])
3251
+
3252
+ t.deepEqual(
3253
+ // `null` stands in for a Rust sysroot that cannot be probed.
3254
+ selectEmnapiLinkDir(
3255
+ emnapiLibDir,
3256
+ 'wasm32-wasip1-threads',
3257
+ true,
3258
+ undefined,
3259
+ { rustWasiLibc: null },
3260
+ ),
3261
+ {
3262
+ linkDirName: 'wasm32-wasip1-threads',
3263
+ wasiSdkMajor: null,
3264
+ needsWasiSdk34: false,
3265
+ },
3266
+ )
3267
+ })
3268
+
3269
+ test('keeps the legacy emnapi archives for a pre wasi-sdk 34 Rust toolchain', async (t) => {
3270
+ const { projectDir } = t.context
3271
+ const emnapiLibDir = await createEmnapiLibDir(projectDir, [
3272
+ 'wasm32-wasip1-threads',
3273
+ EMNAPI_WASI_SDK_34_LINK_DIR,
3274
+ ])
3275
+ const rustWasiLibc = await createWasiLibcArchive(projectDir, 'legacy')
3276
+
3277
+ t.deepEqual(
3278
+ selectEmnapiLinkDir(
3279
+ emnapiLibDir,
3280
+ 'wasm32-wasip1-threads',
3281
+ true,
3282
+ undefined,
3283
+ { rustWasiLibc },
3284
+ ),
3285
+ {
3286
+ linkDirName: 'wasm32-wasip1-threads',
3287
+ wasiSdkMajor: null,
3288
+ needsWasiSdk34: false,
3289
+ },
3290
+ )
3291
+ })
3292
+
3293
+ test('selects the wasi-sdk 34 archives for a Rust toolchain that bundles the new wasi-libc', async (t) => {
3294
+ const { projectDir } = t.context
3295
+ const emnapiLibDir = await createEmnapiLibDir(projectDir, [
3296
+ 'wasm32-wasip1-threads',
3297
+ EMNAPI_WASI_SDK_34_LINK_DIR,
3298
+ ])
3299
+ const rustWasiLibc = await createWasiLibcArchive(projectDir, 'new')
3300
+
3301
+ t.deepEqual(
3302
+ // No wasi-sdk is configured: cargo links Rust's bundled wasi-libc, and
3303
+ // Rust picked up wasi-sdk 34 in rust-lang/rust#161773.
3304
+ selectEmnapiLinkDir(
3305
+ emnapiLibDir,
3306
+ 'wasm32-wasip1-threads',
3307
+ true,
3308
+ undefined,
3309
+ { rustWasiLibc },
3310
+ ),
3311
+ {
3312
+ linkDirName: EMNAPI_WASI_SDK_34_LINK_DIR,
3313
+ wasiSdkMajor: null,
3314
+ needsWasiSdk34: true,
3315
+ },
3316
+ )
3317
+ })
3318
+
3319
+ test('never selects the wasi-sdk 34 archives for the threadless target', async (t) => {
3320
+ const { projectDir } = t.context
3321
+ const emnapiLibDir = await createEmnapiLibDir(projectDir, [
3322
+ 'wasm32-wasip1',
3323
+ EMNAPI_WASI_SDK_34_LINK_DIR,
3324
+ ])
3325
+ const wasiSdkPath = await createWasiSdkDir(projectDir, '34.0')
3326
+
3327
+ t.deepEqual(
3328
+ selectEmnapiLinkDir(emnapiLibDir, 'wasm32-wasip1', false, wasiSdkPath),
3329
+ {
3330
+ linkDirName: 'wasm32-wasip1',
3331
+ wasiSdkMajor: 34,
3332
+ needsWasiSdk34: false,
3333
+ },
3334
+ )
3335
+ })
3336
+
3337
+ test('falls back to the legacy archives when emnapi has no wasi-sdk 34 directory', async (t) => {
3338
+ const { projectDir } = t.context
3339
+ const emnapiLibDir = await createEmnapiLibDir(projectDir, [
3340
+ 'wasm32-wasip1-threads',
3341
+ ])
3342
+ const wasiSdkPath = await createWasiSdkDir(projectDir, '34.0')
3343
+
3344
+ t.deepEqual(
3345
+ selectEmnapiLinkDir(
3346
+ emnapiLibDir,
3347
+ 'wasm32-wasip1-threads',
3348
+ true,
3349
+ wasiSdkPath,
3350
+ ),
3351
+ {
3352
+ linkDirName: 'wasm32-wasip1-threads',
3353
+ wasiSdkMajor: 34,
3354
+ needsWasiSdk34: true,
3355
+ },
3356
+ )
3357
+ })
3358
+
3359
+ test('resolveWasmMemory keeps the template defaults when nothing is configured', (t) => {
3360
+ t.deepEqual(resolveWasmMemory(undefined, true), {
3361
+ initialMemory: undefined,
3362
+ maximumMemory: undefined,
3363
+ })
3364
+ t.deepEqual(resolveWasmMemory({}, false), {
3365
+ initialMemory: undefined,
3366
+ maximumMemory: undefined,
3367
+ })
3368
+ })
3369
+
3370
+ test('resolveWasmMemory applies initialMemory to both flavors without the threadless key', (t) => {
3371
+ const wasm = { initialMemory: 16384, maximumMemory: 65536 }
3372
+ t.deepEqual(resolveWasmMemory(wasm, true), {
3373
+ initialMemory: 16384,
3374
+ maximumMemory: 65536,
3375
+ })
3376
+ t.deepEqual(resolveWasmMemory(wasm, false), {
3377
+ initialMemory: 16384,
3378
+ maximumMemory: 65536,
3379
+ })
3380
+ })
3381
+
3382
+ test('resolveWasmMemory gives the threadless flavor its own floor', (t) => {
3383
+ const wasm = { initialMemory: 16384, threadlessInitialMemory: 1027 }
3384
+ t.is(resolveWasmMemory(wasm, true).initialMemory, 16384)
3385
+ t.is(resolveWasmMemory(wasm, false).initialMemory, 1027)
3386
+ // the threadless key alone leaves the threaded loader on its template default
3387
+ t.is(
3388
+ resolveWasmMemory({ threadlessInitialMemory: 1027 }, true).initialMemory,
3389
+ undefined,
3390
+ )
3391
+ })
3392
+
3393
+ test('resolveWasmMemory rejects an initial memory above the maximum', (t) => {
3394
+ t.throws(
3395
+ () =>
3396
+ resolveWasmMemory(
3397
+ {
3398
+ initialMemory: 4000,
3399
+ threadlessInitialMemory: 2048,
3400
+ maximumMemory: 1024,
3401
+ },
3402
+ false,
3403
+ ),
3404
+ {
3405
+ message:
3406
+ 'napi.wasm.initialMemory (4000 pages) must not exceed napi.wasm.maximumMemory (1024 pages)',
3407
+ },
3408
+ )
3409
+ t.throws(
3410
+ () =>
3411
+ resolveWasmMemory(
3412
+ { threadlessInitialMemory: 2048, maximumMemory: 1024 },
3413
+ false,
3414
+ ),
3415
+ {
3416
+ message:
3417
+ 'napi.wasm.threadlessInitialMemory (2048 pages) must not exceed napi.wasm.maximumMemory (1024 pages)',
3418
+ },
3419
+ )
3420
+ // no maximumMemory => the implicit memory32 ceiling still applies
3421
+ t.throws(() => resolveWasmMemory({ threadlessInitialMemory: 65537 }, false), {
3422
+ message: /between 1 and 65536 pages/,
3423
+ })
3424
+ })
3425
+
3426
+ test('resolveWasmMemory rejects non-page values', (t) => {
3427
+ for (const value of [0, -1, 1.5, Number.NaN, 65537]) {
3428
+ t.throws(
3429
+ () => resolveWasmMemory({ threadlessInitialMemory: value }, false),
3430
+ {
3431
+ message:
3432
+ /napi\.wasm\.threadlessInitialMemory must be an integer between 1 and 65536 pages/,
3433
+ },
3434
+ )
3435
+ }
3436
+ t.throws(() => resolveWasmMemory({ maximumMemory: 65537 }, true), {
3437
+ message:
3438
+ /napi\.wasm\.maximumMemory must be an integer between 1 and 65536 pages/,
3439
+ })
3440
+ })