@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
@@ -1,4 +1,14 @@
1
- import { join, resolve, parse } from 'node:path'
1
+ import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import {
4
+ dirname,
5
+ isAbsolute,
6
+ join,
7
+ parse,
8
+ relative,
9
+ resolve,
10
+ sep,
11
+ } from 'node:path'
2
12
 
3
13
  import * as colors from 'colorette'
4
14
 
@@ -7,142 +17,910 @@ import {
7
17
  type ArtifactsOptions,
8
18
  } from '../def/artifacts.js'
9
19
  import {
10
- readNapiConfig,
20
+ AVAILABLE_TARGETS,
21
+ commitFileSystemTransaction,
11
22
  debugFactory,
23
+ fileExists,
24
+ parseTriple,
12
25
  readFileAsync,
13
- writeFileAsync,
14
- UniArchsByPlatform,
26
+ readNapiConfig,
15
27
  readdirAsync,
28
+ resolvePackageReconciliationPaths,
29
+ type Target,
30
+ UniArchsByPlatform,
31
+ wasiLoaderSuffix,
32
+ wasiTargetHasThreads,
33
+ withFileSystemReconciliation,
16
34
  } from '../utils/index.js'
35
+ import {
36
+ createWasiBrowserEntry,
37
+ WASI_ARTIFACT_METADATA_PREFIX,
38
+ } from './build.js'
17
39
 
18
40
  const debug = debugFactory('artifacts')
19
41
 
