@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
@@ -28,6 +28,7 @@ import {
28
28
  pick,
29
29
  resolvePackageReconciliationPaths,
30
30
  restrictWasiNodeEngine,
31
+ serializeJson,
31
32
  wasiLoaderSuffix,
32
33
  wasiTargetHasThreads,
33
34
  withFileSystemReconciliation,
@@ -48,6 +49,7 @@ export interface PackageMeta {
48
49
  }
49
50
 
50
51
  const WASM_RUNTIME_PACKAGE_NAME = '@napi-rs/wasm-runtime'
52
+ const ASYNC_RUNTIME_PACKAGE_NAME = '@napi-rs/async-runtime'
51
53
 
52
54
  interface PendingMetadataWrite {
53
55
  content: string
@@ -65,25 +67,25 @@ interface OwnedWasiPackage {
65
67
  target: Target
66
68
  }
67
69
 
68
- async function getLatestWasmRuntimeVersion() {
70
+ async function getLatestPackageVersion(packageName: string) {
69
71
  const npmRegistryBase =
70
72
  process.env.npm_config_registry?.replace(/\/?$/, '/') ??
71
73
  'https://registry.npmjs.org/'
72
- const packageMetadataUrl = `${npmRegistryBase}${WASM_RUNTIME_PACKAGE_NAME}`
74
+ const packageMetadataUrl = `${npmRegistryBase}${packageName}`
73
75
  let response: Response
74
76
 
75
77
  try {
76
78
  response = await fetch(packageMetadataUrl)
77
79
  } catch (error) {
78
80
  throw new Error(
79
- `Failed to fetch ${packageMetadataUrl} while resolving ${WASM_RUNTIME_PACKAGE_NAME}. Check your network connection and npm registry availability.`,
81
+ `Failed to fetch ${packageMetadataUrl} while resolving ${packageName}. Check your network connection and npm registry availability.`,
80
82
  { cause: error },
81
83
  )
82
84
  }
83
85
 
84
86
  if (!response.ok) {
85
87
  throw new Error(
86
- `Failed to fetch ${packageMetadataUrl} while resolving ${WASM_RUNTIME_PACKAGE_NAME}: npm registry responded with ${response.status} ${response.statusText || 'Unknown Status'}`,
88
+ `Failed to fetch ${packageMetadataUrl} while resolving ${packageName}: npm registry responded with ${response.status} ${response.statusText || 'Unknown Status'}`,
87
89
  )
88
90
  }
89
91
 
@@ -93,7 +95,7 @@ async function getLatestWasmRuntimeVersion() {
93
95
  packageMeta = (await response.json()) as PackageMeta
94
96
  } catch (error) {
95
97
  throw new Error(
96
- `Failed to parse npm registry metadata for ${WASM_RUNTIME_PACKAGE_NAME} from ${packageMetadataUrl}`,
98
+ `Failed to parse npm registry metadata for ${packageName} from ${packageMetadataUrl}`,
97
99
  { cause: error },
98
100
  )
99
101
  }
@@ -102,7 +104,7 @@ async function getLatestWasmRuntimeVersion() {
102
104
 
103
105
  if (typeof latestVersion !== 'string' || latestVersion.trim().length === 0) {
104
106
  throw new Error(
105
- `npm registry metadata for ${WASM_RUNTIME_PACKAGE_NAME} from ${packageMetadataUrl} did not include a latest dist-tag`,
107
+ `npm registry metadata for ${packageName} from ${packageMetadataUrl} did not include a latest dist-tag`,
106
108
  )
107
109
  }
108
110
 
@@ -445,9 +447,15 @@ async function createNpmDirsUnlocked(
445
447
  ),
446
448
  )
447
449
  ).flat()
448
- const wasmRuntimeVersion = targets.some((target) => target.arch === 'wasm32')
449
- ? await getLatestWasmRuntimeVersion()
450
- : undefined
450
+ const hasWasmTarget = targets.some((target) => target.arch === 'wasm32')
451
+ const [wasmRuntimeVersion, asyncRuntimeVersion] = await Promise.all([
452
+ hasWasmTarget
453
+ ? getLatestPackageVersion(WASM_RUNTIME_PACKAGE_NAME)
454
+ : undefined,
455
+ hasWasmTarget && wasm?.asyncRuntime === true
456
+ ? getLatestPackageVersion(ASYNC_RUNTIME_PACKAGE_NAME)
457
+ : undefined,
458
+ ])
451
459
  const pendingWrites: PendingMetadataWrite[] = []
452
460
 
453
461
  for (const target of targets) {
@@ -559,6 +567,11 @@ async function createNpmDirsUnlocked(
559
567
  '@napi-rs/wasm-runtime': `~${wasmRuntimeVersion}`,
560
568
  '@emnapi/core': emnapiVersion,
561
569
  '@emnapi/runtime': emnapiVersion,
570
+ // The compatibility axis is the host contract version (4), which is
571
+ // stable across a semver major, so a caret range is correct here.
572
+ ...(asyncRuntimeVersion
573
+ ? { '@napi-rs/async-runtime': `^${asyncRuntimeVersion}` }
574
+ : {}),
562
575
  ...(wasm?.browser?.buffer === true &&
563
576
  (wasm.browser.fs !== true || !wasiTargetHasThreads(target))
564
577
  ? { buffer: directBufferDependency }
@@ -574,7 +587,7 @@ async function createNpmDirsUnlocked(
574
587
 
575
588
  const targetPackageJson = join(targetDir, 'package.json')
576
589
  pendingWrites.push({
577
- content: JSON.stringify(scopedPackageJson, null, 2) + '\n',
590
+ content: serializeJson(scopedPackageJson),
578
591
  destination: targetPackageJson,
579
592
  })
580
593
  if (wasmModuleTypeDef) {
package/src/api/new.ts CHANGED
@@ -4,8 +4,8 @@ import { homedir } from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { promises as fs } from 'node:fs'
6
6
 
7
- import { parse as parseToml, stringify as stringifyToml } from '@std/toml'
8
- import { load as yamlLoad, dump as yamlDump } from 'js-yaml'
7
+ import { parse as parseToml } from '@std/toml'
8
+ import { load as yamlLoad } from 'js-yaml'
9
9
 
10
10
  import {
11
11
  applyDefaultNewOptions,
@@ -15,6 +15,9 @@ import {
15
15
  AVAILABLE_TARGETS,
16
16
  CLI_VERSION,
17
17
  debugFactory,
18
+ serializeJson,
19
+ serializeToml,
20
+ serializeYaml,
18
21
  DEFAULT_TARGETS,
19
22
  getWasiTarget,
20
23
  mkdirAsync,
@@ -347,7 +350,7 @@ async function filterTargetsInPackageJson(
347
350
  }
348
351
  }
349
352
 
350
- await fs.writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n')
353
+ await fs.writeFile(filePath, serializeJson(packageJson))
351
354
  await updateGeneratedWasiAttributes(
352
355
  path.join(path.dirname(filePath), '.gitattributes'),
353
356
  packageJson.napi.binaryName,
@@ -381,10 +384,10 @@ async function updateGeneratedWasiAttributes(
381
384
  generatedFiles.add(`${binaryName}.${suffix}-deferred.d.ts`)
382
385
  }
383
386
  }
387
+ while (lines[lines.length - 1] === '') {
388
+ lines.pop()
389
+ }
384
390
  if (generatedFiles.size > 0) {
385
- while (lines[lines.length - 1] === '') {
386
- lines.pop()
387
- }
388
391
  lines.push(
389
392
  '',
390
393
  ...[...generatedFiles].map((file) => `${file} linguist-detectable=false`),
@@ -429,7 +432,7 @@ async function updateCargoTomlTypeDef(
429
432
 
430
433
  dependencies['napi-derive'] = dependencyConfig
431
434
 
432
- await fs.writeFile(filePath, stringifyToml(cargoToml))
435
+ await fs.writeFile(filePath, serializeToml(cargoToml))
433
436
  }
434
437
 
435
438
  export async function updateCargoTomlNodeApiVersion(
@@ -475,7 +478,7 @@ export async function updateCargoTomlNodeApiVersion(
475
478
 
476
479
  dependencies.napi = dependencyConfig
477
480
 
478
- await fs.writeFile(filePath, stringifyToml(cargoToml))
481
+ await fs.writeFile(filePath, serializeToml(cargoToml))
479
482
  }
480
483
 
481
484
  async function filterTargetsInGithubActions(
@@ -683,12 +686,7 @@ async function filterTargetsInGithubActions(
683
686
  }
684
687
 
685
688
  // Write back the filtered YAML
686
- const updatedYaml = yamlDump(yaml, {
687
- lineWidth: -1,
688
- noRefs: true,
689
- sortKeys: false,
690
- })
691
- await fs.writeFile(filePath, updatedYaml)
689
+ await fs.writeFile(filePath, serializeYaml(yaml))
692
690
  }
693
691
 
694
692
  function processOptions(options: RawNewOptions) {
@@ -853,10 +851,7 @@ export async function newProject(userOptions: RawNewOptions) {
853
851
  )
854
852
  }
855
853
 
856
- await fs.writeFile(
857
- packageJsonPath,
858
- JSON.stringify(pkgJson, null, 2) + '\n',
859
- )
854
+ await fs.writeFile(packageJsonPath, serializeJson(pkgJson))
860
855
  } catch (error) {
861
856
  throw new Error(`Failed to create project: ${error}`)
862
857
  }
@@ -58,6 +58,7 @@ import {
58
58
  withFileSystemReconciliation,
59
59
  AVAILABLE_TARGETS,
60
60
  parseTriple,
61
+ serializeJson,
61
62
  type CommonPackageJsonFields,
62
63
  type FileSystemTransactionWrite,
63
64
  type RootPublisher,
@@ -112,6 +113,10 @@ const wasiRuntimeDependencies = [
112
113
  '@napi-rs/wasm-runtime',
113
114
  '@emnapi/core',
114
115
  '@emnapi/runtime',
116
+ // Only the loaders generated for an addon with `napi.wasm.asyncRuntime` set
117
+ // import it. `releasePackageRuntimeImports` matches by prefix, so the package
118
+ // root and any subpath (`@napi-rs/async-runtime/workerd`) both count.
119
+ '@napi-rs/async-runtime',
115
120
  'buffer',
116
121
  ]
117
122
  const require = createRequire(import.meta.url)
@@ -2267,7 +2272,7 @@ async function registerPublicationWorkspaces(
2267
2272
  } else {
2268
2273
  manifest.workspaces = releaseWorkspaces
2269
2274
  }
2270
- await writeFileAtomic(manifestPath, JSON.stringify(manifest, null, 2))
2275
+ await writeFileAtomic(manifestPath, serializeJson(manifest))
2271
2276
  }
2272
2277
 
2273
2278
  async function createPublicationExecutionPackage(
@@ -2391,10 +2396,7 @@ async function materializeRootReleasePlan(
2391
2396
  }
2392
2397
  }
2393
2398
  syncThreadlessWasiRootFacadeManifest(updatedPackageJson, plan.packageJson)
2394
- await writeFileAtomic(
2395
- packageJsonPath,
2396
- JSON.stringify(updatedPackageJson, null, 2),
2397
- )
2399
+ await writeFileAtomic(packageJsonPath, serializeJson(updatedPackageJson))
2398
2400
 
2399
2401
  const generatedFiles = new Set(plan.facade?.generatedFiles ?? [])
2400
2402
  await Promise.all(
@@ -2492,7 +2494,7 @@ async function stageReleasePackage(
2492
2494
  const packageJsonPath = join(stagedPkgDir, 'package.json')
2493
2495
  const packageJson = JSON.parse(await readFileAsync(packageJsonPath, 'utf8'))
2494
2496
  packageJson.version = packageVersion
2495
- await writeFileAtomic(packageJsonPath, JSON.stringify(packageJson, null, 2))
2497
+ await writeFileAtomic(packageJsonPath, serializeJson(packageJson))
2496
2498
  return {
2497
2499
  pkgDir: options.pkgDir,
2498
2500
  rootDir: options.rootDir,
@@ -2620,7 +2622,7 @@ export async function commitPrePublishFileSystemTransaction({
2620
2622
  await commitFileSystemTransaction(transactionRoot, writes, removals)
2621
2623
  }
2622
2624
 
2623
- async function validateReleasePackageContents({
2625
+ export async function validateReleasePackageContents({
2624
2626
  pkgDir,
2625
2627
  rootDir,
2626
2628
  packageName,
@@ -2705,10 +2707,7 @@ async function validateReleasePackageContents({
2705
2707
  if (updateManifest) {
2706
2708
  packageFiles.splice(0, packageFiles.length, ...declarationClosure.files)
2707
2709
  packageJson.files = packageFiles
2708
- await writeFileAtomic(
2709
- packageJsonPath,
2710
- `${JSON.stringify(packageJson, null, 2)}\n`,
2711
- )
2710
+ await writeFileAtomic(packageJsonPath, serializeJson(packageJson))
2712
2711
  }
2713
2712
 
2714
2713
  const publicFiles = new Set<string>(packageFiles)
@@ -3016,6 +3015,30 @@ function validateWasiReleasePackageManifest(
3016
3015
  `Release package ${packageJson.name} must omit buffer when its loaders do not import it`,
3017
3016
  )
3018
3017
  }
3018
+
3019
+ // `create-npm-dirs` resolves the version from the registry rather than
3020
+ // pinning it here, so only the range's shape can be checked. The require /
3021
+ // forbid pair is what matters: an undeclared import breaks the published
3022
+ // package at load, and a declared dependency no loader imports is dead
3023
+ // weight on every consumer of this flavor.
3024
+ if (packagedRuntimeImports.has('@napi-rs/async-runtime')) {
3025
+ const asyncRuntimeVersion = requireStringDependency(
3026
+ packageJson.name,
3027
+ dependencies,
3028
+ '@napi-rs/async-runtime',
3029
+ )
3030
+ try {
3031
+ new Range(asyncRuntimeVersion)
3032
+ } catch {
3033
+ throw new Error(
3034
+ `Release package ${packageJson.name} has invalid @napi-rs/async-runtime dependency ${asyncRuntimeVersion}`,
3035
+ )
3036
+ }
3037
+ } else if (dependencies['@napi-rs/async-runtime'] !== undefined) {
3038
+ throw new Error(
3039
+ `Release package ${packageJson.name} must omit @napi-rs/async-runtime when its loaders do not import it`,
3040
+ )
3041
+ }
3019
3042
  }
3020
3043
 
3021
3044
  function requireStringDependency(
package/src/api/rename.ts CHANGED
@@ -12,8 +12,8 @@ import {
12
12
  sep,
13
13
  } from 'node:path'
14
14
 
15
- import { parse as parseToml, stringify as stringifyToml } from '@std/toml'
16
- import { load as yamlParse, dump as yamlStringify } from 'js-yaml'
15
+ import { parse as parseToml } from '@std/toml'
16
+ import { load as yamlParse } from 'js-yaml'
17
17
  import { isNil, omitBy, pick } from 'es-toolkit'
18
18
 
19
19
  import { applyDefaultRenameOptions, type RenameOptions } from '../def/rename.js'
@@ -23,6 +23,9 @@ import {
23
23
  readFileAsync,
24
24
  resolvePackageReconciliationPaths,
25
25
  type Target,
26
+ serializeJson,
27
+ serializeToml,
28
+ serializeYaml,
26
29
  wasiLoaderSuffix,
27
30
  wasiTargetHasThreads,
28
31
  withPackageFileSystemReconciliation,
@@ -1265,10 +1268,6 @@ function updateNapiConfigRecord(record: JsonRecord, options: RenameOptions) {
1265
1268
  }
1266
1269
  }
1267
1270
 
1268
- function serializeJsonLike(content: string, value: unknown) {
1269
- return `${JSON.stringify(value, null, 2)}${content.endsWith('\n') ? '\n' : ''}`
1270
- }
1271
-
1272
1271
  function sanitizeCargoPackageName(binaryName: string) {
1273
1272
  return binaryName.replace(/[^A-Za-z0-9_]/g, '_').toLowerCase()
1274
1273
  }
@@ -1458,8 +1457,7 @@ async function renameProjectUnlocked(
1458
1457
  const plan = new RenameTransactionPlan()
1459
1458
  plan.addWrite(
1460
1459
  packageJsonPath,
1461
- serializeJsonLike(
1462
- packageJsonContent,
1460
+ serializeJson(
1463
1461
  rewritePackageManifest(
1464
1462
  packageJsonData,
1465
1463
  managedWasiRenames,
@@ -1477,11 +1475,7 @@ async function renameProjectUnlocked(
1477
1475
  throw new Error(`NAPI config must contain a JSON object: ${configPath}`)
1478
1476
  }
1479
1477
  updateNapiConfigRecord(configData, options)
1480
- plan.addWrite(
1481
- configPath,
1482
- serializeJsonLike(configContent, configData),
1483
- configMode,
1484
- )
1478
+ plan.addWrite(configPath, serializeJson(configData), configMode)
1485
1479
  }
1486
1480
 
1487
1481
  if (binaryNameChanged) {
@@ -1495,7 +1489,7 @@ async function renameProjectUnlocked(
1495
1489
  const cargoToml = parseToml(tomlContent) as any
1496
1490
  if (cargoToml.package) {
1497
1491
  cargoToml.package.name = sanitizeCargoPackageName(newName)
1498
- plan.addWrite(cargoTomlPath, stringifyToml(cargoToml), cargoTomlMode)
1492
+ plan.addWrite(cargoTomlPath, serializeToml(cargoToml), cargoTomlMode)
1499
1493
  }
1500
1494
 
1501
1495
  const workflowPath = join(projectRoot, '.github', 'workflows', 'CI.yml')
@@ -1515,11 +1509,7 @@ async function renameProjectUnlocked(
1515
1509
  workflowData.env.APP_NAME = newName
1516
1510
  plan.addWrite(
1517
1511
  canonicalWorkflowPath,
1518
- yamlStringify(workflowData, {
1519
- lineWidth: -1,
1520
- noRefs: true,
1521
- sortKeys: false,
1522
- }),
1512
+ serializeYaml(workflowData),
1523
1513
  workflowMode,
1524
1514
  )
1525
1515
  }
@@ -1567,8 +1557,7 @@ async function renameProjectUnlocked(
1567
1557
  `Managed package manifest must contain a JSON object: ${targetPackageJsonPath}`,
1568
1558
  )
1569
1559
  }
1570
- const updatedContent = serializeJsonLike(
1571
- content,
1560
+ const updatedContent = serializeJson(
1572
1561
  rewritePackageManifest(
1573
1562
  manifest,
1574
1563
  managedWasiRenames,
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The `__napiBindingTarget` contract, shared by every generated loader.
3
+ *
4
+ * This module deliberately imports nothing: both `js-binding.ts` and
5
+ * `load-wasi-template.ts` depend on it, and `load-wasi-template.ts` otherwise
6
+ * has no top-level imports at all. Keeping the contract at the bottom of the
7
+ * graph is what lets both templates emit the same runtime helper without
8
+ * duplicating its source.
9
+ */
10
+
11
+ /**
12
+ * Named export every generated loader uses to report which binding artifact
13
+ * actually loaded: `'native'` for a `.node` addon, otherwise the
14
+ * `platformArchABI` of the WASI flavor (`'wasm32-wasi'`, `'wasm32-wasip1'`).
15
+ */
16
+ export const NAPI_BINDING_TARGET_EXPORT = '__napiBindingTarget'
17
+
18
+ /**
19
+ * `code` on the error the emitted loader throws when the binding it loaded
20
+ * already owns {@link NAPI_BINDING_TARGET_EXPORT}. Named after the other
21
+ * loader-thrown codes (`ERR_NAPI_WASI_LIFECYCLE_REENTRY`,
22
+ * `ERR_NAPI_WASI_CLEANUP_PENDING`, `ERR_NAPI_ASYNC_RUNTIME_BINDING_MISMATCH`)
23
+ * so a consumer can branch on it instead of on the message.
24
+ */
25
+ export const ERR_NAPI_BINDING_TARGET_CONFLICT =
26
+ 'ERR_NAPI_BINDING_TARGET_CONFLICT'
27
+
28
+ /** Name of the runtime helper {@link BINDING_TARGET_STAMP_HELPER} declares. */
29
+ export const NAPI_BINDING_TARGET_STAMP_FN = '__napiStampBindingTarget'
30
+
31
+ /**
32
+ * Reject an export of {@link NAPI_BINDING_TARGET_EXPORT} at build time.
33
+ *
34
+ * `idents` is the type-def export list, so this only sees what napi-rs type
35
+ * generation reports. A name attached imperatively by a
36
+ * `#[napi(module_exports)]` hook emits no type-def entry and is invisible here
37
+ * — the same blind spot `typeDefAvailable` documents for the sibling check.
38
+ * {@link BINDING_TARGET_STAMP_HELPER} is what catches those, at load time.
39
+ *
40
+ * What this check is load-bearing for: a duplicated ident would emit a
41
+ * duplicate `export const` in the ESM loader (a syntax error), and would make
42
+ * the CJS loader overwrite its own reported target. It also keeps the generated
43
+ * `.d.ts` free of a duplicate identifier.
44
+ *
45
+ * All three are consequences of emitting something, so callers must only ask
46
+ * when the build emits it: the loader templates check themselves, and the root
47
+ * `.d.ts` is checked behind `bindingTargetDeclarationPredicate`. A build that
48
+ * writes no loader reserves nothing.
49
+ */
50
+ export function assertBindingTargetIdentFree(idents: string[]): void {
51
+ if (idents.indexOf(NAPI_BINDING_TARGET_EXPORT) !== -1) {
52
+ throw new Error(
53
+ `\`${NAPI_BINDING_TARGET_EXPORT}\` is reserved by the generated binding loader. Rename the napi export, e.g. #[napi(js_name = "...")].`,
54
+ )
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Runtime helper emitted into every loader that stamps
60
+ * {@link NAPI_BINDING_TARGET_EXPORT} onto an exports object it does not own.
61
+ *
62
+ * Five outcomes, in order:
63
+ *
64
+ * | exports object state | result |
65
+ * | ------------------------------- | ----------------------------------------- |
66
+ * | own property, same value | no-op, returns `target` |
67
+ * | own property, different value | throw, `ERR_NAPI_BINDING_TARGET_CONFLICT` |
68
+ * | non-extensible, no own property | skip, returns `target` |
69
+ * | refuses the definition | skip, returns `target` |
70
+ * | otherwise | stamp, returns `target` |
71
+ *
72
+ * The stamp is `Object.defineProperty`, not an assignment. `hasOwnProperty`
73
+ * above sees own properties only and `Object.isExtensible` only own
74
+ * extensibility, so an ordinary assignment would still walk the prototype chain
75
+ * into an inherited accessor on a user-controlled object: its setter can throw,
76
+ * failing an otherwise successful load, or absorb the write and create nothing,
77
+ * leaving the named export the generated declaration promises resolving to
78
+ * `undefined`. `[[Define]]` consults no prototype, and the descriptor is the one
79
+ * a successful assignment would have produced. The `try` around it covers the
80
+ * one shape that can still refuse — an exotic object such as a `Proxy` whose
81
+ * `defineProperty` trap returns `false` — under the same rule as the
82
+ * non-extensible skip: metadata never fails a load.
83
+ *
84
+ * Every branch that does not throw returns `target`, because the CommonJS emit
85
+ * sites assign the return value —
86
+ * `module.exports.__napiBindingTarget = __napiStampBindingTarget(...)` — rather
87
+ * than calling it as a statement. Node's CJS -> ESM named export detection is
88
+ * `cjs-module-lexer`, a static scanner: it reports `__napiBindingTarget` as a
89
+ * named export only when it can see a `module.exports.<name> =` assignment, and
90
+ * a bare call is invisible to it, so `import { __napiBindingTarget }` from a
91
+ * generated CJS loader stops linking entirely.
92
+ *
93
+ * That assignment always succeeds, and it is never what a consumer reads: its
94
+ * target is the loader's own `module.exports`, an ordinary extensible object,
95
+ * and the alias on the next line replaces it with the binding itself. The value
96
+ * an import resolves to is therefore the one the stamp put on the binding, so
97
+ * when the guard skips — a sealed or frozen exports object — the CommonJS
98
+ * entries report `undefined`. `build.spec.ts` pins exactly that, in `a frozen
99
+ * addon keeps __napiBindingTarget importable, just undefined`. The ESM loaders
100
+ * are unaffected: theirs is a module-level `export const`, not a property of
101
+ * the object they hand out.
102
+ * (`Object.defineProperty` is not an alternative shape for the lexer, whatever
103
+ * it is inside the guard: the lexer matches a literal `module.exports.<name> =`
104
+ * and reports nothing for a data-descriptor `defineProperty` call, so the named
105
+ * import stops linking entirely — measured against Node's own detection.)
106
+ *
107
+ * The assignment target is never the object being stamped. Both CommonJS
108
+ * loaders stamp the addon's exports object but assign onto their own
109
+ * `module.exports`, which they replace with that object afterwards: an addon
110
+ * accessor can report the expected value from a getter and still throw from its
111
+ * setter, and only the guard's `hasOwnProperty`-and-read path is safe to run
112
+ * against it.
113
+ *
114
+ * Placement is the same rule in every loader that stamps a binding object:
115
+ * exactly one stamp, after the async runtime host installation — which hands
116
+ * that object to addon-provided registration functions that may reshape it —
117
+ * and inside the initialization guard that can undo a failed load: the `try`
118
+ * that rolls the WASI environment back, or the one that marks a deferred
119
+ * instance failed.
120
+ *
121
+ * The equal-value short circuit is required, not cosmetic: the root CJS loader
122
+ * aliases the object it loaded, so a `NAPI_RS_NATIVE_LIBRARY_PATH` override
123
+ * that is itself a generated WASI loader — and every WASI fallback candidate —
124
+ * hands back an object already carrying the value about to be stamped.
125
+ *
126
+ * Node 12 compatible (no optional chaining, no nullish coalescing) and valid in
127
+ * both sloppy CJS and strict ESM, because all four loaders emit it verbatim.
128
+ */
129
+ export const BINDING_TARGET_STAMP_HELPER = `function ${NAPI_BINDING_TARGET_STAMP_FN}(exportsObject, target) {
130
+ if (
131
+ Object.prototype.hasOwnProperty.call(exportsObject, '${NAPI_BINDING_TARGET_EXPORT}')
132
+ ) {
133
+ if (exportsObject.${NAPI_BINDING_TARGET_EXPORT} === target) {
134
+ // Already ours: the root entry aliases the object it loaded, so a WASI
135
+ // fallback candidate — or a \`NAPI_RS_NATIVE_LIBRARY_PATH\` override that
136
+ // is a generated loader — arrives already stamped with this same value.
137
+ return target
138
+ }
139
+ const error = new Error(
140
+ '\`${NAPI_BINDING_TARGET_EXPORT}\` is reserved by the generated binding loader, but the loaded binding already exports it. Rename the export, e.g. #[napi(js_name = "...")].',
141
+ )
142
+ error.code = '${ERR_NAPI_BINDING_TARGET_CONFLICT}'
143
+ throw error
144
+ }
145
+ if (!Object.isExtensible(exportsObject)) {
146
+ // A \`#[napi(module_exports)]\` hook may seal or freeze this object
147
+ // (\`Object::seal\` / \`Object::freeze\`). Reporting the artifact is metadata,
148
+ // never a reason to fail an otherwise successful load, so the stamp is
149
+ // skipped. What a consumer still sees then follows the entry point: the
150
+ // browser and deferred loaders declare \`${NAPI_BINDING_TARGET_EXPORT}\` at module
151
+ // level and go on reporting it, while the CommonJS entries hand back this
152
+ // very object as \`module.exports\`, so there the value is absent.
153
+ return target
154
+ }
155
+ try {
156
+ // [[Define]], not [[Set]]: an ordinary assignment walks the prototype
157
+ // chain, so an inherited accessor could swallow the value or throw and
158
+ // fail an otherwise successful load. The descriptor is what a successful
159
+ // assignment would have produced.
160
+ Object.defineProperty(exportsObject, '${NAPI_BINDING_TARGET_EXPORT}', {
161
+ configurable: true,
162
+ enumerable: true,
163
+ value: target,
164
+ writable: true,
165
+ })
166
+ } catch {
167
+ // Same rule as the non-extensible skip above: reporting the artifact is
168
+ // metadata, never a reason to fail an otherwise successful load. An exotic
169
+ // object (a Proxy whose defineProperty trap refuses) is skipped, not
170
+ // thrown over.
171
+ }
172
+ // The CommonJS loaders assign this return value so \`cjs-module-lexer\` — and
173
+ // therefore Node's CJS -> ESM named export detection — can see
174
+ // \`${NAPI_BINDING_TARGET_EXPORT}\` statically.
175
+ return target
176
+ }`
@@ -1 +1,2 @@
1
+ export * from './binding-target.js'
1
2
  export * from './js-binding.js'