@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
package/src/api/build.ts CHANGED
@@ -41,11 +41,15 @@ import {
41
41
  removeNodeStreamWebTypeImports,
42
42
  rewriteUnboundNodeGlobalTypeQueries,
43
43
  rewriteTypeImportReferences,
44
+ scanExportedName,
44
45
  type Target,
45
46
  targetToEnvVar,
46
47
  tryInstallCargoBinary,
47
48
  unlinkAsync,
49
+ rustBundledWasiLibc,
50
+ wasiLibcHasNewFutexAbi,
48
51
  wasiLoaderSuffix,
52
+ wasiSdkMajorVersion,
49
53
  wasiTargetHasThreads,
50
54
  writeFileAtomic,
51
55
  withFileSystemReconciliation,
@@ -54,7 +58,12 @@ import {
54
58
  type CargoWorkspaceMetadata,
55
59
  } from '../utils/index.js'
56
60
 
57
- import { createCjsBinding, createEsmBinding } from './templates/index.js'
61
+ import {
62
+ assertBindingTargetIdentFree,
63
+ createCjsBinding,
64
+ createEsmBinding,
65
+ NAPI_BINDING_TARGET_EXPORT,
66
+ } from './templates/index.js'
58
67
  import {
59
68
  createWasiBinding,
60
69
  createWasiBrowserBinding,
@@ -77,6 +86,98 @@ const MANAGED_WASI_FLAVORS = [
77
86
  },
78
87
  ] as const
79
88
 
89
+ /**
90
+ * Everything a generated loader can report through `__napiBindingTarget`, as a
91
+ * TypeScript literal union. It lists every WASI flavor napi-rs knows instead of
92
+ * the flavors one package builds: `NAPI_RS_NATIVE_LIBRARY_PATH` may point at
93
+ * any generated WASI loader, and the root entry then reports what that loader
94
+ * loaded. A narrower union would make TypeScript reject the branches the
95
+ * override can actually reach.
96
+ */
97
+ const BINDING_TARGET_TYPE_UNION = [
98
+ "'native'",
99
+ ...MANAGED_WASI_FLAVORS.map((flavor) => `'${flavor.platformArchABI}'`),
100
+ ].join(' | ')
101
+
102
+ const BINDING_TARGET_TYPE_DECLARATION = `
103
+ /**
104
+ * Which binding artifact the generated loader actually loaded: \`'native'\` for
105
+ * a native addon, otherwise the \`platformArchABI\` of the WASI flavor. Every
106
+ * flavor napi-rs can build is listed, because \`NAPI_RS_NATIVE_LIBRARY_PATH\`
107
+ * can point the loader at a WASI artifact this package does not build itself.
108
+ */
109
+ export declare const ${NAPI_BINDING_TARGET_EXPORT}: ${BINDING_TARGET_TYPE_UNION}
110
+ `
111
+
112
+ /**
113
+ * The declaration for a file that types one fixed WASI artifact.
114
+ *
115
+ * A flavor's own loaders bake their `platformArchABI` in at generation time
116
+ * and have no override to read — `NAPI_RS_NATIVE_LIBRARY_PATH` exists only in
117
+ * the root loader — so the union {@link BINDING_TARGET_TYPE_DECLARATION}
118
+ * declares is unreachable for them, and declaring it would stop a consumer of
119
+ * a fixed artifact from narrowing. The wording matches the declaration the
120
+ * deferred `./workerd` entry already emits for the same reason.
121
+ */
122
+ const createBindingTargetFlavorDeclaration = (platformArchABI: string) => `
123
+ /** The WASI flavor this loader instantiates. */
124
+ export declare const ${NAPI_BINDING_TARGET_EXPORT}: '${platformArchABI}'
125
+ `
126
+
127
+ /**
128
+ * How `source` exports `__napiBindingTarget`, and whether it exports by
129
+ * assignment instead.
130
+ *
131
+ * Only a real top-level export counts, and only the parser can tell one from
132
+ * the other places the name reaches a declaration file: `napi-derive`'s
133
+ * `js_doc` copies doc comments through verbatim, a `--dts-header` may carry a
134
+ * commented-out example of the declaration — or of `export = binding` — and a
135
+ * member of a `declare namespace` or `declare module` block is an export of
136
+ * that block, not of the file. Importing any of those yields TS2305, so
137
+ * treating one as the declaration would leave the generated declaration
138
+ * unwritten. A longer export such as `__napiBindingTargetInfo` is a different
139
+ * name and never counts — `assertBindingTargetIdentFree` only reserves the
140
+ * exact one.
141
+ *
142
+ * {@link scanExportedName} carries the rest of the rule in its `ownsName`
143
+ * result: which export shapes claim the name, and why the ones that live in
144
+ * type space alone let the generated declaration merge with them instead.
145
+ */
146
+ const bindingTargetExports = (source: string) =>
147
+ scanExportedName(source, NAPI_BINDING_TARGET_EXPORT)
148
+
149
+ /**
150
+ * Every literal a declaration this CLI wrote can name. The root entry's union
151
+ * {@link BINDING_TARGET_TYPE_UNION} and a flavor's own literal
152
+ * ({@link createBindingTargetFlavorDeclaration}) are both spelled out of this
153
+ * set, so a declaration whose type is built only from these is one of ours and
154
+ * may be refreshed in place; anything else belongs to whoever wrote it.
155
+ *
156
+ * A flavor dropped from {@link MANAGED_WASI_FLAVORS} stops being recognised,
157
+ * and its declaration is preserved rather than refreshed: a literal this
158
+ * version can no longer write is indistinguishable from one a `--dts-header`
159
+ * contributed.
160
+ */
161
+ const MANAGED_BINDING_TARGET_LITERALS = new Set([
162
+ 'native',
163
+ ...MANAGED_WASI_FLAVORS.map((flavor) => flavor.platformArchABI),
164
+ ])
165
+
166
+ /**
167
+ * Whether a declared type is one this CLI wrote. Keyed on the type rather than
168
+ * on the doc comment above it, so rewording a comment cannot strand a
169
+ * declaration this version would otherwise refresh.
170
+ */
171
+ const isGeneratedBindingTargetType = (type: string) =>
172
+ type
173
+ .split('|')
174
+ .map((member) => member.trim())
175
+ .every(
176
+ (member) =>
177
+ /^'[^']*'$/.test(member) &&
178
+ MANAGED_BINDING_TARGET_LITERALS.has(member.slice(1, -1)),
179
+ )
180
+
80
181
  type OutputKind = 'js' | 'dts' | 'node' | 'exe' | 'wasm'
81
182
  type Output = { kind: OutputKind; path: string }
