@napi-rs/cli 3.7.3 → 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 (51) hide show
  1. package/README.md +33 -0
  2. package/cli.mjs +9 -12
  3. package/dist/cli.js +9277 -1313
  4. package/dist/index.cjs +9266 -1302
  5. package/dist/index.d.cts +176 -158
  6. package/dist/index.d.ts +176 -158
  7. package/dist/index.js +9277 -1313
  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 +1325 -194
  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__/__fixtures__/optional-napi-derive/Cargo.toml +3 -0
  35. package/src/utils/__tests__/__fixtures__/optional-napi-derive/main-crate/Cargo.toml +8 -0
  36. package/src/utils/__tests__/__fixtures__/optional-napi-derive/main-crate/src/lib.rs +1 -0
  37. package/src/utils/__tests__/__fixtures__/optional-napi-derive/napi-derive/Cargo.toml +5 -0
  38. package/src/utils/__tests__/__fixtures__/optional-napi-derive/napi-derive/src/lib.rs +1 -0
  39. package/src/utils/__tests__/__fixtures__/optional-napi-derive/with-optional-derive/Cargo.toml +11 -0
  40. package/src/utils/__tests__/__fixtures__/optional-napi-derive/with-optional-derive/src/lib.rs +1 -0
  41. package/src/utils/__tests__/__snapshots__/target.spec.ts.md +2 -2
  42. package/src/utils/__tests__/__snapshots__/target.spec.ts.snap +0 -0
  43. package/src/utils/__tests__/metadata.spec.ts +43 -2
  44. package/src/utils/__tests__/misc.spec.ts +124 -3
  45. package/src/utils/__tests__/version.spec.ts +47 -1
  46. package/src/utils/config.ts +15 -2
  47. package/src/utils/metadata.ts +206 -7
  48. package/src/utils/misc.ts +6079 -0
  49. package/src/utils/target.ts +66 -8
  50. package/src/utils/typegen.ts +1223 -63
  51. package/src/utils/version.ts +96 -0
package/src/api/build.ts CHANGED
@@ -1,34 +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,
30
+ getNapiDeriveDependentCrates,
31
+ getPackageReconciliationRoot,
19
32
  getTargetLinker,
20
33
  mkdirAsync,
21
34
  type NapiConfig,
22
35
  parseMetadata,
23
36
  parseTriple,
24
- processTypeDef,
37
+ processTypeDefs,
25
38
  readFileAsync,
26
39
  readNapiConfig,
40
+ rebaseDeclarationSpecifiers,
41
+ removeNodeStreamWebTypeImports,
42
+ rewriteUnboundNodeGlobalTypeQueries,
43
+ rewriteTypeImportReferences,
27
44
  type Target,
28
45
  targetToEnvVar,
29
46
  tryInstallCargoBinary,
30
47
  unlinkAsync,
31
- writeFileAsync,
48
+ wasiLoaderSuffix,
49
+ wasiTargetHasThreads,
50
+ writeFileAtomic,
51
+ withFileSystemReconciliation,
32
52
  dirExistsAsync,
33
53
  readdirAsync,
34
54
  type CargoWorkspaceMetadata,
@@ -38,6 +58,8 @@ import { createCjsBinding, createEsmBinding } from './templates/index.js'
38
58
  import {
39
59
  createWasiBinding,
40
60
  createWasiBrowserBinding,
61
+ createWasiDeferredBrowserBinding,
62
+ createWasiDeferredBrowserBindingTypeDef,
41
63
  } from './templates/load-wasi-template.js'
