@napi-rs/cli 3.9.1 → 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.
package/src/api/build.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  removeNodeStreamWebTypeImports,
42
42
  rewriteUnboundNodeGlobalTypeQueries,
43
43
  rewriteTypeImportReferences,
44
+ scanExportedName,
44
45
  type Target,
45
46
  targetToEnvVar,
46
47
  tryInstallCargoBinary,
@@ -57,7 +58,12 @@ import {
57
58
  type CargoWorkspaceMetadata,
58
59
  } from '../utils/index.js'
59
60
 
60
- 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'
61
67
  import {
62
68
  createWasiBinding,
63
69
  createWasiBrowserBinding,
@@ -80,6 +86,98 @@ const MANAGED_WASI_FLAVORS = [
80
86
  },
81
87
  ] as const
82
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
+
83
181
  type OutputKind = 'js' | 'dts' | 'node' | 'exe' | 'wasm'
84
182
  type Output = { kind: OutputKind; path: string }
85
183
  type WasiBindingMetadata = {
@@ -97,6 +195,70 @@ type ParsedBuildOptions = Omit<BuildOptions, 'cwd' | 'format'> & {
97
195
 
98
196
  export const WASI_ARTIFACT_METADATA_PREFIX = '// napi-rs-artifact-metadata:'
99
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
+
100
262
  type CargoConfigFingerprint = readonly [path: string, hash: string]
101
263
 
102
264
  export function getCargoDependencyGraphFingerprint(
@@ -486,9 +648,11 @@ export function createWasiBrowserEntry(
486
648
  export function createWasiDeferredBindingTypeDef(
487
649
  bindingModuleSpecifier: string,
488
650
  hasTypeDef: boolean,
651
+ platformArchABI?: string,
489
652
  ) {
490
653
  const typeDef = createWasiDeferredBrowserBindingTypeDef(
491
654
  bindingModuleSpecifier,
655
+ platformArchABI,
492
656
  )
493
657
  if (hasTypeDef) {
494
658
  return typeDef
@@ -504,6 +668,133 @@ export function createWasiDeferredBindingTypeDef(
504
668
  return typeDef.replace(rootBindingType, 'Record<string, unknown>')
505
669
  }
506
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
+
507
798
  export function prepareWasiBindingTypeDef(
508
799
  source: string,
509
800
  sourcePath: string,
@@ -527,6 +818,136 @@ export function prepareWasiBindingTypeDef(
527
818
  return rebaseDeclarationSpecifiers(targetSource, sourcePath, destinationPath)
528
819
  }
529
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
+
530
951
  export function collectStaleWasiBuildOutputNames(
531
952
  binaryName: string,
532
953
  buildTarget: Target,
@@ -1055,6 +1476,12 @@ class Builder {
1055
1476
  private readonly enableTypeDef: boolean = false
1056
1477
  private readonly stagedOutputDestinations = new Map<string, string>()
1057
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
1058
1485
 
1059
1486
  constructor(
1060
1487
  private readonly metadata: CargoWorkspaceMetadata,
@@ -1314,6 +1741,31 @@ class Builder {
1314
1741
  return entries
1315
1742
  }
1316
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
+
1317
1769
  private async readExistingWasiBindingMetadata(wasiTarget: Target) {
1318
1770
  const loaderSuffix = wasiLoaderSuffix(wasiTarget.platformArchABI)
1319
1771
  const bindingPath = join(
@@ -2069,7 +2521,20 @@ class Builder {
2069
2521
  return []
2070
2522
  }
2071
2523
 
2072
- 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({
2073
2538
  typeDefDir,
2074
2539
  noDtsHeader: this.options.noDtsHeader,
2075
2540
  dtsHeader: this.options.dtsHeader,
@@ -2079,8 +2544,18 @@ class Builder {
2079
2544
  runtimeStringEnum:
2080
2545
  this.options.runtimeStringEnum ?? this.config.runtimeStringEnum,
2081
2546
  cwd: this.options.cwd,
2547
+ declareBindingTarget,
2082
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
+ }
2083
2557
  this.typeDefWithTypeImports = dtsWithTypeImports
2558
+ this.typeDefRenderedHeader = header
2084
2559
 
2085
2560
  const typeDefRelativePath = this.options.dts ?? 'index.d.ts'
2086
2561
  const finalDest = join(this.finalOutputDir, typeDefRelativePath)
@@ -2096,7 +2571,13 @@ class Builder {
2096
2571
  throw new Error(`Failed to write type def file ${dest}`, { cause: e })
2097
2572
  }
2098
2573
 
2099
- 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) {
2100
2581
  this.outputs.push({ kind: 'dts', path: dest })
2101
2582
  }
2102
2583
 
@@ -2124,10 +2605,13 @@ class Builder {
2124
2605
  return stagedPath
2125
2606
  }
2126
2607
 
2127
- private async writeJsBinding(idents: string[]) {
2128
- // Default WASI fallback order: threaded first. The generated root loader
2129
- // also lets consumers pin one exact declared flavor with
2130
- // 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[] {
2131
2615
  const declaredWasiTargets = this.config.targets.filter(
2132
2616
  (t) => t.platform === 'wasi',
2133
2617
  )
@@ -2143,7 +2627,7 @@ class Builder {
2143
2627
  ) {
2144
2628
  declaredWasiTargets.push(this.target)
2145
2629
  }
2146
- const wasiFlavors = [
2630
+ return [
2147
2631
  ...new Set(
2148
2632
  [
2149
2633
  ...declaredWasiTargets.filter(wasiTargetHasThreads),
@@ -2151,6 +2635,13 @@ class Builder {
2151
2635
  ].map((t) => t.platformArchABI),
2152
2636
  ),
2153
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()
2154
2645
  return writeJsBinding({
2155
2646
  platform: this.options.platform,
2156
2647
  noJsBinding: this.options.noJsBinding,
@@ -2253,7 +2744,24 @@ class Builder {
2253
2744
  metadata: WasiBindingMetadata,
2254
2745
  ): Promise<Output[]> {
2255
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
+ }
2256
2760
  const hasThreads = wasiTargetHasThreads(wasiTarget)
2761
+ const { initialMemory, maximumMemory } = resolveWasmMemory(
2762
+ this.config.wasm,
2763
+ hasThreads,
2764
+ )
2257
2765
  const loaderSuffix = wasiLoaderSuffix(wasiTarget.platformArchABI)
2258
2766
  // the wasm file stem referenced from inside the loaders
2259
2767
  const name = `${this.config.binaryName}.${wasiTarget.platformArchABI}`
@@ -2269,13 +2777,23 @@ class Builder {
2269
2777
  dir,
2270
2778
  `${this.config.binaryName}.${loaderSuffix}.d.cts`,
2271
2779
  )
2272
- const exportsCode =
2273
- `module.exports = __napiModule.exports\n` +
2274
- idents
2275
- .map(
2276
- (ident) => `module.exports.${ident} = __napiModule.exports.${ident}`,
2277
- )
2278
- .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')
2279
2797
  await writeFileAtomic(
2280
2798
  bindingPath,
2281
2799
  createWasiArtifactMetadata(
@@ -2288,11 +2806,12 @@ class Builder {
2288
2806
  createWasiBinding(
2289
2807
  name,
2290
2808
  this.config.packageName,
2291
- this.config.wasm?.initialMemory,
2292
- this.config.wasm?.maximumMemory,
2809
+ initialMemory,
2810
+ maximumMemory,
2293
2811
  hasThreads,
2294
2812
  wasiTarget.platformArchABI,
2295
2813
  `${this.config.binaryName}.${wasiTarget.platformArchABI}`,
2814
+ asyncRuntime,
2296
2815
  ) +
2297
2816
  exportsCode +
2298
2817
  '\n',
@@ -2302,13 +2821,15 @@ class Builder {
2302
2821
  browserBindingPath,
2303
2822
  createWasiBrowserBinding(
2304
2823
  name,
2305
- this.config.wasm?.initialMemory,
2306
- this.config.wasm?.maximumMemory,
2824
+ initialMemory,
2825
+ maximumMemory,
2307
2826
  this.config.wasm?.browser?.fs,
2308
2827
  this.config.wasm?.browser?.asyncInit,
2309
2828
  this.config.wasm?.browser?.buffer,
2310
2829
  this.config.wasm?.browser?.errorEvent,
2311
2830
  hasThreads,
2831
+ wasiTarget.platformArchABI,
2832
+ asyncRuntime,
2312
2833
  ) +
2313
2834
  `export default __napiModule.exports\n` +
2314
2835
  idents
@@ -2355,6 +2876,17 @@ export = binding
2355
2876
  ? selectedSourceTypeDef
2356
2877
  : removeNodeStreamWebTypeImports(selectedSourceTypeDef)
2357
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
+ )
2358
2890
  await writeFileAtomic(bindingTypeDefPath, bindingTypeDef, 'utf8')
2359
2891
  const outputs: Output[] = [
2360
2892
  { kind: 'js', path: bindingPath },
@@ -2392,9 +2924,11 @@ export = binding
2392
2924
  deferredBindingPath,
2393
2925
  createWasiDeferredBrowserBinding(
2394
2926
  name,
2395
- this.config.wasm?.initialMemory,
2396
- this.config.wasm?.maximumMemory,
2927
+ initialMemory,
2928
+ maximumMemory,
2397
2929
  this.config.wasm?.browser?.buffer,
2930
+ wasiTarget.platformArchABI,
2931
+ asyncRuntime,
2398
2932
  ),
2399
2933
  'utf8',
2400
2934
  )
@@ -2403,6 +2937,7 @@ export = binding
2403
2937
  createWasiDeferredBindingTypeDef(
2404
2938
  `./${this.config.binaryName}.${loaderSuffix}.cjs`,
2405
2939
  this.enableTypeDef,
2940
+ wasiTarget.platformArchABI,
2406
2941
  ),
2407
2942
  'utf8',
2408
2943
  )
@@ -2578,6 +3113,18 @@ export interface GenerateTypeDefOptions {
2578
3113
  constEnum?: boolean
2579
3114
  runtimeStringEnum?: boolean
2580
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)
2581
3128
  }
2582
3129
 
2583
3130
  /**
@@ -2592,15 +3139,22 @@ export async function generateTypeDef(
2592
3139
  exports: string[]
2593
3140
  dts: string
2594
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
2595
3148
  }> {
2596
- if (!(await dirExistsAsync(options.typeDefDir))) {
2597
- return { exports: [], dts: '', dtsWithTypeImports: '' }
2598
- }
2599
-
2600
3149
  let header = ''
2601
3150
  let dts = ''
2602
3151
  let exports: string[] = []
2603
3152
 
3153
+ const declaresBindingTarget = (generated: readonly string[]) =>
3154
+ typeof options.declareBindingTarget === 'function'
3155
+ ? options.declareBindingTarget(generated)
3156
+ : Boolean(options.declareBindingTarget)
3157
+
2604
3158
  if (!options.noDtsHeader) {
2605
3159
  const dtsHeader = options.dtsHeader ?? options.configDtsHeader
2606
3160
  const dtsHeaderFile = options.dtsHeaderFile ?? options.configDtsHeaderFile
@@ -2619,11 +3173,63 @@ export async function generateTypeDef(
2619
3173
  }
2620
3174
  }
2621
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
+
2622
3228
  const files = await readdirAsync(options.typeDefDir, { withFileTypes: true })
2623
3229
 
2624
3230
  if (!files.length) {
2625
3231
  debug('No type def files found. Skip generating dts file.')
2626
- return { exports: [], dts: '', dtsWithTypeImports: '' }
3232
+ return bindingTargetOnlyTypeDef()
2627
3233
  }
2628
3234
 
2629
3235
  const typeDefFiles = files
@@ -2678,12 +3284,11 @@ export type TypedArray =
2678
3284
  `
2679
3285
  }
2680
3286
 
2681
- if (header && !header.endsWith('\n')) {
2682
- header += '\n'
2683
- }
2684
- if (header && !header.endsWith('\n\n')) {
2685
- header += '\n'
3287
+ if (declaresBindingTarget(exports) && !headerDeclaresBindingTarget) {
3288
+ header += BINDING_TARGET_TYPE_DECLARATION
2686
3289
  }
3290
+
3291
+ header = finalizeTypeDefHeader(header)
2687
3292
  dts = header + dts
2688
3293
  const dtsWithTypeImports = rewriteTypeImportReferences(
2689
3294
  header + dtsWithTypeImportMarkers,
@@ -2695,5 +3300,6 @@ export type TypedArray =
2695
3300
  exports,
2696
3301
  dts,
2697
3302
  dtsWithTypeImports,
3303
+ header: renderedHeader,
2698
3304
  }
2699
3305
  }