@napi-rs/cli 3.7.4 → 3.8.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 (43) hide show
  1. package/README.md +33 -0
  2. package/cli.mjs +9 -12
  3. package/dist/cli.js +9248 -1311
  4. package/dist/index.cjs +9268 -1331
  5. package/dist/index.d.cts +175 -157
  6. package/dist/index.d.ts +175 -157
  7. package/dist/index.js +9248 -1311
  8. package/docs/artifacts.md +33 -0
  9. package/docs/build.md +58 -0
  10. package/docs/create-npm-dirs.md +32 -0
  11. package/docs/new.md +39 -0
  12. package/docs/pre-publish.md +37 -0
  13. package/docs/rename.md +37 -0
  14. package/docs/universalize.md +31 -0
  15. package/docs/version.md +31 -0
  16. package/docs/wasi.md +161 -0
  17. package/package.json +11 -10
  18. package/src/api/__tests__/__snapshots__/templates.spec.ts.md +1019 -114
  19. package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
  20. package/src/api/__tests__/artifacts.spec.ts +17 -0
  21. package/src/api/__tests__/create-npm-dirs.spec.ts +14 -15
  22. package/src/api/__tests__/new.spec.ts +41 -5
  23. package/src/api/artifacts.ts +934 -108
  24. package/src/api/build.ts +1317 -188
  25. package/src/api/create-npm-dirs.ts +472 -101
  26. package/src/api/new.ts +354 -35
  27. package/src/api/pre-publish.ts +2845 -99
  28. package/src/api/rename.ts +1638 -124
  29. package/src/api/templates/js-binding.ts +216 -38
  30. package/src/api/templates/load-wasi-template.ts +1641 -111
  31. package/src/api/templates/wasi-worker-template.ts +32 -3
  32. package/src/commands/rename.ts +8 -3
  33. package/src/def/artifacts.ts +2 -2
  34. package/src/utils/__tests__/__snapshots__/target.spec.ts.md +2 -2
  35. package/src/utils/__tests__/__snapshots__/target.spec.ts.snap +0 -0
  36. package/src/utils/__tests__/misc.spec.ts +124 -3
  37. package/src/utils/__tests__/version.spec.ts +47 -1
  38. package/src/utils/config.ts +15 -2
  39. package/src/utils/metadata.ts +155 -47
  40. package/src/utils/misc.ts +6079 -0
  41. package/src/utils/target.ts +66 -8
  42. package/src/utils/typegen.ts +1223 -63
  43. package/src/utils/version.ts +96 -0
package/src/api/build.ts CHANGED
@@ -1,35 +1,54 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { createHash } from 'node:crypto'
3
- import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
3
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
4
+ import { mkdtemp as mkdtempAsync, rm } from 'node:fs/promises'
4
5
  import { createRequire } from 'node:module'
5
6
  import { homedir } from 'node:os'
6
- import { basename, parse, join, resolve } from 'node:path'
7
+ import {
8
+ basename,
9
+ dirname,
10
+ isAbsolute,
11
+ join,
12
+ parse,
13
+ relative,
14
+ resolve,
15
+ sep,
16
+ } from 'node:path'
7
17
 
8
18
  import * as colors from 'colorette'
9
19
 
10
20
  import type { BuildOptions as RawBuildOptions } from '../def/build.js'
