@napi-rs/cli 3.9.1 → 3.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,6 +49,7 @@ export interface PackageMeta {
49
49
  }
50
50
 
51
51
  const WASM_RUNTIME_PACKAGE_NAME = '@napi-rs/wasm-runtime'
52
+ const ASYNC_RUNTIME_PACKAGE_NAME = '@napi-rs/async-runtime'
52
53
 
53
54
  interface PendingMetadataWrite {
54
55
  content: string
@@ -66,25 +67,25 @@ interface OwnedWasiPackage {
66
67
  target: Target
67
68
  }
68
69
 
69
- async function getLatestWasmRuntimeVersion() {
70
+ async function getLatestPackageVersion(packageName: string) {
70
71
  const npmRegistryBase =
71
72
  process.env.npm_config_registry?.replace(/\/?$/, '/') ??
72
73
  'https://registry.npmjs.org/'
73
- const packageMetadataUrl = `${npmRegistryBase}${WASM_RUNTIME_PACKAGE_NAME}`
74
+ const packageMetadataUrl = `${npmRegistryBase}${packageName}`
74
75
  let response: Response
75
76
 
76
77
  try {
77
78
  response = await fetch(packageMetadataUrl)
78
79
  } catch (error) {
79
80
  throw new Error(
80
- `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.`,
81
82
  { cause: error },
82
83
  )
83
84
  }
84
85
 
85
86
  if (!response.ok) {
86
87
  throw new Error(
87
- `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'}`,
88
89
  )
89
90
  }
90
91
 
@@ -94,7 +95,7 @@ async function getLatestWasmRuntimeVersion() {
94
95
  packageMeta = (await response.json()) as PackageMeta
95
96
  } catch (error) {
96
97
  throw new Error(
97
- `Failed to parse npm registry metadata for ${WASM_RUNTIME_PACKAGE_NAME} from ${packageMetadataUrl}`,
98
+ `Failed to parse npm registry metadata for ${packageName} from ${packageMetadataUrl}`,
98
99
  { cause: error },
99
100
  )
100
101
  }
@@ -103,7 +104,7 @@ async function getLatestWasmRuntimeVersion() {
103
104
 
104
105
  if (typeof latestVersion !== 'string' || latestVersion.trim().length === 0) {
105
106
  throw new Error(
106
- `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`,
107
108
  )
108
109
  }
109
110
 
@@ -446,9 +447,15 @@ async function createNpmDirsUnlocked(
446
447
  ),
447
448
  )
448
449
  ).flat()
449
- const wasmRuntimeVersion = targets.some((target) => target.arch === 'wasm32')
450
- ? await getLatestWasmRuntimeVersion()
451
- : 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
+ ])
452
459
  const pendingWrites: PendingMetadataWrite[] = []
453
460
 
