@napi-rs/cli 3.7.4 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +33 -0
  2. package/cli.mjs +9 -12
  3. package/dist/cli.js +9249 -1312
  4. package/dist/index.cjs +9269 -1332
  5. package/dist/index.d.cts +175 -157
  6. package/dist/index.d.ts +175 -157
  7. package/dist/index.js +9249 -1312
  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 +60 -15
  22. package/src/api/__tests__/new.spec.ts +41 -5
  23. package/src/api/artifacts.ts +934 -108
  24. package/src/api/build.ts +1317 -188
  25. package/src/api/create-npm-dirs.ts +475 -102
  26. package/src/api/new.ts +354 -35
  27. package/src/api/pre-publish.ts +2845 -99
  28. package/src/api/rename.ts +1638 -124
  29. package/src/api/templates/js-binding.ts +216 -38
  30. package/src/api/templates/load-wasi-template.ts +1641 -111
  31. package/src/api/templates/wasi-worker-template.ts +32 -3
  32. package/src/commands/rename.ts +8 -3
  33. package/src/def/artifacts.ts +2 -2
  34. package/src/utils/__tests__/__snapshots__/target.spec.ts.md +2 -2
  35. package/src/utils/__tests__/__snapshots__/target.spec.ts.snap +0 -0
  36. package/src/utils/__tests__/misc.spec.ts +124 -3
  37. package/src/utils/__tests__/version.spec.ts +47 -1
  38. package/src/utils/config.ts +15 -2
  39. package/src/utils/metadata.ts +155 -47
  40. package/src/utils/misc.ts +6079 -0
  41. package/src/utils/target.ts +66 -8
  42. package/src/utils/typegen.ts +1223 -63
  43. package/src/utils/version.ts +96 -0
@@ -1,8 +1,39 @@
1
1
  import { execSync } from 'node:child_process'
2
- import { existsSync, statSync } from 'node:fs'
3
- import { join, resolve } from 'node:path'
2
+ import { createHash } from 'node:crypto'
3
+ import {
4
+ constants,
5
+ existsSync,
6
+ lstatSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ readdirSync,
10
+ statSync,
11
+ } from 'node:fs'
12
+ import { createRequire } from 'node:module'
13
+ import {
14
+ chmod,
15
+ copyFile,
16
+ cp,
17
+ lstat,
18
+ mkdtemp,
19
+ readdir,
20
+ rm,
21
+ symlink,
22
+ } from 'node:fs/promises'
23
+ import { tmpdir } from 'node:os'
24
+ import {
25
+ dirname,
26
+ extname,
27
+ isAbsolute,
28
+ join,
29
+ relative,
30
+ resolve,
31
+ sep,
32
+ } from 'node:path'
33
+ import { isDeepStrictEqual } from 'node:util'
4
34
 
5
35
  import { Octokit } from '@octokit/rest'
36
+ import { Range } from 'semver'
6
37
 