42
+ interface WasiArtifactSource {
43
+ dir: string
44
+ files: Map<string, string>
45
+ }
46
+
47
+ interface PendingWrite {
48
+ content: Buffer
49
+ source: string
50
+ }
51
+
52
+ interface WasiArtifactMetadata {
53
+ exports?: string[]
54
+ rootEntry: string | null
55
+ managedRootEntries: string[]
56
+ }
57
+
58
+ // Removed configured targets are recognizable only when their output identity
59
+ // belongs to napi-rs's supported target set.
60
+ const supportedArtifactTargets = AVAILABLE_TARGETS.map(parseTriple)
61
+
20
62
  export async function collectArtifacts(userOptions: ArtifactsOptions) {
21
63
  const options = applyDefaultArtifactsOptions(userOptions)
22
-
23
- const resolvePath = (...paths: string[]) => resolve(options.cwd, ...paths)
24
- const packageJsonPath = resolvePath(options.packageJsonPath)
25
- const { targets, binaryName, packageName } = await readNapiConfig(
26
- packageJsonPath,
27
- options.configPath ? resolvePath(options.configPath) : undefined,
64
+ const resolvedPaths = resolvePackageReconciliationPaths(
65
+ options.cwd,
66
+ options.packageJsonPath,
67
+ [options.npmDir],
68
+ )
69
+ const { boundary } = resolvedPaths
70
+ return withFileSystemReconciliation(boundary, () =>
71
+ collectArtifactsUnlocked(options, resolvedPaths),
28
72
  )
73
+ }
29
74
 
30
- const distDirs = targets.map((platform) =>
31
- join(options.cwd, options.npmDir, platform.platformArchABI),
75
+ async function collectArtifactsUnlocked(
76
+ options: ReturnType<typeof applyDefaultArtifactsOptions>,
77
+ resolvedPaths: ReturnType<typeof resolvePackageReconciliationPaths>,
78
+ ) {
79
+ const requestedCwd = resolve(options.cwd)
80
+ const cwd = resolvedPaths.cwd
81
+ const resolvePath = (...paths: string[]) => {
82
+ const requestedPath = resolve(requestedCwd, ...paths)
83
+ return isPathAtOrBelow(requestedPath, requestedCwd)
84
+ ? resolve(cwd, relative(requestedCwd, requestedPath))
85
+ : requestedPath
86
+ }
87
+ const packageJsonPath = resolvedPaths.packageJsonPath
88
+ const packageRoot = resolvedPaths.packageRoot
89
+ const { targets, binaryName, packageName, packageJson } =
90
+ await readNapiConfig(
91
+ packageJsonPath,
92
+ options.configPath ? resolvePath(options.configPath) : undefined,
93
+ )
94
+ const npmDir = resolvedPaths.managedPaths[0]
95
+ const outputDir = await realpath(resolvePath(options.outputDir))
96
+ const buildOutputDir = options.buildOutputDir
97
+ ? resolvePath(options.buildOutputDir)
98
+ : cwd
99
+ const managedTargetDirs = [
100
+ ...new Set(
101
+ [...supportedArtifactTargets, ...targets].map(
102
+ (target) => target.platformArchABI,
103
+ ),
104
+ ),
105
+ ].map((target) => join(npmDir, target))
106
+ const excludedSourceRoots = isStrictDescendant(outputDir, npmDir)
107
+ ? [npmDir]
108
+ : resolve(outputDir) === resolve(npmDir)
109
+ ? managedTargetDirs
110
+ : []
111
+ const protectedSourcePaths = new Set(
112
+ await collectRegularFiles(outputDir, excludedSourceRoots),
32
113
  )
33
114
 
34
115
  const universalSourceBins = new Set(
35
116
  targets
36
- .filter((platform) => platform.arch === 'universal')
37
- .flatMap((p) =>
38
- UniArchsByPlatform[p.platform]?.map((a) => `${p.platform}-${a}`),
117
+ .filter((target) => target.arch === 'universal')
118
+ .flatMap((target) =>
119
+ UniArchsByPlatform[target.platform]?.map(
120
+ (arch) => `${target.platform}-${arch}`,
121
+ ),
39
122
  )
40
123
  .filter(Boolean) as string[],
41
124
  )
42
125
 
43
- await collectNodeBinaries(join(options.cwd, options.outputDir)).then(
44
- (output) =>
45
- Promise.all(
46
- output.map(async (filePath) => {
47
- debug.info(`Read [${colors.yellowBright(filePath)}]`)
48
- const sourceContent = await readFileAsync(filePath)
49
- const parsedName = parse(filePath)
50
- const terms = parsedName.name.split('.')
51
- const platformArchABI = terms.pop()!
52
- const _binaryName = terms.join('.')
53
-
54
- if (_binaryName !== binaryName) {
55
- debug.warn(
56
- `[${_binaryName}] is not matched with [${binaryName}], skip`,
57
- )
58
- return
59
- }
60
- const dir = distDirs.find((dir) => dir.includes(platformArchABI))
61
- if (!dir && universalSourceBins.has(platformArchABI)) {
62
- debug.warn(
63
- `[${platformArchABI}] has no dist dir but it is source bin for universal arch, skip`,
64
- )
65
- return
66
- }
67
- if (!dir) {
68
- throw new Error(`No dist dir found for ${filePath}`)
69
- }
70
-
71
- const distFilePath = join(dir, parsedName.base)
72
- debug.info(
73
- `Write file content to [${colors.yellowBright(distFilePath)}]`,
74
- )
75
- await writeFileAsync(distFilePath, sourceContent)
76
- const distFilePathLocal = join(
77
- parse(packageJsonPath).dir,
78
- parsedName.base,
79
- )
126
+ const artifacts = await collectNodeBinaries(outputDir, excludedSourceRoots)
127
+ const artifactsByIdentity = collectArtifactIdentities(artifacts, binaryName)
128
+ rejectDuplicateArtifactIdentities(artifactsByIdentity)
129
+
130
+ const expectedArtifacts = new Map<string, Target>()
131
+ for (const target of targets) {
132
+ expectedArtifacts.set(artifactName(binaryName, target), target)
133
+ }
134
+
135
+ const unexpectedArtifacts = [...artifactsByIdentity].filter(
136
+ ([identity]) =>
137
+ !expectedArtifacts.has(identity) &&
138
+ !universalSourceBins.has(artifactPlatformArchABI(identity)),
139
+ )
140
+ if (unexpectedArtifacts.length > 0) {
141
+ throw new Error(
142
+ `Artifacts were found for unconfigured targets: ${unexpectedArtifacts
143
+ .map(([identity, paths]) => `${identity}: ${paths.join(', ')}`)
144
+ .sort()
145
+ .join('; ')}`,
146
+ )
147
+ }
148
+
149
+ const missingTargets = targets.filter(
150
+ (target) => !artifactsByIdentity.has(artifactName(binaryName, target)),
151
+ )
152
+ if (missingTargets.length > 0) {
153
+ await removeTargetDestinations(
154
+ resolvedPaths.boundary,
155
+ packageRoot,
156
+ npmDir,
157
+ binaryName,
158
+ missingTargets,
159
+ protectedSourcePaths,
160
+ )
161
+ throw new Error(
162
+ `Missing artifacts for configured targets: ${missingTargets
163
+ .map(
164
+ (target) => `${target.triple} (${artifactName(binaryName, target)})`,
165
+ )
166
+ .join(', ')}`,
167
+ )
168
+ }
169
+
170
+ const wasiTargets = targets.filter((target) => target.platform === 'wasi')
171
+ const wasiSources = new Map<string, WasiArtifactSource>()
172
+ for (const target of wasiTargets) {
173
+ const artifactPath = artifactsByIdentity.get(
174
+ artifactName(binaryName, target),
175
+ )![0]
176
+ const candidates = options.buildOutputDir
177
+ ? [buildOutputDir]
178
+ : [...new Set([dirname(artifactPath), cwd])]
179
+ try {
180
+ wasiSources.set(
181
+ target.platformArchABI,
182
+ await findWasiArtifactSource(candidates, binaryName, target),
183
+ )
184
+ } catch (error) {
185
+ await removeTargetDestinations(
186
+ resolvedPaths.boundary,
187
+ packageRoot,
188
+ npmDir,
189
+ binaryName,
190
+ [target],
191
+ protectedSourcePaths,
192
+ )
193
+ throw error
194
+ }
195
+ }
196
+
197
+ const pendingWrites = new Map<string, PendingWrite>()
198
+ for (const target of targets) {
199
+ const identity = artifactName(binaryName, target)
200
+ const source = artifactsByIdentity.get(identity)![0]
201
+ const content = await readFileAsync(source)
202
+ addPendingWrite(
203
+ pendingWrites,
204
+ join(npmDir, target.platformArchABI, identity),
205
+ source,
206
+ content,
207
+ )
208
+ addPendingWrite(pendingWrites, join(packageRoot, identity), source, content)
209
+ }
210
+
211
+ const browserTarget =
212
+ wasiTargets.find((target) => !wasiTargetHasThreads(target)) ??
213
+ wasiTargets[0]
214
+ if (browserTarget) {
215
+ try {
216
+ await addWasiBrowserEntry(
217
+ pendingWrites,
218
+ packageRoot,
219
+ packageName,
220
+ binaryName,
221
+ browserTarget,
222
+ wasiSources.get(browserTarget.platformArchABI)!,
223
+ )
224
+ } catch (error) {
225
+ await removeTargetDestinations(
226
+ resolvedPaths.boundary,
227
+ packageRoot,
228
+ npmDir,
229
+ binaryName,
230
+ [browserTarget],
231
+ protectedSourcePaths,
232
+ )
233
+ throw error
234
+ }
235
+ }
236
+ await addArtifactRootEntry({
237
+ pendingWrites,
238
+ packageRoot,
239
+ packageMain: packageJson.main,
240
+ binaryName,
241
+ targets,
242
+ artifactsByIdentity,
243
+ wasiTargets,
244
+ wasiSources,
245
+ })
246
+
247
+ for (const target of wasiTargets) {
248
+ const source = wasiSources.get(target.platformArchABI)!
249
+ const loaderSuffix = wasiLoaderSuffix(target.platformArchABI)
250
+ const wasiDir = join(npmDir, target.platformArchABI)
251
+ for (const fileName of requiredWasiFiles(binaryName, target)) {
252
+ const sourcePath = source.files.get(fileName)!
253
+ let content = await readFileAsync(sourcePath)
254
+ if (fileName === `${binaryName}.${loaderSuffix}-browser.js`) {
255
+ content = Buffer.from(
256
+ content
257
+ .toString('utf8')
258
+ .replace(
259
+ `new URL('./wasi-worker-browser.mjs', import.meta.url)`,
260
+ `new URL('${packageName}-${target.platformArchABI}/wasi-worker-browser.mjs', import.meta.url)`,
261
+ ),
262
+ )
263
+ }
264
+ addPendingWrite(
265
+ pendingWrites,
266
+ join(wasiDir, fileName),
267
+ sourcePath,
268
+ content,
269
+ )
270
+ }
271
+ }
272
+
273
+ const staleManagedDestinations = await collectStaleManagedDestinations(
274
+ packageRoot,
275
+ npmDir,
276
+ binaryName,
277
+ targets,
278
+ pendingWrites,
279
+ await collectManagedRootEntries(packageRoot, binaryName, wasiSources),
280
+ protectedSourcePaths,
281
+ )
282
+
283
+ await commitArtifactReconciliation(
284
+ resolvedPaths.boundary,
285
+ pendingWrites,
286
+ staleManagedDestinations,
287
+ )
288
+ }
289
+
290
+ async function commitArtifactReconciliation(
291
+ reconciliationRoot: string,
292
+ pendingWrites: Map<string, PendingWrite>,
293
+ staleManagedDestinations: string[],
294
+ ) {
295
+ const stagingRoot = await mkdtemp(join(tmpdir(), 'napi-rs-artifacts-stage-'))
296
+ try {
297
+ const writes = await Promise.all(
298
+ [...pendingWrites].map(
299
+ async ([destination, { content, source }], index) => {
300
+ const stagedSource = join(stagingRoot, String(index))
301
+ await writeFile(stagedSource, content)
80
302
  debug.info(
81
- `Write file content to [${colors.yellowBright(distFilePathLocal)}]`,
303
+ `Write [${colors.yellowBright(source)}] to [${colors.yellowBright(
304
+ destination,
305
+ )}]`,
82
306
  )
83
- await writeFileAsync(distFilePathLocal, sourceContent)
84
- }),
307
+ return { destination, source: stagedSource }
308
+ },
85
309
  ),
310
+ )
311
+ if (writes.length === 0 && staleManagedDestinations.length === 0) {
312
+ return
313
+ }
314
+ await commitFileSystemTransaction(
315
+ reconciliationRoot,
316
+ writes,
317
+ staleManagedDestinations,
318
+ )
319
+ } finally {
320
+ await rm(stagingRoot, { force: true, recursive: true })
321
+ }
322
+ }
323
+
324
+ function artifactName(binaryName: string, target: Target) {
325
+ const extension =
326
+ target.platform === 'wasi' || target.platform === 'wasm' ? 'wasm' : 'node'
327
+ return `${binaryName}.${target.platformArchABI}.${extension}`
328
+ }
329
+
330
+ function artifactPlatformArchABI(identity: string) {
331
+ const parsed = parse(identity)
332
+ return parsed.name.split('.').pop()!
333
+ }
334
+
335
+ function requiredWasiFiles(binaryName: string, target: Target) {
336
+ const loaderSuffix = wasiLoaderSuffix(target.platformArchABI)
337
+ const files = [
338
+ `${binaryName}.${loaderSuffix}.cjs`,
339
+ `${binaryName}.${loaderSuffix}.d.cts`,
340
+ `${binaryName}.${loaderSuffix}-browser.js`,
341
+ ]
342
+ if (wasiTargetHasThreads(target)) {
343
+ files.push('wasi-worker.mjs', 'wasi-worker-browser.mjs')
344
+ } else {
345
+ files.push(
346
+ `${binaryName}.${loaderSuffix}-deferred.js`,
347
+ `${binaryName}.${loaderSuffix}-deferred.d.ts`,
348
+ )
349
+ }
350
+ return files
351
+ }
352
+
353
+ async function findWasiArtifactSource(
354
+ candidateDirs: string[],
355
+ binaryName: string,
356
+ target: Target,
357
+ ): Promise<WasiArtifactSource> {
358
+ const requiredFiles = requiredWasiFiles(binaryName, target)
359
+ const failures: string[] = []
360
+ for (const dir of candidateDirs) {
361
+ const files = new Map<string, string>()
362
+ const missing: string[] = []
363
+ for (const fileName of requiredFiles) {
364
+ const path = join(dir, fileName)
365
+ if (await fileExists(path)) {
366
+ files.set(fileName, path)
367
+ } else {
368
+ missing.push(fileName)
369
+ }
370
+ }
371
+ const browserPath = join(dir, 'browser.js')
372
+ if (await fileExists(browserPath)) {
373
+ files.set('browser.js', browserPath)
374
+ }
375
+ if (missing.length === 0) {
376
+ return { dir, files }
377
+ }
378
+ const failure = `${dir} (missing ${missing.join(', ')})`
379
+ // A candidate containing part of a loader set is authoritative but
380
+ // incomplete. Falling through would combine it with stale local outputs.
381
+ if (files.size > 0) {
382
+ throw new Error(
383
+ `Incomplete artifact source found for ${target.triple}: ${failure}`,
384
+ )
385
+ }
386
+ failures.push(failure)
387
+ }
388
+ throw new Error(
389
+ `No complete artifact source found for ${target.triple}: ${failures.join(
390
+ '; ',
391
+ )}`,
86
392
  )
393
+ }
87
394
 
88
- const wasiTarget = targets.find((t) => t.platform === 'wasi')
89
- if (wasiTarget) {
90
- const buildOutputDir = options.buildOutputDir
91
- ? resolve(options.cwd, options.buildOutputDir)
92
- : options.cwd
93
- const wasiDir = join(
94
- options.cwd,
95
- options.npmDir,
96
- wasiTarget.platformArchABI,
97
- )
98
- const cjsFile = join(buildOutputDir, `${binaryName}.wasi.cjs`)
99
- const workerFile = join(buildOutputDir, `wasi-worker.mjs`)
100
- const browserEntry = join(buildOutputDir, `${binaryName}.wasi-browser.js`)
101
- const browserWorkerFile = join(buildOutputDir, `wasi-worker-browser.mjs`)
102
- debug.info(
103
- `Move wasi binding file [${colors.yellowBright(
104
- cjsFile,
105
- )}] to [${colors.yellowBright(wasiDir)}]`,
106
- )
107
- await writeFileAsync(
108
- join(wasiDir, `${binaryName}.wasi.cjs`),
109
- await readFileAsync(cjsFile),
110
- )
111
- debug.info(
112
- `Move wasi worker file [${colors.yellowBright(
113
- workerFile,
114
- )}] to [${colors.yellowBright(wasiDir)}]`,
115
- )
116
- await writeFileAsync(
117
- join(wasiDir, `wasi-worker.mjs`),
118
- await readFileAsync(workerFile),
119
- )
120
- debug.info(
121
- `Move wasi browser entry file [${colors.yellowBright(
122
- browserEntry,
123
- )}] to [${colors.yellowBright(wasiDir)}]`,
124
- )
125
- await writeFileAsync(
126
- join(wasiDir, `${binaryName}.wasi-browser.js`),
127
- // https://github.com/vitejs/vite/issues/8427
128
- (await readFileAsync(browserEntry, 'utf8')).replace(
129
- `new URL('./wasi-worker-browser.mjs', import.meta.url)`,
130
- `new URL('${packageName}-wasm32-wasi/wasi-worker-browser.mjs', import.meta.url)`,
395
+ async function addWasiBrowserEntry(
396
+ pendingWrites: Map<string, PendingWrite>,
397
+ packageRoot: string,
398
+ packageName: string,
399
+ binaryName: string,
400
+ target: Target,
401
+ source: WasiArtifactSource,
402
+ ) {
403
+ const bindingSource = source.files.get(
404
+ `${binaryName}.${wasiLoaderSuffix(target.platformArchABI)}.cjs`,
405
+ )!
406
+ const metadata = parseWasiArtifactMetadata(
407
+ await readFileAsync(bindingSource, 'utf8'),
408
+ bindingSource,
409
+ )
410
+ if (metadata?.exports !== undefined) {
411
+ addPendingWrite(
412
+ pendingWrites,
413
+ join(packageRoot, 'browser.js'),
414
+ bindingSource,
415
+ Buffer.from(
416
+ createWasiBrowserEntry(
417
+ packageName,
418
+ target.platformArchABI,
419
+ metadata.exports,
420
+ ),
131
421
  ),
132
422
  )
133
- debug.info(
134
- `Move wasi browser worker file [${colors.yellowBright(
135
- browserWorkerFile,
136
- )}] to [${colors.yellowBright(wasiDir)}]`,
423
+ return
424
+ }
425
+
426
+ const browserSource = source.files.get('browser.js')
427
+ if (!browserSource) {
428
+ throw new Error(
429
+ `WASI artifact source ${source.dir} is incomplete: missing required root entry browser.js`,
137
430
  )
138
- await writeFileAsync(
139
- join(wasiDir, `wasi-worker-browser.mjs`),
140
- await readFileAsync(browserWorkerFile),
431
+ }
432
+ addPendingWrite(
433
+ pendingWrites,
434
+ join(packageRoot, 'browser.js'),
435
+ browserSource,
436
+ await readFileAsync(browserSource),
437
+ )
438
+ }
439
+
440
+ async function addWasiRootEntry(
441
+ pendingWrites: Map<string, PendingWrite>,
442
+ packageRoot: string,
443
+ binaryName: string,
444
+ rootTarget: Target,
445
+ source: WasiArtifactSource,
446
+ packageMain: string | undefined,
447
+ ) {
448
+ const bindingSource = source.files.get(
449
+ `${binaryName}.${wasiLoaderSuffix(rootTarget.platformArchABI)}.cjs`,
450
+ )!
451
+ const metadata = parseWasiArtifactMetadata(
452
+ await readFileAsync(bindingSource, 'utf8'),
453
+ bindingSource,
454
+ )
455
+ const rootEntry =
456
+ metadata === undefined
457
+ ? await findLegacyRootEntry(source.dir, packageMain)
458
+ : metadata.rootEntry
459
+ if (rootEntry === null) {
460
+ return
461
+ }
462
+
463
+ const sourcePath = resolveArtifactRelativePath(
464
+ source.dir,
465
+ rootEntry,
466
+ 'WASI root entry',
467
+ )
468
+ if (!(await fileExists(sourcePath.absolute))) {
469
+ throw new Error(
470
+ `WASI artifact source ${source.dir} is incomplete: missing required root entry ${rootEntry}`,
141
471
  )
142
472
  }
473
+ const destination = resolveArtifactRelativePath(
474
+ packageRoot,
475
+ sourcePath.relative,
476
+ 'WASI root entry destination',
477
+ ).absolute
478
+ addPendingWrite(
479
+ pendingWrites,
480
+ destination,
481
+ sourcePath.absolute,
482
+ await readFileAsync(sourcePath.absolute),
483
+ )
143
484
  }
144
485
 
145
- async function collectNodeBinaries(root: string) {
486
+ async function addArtifactRootEntry({
487
+ pendingWrites,
488
+ packageRoot,
489
+ packageMain,
490
+ binaryName,
491
+ targets,
492
+ artifactsByIdentity,
493
+ wasiTargets,
494
+ wasiSources,
495
+ }: {
496
+ pendingWrites: Map<string, PendingWrite>
497
+ packageRoot: string
498
+ packageMain: string | undefined
499
+ binaryName: string
500
+ targets: Target[]
501
+ artifactsByIdentity: Map<string, string[]>
502
+ wasiTargets: Target[]
503
+ wasiSources: Map<string, WasiArtifactSource>
504
+ }) {
505
+ const nativeTargets = targets.filter((target) => target.platform !== 'wasi')
506
+ const rootCandidates = packageRootEntryCandidates(packageMain)
507
+
508
+ for (const target of nativeTargets) {
509
+ const artifactPath = artifactsByIdentity.get(
510
+ artifactName(binaryName, target),
511
+ )?.[0]
512
+ if (!artifactPath) {
513
+ continue
514
+ }
515
+ const sourceDir = dirname(artifactPath)
516
+ for (const candidate of rootCandidates) {
517
+ const source = resolveArtifactRelativePath(
518
+ sourceDir,
519
+ candidate,
520
+ 'native root entry',
521
+ )
522
+ if (!(await fileExists(source.absolute))) {
523
+ continue
524
+ }
525
+ const destination = resolveArtifactRelativePath(
526
+ packageRoot,
527
+ source.relative,
528
+ 'native root entry destination',
529
+ )
530
+ addPendingWrite(
531
+ pendingWrites,
532
+ destination.absolute,
533
+ source.absolute,
534
+ await readFileAsync(source.absolute),
535
+ )
536
+ return
537
+ }
538
+ }
539
+
540
+ if (nativeTargets.length > 0) {
541
+ for (const candidate of rootCandidates) {
542
+ const existing = resolveArtifactRelativePath(
543
+ packageRoot,
544
+ candidate,
545
+ 'existing native root entry',
546
+ )
547
+ if (!(await fileExists(existing.absolute))) {
548
+ continue
549
+ }
550
+ addPendingWrite(
551
+ pendingWrites,
552
+ existing.absolute,
553
+ existing.absolute,
554
+ await readFileAsync(existing.absolute),
555
+ )
556
+ return
557
+ }
558
+ }
559
+
560
+ const rootTarget =
561
+ wasiTargets.find((target) => wasiTargetHasThreads(target)) ?? wasiTargets[0]
562
+ if (rootTarget) {
563
+ await addWasiRootEntry(
564
+ pendingWrites,
565
+ packageRoot,
566
+ binaryName,
567
+ rootTarget,
568
+ wasiSources.get(rootTarget.platformArchABI)!,
569
+ packageMain,
570
+ )
571
+ }
572
+ }
573
+
574
+ function packageRootEntryCandidates(packageMain: string | undefined) {
575
+ return [
576
+ ...(packageMain && /\.[cm]?js$/i.test(packageMain) ? [packageMain] : []),
577
+ 'index.js',
578
+ ]
579
+ }
580
+
581
+ function parseWasiArtifactMetadata(content: string, source: string) {
582
+ const firstLine = content.split(/\r?\n/, 1)[0]
583
+ if (!firstLine.startsWith(WASI_ARTIFACT_METADATA_PREFIX)) {
584
+ return undefined
585
+ }
586
+ let metadata: unknown
587
+ try {
588
+ metadata = JSON.parse(firstLine.slice(WASI_ARTIFACT_METADATA_PREFIX.length))
589
+ } catch (error) {
590
+ throw new Error(`Invalid WASI artifact metadata in ${source}`, {
591
+ cause: error,
592
+ })
593
+ }
594
+ if (typeof metadata !== 'object' || metadata === null) {
595
+ throw new Error(`Unsupported WASI artifact metadata in ${source}`)
596
+ }
597
+ const record = metadata as Record<string, unknown>
598
+ if (
599
+ (record.version !== 1 && record.version !== 2) ||
600
+ (record.rootEntry !== null && typeof record.rootEntry !== 'string')
601
+ ) {
602
+ throw new Error(`Unsupported WASI artifact metadata in ${source}`)
603
+ }
604
+ const managedRootEntries =
605
+ record.version === 1
606
+ ? ['browser.js', ...(record.rootEntry ? [record.rootEntry] : [])]
607
+ : record.managedRootEntries
608
+ if (
609
+ !Array.isArray(managedRootEntries) ||
610
+ !managedRootEntries.every((entry) => typeof entry === 'string')
611
+ ) {
612
+ throw new Error(`Unsupported WASI artifact metadata in ${source}`)
613
+ }
614
+ const exports = record.exports
615
+ if (
616
+ exports !== undefined &&
617
+ (!Array.isArray(exports) ||
618
+ !exports.every((entry) => typeof entry === 'string'))
619
+ ) {
620
+ throw new Error(`Unsupported WASI artifact metadata in ${source}`)
621
+ }
622
+ return {
623
+ exports: exports as string[] | undefined,
624
+ rootEntry: record.rootEntry as string | null,
625
+ managedRootEntries: [...new Set(managedRootEntries)],
626
+ } satisfies WasiArtifactMetadata
627
+ }
628
+
629
+ async function findLegacyRootEntry(
630
+ sourceDir: string,
631
+ packageMain: string | undefined,
632
+ ) {
633
+ const candidates = packageRootEntryCandidates(packageMain)
634
+ for (const candidate of new Set(candidates)) {
635
+ const path = resolveArtifactRelativePath(
636
+ sourceDir,
637
+ candidate,
638
+ 'legacy WASI root entry',
639
+ )
640
+ if (await fileExists(path.absolute)) {
641
+ return path.relative
642
+ }
643
+ }
644
+ throw new Error(
645
+ `WASI artifact source ${sourceDir} is incomplete: missing required root entry ${candidates.join(
646
+ ' or ',
647
+ )}`,
648
+ )
649
+ }
650
+
651
+ function resolveArtifactRelativePath(
652
+ root: string,
653
+ entry: string,
654
+ description: string,
655
+ ) {
656
+ if (!entry || isAbsolute(entry)) {
657
+ throw new Error(
658
+ `${description} must be a non-empty relative path: ${entry}`,
659
+ )
660
+ }
661
+ const absolute = resolve(root, entry)
662
+ const relativePath = relative(root, absolute)
663
+ if (
664
+ relativePath === '' ||
665
+ relativePath === '..' ||
666
+ relativePath.startsWith(`..${sep}`) ||
667
+ isAbsolute(relativePath)
668
+ ) {
669
+ throw new Error(`${description} escapes its output directory: ${entry}`)
670
+ }
671
+ return { absolute, relative: relativePath }
672
+ }
673
+
674
+ function addPendingWrite(
675
+ writes: Map<string, PendingWrite>,
676
+ destination: string,
677
+ source: string,
678
+ content: Buffer,
679
+ ) {
680
+ const existing = writes.get(destination)
681
+ if (existing) {
682
+ if (!existing.content.equals(content)) {
683
+ throw new Error(
684
+ `Conflicting artifacts target ${destination}: ${existing.source} and ${source}`,
685
+ )
686
+ }
687
+ return
688
+ }
689
+ writes.set(destination, { content, source })
690
+ }
691
+
692
+ async function removeTargetDestinations(
693
+ reconciliationRoot: string,
694
+ packageRoot: string,
695
+ npmDir: string,
696
+ binaryName: string,
697
+ targets: Target[],
698
+ protectedSourcePaths: Set<string>,
699
+ ) {
700
+ const paths = targets.flatMap((target) =>
701
+ targetDestinationPaths(packageRoot, npmDir, binaryName, target),
702
+ )
703
+ const removals = [
704
+ ...new Set(
705
+ paths.filter((path) => !protectedSourcePaths.has(resolve(path))),
706
+ ),
707
+ ]
708
+ if (removals.length > 0) {
709
+ await commitFileSystemTransaction(reconciliationRoot, [], removals)
710
+ }
711
+ }
712
+
713
+ function targetDestinationPaths(
714
+ packageRoot: string,
715
+ npmDir: string,
716
+ binaryName: string,
717
+ target: Target,
718
+ ) {
719
+ const identity = artifactName(binaryName, target)
720
+ const paths = [
721
+ join(packageRoot, identity),
722
+ join(npmDir, target.platformArchABI, identity),
723
+ ]
724
+ if (target.platform === 'wasi') {
725
+ const wasiDir = join(npmDir, target.platformArchABI)
726
+ for (const fileName of allManagedWasiFiles(binaryName, target)) {
727
+ paths.push(join(wasiDir, fileName))
728
+ }
729
+ for (const fileName of requiredWasiFiles(binaryName, target)) {
730
+ paths.push(join(packageRoot, fileName))
731
+ }
732
+ paths.push(join(packageRoot, `${binaryName}.wasm`))
733
+ }
734
+ return paths
735
+ }
736
+
737
+ async function collectStaleManagedDestinations(
738
+ packageRoot: string,
739
+ npmDir: string,
740
+ binaryName: string,
741
+ targets: Target[],
742
+ pendingWrites: Map<string, PendingWrite>,
743
+ managedRootEntries: string[],
744
+ protectedSourcePaths: Set<string>,
745
+ ) {
746
+ const stalePaths: string[] = []
747
+ const managedBinaryFiles = new Set(
748
+ [...supportedArtifactTargets, ...targets].map((target) =>
749
+ artifactName(binaryName, target),
750
+ ),
751
+ )
752
+ const managedWasiFiles = new Set<string>()
753
+ for (const loaderSuffix of new Set([
754
+ 'wasi',
755
+ 'wasip1',
756
+ ...targets
757
+ .filter((target) => target.platform === 'wasi')
758
+ .map((target) => wasiLoaderSuffix(target.platformArchABI)),
759
+ ])) {
760
+ for (const fileName of allManagedWasiFilesForSuffix(
761
+ binaryName,
762
+ loaderSuffix,
763
+ )) {
764
+ managedWasiFiles.add(fileName)
765
+ }
766
+ }
767
+
768
+ for (const target of targets) {
769
+ const targetDir = join(npmDir, target.platformArchABI)
770
+ if (!(await fileExists(targetDir))) {
771
+ continue
772
+ }
773
+ for (const entry of await readdirAsync(targetDir, {
774
+ withFileTypes: true,
775
+ })) {
776
+ const path = join(targetDir, entry.name)
777
+ if (
778
+ entry.isFile() &&
779
+ (managedBinaryFiles.has(entry.name) ||
780
+ (target.platform === 'wasi' && managedWasiFiles.has(entry.name))) &&
781
+ !pendingWrites.has(path)
782
+ ) {
783
+ stalePaths.push(path)
784
+ }
785
+ }
786
+ }
787
+
788
+ if (await fileExists(packageRoot)) {
789
+ for (const entry of await readdirAsync(packageRoot, {
790
+ withFileTypes: true,
791
+ })) {
792
+ const path = join(packageRoot, entry.name)
793
+ if (
794
+ entry.isFile() &&
795
+ managedBinaryFiles.has(entry.name) &&
796
+ !pendingWrites.has(path)
797
+ ) {
798
+ stalePaths.push(path)
799
+ }
800
+ }
801
+ }
802
+
803
+ for (const entry of managedRootEntries) {
804
+ const path = resolveArtifactRelativePath(
805
+ packageRoot,
806
+ entry,
807
+ 'managed WASI root entry',
808
+ ).absolute
809
+ if (!pendingWrites.has(path)) {
810
+ stalePaths.push(path)
811
+ }
812
+ }
813
+
814
+ return [
815
+ ...new Set(
816
+ stalePaths.filter((path) => !protectedSourcePaths.has(resolve(path))),
817
+ ),
818
+ ]
819
+ }
820
+
821
+ function allManagedWasiFiles(binaryName: string, target: Target) {
822
+ return allManagedWasiFilesForSuffix(
823
+ binaryName,
824
+ wasiLoaderSuffix(target.platformArchABI),
825
+ )
826
+ }
827
+
828
+ function allManagedWasiFilesForSuffix(
829
+ binaryName: string,
830
+ loaderSuffix: string,
831
+ ) {
832
+ return [
833
+ `${binaryName}.${loaderSuffix}.cjs`,
834
+ `${binaryName}.${loaderSuffix}.d.cts`,
835
+ `${binaryName}.${loaderSuffix}-browser.js`,
836
+ `${binaryName}.${loaderSuffix}-deferred.js`,
837
+ `${binaryName}.${loaderSuffix}-deferred.d.ts`,
838
+ 'wasi-worker.mjs',
839
+ 'wasi-worker-browser.mjs',
840
+ ]
841
+ }
842
+
843
+ async function collectManagedRootEntries(
844
+ packageRoot: string,
845
+ binaryName: string,
846
+ wasiSources: Map<string, WasiArtifactSource>,
847
+ ) {
848
+ const entries = new Set<string>()
849
+ const loaderPaths = new Set<string>()
850
+ for (const loaderSuffix of ['wasi', 'wasip1']) {
851
+ loaderPaths.add(join(packageRoot, `${binaryName}.${loaderSuffix}.cjs`))
852
+ }
853
+ for (const source of wasiSources.values()) {
854
+ for (const [fileName, path] of source.files) {
855
+ if (fileName.endsWith('.cjs')) {
856
+ loaderPaths.add(path)
857
+ }
858
+ }
859
+ }
860
+
861
+ for (const path of loaderPaths) {
862
+ if (!(await fileExists(path))) {
863
+ continue
864
+ }
865
+ const metadata = parseWasiArtifactMetadata(
866
+ await readFileAsync(path, 'utf8'),
867
+ path,
868
+ )
869
+ for (const entry of metadata?.managedRootEntries ?? []) {
870
+ entries.add(entry)
871
+ }
872
+ }
873
+ return [...entries]
874
+ }
875
+
876
+ function collectArtifactIdentities(filePaths: string[], binaryName: string) {
877
+ const artifactsByIdentity = new Map<string, string[]>()
878
+ for (const filePath of filePaths) {
879
+ if (filePath.endsWith('.debug.wasm')) {
880
+ continue
881
+ }
882
+ const parsedName = parse(filePath)
883
+ const terms = parsedName.name.split('.')
884
+ terms.pop()
885
+ if (terms.join('.') !== binaryName) {
886
+ continue
887
+ }
888
+ const matchingArtifacts = artifactsByIdentity.get(parsedName.base)
889
+ if (matchingArtifacts) {
890
+ matchingArtifacts.push(filePath)
891
+ } else {
892
+ artifactsByIdentity.set(parsedName.base, [filePath])
893
+ }
894
+ }
895
+ return artifactsByIdentity
896
+ }
897
+
898
+ function rejectDuplicateArtifactIdentities(
899
+ artifactsByIdentity: Map<string, string[]>,
900
+ ) {
901
+ const duplicates = [...artifactsByIdentity]
902
+ .filter(([, filePaths]) => filePaths.length > 1)
903
+ .map(
904
+ ([identity, filePaths]) =>
905
+ `${identity}: ${[...filePaths].sort().join(', ')}`,
906
+ )
907
+ .sort()
908
+ if (duplicates.length > 0) {
909
+ throw new Error(
910
+ `Multiple artifacts found for binary artifact identities: ${duplicates.join('; ')}. Ensure merged CI artifacts contain exactly one build for each configured target or narrow --output-dir.`,
911
+ )
912
+ }
913
+ }
914
+
915
+ async function collectNodeBinaries(root: string, excludedRoots: string[] = []) {
916
+ const resolvedRoot = resolve(root)
917
+ if (
918
+ excludedRoots.some((excludedRoot) =>
919
+ isPathAtOrBelow(resolvedRoot, excludedRoot),
920
+ )
921
+ ) {
922
+ return []
923
+ }
146
924
  const files = await readdirAsync(root, { withFileTypes: true })
147
925
  const nodeBinaries = files
148
926
  .filter(
@@ -155,8 +933,56 @@ async function collectNodeBinaries(root: string) {
155
933
  const dirs = files.filter((file) => file.isDirectory())
156
934
  for (const dir of dirs) {
157
935
  if (dir.name !== 'node_modules') {
158
- nodeBinaries.push(...(await collectNodeBinaries(join(root, dir.name))))
936
+ nodeBinaries.push(
937
+ ...(await collectNodeBinaries(join(root, dir.name), excludedRoots)),
938
+ )
939
+ }
940
+ }
941
+ return nodeBinaries.sort()
942
+ }
943
+
944
+ async function collectRegularFiles(
945
+ root: string,
946
+ excludedRoots: string[] = [],
947
+ ): Promise<string[]> {
948
+ const resolvedRoot = resolve(root)
949
+ if (
950
+ excludedRoots.some((excludedRoot) =>
951
+ isPathAtOrBelow(resolvedRoot, excludedRoot),
952
+ )
953
+ ) {
954
+ return []
955
+ }
956
+ const files = await readdirAsync(resolvedRoot, { withFileTypes: true })
957
+ const regularFiles = files
958
+ .filter((entry) => entry.isFile())
959
+ .map((entry) => join(resolvedRoot, entry.name))
960
+ for (const entry of files) {
961
+ if (entry.isDirectory() && entry.name !== 'node_modules') {
962
+ regularFiles.push(
963
+ ...(await collectRegularFiles(
964
+ join(resolvedRoot, entry.name),
965
+ excludedRoots,
966
+ )),
967
+ )
159
968
  }
160
969
  }
161
- return nodeBinaries
970
+ return regularFiles
971
+ }
972
+
973
+ function isStrictDescendant(root: string, candidate: string) {
974
+ const relativePath = relative(resolve(root), resolve(candidate))
975
+ return (
976
+ relativePath !== '' &&
977
+ !relativePath.startsWith('..') &&
978
+ !isAbsolute(relativePath)
979
+ )
980
+ }
981
+
982
+ function isPathAtOrBelow(path: string, root: string) {
983
+ const relativePath = relative(resolve(root), resolve(path))
984
+ return (
985
+ relativePath === '' ||
986
+ (!relativePath.startsWith('..') && !isAbsolute(relativePath))
987
+ )
162
988
  }