454
461
  for (const target of targets) {
@@ -560,6 +567,11 @@ async function createNpmDirsUnlocked(
560
567
  '@napi-rs/wasm-runtime': `~${wasmRuntimeVersion}`,
561
568
  '@emnapi/core': emnapiVersion,
562
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
+ : {}),
563
575
  ...(wasm?.browser?.buffer === true &&
564
576
  (wasm.browser.fs !== true || !wasiTargetHasThreads(target))
565
577
  ? { buffer: directBufferDependency }
@@ -113,6 +113,10 @@ const wasiRuntimeDependencies = [
113
113
  '@napi-rs/wasm-runtime',
114
114
  '@emnapi/core',
115
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',
116
120
  'buffer',
117
121
  ]
118
122
  const require = createRequire(import.meta.url)
@@ -2618,7 +2622,7 @@ export async function commitPrePublishFileSystemTransaction({
2618
2622
  await commitFileSystemTransaction(transactionRoot, writes, removals)
2619
2623
  }
2620
2624
 
2621
- async function validateReleasePackageContents({
2625
+ export async function validateReleasePackageContents({
2622
2626
  pkgDir,
2623
2627
  rootDir,
2624
2628
  packageName,
@@ -3011,6 +3015,30 @@ function validateWasiReleasePackageManifest(
3011
3015
  `Release package ${packageJson.name} must omit buffer when its loaders do not import it`,
3012
3016
  )
3013
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
+ }
3014
3042
  }
3015
3043
 
3016
3044
  function requireStringDependency(
@@ -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'
@@ -1,5 +1,12 @@
1
1
  import { wasiLoaderSuffix } from '../../utils/index.js'
2
2
 
3
+ import {
4
+ assertBindingTargetIdentFree,
5
+ BINDING_TARGET_STAMP_HELPER,
6
+ NAPI_BINDING_TARGET_EXPORT,
7
+ NAPI_BINDING_TARGET_STAMP_FN,
8
+ } from './binding-target.js'
9
+
3
10
  function resolveWasiFlavors(wasiFlavors?: string[]): string[] {
4
11
  return wasiFlavors && wasiFlavors.length > 0 ? wasiFlavors : ['wasm32-wasi']
5
12
  }
@@ -62,6 +69,7 @@ function createWasiFallbackChain(
62
69
  }
63
70
  wasiBinding = require('${specifier}')
64
71
  nativeBinding = wasiBinding
72
+ __napiLoadedBindingTarget = '${flavor}'
65
73
  wasiBindingLoaded = true
66
74
  }
67
75
  } catch (err) {
@@ -126,6 +134,7 @@ export function createCjsBinding(
126
134
  wasiFlavors?: string[],
127
135
  localWasiName?: string,
128
136
  ): string {
137
+ assertBindingTargetIdentFree(idents)
129
138
  return `${bindingHeader}
130
139
  ${createCommonBinding(
131
140
  localName,
@@ -134,6 +143,23 @@ ${createCommonBinding(
134
143
  wasiFlavors,
135
144
  localWasiName,
136
145
  )}
146
+ ${BINDING_TARGET_STAMP_HELPER}
147
+ // Stamp before the alias, not after. The guard only reads \`nativeBinding\`
148
+ // (\`hasOwnProperty\` plus a comparison), which is safe against any addon
149
+ // accessor; an assignment is not, because a \`#[napi(module_exports)]\` hook can
150
+ // expose a getter reporting this very value and a setter that throws. So the
151
+ // assignment lands on the loader's own \`module.exports\`, still the original
152
+ // object here, and the alias below replaces it.
153
+ //
154
+ // The assignment is what keeps the marker a statically visible CommonJS export:
155
+ // \`cjs-module-lexer\` is Node's CJS -> ESM named export detection, it cannot see
156
+ // a bare call, and the later \`module.exports = nativeBinding\` does not undo the
157
+ // detection. The assignment itself always succeeds — its target is this
158
+ // loader's own, still extensible \`module.exports\` — and the alias below then
159
+ // discards the value it wrote. What a consumer reads is whatever the guard put
160
+ // on \`nativeBinding\`, so on a frozen binding, where the guard skips, the
161
+ // linked import resolves to \`undefined\`.
162
+ module.exports.${NAPI_BINDING_TARGET_EXPORT} = ${NAPI_BINDING_TARGET_STAMP_FN}(nativeBinding, __napiLoadedBindingTarget)
137
163
  module.exports = nativeBinding
138
164
  ${idents
139
165
  .map((ident) => `module.exports.${ident} = nativeBinding.${ident}`)
@@ -149,11 +175,17 @@ export function createEsmBinding(
149
175
  wasiFlavors?: string[],
150
176
  localWasiName?: string,
151
177
  ): string {
178
+ assertBindingTargetIdentFree(idents)
179
+ // Both branches must carry it, or a zero-ident package silently loses the
180
+ // export.
181
+ const bindingTargetExport = `export const ${NAPI_BINDING_TARGET_EXPORT} = __napiLoadedBindingTarget`
152
182
  const exportsCode =
153
183
  idents.length > 0
154
184
  ? `const { ${idents.join(', ')} } = nativeBinding
155
- ${idents.map((ident) => `export { ${ident} }`).join('\n')}`
156
- : 'export default nativeBinding'
185
+ ${idents.map((ident) => `export { ${ident} }`).join('\n')}
186
+ ${bindingTargetExport}`
187
+ : `export default nativeBinding
188
+ ${bindingTargetExport}`
157
189
  return `${bindingHeader}
158
190
  import { createRequire } from 'module'
159
191
  const require = createRequire(import.meta.url)
@@ -213,6 +245,10 @@ ${identLow}}${versionCheck}`
213
245
 
214
246
  return `const { readFileSync } = require('fs')
215
247
  let nativeBinding = null
248
+ // Which artifact actually loaded. The WASI fallback chain overwrites it with
249
+ // the flavor it resolved; the late native retry below leaves it alone because
250
+ // it only runs while no WASI candidate has been loaded.
251
+ let __napiLoadedBindingTarget = 'native'
216
252
  const loadErrors = []
217
253
 
218
254
  const isMusl = () => {
@@ -271,7 +307,16 @@ const isMuslFromChildProcess = () => {
271
307
  function requireNative() {
272
308
  if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {
273
309
  try {
274
- return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)
310
+ const overrideBinding = require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)
311
+ // The override may be a generated WASI loader, which already reports its
312
+ // own flavor. Adopt it: \`module.exports\` aliases this object, so claiming
313
+ // 'native' would both misreport the artifact and overwrite the loader's
314
+ // marker through the alias.
315
+ __napiLoadedBindingTarget =
316
+ overrideBinding && typeof overrideBinding.${NAPI_BINDING_TARGET_EXPORT} === 'string'
317
+ ? overrideBinding.${NAPI_BINDING_TARGET_EXPORT}
318
+ : 'native'
319
+ return overrideBinding
275
320
  } catch (err) {
276
321
  loadErrors.push(err)
277
322
  }