42
64
  import {
43
65
  createWasiBrowserWorkerBinding,
@@ -46,13 +68,507 @@ import {
46
68
 
47
69
  const debug = debugFactory('build')
48
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
49
79
 
50
80
  type OutputKind = 'js' | 'dts' | 'node' | 'exe' | 'wasm'
51
81
  type Output = { kind: OutputKind; path: string }
82
+ type WasiBindingMetadata = {
83
+ exports: string[]
84
+ bindingTypeDef?: string
85
+ }
52
86
 
53
87
  type BuildOptions = RawBuildOptions & { cargoOptions?: string[] }
54
88
  type ParsedBuildOptions = Omit<BuildOptions, 'cwd'> & { cwd: string }
55
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
+
56
572
  export async function buildProject(rawOptions: BuildOptions) {
57
573
  debug('napi build command receive options: %O', rawOptions)
58
574
 
@@ -79,7 +595,18 @@ export async function buildProject(rawOptions: BuildOptions) {
79
595
  const resolvePath = (...paths: string[]) => resolve(options.cwd, ...paths)
80
596
 
81
597
  const manifestPath = resolvePath(options.manifestPath ?? 'Cargo.toml')
82
- const metadata = await parseMetadata(manifestPath)
598
+ const metadataTarget = options.target ?? process.env.CARGO_BUILD_TARGET
599
+ const metadata = await parseMetadata(manifestPath, {
600
+ cwd: options.cwd,
601
+ featurePackage: options.package,
602
+ features: options.features,
603
+ allFeatures: options.allFeatures,
604
+ noDefaultFeatures: options.noDefaultFeatures,
605
+ cargoOptions: options.cargoOptions,
606
+ filterPlatform: metadataTarget
607
+ ? parseTriple(metadataTarget).triple
608
+ : undefined,
609
+ })
83
610
 
84
611
  const crate = metadata.packages.find((p) => {
85
612
  // package with given name
@@ -391,9 +918,13 @@ class Builder {
391
918
 
392
919
  private readonly target: Target
393
920
  private readonly crateDir: string
394
- private readonly outputDir: string
921
+ private readonly finalOutputDir: string
922
+ private outputDir: string
923
+ private readonly reconciliationRoot: string
395
924
  private readonly targetDir: string
396
925
  private readonly enableTypeDef: boolean = false
926
+ private readonly stagedOutputDestinations = new Map<string, string>()
927
+ private typeDefWithTypeImports: string | undefined
397
928
 
398
929
  constructor(
399
930
  private readonly metadata: CargoWorkspaceMetadata,
@@ -403,10 +934,15 @@ class Builder {
403
934
  ) {
404
935
  this.target = resolveTarget(options.target)
405
936
  this.crateDir = parse(crate.manifest_path).dir
406
- this.outputDir = resolve(
937
+ this.finalOutputDir = resolve(
407
938
  this.options.cwd,
408
939
  options.outputDir ?? this.crateDir,
409
940
  )
941
+ this.outputDir = this.finalOutputDir
942
+ this.reconciliationRoot = getPackageReconciliationRoot(
943
+ this.options.cwd,
944
+ this.options.packageJsonPath,
945
+ )
410
946
  this.targetDir =
411
947
  options.targetDir ??
412
948
  process.env.CARGO_BUILD_TARGET_DIR ??
@@ -455,7 +991,7 @@ class Builder {
455
991
  )
456
992
  }
457
993
 
458
- build() {
994
+ async build() {
459
995
  // Backstop only: `buildProject()` already validated these before running
460
996
  // anything with a side effect (see the top of `buildProject`). Kept here
461
997
  // so a directly constructed `Builder` cannot skip the validation.
@@ -479,14 +1015,13 @@ class Builder {
479
1015
  }
480
1016
  }
481
1017
 
482
- return this.pickBinary()
1018
+ this.pickBinary()
483
1019
  .setPackage()
484
1020
  .setFeatures()
485
1021
  .setTarget()
486
1022
  .pickCrossToolchain()
487
- .setEnvs()
488
- .setBypassArgs()
489
- .exec()
1023
+ await this.setEnvs()
1024
+ return this.setBypassArgs().exec()
490
1025
  }
491
1026
 
492
1027
  private pickCrossToolchain() {
@@ -583,6 +1118,100 @@ class Builder {
583
1118
  }
584
1119
  }
585
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
+
586
1215
  private pickBinary() {
587
1216
  let set = false
588
1217
  if (this.options.watch) {
@@ -669,14 +1298,7 @@ class Builder {
669
1298
  return this
670
1299
  }
671
1300
 
672
- private setEnvs() {
673
- // TYPE DEF
674
- if (this.enableTypeDef) {
675
- this.envs.NAPI_TYPE_DEF_TMP_FOLDER =
676
- this.generateIntermediateTypeDefFolder()
677
- this.setForceBuildEnvs(this.envs.NAPI_TYPE_DEF_TMP_FOLDER)
678
- }
679
-
1301
+ private async setEnvs() {
680
1302
  // RUSTFLAGS
681
1303
  let rustflags =
682
1304
  process.env.RUSTFLAGS ?? process.env.CARGO_BUILD_RUSTFLAGS ?? ''
@@ -692,6 +1314,13 @@ class Builder {
692
1314
  rustflags += ' -C link-arg=-s'
693
1315
  }
694
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
+
695
1324
  if (rustflags.length) {
696
1325
  this.envs.RUSTFLAGS = rustflags
697
1326
  }
@@ -734,11 +1363,8 @@ class Builder {
734
1363
 
735
1364
  private setForceBuildEnvs(typeDefTmpFolder: string) {
736
1365
  // dynamically check all napi-rs deps and set `NAPI_FORCE_BUILD_{uppercase(snake_case(name))} = timestamp`
737
- this.metadata.packages.forEach((crate) => {
738
- if (
739
- crate.dependencies.some((d) => d.name === 'napi-derive') &&
740
- !existsSync(join(typeDefTmpFolder, crate.name))
741
- ) {
1366
+ getNapiDeriveDependentCrates(this.metadata).forEach((crate) => {
1367
+ if (!existsSync(join(typeDefTmpFolder, crate.name))) {
742
1368
  this.envs[
743
1369
  `NAPI_FORCE_BUILD_${crate.name.replace(/-/g, '_').toUpperCase()}`
744
1370
  ] = Date.now().toString()
@@ -783,17 +1409,37 @@ class Builder {
783
1409
  }
784
1410
 
785
1411
  private setWasiEnv() {
786
- const emnapi = join(
787
- require.resolve('emnapi'),
788
- '..',
789
- 'lib',
790
- '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',
791
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
+ }
792
1426
  this.envs.EMNAPI_LINK_DIR = emnapi
793
- const emnapiVersion = require('emnapi/package.json').version
794
- const projectRequire = createRequire(join(this.options.cwd, 'package.json'))
795
- const emnapiCoreVersion = projectRequire('@emnapi/core').version
796
- 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')
797
1443
 
798
1444
  if (
799
1445
  emnapiVersion !== emnapiCoreVersion ||
@@ -836,18 +1482,18 @@ class Builder {
836
1482
  'TARGET_RANLIB',
837
1483
  join(WASI_SDK_PATH, 'bin', 'ranlib'),
838
1484
  )
839
- this.setEnvIfNotExists(
840
- 'TARGET_CFLAGS',
841
- `--target=wasm32-wasip1-threads --sysroot=${WASI_SDK_PATH}/share/wasi-sysroot -pthread -mllvm -wasm-enable-sjlj`,
842
- )
843
- this.setEnvIfNotExists(
844
- 'TARGET_CXXFLAGS',
845
- `--target=wasm32-wasip1-threads --sysroot=${WASI_SDK_PATH}/share/wasi-sysroot -pthread -mllvm -wasm-enable-sjlj`,
846
- )
847
- this.setEnvIfNotExists(
848
- `TARGET_LDFLAGS`,
849
- `-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,
850
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)
851
1497
  }
852
1498
  }
853
1499
 
@@ -942,58 +1588,198 @@ class Builder {
942
1588
  return this
943
1589
  }
944
1590
 
945
- private generateIntermediateTypeDefFolder() {
946
- let folder = join(
947
- this.targetDir,
948
- 'napi-rs',
949
- `${this.crate.name}-${createHash('sha256')
950
- .update(this.crate.manifest_path)
951
- .update(CLI_VERSION)
952
- .digest('hex')
953
- .substring(0, 8)}`,
954
- )
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
+ })
955
1623
 
956
1624
  if (!this.options.dtsCache) {
957
- rmSync(folder, { recursive: true, force: true })
958
- folder += `_${Date.now()}`
1625
+ await mkdirAsync(dirname(folder), { recursive: true })
1626
+ folder = await mkdtempAsync(`${folder}-`)
1627
+ } else {
1628
+ await mkdirAsync(folder, { recursive: true })
959
1629
  }
960
1630
 
961
- mkdirAsync(folder, { recursive: true })
962
-
963
1631
  return folder
964
1632
  }
965
1633
 
966
- 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
967
1694
  try {
968
1695
  debug(`Try to create output directory:`)
969
- debug(' %i', this.outputDir)
970
- await mkdirAsync(this.outputDir, { recursive: true })
1696
+ debug(' %i', finalOutputDir)
1697
+ await mkdirAsync(finalOutputDir, { recursive: true })
971
1698
  debug(`Output directory created`)
972
1699
  } catch (e) {
973
- throw new Error(`Failed to create output directory ${this.outputDir}`, {
1700
+ throw new Error(`Failed to create output directory ${finalOutputDir}`, {
974
1701
  cause: e,
975
1702
  })
976
1703
  }
977
1704
 
978
- 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
979
1714
 
980
- // only for cdylib
981
- if (this.cdyLibName) {
982
- const idents = await this.generateTypeDef()
983
- const jsOutput = await this.writeJsBinding(idents)
984
- const wasmBindingsOutput = await this.writeWasiBinding(
985
- wasmBinaryName,
986
- idents,
987
- )
988
- if (jsOutput) {
989
- this.outputs.push(jsOutput)
990
- }
991
- if (wasmBindingsOutput) {
992
- 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
+ }
993
1732
  }
994
- }
995
1733
 
996
- 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
+ }
997
1783
  }
998
1784
 
999
1785
  private async copyArtifact() {
@@ -1008,49 +1794,59 @@ class Builder {
1008
1794
  debug(`Copy artifact from: [${src}]`)
1009
1795
  const dest = join(this.outputDir, destName)
1010
1796
  const isWasm = dest.endsWith('.wasm')
1797
+ const debugDest = isWasm
1798
+ ? dest.replace(/\.wasm$/, '.debug.wasm')
1799
+ : undefined
1800
+ let artifactReplaced = false
1011
1801
 
1012
1802
  try {
1013
- if (await fileExists(dest)) {
1014
- debug('Old artifact found, remove it first')
1015
- await unlinkAsync(dest)
1016
- }
1017
1803
  debug('Copy artifact to:')
1018
1804
  debug(' %i', dest)
1019
1805
  if (isWasm) {
1020
1806
  const { ModuleConfig } = await import('@napi-rs/wasm-tools')
1021
1807
  debug('Generate debug wasm module')
1022
1808
  try {
1023
- const debugWasmModule = new ModuleConfig()
1024
- .generateDwarf(true)
1025
- .generateNameSection(true)
1026
- .generateProducersSection(true)
1027
- .preserveCodeTransform(true)
1028
- .strictValidate(false)
1029
- .parse(await readFileAsync(src))
1030
- const debugWasmBinary = debugWasmModule.emitWasm(true)
1031
- await writeFileAsync(
1032
- dest.replace(/\.wasm$/, '.debug.wasm'),
1033
- 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',
1034
1819
  )
1820
+ await writeFileAtomic(debugDest!, debugWasmBinary)
1035
1821
  debug('Generate release wasm module')
1036
- const releaseWasmModule = new ModuleConfig()
1037
- .generateDwarf(false)
1038
- .generateNameSection(false)
1039
- .generateProducersSection(false)
1040
- .preserveCodeTransform(false)
1041
- .strictValidate(false)
1042
- .onlyStableFeatures(false)
1043
- .parse(debugWasmBinary)
1044
- const releaseWasmBinary = releaseWasmModule.emitWasm(false)
1045
- 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
1046
1836
  } catch (e) {
1047
1837
  debug.warn(
1048
1838
  `Failed to generate debug wasm module: ${(e as any).message ?? e}`,
1049
1839
  )
1050
- 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)
1051
1846
  }
1052
1847
  } else {
1053
- await copyFileAsync(src, dest)
1848
+ await copyFileAtomic(src, dest)
1849
+ artifactReplaced = true
1054
1850
  }
1055
1851
  this.outputs.push({
1056
1852
  kind: dest.endsWith('.node') ? 'node' : isWasm ? 'wasm' : 'exe',
@@ -1058,6 +1854,13 @@ class Builder {
1058
1854
  })
1059
1855
  return wasmBinaryName ? join(this.outputDir, wasmBinaryName) : null
1060
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
+ }
1061
1864
  throw new Error('Failed to copy artifact', { cause: e })
1062
1865
  }
1063
1866
  }
@@ -1065,7 +1868,14 @@ class Builder {
1065
1868
  private getArtifactNames() {
1066
1869
  if (this.cdyLibName) {
1067
1870
  const cdyLib = this.cdyLibName.replace(/-/g, '_')
1068
- 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')
1069
1879
 
1070
1880
  const srcName =
1071
1881
  this.target.platform === 'darwin'
@@ -1076,18 +1886,12 @@ class Builder {
1076
1886
  ? `${cdyLib}.wasm`
1077
1887
  : `lib${cdyLib}.so`
1078
1888
 
1079
- let destName = this.config.binaryName
1080
- // add platform suffix to binary name
1081
- // index[.linux-x64-gnu].node
1082
- // ^^^^^^^^^^^^^^
1083
- if (this.options.platform) {
1084
- destName += `.${this.target.platformArchABI}`
1085
- }
1086
- if (srcName.endsWith('.wasm')) {
1087
- destName += '.wasm'
1088
- } else {
1089
- destName += '.node'
1090
- }
1889
+ const destName = createArtifactDestinationName(
1890
+ this.config.binaryName,
1891
+ this.target,
1892
+ srcName,
1893
+ this.options.platform ?? false,
1894
+ )
1091
1895
 
1092
1896
  return [
1093
1897
  srcName,
@@ -1112,7 +1916,7 @@ class Builder {
1112
1916
  return []
1113
1917
  }
1114
1918
 
1115
- const { exports, dts } = await generateTypeDef({
1919
+ const { exports, dts, dtsWithTypeImports } = await generateTypeDef({
1116
1920
  typeDefDir,
1117
1921
  noDtsHeader: this.options.noDtsHeader,
1118
1922
  dtsHeader: this.options.dtsHeader,
@@ -1123,27 +1927,77 @@ class Builder {
1123
1927
  this.options.runtimeStringEnum ?? this.config.runtimeStringEnum,
1124
1928
  cwd: this.options.cwd,
1125
1929
  })
1930
+ this.typeDefWithTypeImports = dtsWithTypeImports
1126
1931
 
1127
- 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)
1128
1935
 
1129
1936
  try {
1130
1937
  debug('Writing type def to:')
1131
1938
  debug(' %i', dest)
1132
- await writeFileAsync(dest, dts, 'utf-8')
1939
+ assertWritableOutputDestination(finalDest)
1940
+ await mkdirAsync(dirname(dest), { recursive: true })
1941
+ await writeFileAtomic(dest, dts, 'utf-8')
1133
1942
  } catch (e) {
1134
- debug.error('Failed to write type def file')
1135
- debug.error(e as Error)
1943
+ throw new Error(`Failed to write type def file ${dest}`, { cause: e })
1136
1944
  }
1137
1945
 
1138
1946
  if (exports.length > 0) {
1139
- const dest = join(this.outputDir, this.options.dts ?? 'index.d.ts')
1140
1947
  this.outputs.push({ kind: 'dts', path: dest })
1141
1948
  }
1142
1949
 
1143
1950
  return exports
1144
1951
  }
1145
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
+
1146
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
+ ]
1147
2001
  return writeJsBinding({
1148
2002
  platform: this.options.platform,
1149
2003
  noJsBinding: this.options.noJsBinding,
@@ -1154,6 +2008,7 @@ class Builder {
1154
2008
  packageName: this.options.jsPackageName ?? this.config.packageName,
1155
2009
  version: process.env.npm_new_version ?? this.config.packageJson.version,
1156
2010
  outputDir: this.outputDir,
2011
+ wasiFlavors,
1157
2012
  })
1158
2013
  }
1159
2014
 
@@ -1162,78 +2017,248 @@ class Builder {
1162
2017
  idents: string[],
1163
2018
  ) {
1164
2019
  if (distFileName) {
1165
- const { name, dir } = parse(distFileName)
1166
- const bindingPath = join(dir, `${this.config.binaryName}.wasi.cjs`)
1167
- const browserBindingPath = join(
1168
- dir,
1169
- `${this.config.binaryName}.wasi-browser.js`,
1170
- )
1171
- const workerPath = join(dir, 'wasi-worker.mjs')
1172
- const browserWorkerPath = join(dir, 'wasi-worker-browser.mjs')
1173
- const browserEntryPath = join(dir, 'browser.js')
1174
- const exportsCode =
1175
- `module.exports = __napiModule.exports\n` +
1176
- idents
1177
- .map(
1178
- (ident) =>
1179
- `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',
1180
2085
  )
1181
- .join('\n')
1182
- await writeFileAsync(
1183
- 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
+ ) +
1184
2135
  createWasiBinding(
1185
2136
  name,
1186
2137
  this.config.packageName,
1187
2138
  this.config.wasm?.initialMemory,
1188
2139
  this.config.wasm?.maximumMemory,
2140
+ hasThreads,
2141
+ wasiTarget.platformArchABI,
2142
+ `${this.config.binaryName}.${wasiTarget.platformArchABI}`,
1189
2143
  ) +
1190
- exportsCode +
1191
- '\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
+ ),
1192
2222
  'utf8',
1193
2223
  )
1194
- await writeFileAsync(
1195
- browserBindingPath,
1196
- 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(
1197
2241
  name,
1198
2242
  this.config.wasm?.initialMemory,
1199
2243
  this.config.wasm?.maximumMemory,
1200
- this.config.wasm?.browser?.fs,
1201
- this.config.wasm?.browser?.asyncInit,
1202
2244
  this.config.wasm?.browser?.buffer,
1203
- this.config.wasm?.browser?.errorEvent,
1204
- ) +
1205
- `export default __napiModule.exports\n` +
1206
- idents
1207
- .map(
1208
- (ident) =>
1209
- `export const ${ident} = __napiModule.exports.${ident}`,
1210
- )
1211
- .join('\n') +
1212
- '\n',
2245
+ ),
1213
2246
  'utf8',
1214
2247
  )
1215
- await writeFileAsync(workerPath, WASI_WORKER_TEMPLATE, 'utf8')
1216
- await writeFileAsync(
1217
- browserWorkerPath,
1218
- createWasiBrowserWorkerBinding(
1219
- this.config.wasm?.browser?.fs ?? false,
1220
- this.config.wasm?.browser?.errorEvent ?? false,
2248
+ await writeFileAtomic(
2249
+ deferredTypeDefPath,
2250
+ createWasiDeferredBindingTypeDef(
2251
+ `./${this.config.binaryName}.${loaderSuffix}.cjs`,
2252
+ this.enableTypeDef,
1221
2253
  ),
1222
2254
  'utf8',
1223
2255
  )
1224
- await writeFileAsync(
1225
- browserEntryPath,
1226
- `export * from '${this.config.packageName}-wasm32-wasi'\n`,
2256
+ outputs.push(
2257
+ { kind: 'js', path: deferredBindingPath },
2258
+ { kind: 'dts', path: deferredTypeDefPath },
1227
2259
  )
1228
- return [
1229
- { kind: 'js', path: bindingPath },
1230
- { kind: 'js', path: browserBindingPath },
1231
- { kind: 'js', path: workerPath },
1232
- { kind: 'js', path: browserWorkerPath },
1233
- { kind: 'js', path: browserEntryPath },
1234
- ] satisfies Output[]
1235
2260
  }
1236
- return []
2261
+ return outputs
1237
2262
  }
1238
2263
 
1239
2264
  private setEnvIfNotExists(env: string, value: string) {
@@ -1243,6 +2268,85 @@ class Builder {
1243
2268
  }
1244
2269
  }
1245
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
+
1246
2350
  export interface WriteJsBindingOptions {
1247
2351
  platform?: boolean
1248
2352
  noJsBinding?: boolean
@@ -1253,21 +2357,37 @@ export interface WriteJsBindingOptions {
1253
2357
  packageName: string
1254
2358
  version: string
1255
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[]
1256
2367
  }
1257
2368
 
1258
2369
  export async function writeJsBinding(
1259
2370
  options: WriteJsBindingOptions,
1260
2371
  ): Promise<Output | undefined> {
2372
+ const hasWasiFallback = Boolean(options.wasiFlavors?.length)
1261
2373
  if (
1262
2374
  !options.platform ||
1263
2375
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
1264
2376
  options.noJsBinding ||
1265
- options.idents.length === 0
2377
+ (options.idents.length === 0 && !hasWasiFallback)
1266
2378
  ) {
1267
2379
  return
1268
2380
  }
1269
2381
 
1270
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}`
1271
2391
 
1272
2392
  const createBinding = options.esm ? createEsmBinding : createCjsBinding
1273
2393
  const binding = createBinding(
@@ -1276,13 +2396,15 @@ export async function writeJsBinding(
1276
2396
  options.idents,
1277
2397
  // in npm preversion hook
1278
2398
  options.version,
2399
+ options.wasiFlavors,
2400
+ localWasiSpecifier,
1279
2401
  )
1280
2402
 
1281
2403
  try {
1282
- const dest = join(options.outputDir, name)
1283
2404
  debug('Writing js binding to:')
1284
2405
  debug(' %i', dest)
1285
- await writeFileAsync(dest, binding, 'utf-8')
2406
+ await mkdirAsync(dirname(dest), { recursive: true })
2407
+ await writeFileAtomic(dest, binding, 'utf-8')
1286
2408
  return { kind: 'js', path: dest } satisfies Output
1287
2409
  } catch (e) {
1288
2410
  throw new Error('Failed to write js binding file', { cause: e })
@@ -1309,9 +2431,13 @@ export interface GenerateTypeDefOptions {
1309
2431
  */
1310
2432
  export async function generateTypeDef(
1311
2433
  options: GenerateTypeDefOptions,
1312
- ): Promise<{ exports: string[]; dts: string }> {
2434
+ ): Promise<{
2435
+ exports: string[]
2436
+ dts: string
2437
+ dtsWithTypeImports: string
2438
+ }> {
1313
2439
  if (!(await dirExistsAsync(options.typeDefDir))) {
1314
- return { exports: [], dts: '' }
2440
+ return { exports: [], dts: '', dtsWithTypeImports: '' }
1315
2441
  }
1316
2442
 
1317
2443
  let header = ''
@@ -1340,7 +2466,7 @@ export async function generateTypeDef(
1340
2466
 
1341
2467
  if (!files.length) {
1342
2468
  debug('No type def files found. Skip generating dts file.')
1343
- return { exports: [], dts: '' }
2469
+ return { exports: [], dts: '', dtsWithTypeImports: '' }
1344
2470
  }
1345
2471
 
1346
2472
  const typeDefFiles = files
@@ -1355,18 +2481,17 @@ export async function generateTypeDef(
1355
2481
  )
1356
2482
  }
1357
2483
 
1358
- const processedTypeDefs = await Promise.all(
1359
- typeDefFiles.map((file) =>
1360
- processTypeDef(
1361
- join(options.typeDefDir, file.name),
1362
- constEnum,
1363
- runtimeStringEnum,
1364
- ),
1365
- ),
2484
+ const processedTypeDefs = await processTypeDefs(
2485
+ typeDefFiles.map((file) => join(options.typeDefDir, file.name)),
2486
+ constEnum,
2487
+ runtimeStringEnum,
2488
+ header,
1366
2489
  )
1367
2490
 
1368
- dts = processedTypeDefs.map(({ dts }) => dts).join('')
1369
- exports = processedTypeDefs.flatMap(({ exports }) => exports)
2491
+ dts = processedTypeDefs.dts
2492
+ exports = processedTypeDefs.exports
2493
+ const dtsWithTypeImportMarkers = processedTypeDefs.dtsWithTypeImportMarkers
2494
+ const typeImports = processedTypeDefs.typeImports
1370
2495
 
1371
2496
  if (dts.indexOf('ExternalObject<') > -1) {
1372
2497
  header += `
@@ -1386,9 +2511,15 @@ export type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array
1386
2511
  }
1387
2512
 
1388
2513
  dts = header + dts
2514
+ const dtsWithTypeImports = rewriteTypeImportReferences(
2515
+ header + dtsWithTypeImportMarkers,
2516
+ typeImports,
2517
+ true,
2518
+ )
1389
2519
 
1390
2520
  return {
1391
2521
  exports,
1392
2522
  dts,
2523
+ dtsWithTypeImports,
1393
2524
  }
1394
2525
  }