11
21
  import {
12
22
  CLI_VERSION,
13
- copyFileAsync,
23
+ commitFileSystemTransaction,
24
+ copyFileAtomic,
14
25
  type Crate,
15
26
  debugFactory,
16
27
  DEFAULT_TYPE_DEF_HEADER,
17
28
  fileExists,
18
29
  getSystemDefaultTarget,
19
30
  getNapiDeriveDependentCrates,
31
+ getPackageReconciliationRoot,
20
32
  getTargetLinker,
21
33
  mkdirAsync,
22
34
  type NapiConfig,
23
35
  parseMetadata,
24
36
  parseTriple,
25
- processTypeDef,
37
+ processTypeDefs,
26
38
  readFileAsync,
27
39
  readNapiConfig,
40
+ rebaseDeclarationSpecifiers,
41
+ removeNodeStreamWebTypeImports,
42
+ rewriteUnboundNodeGlobalTypeQueries,
43
+ rewriteTypeImportReferences,
28
44
  type Target,
29
45
  targetToEnvVar,
30
46
  tryInstallCargoBinary,
31
47
  unlinkAsync,
32
- writeFileAsync,
48
+ wasiLoaderSuffix,
49
+ wasiTargetHasThreads,
50
+ writeFileAtomic,
51
+ withFileSystemReconciliation,
33
52
  dirExistsAsync,
34
53
  readdirAsync,
35
54
  type CargoWorkspaceMetadata,
@@ -39,6 +58,8 @@ import { createCjsBinding, createEsmBinding } from './templates/index.js'
39
58
  import {
40
59
  createWasiBinding,
41
60
  createWasiBrowserBinding,
61
+ createWasiDeferredBrowserBinding,
62
+ createWasiDeferredBrowserBindingTypeDef,
42
63
  } from './templates/load-wasi-template.js'
43
64
  import {
44
65
  createWasiBrowserWorkerBinding,
@@ -47,13 +68,507 @@ import {
47
68
 
48
69
  const debug = debugFactory('build')
49
70
  const require = createRequire(import.meta.url)
71
+ const MANAGED_WASI_FLAVORS = [
72
+ { platformArchABI: 'wasm32-wasi', loaderSuffix: 'wasi', hasThreads: true },
73
+ {
74
+ platformArchABI: 'wasm32-wasip1',
75
+ loaderSuffix: 'wasip1',
76
+ hasThreads: false,
77
+ },
78
+ ] as const
50
79
 
51
80
  type OutputKind = 'js' | 'dts' | 'node' | 'exe' | 'wasm'
52
81
  type Output = { kind: OutputKind; path: string }
82
+ type WasiBindingMetadata = {
83
+ exports: string[]
84
+ bindingTypeDef?: string
85
+ }
53
86
 
54
87
  type BuildOptions = RawBuildOptions & { cargoOptions?: string[] }
55
88
  type ParsedBuildOptions = Omit<BuildOptions, 'cwd'> & { cwd: string }
56
89
 
90
+ export const WASI_ARTIFACT_METADATA_PREFIX = '// napi-rs-artifact-metadata:'
91
+
92
+ type CargoConfigFingerprint = readonly [path: string, hash: string]
93
+
94
+ export function getCargoDependencyGraphFingerprint(
95
+ metadata: CargoWorkspaceMetadata,
96
+ rootPackageId: string,
97
+ ): string {
98
+ const packages = new Map(metadata.packages.map((pkg) => [pkg.id, pkg]))
99
+ const nodes = new Map(
100
+ (metadata.resolve?.nodes ?? []).map((node) => [node.id, node]),
101
+ )
102
+ const pending = [rootPackageId]
103
+ const visited = new Set<string>()
104
+ const graph = []
105
+
106
+ while (pending.length > 0) {
107
+ const id = pending.pop()!
108
+ if (visited.has(id)) {
109
+ continue
110
+ }
111
+ visited.add(id)
112
+
113
+ const node = nodes.get(id)
114
+ const pkg = packages.get(id)
115
+ const dependencies = (node?.deps ?? [])
116
+ .map((dependency) => ({
117
+ name: dependency.name,
118
+ pkg: dependency.pkg,
119
+ kinds: dependency.dep_kinds
120
+ .map(({ kind, target }) => ({ kind, target }))
121
+ .sort(
122
+ (left, right) =>
123
+ (left.kind ?? '').localeCompare(right.kind ?? '') ||
124
+ (left.target ?? '').localeCompare(right.target ?? ''),
125
+ ),
126
+ }))
127
+ .sort((left, right) =>
128
+ JSON.stringify(left).localeCompare(JSON.stringify(right)),
129
+ )
130
+ for (const dependency of dependencies) {
131
+ pending.push(dependency.pkg)
132
+ }
133
+
134
+ graph.push({
135
+ id,
136
+ manifestPath: pkg?.manifest_path,
137
+ resolvedFeatures: [...(node?.features ?? [])].sort(),
138
+ dependencies,
139
+ declaredDependencies: (pkg?.dependencies ?? [])
140
+ .map((dependency) => ({
141
+ name: dependency.name,
142
+ source: dependency.source,
143
+ requirement: dependency.req,
144
+ kind: dependency.kind,
145
+ rename: dependency.rename,
146
+ optional: dependency.optional,
147
+ usesDefaultFeatures: dependency.uses_default_features,
148
+ features: [...dependency.features].sort(),
149
+ target: dependency.target,
150
+ registry: dependency.registry,
151
+ }))
152
+ .sort((left, right) =>
153
+ JSON.stringify(left).localeCompare(JSON.stringify(right)),
154
+ ),
155
+ declaredFeatures: Object.entries(pkg?.features ?? {})
156
+ .map(([name, features]) => [name, [...features].sort()] as const)
157
+ .sort(([left], [right]) => left.localeCompare(right)),
158
+ })
159
+ }
160
+
161
+ graph.sort((left, right) => left.id.localeCompare(right.id))
162
+ return createHash('sha256').update(JSON.stringify(graph)).digest('hex')
163
+ }
164
+
165
+ export function getTypeDefCacheFolder(options: {
166
+ targetDir: string
167
+ crateName: string
168
+ manifestPath: string
169
+ targetTriple: string
170
+ profile: string
171
+ features?: string[]
172
+ allFeatures?: boolean
173
+ noDefaultFeatures?: boolean
174
+ cargoOptions?: string[]
175
+ rustFlags?: Record<string, string | undefined>
176
+ cargoProfileEnv?: Record<string, string | undefined>
177
+ cargoConfig?: CargoConfigFingerprint[]
178
+ cargoDependencyGraph?: string
179
+ }) {
180
+ const features = [
181
+ ...new Set(
182
+ (options.features ?? []).flatMap((feature) =>
183
+ feature.split(/[,\s]+/).filter(Boolean),
184
+ ),
185
+ ),
186
+ ].sort()
187
+ const rustFlags = Object.entries(options.rustFlags ?? {})
188
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
189
+ .sort(([left], [right]) => left.localeCompare(right))
190
+ const cargoConfig = [...(options.cargoConfig ?? [])].sort(([left], [right]) =>
191
+ left.localeCompare(right),
192
+ )
193
+ const cargoProfileEnv = Object.entries(options.cargoProfileEnv ?? {})
194
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
195
+ .sort(([left], [right]) => left.localeCompare(right))
196
+ const identity = JSON.stringify({
197
+ version: 4,
198
+ cliVersion: CLI_VERSION,
199
+ manifestPath: options.manifestPath,
200
+ cargoDependencyGraph: options.cargoDependencyGraph,
201
+ targetTriple: options.targetTriple,
202
+ profile: options.profile,
203
+ features: {
204
+ selected: features,
205
+ all: options.allFeatures === true,
206
+ noDefault: options.noDefaultFeatures === true,
207
+ },
208
+ cargoOptions: options.cargoOptions ?? [],
209
+ rustFlags,
210
+ cargoProfileEnv,
211
+ cargoConfig,
212
+ })
213
+ const hash = createHash('sha256')
214
+ .update(identity)
215
+ .digest('hex')
216
+ .substring(0, 16)
217
+
218
+ return join(options.targetDir, 'napi-rs', `${options.crateName}-${hash}`)
219
+ }
220
+
221
+ export function createWasiCompilerFlags(
222
+ wasiSdkPath: string,
223
+ wasiTarget: string,
224
+ hasThreads: boolean,
225
+ shellEscapedFlags = true,
226
+ ) {
227
+ const compileArguments = [
228
+ `--target=${wasiTarget}`,
229
+ `--sysroot=${join(wasiSdkPath, 'share', 'wasi-sysroot')}`,
230
+ ...(hasThreads ? ['-pthread'] : []),
231
+ '-mllvm',
232
+ '-wasm-enable-sjlj',
233
+ ]
234
+ const linkerArguments = [
235
+ `-fuse-ld=${join(wasiSdkPath, 'bin', 'wasm-ld')}`,
236
+ `--target=${wasiTarget}`,
237
+ ]
238
+ return {
239
+ compileFlags: joinCompilerArguments(compileArguments, shellEscapedFlags),
240
+ linkerFlags: joinCompilerArguments(linkerArguments, shellEscapedFlags),
241
+ }
242
+ }
243
+
244
+ export function createArtifactDestinationName(
245
+ binaryName: string,
246
+ target: Target,
247
+ sourceName: string,
248
+ platform: boolean,
249
+ ) {
250
+ let destinationName = binaryName
251
+ if (platform || target.platform === 'wasi') {
252
+ destinationName += `.${target.platformArchABI}`
253
+ }
254
+ return `${destinationName}.${sourceName.endsWith('.wasm') ? 'wasm' : 'node'}`
255
+ }
256
+
257
+ function joinCompilerArguments(
258
+ arguments_: string[],
259
+ shellEscapedFlags: boolean,
260
+ ) {
261
+ if (!shellEscapedFlags) {
262
+ const argumentWithWhitespace = arguments_.find((argument) =>
263
+ /\s/.test(argument),
264
+ )
265
+ if (argumentWithWhitespace) {
266
+ throw new Error(
267
+ `CC_SHELL_ESCAPED_FLAGS disables shell parsing, so the WASI SDK argument cannot contain whitespace: ${argumentWithWhitespace}`,
268
+ )
269
+ }
270
+ return arguments_.join(' ')
271
+ }
272
+ return arguments_
273
+ .map((argument) => `'${argument.replaceAll("'", "'\\''")}'`)
274
+ .join(' ')
275
+ }
276
+
277
+ function envBoolean(value: string | undefined) {
278
+ return (
279
+ value !== undefined &&
280
+ value !== '' &&
281
+ value !== '0' &&
282
+ value !== 'false' &&
283
+ value !== 'no'
284
+ )
285
+ }
286
+
287
+ function createWasiArtifactMetadata(
288
+ rootEntry: string | null,
289
+ binaryName: string,
290
+ exports: string[],
291
+ ) {
292
+ return `${WASI_ARTIFACT_METADATA_PREFIX}${JSON.stringify({
293
+ version: 2,
294
+ rootEntry,
295
+ exports,
296
+ managedRootEntries: [
297
+ 'browser.js',
298
+ ...(rootEntry ? [rootEntry] : []),
299
+ `${binaryName}.wasm`,
300
+ `${binaryName}.debug.wasm`,
301
+ ],
302
+ })}\n`
303
+ }
304
+
305
+ function parseExistingWasiArtifactMetadata(content: string) {
306
+ const firstLine = content.split(/\r?\n/, 1)[0]
307
+ if (!firstLine.startsWith(WASI_ARTIFACT_METADATA_PREFIX)) {
308
+ return undefined
309
+ }
310
+ try {
311
+ const metadata = JSON.parse(
312
+ firstLine.slice(WASI_ARTIFACT_METADATA_PREFIX.length),
313
+ ) as Record<string, unknown>
314
+ if (
315
+ (metadata.version !== 1 && metadata.version !== 2) ||
316
+ (metadata.rootEntry !== null && typeof metadata.rootEntry !== 'string')
317
+ ) {
318
+ return undefined
319
+ }
320
+ const managedRootEntries =
321
+ metadata.version === 1
322
+ ? ['browser.js', ...(metadata.rootEntry ? [metadata.rootEntry] : [])]
323
+ : metadata.managedRootEntries
324
+ if (
325
+ !Array.isArray(managedRootEntries) ||
326
+ !managedRootEntries.every((entry) => typeof entry === 'string')
327
+ ) {
328
+ return undefined
329
+ }
330
+ const exports = metadata.exports
331
+ return {
332
+ managedRootEntries,
333
+ exports:
334
+ Array.isArray(exports) &&
335
+ exports.every((entry) => typeof entry === 'string')
336
+ ? exports
337
+ : undefined,
338
+ }
339
+ } catch {
340
+ return undefined
341
+ }
342
+ }
343
+
344
+ function resolveManagedOutputPath(
345
+ outputDir: string,
346
+ entry: string,
347
+ description: string,
348
+ ) {
349
+ if (!entry || isAbsolute(entry)) {
350
+ throw new Error(
351
+ `${description} must be a non-empty relative path: ${entry}`,
352
+ )
353
+ }
354
+ const outputRoot = resolve(outputDir)
355
+ const path = resolve(outputRoot, entry)
356
+ const relativePath = relative(outputRoot, path)
357
+ if (
358
+ relativePath === '' ||
359
+ relativePath === '..' ||
360
+ relativePath.startsWith(`..${sep}`) ||
361
+ isAbsolute(relativePath)
362
+ ) {
363
+ throw new Error(`${description} escapes its output directory: ${entry}`)
364
+ }
365
+ return path
366
+ }
367
+
368
+ export function selectWasiBrowserTarget(
369
+ buildTarget: Target,
370
+ configuredTargets: Target[],
371
+ emittedTargets: Target[],
372
+ ) {
373
+ if (buildTarget.platform === 'wasi') {
374
+ return buildTarget
375
+ }
376
+ const configuredWasiTargets = configuredTargets.filter(
377
+ (target) => target.platform === 'wasi',
378
+ )
379
+ return (
380
+ configuredWasiTargets.find((target) => !wasiTargetHasThreads(target)) ??
381
+ configuredWasiTargets[0] ??
382
+ emittedTargets.find((target) => !wasiTargetHasThreads(target)) ??
383
+ emittedTargets[0]
384
+ )
385
+ }
386
+
387
+ export function createWasiBrowserEntry(
388
+ packageName: string,
389
+ platformArchABI: string,
390
+ idents: string[],
391
+ ) {
392
+ const packageSpecifier = `${packageName}-${platformArchABI}`
393
+ return (
394
+ `export * from '${packageSpecifier}'\n` +
395
+ (idents.length === 0
396
+ ? `export { default } from '${packageSpecifier}'\n`
397
+ : '')
398
+ )
399
+ }
400
+
401
+ export function createWasiDeferredBindingTypeDef(
402
+ bindingModuleSpecifier: string,
403
+ hasTypeDef: boolean,
404
+ ) {
405
+ const typeDef = createWasiDeferredBrowserBindingTypeDef(
406
+ bindingModuleSpecifier,
407
+ )
408
+ if (hasTypeDef) {
409
+ return typeDef
410
+ }
411
+
412
+ const rootBindingType = `typeof import('${bindingModuleSpecifier}')`
413
+ if (!typeDef.includes(rootBindingType)) {
414
+ throw new Error(
415
+ 'The deferred WASI type definition no longer contains its root binding type',
416
+ )
417
+ }
418
+
419
+ return typeDef.replace(rootBindingType, 'Record<string, unknown>')
420
+ }
421
+
422
+ export function prepareWasiBindingTypeDef(
423
+ source: string,
424
+ sourcePath: string,
425
+ destinationPath: string,
426
+ hasThreads: boolean,
427
+ packageType?: 'module' | 'commonjs',
428
+ ) {
429
+ if (
430
+ sourcePath.endsWith('.d.mts') ||
431
+ (sourcePath.endsWith('.d.ts') && packageType === 'module')
432
+ ) {
433
+ throw new Error(
434
+ `Cannot emit the CommonJS WASI declaration ${destinationPath} from the ESM declaration ${sourcePath}. Use a .d.cts --dts path for WASI builds in module packages.`,
435
+ )
436
+ }
437
+ const targetSource = hasThreads
438
+ ? source
439
+ : rewriteUnboundNodeGlobalTypeQueries(
440
+ removeNodeStreamWebTypeImports(source),
441
+ )
442
+ return rebaseDeclarationSpecifiers(targetSource, sourcePath, destinationPath)
443
+ }
444
+
445
+ export function collectStaleWasiBuildOutputNames(
446
+ binaryName: string,
447
+ buildTarget: Target,
448
+ configuredTargets: Target[],
449
+ ) {
450
+ const staleNames = new Set<string>()
451
+ const retainedFlavors = new Set(
452
+ [
453
+ ...configuredTargets.filter((target) => target.platform === 'wasi'),
454
+ buildTarget,
455
+ ].map((target) => target.platformArchABI),
456
+ )
457
+ for (const flavor of MANAGED_WASI_FLAVORS) {
458
+ const regenerated = flavor.platformArchABI === buildTarget.platformArchABI
459
+ if (!regenerated && retainedFlavors.has(flavor.platformArchABI)) {
460
+ continue
461
+ }
462
+ for (const suffix of [
463
+ `${flavor.loaderSuffix}.cjs`,
464
+ `${flavor.loaderSuffix}.d.cts`,
465
+ `${flavor.loaderSuffix}-browser.js`,
466
+ `${flavor.loaderSuffix}-deferred.js`,
467
+ `${flavor.loaderSuffix}-deferred.d.ts`,
468
+ ...(!regenerated
469
+ ? [
470
+ `${flavor.platformArchABI}.wasm`,
471
+ `${flavor.platformArchABI}.debug.wasm`,
472
+ ]
473
+ : []),
474
+ ]) {
475
+ staleNames.add(`${binaryName}.${suffix}`)
476
+ }
477
+ }
478
+ const regeneratesWorkers = wasiTargetHasThreads(buildTarget)
479
+ const retainsThreadedFlavor = MANAGED_WASI_FLAVORS.some(
480
+ (flavor) =>
481
+ flavor.hasThreads && retainedFlavors.has(flavor.platformArchABI),
482
+ )
483
+ if (regeneratesWorkers || !retainsThreadedFlavor) {
484
+ staleNames.add('wasi-worker.mjs')
485
+ staleNames.add('wasi-worker-browser.mjs')
486
+ }
487
+ return staleNames
488
+ }
489
+
490
+ export function removeWasmCustomSection(
491
+ binary: Uint8Array,
492
+ sectionName: string,
493
+ ): Uint8Array {
494
+ if (
495
+ binary.length < 8 ||
496
+ binary[0] !== 0x00 ||
497
+ binary[1] !== 0x61 ||
498
+ binary[2] !== 0x73 ||
499
+ binary[3] !== 0x6d
500
+ ) {
501
+ throw new Error('Invalid WebAssembly module header')
502
+ }
503
+
504
+ const retainedChunks: Uint8Array[] = []
505
+ let retainedStart = 0
506
+ let offset = 8
507
+ let removed = false
508
+
509
+ while (offset < binary.length) {
510
+ const sectionStart = offset
511
+ const sectionId = binary[offset++]
512
+ const sectionSize = readWasmU32(binary, offset)
513
+ const payloadStart = sectionSize.nextOffset
514
+ const sectionEnd = payloadStart + sectionSize.value
515
+ if (sectionEnd > binary.length) {
516
+ throw new Error('Invalid WebAssembly section size')
517
+ }
518
+
519
+ let shouldRemove = false
520
+ if (sectionId === 0) {
521
+ const nameSize = readWasmU32(binary, payloadStart)
522
+ const nameEnd = nameSize.nextOffset + nameSize.value
523
+ if (nameEnd > sectionEnd) {
524
+ throw new Error('Invalid WebAssembly custom section name')
525
+ }
526
+ shouldRemove =
527
+ Buffer.from(binary.subarray(nameSize.nextOffset, nameEnd)).toString(
528
+ 'utf8',
529
+ ) === sectionName
530
+ }
531
+
532
+ if (shouldRemove) {
533
+ retainedChunks.push(binary.subarray(retainedStart, sectionStart))
534
+ retainedStart = sectionEnd
535
+ removed = true
536
+ }
537
+ offset = sectionEnd
538
+ }
539
+
540
+ if (!removed) {
541
+ return binary
542
+ }
543
+ retainedChunks.push(binary.subarray(retainedStart))
544
+ const outputLength = retainedChunks.reduce(
545
+ (length, chunk) => length + chunk.length,
546
+ 0,
547
+ )
548
+ const output = new Uint8Array(outputLength)
549
+ let outputOffset = 0
550
+ for (const chunk of retainedChunks) {
551
+ output.set(chunk, outputOffset)
552
+ outputOffset += chunk.length
553
+ }
554
+ return output
555
+ }
556
+
557
+ function readWasmU32(binary: Uint8Array, start: number) {
558
+ let value = 0
559
+ let shift = 0
560
+ let offset = start
561
+ while (offset < binary.length && shift <= 28) {
562
+ const byte = binary[offset++]
563
+ value += (byte & 0x7f) * 2 ** shift
564
+ if ((byte & 0x80) === 0) {
565
+ return { value, nextOffset: offset }
566
+ }
567
+ shift += 7
568
+ }
569
+ throw new Error('Invalid WebAssembly unsigned LEB128 value')
570
+ }
571
+
57
572
  export async function buildProject(rawOptions: BuildOptions) {
58
573
  debug('napi build command receive options: %O', rawOptions)
59
574
 
@@ -80,10 +595,17 @@ export async function buildProject(rawOptions: BuildOptions) {
80
595
  const resolvePath = (...paths: string[]) => resolve(options.cwd, ...paths)
81
596
 
82
597
  const manifestPath = resolvePath(options.manifestPath ?? 'Cargo.toml')
598
+ const metadataTarget = options.target ?? process.env.CARGO_BUILD_TARGET
83
599
  const metadata = await parseMetadata(manifestPath, {
600
+ cwd: options.cwd,
601
+ featurePackage: options.package,
84
602
  features: options.features,
85
603
  allFeatures: options.allFeatures,
86
604
  noDefaultFeatures: options.noDefaultFeatures,
605
+ cargoOptions: options.cargoOptions,
606
+ filterPlatform: metadataTarget
607
+ ? parseTriple(metadataTarget).triple
608
+ : undefined,
87
609
  })
88
610
 
89
611
  const crate = metadata.packages.find((p) => {
@@ -396,9 +918,13 @@ class Builder {
396
918
 
397
919
  private readonly target: Target
398
920
  private readonly crateDir: string
399
- private readonly outputDir: string
921
+ private readonly finalOutputDir: string
922
+ private outputDir: string
923
+ private readonly reconciliationRoot: string
400
924
  private readonly targetDir: string
401
925
  private readonly enableTypeDef: boolean = false
926
+ private readonly stagedOutputDestinations = new Map<string, string>()
927
+ private typeDefWithTypeImports: string | undefined
402
928
 
403
929
  constructor(
404
930
  private readonly metadata: CargoWorkspaceMetadata,
@@ -408,10 +934,15 @@ class Builder {
408
934
  ) {
409
935
  this.target = resolveTarget(options.target)
410
936
  this.crateDir = parse(crate.manifest_path).dir
411
- this.outputDir = resolve(
937
+ this.finalOutputDir = resolve(
412
938
  this.options.cwd,
413
939
  options.outputDir ?? this.crateDir,
414
940
  )
941
+ this.outputDir = this.finalOutputDir
942
+ this.reconciliationRoot = getPackageReconciliationRoot(
943
+ this.options.cwd,
944
+ this.options.packageJsonPath,
945
+ )
415
946
  this.targetDir =
416
947
  options.targetDir ??
417
948
  process.env.CARGO_BUILD_TARGET_DIR ??
@@ -460,7 +991,7 @@ class Builder {
460
991
  )
461
992
  }
462
993
 
463
- build() {
994
+ async build() {
464
995
  // Backstop only: `buildProject()` already validated these before running
465
996
  // anything with a side effect (see the top of `buildProject`). Kept here
466
997
  // so a directly constructed `Builder` cannot skip the validation.
@@ -484,14 +1015,13 @@ class Builder {
484
1015
  }
485
1016
  }
486
1017
 
487
- return this.pickBinary()
1018
+ this.pickBinary()
488
1019
  .setPackage()
489
1020
  .setFeatures()
490
1021
  .setTarget()
491
1022
  .pickCrossToolchain()
492
- .setEnvs()
493
- .setBypassArgs()
494
- .exec()
1023
+ await this.setEnvs()
1024
+ return this.setBypassArgs().exec()
495
1025
  }
496
1026
 
497
1027
  private pickCrossToolchain() {
@@ -588,6 +1118,100 @@ class Builder {
588
1118
  }
589
1119
  }
590
1120
 
1121
+ private async collectStaleBuildOutputPaths() {
1122
+ const stalePaths = new Set<string>()
1123
+ const managedRootEntries = await this.readManagedWasiRootEntries()
1124
+
1125
+ for (const name of collectStaleWasiBuildOutputNames(
1126
+ this.config.binaryName,
1127
+ this.target,
1128
+ this.config.targets,
1129
+ )) {
1130
+ stalePaths.add(join(this.outputDir, name))
1131
+ }
1132
+ const hasWasiOutput =
1133
+ this.target.platform === 'wasi' ||
1134
+ this.config.targets.some((target) => target.platform === 'wasi') ||
1135
+ managedRootEntries.size > 0
1136
+ if (hasWasiOutput) {
1137
+ stalePaths.add(join(this.outputDir, 'browser.js'))
1138
+ stalePaths.add(join(this.outputDir, `${this.config.binaryName}.wasm`))
1139
+ stalePaths.add(
1140
+ join(this.outputDir, `${this.config.binaryName}.debug.wasm`),
1141
+ )
1142
+ if (this.options.platform && !this.options.noJsBinding) {
1143
+ stalePaths.add(
1144
+ join(this.outputDir, this.options.jsBinding ?? 'index.js'),
1145
+ )
1146
+ }
1147
+ }
1148
+ for (const entry of managedRootEntries) {
1149
+ stalePaths.add(
1150
+ resolveManagedOutputPath(this.outputDir, entry, 'WASI root entry'),
1151
+ )
1152
+ }
1153
+
1154
+ return stalePaths
1155
+ }
1156
+
1157
+ private async unlinkIfExists(path: string) {
1158
+ try {
1159
+ await unlinkAsync(path)
1160
+ } catch (error) {
1161
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
1162
+ throw error
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ private async readManagedWasiRootEntries() {
1168
+ const entries = new Set<string>()
1169
+ for (const { loaderSuffix } of MANAGED_WASI_FLAVORS) {
1170
+ const path = join(
1171
+ this.outputDir,
1172
+ `${this.config.binaryName}.${loaderSuffix}.cjs`,
1173
+ )
1174
+ if (!(await fileExists(path))) {
1175
+ continue
1176
+ }
1177
+ const metadata = parseExistingWasiArtifactMetadata(
1178
+ await readFileAsync(path, 'utf8'),
1179
+ )
1180
+ for (const entry of metadata?.managedRootEntries ?? []) {
1181
+ entries.add(entry)
1182
+ }
1183
+ }
1184
+ return entries
1185
+ }
1186
+
1187
+ private async readExistingWasiBindingMetadata(wasiTarget: Target) {
1188
+ const loaderSuffix = wasiLoaderSuffix(wasiTarget.platformArchABI)
1189
+ const bindingPath = join(
1190
+ this.finalOutputDir,
1191
+ `${this.config.binaryName}.${loaderSuffix}.cjs`,
1192
+ )
1193
+ const bindingTypeDefPath = join(
1194
+ this.finalOutputDir,
1195
+ `${this.config.binaryName}.${loaderSuffix}.d.cts`,
1196
+ )
1197
+ if (
1198
+ !(await fileExists(bindingPath)) ||
1199
+ !(await fileExists(bindingTypeDefPath))
1200
+ ) {
1201
+ return undefined
1202
+ }
1203
+ const artifactMetadata = parseExistingWasiArtifactMetadata(
1204
+ await readFileAsync(bindingPath, 'utf8'),
1205
+ )
1206
+ if (artifactMetadata?.exports === undefined) {
1207
+ return undefined
1208
+ }
1209
+ return {
1210
+ exports: artifactMetadata.exports,
1211
+ bindingTypeDef: await readFileAsync(bindingTypeDefPath, 'utf8'),
1212
+ }
1213
+ }
1214
+
591
1215
  private pickBinary() {
592
1216
  let set = false
593
1217
  if (this.options.watch) {
@@ -674,14 +1298,7 @@ class Builder {
674
1298
  return this
675
1299
  }
676
1300
 
677
- private setEnvs() {
678
- // TYPE DEF
679
- if (this.enableTypeDef) {
680
- this.envs.NAPI_TYPE_DEF_TMP_FOLDER =
681
- this.generateIntermediateTypeDefFolder()
682
- this.setForceBuildEnvs(this.envs.NAPI_TYPE_DEF_TMP_FOLDER)
683
- }
684
-
1301
+ private async setEnvs() {
685
1302
  // RUSTFLAGS
686
1303
  let rustflags =
687
1304
  process.env.RUSTFLAGS ?? process.env.CARGO_BUILD_RUSTFLAGS ?? ''
@@ -697,6 +1314,13 @@ class Builder {
697
1314
  rustflags += ' -C link-arg=-s'
698
1315
  }
699
1316
 
1317
+ // TYPE DEF
1318
+ if (this.enableTypeDef) {
1319
+ this.envs.NAPI_TYPE_DEF_TMP_FOLDER =
1320
+ await this.generateIntermediateTypeDefFolder(rustflags)
1321
+ this.setForceBuildEnvs(this.envs.NAPI_TYPE_DEF_TMP_FOLDER)
1322
+ }
1323
+
700
1324
  if (rustflags.length) {
701
1325
  this.envs.RUSTFLAGS = rustflags
702
1326
  }
@@ -785,17 +1409,37 @@ class Builder {
785
1409
  }
786
1410
 
787
1411
  private setWasiEnv() {
788
- const emnapi = join(
789
- require.resolve('emnapi'),
790
- '..',
791
- 'lib',
792
- 'wasm32-wasip1-threads',
1412
+ const hasThreads = wasiTargetHasThreads(this.target)
1413
+ const wasiTarget = hasThreads ? 'wasm32-wasip1-threads' : 'wasm32-wasip1'
1414
+ const emnapi = join(require.resolve('emnapi'), '..', 'lib', wasiTarget)
1415
+ const emnapiVersion = require('emnapi/package.json').version
1416
+ // Keep this in sync with `emnapi_link_library` in `crates/build/src/wasi.rs`.
1417
+ const emnapiArchive = join(
1418
+ emnapi,
1419
+ hasThreads ? 'libemnapi-napi-rs-mt.a' : 'libemnapi-basic-napi-rs.a',
793
1420
  )
1421
+ if (!existsSync(emnapiArchive)) {
1422
+ throw new Error(
1423
+ `emnapi@${emnapiVersion} is missing the ${wasiTarget} archive required by napi-rs at ${emnapiArchive}. Install emnapi v2 with support for this target.`,
1424
+ )
1425
+ }
794
1426
  this.envs.EMNAPI_LINK_DIR = emnapi
795
- const emnapiVersion = require('emnapi/package.json').version
796
- const projectRequire = createRequire(join(this.options.cwd, 'package.json'))
797
- const emnapiCoreVersion = projectRequire('@emnapi/core').version
798
- const emnapiRuntimeVersion = projectRequire('@emnapi/runtime').version
1427
+ const projectRequire = createRequire(
1428
+ resolve(this.options.cwd, 'package.json'),
1429
+ )
1430
+ const projectPackageVersion = (name: string): string => {
1431
+ try {
1432
+ return projectRequire(`${name}/package.json`).version
1433
+ } catch {
1434
+ // emnapi v1 packages do not list `./package.json` in their export
1435
+ // maps, so fall back to the module's own `version` export to keep the
1436
+ // descriptive version-mismatch error below reachable for stale
1437
+ // installs instead of a bare ERR_PACKAGE_PATH_NOT_EXPORTED.
1438
+ return projectRequire(name).version
1439
+ }
1440
+ }
1441
+ const emnapiCoreVersion = projectPackageVersion('@emnapi/core')
1442
+ const emnapiRuntimeVersion = projectPackageVersion('@emnapi/runtime')
799
1443
 
800
1444
  if (
801
1445
  emnapiVersion !== emnapiCoreVersion ||
@@ -838,18 +1482,18 @@ class Builder {
838
1482
  'TARGET_RANLIB',
839
1483
  join(WASI_SDK_PATH, 'bin', 'ranlib'),
840
1484
  )
841
- this.setEnvIfNotExists(
842
- 'TARGET_CFLAGS',
843
- `--target=wasm32-wasip1-threads --sysroot=${WASI_SDK_PATH}/share/wasi-sysroot -pthread -mllvm -wasm-enable-sjlj`,
844
- )
845
- this.setEnvIfNotExists(
846
- 'TARGET_CXXFLAGS',
847
- `--target=wasm32-wasip1-threads --sysroot=${WASI_SDK_PATH}/share/wasi-sysroot -pthread -mllvm -wasm-enable-sjlj`,
848
- )
849
- this.setEnvIfNotExists(
850
- `TARGET_LDFLAGS`,
851
- `-fuse-ld=${WASI_SDK_PATH}/bin/wasm-ld --target=wasm32-wasip1-threads`,
1485
+ const { compileFlags, linkerFlags } = createWasiCompilerFlags(
1486
+ WASI_SDK_PATH,
1487
+ wasiTarget,
1488
+ hasThreads,
1489
+ process.env.CC_SHELL_ESCAPED_FLAGS
1490
+ ? envBoolean(process.env.CC_SHELL_ESCAPED_FLAGS)
1491
+ : true,
852
1492
  )
1493
+ this.setEnvIfNotExists('CC_SHELL_ESCAPED_FLAGS', '1')
1494
+ this.setEnvIfNotExists('TARGET_CFLAGS', compileFlags)
1495
+ this.setEnvIfNotExists('TARGET_CXXFLAGS', compileFlags)
1496
+ this.setEnvIfNotExists(`TARGET_LDFLAGS`, linkerFlags)
853
1497
  }
854
1498
  }
855
1499
 
@@ -944,58 +1588,198 @@ class Builder {
944
1588
  return this
945
1589
  }
946
1590
 
947
- private generateIntermediateTypeDefFolder() {
948
- let folder = join(
949
- this.targetDir,
950
- 'napi-rs',
951
- `${this.crate.name}-${createHash('sha256')
952
- .update(this.crate.manifest_path)
953
- .update(CLI_VERSION)
954
- .digest('hex')
955
- .substring(0, 8)}`,
956
- )
1591
+ private async generateIntermediateTypeDefFolder(rustflags: string) {
1592
+ const targetRustFlagsEnv = `CARGO_TARGET_${targetToEnvVar(
1593
+ this.target.triple,
1594
+ )}_RUSTFLAGS`
1595
+ let folder = getTypeDefCacheFolder({
1596
+ targetDir: this.targetDir,
1597
+ crateName: this.crate.name,
1598
+ manifestPath: this.crate.manifest_path,
1599
+ targetTriple: this.target.triple,
1600
+ profile:
1601
+ this.options.profile ?? (this.options.release ? 'release' : 'dev'),
1602
+ features: this.options.features,
1603
+ allFeatures: this.options.allFeatures,
1604
+ noDefaultFeatures: this.options.noDefaultFeatures,
1605
+ cargoOptions: this.options.cargoOptions,
1606
+ rustFlags: {
1607
+ RUSTFLAGS: rustflags || undefined,
1608
+ CARGO_ENCODED_RUSTFLAGS: process.env.CARGO_ENCODED_RUSTFLAGS,
1609
+ CARGO_BUILD_RUSTFLAGS: process.env.CARGO_BUILD_RUSTFLAGS,
1610
+ [targetRustFlagsEnv]: process.env[targetRustFlagsEnv],
1611
+ },
1612
+ cargoProfileEnv: Object.fromEntries(
1613
+ Object.entries(process.env).filter(([name]) =>
1614
+ name.startsWith('CARGO_PROFILE_'),
1615
+ ),
1616
+ ),
1617
+ cargoConfig: this.getCargoConfigFingerprints(),
1618
+ cargoDependencyGraph: getCargoDependencyGraphFingerprint(
1619
+ this.metadata,
1620
+ this.crate.id,
1621
+ ),
1622
+ })
957
1623
 
958
1624
  if (!this.options.dtsCache) {
959
- rmSync(folder, { recursive: true, force: true })
960
- folder += `_${Date.now()}`
1625
+ await mkdirAsync(dirname(folder), { recursive: true })
1626
+ folder = await mkdtempAsync(`${folder}-`)
1627
+ } else {
1628
+ await mkdirAsync(folder, { recursive: true })
961
1629
  }
962
1630
 
963
- mkdirAsync(folder, { recursive: true })
964
-
965
1631
  return folder
966
1632
  }
967
1633
 
968
- private async postBuild() {
1634
+ private getCargoConfigFingerprints(): CargoConfigFingerprint[] {
1635
+ const configPaths = new Set<string>()
1636
+ const addConfigIfPresent = (path: string) => {
1637
+ if (existsSync(path) && statSync(path).isFile()) {
1638
+ configPaths.add(path)
1639
+ }
1640
+ }
1641
+ const addConfigDirectory = (directory: string) => {
1642
+ addConfigIfPresent(join(directory, 'config.toml'))
1643
+ addConfigIfPresent(join(directory, 'config'))
1644
+ }
1645
+
1646
+ let currentDirectory = resolve(this.options.cwd)
1647
+ while (true) {
1648
+ addConfigDirectory(join(currentDirectory, '.cargo'))
1649
+ const parentDirectory = dirname(currentDirectory)
1650
+ if (parentDirectory === currentDirectory) {
1651
+ break
1652
+ }
1653
+ currentDirectory = parentDirectory
1654
+ }
1655
+
1656
+ addConfigDirectory(
1657
+ process.env.CARGO_HOME
1658
+ ? resolve(this.options.cwd, process.env.CARGO_HOME)
1659
+ : join(homedir(), '.cargo'),
1660
+ )
1661
+
1662
+ const cargoOptions = this.options.cargoOptions ?? []
1663
+ for (let index = 0; index < cargoOptions.length; index++) {
1664
+ const option = cargoOptions[index]
1665
+ if (option === '--config') {
1666
+ const value = cargoOptions[index + 1]
1667
+ if (value) {
1668
+ addConfigIfPresent(resolve(this.options.cwd, value))
1669
+ index++
1670
+ }
1671
+ } else if (option.startsWith('--config=')) {
1672
+ addConfigIfPresent(
1673
+ resolve(this.options.cwd, option.slice('--config='.length)),
1674
+ )
1675
+ }
1676
+ }
1677
+
1678
+ return [...configPaths]
1679
+ .sort()
1680
+ .map((path) => [
1681
+ path,
1682
+ createHash('sha256').update(readFileSync(path)).digest('hex'),
1683
+ ])
1684
+ }
1685
+
1686
+ private postBuild() {
1687
+ return withFileSystemReconciliation(this.reconciliationRoot, () =>
1688
+ this.postBuildUnlocked(),
1689
+ )
1690
+ }
1691
+
1692
+ private async postBuildUnlocked() {
1693
+ const finalOutputDir = this.outputDir
969
1694
  try {
970
1695
  debug(`Try to create output directory:`)
971
- debug(' %i', this.outputDir)
972
- await mkdirAsync(this.outputDir, { recursive: true })
1696
+ debug(' %i', finalOutputDir)
1697
+ await mkdirAsync(finalOutputDir, { recursive: true })
973
1698
  debug(`Output directory created`)
974
1699
  } catch (e) {
975
- throw new Error(`Failed to create output directory ${this.outputDir}`, {
1700
+ throw new Error(`Failed to create output directory ${finalOutputDir}`, {
976
1701
  cause: e,
977
1702
  })
978
1703
  }
979
1704
 
980
- const wasmBinaryName = await this.copyArtifact()
1705
+ const stalePaths = this.options.watch
1706
+ ? new Set<string>()
1707
+ : await this.collectStaleBuildOutputPaths()
1708
+ const stagingDir = await mkdtempAsync(
1709
+ join(dirname(finalOutputDir), `.${basename(finalOutputDir)}.napi-stage-`),
1710
+ )
1711
+ const outputStart = this.outputs.length
1712
+ this.stagedOutputDestinations.clear()
1713
+ this.outputDir = stagingDir
981
1714
 
982
- // only for cdylib
983
- if (this.cdyLibName) {
984
- const idents = await this.generateTypeDef()
985
- const jsOutput = await this.writeJsBinding(idents)
986
- const wasmBindingsOutput = await this.writeWasiBinding(
987
- wasmBinaryName,
988
- idents,
989
- )
990
- if (jsOutput) {
991
- this.outputs.push(jsOutput)
992
- }
993
- if (wasmBindingsOutput) {
994
- this.outputs.push(...wasmBindingsOutput)
1715
+ try {
1716
+ const wasmBinaryName = await this.copyArtifact()
1717
+
1718
+ // only for cdylib
1719
+ if (this.cdyLibName) {
1720
+ const idents = await this.generateTypeDef()
1721
+ const jsOutput = await this.writeJsBinding(idents)
1722
+ const wasmBindingsOutput = await this.writeWasiBinding(
1723
+ wasmBinaryName,
1724
+ idents,
1725
+ )
1726
+ if (jsOutput) {
1727
+ this.outputs.push(jsOutput)
1728
+ }
1729
+ if (wasmBindingsOutput) {
1730
+ this.outputs.push(...wasmBindingsOutput)
1731
+ }
995
1732
  }
996
- }
997
1733
 
998
- return this.outputs
1734
+ const stagedFiles = await collectFilesRecursively(stagingDir)
1735
+ const writes = [
1736
+ ...stagedFiles
1737
+ .filter((source) => !this.stagedOutputDestinations.has(source))
1738
+ .map((source) => ({
1739
+ source,
1740
+ destination: join(finalOutputDir, relative(stagingDir, source)),
1741
+ })),
1742
+ ...[...this.stagedOutputDestinations].map(([source, destination]) => ({
1743
+ source,
1744
+ destination,
1745
+ })),
1746
+ ]
1747
+ const writtenDestinations = new Set(
1748
+ writes.map(({ destination }) => destination),
1749
+ )
1750
+ const transactionRoot = commonPathAncestor([
1751
+ finalOutputDir,
1752
+ ...writtenDestinations,
1753
+ ...stalePaths,
1754
+ ])
1755
+ await commitFileSystemTransaction(
1756
+ transactionRoot,
1757
+ writes,
1758
+ [...stalePaths].filter((path) => !writtenDestinations.has(path)),
1759
+ )
1760
+
1761
+ const committedOutputs = this.outputs
1762
+ .slice(outputStart)
1763
+ .map((output) => ({
1764
+ ...output,
1765
+ path:
1766
+ this.stagedOutputDestinations.get(output.path) ??
1767
+ join(finalOutputDir, relative(stagingDir, output.path)),
1768
+ }))
1769
+ this.outputs.splice(
1770
+ outputStart,
1771
+ this.outputs.length - outputStart,
1772
+ ...committedOutputs,
1773
+ )
1774
+ return this.outputs
1775
+ } catch (error) {
1776
+ this.outputs.splice(outputStart)
1777
+ throw error
1778
+ } finally {
1779
+ this.outputDir = finalOutputDir
1780
+ this.stagedOutputDestinations.clear()
1781
+ await rm(stagingDir, { force: true, recursive: true })
1782
+ }
999
1783
  }
1000
1784
 
1001
1785
  private async copyArtifact() {
@@ -1010,49 +1794,59 @@ class Builder {
1010
1794
  debug(`Copy artifact from: [${src}]`)
1011
1795
  const dest = join(this.outputDir, destName)
1012
1796
  const isWasm = dest.endsWith('.wasm')
1797
+ const debugDest = isWasm
1798
+ ? dest.replace(/\.wasm$/, '.debug.wasm')
1799
+ : undefined
1800
+ let artifactReplaced = false
1013
1801
 
1014
1802
  try {
1015
- if (await fileExists(dest)) {
1016
- debug('Old artifact found, remove it first')
1017
- await unlinkAsync(dest)
1018
- }
1019
1803
  debug('Copy artifact to:')
1020
1804
  debug(' %i', dest)
1021
1805
  if (isWasm) {
1022
1806
  const { ModuleConfig } = await import('@napi-rs/wasm-tools')
1023
1807
  debug('Generate debug wasm module')
1024
1808
  try {
1025
- const debugWasmModule = new ModuleConfig()
1026
- .generateDwarf(true)
1027
- .generateNameSection(true)
1028
- .generateProducersSection(true)
1029
- .preserveCodeTransform(true)
1030
- .strictValidate(false)
1031
- .parse(await readFileAsync(src))
1032
- const debugWasmBinary = debugWasmModule.emitWasm(true)
1033
- await writeFileAsync(
1034
- dest.replace(/\.wasm$/, '.debug.wasm'),
1035
- debugWasmBinary,
1809
+ const debugWasmBinary = removeWasmCustomSection(
1810
+ new ModuleConfig()
1811
+ .generateDwarf(true)
1812
+ .generateNameSection(true)
1813
+ .generateProducersSection(true)
1814
+ .preserveCodeTransform(true)
1815
+ .strictValidate(false)
1816
+ .parse(await readFileAsync(src))
1817
+ .emitWasm(true),
1818
+ 'build_id',
1036
1819
  )
1820
+ await writeFileAtomic(debugDest!, debugWasmBinary)
1037
1821
  debug('Generate release wasm module')
1038
- const releaseWasmModule = new ModuleConfig()
1039
- .generateDwarf(false)
1040
- .generateNameSection(false)
1041
- .generateProducersSection(false)
1042
- .preserveCodeTransform(false)
1043
- .strictValidate(false)
1044
- .onlyStableFeatures(false)
1045
- .parse(debugWasmBinary)
1046
- const releaseWasmBinary = releaseWasmModule.emitWasm(false)
1047
- await writeFileAsync(dest, releaseWasmBinary)
1822
+ const releaseWasmBinary = removeWasmCustomSection(
1823
+ new ModuleConfig()
1824
+ .generateDwarf(false)
1825
+ .generateNameSection(false)
1826
+ .generateProducersSection(false)
1827
+ .preserveCodeTransform(false)
1828
+ .strictValidate(false)
1829
+ .onlyStableFeatures(false)
1830
+ .parse(debugWasmBinary)
1831
+ .emitWasm(false),
1832
+ 'build_id',
1833
+ )
1834
+ await writeFileAtomic(dest, releaseWasmBinary)
1835
+ artifactReplaced = true
1048
1836
  } catch (e) {
1049
1837
  debug.warn(
1050
1838
  `Failed to generate debug wasm module: ${(e as any).message ?? e}`,
1051
1839
  )
1052
- await copyFileAsync(src, dest)
1840
+ await this.unlinkIfExists(debugDest!)
1841
+ await copyFileAtomic(src, dest)
1842
+ artifactReplaced = true
1843
+ }
1844
+ if (this.target.platform === 'wasi') {
1845
+ await verifyWasiReactor(dest)
1053
1846
  }
1054
1847
  } else {
1055
- await copyFileAsync(src, dest)
1848
+ await copyFileAtomic(src, dest)
1849
+ artifactReplaced = true
1056
1850
  }
1057
1851
  this.outputs.push({
1058
1852
  kind: dest.endsWith('.node') ? 'node' : isWasm ? 'wasm' : 'exe',
@@ -1060,6 +1854,13 @@ class Builder {
1060
1854
  })
1061
1855
  return wasmBinaryName ? join(this.outputDir, wasmBinaryName) : null
1062
1856
  } catch (e) {
1857
+ if (artifactReplaced) {
1858
+ await Promise.all(
1859
+ [dest, debugDest]
1860
+ .filter((path): path is string => path !== undefined)
1861
+ .map((path) => this.unlinkIfExists(path)),
1862
+ )
1863
+ }
1063
1864
  throw new Error('Failed to copy artifact', { cause: e })
1064
1865
  }
1065
1866
  }
@@ -1067,7 +1868,14 @@ class Builder {
1067
1868
  private getArtifactNames() {
1068
1869
  if (this.cdyLibName) {
1069
1870
  const cdyLib = this.cdyLibName.replace(/-/g, '_')
1070
- const wasiTarget = this.config.targets.find((t) => t.platform === 'wasi')
1871
+ // When building a wasi target, name the wasm artifact after the flavor
1872
+ // being built (two wasi flavors may be declared side by side); for
1873
+ // non-wasi builds fall back to the first declared wasi target so the
1874
+ // loader set can still be regenerated deterministically.
1875
+ const wasiTarget =
1876
+ this.target.platform === 'wasi'
1877
+ ? this.target
1878
+ : this.config.targets.find((t) => t.platform === 'wasi')
1071
1879
 
1072
1880
  const srcName =
1073
1881
  this.target.platform === 'darwin'
@@ -1078,18 +1886,12 @@ class Builder {
1078
1886
  ? `${cdyLib}.wasm`
1079
1887
  : `lib${cdyLib}.so`
1080
1888
 
1081
- let destName = this.config.binaryName
1082
- // add platform suffix to binary name
1083
- // index[.linux-x64-gnu].node
1084
- // ^^^^^^^^^^^^^^
1085
- if (this.options.platform) {
1086
- destName += `.${this.target.platformArchABI}`
1087
- }
1088
- if (srcName.endsWith('.wasm')) {
1089
- destName += '.wasm'
1090
- } else {
1091
- destName += '.node'
1092
- }
1889
+ const destName = createArtifactDestinationName(
1890
+ this.config.binaryName,
1891
+ this.target,
1892
+ srcName,
1893
+ this.options.platform ?? false,
1894
+ )
1093
1895
 
1094
1896
  return [
1095
1897
  srcName,
@@ -1114,7 +1916,7 @@ class Builder {
1114
1916
  return []
1115
1917
  }
1116
1918
 
1117
- const { exports, dts } = await generateTypeDef({
1919
+ const { exports, dts, dtsWithTypeImports } = await generateTypeDef({
1118
1920
  typeDefDir,
1119
1921
  noDtsHeader: this.options.noDtsHeader,
1120
1922
  dtsHeader: this.options.dtsHeader,
@@ -1125,27 +1927,77 @@ class Builder {
1125
1927
  this.options.runtimeStringEnum ?? this.config.runtimeStringEnum,
1126
1928
  cwd: this.options.cwd,
1127
1929
  })
1930
+ this.typeDefWithTypeImports = dtsWithTypeImports
1128
1931
 
1129
- const dest = join(this.outputDir, this.options.dts ?? 'index.d.ts')
1932
+ const typeDefRelativePath = this.options.dts ?? 'index.d.ts'
1933
+ const finalDest = join(this.finalOutputDir, typeDefRelativePath)
1934
+ const dest = this.getStagedOutputPath(finalDest)
1130
1935
 
1131
1936
  try {
1132
1937
  debug('Writing type def to:')
1133
1938
  debug(' %i', dest)
1134
- await writeFileAsync(dest, dts, 'utf-8')
1939
+ assertWritableOutputDestination(finalDest)
1940
+ await mkdirAsync(dirname(dest), { recursive: true })
1941
+ await writeFileAtomic(dest, dts, 'utf-8')
1135
1942
  } catch (e) {
1136
- debug.error('Failed to write type def file')
1137
- debug.error(e as Error)
1943
+ throw new Error(`Failed to write type def file ${dest}`, { cause: e })
1138
1944
  }
1139
1945
 
1140
1946
  if (exports.length > 0) {
1141
- const dest = join(this.outputDir, this.options.dts ?? 'index.d.ts')
1142
1947
  this.outputs.push({ kind: 'dts', path: dest })
1143
1948
  }
1144
1949
 
1145
1950
  return exports
1146
1951
  }
1147
1952
 
1953
+ private getStagedOutputPath(finalPath: string) {
1954
+ if (this.outputDir === this.finalOutputDir) {
1955
+ return finalPath
1956
+ }
1957
+ const relativePath = relative(this.finalOutputDir, finalPath)
1958
+ if (
1959
+ relativePath !== '..' &&
1960
+ !relativePath.startsWith(`..${sep}`) &&
1961
+ !isAbsolute(relativePath)
1962
+ ) {
1963
+ return join(this.outputDir, relativePath)
1964
+ }
1965
+ const stagedPath = join(
1966
+ this.outputDir,
1967
+ '.napi-external',
1968
+ createHash('sha256').update(finalPath).digest('hex'),
1969
+ )
1970
+ this.stagedOutputDestinations.set(stagedPath, finalPath)
1971
+ return stagedPath
1972
+ }
1973
+
1148
1974
  private async writeJsBinding(idents: string[]) {
1975
+ // Default WASI fallback order: threaded first. The generated root loader
1976
+ // also lets consumers pin one exact declared flavor with
1977
+ // NAPI_RS_WASI_FLAVOR.
1978
+ const declaredWasiTargets = this.config.targets.filter(
1979
+ (t) => t.platform === 'wasi',
1980
+ )
1981
+ // A direct `napi build --target wasm32-wasip1` (or any wasi triple) must
1982
+ // participate even when the config does not declare that target —
1983
+ // `writeWasiBinding` emits its loader set, so the index chain has to
1984
+ // reference the same flavor.
1985
+ if (
1986
+ this.target.platform === 'wasi' &&
1987
+ !declaredWasiTargets.some(
1988
+ (t) => t.platformArchABI === this.target.platformArchABI,
1989
+ )
1990
+ ) {
1991
+ declaredWasiTargets.push(this.target)
1992
+ }
1993
+ const wasiFlavors = [
1994
+ ...new Set(
1995
+ [
1996
+ ...declaredWasiTargets.filter(wasiTargetHasThreads),
1997
+ ...declaredWasiTargets.filter((t) => !wasiTargetHasThreads(t)),
1998
+ ].map((t) => t.platformArchABI),
1999
+ ),
2000
+ ]
1149
2001
  return writeJsBinding({
1150
2002
  platform: this.options.platform,
1151
2003
  noJsBinding: this.options.noJsBinding,
@@ -1156,6 +2008,7 @@ class Builder {
1156
2008
  packageName: this.options.jsPackageName ?? this.config.packageName,
1157
2009
  version: process.env.npm_new_version ?? this.config.packageJson.version,
1158
2010
  outputDir: this.outputDir,
2011
+ wasiFlavors,
1159
2012
  })
1160
2013
  }
1161
2014
 
@@ -1164,78 +2017,248 @@ class Builder {
1164
2017
  idents: string[],
1165
2018
  ) {
1166
2019
  if (distFileName) {
1167
- const { name, dir } = parse(distFileName)
1168
- const bindingPath = join(dir, `${this.config.binaryName}.wasi.cjs`)
1169
- const browserBindingPath = join(
1170
- dir,
1171
- `${this.config.binaryName}.wasi-browser.js`,
1172
- )
1173
- const workerPath = join(dir, 'wasi-worker.mjs')
1174
- const browserWorkerPath = join(dir, 'wasi-worker-browser.mjs')
1175
- const browserEntryPath = join(dir, 'browser.js')
1176
- const exportsCode =
1177
- `module.exports = __napiModule.exports\n` +
1178
- idents
1179
- .map(
1180
- (ident) =>
1181
- `module.exports.${ident} = __napiModule.exports.${ident}`,
2020
+ const { dir } = parse(distFileName)
2021
+ // A WASI build owns the export/declaration metadata for its flavor.
2022
+ // Non-WASI builds use that metadata to regenerate declared loader sets
2023
+ // without substituting the native surface. Legacy flavor files without
2024
+ // metadata remain untouched. Two triples mapping to the same
2025
+ // `platformArchABI` (e.g. `wasm32-wasip1-threads` and
2026
+ // `wasm32-wasi-preview1-threads`) describe the same artifact set, so
2027
+ // dedupe on it.
2028
+ const wasiTargets: Target[] = []
2029
+ const seen = new Set<string>()
2030
+ const declaredWasiTargets =
2031
+ this.target.platform === 'wasi'
2032
+ ? [this.target]
2033
+ : this.config.targets.filter((t) => t.platform === 'wasi')
2034
+ for (const wasiTarget of declaredWasiTargets) {
2035
+ if (seen.has(wasiTarget.platformArchABI)) {
2036
+ continue
2037
+ }
2038
+ seen.add(wasiTarget.platformArchABI)
2039
+ wasiTargets.push(wasiTarget)
2040
+ }
2041
+ const outputs: Output[] = []
2042
+ const metadataByFlavor = new Map<string, WasiBindingMetadata>()
2043
+ for (const wasiTarget of wasiTargets) {
2044
+ const metadata =
2045
+ this.target.platform === 'wasi' &&
2046
+ wasiTarget.platformArchABI === this.target.platformArchABI
2047
+ ? { exports: idents }
2048
+ : await this.readExistingWasiBindingMetadata(wasiTarget)
2049
+ if (!metadata) {
2050
+ continue
2051
+ }
2052
+ metadataByFlavor.set(wasiTarget.platformArchABI, metadata)
2053
+ outputs.push(
2054
+ ...(await this.writeWasiBindingForTarget(wasiTarget, dir, metadata)),
2055
+ )
2056
+ }
2057
+ if (wasiTargets.length > 0) {
2058
+ // The browser entry re-exports a single flavor: the non-threaded one
2059
+ // when declared (browser environments without cross-origin isolation
2060
+ // cannot use the threaded flavor). An explicit WASI build target is
2061
+ // authoritative, even when package config declares another flavor.
2062
+ const browserFlavor = selectWasiBrowserTarget(
2063
+ this.target,
2064
+ this.config.targets,
2065
+ wasiTargets,
2066
+ )
2067
+ const browserEntryPath = join(dir, 'browser.js')
2068
+ const browserMetadata = metadataByFlavor.get(
2069
+ browserFlavor.platformArchABI,
2070
+ )
2071
+ if (browserMetadata) {
2072
+ await writeFileAtomic(
2073
+ browserEntryPath,
2074
+ createWasiBrowserEntry(
2075
+ this.config.packageName,
2076
+ browserFlavor.platformArchABI,
2077
+ browserMetadata.exports,
2078
+ ),
2079
+ )
2080
+ outputs.push({ kind: 'js', path: browserEntryPath })
2081
+ } else {
2082
+ const existingBrowserEntryPath = join(
2083
+ this.finalOutputDir,
2084
+ 'browser.js',
1182
2085
  )
1183
- .join('\n')
1184
- await writeFileAsync(
1185
- bindingPath,
2086
+ if (await fileExists(existingBrowserEntryPath)) {
2087
+ await copyFileAtomic(existingBrowserEntryPath, browserEntryPath)
2088
+ outputs.push({ kind: 'js', path: browserEntryPath })
2089
+ }
2090
+ }
2091
+ }
2092
+ return outputs
2093
+ }
2094
+ return []
2095
+ }
2096
+
2097
+ private async writeWasiBindingForTarget(
2098
+ wasiTarget: Target,
2099
+ dir: string,
2100
+ metadata: WasiBindingMetadata,
2101
+ ): Promise<Output[]> {
2102
+ const { exports: idents } = metadata
2103
+ const hasThreads = wasiTargetHasThreads(wasiTarget)
2104
+ const loaderSuffix = wasiLoaderSuffix(wasiTarget.platformArchABI)
2105
+ // the wasm file stem referenced from inside the loaders
2106
+ const name = `${this.config.binaryName}.${wasiTarget.platformArchABI}`
2107
+ const bindingPath = join(
2108
+ dir,
2109
+ `${this.config.binaryName}.${loaderSuffix}.cjs`,
2110
+ )
2111
+ const browserBindingPath = join(
2112
+ dir,
2113
+ `${this.config.binaryName}.${loaderSuffix}-browser.js`,
2114
+ )
2115
+ const bindingTypeDefPath = join(
2116
+ dir,
2117
+ `${this.config.binaryName}.${loaderSuffix}.d.cts`,
2118
+ )
2119
+ const exportsCode =
2120
+ `module.exports = __napiModule.exports\n` +
2121
+ idents
2122
+ .map(
2123
+ (ident) => `module.exports.${ident} = __napiModule.exports.${ident}`,
2124
+ )
2125
+ .join('\n')
2126
+ await writeFileAtomic(
2127
+ bindingPath,
2128
+ createWasiArtifactMetadata(
2129
+ this.options.platform && !this.options.noJsBinding
2130
+ ? (this.options.jsBinding ?? 'index.js')
2131
+ : null,
2132
+ this.config.binaryName,
2133
+ idents,
2134
+ ) +
1186
2135
  createWasiBinding(
1187
2136
  name,
1188
2137
  this.config.packageName,
1189
2138
  this.config.wasm?.initialMemory,
1190
2139
  this.config.wasm?.maximumMemory,
2140
+ hasThreads,
2141
+ wasiTarget.platformArchABI,
2142
+ `${this.config.binaryName}.${wasiTarget.platformArchABI}`,
1191
2143
  ) +
1192
- exportsCode +
1193
- '\n',
2144
+ exportsCode +
2145
+ '\n',
2146
+ 'utf8',
2147
+ )
2148
+ await writeFileAtomic(
2149
+ browserBindingPath,
2150
+ createWasiBrowserBinding(
2151
+ name,
2152
+ this.config.wasm?.initialMemory,
2153
+ this.config.wasm?.maximumMemory,
2154
+ this.config.wasm?.browser?.fs,
2155
+ this.config.wasm?.browser?.asyncInit,
2156
+ this.config.wasm?.browser?.buffer,
2157
+ this.config.wasm?.browser?.errorEvent,
2158
+ hasThreads,
2159
+ ) +
2160
+ `export default __napiModule.exports\n` +
2161
+ idents
2162
+ .map(
2163
+ (ident) => `export const ${ident} = __napiModule.exports.${ident}`,
2164
+ )
2165
+ .join('\n') +
2166
+ '\n',
2167
+ 'utf8',
2168
+ )
2169
+ let bindingTypeDef = metadata.bindingTypeDef
2170
+ if (bindingTypeDef === undefined) {
2171
+ const finalSourceTypeDefPath = join(
2172
+ this.finalOutputDir,
2173
+ this.options.dts ?? 'index.d.ts',
2174
+ )
2175
+ const sourceTypeDefPath = this.enableTypeDef
2176
+ ? this.getStagedOutputPath(finalSourceTypeDefPath)
2177
+ : finalSourceTypeDefPath
2178
+ const sourceTypeDef = this.enableTypeDef
2179
+ ? await readFileAsync(sourceTypeDefPath, 'utf8')
2180
+ : `${DEFAULT_TYPE_DEF_HEADER}
2181
+ declare const binding: Record<string, unknown>
2182
+ export = binding
2183
+ `
2184
+ const selectedSourceTypeDef =
2185
+ !hasThreads &&
2186
+ this.config.wasm?.browser?.buffer === true &&
2187
+ this.typeDefWithTypeImports
2188
+ ? this.typeDefWithTypeImports
2189
+ : sourceTypeDef
2190
+ bindingTypeDef = this.enableTypeDef
2191
+ ? prepareWasiBindingTypeDef(
2192
+ selectedSourceTypeDef,
2193
+ finalSourceTypeDefPath,
2194
+ join(
2195
+ this.finalOutputDir,
2196
+ relative(this.outputDir, bindingTypeDefPath),
2197
+ ),
2198
+ hasThreads,
2199
+ this.config.packageJson.type,
2200
+ )
2201
+ : hasThreads
2202
+ ? selectedSourceTypeDef
2203
+ : removeNodeStreamWebTypeImports(selectedSourceTypeDef)
2204
+ }
2205
+ await writeFileAtomic(bindingTypeDefPath, bindingTypeDef, 'utf8')
2206
+ const outputs: Output[] = [
2207
+ { kind: 'js', path: bindingPath },
2208
+ { kind: 'js', path: browserBindingPath },
2209
+ { kind: 'dts', path: bindingTypeDefPath },
2210
+ ]
2211
+ if (hasThreads) {
2212
+ // worker scripts are only referenced by the threaded loaders
2213
+ const workerPath = join(dir, 'wasi-worker.mjs')
2214
+ const browserWorkerPath = join(dir, 'wasi-worker-browser.mjs')
2215
+ await writeFileAtomic(workerPath, WASI_WORKER_TEMPLATE, 'utf8')
2216
+ await writeFileAtomic(
2217
+ browserWorkerPath,
2218
+ createWasiBrowserWorkerBinding(
2219
+ this.config.wasm?.browser?.fs ?? false,
2220
+ this.config.wasm?.browser?.errorEvent ?? false,
2221
+ ),
1194
2222
  'utf8',
1195
2223
  )
1196
- await writeFileAsync(
1197
- browserBindingPath,
1198
- createWasiBrowserBinding(
2224
+ outputs.push(
2225
+ { kind: 'js', path: workerPath },
2226
+ { kind: 'js', path: browserWorkerPath },
2227
+ )
2228
+ } else {
2229
+ // the deferred workerd-safe loader only exists for non-threaded flavors
2230
+ const deferredBindingPath = join(
2231
+ dir,
2232
+ `${this.config.binaryName}.${loaderSuffix}-deferred.js`,
2233
+ )
2234
+ const deferredTypeDefPath = join(
2235
+ dir,
2236
+ `${this.config.binaryName}.${loaderSuffix}-deferred.d.ts`,
2237
+ )
2238
+ await writeFileAtomic(
2239
+ deferredBindingPath,
2240
+ createWasiDeferredBrowserBinding(
1199
2241
  name,
1200
2242
  this.config.wasm?.initialMemory,
1201
2243
  this.config.wasm?.maximumMemory,
1202
- this.config.wasm?.browser?.fs,
1203
- this.config.wasm?.browser?.asyncInit,
1204
2244
  this.config.wasm?.browser?.buffer,
1205
- this.config.wasm?.browser?.errorEvent,
1206
- ) +
1207
- `export default __napiModule.exports\n` +
1208
- idents
1209
- .map(
1210
- (ident) =>
1211
- `export const ${ident} = __napiModule.exports.${ident}`,
1212
- )
1213
- .join('\n') +
1214
- '\n',
2245
+ ),
1215
2246
  'utf8',
1216
2247
  )
1217
- await writeFileAsync(workerPath, WASI_WORKER_TEMPLATE, 'utf8')
1218
- await writeFileAsync(
1219
- browserWorkerPath,
1220
- createWasiBrowserWorkerBinding(
1221
- this.config.wasm?.browser?.fs ?? false,
1222
- this.config.wasm?.browser?.errorEvent ?? false,
2248
+ await writeFileAtomic(
2249
+ deferredTypeDefPath,
2250
+ createWasiDeferredBindingTypeDef(
2251
+ `./${this.config.binaryName}.${loaderSuffix}.cjs`,
2252
+ this.enableTypeDef,
1223
2253
  ),
1224
2254
  'utf8',
1225
2255
  )
1226
- await writeFileAsync(
1227
- browserEntryPath,
1228
- `export * from '${this.config.packageName}-wasm32-wasi'\n`,
2256
+ outputs.push(
2257
+ { kind: 'js', path: deferredBindingPath },
2258
+ { kind: 'dts', path: deferredTypeDefPath },
1229
2259
  )
1230
- return [
1231
- { kind: 'js', path: bindingPath },
1232
- { kind: 'js', path: browserBindingPath },
1233
- { kind: 'js', path: workerPath },
1234
- { kind: 'js', path: browserWorkerPath },
1235
- { kind: 'js', path: browserEntryPath },
1236
- ] satisfies Output[]
1237
2260
  }
1238
- return []
2261
+ return outputs
1239
2262
  }
1240
2263
 
1241
2264
  private setEnvIfNotExists(env: string, value: string) {
@@ -1245,6 +2268,85 @@ class Builder {
1245
2268
  }
1246
2269
  }
1247
2270
 
2271
+ export async function verifyWasiReactor(wasmPath: string): Promise<void> {
2272
+ const bytes = await readFileAsync(wasmPath)
2273
+ let module: WebAssembly.Module
2274
+ try {
2275
+ module = new WebAssembly.Module(bytes)
2276
+ } catch (error) {
2277
+ throw new Error(`Failed to validate WASI artifact ${wasmPath}`, {
2278
+ cause: error,
2279
+ })
2280
+ }
2281
+ if (
2282
+ !WebAssembly.Module.exports(module).some(
2283
+ ({ name, kind }) => name === '_initialize' && kind === 'function',
2284
+ )
2285
+ ) {
2286
+ throw new Error(
2287
+ `WASI artifact ${wasmPath} does not export _initialize. Ensure napi-build can locate crt1-reactor.o for the selected Rust target.`,
2288
+ )
2289
+ }
2290
+ }
2291
+
2292
+ function assertWritableOutputDestination(path: string) {
2293
+ if (existsSync(path) && statSync(path).isDirectory()) {
2294
+ throw new Error(`Output destination is a directory: ${path}`)
2295
+ }
2296
+
2297
+ let parent = dirname(path)
2298
+ while (!existsSync(parent)) {
2299
+ const nextParent = dirname(parent)
2300
+ if (nextParent === parent) {
2301
+ return
2302
+ }
2303
+ parent = nextParent
2304
+ }
2305
+ if (!statSync(parent).isDirectory()) {
2306
+ throw new Error(`Output parent is not a directory: ${parent}`)
2307
+ }
2308
+ }
2309
+
2310
+ function commonPathAncestor(paths: Iterable<string>) {
2311
+ const resolvedPaths = [...paths].map((path) => resolve(path))
2312
+ if (resolvedPaths.length === 0) {
2313
+ throw new Error('Cannot compute a common path for no filesystem outputs')
2314
+ }
2315
+ let common = resolvedPaths[0]
2316
+ for (const path of resolvedPaths.slice(1)) {
2317
+ while (true) {
2318
+ const relativePath = relative(common, path)
2319
+ if (
2320
+ relativePath === '' ||
2321
+ (relativePath !== '..' &&
2322
+ !relativePath.startsWith(`..${sep}`) &&
2323
+ !isAbsolute(relativePath))
2324
+ ) {
2325
+ break
2326
+ }
2327
+ const parent = dirname(common)
2328
+ if (parent === common) {
2329
+ break
2330
+ }
2331
+ common = parent
2332
+ }
2333
+ }
2334
+ return common
2335
+ }
2336
+
2337
+ async function collectFilesRecursively(root: string): Promise<string[]> {
2338
+ const files: string[] = []
2339
+ for (const entry of await readdirAsync(root, { withFileTypes: true })) {
2340
+ const path = join(root, entry.name)
2341
+ if (entry.isDirectory()) {
2342
+ files.push(...(await collectFilesRecursively(path)))
2343
+ } else if (entry.isFile()) {
2344
+ files.push(path)
2345
+ }
2346
+ }
2347
+ return files.sort()
2348
+ }
2349
+
1248
2350
  export interface WriteJsBindingOptions {
1249
2351
  platform?: boolean
1250
2352
  noJsBinding?: boolean
@@ -1255,21 +2357,37 @@ export interface WriteJsBindingOptions {
1255
2357
  packageName: string
1256
2358
  version: string
1257
2359
  outputDir: string
2360
+ /**
2361
+ * `platformArchABI`s of the declared WASI targets in fallback preference
2362
+ * order (threaded first). Defaults to the legacy `['wasm32-wasi']` chain
2363
+ * when omitted or empty. Generated loaders expose these exact identities
2364
+ * through `NAPI_RS_WASI_FLAVOR`.
2365
+ */
2366
+ wasiFlavors?: string[]
1258
2367
  }
1259
2368
 
1260
2369
  export async function writeJsBinding(
1261
2370
  options: WriteJsBindingOptions,
1262
2371
  ): Promise<Output | undefined> {
2372
+ const hasWasiFallback = Boolean(options.wasiFlavors?.length)
1263
2373
  if (
1264
2374
  !options.platform ||
1265
2375
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
1266
2376
  options.noJsBinding ||
1267
- options.idents.length === 0
2377
+ (options.idents.length === 0 && !hasWasiFallback)
1268
2378
  ) {
1269
2379
  return
1270
2380
  }
1271
2381
 
1272
2382
  const name = options.jsBinding ?? 'index.js'
2383
+ const dest = join(options.outputDir, name)
2384
+ const localWasiName = relative(
2385
+ dirname(dest),
2386
+ join(options.outputDir, options.binaryName),
2387
+ ).replaceAll('\\', '/')
2388
+ const localWasiSpecifier = localWasiName.startsWith('.')
2389
+ ? localWasiName
2390
+ : `./${localWasiName}`
1273
2391
 
1274
2392
  const createBinding = options.esm ? createEsmBinding : createCjsBinding
1275
2393
  const binding = createBinding(
@@ -1278,13 +2396,15 @@ export async function writeJsBinding(
1278
2396
  options.idents,
1279
2397
  // in npm preversion hook
1280
2398
  options.version,
2399
+ options.wasiFlavors,
2400
+ localWasiSpecifier,
1281
2401
  )
1282
2402
 
1283
2403
  try {
1284
- const dest = join(options.outputDir, name)
1285
2404
  debug('Writing js binding to:')
1286
2405
  debug(' %i', dest)
1287
- await writeFileAsync(dest, binding, 'utf-8')
2406
+ await mkdirAsync(dirname(dest), { recursive: true })
2407
+ await writeFileAtomic(dest, binding, 'utf-8')
1288
2408
  return { kind: 'js', path: dest } satisfies Output
1289
2409
  } catch (e) {
1290
2410
  throw new Error('Failed to write js binding file', { cause: e })
@@ -1311,9 +2431,13 @@ export interface GenerateTypeDefOptions {
1311
2431
  */
1312
2432
  export async function generateTypeDef(
1313
2433
  options: GenerateTypeDefOptions,
1314
- ): Promise<{ exports: string[]; dts: string }> {
2434
+ ): Promise<{
2435
+ exports: string[]
2436
+ dts: string
2437
+ dtsWithTypeImports: string
2438
+ }> {
1315
2439
  if (!(await dirExistsAsync(options.typeDefDir))) {
1316
- return { exports: [], dts: '' }
2440
+ return { exports: [], dts: '', dtsWithTypeImports: '' }
1317
2441
  }
1318
2442
 
1319
2443
  let header = ''
@@ -1342,7 +2466,7 @@ export async function generateTypeDef(
1342
2466
 
1343
2467
  if (!files.length) {
1344
2468
  debug('No type def files found. Skip generating dts file.')
1345
- return { exports: [], dts: '' }
2469
+ return { exports: [], dts: '', dtsWithTypeImports: '' }
1346
2470
  }
1347
2471
 
1348
2472
  const typeDefFiles = files
@@ -1357,18 +2481,17 @@ export async function generateTypeDef(
1357
2481
  )
1358
2482
  }
1359
2483
 
1360
- const processedTypeDefs = await Promise.all(
1361
- typeDefFiles.map((file) =>
1362
- processTypeDef(
1363
- join(options.typeDefDir, file.name),
1364
- constEnum,
1365
- runtimeStringEnum,
1366
- ),
1367
- ),
2484
+ const processedTypeDefs = await processTypeDefs(
2485
+ typeDefFiles.map((file) => join(options.typeDefDir, file.name)),
2486
+ constEnum,
2487
+ runtimeStringEnum,
2488
+ header,
1368
2489
  )
1369
2490
 
1370
- dts = processedTypeDefs.map(({ dts }) => dts).join('')
1371
- exports = processedTypeDefs.flatMap(({ exports }) => exports)
2491
+ dts = processedTypeDefs.dts
2492
+ exports = processedTypeDefs.exports
2493
+ const dtsWithTypeImportMarkers = processedTypeDefs.dtsWithTypeImportMarkers
2494
+ const typeImports = processedTypeDefs.typeImports
1372
2495
 
1373
2496
  if (dts.indexOf('ExternalObject<') > -1) {
1374
2497
  header += `
@@ -1388,9 +2511,15 @@ export type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array
1388
2511
  }
1389
2512
 
1390
2513
  dts = header + dts
2514
+ const dtsWithTypeImports = rewriteTypeImportReferences(
2515
+ header + dtsWithTypeImportMarkers,
2516
+ typeImports,
2517
+ true,
2518
+ )
1391
2519
 
1392
2520
  return {
1393
2521
  exports,
1394
2522
  dts,
2523
+ dtsWithTypeImports,
1395
2524
  }
1396
2525
  }