82
183
  type WasiBindingMetadata = {
@@ -94,6 +195,70 @@ type ParsedBuildOptions = Omit<BuildOptions, 'cwd' | 'format'> & {
94
195
 
95
196
  export const WASI_ARTIFACT_METADATA_PREFIX = '// napi-rs-artifact-metadata:'
96
197
 
198
+ // The host protocol a binding built with the `napi-async-runtime` crate
199
+ // exposes (contract version 4). See crates/async-runtime/README.md.
200
+ const ASYNC_RUNTIME_HOST_EXPORTS = [
201
+ 'getCurrentThreadTaskHostContractVersion',
202
+ 'isCurrentThreadHostRegistrationActive',
203
+ 'registerCurrentThreadTaskHost',
204
+ 'registerTimerHost',
205
+ 'reserveCurrentThreadHostRegistration',
206
+ 'unregisterCurrentThreadTaskHost',
207
+ 'unregisterTimerHost',
208
+ ] as const
209
+
210
+ export interface AsyncRuntimeHostContractInput {
211
+ /** The addon's export names, as reported by the type definition pass. */
212
+ idents: string[]
213
+ /** Whether `napi.wasm.asyncRuntime` is on. */
214
+ asyncRuntime: boolean
215
+ /**
216
+ * Whether `idents` is real export metadata. `napi-derive` without its
217
+ * `type-def` feature emits no type definitions, so the ident list is empty
218
+ * for every such addon — including one that does export the whole host
219
+ * contract. An empty list is only evidence when this is `true`.
220
+ */
221
+ typeDefAvailable: boolean
222
+ packageName: string
223
+ }
224
+
225
+ /**
226
+ * Cross-check `napi.wasm.asyncRuntime` against the addon's export list.
227
+ *
228
+ * Returns the message to throw (`error`) or to log (`warning`), if any. With
229
+ * no type-def metadata there is nothing to check against, so the build says so
230
+ * and defers to the loader's own runtime detection.
231
+ */
232
+ export function checkAsyncRuntimeHostContract({
233
+ idents,
234
+ asyncRuntime,
235
+ typeDefAvailable,
236
+ packageName,
237
+ }: AsyncRuntimeHostContractInput): { error?: string; warning?: string } {
238
+ if (!typeDefAvailable) {
239
+ if (!asyncRuntime) {
240
+ return {}
241
+ }
242
+ return {
243
+ warning: `napi.wasm.asyncRuntime is enabled but ${packageName} is built without the \`type-def\` feature of \`napi-derive\`, so \`napi build\` has no export list to check the napi-async-runtime host contract against. The generated loaders still verify the contract at load time and fail with ERR_NAPI_ASYNC_RUNTIME_BINDING_MISMATCH if the binding does not expose it.`,
244
+ }
245
+ }
246
+ const missingHostExports = ASYNC_RUNTIME_HOST_EXPORTS.filter(
247
+ (name) => !idents.includes(name),
248
+ )
249
+ if (asyncRuntime && missingHostExports.length > 0) {
250
+ return {
251
+ error: `napi.wasm.asyncRuntime is enabled but ${packageName} does not export the napi-async-runtime host contract. Missing: ${missingHostExports.join(', ')}. Build the addon against the \`napi-async-runtime\` crate (see crates/async-runtime/README.md) or unset napi.wasm.asyncRuntime.`,
252
+ }
253
+ }
254
+ if (!asyncRuntime && missingHostExports.length === 0) {
255
+ return {
256
+ warning: `${packageName} exports the napi-async-runtime host contract but napi.wasm.asyncRuntime is not enabled. The generated WASI loaders will not install the CurrentThread task and timer hosts, so async exports will never make progress unless the host is installed by hand.`,
257
+ }
258
+ }
259
+ return {}
260
+ }
261
+
97
262
  type CargoConfigFingerprint = readonly [path: string, hash: string]
98
263
 
99
264
  export function getCargoDependencyGraphFingerprint(
@@ -223,6 +388,83 @@ export function getTypeDefCacheFolder(options: {
223
388
  return join(options.targetDir, 'napi-rs', `${options.crateName}-${hash}`)
224
389
  }
225
390
 
391
+ /**
392
+ * Name of the emnapi archive directory built against the wasi-sdk 34 ABI.
393
+ * Only the threaded target has one: the signature change it carries lives in
394
+ * the threads-only futex code.
395
+ */
396
+ export const EMNAPI_WASI_SDK_34_LINK_DIR = 'wasm32-wasip1-threads-wasi-sdk-34'
397
+
398
+ export interface EmnapiLinkDirSelection {
399
+ /** Directory name under `emnapi/lib` whose archives should be linked. */
400
+ linkDirName: string
401
+ /** Detected wasi-sdk major version, `null` when no wasi-sdk is configured. */
402
+ wasiSdkMajor: number | null
403
+ /** `true` when the toolchain needs the wasi-sdk 34 archives. */
404
+ needsWasiSdk34: boolean
405
+ }
406
+
407
+ /**
408
+ * Pick the emnapi archive directory for a WASI build.
409
+ *
410
+ * wasi-libc removed the unused `int op` parameter from
411
+ * `__wasilibc_futex_wait_atomic_wait` / `__wasilibc_futex_wait_maybe_busy`,
412
+ * and wasi-sdk 34 is the first release shipping the 3-argument signature.
413
+ * Linking the legacy 4-argument archives against a wasi-sdk >= 34 sysroot
414
+ * makes `wasm-ld` report `function signature mismatch` and emit an invalid
415
+ * wasm module, so emnapi publishes a second archive set for the new ABI and
416
+ * the directory has to be chosen by wasi-sdk version, not by target triple.
417
+ *
418
+ * Without `WASI_SDK_PATH`, cargo links through `rust-lld` against the
419
+ * wasi-libc bundled with the Rust standard library, so that archive is
420
+ * probed instead. Rust picked up wasi-sdk 34 in rust-lang/rust#161773, which
421
+ * lands in 1.100, and a toolchain carrying it needs the new archives even
422
+ * though no wasi-sdk is configured.
423
+ *
424
+ * The legacy directory stays the default whenever the ABI cannot be
425
+ * determined, and older emnapi releases ship it alone.
426
+ */
427
+ export function selectEmnapiLinkDir(
428
+ emnapiLibDir: string,
429
+ wasiTarget: string,
430
+ hasThreads: boolean,
431
+ wasiSdkPath: string | undefined,
432
+ {
433
+ cwd,
434
+ rustWasiLibc,
435
+ }: {
436
+ // The directory Cargo is spawned in. It selects the toolchain, so the
437
+ // sysroot probe has to use it too.
438
+ cwd?: string
439
+ // Omit to resolve the Rust sysroot lazily, so a configured wasi-sdk never
440
+ // pays for a `rustc` invocation. Pass `null` to skip the probe.
441
+ rustWasiLibc?: string | null
442
+ } = {},
443
+ ): EmnapiLinkDirSelection {
444
+ const usingWasiSdk = Boolean(wasiSdkPath) && existsSync(wasiSdkPath!)
445
+ const wasiSdkMajor = usingWasiSdk ? wasiSdkMajorVersion(wasiSdkPath!) : null
446
+ // Whichever wasi-libc actually gets linked decides the ABI: the wasi-sdk
447
+ // sysroot when one is configured, otherwise the copy bundled with the Rust
448
+ // standard library. Only the threaded target has a second archive set, so
449
+ // `hasThreads` gates the probe as well as the result — a threadless build
450
+ // must not pay for a `rustc` invocation and an archive read it cannot use.
451
+ const needsWasiSdk34 =
452
+ hasThreads &&
453
+ (usingWasiSdk
454
+ ? wasiSdkMajor !== null && wasiSdkMajor >= 34
455
+ : wasiLibcHasNewFutexAbi(
456
+ rustWasiLibc === undefined
457
+ ? rustBundledWasiLibc(wasiTarget, cwd)
458
+ : rustWasiLibc,
459
+ ) === true)
460
+ const linkDirName =
461
+ needsWasiSdk34 &&
462
+ existsSync(join(emnapiLibDir, EMNAPI_WASI_SDK_34_LINK_DIR))
463
+ ? EMNAPI_WASI_SDK_34_LINK_DIR
464
+ : wasiTarget
465
+ return { linkDirName, wasiSdkMajor, needsWasiSdk34 }
466
+ }
467
+
226
468
  export function createWasiCompilerFlags(
227
469
  wasiSdkPath: string,
228
470
  wasiTarget: string,
@@ -406,9 +648,11 @@ export function createWasiBrowserEntry(
406
648
  export function createWasiDeferredBindingTypeDef(
407
649
  bindingModuleSpecifier: string,
408
650
  hasTypeDef: boolean,
651
+ platformArchABI?: string,
409
652
  ) {
410
653
  const typeDef = createWasiDeferredBrowserBindingTypeDef(
411
654
  bindingModuleSpecifier,
655
+ platformArchABI,
412
656
  )
413
657
  if (hasTypeDef) {
414
658
  return typeDef
@@ -424,6 +668,133 @@ export function createWasiDeferredBindingTypeDef(
424
668
  return typeDef.replace(rootBindingType, 'Record<string, unknown>')
425
669
  }
426
670
 
671
+ /** memory32 tops out at 4 GiB, which is also `--max-memory` in `crates/build/src/wasi.rs`. */
672
+ const WASM32_MAX_MEMORY_PAGES = 65536
673
+
674
+ function validateWasmMemoryPages(
675
+ value: number | undefined,
676
+ key: string,
677
+ ): number | undefined {
678
+ if (value === undefined) {
679
+ return undefined
680
+ }
681
+ if (
682
+ !Number.isInteger(value) ||
683
+ value < 1 ||
684
+ value > WASM32_MAX_MEMORY_PAGES
685
+ ) {
686
+ throw new Error(
687
+ `${key} must be an integer between 1 and ${WASM32_MAX_MEMORY_PAGES} pages; received ${value}`,
688
+ )
689
+ }
690
+ return value
691
+ }
692
+
693
+ export interface ResolvedWasmMemory {
694
+ /** `undefined` keeps each template's own default (4000, or 1024 for `./workerd`). */
695
+ initialMemory?: number
696
+ /** `undefined` keeps the template default (65536). */
697
+ maximumMemory?: number
698
+ }
699
+
700
+ /**
701
+ * Resolve the `WebAssembly.Memory` descriptor the generated loaders embed, for
702
+ * one WASI flavor.
703
+ *
704
+ * The threaded loaders share one `shared: true` memory with every wasi-threads
705
+ * worker, so it is sized for the whole pool. The threadless loaders have a
706
+ * single thread and a plain growable `ArrayBuffer`, so they only need to clear
707
+ * the module's link-time floor (`-zstack-size` plus static data) and grow on
708
+ * demand. `napi.wasm.threadlessInitialMemory` sizes the threadless loaders
709
+ * without shrinking the threaded one; unset, it falls back to
710
+ * `napi.wasm.initialMemory` and nothing changes.
711
+ */
712
+ export function resolveWasmMemory(
713
+ wasm: NapiConfig['wasm'],
714
+ hasThreads: boolean,
715
+ ): ResolvedWasmMemory {
716
+ const initialMemory = validateWasmMemoryPages(
717
+ wasm?.initialMemory,
718
+ 'napi.wasm.initialMemory',
719
+ )
720
+ const threadlessInitialMemory = validateWasmMemoryPages(
721
+ wasm?.threadlessInitialMemory,
722
+ 'napi.wasm.threadlessInitialMemory',
723
+ )
724
+ const maximumMemory = validateWasmMemoryPages(
725
+ wasm?.maximumMemory,
726
+ 'napi.wasm.maximumMemory',
727
+ )
728
+ const ceiling = maximumMemory ?? WASM32_MAX_MEMORY_PAGES
729
+ for (const [key, value] of [
730
+ ['napi.wasm.initialMemory', initialMemory],
731
+ ['napi.wasm.threadlessInitialMemory', threadlessInitialMemory],
732
+ ] as const) {
733
+ if (value !== undefined && value > ceiling) {
734
+ throw new Error(
735
+ `${key} (${value} pages) must not exceed napi.wasm.maximumMemory (${ceiling} pages)`,
736
+ )
737
+ }
738
+ }
739
+ return {
740
+ // The deferred `./workerd` loader is only emitted for threadless flavors
741
+ // (see `writeWasiBindingForTarget`), so it receives this same value.
742
+ initialMemory: hasThreads
743
+ ? initialMemory
744
+ : (threadlessInitialMemory ?? initialMemory),
745
+ maximumMemory,
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Normalize a rendered `.d.ts` header. The generated body is concatenated
751
+ * straight onto it, so it always ends with a blank line — and an empty header
752
+ * stays empty, so a file with no header keeps no leading whitespace.
753
+ */
754
+ function finalizeTypeDefHeader(header: string) {
755
+ let normalized = header
756
+ if (normalized && !normalized.endsWith('\n')) {
757
+ normalized += '\n'
758
+ }
759
+ if (normalized && !normalized.endsWith('\n\n')) {
760
+ normalized += '\n'
761
+ }
762
+ return normalized
763
+ }
764
+
765
+ /**
766
+ * Whether a build's declaration file should declare `__napiBindingTarget`.
767
+ *
768
+ * The export exists only on a generated loader, so the declaration has to
769
+ * follow the loaders a build actually writes. This mirrors both writers:
770
+ *
771
+ * - {@link writeJsBinding} writes the root loader for `--platform` without
772
+ * `--no-js-binding` (`rootLoaderCandidate`), and then only when the build has
773
+ * runtime exports or a WASI flavor to fall back to.
774
+ * - `Builder.writeWasiBinding` writes a flavor's loader set when that flavor is
775
+ * the build target, or when an earlier build of it left metadata on disk
776
+ * (`emitsWasiLoader`). A merely configured flavor is skipped, and a build
777
+ * that skips every flavor emits no loader to carry the export.
778
+ *
779
+ * The export list is only known once typegen has run, and typegen writes the
780
+ * declaration file itself, so the decision comes back as a predicate over that
781
+ * list instead of a flag taken up front.
782
+ *
783
+ * It gates the *reservation* of the name too — `Builder.generateTypeDef` calls
784
+ * {@link assertBindingTargetIdentFree} only when this answers `true`. Declaring
785
+ * the name and forbidding an addon from exporting it are the same question, and
786
+ * a build that emits no loader asks neither.
787
+ */
788
+ export function bindingTargetDeclarationPredicate(input: {
789
+ rootLoaderCandidate: boolean
790
+ hasWasiFallback: boolean
791
+ emitsWasiLoader: boolean
792
+ }): (exports: readonly string[]) => boolean {
793
+ return (exports) =>
794
+ input.emitsWasiLoader ||
795
+ (input.rootLoaderCandidate && (input.hasWasiFallback || exports.length > 0))
796
+ }
797
+
427
798
  export function prepareWasiBindingTypeDef(
428
799
  source: string,
429
800
  sourcePath: string,
@@ -447,6 +818,136 @@ export function prepareWasiBindingTypeDef(
447
818
  return rebaseDeclarationSpecifiers(targetSource, sourcePath, destinationPath)
448
819
  }
449
820
 
821
+ /**
822
+ * Make a declaration file declare the `__napiBindingTarget` its loader exports.
823
+ *
824
+ * `platformArchABI` names the WASI flavor the file types, and the declaration
825
+ * is then that flavor's exact literal; without it the file is the root entry's
826
+ * and keeps the full union, which only the root entry can reach.
827
+ *
828
+ * Two kinds of file arrive here. A WASI declaration derived fresh from the root
829
+ * `index.d.ts` inherits that union and has to be narrowed. A declaration kept
830
+ * from an earlier build — a build that does not target WASI regenerates every
831
+ * declared flavor's loader from the metadata that flavor's own build left
832
+ * behind, and reuses its declaration file verbatim — may predate the export
833
+ * entirely, or carry a union an earlier version of this branch wrote.
834
+ *
835
+ * Either may also carry a declaration nobody here wrote, because a project's
836
+ * own `--dts-header` may declare the export to work around loaders that predate
837
+ * it. So ownership, not mere presence, decides what happens.
838
+ *
839
+ * Three cases, decided in this order:
840
+ *
841
+ * - The declaration the rendered `.d.ts` header owns. `renderedHeader` is that
842
+ * header as this build rendered it; if it declares the export, the project's
843
+ * own declaration wins: nothing is appended, and it is never rewritten.
844
+ * Declaring it a second time would be a TS2451 redeclaration.
845
+ *
846
+ * The header is matched by the text of its declaration against the first
847
+ * declaration in the file, not by a prefix of the file. A WASI declaration is
848
+ * derived through {@link prepareWasiBindingTypeDef}, which rewrites relative
849
+ * import specifiers and, for the threadless flavor, drops `node:stream/web`
850
+ * imports — a header carrying either stops being a literal prefix of the file
851
+ * derived from it, while the declaration statement itself comes through
852
+ * untouched and stays the first one, because those rewrites never reorder
853
+ * statements. On the preserved path the header in hand is this build's, not
854
+ * the one the kept file was written with, and a kept declaration that reads
855
+ * differently is correctly not the header's.
856
+ * - A declaration the header does not own, alone in its statement, whose type
857
+ * is built only out of the literals this CLI writes
858
+ * ({@link isGeneratedBindingTargetType}), is one of ours. Stale — an older
859
+ * union, or the other flavor's literal — it is replaced in place, comment
860
+ * and all, so a file kept from an earlier build ends up saying what the
861
+ * loader written beside it now reports.
862
+ * - Any other export of the name is someone else's and is preserved, with
863
+ * nothing appended beside it. A looser match would rewrite a declaration a
864
+ * `--dts-header` contributed. That covers a statement declaring more names
865
+ * than this one — the block a refresh replaces spans them too, and this CLI
866
+ * writes the declaration alone — and it covers every other way a header can
867
+ * claim the name: as a `function`, `class`, `enum` or instantiated
868
+ * `namespace`, through an `export { target as __napiBindingTarget }` or
869
+ * `export * as __napiBindingTarget from '…'` clause, or through
870
+ * `export import __napiBindingTarget = …`. None of those leaves a
871
+ * declaration here to rewrite, and an export added beside any of them is a
872
+ * TypeScript error (TS2300, TS2323, TS2440 or TS2567 by form).
873
+ *
874
+ * A header that exports the name in type space only — a `type`, an
875
+ * `interface`, a namespace that declares no value — claims nothing: the
876
+ * declaration merges with it, and withholding it would cost a consumer the
877
+ * value the loader really exports — see {@link scanExportedName}.
878
+ *
879
+ * A declaration file that exports by assignment (`export = binding`, what a
880
+ * build without `napi-derive`'s `type-def` feature emits) cannot carry a named
881
+ * export declaration, so it is left alone; its exports are untyped anyway.
882
+ * That, too, is read off the parse — a block comment documenting the CommonJS
883
+ * form is not an export assignment.
884
+ */
885
+ export function ensureBindingTargetDeclaration(
886
+ typeDef: string,
887
+ platformArchABI?: string,
888
+ renderedHeader?: string,
889
+ ) {
890
+ const { exportsByAssignment, ownsName, declarations } =
891
+ bindingTargetExports(typeDef)
892
+ if (exportsByAssignment) {
893
+ return typeDef
894
+ }
895
+ const declaration = platformArchABI
896
+ ? createBindingTargetFlavorDeclaration(platformArchABI)
897
+ : BINDING_TARGET_TYPE_DECLARATION
898
+ if (!ownsName) {
899
+ const separator = typeDef.length === 0 || typeDef.endsWith('\n') ? '' : '\n'
900
+ return `${typeDef}${separator}${declaration}`
901
+ }
902
+ // Compared declarator by declarator rather than statement by statement: one
903
+ // statement may declare more names, and rebasing a relative inline import in
904
+ // a sibling's type rewrites the statement without touching this declarator.
905
+ const headerDeclarator = renderedHeader
906
+ ? bindingTargetExports(renderedHeader).declarations.map((found) =>
907
+ renderedHeader.slice(found.declaratorStart, found.declaratorEnd),
908
+ )[0]
909
+ : undefined
910
+ const headerOwnsFirst =
911
+ headerDeclarator !== undefined &&
912
+ declarations.length > 0 &&
913
+ typeDef.slice(
914
+ declarations[0].declaratorStart,
915
+ declarations[0].declaratorEnd,
916
+ ) === headerDeclarator
917
+ for (const [index, found] of declarations.entries()) {
918
+ if (headerOwnsFirst && index === 0) {
919
+ continue
920
+ }
921
+ if (
922
+ // A statement that declares more than this one name belongs to whoever
923
+ // wrote it whatever its type says: the block a refresh replaces covers
924
+ // those names too, and this CLI has never written one, so a declaration
925
+ // with siblings is not a declaration of ours to refresh.
926
+ found.declaratorCount > 1 ||
927
+ found.type === undefined ||
928
+ !isGeneratedBindingTargetType(found.type)
929
+ ) {
930
+ continue
931
+ }
932
+ // Replacing the block instead of appending: a file that carries a union,
933
+ // or the other flavor's literal, has to be narrowed to the flavor it
934
+ // types rather than gaining a second, conflicting declaration. The span
935
+ // covers the doc comment above the statement, so the comment is swapped
936
+ // with it rather than stranded — and the statement's own `;`, which has to
937
+ // come back, or a statement that followed on the same line runs into the
938
+ // replacement (TS1005).
939
+ const terminator =
940
+ typeDef.slice(found.end - 1, found.end) === ';' ? ';' : ''
941
+ return (
942
+ typeDef.slice(0, found.start) +
943
+ declaration.trim() +
944
+ terminator +
945
+ typeDef.slice(found.end)
946
+ )
947
+ }
948
+ return typeDef
949
+ }
950
+
450
951
  export function collectStaleWasiBuildOutputNames(
451
952
  binaryName: string,
452
953
  buildTarget: Target,
@@ -975,6 +1476,12 @@ class Builder {
975
1476
  private readonly enableTypeDef: boolean = false
976
1477
  private readonly stagedOutputDestinations = new Map<string, string>()
977
1478
  private typeDefWithTypeImports: string | undefined
1479
+ /**
1480
+ * The `.d.ts` header this build rendered, so `writeWasiBindingForTarget` can
1481
+ * tell a `__napiBindingTarget` declaration the header owns from one typegen
1482
+ * wrote. `undefined` until typegen runs, and for a build with no typegen.
1483
+ */
1484
+ private typeDefRenderedHeader: string | undefined
978
1485
 
979
1486
  constructor(
980
1487
  private readonly metadata: CargoWorkspaceMetadata,
@@ -1234,6 +1741,31 @@ class Builder {
1234
1741
  return entries
1235
1742
  }
1236
1743
 
1744
+ /**
1745
+ * Whether {@link Builder.writeWasiBinding} will actually write a WASI loader
1746
+ * set. A declared WASI target is not enough: a non-WASI build only
1747
+ * regenerates the flavors whose previous loader metadata is still on disk and
1748
+ * skips the rest, so a configured-but-never-built flavor leaves no loader to
1749
+ * carry `__napiBindingTarget`.
1750
+ *
1751
+ * Safe to call from {@link Builder.generateTypeDef}: the metadata probe reads
1752
+ * `finalOutputDir`, which the staging swap never touches, and nothing writes
1753
+ * those files between this probe and `writeWasiBinding`.
1754
+ */
1755
+ private async willEmitWasiLoader() {
1756
+ if (this.target.platform === 'wasi') {
1757
+ return true
1758
+ }
1759
+ for (const wasiTarget of this.config.targets.filter(
1760
+ (target) => target.platform === 'wasi',
1761
+ )) {
1762
+ if (await this.readExistingWasiBindingMetadata(wasiTarget)) {
1763
+ return true
1764
+ }
1765
+ }
1766
+ return false
1767
+ }
1768
+
1237
1769
  private async readExistingWasiBindingMetadata(wasiTarget: Target) {
1238
1770
  const loaderSuffix = wasiLoaderSuffix(wasiTarget.platformArchABI)
1239
1771
  const bindingPath = join(
@@ -1461,16 +1993,40 @@ class Builder {
1461
1993
  private setWasiEnv() {
1462
1994
  const hasThreads = wasiTargetHasThreads(this.target)
1463
1995
  const wasiTarget = hasThreads ? 'wasm32-wasip1-threads' : 'wasm32-wasip1'
1464
- const emnapi = join(require.resolve('emnapi'), '..', 'lib', wasiTarget)
1996
+ const emnapiLibDir = join(require.resolve('emnapi'), '..', 'lib')
1465
1997
  const emnapiVersion = require('emnapi/package.json').version
1998
+ const { WASI_SDK_PATH } = process.env
1999
+ // `wasiTarget` stays the real target triple, because it is what the clang
2000
+ // driver is told to build for below. The emnapi archive directory is a
2001
+ // separate choice: emnapi ships two archive sets for the threaded target,
2002
+ // one per wasi-libc futex ABI. See `selectEmnapiLinkDir`.
2003
+ const { linkDirName, wasiSdkMajor, needsWasiSdk34 } = selectEmnapiLinkDir(
2004
+ emnapiLibDir,
2005
+ wasiTarget,
2006
+ hasThreads,
2007
+ WASI_SDK_PATH,
2008
+ { cwd: this.options.cwd },
2009
+ )
2010
+ const emnapi = join(emnapiLibDir, linkDirName)
1466
2011
  // Keep this in sync with `emnapi_link_library` in `crates/build/src/wasi.rs`.
1467
2012
  const emnapiArchive = join(
1468
2013
  emnapi,
1469
2014
  hasThreads ? 'libemnapi-napi-rs-mt.a' : 'libemnapi-basic-napi-rs.a',
1470
2015
  )
2016
+ const fellBackToLegacy =
2017
+ needsWasiSdk34 && linkDirName !== EMNAPI_WASI_SDK_34_LINK_DIR
1471
2018
  if (!existsSync(emnapiArchive)) {
1472
2019
  throw new Error(
1473
- `emnapi@${emnapiVersion} is missing the ${wasiTarget} archive required by napi-rs at ${emnapiArchive}. Install emnapi v2 with support for this target.`,
2020
+ fellBackToLegacy
2021
+ ? `emnapi@${emnapiVersion} does not ship the ${EMNAPI_WASI_SDK_34_LINK_DIR} archives that wasi-sdk ${wasiSdkMajor} requires, and the ${linkDirName} archive it fell back to is missing too at ${emnapiArchive}. Upgrade emnapi to a version that ships the wasi-sdk 34 archives.`
2022
+ : needsWasiSdk34
2023
+ ? `emnapi@${emnapiVersion} is missing the ${linkDirName} archive required by napi-rs at ${emnapiArchive}. wasi-sdk ${wasiSdkMajor} needs those archives, so upgrade emnapi to a version that ships them.`
2024
+ : `emnapi@${emnapiVersion} is missing the ${linkDirName} archive required by napi-rs at ${emnapiArchive}. Install emnapi v2 with support for this target.`,
2025
+ )
2026
+ }
2027
+ if (fellBackToLegacy) {
2028
+ debug.warn(
2029
+ `emnapi@${emnapiVersion} does not ship the ${EMNAPI_WASI_SDK_34_LINK_DIR} archives that wasi-sdk ${wasiSdkMajor} requires. Falling back to ${linkDirName}, which links the legacy wasi-libc futex ABI and may fail with \`wasm-ld: function signature mismatch\`. Upgrade emnapi to fix this.`,
1474
2030
  )
1475
2031
  }
1476
2032
  this.envs.EMNAPI_LINK_DIR = emnapi
@@ -1499,7 +2055,6 @@ class Builder {
1499
2055
  `emnapi version mismatch: emnapi@${emnapiVersion}, @emnapi/core@${emnapiCoreVersion}, @emnapi/runtime@${emnapiRuntimeVersion}. Please ensure all emnapi packages are the same version.`,
1500
2056
  )
1501
2057
  }
1502
- const { WASI_SDK_PATH } = process.env
1503
2058
 
1504
2059
  if (WASI_SDK_PATH && existsSync(WASI_SDK_PATH)) {
1505
2060
  this.envs.CARGO_TARGET_WASM32_WASI_PREVIEW1_THREADS_LINKER = join(
@@ -1966,7 +2521,20 @@ class Builder {
1966
2521
  return []
1967
2522
  }
1968
2523
 
1969
- const { exports, dts, dtsWithTypeImports } = await generateTypeDef({
2524
+ // Declare the loader export only for builds that actually emit a loader:
2525
+ // the native root loader (`--platform` without `--no-js-binding`) or a WASI
2526
+ // flavor loader set this build writes. Whether the root loader is written
2527
+ // also depends on the exports typegen is about to produce, so the decision
2528
+ // travels as a predicate — see `bindingTargetDeclarationPredicate`.
2529
+ const declareBindingTarget = bindingTargetDeclarationPredicate({
2530
+ rootLoaderCandidate:
2531
+ Boolean(this.options.platform) && !this.options.noJsBinding,
2532
+ // the same fallback list `writeJsBinding` is handed
2533
+ hasWasiFallback: this.declaredWasiFlavors().length > 0,
2534
+ emitsWasiLoader: await this.willEmitWasiLoader(),
2535
+ })
2536
+
2537
+ const { exports, dts, dtsWithTypeImports, header } = await generateTypeDef({
1970
2538
  typeDefDir,
1971
2539
  noDtsHeader: this.options.noDtsHeader,
1972
2540
  dtsHeader: this.options.dtsHeader,
@@ -1976,8 +2544,18 @@ class Builder {
1976
2544
  runtimeStringEnum:
1977
2545
  this.options.runtimeStringEnum ?? this.config.runtimeStringEnum,
1978
2546
  cwd: this.options.cwd,
2547
+ declareBindingTarget,
1979
2548
  })
2549
+ // The name is reserved exactly when a loader reports it and this file
2550
+ // declares it — one condition, so ask the one predicate. A build that emits
2551
+ // no loader (no `--platform`, or `--no-js`, with no WASI loader set
2552
+ // regenerated) declares nothing, and must keep accepting an addon export of
2553
+ // this name the way every release before the export did.
2554
+ if (declareBindingTarget(exports)) {
2555
+ assertBindingTargetIdentFree(exports)
2556
+ }
1980
2557
  this.typeDefWithTypeImports = dtsWithTypeImports
2558
+ this.typeDefRenderedHeader = header
1981
2559
 
1982
2560
  const typeDefRelativePath = this.options.dts ?? 'index.d.ts'
1983
2561
  const finalDest = join(this.finalOutputDir, typeDefRelativePath)
@@ -1993,7 +2571,13 @@ class Builder {
1993
2571
  throw new Error(`Failed to write type def file ${dest}`, { cause: e })
1994
2572
  }
1995
2573
 
1996
- if (exports.length > 0) {
2574
+ // The file is written unconditionally above, so track it whenever this
2575
+ // build filled it. Runtime exports are one reason it has content; the
2576
+ // `__napiBindingTarget` declaration is another, and a crate that registers
2577
+ // everything from a `#[napi(module_exports)]` hook has only the second.
2578
+ // `--pipe` (`cli/src/commands/build.ts`) and the `NapiCli.build` return
2579
+ // value see registered outputs only.
2580
+ if (dts.length > 0) {
1997
2581
  this.outputs.push({ kind: 'dts', path: dest })
1998
2582
  }
1999
2583
 
@@ -2021,10 +2605,13 @@ class Builder {
2021
2605
  return stagedPath
2022
2606
  }
2023
2607
 
2024
- private async writeJsBinding(idents: string[]) {
2025
- // Default WASI fallback order: threaded first. The generated root loader
2026
- // also lets consumers pin one exact declared flavor with
2027
- // NAPI_RS_WASI_FLAVOR.
2608
+ /**
2609
+ * `platformArchABI`s of every WASI flavor this build's loaders can reference,
2610
+ * in fallback preference order (threaded first). The generated root loader
2611
+ * also lets consumers pin one exact declared flavor with
2612
+ * NAPI_RS_WASI_FLAVOR.
2613
+ */
2614
+ private declaredWasiFlavors(): string[] {
2028
2615
  const declaredWasiTargets = this.config.targets.filter(
2029
2616
  (t) => t.platform === 'wasi',
2030
2617
  )
@@ -2040,7 +2627,7 @@ class Builder {
2040
2627
  ) {
2041
2628
  declaredWasiTargets.push(this.target)
2042
2629
  }
2043
- const wasiFlavors = [
2630
+ return [
2044
2631
  ...new Set(
2045
2632
  [
2046
2633
  ...declaredWasiTargets.filter(wasiTargetHasThreads),
@@ -2048,6 +2635,13 @@ class Builder {
2048
2635
  ].map((t) => t.platformArchABI),
2049
2636
  ),
2050
2637
  ]
2638
+ }
2639
+
2640
+ private async writeJsBinding(idents: string[]) {
2641
+ // No reserved-name check here: it belongs to the decision that emits the
2642
+ // declaration, which lives in `generateTypeDef` above. `writeJsBinding`
2643
+ // runs for every cdylib build, including the ones that write no loader.
2644
+ const wasiFlavors = this.declaredWasiFlavors()
2051
2645
  return writeJsBinding({
2052
2646
  platform: this.options.platform,
2053
2647
  noJsBinding: this.options.noJsBinding,
@@ -2150,7 +2744,24 @@ class Builder {
2150
2744
  metadata: WasiBindingMetadata,
2151
2745
  ): Promise<Output[]> {
2152
2746
  const { exports: idents } = metadata
2747
+ const asyncRuntime = this.config.wasm?.asyncRuntime === true
2748
+ const hostContract = checkAsyncRuntimeHostContract({
2749
+ idents,
2750
+ asyncRuntime,
2751
+ typeDefAvailable: this.enableTypeDef,
2752
+ packageName: this.config.packageName,
2753
+ })
2754
+ if (hostContract.error) {
2755
+ throw new Error(hostContract.error)
2756
+ }
2757
+ if (hostContract.warning) {
2758
+ debug.warn(hostContract.warning)
2759
+ }
2153
2760
  const hasThreads = wasiTargetHasThreads(wasiTarget)
2761
+ const { initialMemory, maximumMemory } = resolveWasmMemory(
2762
+ this.config.wasm,
2763
+ hasThreads,
2764
+ )
2154
2765
  const loaderSuffix = wasiLoaderSuffix(wasiTarget.platformArchABI)
2155
2766
  // the wasm file stem referenced from inside the loaders
2156
2767
  const name = `${this.config.binaryName}.${wasiTarget.platformArchABI}`
@@ -2166,13 +2777,23 @@ class Builder {
2166
2777
  dir,
2167
2778
  `${this.config.binaryName}.${loaderSuffix}.d.cts`,
2168
2779
  )
2169
- const exportsCode =
2170
- `module.exports = __napiModule.exports\n` +
2171
- idents
2172
- .map(
2173
- (ident) => `module.exports.${ident} = __napiModule.exports.${ident}`,
2174
- )
2175
- .join('\n')
2780
+ // A flavor's own export list, not the root type-def's: this is reached only
2781
+ // once `writeWasiBinding` has metadata for the flavor, i.e. only when this
2782
+ // loader set really is written. The root-side check lives in
2783
+ // `Builder.generateTypeDef`, gated on the same predicate as the
2784
+ // declaration — do not re-add an unconditional one here or there.
2785
+ assertBindingTargetIdentFree(idents)
2786
+ // No stamp here. `createWasiBinding` emits the only one, inside the
2787
+ // initialization `try` that rolls the environment back — both the guard and
2788
+ // the assignment can throw, and neither may escape past that boundary. The
2789
+ // assignment there is what `cjs-module-lexer` reads, so this tail stays
2790
+ // purely declarative.
2791
+ const exportsCode = [
2792
+ `module.exports = __napiModule.exports`,
2793
+ ...idents.map(
2794
+ (ident) => `module.exports.${ident} = __napiModule.exports.${ident}`,
2795
+ ),
2796
+ ].join('\n')
2176
2797
  await writeFileAtomic(
2177
2798
  bindingPath,
2178
2799
  createWasiArtifactMetadata(
@@ -2185,11 +2806,12 @@ class Builder {
2185
2806
  createWasiBinding(
2186
2807
  name,
2187
2808
  this.config.packageName,
2188
- this.config.wasm?.initialMemory,
2189
- this.config.wasm?.maximumMemory,
2809
+ initialMemory,
2810
+ maximumMemory,
2190
2811
  hasThreads,
2191
2812
  wasiTarget.platformArchABI,
2192
2813
  `${this.config.binaryName}.${wasiTarget.platformArchABI}`,
2814
+ asyncRuntime,
2193
2815
  ) +
2194
2816
  exportsCode +
2195
2817
  '\n',
@@ -2199,13 +2821,15 @@ class Builder {
2199
2821
  browserBindingPath,
2200
2822
  createWasiBrowserBinding(
2201
2823
  name,
2202
- this.config.wasm?.initialMemory,
2203
- this.config.wasm?.maximumMemory,
2824
+ initialMemory,
2825
+ maximumMemory,
2204
2826
  this.config.wasm?.browser?.fs,
2205
2827
  this.config.wasm?.browser?.asyncInit,
2206
2828
  this.config.wasm?.browser?.buffer,
2207
2829
  this.config.wasm?.browser?.errorEvent,
2208
2830
  hasThreads,
2831
+ wasiTarget.platformArchABI,
2832
+ asyncRuntime,
2209
2833
  ) +
2210
2834
  `export default __napiModule.exports\n` +
2211
2835
  idents
@@ -2252,6 +2876,17 @@ export = binding
2252
2876
  ? selectedSourceTypeDef
2253
2877
  : removeNodeStreamWebTypeImports(selectedSourceTypeDef)
2254
2878
  }
2879
+ // This flavor's loaders report a compile-time-fixed identity, so this file
2880
+ // declares that one literal: the root entry's union exists only for
2881
+ // `NAPI_RS_NATIVE_LIBRARY_PATH`, which no flavor loader reads. On the fresh
2882
+ // path that narrows the union inherited from the root `index.d.ts`; on the
2883
+ // preserved path it refreshes a file kept from an earlier build, which may
2884
+ // predate the `__napiBindingTarget` export the loader written above carries.
2885
+ bindingTypeDef = ensureBindingTargetDeclaration(
2886
+ bindingTypeDef,
2887
+ wasiTarget.platformArchABI,
2888
+ this.typeDefRenderedHeader,
2889
+ )
2255
2890
  await writeFileAtomic(bindingTypeDefPath, bindingTypeDef, 'utf8')
2256
2891
  const outputs: Output[] = [
2257
2892
  { kind: 'js', path: bindingPath },
@@ -2289,9 +2924,11 @@ export = binding
2289
2924
  deferredBindingPath,
2290
2925
  createWasiDeferredBrowserBinding(
2291
2926
  name,
2292
- this.config.wasm?.initialMemory,
2293
- this.config.wasm?.maximumMemory,
2927
+ initialMemory,
2928
+ maximumMemory,
2294
2929
  this.config.wasm?.browser?.buffer,
2930
+ wasiTarget.platformArchABI,
2931
+ asyncRuntime,
2295
2932
  ),
2296
2933
  'utf8',
2297
2934
  )
@@ -2300,6 +2937,7 @@ export = binding
2300
2937
  createWasiDeferredBindingTypeDef(
2301
2938
  `./${this.config.binaryName}.${loaderSuffix}.cjs`,
2302
2939
  this.enableTypeDef,
2940
+ wasiTarget.platformArchABI,
2303
2941
  ),
2304
2942
  'utf8',
2305
2943
  )
@@ -2475,6 +3113,18 @@ export interface GenerateTypeDefOptions {
2475
3113
  constEnum?: boolean
2476
3114
  runtimeStringEnum?: boolean
2477
3115
  cwd: string
3116
+ /**
3117
+ * Declare the generated loader's `__napiBindingTarget` export. Set it for
3118
+ * builds that emit a loader; a build that emits none declares nothing. The
3119
+ * declared union covers every artifact a loader can hand back, not only the
3120
+ * targets this package builds — see {@link BINDING_TARGET_TYPE_UNION}.
3121
+ *
3122
+ * Whether the root loader is written depends on the exports this build
3123
+ * produced, which only typegen knows, so a predicate may be passed instead
3124
+ * of a flag; it is called with the generated export list. See
3125
+ * {@link bindingTargetDeclarationPredicate}.
3126
+ */
3127
+ declareBindingTarget?: boolean | ((exports: readonly string[]) => boolean)
2478
3128
  }
2479
3129
 
2480
3130
  /**
@@ -2489,15 +3139,22 @@ export async function generateTypeDef(
2489
3139
  exports: string[]
2490
3140
  dts: string
2491
3141
  dtsWithTypeImports: string
3142
+ /**
3143
+ * The `.d.ts` header as rendered, before anything the generated body needs
3144
+ * was appended to it. `ensureBindingTargetDeclaration` takes it to tell a
3145
+ * declaration the header owns from one this build wrote.
3146
+ */
3147
+ header: string
2492
3148
  }> {
2493
- if (!(await dirExistsAsync(options.typeDefDir))) {
2494
- return { exports: [], dts: '', dtsWithTypeImports: '' }
2495
- }
2496
-
2497
3149
  let header = ''
2498
3150
  let dts = ''
2499
3151
  let exports: string[] = []
2500
3152
 
3153
+ const declaresBindingTarget = (generated: readonly string[]) =>
3154
+ typeof options.declareBindingTarget === 'function'
3155
+ ? options.declareBindingTarget(generated)
3156
+ : Boolean(options.declareBindingTarget)
3157
+
2501
3158
  if (!options.noDtsHeader) {
2502
3159
  const dtsHeader = options.dtsHeader ?? options.configDtsHeader
2503
3160
  const dtsHeaderFile = options.dtsHeaderFile ?? options.configDtsHeaderFile
@@ -2516,11 +3173,63 @@ export async function generateTypeDef(
2516
3173
  }
2517
3174
  }
2518
3175
 
3176
+ // The header exactly as the project wrote it, captured before the appends
3177
+ // below grow it, so `ensureBindingTargetDeclaration` can tell a declaration
3178
+ // the header owns from one this build wrote into a file derived from this
3179
+ // one.
3180
+ const renderedHeader = header
3181
+ const headerBindingTarget = bindingTargetExports(renderedHeader)
3182
+ // That header may declare `__napiBindingTarget` itself, as a workaround for
3183
+ // loaders that predate the export. Declaring it again below would be a TS2451
3184
+ // redeclaration, so the header's own wins — the rule
3185
+ // `ensureBindingTargetDeclaration` already applies to a preserved WASI
3186
+ // declaration.
3187
+ //
3188
+ // A header that exports by assignment (`export = binding`, the form a build
3189
+ // without `napi-derive`'s `type-def` feature emits) leaves no room either: an
3190
+ // export assignment cannot sit beside any named export, so appending one is
3191
+ // TS2309 rather than a declaration anybody can use. Its exports are untyped
3192
+ // anyway. `ensureBindingTargetDeclaration` already leaves such a file alone,
3193
+ // and the WASI declaration derived from this one goes through it — reading
3194
+ // the same answer here keeps both ends of that derivation agreeing.
3195
+ const headerDeclaresBindingTarget =
3196
+ headerBindingTarget.ownsName || headerBindingTarget.exportsByAssignment
3197
+
3198
+ // A crate can register every export from a `#[napi(module_exports)]` hook
3199
+ // and emit no `.type` file, and the loader written for it still exports
3200
+ // `__napiBindingTarget`. Declare it, so the declaration file describes the
3201
+ // loader that was written rather than staying empty.
3202
+ const bindingTargetOnlyTypeDef = () => {
3203
+ if (!declaresBindingTarget([])) {
3204
+ return {
3205
+ exports: [],
3206
+ dts: '',
3207
+ dtsWithTypeImports: '',
3208
+ header: renderedHeader,
3209
+ }
3210
+ }
3211
+ const declarationOnly = finalizeTypeDefHeader(
3212
+ headerDeclaresBindingTarget
3213
+ ? header
3214
+ : header + BINDING_TARGET_TYPE_DECLARATION,
3215
+ )
3216
+ return {
3217
+ exports: [],
3218
+ dts: declarationOnly,
3219
+ dtsWithTypeImports: declarationOnly,
3220
+ header: renderedHeader,
3221
+ }
3222
+ }
3223
+
3224
+ if (!(await dirExistsAsync(options.typeDefDir))) {
3225
+ return bindingTargetOnlyTypeDef()
3226
+ }
3227
+
2519
3228
  const files = await readdirAsync(options.typeDefDir, { withFileTypes: true })
2520
3229
 
2521
3230
  if (!files.length) {
2522
3231
  debug('No type def files found. Skip generating dts file.')
2523
- return { exports: [], dts: '', dtsWithTypeImports: '' }
3232
+ return bindingTargetOnlyTypeDef()
2524
3233
  }
2525
3234
 
2526
3235
  const typeDefFiles = files
@@ -2560,10 +3269,26 @@ export declare class ExternalObject<T> {
2560
3269
 
2561
3270
  if (dts.indexOf('TypedArray') > -1) {
2562
3271
  header += `
2563
- export type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array
3272
+ export type TypedArray =
3273
+ | Int8Array
3274
+ | Uint8Array
3275
+ | Uint8ClampedArray
3276
+ | Int16Array
3277
+ | Uint16Array
3278
+ | Int32Array
3279
+ | Uint32Array
3280
+ | Float32Array
3281
+ | Float64Array
3282
+ | BigInt64Array
3283
+ | BigUint64Array
2564
3284
  `
2565
3285
  }
2566
3286
 
3287
+ if (declaresBindingTarget(exports) && !headerDeclaresBindingTarget) {
3288
+ header += BINDING_TARGET_TYPE_DECLARATION
3289
+ }
3290
+
3291
+ header = finalizeTypeDefHeader(header)
2567
3292
  dts = header + dts
2568
3293
  const dtsWithTypeImports = rewriteTypeImportReferences(
2569
3294
  header + dtsWithTypeImportMarkers,
@@ -2575,5 +3300,6 @@ export type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array
2575
3300
  exports,
2576
3301
  dts,
2577
3302
  dtsWithTypeImports,
3303
+ header: renderedHeader,
2578
3304
  }
2579
3305
  }