7
38
  import {
8
39
  applyDefaultPrePublishOptions,
@@ -11,13 +42,78 @@ import {
11
42
  import {
12
43
  readFileAsync,
13
44
  readNapiConfig,
45
+ collectRelativeDeclarationSpecifiers,
46
+ commitFileSystemTransaction,
47
+ createWasmModuleTypeDef,
48
+ copyFileAtomic,
14
49
  debugFactory,
15
- updatePackageJson,
50
+ MINIMUM_WASI_NODE_VERSION,
51
+ mkdirAsync,
52
+ restrictWasiNodeEngine,
53
+ resolvePackageReconciliationPaths,
54
+ wasiLoaderSuffix,
55
+ wasiTargetHasThreads,
56
+ writeFileAtomic,
57
+ withFileSystemReconciliation,
58
+ AVAILABLE_TARGETS,
59
+ parseTriple,
60
+ type CommonPackageJsonFields,
61
+ type FileSystemTransactionWrite,
62
+ type Target,
16
63
  } from '../utils/index.js'
17
64
 
18
- import { version } from './version.js'
19
-
20
65
  const debug = debugFactory('pre-publish')
66
+ const THREADLESS_WASI_ROOT_SUBPATHS = new Set([
67
+ './workerd',
68
+ './wasm',
69
+ './wasm.wasm',
70
+ ])
71
+ const MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES = new Set(
72
+ AVAILABLE_TARGETS.map((target) => parseTriple(target).platformArchABI),
73
+ )
74
+ const LEGACY_DEEP_IMPORT_EXTENSIONS = ['.js', '.json', '.node']
75
+ const DECLARATION_EXTENSIONS = ['.d.ts', '.d.cts', '.d.mts']
76
+ const RECONCILIATION_STATE_PREFIXES = [
77
+ '.napi-rs-filesystem-reconciliation',
78
+ '.napi-rs-filesystem-transaction',
79
+ ]
80
+ const PUBLICATION_CONTEXT_EXCLUDED_NAMES = new Set(['.git'])
81
+ const PUBLICATION_CONTEXT_FILES = [
82
+ '.npmrc',
83
+ '.pnp.cjs',
84
+ '.pnp.loader.mjs',
85
+ '.pnpmfile.cjs',
86
+ '.yarnrc',
87
+ '.yarnrc.yml',
88
+ 'bun.lock',
89
+ 'bun.lockb',
90
+ 'npm-shrinkwrap.json',
91
+ 'package-lock.json',
92
+ 'package.json',
93
+ 'pnpm-lock.yaml',
94
+ 'pnpm-workspace.yaml',
95
+ 'yarn.lock',
96
+ ]
97
+ const PUBLICATION_LIFECYCLE_SCRIPTS = [
98
+ 'prepublish',
99
+ 'prepare',
100
+ 'prepublishOnly',
101
+ 'prepack',
102
+ 'postpack',
103
+ 'publish',
104
+ 'postpublish',
105
+ ]
106
+ const WASI_ROOT_FACADE_MARKER_PREFIX = '// napi-rs-wasi-root-facade:'
107
+ const directBufferDependency = '^6.0.3'
108
+ const wasiRuntimeDependencies = [
109
+ '@napi-rs/wasm-runtime',
110
+ '@emnapi/core',
111
+ '@emnapi/runtime',
112
+ 'buffer',
113
+ ]
114
+ const require = createRequire(import.meta.url)
115
+ type TypeScriptModule = typeof import('typescript')
116
+ let loadedTypeScript: TypeScriptModule | undefined
21
117
 
22
118
  interface PackageInfo {
23
119
  name: string
@@ -25,19 +121,347 @@ interface PackageInfo {
25
121
  tag: string
26
122
  }
27
123
 
124
+ interface PreparedReleasePackage {
125
+ artifactPath: string
126
+ filename: string
127
+ packageDir: string
128
+ }
129
+
130
+ interface PreparedPrePublish {
131
+ npmClient: string
132
+ packageName: string
133
+ packageVersion: string
134
+ releasePackages: PreparedReleasePackage[]
135
+ workspaceRoot?: string
136
+ }
137
+
138
+ async function removePreparedSnapshot(
139
+ snapshotRoot: string,
140
+ phase: 'preparation' | 'publication',
141
+ primaryFailed: boolean,
142
+ primaryError?: unknown,
143
+ ) {
144
+ try {
145
+ await removeOwnedTemporaryTree(snapshotRoot)
146
+ } catch (cleanupError) {
147
+ if (primaryFailed) {
148
+ throw new AggregateError(
149
+ [primaryError, cleanupError],
150
+ `Pre-publish ${phase} failed and its prepared snapshot could not be removed from ${snapshotRoot}`,
151
+ { cause: primaryError },
152
+ )
153
+ }
154
+ debug.warn(
155
+ `Pre-publish ${phase} completed, but its prepared snapshot could not be removed from ${snapshotRoot}: ${String(cleanupError)}`,
156
+ )
157
+ return
158
+ }
159
+ if (primaryFailed) {
160
+ throw primaryError
161
+ }
162
+ }
163
+
164
+ async function removeOwnedTemporaryTree(root: string) {
165
+ try {
166
+ await rm(root, { recursive: true, force: true })
167
+ return
168
+ } catch (initialError) {
169
+ if (
170
+ !['EACCES', 'EPERM'].includes(
171
+ (initialError as NodeJS.ErrnoException).code ?? '',
172
+ )
173
+ ) {
174
+ throw initialError
175
+ }
176
+ try {
177
+ await makeOwnedTemporaryDirectoriesWritable(root)
178
+ await rm(root, { recursive: true, force: true })
179
+ return
180
+ } catch (retryError) {
181
+ throw new AggregateError(
182
+ [initialError, retryError],
183
+ `Prepared snapshot could not be removed from ${root} after restoring directory permissions`,
184
+ { cause: initialError },
185
+ )
186
+ }
187
+ }
188
+ }
189
+
190
+ async function makeOwnedTemporaryDirectoriesWritable(root: string) {
191
+ let stats
192
+ try {
193
+ stats = await lstat(root)
194
+ } catch (error) {
195
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
196
+ throw error
197
+ }
198
+ if (!stats.isDirectory()) {
199
+ throw new Error(`Prepared snapshot path is not a directory: ${root}`)
200
+ }
201
+
202
+ try {
203
+ await chmod(root, stats.mode | 0o700)
204
+ } catch (error) {
205
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
206
+ throw error
207
+ }
208
+ let entries
209
+ try {
210
+ entries = await readdir(root, { withFileTypes: true })
211
+ } catch (error) {
212
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
213
+ throw error
214
+ }
215
+ for (const entry of entries) {
216
+ if (entry.isDirectory()) {
217
+ await makeOwnedTemporaryDirectoriesWritable(join(root, entry.name))
218
+ }
219
+ }
220
+ }
221
+
222
+ async function copyOwnedTemporaryPath(source: string, destination: string) {
223
+ await cp(source, destination, { recursive: true })
224
+ const stats = await lstat(destination)
225
+ if (stats.isDirectory()) {
226
+ await makeOwnedTemporaryDirectoriesWritable(destination)
227
+ }
228
+ }
229
+
28
230
  export async function prePublish(userOptions: PrePublishOptions) {
29
231
  debug('Receive pre-publish options:')
30
232
  debug(' %O', userOptions)
31
233
 
32
234
  const options = applyDefaultPrePublishOptions(userOptions)
33
235
 
34
- const packageJsonPath = resolve(options.cwd, options.packageJsonPath)
236
+ const resolvedPaths = resolvePackageReconciliationPaths(
237
+ options.cwd,
238
+ options.packageJsonPath,
239
+ [options.npmDir],
240
+ )
241
+ const {
242
+ boundary: reconciliationRoot,
243
+ packageJsonPath,
244
+ packageRoot: rootDir,
245
+ } = resolvedPaths
246
+ const npmDir = resolvedPaths.managedPaths[0]
247
+ let preparedSnapshotRoot: string | undefined
248
+ let prepared: PreparedPrePublish
249
+ try {
250
+ prepared = await withFileSystemReconciliation(
251
+ reconciliationRoot,
252
+ async () => {
253
+ const {
254
+ packageJson,
255
+ targets,
256
+ packageName,
257
+ binaryName,
258
+ npmClient,
259
+ wasm,
260
+ } = await readNapiConfig(
261
+ packageJsonPath,
262
+ options.configPath
263
+ ? resolve(options.cwd, options.configPath)
264
+ : undefined,
265
+ )
266
+ const threadlessWasiTarget = targets.find(
267
+ (target) =>
268
+ target.platform === 'wasi' && !wasiTargetHasThreads(target),
269
+ )
35
270
 
36
- const { packageJson, targets, packageName, binaryName, npmClient } =
37
- await readNapiConfig(
38
- packageJsonPath,
39
- options.configPath ? resolve(options.cwd, options.configPath) : undefined,
271
+ let preparedWorkspaceRoot: string | undefined
272
+ if (!options.dryRun) {
273
+ preparedSnapshotRoot = await mkdtemp(
274
+ join(tmpdir(), 'napi-rs-pre-publish-stage-'),
275
+ )
276
+ preparedWorkspaceRoot = join(preparedSnapshotRoot, 'workspace')
277
+ }
278
+ const releasePackagePlans: ReleasePackageMaterializationPlan[] = []
279
+ for (const target of targets) {
280
+ const pkgDir = join(npmDir, target.platformArchABI)
281
+ const validationOptions: ReleasePackageValidationOptions = {
282
+ pkgDir,
283
+ rootDir,
284
+ packageName,
285
+ binaryName,
286
+ target,
287
+ requireDirectBufferDependency:
288
+ wasm?.browser?.buffer === true &&
289
+ (wasm.browser.fs !== true || !wasiTargetHasThreads(target)),
290
+ }
291
+ releasePackagePlans.push(
292
+ preparedWorkspaceRoot
293
+ ? await stageReleasePackage(
294
+ preparedWorkspaceRoot,
295
+ reconciliationRoot,
296
+ packageJson.version,
297
+ validationOptions,
298
+ )
299
+ : await validateReleasePackage(validationOptions),
300
+ )
301
+ }
302
+ const rootFacadeReconciliation = reconcileThreadlessWasiRootFacade(
303
+ packageJson,
304
+ rootDir,
305
+ npmDir,
306
+ packageName,
307
+ )
308
+ let reconciledPackageJson = rootFacadeReconciliation.packageJson
309
+ let rootFacade: ThreadlessWasiRootFacade | undefined
310
+ if (threadlessWasiTarget) {
311
+ rootFacade = planThreadlessWasiRootFacade({
312
+ packageJsonPath,
313
+ packageJson: reconciledPackageJson,
314
+ packageName,
315
+ binaryName,
316
+ target: threadlessWasiTarget,
317
+ npmDir,
318
+ managedGeneratedFiles: rootFacadeReconciliation.staleGeneratedFiles,
319
+ })
320
+ const stagedThreadlessPackage = releasePackagePlans.find(
321
+ (plan) =>
322
+ plan.target.platformArchABI ===
323
+ threadlessWasiTarget.platformArchABI,
324
+ )?.stagedPkgDir
325
+ if (stagedThreadlessPackage) {
326
+ const stagedWasmSourcePath = join(
327
+ stagedThreadlessPackage,
328
+ `${binaryName}.${threadlessWasiTarget.platformArchABI}.wasm`,
329
+ )
330
+ rootFacade = {
331
+ ...rootFacade,
332
+ marker: createThreadlessWasiRootFacadeMarker(
333
+ `${packageName}-${threadlessWasiTarget.platformArchABI}`,
334
+ readFileSync(stagedWasmSourcePath),
335
+ ),
336
+ wasmSourcePath: stagedWasmSourcePath,
337
+ }
338
+ }
339
+ reconciledPackageJson = applyThreadlessWasiRootFacade(
340
+ reconciledPackageJson,
341
+ rootFacade,
342
+ )
343
+ }
344
+ const optionalDependencies = {
345
+ ...asRecord(packageJson.optionalDependencies),
346
+ }
347
+ const managedPackageNames = new Set([packageName])
348
+ for (const flavorPackage of rootFacadeReconciliation.managedFlavorPackages) {
349
+ for (const suffix of MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES) {
350
+ const ending = `-${suffix}`
351
+ if (flavorPackage.endsWith(ending)) {
352
+ managedPackageNames.add(flavorPackage.slice(0, -ending.length))
353
+ }
354
+ }
355
+ }
356
+ for (const managedPackageName of managedPackageNames) {
357
+ for (const suffix of MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES) {
358
+ delete optionalDependencies[`${managedPackageName}-${suffix}`]
359
+ }
360
+ }
361
+ for (const target of targets) {
362
+ optionalDependencies[`${packageName}-${target.platformArchABI}`] =
363
+ packageJson.version
364
+ }
365
+ const nodeEngine =
366
+ targets.length > 0 &&
367
+ targets.every((target) => target.platform === 'wasi')
368
+ ? restrictWasiNodeEngine(
369
+ packageJson.engines?.node ?? MINIMUM_WASI_NODE_VERSION,
370
+ )
371
+ : undefined
372
+ const rootReleasePlan: RootReleaseMaterializationPlan = {
373
+ packageJson: reconciledPackageJson,
374
+ optionalDependencies,
375
+ nodeEngine,
376
+ facade: rootFacade,
377
+ staleGeneratedFiles: rootFacadeReconciliation.staleGeneratedFiles,
378
+ }
379
+ if (preparedWorkspaceRoot) {
380
+ const stagedTransactionRoot = join(
381
+ preparedSnapshotRoot!,
382
+ 'transaction-root',
383
+ )
384
+ const stagedRootDir = publicationContextPath(
385
+ preparedWorkspaceRoot,
386
+ reconciliationRoot,
387
+ rootDir,
388
+ )
389
+ await stageRootReleasePlan(
390
+ packageJsonPath,
391
+ rootDir,
392
+ stagedRootDir,
393
+ rootReleasePlan,
394
+ )
395
+ await mkdirAsync(stagedTransactionRoot, { recursive: true })
396
+ const stagedRootManifestPath = join(
397
+ stagedTransactionRoot,
398
+ 'package.json',
399
+ )
400
+ await copyFile(
401
+ join(stagedRootDir, 'package.json'),
402
+ stagedRootManifestPath,
403
+ constants.COPYFILE_FICLONE,
404
+ )
405
+ if (releasePackagePlans.length > 0) {
406
+ await stagePublicationContext(
407
+ reconciliationRoot,
408
+ preparedWorkspaceRoot,
409
+ rootDir,
410
+ releasePackagePlans,
411
+ )
412
+ await registerPublicationWorkspaces(
413
+ reconciliationRoot,
414
+ preparedWorkspaceRoot,
415
+ releasePackagePlans.map(({ pkgDir }) => pkgDir),
416
+ )
417
+ }
418
+ await commitPrePublishFileSystemTransaction({
419
+ packageJsonPath,
420
+ transactionRoot: reconciliationRoot,
421
+ releasePackagePlans,
422
+ rootDir,
423
+ rootReleasePlan,
424
+ stagedRootManifestPath,
425
+ stagedRootDir,
426
+ })
427
+ } else if (rootFacade) {
428
+ await validateRootReleasePlan(
429
+ packageJsonPath,
430
+ rootDir,
431
+ rootReleasePlan,
432
+ )
433
+ }
434
+
435
+ return {
436
+ npmClient,
437
+ packageName,
438
+ packageVersion: packageJson.version,
439
+ releasePackages: releasePackagePlans.map((plan) =>
440
+ prepareReleasePackage(plan, binaryName),
441
+ ),
442
+ workspaceRoot: preparedWorkspaceRoot,
443
+ }
444
+ },
40
445
  )
446
+ } catch (error) {
447
+ if (preparedSnapshotRoot) {
448
+ await removePreparedSnapshot(
449
+ preparedSnapshotRoot,
450
+ 'preparation',
451
+ true,
452
+ error,
453
+ )
454
+ }
455
+ throw error
456
+ }
457
+
458
+ const {
459
+ npmClient,
460
+ packageName,
461
+ packageVersion,
462
+ releasePackages,
463
+ workspaceRoot,
464
+ } = prepared
41
465
 
42
466
  async function createGhRelease(packageName: string, version: string) {
43
467
  if (!options.ghRelease) {
@@ -130,105 +554,2427 @@ export async function prePublish(userOptions: PrePublishOptions) {
130
554
  return { owner, repo, pkgInfo, octokit }
131
555
  }
132
556
 
133
- if (!options.dryRun) {
134
- await version(userOptions)
135
- await updatePackageJson(packageJsonPath, {
136
- optionalDependencies: targets.reduce(
137
- (deps, target) => {
138
- deps[`${packageName}-${target.platformArchABI}`] = packageJson.version
557
+ let publicationFailed = false
558
+ let publicationError: unknown
559
+ try {
560
+ const { owner, repo, pkgInfo, octokit } = options.ghReleaseId
561
+ ? getRepoInfo(packageName, packageVersion)
562
+ : await createGhRelease(packageName, packageVersion)
139
563
 
140
- return deps
141
- },
142
- {} as Record<string, string>,
143
- ),
144
- })
145
- }
564
+ for (const releasePackage of releasePackages) {
565
+ const { artifactPath, filename, packageDir } = releasePackage
566
+ let publicationPackageDir = packageDir
567
+ let publicationArtifactPath = artifactPath
568
+ let publicationExecutionRoot: string | undefined
569
+ let releaseFailed = false
570
+ let releaseError: unknown
146
571
 
147
- const { owner, repo, pkgInfo, octokit } = options.ghReleaseId
148
- ? getRepoInfo(packageName, packageJson.version)
149
- : await createGhRelease(packageName, packageJson.version)
150
-
151
- for (const target of targets) {
152
- const pkgDir = resolve(
153
- options.cwd,
154
- options.npmDir,
155
- `${target.platformArchABI}`,
156
- )
157
- const ext =
158
- target.platform === 'wasi' || target.platform === 'wasm' ? 'wasm' : 'node'
159
- const filename = `${binaryName}.${target.platformArchABI}.${ext}`
160
- const dstPath = join(pkgDir, filename)
572
+ try {
573
+ if (!options.dryRun) {
574
+ if (!existsSync(artifactPath)) {
575
+ throw new Error(`Release artifact does not exist: ${artifactPath}`)
576
+ }
161
577
 
162
- if (!options.dryRun) {
163
- if (!existsSync(dstPath)) {
164
- debug.warn(`%s doesn't exist`, dstPath)
165
- continue
166
- }
578
+ if (!options.skipOptionalPublish) {
579
+ if (!workspaceRoot) {
580
+ throw new Error(
581
+ 'Prepared publication workspace is missing for a real publish',
582
+ )
583
+ }
584
+ const publicationExecution =
585
+ await createPublicationExecutionPackage(workspaceRoot, packageDir)
586
+ publicationExecutionRoot = publicationExecution.root
587
+ publicationPackageDir = publicationExecution.packageDir
588
+ publicationArtifactPath = join(publicationPackageDir, filename)
589
+ try {
590
+ const output = execSync(`${npmClient} publish`, {
591
+ cwd: publicationPackageDir,
592
+ env: process.env,
593
+ stdio: 'pipe',
594
+ })
595
+ process.stdout.write(output)
596
+ } catch (e) {
597
+ if (
598
+ e instanceof Error &&
599
+ e.message.includes(
600
+ 'You cannot publish over the previously published versions',
601
+ )
602
+ ) {
603
+ console.info(e.message)
604
+ debug.warn(`${packageDir} has been published, skipping`)
605
+ } else {
606
+ throw e
607
+ }
608
+ }
609
+ }
167
610
 
168
- if (!options.skipOptionalPublish) {
169
- try {
170
- const output = execSync(`${npmClient} publish`, {
171
- cwd: pkgDir,
172
- env: process.env,
173
- stdio: 'pipe',
174
- })
175
- process.stdout.write(output)
176
- } catch (e) {
177
- if (
178
- e instanceof Error &&
179
- e.message.includes(
180
- 'You cannot publish over the previously published versions',
181
- )
182
- ) {
183
- console.info(e.message)
184
- debug.warn(`${pkgDir} has been published, skipping`)
185
- } else {
186
- throw e
611
+ if (options.ghRelease && repo && owner) {
612
+ debug.info(`Creating GitHub release ${pkgInfo.tag}`)
613
+ try {
614
+ const releaseId = options.ghReleaseId
615
+ ? Number(options.ghReleaseId)
616
+ : (
617
+ await octokit!.repos.getReleaseByTag({
618
+ repo: repo,
619
+ owner: owner,
620
+ tag: pkgInfo.tag,
621
+ })
622
+ ).data.id
623
+ const artifactStats = statSync(publicationArtifactPath)
624
+ const assetInfo = await octokit!.repos.uploadReleaseAsset({
625
+ owner: owner,
626
+ repo: repo,
627
+ name: filename,
628
+ release_id: releaseId,
629
+ mediaType: { format: 'raw' },
630
+ headers: {
631
+ 'content-length': artifactStats.size,
632
+ 'content-type': 'application/octet-stream',
633
+ },
634
+ // @ts-expect-error octokit types are wrong
635
+ data: await readFileAsync(publicationArtifactPath),
636
+ })
637
+ debug.info(`GitHub release created`)
638
+ debug.info(
639
+ `Download URL: %s`,
640
+ assetInfo.data.browser_download_url,
641
+ )
642
+ } catch (e) {
643
+ debug.error(
644
+ `Param: ${JSON.stringify(
645
+ {
646
+ owner,
647
+ repo,
648
+ tag: pkgInfo.tag,
649
+ filename: publicationArtifactPath,
650
+ },
651
+ null,
652
+ 2,
653
+ )}`,
654
+ )
655
+ debug.error(e)
656
+ }
187
657
  }
188
658
  }
659
+ } catch (error) {
660
+ releaseFailed = true
661
+ releaseError = error
189
662
  }
190
-
191
- if (options.ghRelease && repo && owner) {
192
- debug.info(`Creating GitHub release ${pkgInfo.tag}`)
193
- try {
194
- const releaseId = options.ghReleaseId
195
- ? Number(options.ghReleaseId)
196
- : (
197
- await octokit!.repos.getReleaseByTag({
198
- repo: repo,
199
- owner: owner,
200
- tag: pkgInfo.tag,
201
- })
202
- ).data.id
203
- const dstFileStats = statSync(dstPath)
204
- const assetInfo = await octokit!.repos.uploadReleaseAsset({
205
- owner: owner,
206
- repo: repo,
207
- name: filename,
208
- release_id: releaseId,
209
- mediaType: { format: 'raw' },
210
- headers: {
211
- 'content-length': dstFileStats.size,
212
- 'content-type': 'application/octet-stream',
213
- },
214
- // @ts-expect-error octokit types are wrong
215
- data: await readFileAsync(dstPath),
216
- })
217
- debug.info(`GitHub release created`)
218
- debug.info(`Download URL: %s`, assetInfo.data.browser_download_url)
219
- } catch (e) {
220
- debug.error(
221
- `Param: ${JSON.stringify(
222
- { owner, repo, tag: pkgInfo.tag, filename: dstPath },
223
- null,
224
- 2,
225
- )}`,
226
- )
227
- debug.error(e)
228
- }
663
+ if (publicationExecutionRoot) {
664
+ await removePreparedSnapshot(
665
+ publicationExecutionRoot,
666
+ 'publication',
667
+ releaseFailed,
668
+ releaseError,
669
+ )
670
+ }
671
+ if (releaseFailed) {
672
+ throw releaseError
229
673
  }
230
674
  }
675
+ } catch (error) {
676
+ publicationFailed = true
677
+ publicationError = error
678
+ }
679
+ if (preparedSnapshotRoot) {
680
+ await removePreparedSnapshot(
681
+ preparedSnapshotRoot,
682
+ 'publication',
683
+ publicationFailed,
684
+ publicationError,
685
+ )
686
+ }
687
+ if (publicationFailed) {
688
+ throw publicationError
689
+ }
690
+ }
691
+
692
+ interface ThreadlessWasiRootFacadeOptions {
693
+ packageJsonPath: string
694
+ packageJson: CommonPackageJsonFields
695
+ packageName: string
696
+ binaryName: string
697
+ target: Target
698
+ npmDir: string
699
+ managedGeneratedFiles: string[]
700
+ }
701
+
702
+ interface ThreadlessWasiRootFacade {
703
+ exports: Record<string, unknown>
704
+ publishConfigExports?: Record<string, unknown>
705
+ generatedFiles: string[]
706
+ files: ThreadlessWasiRootFacadeFiles
707
+ wasmSourcePath: string
708
+ marker: string
709
+ forwardingModule: string
710
+ packageJsonUpdate: {
711
+ files?: string[]
712
+ }
713
+ }
714
+
715
+ interface ThreadlessWasiRootFacadeFiles {
716
+ workerdEntry: string
717
+ workerdTypeDef: string
718
+ wasmEntry: string
719
+ wasmTypeDef: string
720
+ }
721
+
722
+ interface ThreadlessWasiRootFacadeReconciliation {
723
+ managedFlavorPackages: string[]
724
+ packageJson: CommonPackageJsonFields
725
+ staleGeneratedFiles: string[]
726
+ }
727
+
728
+ interface ManagedThreadlessWasiRootFacade {
729
+ files: ThreadlessWasiRootFacadeFiles
730
+ flavorPackage: string
731
+ }
732
+
733
+ interface ThreadlessWasiRootFacadeMarker {
734
+ version: 1
735
+ flavorPackage: string
736
+ wasmSha256: string
737
+ }
738
+
739
+ function reconcileThreadlessWasiRootFacade(
740
+ packageJson: CommonPackageJsonFields,
741
+ rootDir: string,
742
+ npmDir: string,
743
+ packageName: string,
744
+ ): ThreadlessWasiRootFacadeReconciliation {
745
+ const reconciledPackageJson = { ...packageJson }
746
+ const staleGeneratedFiles = new Set<string>()
747
+ const managedFlavorPackages = new Set<string>()
748
+ const rootExports = removeThreadlessWasiRootExports(
749
+ packageJson.exports,
750
+ packageJson,
751
+ rootDir,
752
+ npmDir,
753
+ packageName,
754
+ )
755
+ for (const file of rootExports.generatedFiles) {
756
+ staleGeneratedFiles.add(file)
757
+ }
758
+ if (rootExports.managedFlavorPackage) {
759
+ managedFlavorPackages.add(rootExports.managedFlavorPackage)
760
+ }
761
+ setOptionalProperty(
762
+ reconciledPackageJson as Record<string, unknown>,
763
+ 'exports',
764
+ rootExports.exports,
765
+ )
766
+
767
+ const publishConfig = asRecord(packageJson.publishConfig)
768
+ if (
769
+ publishConfig &&
770
+ Object.prototype.hasOwnProperty.call(publishConfig, 'exports')
771
+ ) {
772
+ const publishConfigExports = removeThreadlessWasiRootExports(
773
+ publishConfig.exports as CommonPackageJsonFields['exports'],
774
+ packageJson,
775
+ rootDir,
776
+ npmDir,
777
+ packageName,
778
+ )
779
+ for (const file of publishConfigExports.generatedFiles) {
780
+ staleGeneratedFiles.add(file)
781
+ }
782
+ if (publishConfigExports.managedFlavorPackage) {
783
+ managedFlavorPackages.add(publishConfigExports.managedFlavorPackage)
784
+ }
785
+ const reconciledPublishConfig = { ...publishConfig }
786
+ setOptionalProperty(
787
+ reconciledPublishConfig,
788
+ 'exports',
789
+ publishConfigExports.exports,
790
+ )
791
+ reconciledPackageJson.publishConfig = reconciledPublishConfig
792
+ }
793
+
794
+ if (Array.isArray(packageJson.files)) {
795
+ reconciledPackageJson.files = packageJson.files.filter(
796
+ (file) => !staleGeneratedFiles.has(file),
797
+ )
798
+ }
799
+
800
+ return {
801
+ managedFlavorPackages: [...managedFlavorPackages],
802
+ packageJson: reconciledPackageJson,
803
+ staleGeneratedFiles: [...staleGeneratedFiles],
804
+ }
805
+ }
806
+
807
+ function removeThreadlessWasiRootExports(
808
+ currentExports: CommonPackageJsonFields['exports'],
809
+ packageJson: CommonPackageJsonFields,
810
+ rootDir: string,
811
+ npmDir: string,
812
+ packageName: string,
813
+ ) {
814
+ const exportsMap = asRecord(currentExports)
815
+ const managedFacade = getManagedThreadlessWasiRootFacadeFiles(
816
+ exportsMap,
817
+ rootDir,
818
+ npmDir,
819
+ packageName,
820
+ )
821
+ if (!exportsMap || !managedFacade) {
822
+ return {
823
+ exports: currentExports,
824
+ generatedFiles: [],
825
+ managedFlavorPackage: undefined,
826
+ }
827
+ }
828
+ const { files: generatedFiles, flavorPackage: managedFlavorPackage } =
829
+ managedFacade
830
+
831
+ const nextExports = { ...exportsMap }
832
+ delete nextExports['./workerd']
833
+ delete nextExports['./wasm']
834
+ delete nextExports['./wasm.wasm']
835
+ const keys = Object.keys(nextExports)
836
+ const generatedLegacyExports = createLegacyDeepImportExports(rootDir)
837
+ if (
838
+ Object.prototype.hasOwnProperty.call(nextExports, '.') &&
839
+ nextExports['./*'] === './*' &&
840
+ isDeepStrictEqual(
841
+ nextExports['.'],
842
+ createLegacyRootExport(packageJson, rootDir),
843
+ ) &&
844
+ keys
845
+ .filter((key) => key !== '.' && key !== './*')
846
+ .every(
847
+ (key) =>
848
+ generatedLegacyExports[key] !== undefined &&
849
+ isDeepStrictEqual(nextExports[key], generatedLegacyExports[key]),
850
+ )
851
+ ) {
852
+ return {
853
+ exports: undefined,
854
+ generatedFiles: Object.values(generatedFiles),
855
+ managedFlavorPackage,
856
+ }
857
+ }
858
+ if (keys.length === 1 && keys[0] === '.') {
859
+ return {
860
+ exports: nextExports['.'],
861
+ generatedFiles: Object.values(generatedFiles),
862
+ managedFlavorPackage,
863
+ }
864
+ }
865
+ return {
866
+ exports: keys.length > 0 ? nextExports : undefined,
867
+ generatedFiles: Object.values(generatedFiles),
868
+ managedFlavorPackage,
869
+ }
870
+ }
871
+
872
+ function getManagedThreadlessWasiRootFacadeFiles(
873
+ exportsMap: Record<string, unknown> | undefined,
874
+ rootDir: string,
875
+ npmDir: string,
876
+ packageName: string,
877
+ ): ManagedThreadlessWasiRootFacade | undefined {
878
+ if (!exportsMap) {
879
+ return undefined
880
+ }
881
+ const workerdExport = asRecord(exportsMap['./workerd'])
882
+ const workerdEntry =
883
+ typeof workerdExport?.default === 'string'
884
+ ? workerdExport.default.match(
885
+ /^\.\/([^/\\]+\.wasm32-wasip1)\.workerd\.mjs$/,
886
+ )
887
+ : null
888
+ if (!workerdEntry) {
889
+ return undefined
890
+ }
891
+ const generatedPrefix = workerdEntry[1]
892
+ const files: ThreadlessWasiRootFacadeFiles = {
893
+ workerdEntry: `${generatedPrefix}.workerd.mjs`,
894
+ workerdTypeDef: `${generatedPrefix}.workerd.d.mts`,
895
+ wasmEntry: `${generatedPrefix}.wasm`,
896
+ wasmTypeDef: `${generatedPrefix}.wasm.d.mts`,
897
+ }
898
+ const expectedWorkerdExport = {
899
+ types: `./${files.workerdTypeDef}`,
900
+ default: `./${files.workerdEntry}`,
901
+ }
902
+ const expectedWasmExport = {
903
+ types: `./${files.wasmTypeDef}`,
904
+ default: `./${files.wasmEntry}`,
905
+ }
906
+ if (
907
+ !isDeepStrictEqual(workerdExport, expectedWorkerdExport) ||
908
+ !isDeepStrictEqual(exportsMap['./wasm'], expectedWasmExport) ||
909
+ !isDeepStrictEqual(exportsMap['./wasm.wasm'], expectedWasmExport)
910
+ ) {
911
+ return undefined
912
+ }
913
+ const flavorPackage = `${packageName}-wasm32-wasip1`
914
+ const managedFlavorPackage =
915
+ getManagedThreadlessWasiRootFacadeMarker(rootDir, files) ??
916
+ getPartialManagedThreadlessWasiRootFacadeMarker(rootDir, npmDir, files) ??
917
+ getLegacyThreadlessWasiRootFacade(rootDir, npmDir, files)
918
+ if (managedFlavorPackage) {
919
+ return { files, flavorPackage: managedFlavorPackage }
920
+ }
921
+ if (
922
+ hasPartialOrCorruptThreadlessWasiRootFacade(rootDir, files, flavorPackage)
923
+ ) {
924
+ throw new Error(
925
+ 'The threadless WASI root facade is partial or corrupt and ownership cannot be verified. Restore the generated facade files or remove the generated-shaped exports and files before running pre-publish.',
926
+ )
927
+ }
928
+ return undefined
929
+ }
930
+
931
+ function getManagedThreadlessWasiRootFacadeMarker(
932
+ rootDir: string,
933
+ files: ThreadlessWasiRootFacadeFiles,
934
+ ) {
935
+ const workerdEntry = readRegularFile(join(rootDir, files.workerdEntry))
936
+ const workerdTypeDef = readRegularFile(join(rootDir, files.workerdTypeDef))
937
+ const wasmEntry = readRegularFile(join(rootDir, files.wasmEntry))
938
+ const wasmTypeDef = readRegularFile(join(rootDir, files.wasmTypeDef))
939
+ if (!workerdEntry || !workerdTypeDef || !wasmEntry || !wasmTypeDef) {
940
+ return undefined
941
+ }
942
+
943
+ const markedFiles = [workerdEntry, workerdTypeDef, wasmTypeDef].map((file) =>
944
+ parseThreadlessWasiRootFacadeMarker(file.toString('utf8')),
945
+ )
946
+ if (markedFiles.some((file) => file === undefined)) {
947
+ return undefined
948
+ }
949
+ const [markedWorkerdEntry, markedWorkerdTypeDef, markedWasmTypeDef] =
950
+ markedFiles as Array<{
951
+ marker: ThreadlessWasiRootFacadeMarker
952
+ markerLine: string
953
+ body: string
954
+ }>
955
+ if (
956
+ markedWorkerdEntry.markerLine !== markedWorkerdTypeDef.markerLine ||
957
+ markedWorkerdEntry.markerLine !== markedWasmTypeDef.markerLine
958
+ ) {
959
+ return undefined
960
+ }
961
+
962
+ const expectedMarker: ThreadlessWasiRootFacadeMarker = {
963
+ version: 1,
964
+ flavorPackage: markedWorkerdEntry.marker.flavorPackage,
965
+ wasmSha256: createHash('sha256').update(wasmEntry).digest('hex'),
966
+ }
967
+ if (!isDeepStrictEqual(markedWorkerdEntry.marker, expectedMarker)) {
968
+ return undefined
969
+ }
970
+ const forwardingModule = createThreadlessWasiRootForwardingModule(
971
+ markedWorkerdEntry.marker.flavorPackage,
972
+ )
973
+ return markedWorkerdEntry.body === forwardingModule &&
974
+ markedWorkerdTypeDef.body === forwardingModule &&
975
+ markedWasmTypeDef.body === createWasmModuleTypeDef()
976
+ ? markedWorkerdEntry.marker.flavorPackage
977
+ : undefined
978
+ }
979
+
980
+ function getPartialManagedThreadlessWasiRootFacadeMarker(
981
+ rootDir: string,
982
+ npmDir: string,
983
+ files: ThreadlessWasiRootFacadeFiles,
984
+ ) {
985
+ const availableWasmHashes = new Set<string>()
986
+ for (const wasm of [
987
+ readRegularFile(join(rootDir, files.wasmEntry)),
988
+ readRegularFile(join(npmDir, 'wasm32-wasip1', files.wasmEntry)),
989
+ ]) {
990
+ if (wasm) {
991
+ availableWasmHashes.add(createHash('sha256').update(wasm).digest('hex'))
992
+ }
993
+ }
994
+ if (availableWasmHashes.size === 0) {
995
+ return undefined
996
+ }
997
+
998
+ const flavorPackages = new Set<string>()
999
+ for (const file of [
1000
+ files.workerdEntry,
1001
+ files.workerdTypeDef,
1002
+ files.wasmTypeDef,
1003
+ ]) {
1004
+ const contents = readRegularFile(join(rootDir, file))
1005
+ if (!contents) {
1006
+ continue
1007
+ }
1008
+ const markedFile = parseThreadlessWasiRootFacadeMarker(
1009
+ contents.toString('utf8'),
1010
+ )
1011
+ if (
1012
+ markedFile !== undefined &&
1013
+ availableWasmHashes.has(markedFile.marker.wasmSha256)
1014
+ ) {
1015
+ flavorPackages.add(markedFile.marker.flavorPackage)
1016
+ }
1017
+ }
1018
+ return flavorPackages.size === 1 ? [...flavorPackages][0] : undefined
1019
+ }
1020
+
1021
+ function getLegacyThreadlessWasiRootFacade(
1022
+ rootDir: string,
1023
+ npmDir: string,
1024
+ files: ThreadlessWasiRootFacadeFiles,
1025
+ ) {
1026
+ const workerdEntry = readRegularFile(join(rootDir, files.workerdEntry))
1027
+ const workerdTypeDef = readRegularFile(join(rootDir, files.workerdTypeDef))
1028
+ const wasmEntry = readRegularFile(join(rootDir, files.wasmEntry))
1029
+ const wasmTypeDef = readRegularFile(join(rootDir, files.wasmTypeDef))
1030
+ const flavorWasmEntry = readRegularFile(
1031
+ join(npmDir, 'wasm32-wasip1', files.wasmEntry),
1032
+ )
1033
+ if (!wasmEntry || !flavorWasmEntry || !wasmEntry.equals(flavorWasmEntry)) {
1034
+ return undefined
1035
+ }
1036
+
1037
+ const forwardingPackages = new Set<string>()
1038
+ for (const [file, contents] of [
1039
+ [files.workerdEntry, workerdEntry],
1040
+ [files.workerdTypeDef, workerdTypeDef],
1041
+ ] as const) {
1042
+ if (!existsSync(join(rootDir, file)) && contents === undefined) {
1043
+ continue
1044
+ }
1045
+ const flavorPackage = contents
1046
+ ? parseThreadlessWasiRootForwardingModule(contents.toString('utf8'))
1047
+ : undefined
1048
+ if (!flavorPackage) {
1049
+ return undefined
1050
+ }
1051
+ forwardingPackages.add(flavorPackage)
1052
+ }
1053
+ if (
1054
+ (existsSync(join(rootDir, files.wasmTypeDef)) ||
1055
+ wasmTypeDef !== undefined) &&
1056
+ wasmTypeDef?.toString('utf8') !== createWasmModuleTypeDef()
1057
+ ) {
1058
+ return undefined
1059
+ }
1060
+ return forwardingPackages.size === 1 ? [...forwardingPackages][0] : undefined
1061
+ }
1062
+
1063
+ function hasPartialOrCorruptThreadlessWasiRootFacade(
1064
+ rootDir: string,
1065
+ files: ThreadlessWasiRootFacadeFiles,
1066
+ flavorPackage: string,
1067
+ ) {
1068
+ const facadeFiles = Object.values(files).map(
1069
+ (file) => [file, readRegularFile(join(rootDir, file))] as const,
1070
+ )
1071
+ if (facadeFiles.some(([, contents]) => contents === undefined)) {
1072
+ return true
1073
+ }
1074
+
1075
+ const forwardingModule =
1076
+ createThreadlessWasiRootForwardingModule(flavorPackage)
1077
+ const expectedLegacyContents = new Map([
1078
+ [files.workerdEntry, forwardingModule],
1079
+ [files.workerdTypeDef, forwardingModule],
1080
+ [files.wasmTypeDef, createWasmModuleTypeDef()],
1081
+ ])
1082
+ return facadeFiles.some(([file, contents]) => {
1083
+ const source = contents!.toString('utf8')
1084
+ return (
1085
+ source.startsWith(WASI_ROOT_FACADE_MARKER_PREFIX) ||
1086
+ source === expectedLegacyContents.get(file)
1087
+ )
1088
+ })
1089
+ }
1090
+
1091
+ function readRegularFile(path: string) {
1092
+ try {
1093
+ if (!lstatSync(path).isFile()) {
1094
+ return undefined
1095
+ }
1096
+ return readFileSync(path)
1097
+ } catch {
1098
+ return undefined
1099
+ }
1100
+ }
1101
+
1102
+ function parseThreadlessWasiRootFacadeMarker(source: string) {
1103
+ const markerEnd = source.indexOf('\n')
1104
+ if (markerEnd === -1) {
1105
+ return undefined
1106
+ }
1107
+ const markerLine = source.slice(0, markerEnd)
1108
+ if (!markerLine.startsWith(WASI_ROOT_FACADE_MARKER_PREFIX)) {
1109
+ return undefined
1110
+ }
1111
+
1112
+ let marker: unknown
1113
+ try {
1114
+ marker = JSON.parse(markerLine.slice(WASI_ROOT_FACADE_MARKER_PREFIX.length))
1115
+ } catch {
1116
+ return undefined
1117
+ }
1118
+ const markerRecord = asRecord(marker)
1119
+ if (
1120
+ !markerRecord ||
1121
+ !isDeepStrictEqual(Object.keys(markerRecord), [
1122
+ 'version',
1123
+ 'flavorPackage',
1124
+ 'wasmSha256',
1125
+ ]) ||
1126
+ markerRecord.version !== 1 ||
1127
+ typeof markerRecord.flavorPackage !== 'string' ||
1128
+ typeof markerRecord.wasmSha256 !== 'string' ||
1129
+ !/^[0-9a-f]{64}$/.test(markerRecord.wasmSha256) ||
1130
+ markerLine !==
1131
+ `${WASI_ROOT_FACADE_MARKER_PREFIX}${JSON.stringify(markerRecord)}`
1132
+ ) {
1133
+ return undefined
1134
+ }
1135
+ return {
1136
+ marker: markerRecord as unknown as ThreadlessWasiRootFacadeMarker,
1137
+ markerLine,
1138
+ body: source.slice(markerEnd + 1),
1139
+ }
1140
+ }
1141
+
1142
+ function createThreadlessWasiRootFacadeMarker(
1143
+ flavorPackage: string,
1144
+ wasm: Buffer,
1145
+ ) {
1146
+ const marker: ThreadlessWasiRootFacadeMarker = {
1147
+ version: 1,
1148
+ flavorPackage,
1149
+ wasmSha256: createHash('sha256').update(wasm).digest('hex'),
1150
+ }
1151
+ return `${WASI_ROOT_FACADE_MARKER_PREFIX}${JSON.stringify(marker)}`
1152
+ }
1153
+
1154
+ function createThreadlessWasiRootForwardingModule(flavorPackage: string) {
1155
+ return `export * from ${JSON.stringify(`${flavorPackage}/workerd`)}\n`
1156
+ }
1157
+
1158
+ function parseThreadlessWasiRootForwardingModule(source: string) {
1159
+ const match = /^export \* from (".+")\n$/.exec(source)
1160
+ if (!match) {
1161
+ return undefined
1162
+ }
1163
+ let specifier: unknown
1164
+ try {
1165
+ specifier = JSON.parse(match[1])
1166
+ } catch {
1167
+ return undefined
1168
+ }
1169
+ if (
1170
+ typeof specifier !== 'string' ||
1171
+ !specifier.endsWith('-wasm32-wasip1/workerd')
1172
+ ) {
1173
+ return undefined
1174
+ }
1175
+ return specifier.slice(0, -'/workerd'.length)
1176
+ }
1177
+
1178
+ function applyThreadlessWasiRootFacade(
1179
+ packageJson: CommonPackageJsonFields,
1180
+ rootFacade: ThreadlessWasiRootFacade,
1181
+ ) {
1182
+ const updatedPackageJson = { ...packageJson }
1183
+ if (rootFacade.publishConfigExports) {
1184
+ // The facade exports were built from publishConfig.exports, so write the
1185
+ // result back there and leave the package's local exports map untouched.
1186
+ updatedPackageJson.publishConfig = {
1187
+ ...asRecord(packageJson.publishConfig),
1188
+ exports: rootFacade.publishConfigExports,
1189
+ }
1190
+ } else {
1191
+ updatedPackageJson.exports = rootFacade.exports
1192
+ }
1193
+ if (rootFacade.packageJsonUpdate.files) {
1194
+ updatedPackageJson.files = rootFacade.packageJsonUpdate.files
1195
+ }
1196
+ return updatedPackageJson
1197
+ }
1198
+
1199
+ function syncThreadlessWasiRootFacadeManifest(
1200
+ destination: Record<string, unknown>,
1201
+ source: CommonPackageJsonFields,
1202
+ ) {
1203
+ setOptionalProperty(destination, 'exports', source.exports)
1204
+ setOptionalProperty(destination, 'files', source.files)
1205
+
1206
+ const destinationPublishConfig = asRecord(destination.publishConfig)
1207
+ const sourcePublishConfig = asRecord(source.publishConfig)
1208
+ if (destinationPublishConfig || sourcePublishConfig?.exports !== undefined) {
1209
+ const publishConfig = { ...destinationPublishConfig }
1210
+ setOptionalProperty(publishConfig, 'exports', sourcePublishConfig?.exports)
1211
+ destination.publishConfig = publishConfig
1212
+ }
1213
+ }
1214
+
1215
+ function setOptionalProperty(
1216
+ object: Record<string, unknown>,
1217
+ key: string,
1218
+ value: unknown,
1219
+ ) {
1220
+ if (value === undefined) {
1221
+ delete object[key]
1222
+ } else {
1223
+ object[key] = value
1224
+ }
1225
+ }
1226
+
1227
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
1228
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1229
+ ? (value as Record<string, unknown>)
1230
+ : undefined
1231
+ }
1232
+
1233
+ function planThreadlessWasiRootFacade({
1234
+ packageJsonPath,
1235
+ packageJson,
1236
+ packageName,
1237
+ binaryName,
1238
+ target,
1239
+ npmDir,
1240
+ managedGeneratedFiles,
1241
+ }: ThreadlessWasiRootFacadeOptions): ThreadlessWasiRootFacade {
1242
+ const rootDir = dirname(packageJsonPath)
1243
+ const flavorPackageName = `${packageName}-${target.platformArchABI}`
1244
+ const wasmFileName = `${binaryName}.${target.platformArchABI}.wasm`
1245
+ const generatedPrefix = `${binaryName}.${target.platformArchABI}`
1246
+ const files: ThreadlessWasiRootFacadeFiles = {
1247
+ workerdEntry: `${generatedPrefix}.workerd.mjs`,
1248
+ workerdTypeDef: `${generatedPrefix}.workerd.d.mts`,
1249
+ wasmEntry: wasmFileName,
1250
+ wasmTypeDef: `${wasmFileName}.d.mts`,
1251
+ }
1252
+ const publishConfig = asRecord(packageJson.publishConfig)
1253
+ const hasPublishConfigExports =
1254
+ publishConfig &&
1255
+ Object.prototype.hasOwnProperty.call(publishConfig, 'exports')
1256
+ const effectiveExports = hasPublishConfigExports
1257
+ ? (publishConfig.exports as CommonPackageJsonFields['exports'])
1258
+ : packageJson.exports
1259
+ const exports = addThreadlessWasiRootExports(
1260
+ effectiveExports,
1261
+ packageJson,
1262
+ files,
1263
+ rootDir,
1264
+ )
1265
+ const publishConfigExports = hasPublishConfigExports ? exports : undefined
1266
+ const forwardingModule =
1267
+ createThreadlessWasiRootForwardingModule(flavorPackageName)
1268
+ const generatedFiles = Object.values(files)
1269
+ const managedFiles = new Set(managedGeneratedFiles)
1270
+ const wasmSourcePath = join(npmDir, target.platformArchABI, wasmFileName)
1271
+ const marker = createThreadlessWasiRootFacadeMarker(
1272
+ flavorPackageName,
1273
+ readFileSync(wasmSourcePath),
1274
+ )
1275
+ const conflictingFile = generatedFiles.find((file) => {
1276
+ const rootPath = join(rootDir, file)
1277
+ if (!existsSync(rootPath) || managedFiles.has(file)) {
1278
+ return false
1279
+ }
1280
+ // `napi artifacts` already copies the flavor artifact into the root
1281
+ // package. Treat that exact regular file as managed so the standard
1282
+ // artifacts -> pre-publish flow can materialize the root facade.
1283
+ return !(
1284
+ file === files.wasmEntry &&
1285
+ lstatSync(rootPath).isFile() &&
1286
+ readFileSync(rootPath).equals(readFileSync(wasmSourcePath))
1287
+ )
1288
+ })
1289
+ if (conflictingFile) {
1290
+ throw new Error(
1291
+ `Cannot generate the threadless WASI root facade file ${conflictingFile}: the path already exists and is not owned by a managed facade. Remove or rename the existing file before running pre-publish.`,
1292
+ )
1293
+ }
1294
+ const facade: ThreadlessWasiRootFacade = {
1295
+ exports,
1296
+ publishConfigExports,
1297
+ generatedFiles,
1298
+ files,
1299
+ wasmSourcePath,
1300
+ marker,
1301
+ forwardingModule,
1302
+ packageJsonUpdate: {},
1303
+ }
1304
+ if (Array.isArray(packageJson.files)) {
1305
+ facade.packageJsonUpdate = {
1306
+ files: [...new Set([...packageJson.files, ...generatedFiles])],
1307
+ }
1308
+ }
1309
+ return facade
1310
+ }
1311
+
1312
+ async function materializeThreadlessWasiRootFacade(
1313
+ rootDir: string,
1314
+ facade: ThreadlessWasiRootFacade,
1315
+ ) {
1316
+ await copyFileAtomic(
1317
+ facade.wasmSourcePath,
1318
+ join(rootDir, facade.files.wasmEntry),
1319
+ )
1320
+ await Promise.all([
1321
+ writeFileAtomic(
1322
+ join(rootDir, facade.files.workerdEntry),
1323
+ `${facade.marker}\n${facade.forwardingModule}`,
1324
+ 'utf8',
1325
+ ),
1326
+ writeFileAtomic(
1327
+ join(rootDir, facade.files.workerdTypeDef),
1328
+ `${facade.marker}\n${facade.forwardingModule}`,
1329
+ 'utf8',
1330
+ ),
1331
+ writeFileAtomic(
1332
+ join(rootDir, facade.files.wasmTypeDef),
1333
+ `${facade.marker}\n${createWasmModuleTypeDef()}`,
1334
+ 'utf8',
1335
+ ),
1336
+ ])
1337
+ }
1338
+
1339
+ function addThreadlessWasiRootExports(
1340
+ currentExports: CommonPackageJsonFields['exports'],
1341
+ packageJson: CommonPackageJsonFields,
1342
+ files: ThreadlessWasiRootFacadeFiles,
1343
+ rootDir: string,
1344
+ ) {
1345
+ let exportsMap: Record<string, unknown>
1346
+ if (currentExports === undefined) {
1347
+ exportsMap = {
1348
+ '.': createLegacyRootExport(packageJson, rootDir),
1349
+ // Adding an exports map would otherwise encapsulate every historical
1350
+ // deep import. The wildcard preserves explicit paths, while exact
1351
+ // aliases preserve CommonJS extension and directory-index resolution.
1352
+ './*': './*',
1353
+ ...createLegacyDeepImportExports(rootDir),
1354
+ }
1355
+ } else if (
1356
+ typeof currentExports === 'object' &&
1357
+ currentExports !== null &&
1358
+ !Array.isArray(currentExports) &&
1359
+ Object.keys(currentExports).some((key) => key.startsWith('.'))
1360
+ ) {
1361
+ exportsMap = { ...currentExports }
1362
+ } else {
1363
+ // A string, array, or condition-only object describes the package root.
1364
+ exportsMap = { '.': currentExports }
1365
+ }
1366
+
1367
+ setThreadlessWasiRootExport(exportsMap, './workerd', {
1368
+ types: `./${files.workerdTypeDef}`,
1369
+ default: `./${files.workerdEntry}`,
1370
+ })
1371
+ const wasmExport = {
1372
+ types: `./${files.wasmTypeDef}`,
1373
+ default: `./${files.wasmEntry}`,
1374
+ }
1375
+ setThreadlessWasiRootExport(exportsMap, './wasm', wasmExport)
1376
+ setThreadlessWasiRootExport(exportsMap, './wasm.wasm', wasmExport)
1377
+ return exportsMap
1378
+ }
1379
+
1380
+ function setThreadlessWasiRootExport(
1381
+ exportsMap: Record<string, unknown>,
1382
+ subpath: string,
1383
+ generatedExport: unknown,
1384
+ ) {
1385
+ if (!Object.prototype.hasOwnProperty.call(exportsMap, subpath)) {
1386
+ exportsMap[subpath] = generatedExport
1387
+ return
1388
+ }
1389
+ if (isDeepStrictEqual(exportsMap[subpath], generatedExport)) {
1390
+ return
1391
+ }
1392
+ throw new Error(
1393
+ `Cannot generate the threadless WASI root export ${subpath}: package.json already defines that subpath. Remove or rename the existing export before running pre-publish.`,
1394
+ )
1395
+ }
1396
+
1397
+ function createLegacyRootExport(
1398
+ packageJson: CommonPackageJsonFields,
1399
+ rootDir: string,
1400
+ ) {
1401
+ const main = resolveLegacyPackageTarget(
1402
+ rootDir,
1403
+ packageJson.main ?? 'index.js',
1404
+ )
1405
+ const rootExport: Record<string, string> = {}
1406
+ const legacyPackageJson = packageJson as CommonPackageJsonFields & {
1407
+ typings?: unknown
1408
+ }
1409
+ const typeDef =
1410
+ typeof packageJson.types === 'string'
1411
+ ? packageJson.types
1412
+ : typeof legacyPackageJson.typings === 'string'
1413
+ ? legacyPackageJson.typings
1414
+ : undefined
1415
+ if (typeDef) {
1416
+ rootExport.types = resolveLegacyPackageTarget(rootDir, typeDef)
1417
+ }
1418
+ if (typeof packageJson.browser === 'string') {
1419
+ rootExport.browser = resolveLegacyPackageTarget(
1420
+ rootDir,
1421
+ packageJson.browser,
1422
+ )
1423
+ }
1424
+ if (typeof packageJson.module === 'string') {
1425
+ const module = resolveLegacyPackageTarget(rootDir, packageJson.module)
1426
+ if (isNodeEsmTarget(module, packageJson.type)) {
1427
+ rootExport.import = module
1428
+ } else {
1429
+ rootExport.module = module
1430
+ rootExport.require = main
1431
+ }
1432
+ } else if (packageJson.type === 'module') {
1433
+ rootExport.import = main
1434
+ } else {
1435
+ rootExport.require = main
1436
+ }
1437
+ // Node ignores the legacy `module` field. CommonJS resolution reaches the
1438
+ // main entry through `node`, while ESM reaches `import` above when present.
1439
+ rootExport.node = main
1440
+ rootExport.default = main
1441
+ return rootExport
1442
+ }
1443
+
1444
+ function normalizePackageTarget(target: string) {
1445
+ return target.startsWith('./') ? target : `./${target}`
1446
+ }
1447
+
1448
+ function resolveLegacyPackageTarget(rootDir: string, target: string) {
1449
+ try {
1450
+ const resolvedTarget = createRequire(join(rootDir, 'package.json')).resolve(
1451
+ resolve(rootDir, target),
1452
+ )
1453
+ const relativeTarget = relative(rootDir, resolvedTarget)
1454
+ if (
1455
+ relativeTarget.length > 0 &&
1456
+ relativeTarget !== '..' &&
1457
+ !relativeTarget.startsWith(`..${sep}`) &&
1458
+ !resolve(rootDir, relativeTarget).startsWith(
1459
+ `${resolve(rootDir)}${sep}node_modules${sep}`,
1460
+ )
1461
+ ) {
1462
+ return normalizePackageTarget(relativeTarget.split(sep).join('/'))
1463
+ }
1464
+ } catch {
1465
+ // Preserve the legacy value so root validation can report the missing path.
231
1466
  }
1467
+ return normalizePackageTarget(target)
1468
+ }
1469
+
1470
+ function isNodeEsmTarget(
1471
+ target: string,
1472
+ packageType: CommonPackageJsonFields['type'],
1473
+ ) {
1474
+ const extension = extname(target)
1475
+ return (
1476
+ extension === '.mjs' || (extension === '.js' && packageType === 'module')
1477
+ )
1478
+ }
1479
+
1480
+ function createLegacyDeepImportExports(rootDir: string) {
1481
+ const packedFiles = readNpmPackFiles(rootDir, 'root package')
1482
+ const exportsMap: Record<string, string> = {}
1483
+ const matchingFiles = [...packedFiles]
1484
+ .filter((file) =>
1485
+ LEGACY_DEEP_IMPORT_EXTENSIONS.some((extension) =>
1486
+ file.endsWith(extension),
1487
+ ),
1488
+ )
1489
+ .sort()
1490
+
1491
+ for (const extension of LEGACY_DEEP_IMPORT_EXTENSIONS) {
1492
+ for (const file of matchingFiles.filter((file) =>
1493
+ file.endsWith(extension),
1494
+ )) {
1495
+ const target = `./${file}`
1496
+ const extensionless = `./${file.slice(0, -extension.length)}`
1497
+ exportsMap[extensionless] ??= target
1498
+ }
1499
+ }
1500
+
1501
+ for (const packageManifest of matchingFiles.filter((file) =>
1502
+ file.endsWith('/package.json'),
1503
+ )) {
1504
+ const directory = dirname(packageManifest).split(sep).join('/')
1505
+ const target = resolveLegacyPackageTarget(rootDir, directory)
1506
+ if (packedFiles.has(target.slice(2))) {
1507
+ exportsMap[`./${directory}`] ??= target
1508
+ }
1509
+ }
1510
+
1511
+ for (const extension of LEGACY_DEEP_IMPORT_EXTENSIONS) {
1512
+ for (const file of matchingFiles.filter((file) =>
1513
+ file.endsWith(extension),
1514
+ )) {
1515
+ const indexSuffix = `/index${extension}`
1516
+ if (file.endsWith(indexSuffix)) {
1517
+ const directory = `./${file.slice(0, -indexSuffix.length)}`
1518
+ exportsMap[directory] ??= `./${file}`
1519
+ }
1520
+ }
1521
+ }
1522
+
1523
+ return exportsMap
1524
+ }
1525
+
1526
+ function collectRootPackagePathReferences(
1527
+ packageJson: CommonPackageJsonFields,
1528
+ ) {
1529
+ const files = new Set<string>()
1530
+ addLegacyPackageFileReference(files, packageJson.main)
1531
+ if (packageJson.main === undefined && packageJson.exports === undefined) {
1532
+ addLegacyPackageFileReference(files, 'index.js')
1533
+ }
1534
+ addLegacyPackageFileReference(files, packageJson.module)
1535
+ addLegacyPackageFileReference(files, packageJson.types)
1536
+ addLegacyPackageFileReference(
1537
+ files,
1538
+ (packageJson as CommonPackageJsonFields & { typings?: unknown }).typings,
1539
+ )
1540
+ addLegacyPackageFileReference(files, packageJson.browser)
1541
+ collectRootExportFileReferences(files, packageJson.exports)
1542
+
1543
+ const publishConfig = asRecord(packageJson.publishConfig)
1544
+ if (
1545
+ publishConfig &&
1546
+ Object.prototype.hasOwnProperty.call(publishConfig, 'exports')
1547
+ ) {
1548
+ collectRootExportFileReferences(files, publishConfig.exports)
1549
+ }
1550
+ return [...files]
1551
+ }
1552
+
1553
+ function addLegacyPackageFileReference(files: Set<string>, target: unknown) {
1554
+ if (typeof target === 'string') {
1555
+ addPackageFileReference(files, normalizePackageTarget(target))
1556
+ }
1557
+ }
1558
+
1559
+ function collectRootExportFileReferences(
1560
+ files: Set<string>,
1561
+ exportsField: unknown,
1562
+ ) {
1563
+ const exportsMap = asRecord(exportsField)
1564
+ if (
1565
+ exportsMap &&
1566
+ Object.keys(exportsMap).some((key) => key.startsWith('.'))
1567
+ ) {
1568
+ for (const [subpath, target] of Object.entries(exportsMap)) {
1569
+ if (
1570
+ !subpath.includes('*') &&
1571
+ !THREADLESS_WASI_ROOT_SUBPATHS.has(subpath)
1572
+ ) {
1573
+ collectPackageTargetFileReferences(files, target)
1574
+ }
1575
+ }
1576
+ return
1577
+ }
1578
+ collectPackageTargetFileReferences(files, exportsField)
1579
+ }
1580
+
1581
+ function collectPackageTargetFileReferences(
1582
+ files: Set<string>,
1583
+ target: unknown,
1584
+ ) {
1585
+ if (typeof target === 'string') {
1586
+ addPackageFileReference(files, target)
1587
+ } else if (Array.isArray(target)) {
1588
+ for (const entry of target) {
1589
+ collectPackageTargetFileReferences(files, entry)
1590
+ }
1591
+ } else if (typeof target === 'object' && target !== null) {
1592
+ for (const entry of Object.values(target)) {
1593
+ collectPackageTargetFileReferences(files, entry)
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ function addPackageFileReference(files: Set<string>, target: string) {
1599
+ if (
1600
+ !target.startsWith('./') ||
1601
+ target.includes('*') ||
1602
+ target.endsWith('/')
1603
+ ) {
1604
+ return
1605
+ }
1606
+ const file = target.slice(2).replaceAll('\\', '/')
1607
+ if (
1608
+ file.length === 0 ||
1609
+ file.split('/').some((segment) => segment === '..')
1610
+ ) {
1611
+ return
1612
+ }
1613
+ files.add(file)
1614
+ }
1615
+
1616
+ interface PackagePath {
1617
+ path: string
1618
+ directory: boolean
1619
+ }
1620
+
1621
+ function validateRootPackagePaths(
1622
+ rootDir: string,
1623
+ files: string[],
1624
+ ): PackagePath[] {
1625
+ const paths: PackagePath[] = []
1626
+ for (const file of new Set(files)) {
1627
+ const path = join(rootDir, file)
1628
+ if (!existsSync(path)) {
1629
+ throw new Error(`Root release package is incomplete: missing ${file}`)
1630
+ }
1631
+ paths.push({ path: file, directory: statSync(path).isDirectory() })
1632
+ }
1633
+ return paths
1634
+ }
1635
+
1636
+ function readNpmPackFiles(packageDir: string, packageDescription: string) {
1637
+ try {
1638
+ const output = execSync('npm pack --dry-run --json --ignore-scripts', {
1639
+ cwd: packageDir,
1640
+ encoding: 'utf8',
1641
+ maxBuffer: 64 * 1024 * 1024,
1642
+ stdio: ['ignore', 'pipe', 'pipe'],
1643
+ })
1644
+ const packResult = JSON.parse(output) as {
1645
+ files?: { path?: unknown }[]
1646
+ }[]
1647
+ if (!Array.isArray(packResult) || !Array.isArray(packResult[0]?.files)) {
1648
+ throw new Error('npm pack returned an unexpected JSON result')
1649
+ }
1650
+ return new Set(
1651
+ packResult[0].files
1652
+ .map(({ path }) => path)
1653
+ .filter((path): path is string => typeof path === 'string')
1654
+ .map((path) => path.replaceAll('\\', '/').replace(/^\.\//, '')),
1655
+ )
1656
+ } catch (error) {
1657
+ throw new Error(
1658
+ `Failed to validate the ${packageDescription} with npm pack --dry-run. Ensure npm is available and the package can be packed.`,
1659
+ { cause: error },
1660
+ )
1661
+ }
1662
+ }
1663
+
1664
+ function validateRootFacadePacklist(
1665
+ rootDir: string,
1666
+ rootPackageFiles: string[],
1667
+ ) {
1668
+ const paths = validateRootPackagePaths(rootDir, rootPackageFiles)
1669
+ const packedFiles = readNpmPackFiles(rootDir, 'threadless WASI root package')
1670
+
1671
+ const missingPaths = paths
1672
+ .filter(({ path, directory }) => {
1673
+ const normalizedPath = path.replaceAll('\\', '/')
1674
+ return directory
1675
+ ? ![...packedFiles].some((file) =>
1676
+ file.startsWith(`${normalizedPath}/`),
1677
+ )
1678
+ : !packedFiles.has(normalizedPath)
1679
+ })
1680
+ .map(({ path }) => path)
1681
+ if (missingPaths.length > 0) {
1682
+ throw new Error(
1683
+ `The threadless WASI root package references paths omitted by npm pack: ${missingPaths.join(', ')}. Add them to package.json "files" or remove the matching .npmignore rules.`,
1684
+ )
1685
+ }
1686
+ }
1687
+
1688
+ interface RootReleaseMaterializationPlan {
1689
+ packageJson: CommonPackageJsonFields
1690
+ optionalDependencies: Record<string, unknown>
1691
+ nodeEngine?: string
1692
+ facade?: ThreadlessWasiRootFacade
1693
+ staleGeneratedFiles: string[]
1694
+ }
1695
+
1696
+ function pathIsWithin(root: string, path: string) {
1697
+ const relativePath = relative(resolve(root), resolve(path))
1698
+ return (
1699
+ relativePath === '' ||
1700
+ (!isAbsolute(relativePath) &&
1701
+ relativePath !== '..' &&
1702
+ !relativePath.startsWith(`..${sep}`))
1703
+ )
1704
+ }
1705
+
1706
+ function publicationContextPath(
1707
+ workspaceRoot: string,
1708
+ contextRoot: string,
1709
+ sourcePath: string,
1710
+ ) {
1711
+ if (!pathIsWithin(contextRoot, sourcePath)) {
1712
+ throw new Error(
1713
+ `Publication context path is outside ${contextRoot}: ${sourcePath}`,
1714
+ )
1715
+ }
1716
+ return join(workspaceRoot, relative(contextRoot, sourcePath))
1717
+ }
1718
+
1719
+ async function stagePublicationContext(
1720
+ contextRoot: string,
1721
+ workspaceRoot: string,
1722
+ rootDir: string,
1723
+ releasePackagePlans: ReleasePackageMaterializationPlan[],
1724
+ ) {
1725
+ const canonicalContextRoot = realpathSync.native(contextRoot)
1726
+ const protectedDirectories = releasePackagePlans.map(({ pkgDir }) =>
1727
+ realpathSync.native(pkgDir),
1728
+ )
1729
+ const selection = await collectPublicationContextSelection(
1730
+ canonicalContextRoot,
1731
+ rootDir,
1732
+ releasePackagePlans,
1733
+ )
1734
+ await materializePublicationContextDirectory(
1735
+ canonicalContextRoot,
1736
+ workspaceRoot,
1737
+ protectedDirectories,
1738
+ [
1739
+ {
1740
+ sourceRoot: canonicalContextRoot,
1741
+ destinationRoot: workspaceRoot,
1742
+ },
1743
+ ],
1744
+ selection,
1745
+ )
1746
+ }
1747
+
1748
+ interface PublicationPathMapping {
1749
+ sourceRoot: string
1750
+ destinationRoot: string
1751
+ }
1752
+
1753
+ interface PublicationContextSelection {
1754
+ files: Set<string>
1755
+ recursiveDirectories: Set<string>
1756
+ }
1757
+
1758
+ async function collectPublicationContextSelection(
1759
+ contextRoot: string,
1760
+ rootDir: string,
1761
+ releasePackagePlans: ReleasePackageMaterializationPlan[],
1762
+ ) {
1763
+ const files = new Set<string>()
1764
+ const recursiveDirectories = new Set<string>()
1765
+ const structuralDirectories = new Set<string>()
1766
+ for (const leaf of [
1767
+ rootDir,
1768
+ ...releasePackagePlans.map(({ pkgDir }) => pkgDir),
1769
+ ]) {
1770
+ let current = resolve(leaf)
1771
+ while (pathIsWithin(contextRoot, current)) {
1772
+ structuralDirectories.add(current)
1773
+ for (const name of PUBLICATION_CONTEXT_FILES) {
1774
+ const path = join(current, name)
1775
+ if (existsSync(path)) files.add(path)
1776
+ }
1777
+ if (current === contextRoot) break
1778
+ current = dirname(current)
1779
+ }
1780
+ }
1781
+
1782
+ const yarnContext = join(contextRoot, '.yarn')
1783
+ if (existsSync(yarnContext)) recursiveDirectories.add(yarnContext)
1784
+
1785
+ let hasLifecycleScripts = false
1786
+ for (const plan of releasePackagePlans) {
1787
+ if (!plan.stagedPkgDir) continue
1788
+ const manifest = JSON.parse(
1789
+ await readFileAsync(join(plan.stagedPkgDir, 'package.json'), 'utf8'),
1790
+ ) as ReleasePackageManifest
1791
+ const scripts = asRecord(manifest.scripts)
1792
+ if (!scripts) continue
1793
+ for (const script of publicationLifecycleScriptCommands(scripts)) {
1794
+ hasLifecycleScripts = true
1795
+ for (const reference of relativeLifecycleScriptPaths(script)) {
1796
+ const path = resolve(plan.pkgDir, reference)
1797
+ if (!pathIsWithin(contextRoot, path)) {
1798
+ throw new Error(
1799
+ `Release package lifecycle script path escapes ${contextRoot}: ${reference}`,
1800
+ )
1801
+ }
1802
+ if (!existsSync(path) || pathIsWithin(plan.pkgDir, path)) continue
1803
+ const stats = statSync(path)
1804
+ if (stats.isDirectory()) {
1805
+ recursiveDirectories.add(path)
1806
+ } else if (stats.isFile()) {
1807
+ files.add(path)
1808
+ const parent = dirname(path)
1809
+ if (parent !== contextRoot) recursiveDirectories.add(parent)
1810
+ }
1811
+ }
1812
+ }
1813
+ }
1814
+ if (hasLifecycleScripts) {
1815
+ for (const directory of structuralDirectories) {
1816
+ const nodeModules = join(directory, 'node_modules')
1817
+ if (existsSync(nodeModules)) recursiveDirectories.add(nodeModules)
1818
+ }
1819
+ }
1820
+
1821
+ for (const directory of structuralDirectories) {
1822
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
1823
+ if (!entry.isSymbolicLink()) continue
1824
+ const path = join(directory, entry.name)
1825
+ let target: string
1826
+ try {
1827
+ target = realpathSync.native(path)
1828
+ } catch {
1829
+ continue
1830
+ }
1831
+ if (
1832
+ [...recursiveDirectories].some((selectedDirectory) =>
1833
+ pathIsWithin(selectedDirectory, target),
1834
+ )
1835
+ ) {
1836
+ files.add(path)
1837
+ }
1838
+ }
1839
+ }
1840
+
1841
+ return { files, recursiveDirectories }
1842
+ }
1843
+
1844
+ function stagedPublicationWorkspacePatterns(
1845
+ workspaces: unknown[],
1846
+ releaseWorkspaces: string[],
1847
+ ) {
1848
+ // Yarn applies negative workspace patterns after positive matches regardless
1849
+ // of order. The staged tree is already selectively materialized, so retain
1850
+ // positive source patterns and add exact release package paths.
1851
+ return [
1852
+ ...new Set([
1853
+ ...workspaces.filter(
1854
+ (workspace) =>
1855
+ typeof workspace !== 'string' || !workspace.startsWith('!'),
1856
+ ),
1857
+ ...releaseWorkspaces,
1858
+ ]),
1859
+ ]
1860
+ }
1861
+
1862
+ function publicationLifecycleScriptCommands(scripts: Record<string, unknown>) {
1863
+ const commands: string[] = []
1864
+ const pending = [...PUBLICATION_LIFECYCLE_SCRIPTS]
1865
+ const visited = new Set<string>()
1866
+ while (pending.length > 0) {
1867
+ const name = pending.shift()!
1868
+ if (visited.has(name)) continue
1869
+ visited.add(name)
1870
+ const command = scripts[name]
1871
+ if (typeof command !== 'string') continue
1872
+ commands.push(command)
1873
+ for (const referencedScript of lifecycleRunScriptReferences(command)) {
1874
+ if (!visited.has(referencedScript)) pending.push(referencedScript)
1875
+ }
1876
+ }
1877
+ return commands
1878
+ }
1879
+
1880
+ function lifecycleRunScriptReferences(script: string) {
1881
+ const references = new Set<string>()
1882
+ const pattern =
1883
+ /\b(?:npm|pnpm|bun)\s+run(?:-script)?\s+([^\s;&|]+)|\byarn\s+(?:run\s+)?([^\s;&|]+)/gu
1884
+ for (const match of script.matchAll(pattern)) {
1885
+ references.add(match[1] ?? match[2])
1886
+ }
1887
+ return references
1888
+ }
1889
+
1890
+ function relativeLifecycleScriptPaths(script: string) {
1891
+ const paths = new Set<string>()
1892
+ const pattern = /(?:^|[\s"'`=,(])((?:\.\.?[\\/])[^ \t\r\n"'`;&|<>),]+)/gu
1893
+ for (const match of script.matchAll(pattern)) {
1894
+ paths.add(match[1])
1895
+ }
1896
+ return paths
1897
+ }
1898
+
1899
+ function publicationContextEntryIsSelected(
1900
+ path: string,
1901
+ directory: boolean,
1902
+ selection?: PublicationContextSelection,
1903
+ ) {
1904
+ if (!selection) return true
1905
+ if (selection.files.has(path)) return true
1906
+ if (
1907
+ [...selection.recursiveDirectories].some((root) => pathIsWithin(root, path))
1908
+ ) {
1909
+ return true
1910
+ }
1911
+ if (!directory) return false
1912
+ return (
1913
+ [...selection.recursiveDirectories].some((root) =>
1914
+ pathIsWithin(path, root),
1915
+ ) || [...selection.files].some((file) => pathIsWithin(path, file))
1916
+ )
1917
+ }
1918
+
1919
+ async function materializePublicationContextDirectory(
1920
+ sourceDir: string,
1921
+ destinationDir: string,
1922
+ protectedDirectories: string[],
1923
+ mappings: PublicationPathMapping[],
1924
+ selection?: PublicationContextSelection,
1925
+ ) {
1926
+ if (
1927
+ protectedDirectories.some(
1928
+ (protectedDirectory) => relative(protectedDirectory, sourceDir) === '',
1929
+ )
1930
+ ) {
1931
+ return
1932
+ }
1933
+ await mkdirAsync(destinationDir, { recursive: true })
1934
+ for (const entry of await readdir(sourceDir, { withFileTypes: true })) {
1935
+ if (
1936
+ PUBLICATION_CONTEXT_EXCLUDED_NAMES.has(entry.name) ||
1937
+ RECONCILIATION_STATE_PREFIXES.some((prefix) =>
1938
+ entry.name.startsWith(prefix),
1939
+ )
1940
+ ) {
1941
+ continue
1942
+ }
1943
+ const source = join(sourceDir, entry.name)
1944
+ const destination = join(destinationDir, entry.name)
1945
+ if (
1946
+ !publicationContextEntryIsSelected(source, entry.isDirectory(), selection)
1947
+ ) {
1948
+ continue
1949
+ }
1950
+ const protectedDescendant = protectedDirectories.some(
1951
+ (protectedDirectory) =>
1952
+ pathIsWithin(source, protectedDirectory) &&
1953
+ relative(source, protectedDirectory) !== '',
1954
+ )
1955
+ if (existsSync(destination)) {
1956
+ if (entry.isDirectory()) {
1957
+ await materializePublicationContextDirectory(
1958
+ source,
1959
+ destination,
1960
+ protectedDirectories,
1961
+ mappings,
1962
+ selection,
1963
+ )
1964
+ }
1965
+ continue
1966
+ }
1967
+ if (protectedDescendant) {
1968
+ await materializePublicationContextDirectory(
1969
+ source,
1970
+ destination,
1971
+ protectedDirectories,
1972
+ mappings,
1973
+ selection,
1974
+ )
1975
+ } else if (entry.isDirectory()) {
1976
+ await materializePublicationContextDirectory(
1977
+ source,
1978
+ destination,
1979
+ protectedDirectories,
1980
+ mappings,
1981
+ selection,
1982
+ )
1983
+ } else if (entry.isSymbolicLink()) {
1984
+ await materializePublicationContextSymlink(
1985
+ source,
1986
+ destination,
1987
+ protectedDirectories,
1988
+ mappings,
1989
+ )
1990
+ } else if (entry.isFile()) {
1991
+ await copyFile(source, destination, constants.COPYFILE_FICLONE)
1992
+ } else {
1993
+ throw new Error(`Unsupported publication context entry: ${source}`)
1994
+ }
1995
+ }
1996
+ }
1997
+
1998
+ async function materializePublicationContextSymlink(
1999
+ source: string,
2000
+ destination: string,
2001
+ protectedDirectories: string[],
2002
+ mappings: PublicationPathMapping[],
2003
+ ) {
2004
+ const canonicalTarget = realpathSync.native(source)
2005
+ const targetStats = statSync(source)
2006
+ const mapping = mappings
2007
+ .filter(({ sourceRoot }) => pathIsWithin(sourceRoot, canonicalTarget))
2008
+ .sort((left, right) => right.sourceRoot.length - left.sourceRoot.length)[0]
2009
+ if (mapping) {
2010
+ const mappedTarget = join(
2011
+ mapping.destinationRoot,
2012
+ relative(mapping.sourceRoot, canonicalTarget),
2013
+ )
2014
+ if (resolve(mappedTarget) === resolve(destination)) {
2015
+ throw new Error(`Publication context contains a cyclic link: ${source}`)
2016
+ }
2017
+ await mkdirAsync(dirname(destination), { recursive: true })
2018
+ const linkTarget =
2019
+ process.platform === 'win32' && targetStats.isDirectory()
2020
+ ? mappedTarget
2021
+ : relative(dirname(destination), mappedTarget) || '.'
2022
+ await symlink(
2023
+ linkTarget,
2024
+ destination,
2025
+ targetStats.isDirectory() ? 'junction' : 'file',
2026
+ )
2027
+ return
2028
+ }
2029
+
2030
+ if (
2031
+ mappings.some(({ sourceRoot }) => pathIsWithin(canonicalTarget, sourceRoot))
2032
+ ) {
2033
+ throw new Error(
2034
+ `Publication context link expands above its materialized root: ${source}`,
2035
+ )
2036
+ }
2037
+ if (targetStats.isDirectory()) {
2038
+ await materializePublicationContextDirectory(
2039
+ canonicalTarget,
2040
+ destination,
2041
+ protectedDirectories,
2042
+ [
2043
+ ...mappings,
2044
+ {
2045
+ sourceRoot: canonicalTarget,
2046
+ destinationRoot: destination,
2047
+ },
2048
+ ],
2049
+ undefined,
2050
+ )
2051
+ } else if (targetStats.isFile()) {
2052
+ await copyFile(canonicalTarget, destination, constants.COPYFILE_FICLONE)
2053
+ } else {
2054
+ throw new Error(`Unsupported publication context link target: ${source}`)
2055
+ }
2056
+ }
2057
+
2058
+ async function registerPublicationWorkspaces(
2059
+ contextRoot: string,
2060
+ workspaceRoot: string,
2061
+ packageDirs: string[],
2062
+ ) {
2063
+ const manifestPath = join(workspaceRoot, 'package.json')
2064
+ let manifest: Record<string, unknown> = { private: true }
2065
+ if (existsSync(manifestPath)) {
2066
+ try {
2067
+ manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8'))
2068
+ } catch (error) {
2069
+ throw new Error(
2070
+ `Failed to read staged workspace manifest ${manifestPath}`,
2071
+ { cause: error },
2072
+ )
2073
+ }
2074
+ }
2075
+
2076
+ const releaseWorkspaces = packageDirs.map((packageDir) =>
2077
+ relative(contextRoot, packageDir).split(sep).join('/'),
2078
+ )
2079
+ const workspaces = manifest.workspaces
2080
+ if (Array.isArray(workspaces)) {
2081
+ manifest.workspaces = stagedPublicationWorkspacePatterns(
2082
+ workspaces,
2083
+ releaseWorkspaces,
2084
+ )
2085
+ } else if (
2086
+ typeof workspaces === 'object' &&
2087
+ workspaces !== null &&
2088
+ Array.isArray((workspaces as { packages?: unknown }).packages)
2089
+ ) {
2090
+ manifest.workspaces = {
2091
+ ...workspaces,
2092
+ packages: stagedPublicationWorkspacePatterns(
2093
+ (workspaces as { packages: unknown[] }).packages,
2094
+ releaseWorkspaces,
2095
+ ),
2096
+ }
2097
+ } else {
2098
+ manifest.workspaces = releaseWorkspaces
2099
+ }
2100
+ await writeFileAtomic(manifestPath, JSON.stringify(manifest, null, 2))
2101
+ }
2102
+
2103
+ async function createPublicationExecutionPackage(
2104
+ workspaceRoot: string,
2105
+ packageDir: string,
2106
+ ) {
2107
+ if (!pathIsWithin(workspaceRoot, packageDir)) {
2108
+ throw new Error(
2109
+ `Prepared release package is outside its publication workspace: ${packageDir}`,
2110
+ )
2111
+ }
2112
+ const root = await mkdtemp(join(tmpdir(), 'napi-rs-pre-publish-execution-'))
2113
+ const executionWorkspaceRoot = join(root, 'workspace')
2114
+ try {
2115
+ const canonicalWorkspaceRoot = realpathSync.native(workspaceRoot)
2116
+ await materializePublicationContextDirectory(
2117
+ canonicalWorkspaceRoot,
2118
+ executionWorkspaceRoot,
2119
+ [],
2120
+ [
2121
+ {
2122
+ sourceRoot: canonicalWorkspaceRoot,
2123
+ destinationRoot: executionWorkspaceRoot,
2124
+ },
2125
+ ],
2126
+ )
2127
+ } catch (error) {
2128
+ await removePreparedSnapshot(root, 'preparation', true, error)
2129
+ }
2130
+ return {
2131
+ packageDir: join(
2132
+ executionWorkspaceRoot,
2133
+ relative(workspaceRoot, packageDir),
2134
+ ),
2135
+ root,
2136
+ }
2137
+ }
2138
+
2139
+ async function validateRootReleasePlan(
2140
+ packageJsonPath: string,
2141
+ rootDir: string,
2142
+ plan: RootReleaseMaterializationPlan,
2143
+ ) {
2144
+ const stagingRoot = await mkdtemp(
2145
+ join(tmpdir(), 'napi-rs-pre-publish-root-validation-'),
2146
+ )
2147
+ const stagedRootDir = join(stagingRoot, 'package')
2148
+ try {
2149
+ await stageRootReleasePlan(packageJsonPath, rootDir, stagedRootDir, plan)
2150
+ } finally {
2151
+ await removeOwnedTemporaryTree(stagingRoot)
2152
+ }
2153
+ }
2154
+
2155
+ async function stageRootReleasePlan(
2156
+ packageJsonPath: string,
2157
+ rootDir: string,
2158
+ stagedRootDir: string,
2159
+ plan: RootReleaseMaterializationPlan,
2160
+ ) {
2161
+ await mkdirAsync(stagedRootDir, { recursive: true })
2162
+ await cp(packageJsonPath, join(stagedRootDir, 'package.json'))
2163
+ if (plan.facade) {
2164
+ const packedFiles = readNpmPackFiles(rootDir, 'root package')
2165
+ const referencedFiles = collectRootPackagePathReferences(plan.packageJson)
2166
+ for (const file of new Set([
2167
+ ...packedFiles,
2168
+ ...referencedFiles,
2169
+ '.npmignore',
2170
+ '.gitignore',
2171
+ '.npmrc',
2172
+ ])) {
2173
+ if (file === 'package.json') {
2174
+ continue
2175
+ }
2176
+ const source = join(rootDir, file)
2177
+ if (!existsSync(source)) {
2178
+ continue
2179
+ }
2180
+ const destination = join(stagedRootDir, file)
2181
+ await mkdirAsync(dirname(destination), { recursive: true })
2182
+ await copyOwnedTemporaryPath(source, destination)
2183
+ }
2184
+ }
2185
+ await materializeRootReleasePlan(stagedRootDir, plan)
2186
+ if (plan.facade) {
2187
+ validateRootFacadePacklist(stagedRootDir, [
2188
+ ...plan.facade.generatedFiles,
2189
+ ...collectRootPackagePathReferences(plan.packageJson),
2190
+ ])
2191
+ }
2192
+ }
2193
+
2194
+ async function materializeRootReleasePlan(
2195
+ rootDir: string,
2196
+ plan: RootReleaseMaterializationPlan,
2197
+ ) {
2198
+ if (plan.facade) {
2199
+ await materializeThreadlessWasiRootFacade(rootDir, plan.facade)
2200
+ }
2201
+
2202
+ const packageJsonPath = join(rootDir, 'package.json')
2203
+ const updatedPackageJson = JSON.parse(
2204
+ await readFileAsync(packageJsonPath, 'utf8'),
2205
+ )
2206
+ updatedPackageJson.optionalDependencies = plan.optionalDependencies
2207
+ if (plan.nodeEngine !== undefined) {
2208
+ updatedPackageJson.engines = {
2209
+ ...asRecord(updatedPackageJson.engines),
2210
+ node: plan.nodeEngine,
2211
+ }
2212
+ }
2213
+ syncThreadlessWasiRootFacadeManifest(updatedPackageJson, plan.packageJson)
2214
+ await writeFileAtomic(
2215
+ packageJsonPath,
2216
+ JSON.stringify(updatedPackageJson, null, 2),
2217
+ )
2218
+
2219
+ const generatedFiles = new Set(plan.facade?.generatedFiles ?? [])
2220
+ await Promise.all(
2221
+ plan.staleGeneratedFiles
2222
+ .filter((file) => !generatedFiles.has(file))
2223
+ .map((file) => rm(join(rootDir, file), { force: true })),
2224
+ )
2225
+ }
2226
+
2227
+ interface ReleasePackageValidationOptions {
2228
+ pkgDir: string
2229
+ rootDir: string
2230
+ packageName: string
2231
+ binaryName: string
2232
+ target: Target
2233
+ requireDirectBufferDependency: boolean
2234
+ }
2235
+
2236
+ interface ReleasePackageMaterializationPlan {
2237
+ target: Target
2238
+ pkgDir: string
2239
+ rootDir: string
2240
+ stagedPkgDir?: string
2241
+ packageFiles: string[]
2242
+ declarationDependencies: string[]
2243
+ updateManifest: boolean
2244
+ }
2245
+
2246
+ interface ReleasePackageManifest {
2247
+ name?: string
2248
+ type?: unknown
2249
+ cpu?: unknown
2250
+ os?: unknown
2251
+ main?: unknown
2252
+ types?: unknown
2253
+ browser?: unknown
2254
+ files?: unknown
2255
+ exports?: unknown
2256
+ dependencies?: unknown
2257
+ scripts?: unknown
2258
+ }
2259
+
2260
+ async function validateReleasePackage({
2261
+ pkgDir,
2262
+ ...options
2263
+ }: ReleasePackageValidationOptions): Promise<ReleasePackageMaterializationPlan> {
2264
+ assertReleasePackageTree(pkgDir)
2265
+ const packageJsonPath = join(pkgDir, 'package.json')
2266
+ if (!existsSync(packageJsonPath)) {
2267
+ throw new Error(
2268
+ `Release package manifest does not exist: ${packageJsonPath}`,
2269
+ )
2270
+ }
2271
+
2272
+ const stagingRoot = await mkdtemp(
2273
+ join(tmpdir(), 'napi-rs-pre-publish-validation-'),
2274
+ )
2275
+ const stagedPkgDir = join(stagingRoot, 'package')
2276
+ try {
2277
+ await copyOwnedTemporaryPath(pkgDir, stagedPkgDir)
2278
+ assertReleasePackageTree(stagedPkgDir)
2279
+ const validation = await validateReleasePackageContents({
2280
+ ...options,
2281
+ pkgDir: stagedPkgDir,
2282
+ })
2283
+ return {
2284
+ pkgDir,
2285
+ rootDir: options.rootDir,
2286
+ target: options.target,
2287
+ ...validation,
2288
+ }
2289
+ } finally {
2290
+ await removeOwnedTemporaryTree(stagingRoot)
2291
+ }
2292
+ }
2293
+
2294
+ async function stageReleasePackage(
2295
+ workspaceRoot: string,
2296
+ contextRoot: string,
2297
+ packageVersion: string,
2298
+ options: ReleasePackageValidationOptions,
2299
+ ) {
2300
+ assertReleasePackageTree(options.pkgDir)
2301
+ const stagedPkgDir = publicationContextPath(
2302
+ workspaceRoot,
2303
+ contextRoot,
2304
+ options.pkgDir,
2305
+ )
2306
+ await copyOwnedTemporaryPath(options.pkgDir, stagedPkgDir)
2307
+ assertReleasePackageTree(stagedPkgDir)
2308
+ const validation = await validateReleasePackageContents({
2309
+ ...options,
2310
+ pkgDir: stagedPkgDir,
2311
+ })
2312
+ const packageJsonPath = join(stagedPkgDir, 'package.json')
2313
+ const packageJson = JSON.parse(await readFileAsync(packageJsonPath, 'utf8'))
2314
+ packageJson.version = packageVersion
2315
+ await writeFileAtomic(packageJsonPath, JSON.stringify(packageJson, null, 2))
2316
+ return {
2317
+ pkgDir: options.pkgDir,
2318
+ rootDir: options.rootDir,
2319
+ stagedPkgDir,
2320
+ target: options.target,
2321
+ ...validation,
2322
+ }
2323
+ }
2324
+
2325
+ function prepareReleasePackage(
2326
+ plan: ReleasePackageMaterializationPlan,
2327
+ binaryName: string,
2328
+ ): PreparedReleasePackage {
2329
+ const packageDir = plan.stagedPkgDir ?? plan.pkgDir
2330
+ const artifactExtension =
2331
+ plan.target.platform === 'wasi' || plan.target.platform === 'wasm'
2332
+ ? 'wasm'
2333
+ : 'node'
2334
+ const filename = `${binaryName}.${plan.target.platformArchABI}.${artifactExtension}`
2335
+ return {
2336
+ artifactPath: join(packageDir, filename),
2337
+ filename,
2338
+ packageDir,
2339
+ }
2340
+ }
2341
+
2342
+ function assertReleasePackageTree(pkgDir: string) {
2343
+ let stats
2344
+ try {
2345
+ stats = lstatSync(pkgDir)
2346
+ } catch (error) {
2347
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
2348
+ throw new Error(`Release package directory does not exist: ${pkgDir}`, {
2349
+ cause: error,
2350
+ })
2351
+ }
2352
+ throw error
2353
+ }
2354
+ if (stats.isSymbolicLink()) {
2355
+ throw new Error(
2356
+ `Release package directory must not be a symbolic link: ${pkgDir}`,
2357
+ )
2358
+ }
2359
+ if (!stats.isDirectory()) {
2360
+ throw new Error(`Release package path is not a directory: ${pkgDir}`)
2361
+ }
2362
+ const canonicalRoot = realpathSync.native(pkgDir)
2363
+ const visit = (path: string) => {
2364
+ const pathStats = lstatSync(path)
2365
+ if (pathStats.isSymbolicLink()) {
2366
+ throw new Error(
2367
+ `Release package contents must not contain symbolic links: ${path}`,
2368
+ )
2369
+ }
2370
+ const canonicalPath = realpathSync.native(path)
2371
+ if (!pathIsWithin(canonicalRoot, canonicalPath)) {
2372
+ throw new Error(
2373
+ `Release package path escapes ${canonicalRoot}: ${canonicalPath}`,
2374
+ )
2375
+ }
2376
+ if (pathStats.isDirectory()) {
2377
+ for (const entry of readdirSync(path)) {
2378
+ visit(join(path, entry))
2379
+ }
2380
+ }
2381
+ }
2382
+ visit(pkgDir)
2383
+ }
2384
+
2385
+ interface CommitPrePublishFileSystemTransactionOptions {
2386
+ packageJsonPath: string
2387
+ releasePackagePlans: ReleasePackageMaterializationPlan[]
2388
+ rootDir: string
2389
+ rootReleasePlan: RootReleaseMaterializationPlan
2390
+ stagedRootManifestPath?: string
2391
+ stagedRootDir: string
2392
+ transactionRoot?: string
2393
+ }
2394
+
2395
+ export async function commitPrePublishFileSystemTransaction({
2396
+ packageJsonPath,
2397
+ releasePackagePlans,
2398
+ rootDir,
2399
+ rootReleasePlan,
2400
+ stagedRootDir,
2401
+ stagedRootManifestPath = join(stagedRootDir, 'package.json'),
2402
+ transactionRoot = rootDir,
2403
+ }: CommitPrePublishFileSystemTransactionOptions) {
2404
+ const writes: FileSystemTransactionWrite[] = []
2405
+ for (const plan of releasePackagePlans) {
2406
+ if (!plan.stagedPkgDir) {
2407
+ throw new Error(
2408
+ `Release package ${plan.target.platformArchABI} was not staged`,
2409
+ )
2410
+ }
2411
+ for (const declarationFile of plan.declarationDependencies) {
2412
+ writes.push({
2413
+ destination: join(plan.pkgDir, declarationFile),
2414
+ source: join(plan.stagedPkgDir, declarationFile),
2415
+ })
2416
+ }
2417
+ writes.push({
2418
+ destination: join(plan.pkgDir, 'package.json'),
2419
+ source: join(plan.stagedPkgDir, 'package.json'),
2420
+ })
2421
+ }
2422
+
2423
+ for (const generatedFile of rootReleasePlan.facade?.generatedFiles ?? []) {
2424
+ writes.push({
2425
+ destination: join(rootDir, generatedFile),
2426
+ source: join(stagedRootDir, generatedFile),
2427
+ })
2428
+ }
2429
+ // Keep the root manifest last so every package and facade mutation is
2430
+ // rolled back if the final publication metadata cannot be committed.
2431
+ writes.push({
2432
+ destination: packageJsonPath,
2433
+ source: stagedRootManifestPath,
2434
+ })
2435
+
2436
+ const generatedFiles = new Set(rootReleasePlan.facade?.generatedFiles ?? [])
2437
+ const removals = rootReleasePlan.staleGeneratedFiles
2438
+ .filter((file) => !generatedFiles.has(file))
2439
+ .map((file) => join(rootDir, file))
2440
+ await commitFileSystemTransaction(transactionRoot, writes, removals)
2441
+ }
2442
+
2443
+ async function validateReleasePackageContents({
2444
+ pkgDir,
2445
+ rootDir,
2446
+ packageName,
2447
+ binaryName,
2448
+ target,
2449
+ requireDirectBufferDependency,
2450
+ }: ReleasePackageValidationOptions) {
2451
+ const packageJsonPath = join(pkgDir, 'package.json')
2452
+ if (!existsSync(packageJsonPath)) {
2453
+ throw new Error(
2454
+ `Release package manifest does not exist: ${packageJsonPath}`,
2455
+ )
2456
+ }
2457
+
2458
+ let packageJson: ReleasePackageManifest
2459
+ try {
2460
+ packageJson = JSON.parse(await readFileAsync(packageJsonPath, 'utf8'))
2461
+ } catch (error) {
2462
+ throw new Error(`Failed to read release package ${packageJsonPath}`, {
2463
+ cause: error,
2464
+ })
2465
+ }
2466
+
2467
+ const expectedPackageName = `${packageName}-${target.platformArchABI}`
2468
+ if (packageJson.name !== expectedPackageName) {
2469
+ throw new Error(
2470
+ `Release package ${pkgDir} has stale package name ${String(packageJson.name)}; expected ${expectedPackageName}`,
2471
+ )
2472
+ }
2473
+
2474
+ if (
2475
+ !Array.isArray(packageJson.files) ||
2476
+ !packageJson.files.every((file): file is string => typeof file === 'string')
2477
+ ) {
2478
+ throw new Error(
2479
+ `Release package ${expectedPackageName} must declare a string files array`,
2480
+ )
2481
+ }
2482
+ const packageFiles = [...new Set(packageJson.files)]
2483
+ for (const file of packageFiles) {
2484
+ resolveReleasePackageContentPath(pkgDir, file, expectedPackageName)
2485
+ }
2486
+ const packagedRuntimeImports =
2487
+ target.arch === 'wasm32'
2488
+ ? await releasePackageRuntimeImports(
2489
+ pkgDir,
2490
+ expectedPackageName,
2491
+ packageFiles,
2492
+ )
2493
+ : new Set<string>()
2494
+ const packagedLoaderImportsBuffer = packagedRuntimeImports.has('buffer')
2495
+
2496
+ validateExpectedReleasePackageManifest(
2497
+ packageJson,
2498
+ packageFiles,
2499
+ binaryName,
2500
+ target,
2501
+ requireDirectBufferDependency || packagedLoaderImportsBuffer,
2502
+ packagedRuntimeImports,
2503
+ )
2504
+
2505
+ for (const file of packageFiles) {
2506
+ const path = resolveReleasePackageContentPath(
2507
+ pkgDir,
2508
+ file,
2509
+ expectedPackageName,
2510
+ )
2511
+ if (!existsSync(path) || !lstatSync(path).isFile()) {
2512
+ throw new Error(
2513
+ `Release package ${expectedPackageName} is incomplete: missing ${file}`,
2514
+ )
2515
+ }
2516
+ }
2517
+
2518
+ const declarationClosure = await completeDeclarationDependencyClosure({
2519
+ pkgDir,
2520
+ rootDir,
2521
+ packageName: expectedPackageName,
2522
+ packageFiles,
2523
+ })
2524
+ const updateManifest = declarationClosure.files.length > packageFiles.length
2525
+ if (updateManifest) {
2526
+ packageFiles.splice(0, packageFiles.length, ...declarationClosure.files)
2527
+ packageJson.files = packageFiles
2528
+ await writeFileAtomic(
2529
+ packageJsonPath,
2530
+ `${JSON.stringify(packageJson, null, 2)}\n`,
2531
+ )
2532
+ }
2533
+
2534
+ const publicFiles = new Set<string>(packageFiles)
2535
+ addLegacyPackageFileReference(publicFiles, packageJson.main)
2536
+ addLegacyPackageFileReference(publicFiles, packageJson.types)
2537
+ addLegacyPackageFileReference(publicFiles, packageJson.browser)
2538
+ collectPackageTargetFileReferences(publicFiles, packageJson.exports)
2539
+ publicFiles.add('package.json')
2540
+
2541
+ const packedFiles = readNpmPackFiles(
2542
+ pkgDir,
2543
+ `release package ${expectedPackageName}`,
2544
+ )
2545
+ const omittedFiles = [...publicFiles].filter(
2546
+ (file) => !packedFiles.has(file.replaceAll('\\', '/')),
2547
+ )
2548
+ if (omittedFiles.length > 0) {
2549
+ throw new Error(
2550
+ `Release package ${expectedPackageName} references files omitted by npm pack: ${omittedFiles.join(', ')}`,
2551
+ )
2552
+ }
2553
+ return {
2554
+ packageFiles,
2555
+ declarationDependencies: declarationClosure.materializedFiles,
2556
+ updateManifest,
2557
+ }
2558
+ }
2559
+
2560
+ async function releasePackageRuntimeImports(
2561
+ pkgDir: string,
2562
+ packageName: string,
2563
+ packageFiles: string[],
2564
+ ) {
2565
+ const runtimeImports = new Set<string>()
2566
+ for (const file of packageFiles) {
2567
+ if (!/\.(?:cjs|mjs|js)$/.test(file)) {
2568
+ continue
2569
+ }
2570
+ const path = resolveReleasePackageContentPath(pkgDir, file, packageName)
2571
+ if (!existsSync(path) || !lstatSync(path).isFile()) {
2572
+ continue
2573
+ }
2574
+ const source = await readFileAsync(path, 'utf8')
2575
+ for (const specifier of staticModuleSpecifiers(source, path)) {
2576
+ const dependency = wasiRuntimeDependencies.find(
2577
+ (candidate) =>
2578
+ specifier === candidate || specifier.startsWith(`${candidate}/`),
2579
+ )
2580
+ if (dependency) runtimeImports.add(dependency)
2581
+ }
2582
+ }
2583
+ return runtimeImports
2584
+ }
2585
+
2586
+ function resolveReleasePackageContentPath(
2587
+ pkgDir: string,
2588
+ file: string,
2589
+ packageName: string,
2590
+ ) {
2591
+ const normalizedFile = file.replaceAll('\\', '/')
2592
+ if (
2593
+ normalizedFile.length === 0 ||
2594
+ isAbsolute(file) ||
2595
+ normalizedFile.split('/').some((segment) => segment === '..')
2596
+ ) {
2597
+ throw new Error(
2598
+ `Release package ${packageName} path escapes its package directory: ${file}`,
2599
+ )
2600
+ }
2601
+ const path = resolve(pkgDir, file)
2602
+ if (!pathIsWithin(pkgDir, path)) {
2603
+ throw new Error(
2604
+ `Release package ${packageName} path escapes its package directory: ${file}`,
2605
+ )
2606
+ }
2607
+ return path
2608
+ }
2609
+
2610
+ function staticModuleSpecifiers(source: string, path: string) {
2611
+ const typescript = loadTypeScript()
2612
+ const sourceFile = typescript.createSourceFile(
2613
+ path,
2614
+ source,
2615
+ typescript.ScriptTarget.Latest,
2616
+ true,
2617
+ typescript.ScriptKind.JS,
2618
+ )
2619
+ const specifiers = new Set<string>()
2620
+ const addSpecifier = (value: unknown) => {
2621
+ if (typeof value === 'string') specifiers.add(value)
2622
+ }
2623
+ const visit = (node: import('typescript').Node) => {
2624
+ if (
2625
+ (typescript.isImportDeclaration(node) ||
2626
+ typescript.isExportDeclaration(node)) &&
2627
+ node.moduleSpecifier &&
2628
+ typescript.isStringLiteralLike(node.moduleSpecifier)
2629
+ ) {
2630
+ addSpecifier(node.moduleSpecifier.text)
2631
+ } else if (
2632
+ typescript.isImportEqualsDeclaration(node) &&
2633
+ typescript.isExternalModuleReference(node.moduleReference) &&
2634
+ node.moduleReference.expression &&
2635
+ typescript.isStringLiteralLike(node.moduleReference.expression)
2636
+ ) {
2637
+ addSpecifier(node.moduleReference.expression.text)
2638
+ } else if (
2639
+ typescript.isCallExpression(node) &&
2640
+ node.arguments.length === 1 &&
2641
+ typescript.isStringLiteralLike(node.arguments[0]) &&
2642
+ (node.expression.kind === typescript.SyntaxKind.ImportKeyword ||
2643
+ (typescript.isIdentifier(node.expression) &&
2644
+ node.expression.text === 'require') ||
2645
+ (typescript.isPropertyAccessExpression(node.expression) &&
2646
+ typescript.isIdentifier(node.expression.expression) &&
2647
+ node.expression.expression.text === 'require' &&
2648
+ node.expression.name.text === 'resolve'))
2649
+ ) {
2650
+ addSpecifier(node.arguments[0].text)
2651
+ }
2652
+ typescript.forEachChild(node, visit)
2653
+ }
2654
+ visit(sourceFile)
2655
+ return specifiers
2656
+ }
2657
+
2658
+ function loadTypeScript(): TypeScriptModule {
2659
+ loadedTypeScript ??= require('typescript') as TypeScriptModule
2660
+ return loadedTypeScript
2661
+ }
2662
+
2663
+ function validateExpectedReleasePackageManifest(
2664
+ packageJson: ReleasePackageManifest,
2665
+ packageFiles: string[],
2666
+ binaryName: string,
2667
+ target: Target,
2668
+ requireDirectBufferDependency: boolean,
2669
+ packagedRuntimeImports: Set<string>,
2670
+ ) {
2671
+ const isWasm = target.arch === 'wasm32'
2672
+ const artifactExtension = isWasm ? 'wasm' : 'node'
2673
+ const artifact = `${binaryName}.${target.platformArchABI}.${artifactExtension}`
2674
+ const expectedFiles = [artifact]
2675
+ const expectedMain = isWasm
2676
+ ? `${binaryName}.${wasiLoaderSuffix(target.platformArchABI)}.cjs`
2677
+ : artifact
2678
+
2679
+ if (packageJson.main !== expectedMain) {
2680
+ throw new Error(
2681
+ `Release package ${packageJson.name} has stale main entry ${String(packageJson.main)}; expected ${expectedMain}`,
2682
+ )
2683
+ }
2684
+
2685
+ if (isWasm) {
2686
+ validateWasiReleasePackageManifest(
2687
+ packageJson,
2688
+ requireDirectBufferDependency,
2689
+ packagedRuntimeImports,
2690
+ )
2691
+ const loaderSuffix = wasiLoaderSuffix(target.platformArchABI)
2692
+ const expectedTypes = `${binaryName}.${loaderSuffix}.d.cts`
2693
+ const expectedBrowser = `${binaryName}.${loaderSuffix}-browser.js`
2694
+ expectedFiles.push(expectedMain, expectedTypes, expectedBrowser)
2695
+ if (packageJson.types !== expectedTypes) {
2696
+ throw new Error(
2697
+ `Release package ${packageJson.name} has stale types entry ${String(packageJson.types)}; expected ${expectedTypes}`,
2698
+ )
2699
+ }
2700
+ if (packageJson.browser !== expectedBrowser) {
2701
+ throw new Error(
2702
+ `Release package ${packageJson.name} has stale browser entry ${String(packageJson.browser)}; expected ${expectedBrowser}`,
2703
+ )
2704
+ }
2705
+ if (wasiTargetHasThreads(target)) {
2706
+ expectedFiles.push('wasi-worker.mjs', 'wasi-worker-browser.mjs')
2707
+ if (packageJson.exports !== undefined) {
2708
+ throw new Error(
2709
+ `Release package ${packageJson.name} must omit exports for its threaded WASI legacy entries`,
2710
+ )
2711
+ }
2712
+ } else {
2713
+ const deferredEntry = `${binaryName}.${loaderSuffix}-deferred.js`
2714
+ const deferredTypeDef = `${binaryName}.${loaderSuffix}-deferred.d.ts`
2715
+ const wasmTypeDef = `${artifact}.d.ts`
2716
+ expectedFiles.push(deferredEntry, deferredTypeDef, wasmTypeDef)
2717
+ const exportsMap = asRecord(packageJson.exports)
2718
+ const expectedExports = {
2719
+ '.': {
2720
+ types: `./${expectedTypes}`,
2721
+ browser: `./${expectedBrowser}`,
2722
+ require: `./${expectedMain}`,
2723
+ default: `./${expectedMain}`,
2724
+ },
2725
+ './workerd': {
2726
+ types: `./${deferredTypeDef}`,
2727
+ default: `./${deferredEntry}`,
2728
+ },
2729
+ './wasm': {
2730
+ types: `./${wasmTypeDef}`,
2731
+ default: `./${artifact}`,
2732
+ },
2733
+ './wasm.wasm': {
2734
+ types: `./${wasmTypeDef}`,
2735
+ default: `./${artifact}`,
2736
+ },
2737
+ './package.json': './package.json',
2738
+ }
2739
+ for (const [subpath, expectedExport] of Object.entries(expectedExports)) {
2740
+ if (!isDeepStrictEqual(exportsMap?.[subpath], expectedExport)) {
2741
+ throw new Error(
2742
+ `Release package ${packageJson.name} has a stale or invalid ${subpath} export`,
2743
+ )
2744
+ }
2745
+ }
2746
+ }
2747
+ }
2748
+
2749
+ const missingExpectedFiles = expectedFiles.filter(
2750
+ (file) => !packageFiles.includes(file),
2751
+ )
2752
+ if (missingExpectedFiles.length > 0) {
2753
+ throw new Error(
2754
+ `Release package ${packageJson.name} does not publish required files: ${missingExpectedFiles.join(', ')}`,
2755
+ )
2756
+ }
2757
+ }
2758
+
2759
+ function validateWasiReleasePackageManifest(
2760
+ packageJson: ReleasePackageManifest,
2761
+ requireDirectBufferDependency: boolean,
2762
+ packagedRuntimeImports: Set<string>,
2763
+ ) {
2764
+ if (packageJson.type !== 'module') {
2765
+ throw new Error(
2766
+ `Release package ${packageJson.name} must declare type module for its WASI JavaScript loaders`,
2767
+ )
2768
+ }
2769
+ if (packageJson.cpu !== undefined) {
2770
+ throw new Error(
2771
+ `Release package ${packageJson.name} must omit cpu so WASI can run on any host architecture`,
2772
+ )
2773
+ }
2774
+ if (packageJson.os !== undefined) {
2775
+ throw new Error(
2776
+ `Release package ${packageJson.name} must omit os so WASI can run on any host operating system`,
2777
+ )
2778
+ }
2779
+
2780
+ const dependencies = asRecord(packageJson.dependencies)
2781
+ const declaredRuntimeDependencies = wasiRuntimeDependencies.filter(
2782
+ (dependency) => dependencies?.[dependency] !== undefined,
2783
+ )
2784
+ if (
2785
+ packagedRuntimeImports.size === 0 &&
2786
+ declaredRuntimeDependencies.length === 0
2787
+ ) {
2788
+ return
2789
+ }
2790
+ if (!dependencies) {
2791
+ throw new Error(
2792
+ `Release package ${packageJson.name} must declare WASI runtime dependencies`,
2793
+ )
2794
+ }
2795
+
2796
+ const wasmRuntimeVersion = requireStringDependency(
2797
+ packageJson.name,
2798
+ dependencies,
2799
+ '@napi-rs/wasm-runtime',
2800
+ )
2801
+ try {
2802
+ new Range(wasmRuntimeVersion)
2803
+ } catch {
2804
+ throw new Error(
2805
+ `Release package ${packageJson.name} has invalid @napi-rs/wasm-runtime dependency ${wasmRuntimeVersion}`,
2806
+ )
2807
+ }
2808
+
2809
+ const emnapiVersion = require('emnapi/package.json').version
2810
+ for (const dependency of ['@emnapi/core', '@emnapi/runtime']) {
2811
+ const dependencyVersion = requireStringDependency(
2812
+ packageJson.name,
2813
+ dependencies,
2814
+ dependency,
2815
+ )
2816
+ if (dependencyVersion !== emnapiVersion) {
2817
+ throw new Error(
2818
+ `Release package ${packageJson.name} must declare ${dependency} ${emnapiVersion}; found ${dependencyVersion}`,
2819
+ )
2820
+ }
2821
+ }
2822
+
2823
+ if (requireDirectBufferDependency) {
2824
+ const bufferVersion = requireStringDependency(
2825
+ packageJson.name,
2826
+ dependencies,
2827
+ 'buffer',
2828
+ )
2829
+ if (bufferVersion !== directBufferDependency) {
2830
+ throw new Error(
2831
+ `Release package ${packageJson.name} must declare buffer ${directBufferDependency}; found ${bufferVersion}`,
2832
+ )
2833
+ }
2834
+ } else if (dependencies.buffer !== undefined) {
2835
+ throw new Error(
2836
+ `Release package ${packageJson.name} must omit buffer when its loaders do not import it`,
2837
+ )
2838
+ }
2839
+ }
2840
+
2841
+ function requireStringDependency(
2842
+ packageName: string | undefined,
2843
+ dependencies: Record<string, unknown>,
2844
+ dependency: string,
2845
+ ) {
2846
+ const version = dependencies[dependency]
2847
+ if (typeof version !== 'string' || version.trim().length === 0) {
2848
+ throw new Error(
2849
+ `Release package ${packageName} must declare dependency ${dependency}`,
2850
+ )
2851
+ }
2852
+ return version.trim()
2853
+ }
2854
+
2855
+ interface DeclarationDependencyClosureOptions {
2856
+ pkgDir: string
2857
+ rootDir: string
2858
+ packageName: string
2859
+ packageFiles: string[]
2860
+ }
2861
+
2862
+ async function completeDeclarationDependencyClosure({
2863
+ pkgDir,
2864
+ rootDir,
2865
+ packageName,
2866
+ packageFiles,
2867
+ }: DeclarationDependencyClosureOptions) {
2868
+ const includedFiles = new Set(packageFiles)
2869
+ const materializedFiles = new Set<string>()
2870
+ const queue = packageFiles.filter(isDeclarationFile)
2871
+ const visited = new Set<string>()
2872
+
2873
+ while (queue.length > 0) {
2874
+ const declarationFile = queue.shift()!
2875
+ if (visited.has(declarationFile)) {
2876
+ continue
2877
+ }
2878
+ visited.add(declarationFile)
2879
+ const declaration = await readFileAsync(
2880
+ join(pkgDir, declarationFile),
2881
+ 'utf8',
2882
+ )
2883
+ for (const specifier of extractRelativeDeclarationSpecifiers(declaration)) {
2884
+ const dependency = resolveDeclarationDependency(
2885
+ pkgDir,
2886
+ declarationFile,
2887
+ specifier,
2888
+ )
2889
+ const sourceDependency =
2890
+ dependency ??
2891
+ resolveDeclarationDependency(rootDir, declarationFile, specifier)
2892
+ if (!sourceDependency) {
2893
+ throw new Error(
2894
+ `Release package ${packageName} declaration ${declarationFile} references missing ${specifier}`,
2895
+ )
2896
+ }
2897
+ if (!dependency) {
2898
+ const destination = join(pkgDir, sourceDependency)
2899
+ await mkdirAsync(dirname(destination), { recursive: true })
2900
+ await copyFileAtomic(join(rootDir, sourceDependency), destination)
2901
+ materializedFiles.add(sourceDependency)
2902
+ }
2903
+ if (!includedFiles.has(sourceDependency)) {
2904
+ includedFiles.add(sourceDependency)
2905
+ if (isDeclarationFile(sourceDependency)) {
2906
+ queue.push(sourceDependency)
2907
+ }
2908
+ }
2909
+ }
2910
+ }
2911
+ return {
2912
+ files: [...includedFiles],
2913
+ materializedFiles: [...materializedFiles],
2914
+ }
2915
+ }
2916
+
2917
+ function extractRelativeDeclarationSpecifiers(source: string) {
2918
+ return new Set(collectRelativeDeclarationSpecifiers(source))
2919
+ }
2920
+
2921
+ function resolveDeclarationDependency(
2922
+ baseDir: string,
2923
+ declarationFile: string,
2924
+ specifier: string,
2925
+ ) {
2926
+ const canonicalBaseDir = realpathSync.native(baseDir)
2927
+ const unresolved = resolve(dirname(join(baseDir, declarationFile)), specifier)
2928
+ const unresolvedRelative = relative(baseDir, unresolved)
2929
+ const nodeModulesDir = join(resolve(baseDir), 'node_modules')
2930
+ if (
2931
+ unresolvedRelative === '..' ||
2932
+ unresolvedRelative.startsWith(`..${sep}`) ||
2933
+ unresolved === nodeModulesDir ||
2934
+ unresolved.startsWith(`${nodeModulesDir}${sep}`)
2935
+ ) {
2936
+ return undefined
2937
+ }
2938
+
2939
+ for (const candidate of declarationDependencyCandidates(unresolved)) {
2940
+ if (
2941
+ existsSync(candidate) &&
2942
+ lstatSync(candidate).isFile() &&
2943
+ pathIsWithin(canonicalBaseDir, realpathSync.native(candidate))
2944
+ ) {
2945
+ return relative(baseDir, candidate).split(sep).join('/')
2946
+ }
2947
+ }
2948
+ return undefined
2949
+ }
2950
+
2951
+ function declarationDependencyCandidates(unresolved: string) {
2952
+ if (isDeclarationFile(unresolved)) {
2953
+ return [unresolved]
2954
+ }
2955
+ const extension = extname(unresolved)
2956
+ if (extension === '.cjs') {
2957
+ return [`${unresolved.slice(0, -extension.length)}.d.cts`]
2958
+ }
2959
+ if (extension === '.mjs') {
2960
+ return [`${unresolved.slice(0, -extension.length)}.d.mts`]
2961
+ }
2962
+ if (extension === '.js' || extension === '.jsx') {
2963
+ return [`${unresolved.slice(0, -extension.length)}.d.ts`]
2964
+ }
2965
+ if (extension.length > 0) {
2966
+ return [unresolved]
2967
+ }
2968
+ return [
2969
+ ...DECLARATION_EXTENSIONS.map((candidate) => `${unresolved}${candidate}`),
2970
+ ...DECLARATION_EXTENSIONS.map((candidate) =>
2971
+ join(unresolved, `index${candidate}`),
2972
+ ),
2973
+ ]
2974
+ }
2975
+
2976
+ function isDeclarationFile(file: string) {
2977
+ return DECLARATION_EXTENSIONS.some((extension) => file.endsWith(extension))
232
2978
  }
233
2979
 
234
2980
  function parseTag(tag: string) {