UNPKG

@napi-rs/cli

Version:

Cli tools for napi-rs

1,688 lines (1,601 loc) 90.4 kB
import { execSync } from 'node:child_process' import { createHash } from 'node:crypto' import { constants, existsSync, lstatSync, readFileSync, realpathSync, readdirSync, statSync, } from 'node:fs' import { createRequire } from 'node:module' import { chmod, copyFile, cp, lstat, mkdtemp, readdir, rm, symlink, } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, extname, isAbsolute, join, relative, resolve, sep, } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { Octokit } from '@octokit/rest' import { Range } from 'semver' import { applyDefaultPrePublishOptions, type PrePublishOptions, } from '../def/pre-publish.js' import { readFileAsync, readNapiConfig, collectRelativeDeclarationSpecifiers, commitFileSystemTransaction, createWasmModuleTypeDef, copyFileAtomic, debugFactory, MINIMUM_WASI_NODE_VERSION, mkdirAsync, restrictWasiNodeEngine, resolvePackageReconciliationPaths, wasiLoaderSuffix, wasiTargetHasThreads, writeFileAtomic, withFileSystemReconciliation, AVAILABLE_TARGETS, parseTriple, type CommonPackageJsonFields, type FileSystemTransactionWrite, type Target, type UserNapiConfig, } from '../utils/index.js' const debug = debugFactory('pre-publish') const THREADLESS_WASI_ROOT_SUBPATHS = new Set([ './workerd', './wasm', './wasm.wasm', ]) const MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES = new Set( AVAILABLE_TARGETS.map((target) => parseTriple(target).platformArchABI), ) const LEGACY_DEEP_IMPORT_EXTENSIONS = ['.js', '.json', '.node'] const DECLARATION_EXTENSIONS = ['.d.ts', '.d.cts', '.d.mts'] const RECONCILIATION_STATE_PREFIXES = [ '.napi-rs-filesystem-reconciliation', '.napi-rs-filesystem-transaction', ] const PUBLICATION_CONTEXT_EXCLUDED_NAMES = new Set(['.git']) const PUBLICATION_CONTEXT_FILES = [ '.npmrc', '.pnp.cjs', '.pnp.loader.mjs', '.pnpmfile.cjs', '.yarnrc', '.yarnrc.yml', 'bun.lock', 'bun.lockb', 'npm-shrinkwrap.json', 'package-lock.json', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', ] const PUBLICATION_LIFECYCLE_SCRIPTS = [ 'prepublish', 'prepare', 'prepublishOnly', 'prepack', 'postpack', 'publish', 'postpublish', ] const WASI_ROOT_FACADE_MARKER_PREFIX = '// napi-rs-wasi-root-facade:' const directBufferDependency = '^6.0.3' const wasiRuntimeDependencies = [ '@napi-rs/wasm-runtime', '@emnapi/core', '@emnapi/runtime', 'buffer', ] const require = createRequire(import.meta.url) type TypeScriptModule = typeof import('typescript') let loadedTypeScript: TypeScriptModule | undefined interface PackageInfo { name: string version: string tag: string } interface PreparedReleasePackage { artifactPath: string filename: string packageDir: string } interface PreparedPrePublish { npmClient: string packageName: string packageVersion: string releasePackages: PreparedReleasePackage[] workspaceRoot?: string } async function removePreparedSnapshot( snapshotRoot: string, phase: 'preparation' | 'publication', primaryFailed: boolean, primaryError?: unknown, ) { try { await removeOwnedTemporaryTree(snapshotRoot) } catch (cleanupError) { if (primaryFailed) { throw new AggregateError( [primaryError, cleanupError], `Pre-publish ${phase} failed and its prepared snapshot could not be removed from ${snapshotRoot}`, { cause: primaryError }, ) } debug.warn( `Pre-publish ${phase} completed, but its prepared snapshot could not be removed from ${snapshotRoot}: ${String(cleanupError)}`, ) return } if (primaryFailed) { throw primaryError } } async function removeOwnedTemporaryTree(root: string) { try { await rm(root, { recursive: true, force: true }) return } catch (initialError) { if ( !['EACCES', 'EPERM'].includes( (initialError as NodeJS.ErrnoException).code ?? '', ) ) { throw initialError } try { await makeOwnedTemporaryDirectoriesWritable(root) await rm(root, { recursive: true, force: true }) return } catch (retryError) { throw new AggregateError( [initialError, retryError], `Prepared snapshot could not be removed from ${root} after restoring directory permissions`, { cause: initialError }, ) } } } async function makeOwnedTemporaryDirectoriesWritable(root: string) { let stats try { stats = await lstat(root) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return throw error } if (!stats.isDirectory()) { throw new Error(`Prepared snapshot path is not a directory: ${root}`) } try { await chmod(root, stats.mode | 0o700) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return throw error } let entries try { entries = await readdir(root, { withFileTypes: true }) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return throw error } for (const entry of entries) { if (entry.isDirectory()) { await makeOwnedTemporaryDirectoriesWritable(join(root, entry.name)) } } } async function copyOwnedTemporaryPath(source: string, destination: string) { await cp(source, destination, { recursive: true }) const stats = await lstat(destination) if (stats.isDirectory()) { await makeOwnedTemporaryDirectoriesWritable(destination) } } function wasiIsOnlyTarget(targets: Target[]) { return ( targets.length > 0 && targets.every((target) => target.platform === 'wasi') ) } /** * Build the root package's `optionalDependencies` map. * * A WASI package is a fallback for hosts that cannot load a `.node` binary, and * the generated binding loader selects it at require time rather than npm * selecting it at install time. Declaring it as an `optionalDependency` * alongside the native packages cannot express "install this only when nothing * else matched": npm evaluates every entry independently, so every consumer * downloads a `.wasm` binary they will never load. * * It is therefore only declared when WASI is the only configured target, which * makes it the primary artifact rather than a fallback. * `napi.wasm.optionalDependency` overrides the default in both directions. * * See https://github.com/rolldown/rolldown/issues/10556 */ export function resolveRootOptionalDependencies({ existing, managedPackageNames, packageName, targets, version, wasm, }: { existing: unknown managedPackageNames: Iterable<string> packageName: string targets: Target[] version: string wasm?: UserNapiConfig['wasm'] }): Record<string, unknown> { const optionalDependencies: Record<string, unknown> = { ...asRecord(existing), } for (const managedPackageName of managedPackageNames) { for (const suffix of MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES) { delete optionalDependencies[`${managedPackageName}-${suffix}`] } } const declareWasi = wasm?.optionalDependency ?? wasiIsOnlyTarget(targets) for (const target of targets) { if (target.platform === 'wasi' && !declareWasi) { continue } optionalDependencies[`${packageName}-${target.platformArchABI}`] = version } return optionalDependencies } export async function prePublish(userOptions: PrePublishOptions) { debug('Receive pre-publish options:') debug(' %O', userOptions) const options = applyDefaultPrePublishOptions(userOptions) const resolvedPaths = resolvePackageReconciliationPaths( options.cwd, options.packageJsonPath, [options.npmDir], ) const { boundary: reconciliationRoot, packageJsonPath, packageRoot: rootDir, } = resolvedPaths const npmDir = resolvedPaths.managedPaths[0] let preparedSnapshotRoot: string | undefined let prepared: PreparedPrePublish try { prepared = await withFileSystemReconciliation( reconciliationRoot, async () => { const { packageJson, targets, packageName, binaryName, npmClient, wasm, } = await readNapiConfig( packageJsonPath, options.configPath ? resolve(options.cwd, options.configPath) : undefined, ) const threadlessWasiTarget = targets.find( (target) => target.platform === 'wasi' && !wasiTargetHasThreads(target), ) let preparedWorkspaceRoot: string | undefined if (!options.dryRun) { preparedSnapshotRoot = await mkdtemp( join(tmpdir(), 'napi-rs-pre-publish-stage-'), ) preparedWorkspaceRoot = join(preparedSnapshotRoot, 'workspace') } const releasePackagePlans: ReleasePackageMaterializationPlan[] = [] for (const target of targets) { const pkgDir = join(npmDir, target.platformArchABI) const validationOptions: ReleasePackageValidationOptions = { pkgDir, rootDir, packageName, binaryName, target, requireDirectBufferDependency: wasm?.browser?.buffer === true && (wasm.browser.fs !== true || !wasiTargetHasThreads(target)), } releasePackagePlans.push( preparedWorkspaceRoot ? await stageReleasePackage( preparedWorkspaceRoot, reconciliationRoot, packageJson.version, validationOptions, ) : await validateReleasePackage(validationOptions), ) } const rootFacadeReconciliation = reconcileThreadlessWasiRootFacade( packageJson, rootDir, npmDir, packageName, ) let reconciledPackageJson = rootFacadeReconciliation.packageJson let rootFacade: ThreadlessWasiRootFacade | undefined if (threadlessWasiTarget) { rootFacade = planThreadlessWasiRootFacade({ packageJsonPath, packageJson: reconciledPackageJson, packageName, binaryName, target: threadlessWasiTarget, npmDir, managedGeneratedFiles: rootFacadeReconciliation.staleGeneratedFiles, }) const stagedThreadlessPackage = releasePackagePlans.find( (plan) => plan.target.platformArchABI === threadlessWasiTarget.platformArchABI, )?.stagedPkgDir if (stagedThreadlessPackage) { const stagedWasmSourcePath = join( stagedThreadlessPackage, `${binaryName}.${threadlessWasiTarget.platformArchABI}.wasm`, ) rootFacade = { ...rootFacade, marker: createThreadlessWasiRootFacadeMarker( `${packageName}-${threadlessWasiTarget.platformArchABI}`, readFileSync(stagedWasmSourcePath), ), wasmSourcePath: stagedWasmSourcePath, } } reconciledPackageJson = applyThreadlessWasiRootFacade( reconciledPackageJson, rootFacade, ) } const managedPackageNames = new Set([packageName]) for (const flavorPackage of rootFacadeReconciliation.managedFlavorPackages) { for (const suffix of MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES) { const ending = `-${suffix}` if (flavorPackage.endsWith(ending)) { managedPackageNames.add(flavorPackage.slice(0, -ending.length)) } } } const optionalDependencies = resolveRootOptionalDependencies({ existing: packageJson.optionalDependencies, managedPackageNames, packageName, targets, version: packageJson.version, wasm, }) const nodeEngine = wasiIsOnlyTarget(targets) ? restrictWasiNodeEngine( packageJson.engines?.node ?? MINIMUM_WASI_NODE_VERSION, ) : undefined const rootReleasePlan: RootReleaseMaterializationPlan = { packageJson: reconciledPackageJson, optionalDependencies, nodeEngine, facade: rootFacade, staleGeneratedFiles: rootFacadeReconciliation.staleGeneratedFiles, } if (preparedWorkspaceRoot) { const stagedTransactionRoot = join( preparedSnapshotRoot!, 'transaction-root', ) const stagedRootDir = publicationContextPath( preparedWorkspaceRoot, reconciliationRoot, rootDir, ) await stageRootReleasePlan( packageJsonPath, rootDir, stagedRootDir, rootReleasePlan, ) await mkdirAsync(stagedTransactionRoot, { recursive: true }) const stagedRootManifestPath = join( stagedTransactionRoot, 'package.json', ) await copyFile( join(stagedRootDir, 'package.json'), stagedRootManifestPath, constants.COPYFILE_FICLONE, ) if (releasePackagePlans.length > 0) { await stagePublicationContext( reconciliationRoot, preparedWorkspaceRoot, rootDir, releasePackagePlans, ) await registerPublicationWorkspaces( reconciliationRoot, preparedWorkspaceRoot, releasePackagePlans.map(({ pkgDir }) => pkgDir), ) } await commitPrePublishFileSystemTransaction({ packageJsonPath, transactionRoot: reconciliationRoot, releasePackagePlans, rootDir, rootReleasePlan, stagedRootManifestPath, stagedRootDir, }) } else if (rootFacade) { await validateRootReleasePlan( packageJsonPath, rootDir, rootReleasePlan, ) } return { npmClient, packageName, packageVersion: packageJson.version, releasePackages: releasePackagePlans.map((plan) => prepareReleasePackage(plan, binaryName), ), workspaceRoot: preparedWorkspaceRoot, } }, ) } catch (error) { if (preparedSnapshotRoot) { await removePreparedSnapshot( preparedSnapshotRoot, 'preparation', true, error, ) } throw error } const { npmClient, packageName, packageVersion, releasePackages, workspaceRoot, } = prepared async function createGhRelease(packageName: string, version: string) { if (!options.ghRelease) { return { owner: null, repo: null, pkgInfo: { name: null, version: null, tag: null }, } } const { repo, owner, pkgInfo, octokit } = getRepoInfo(packageName, version) if (!repo || !owner) { return { owner: null, repo: null, pkgInfo: { name: null, version: null, tag: null }, } } if (!options.dryRun) { try { await octokit.repos.createRelease({ owner, repo, tag_name: pkgInfo.tag, name: options.ghReleaseName, prerelease: version.includes('alpha') || version.includes('beta') || version.includes('rc'), }) } catch (e) { debug( `Params: ${JSON.stringify( { owner, repo, tag_name: pkgInfo.tag }, null, 2, )}`, ) console.error(e) } } return { owner, repo, pkgInfo, octokit } } function getRepoInfo(packageName: string, version: string) { const headCommit = execSync('git log -1 --pretty=%B', { cwd: options.cwd, encoding: 'utf-8', }).trim() const { GITHUB_REPOSITORY } = process.env if (!GITHUB_REPOSITORY) { return { owner: null, repo: null, pkgInfo: { name: null, version: null, tag: null }, } } debug(`Github repository: ${GITHUB_REPOSITORY}`) const [owner, repo] = GITHUB_REPOSITORY.split('/') const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN, }) let pkgInfo: PackageInfo | undefined if (options.tagStyle === 'lerna') { const packagesToPublish = headCommit .split('\n') .map((line) => line.trim()) .filter((line, index) => line.length && index) .map((line) => line.substring(2)) .map(parseTag) pkgInfo = packagesToPublish.find( (pkgInfo) => pkgInfo.name === packageName, ) if (!pkgInfo) { throw new TypeError( `No release commit found with ${packageName}, original commit info: ${headCommit}`, ) } } else { pkgInfo = { tag: `v${version}`, version, name: packageName, } } return { owner, repo, pkgInfo, octokit } } let publicationFailed = false let publicationError: unknown try { const { owner, repo, pkgInfo, octokit } = options.ghReleaseId ? getRepoInfo(packageName, packageVersion) : await createGhRelease(packageName, packageVersion) for (const releasePackage of releasePackages) { const { artifactPath, filename, packageDir } = releasePackage let publicationPackageDir = packageDir let publicationArtifactPath = artifactPath let publicationExecutionRoot: string | undefined let releaseFailed = false let releaseError: unknown try { if (!options.dryRun) { if (!existsSync(artifactPath)) { throw new Error(`Release artifact does not exist: ${artifactPath}`) } if (!options.skipOptionalPublish) { if (!workspaceRoot) { throw new Error( 'Prepared publication workspace is missing for a real publish', ) } const publicationExecution = await createPublicationExecutionPackage(workspaceRoot, packageDir) publicationExecutionRoot = publicationExecution.root publicationPackageDir = publicationExecution.packageDir publicationArtifactPath = join(publicationPackageDir, filename) try { const output = execSync(`${npmClient} publish`, { cwd: publicationPackageDir, env: process.env, stdio: 'pipe', }) process.stdout.write(output) } catch (e) { if ( e instanceof Error && e.message.includes( 'You cannot publish over the previously published versions', ) ) { console.info(e.message) debug.warn(`${packageDir} has been published, skipping`) } else { throw e } } } if (options.ghRelease && repo && owner) { debug.info(`Creating GitHub release ${pkgInfo.tag}`) try { const releaseId = options.ghReleaseId ? Number(options.ghReleaseId) : ( await octokit!.repos.getReleaseByTag({ repo: repo, owner: owner, tag: pkgInfo.tag, }) ).data.id const artifactStats = statSync(publicationArtifactPath) const assetInfo = await octokit!.repos.uploadReleaseAsset({ owner: owner, repo: repo, name: filename, release_id: releaseId, mediaType: { format: 'raw' }, headers: { 'content-length': artifactStats.size, 'content-type': 'application/octet-stream', }, // @ts-expect-error octokit types are wrong data: await readFileAsync(publicationArtifactPath), }) debug.info(`GitHub release created`) debug.info( `Download URL: %s`, assetInfo.data.browser_download_url, ) } catch (e) { debug.error( `Param: ${JSON.stringify( { owner, repo, tag: pkgInfo.tag, filename: publicationArtifactPath, }, null, 2, )}`, ) debug.error(e) } } } } catch (error) { releaseFailed = true releaseError = error } if (publicationExecutionRoot) { await removePreparedSnapshot( publicationExecutionRoot, 'publication', releaseFailed, releaseError, ) } if (releaseFailed) { throw releaseError } } } catch (error) { publicationFailed = true publicationError = error } if (preparedSnapshotRoot) { await removePreparedSnapshot( preparedSnapshotRoot, 'publication', publicationFailed, publicationError, ) } if (publicationFailed) { throw publicationError } } interface ThreadlessWasiRootFacadeOptions { packageJsonPath: string packageJson: CommonPackageJsonFields packageName: string binaryName: string target: Target npmDir: string managedGeneratedFiles: string[] } interface ThreadlessWasiRootFacade { exports: Record<string, unknown> publishConfigExports?: Record<string, unknown> generatedFiles: string[] files: ThreadlessWasiRootFacadeFiles wasmSourcePath: string marker: string forwardingModule: string packageJsonUpdate: { files?: string[] } } interface ThreadlessWasiRootFacadeFiles { workerdEntry: string workerdTypeDef: string wasmEntry: string wasmTypeDef: string } interface ThreadlessWasiRootFacadeReconciliation { managedFlavorPackages: string[] packageJson: CommonPackageJsonFields staleGeneratedFiles: string[] } interface ManagedThreadlessWasiRootFacade { files: ThreadlessWasiRootFacadeFiles flavorPackage: string } interface ThreadlessWasiRootFacadeMarker { version: 1 flavorPackage: string wasmSha256: string } function reconcileThreadlessWasiRootFacade( packageJson: CommonPackageJsonFields, rootDir: string, npmDir: string, packageName: string, ): ThreadlessWasiRootFacadeReconciliation { const reconciledPackageJson = { ...packageJson } const staleGeneratedFiles = new Set<string>() const managedFlavorPackages = new Set<string>() const rootExports = removeThreadlessWasiRootExports( packageJson.exports, packageJson, rootDir, npmDir, packageName, ) for (const file of rootExports.generatedFiles) { staleGeneratedFiles.add(file) } if (rootExports.managedFlavorPackage) { managedFlavorPackages.add(rootExports.managedFlavorPackage) } setOptionalProperty( reconciledPackageJson as Record<string, unknown>, 'exports', rootExports.exports, ) const publishConfig = asRecord(packageJson.publishConfig) if ( publishConfig && Object.prototype.hasOwnProperty.call(publishConfig, 'exports') ) { const publishConfigExports = removeThreadlessWasiRootExports( publishConfig.exports as CommonPackageJsonFields['exports'], packageJson, rootDir, npmDir, packageName, ) for (const file of publishConfigExports.generatedFiles) { staleGeneratedFiles.add(file) } if (publishConfigExports.managedFlavorPackage) { managedFlavorPackages.add(publishConfigExports.managedFlavorPackage) } const reconciledPublishConfig = { ...publishConfig } setOptionalProperty( reconciledPublishConfig, 'exports', publishConfigExports.exports, ) reconciledPackageJson.publishConfig = reconciledPublishConfig } if (Array.isArray(packageJson.files)) { reconciledPackageJson.files = packageJson.files.filter( (file) => !staleGeneratedFiles.has(file), ) } return { managedFlavorPackages: [...managedFlavorPackages], packageJson: reconciledPackageJson, staleGeneratedFiles: [...staleGeneratedFiles], } } function removeThreadlessWasiRootExports( currentExports: CommonPackageJsonFields['exports'], packageJson: CommonPackageJsonFields, rootDir: string, npmDir: string, packageName: string, ) { const exportsMap = asRecord(currentExports) const managedFacade = getManagedThreadlessWasiRootFacadeFiles( exportsMap, rootDir, npmDir, packageName, ) if (!exportsMap || !managedFacade) { return { exports: currentExports, generatedFiles: [], managedFlavorPackage: undefined, } } const { files: generatedFiles, flavorPackage: managedFlavorPackage } = managedFacade const nextExports = { ...exportsMap } delete nextExports['./workerd'] delete nextExports['./wasm'] delete nextExports['./wasm.wasm'] const keys = Object.keys(nextExports) const generatedLegacyExports = createLegacyDeepImportExports(rootDir) if ( Object.prototype.hasOwnProperty.call(nextExports, '.') && nextExports['./*'] === './*' && isDeepStrictEqual( nextExports['.'], createLegacyRootExport(packageJson, rootDir), ) && keys .filter((key) => key !== '.' && key !== './*') .every( (key) => generatedLegacyExports[key] !== undefined && isDeepStrictEqual(nextExports[key], generatedLegacyExports[key]), ) ) { return { exports: undefined, generatedFiles: Object.values(generatedFiles), managedFlavorPackage, } } if (keys.length === 1 && keys[0] === '.') { return { exports: nextExports['.'], generatedFiles: Object.values(generatedFiles), managedFlavorPackage, } } return { exports: keys.length > 0 ? nextExports : undefined, generatedFiles: Object.values(generatedFiles), managedFlavorPackage, } } function getManagedThreadlessWasiRootFacadeFiles( exportsMap: Record<string, unknown> | undefined, rootDir: string, npmDir: string, packageName: string, ): ManagedThreadlessWasiRootFacade | undefined { if (!exportsMap) { return undefined } const workerdExport = asRecord(exportsMap['./workerd']) const workerdEntry = typeof workerdExport?.default === 'string' ? workerdExport.default.match( /^\.\/([^/\\]+\.wasm32-wasip1)\.workerd\.mjs$/, ) : null if (!workerdEntry) { return undefined } const generatedPrefix = workerdEntry[1] const files: ThreadlessWasiRootFacadeFiles = { workerdEntry: `${generatedPrefix}.workerd.mjs`, workerdTypeDef: `${generatedPrefix}.workerd.d.mts`, wasmEntry: `${generatedPrefix}.wasm`, wasmTypeDef: `${generatedPrefix}.wasm.d.mts`, } const expectedWorkerdExport = { types: `./${files.workerdTypeDef}`, default: `./${files.workerdEntry}`, } const expectedWasmExport = { types: `./${files.wasmTypeDef}`, default: `./${files.wasmEntry}`, } if ( !isDeepStrictEqual(workerdExport, expectedWorkerdExport) || !isDeepStrictEqual(exportsMap['./wasm'], expectedWasmExport) || !isDeepStrictEqual(exportsMap['./wasm.wasm'], expectedWasmExport) ) { return undefined } const flavorPackage = `${packageName}-wasm32-wasip1` const managedFlavorPackage = getManagedThreadlessWasiRootFacadeMarker(rootDir, files) ?? getPartialManagedThreadlessWasiRootFacadeMarker(rootDir, npmDir, files) ?? getLegacyThreadlessWasiRootFacade(rootDir, npmDir, files) if (managedFlavorPackage) { return { files, flavorPackage: managedFlavorPackage } } if ( hasPartialOrCorruptThreadlessWasiRootFacade(rootDir, files, flavorPackage) ) { throw new Error( '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.', ) } return undefined } function getManagedThreadlessWasiRootFacadeMarker( rootDir: string, files: ThreadlessWasiRootFacadeFiles, ) { const workerdEntry = readRegularFile(join(rootDir, files.workerdEntry)) const workerdTypeDef = readRegularFile(join(rootDir, files.workerdTypeDef)) const wasmEntry = readRegularFile(join(rootDir, files.wasmEntry)) const wasmTypeDef = readRegularFile(join(rootDir, files.wasmTypeDef)) if (!workerdEntry || !workerdTypeDef || !wasmEntry || !wasmTypeDef) { return undefined } const markedFiles = [workerdEntry, workerdTypeDef, wasmTypeDef].map((file) => parseThreadlessWasiRootFacadeMarker(file.toString('utf8')), ) if (markedFiles.some((file) => file === undefined)) { return undefined } const [markedWorkerdEntry, markedWorkerdTypeDef, markedWasmTypeDef] = markedFiles as Array<{ marker: ThreadlessWasiRootFacadeMarker markerLine: string body: string }> if ( markedWorkerdEntry.markerLine !== markedWorkerdTypeDef.markerLine || markedWorkerdEntry.markerLine !== markedWasmTypeDef.markerLine ) { return undefined } const expectedMarker: ThreadlessWasiRootFacadeMarker = { version: 1, flavorPackage: markedWorkerdEntry.marker.flavorPackage, wasmSha256: createHash('sha256').update(wasmEntry).digest('hex'), } if (!isDeepStrictEqual(markedWorkerdEntry.marker, expectedMarker)) { return undefined } const forwardingModule = createThreadlessWasiRootForwardingModule( markedWorkerdEntry.marker.flavorPackage, ) return markedWorkerdEntry.body === forwardingModule && markedWorkerdTypeDef.body === forwardingModule && markedWasmTypeDef.body === createWasmModuleTypeDef() ? markedWorkerdEntry.marker.flavorPackage : undefined } function getPartialManagedThreadlessWasiRootFacadeMarker( rootDir: string, npmDir: string, files: ThreadlessWasiRootFacadeFiles, ) { const availableWasmHashes = new Set<string>() for (const wasm of [ readRegularFile(join(rootDir, files.wasmEntry)), readRegularFile(join(npmDir, 'wasm32-wasip1', files.wasmEntry)), ]) { if (wasm) { availableWasmHashes.add(createHash('sha256').update(wasm).digest('hex')) } } if (availableWasmHashes.size === 0) { return undefined } const flavorPackages = new Set<string>() for (const file of [ files.workerdEntry, files.workerdTypeDef, files.wasmTypeDef, ]) { const contents = readRegularFile(join(rootDir, file)) if (!contents) { continue } const markedFile = parseThreadlessWasiRootFacadeMarker( contents.toString('utf8'), ) if ( markedFile !== undefined && availableWasmHashes.has(markedFile.marker.wasmSha256) ) { flavorPackages.add(markedFile.marker.flavorPackage) } } return flavorPackages.size === 1 ? [...flavorPackages][0] : undefined } function getLegacyThreadlessWasiRootFacade( rootDir: string, npmDir: string, files: ThreadlessWasiRootFacadeFiles, ) { const workerdEntry = readRegularFile(join(rootDir, files.workerdEntry)) const workerdTypeDef = readRegularFile(join(rootDir, files.workerdTypeDef)) const wasmEntry = readRegularFile(join(rootDir, files.wasmEntry)) const wasmTypeDef = readRegularFile(join(rootDir, files.wasmTypeDef)) const flavorWasmEntry = readRegularFile( join(npmDir, 'wasm32-wasip1', files.wasmEntry), ) if (!wasmEntry || !flavorWasmEntry || !wasmEntry.equals(flavorWasmEntry)) { return undefined } const forwardingPackages = new Set<string>() for (const [file, contents] of [ [files.workerdEntry, workerdEntry], [files.workerdTypeDef, workerdTypeDef], ] as const) { if (!existsSync(join(rootDir, file)) && contents === undefined) { continue } const flavorPackage = contents ? parseThreadlessWasiRootForwardingModule(contents.toString('utf8')) : undefined if (!flavorPackage) { return undefined } forwardingPackages.add(flavorPackage) } if ( (existsSync(join(rootDir, files.wasmTypeDef)) || wasmTypeDef !== undefined) && wasmTypeDef?.toString('utf8') !== createWasmModuleTypeDef() ) { return undefined } return forwardingPackages.size === 1 ? [...forwardingPackages][0] : undefined } function hasPartialOrCorruptThreadlessWasiRootFacade( rootDir: string, files: ThreadlessWasiRootFacadeFiles, flavorPackage: string, ) { const facadeFiles = Object.values(files).map( (file) => [file, readRegularFile(join(rootDir, file))] as const, ) if (facadeFiles.some(([, contents]) => contents === undefined)) { return true } const forwardingModule = createThreadlessWasiRootForwardingModule(flavorPackage) const expectedLegacyContents = new Map([ [files.workerdEntry, forwardingModule], [files.workerdTypeDef, forwardingModule], [files.wasmTypeDef, createWasmModuleTypeDef()], ]) return facadeFiles.some(([file, contents]) => { const source = contents!.toString('utf8') return ( source.startsWith(WASI_ROOT_FACADE_MARKER_PREFIX) || source === expectedLegacyContents.get(file) ) }) } function readRegularFile(path: string) { try { if (!lstatSync(path).isFile()) { return undefined } return readFileSync(path) } catch { return undefined } } function parseThreadlessWasiRootFacadeMarker(source: string) { const markerEnd = source.indexOf('\n') if (markerEnd === -1) { return undefined } const markerLine = source.slice(0, markerEnd) if (!markerLine.startsWith(WASI_ROOT_FACADE_MARKER_PREFIX)) { return undefined } let marker: unknown try { marker = JSON.parse(markerLine.slice(WASI_ROOT_FACADE_MARKER_PREFIX.length)) } catch { return undefined } const markerRecord = asRecord(marker) if ( !markerRecord || !isDeepStrictEqual(Object.keys(markerRecord), [ 'version', 'flavorPackage', 'wasmSha256', ]) || markerRecord.version !== 1 || typeof markerRecord.flavorPackage !== 'string' || typeof markerRecord.wasmSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(markerRecord.wasmSha256) || markerLine !== `${WASI_ROOT_FACADE_MARKER_PREFIX}${JSON.stringify(markerRecord)}` ) { return undefined } return { marker: markerRecord as unknown as ThreadlessWasiRootFacadeMarker, markerLine, body: source.slice(markerEnd + 1), } } function createThreadlessWasiRootFacadeMarker( flavorPackage: string, wasm: Buffer, ) { const marker: ThreadlessWasiRootFacadeMarker = { version: 1, flavorPackage, wasmSha256: createHash('sha256').update(wasm).digest('hex'), } return `${WASI_ROOT_FACADE_MARKER_PREFIX}${JSON.stringify(marker)}` } function createThreadlessWasiRootForwardingModule(flavorPackage: string) { return `export * from ${JSON.stringify(`${flavorPackage}/workerd`)}\n` } function parseThreadlessWasiRootForwardingModule(source: string) { const match = /^export \* from (".+")\n$/.exec(source) if (!match) { return undefined } let specifier: unknown try { specifier = JSON.parse(match[1]) } catch { return undefined } if ( typeof specifier !== 'string' || !specifier.endsWith('-wasm32-wasip1/workerd') ) { return undefined } return specifier.slice(0, -'/workerd'.length) } function applyThreadlessWasiRootFacade( packageJson: CommonPackageJsonFields, rootFacade: ThreadlessWasiRootFacade, ) { const updatedPackageJson = { ...packageJson } if (rootFacade.publishConfigExports) { // The facade exports were built from publishConfig.exports, so write the // result back there and leave the package's local exports map untouched. updatedPackageJson.publishConfig = { ...asRecord(packageJson.publishConfig), exports: rootFacade.publishConfigExports, } } else { updatedPackageJson.exports = rootFacade.exports } if (rootFacade.packageJsonUpdate.files) { updatedPackageJson.files = rootFacade.packageJsonUpdate.files } return updatedPackageJson } function syncThreadlessWasiRootFacadeManifest( destination: Record<string, unknown>, source: CommonPackageJsonFields, ) { setOptionalProperty(destination, 'exports', source.exports) setOptionalProperty(destination, 'files', source.files) const destinationPublishConfig = asRecord(destination.publishConfig) const sourcePublishConfig = asRecord(source.publishConfig) if (destinationPublishConfig || sourcePublishConfig?.exports !== undefined) { const publishConfig = { ...destinationPublishConfig } setOptionalProperty(publishConfig, 'exports', sourcePublishConfig?.exports) destination.publishConfig = publishConfig } } function setOptionalProperty( object: Record<string, unknown>, key: string, value: unknown, ) { if (value === undefined) { delete object[key] } else { object[key] = value } } function asRecord(value: unknown): Record<string, unknown> | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined } function planThreadlessWasiRootFacade({ packageJsonPath, packageJson, packageName, binaryName, target, npmDir, managedGeneratedFiles, }: ThreadlessWasiRootFacadeOptions): ThreadlessWasiRootFacade { const rootDir = dirname(packageJsonPath) const flavorPackageName = `${packageName}-${target.platformArchABI}` const wasmFileName = `${binaryName}.${target.platformArchABI}.wasm` const generatedPrefix = `${binaryName}.${target.platformArchABI}` const files: ThreadlessWasiRootFacadeFiles = { workerdEntry: `${generatedPrefix}.workerd.mjs`, workerdTypeDef: `${generatedPrefix}.workerd.d.mts`, wasmEntry: wasmFileName, wasmTypeDef: `${wasmFileName}.d.mts`, } const publishConfig = asRecord(packageJson.publishConfig) const hasPublishConfigExports = publishConfig && Object.prototype.hasOwnProperty.call(publishConfig, 'exports') const effectiveExports = hasPublishConfigExports ? (publishConfig.exports as CommonPackageJsonFields['exports']) : packageJson.exports const exports = addThreadlessWasiRootExports( effectiveExports, packageJson, files, rootDir, ) const publishConfigExports = hasPublishConfigExports ? exports : undefined const forwardingModule = createThreadlessWasiRootForwardingModule(flavorPackageName) const generatedFiles = Object.values(files) const managedFiles = new Set(managedGeneratedFiles) const wasmSourcePath = join(npmDir, target.platformArchABI, wasmFileName) const marker = createThreadlessWasiRootFacadeMarker( flavorPackageName, readFileSync(wasmSourcePath), ) const conflictingFile = generatedFiles.find((file) => { const rootPath = join(rootDir, file) if (!existsSync(rootPath) || managedFiles.has(file)) { return false } // `napi artifacts` already copies the flavor artifact into the root // package. Treat that exact regular file as managed so the standard // artifacts -> pre-publish flow can materialize the root facade. return !( file === files.wasmEntry && lstatSync(rootPath).isFile() && readFileSync(rootPath).equals(readFileSync(wasmSourcePath)) ) }) if (conflictingFile) { throw new Error( `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.`, ) } const facade: ThreadlessWasiRootFacade = { exports, publishConfigExports, generatedFiles, files, wasmSourcePath, marker, forwardingModule, packageJsonUpdate: {}, } if (Array.isArray(packageJson.files)) { facade.packageJsonUpdate = { files: [...new Set([...packageJson.files, ...generatedFiles])], } } return facade } async function materializeThreadlessWasiRootFacade( rootDir: string, facade: ThreadlessWasiRootFacade, ) { await copyFileAtomic( facade.wasmSourcePath, join(rootDir, facade.files.wasmEntry), ) await Promise.all([ writeFileAtomic( join(rootDir, facade.files.workerdEntry), `${facade.marker}\n${facade.forwardingModule}`, 'utf8', ), writeFileAtomic( join(rootDir, facade.files.workerdTypeDef), `${facade.marker}\n${facade.forwardingModule}`, 'utf8', ), writeFileAtomic( join(rootDir, facade.files.wasmTypeDef), `${facade.marker}\n${createWasmModuleTypeDef()}`, 'utf8', ), ]) } function addThreadlessWasiRootExports( currentExports: CommonPackageJsonFields['exports'], packageJson: CommonPackageJsonFields, files: ThreadlessWasiRootFacadeFiles, rootDir: string, ) { let exportsMap: Record<string, unknown> if (currentExports === undefined) { exportsMap = { '.': createLegacyRootExport(packageJson, rootDir), // Adding an exports map would otherwise encapsulate every historical // deep import. The wildcard preserves explicit paths, while exact // aliases preserve CommonJS extension and directory-index resolution. './*': './*', ...createLegacyDeepImportExports(rootDir), } } else if ( typeof currentExports === 'object' && currentExports !== null && !Array.isArray(currentExports) && Object.keys(currentExports).some((key) => key.startsWith('.')) ) { exportsMap = { ...currentExports } } else { // A string, array, or condition-only object describes the package root. exportsMap = { '.': currentExports } } setThreadlessWasiRootExport(exportsMap, './workerd', { types: `./${files.workerdTypeDef}`, default: `./${files.workerdEntry}`, }) const wasmExport = { types: `./${files.wasmTypeDef}`, default: `./${files.wasmEntry}`, } setThreadlessWasiRootExport(exportsMap, './wasm', wasmExport) setThreadlessWasiRootExport(exportsMap, './wasm.wasm', wasmExport) return exportsMap } function setThreadlessWasiRootExport( exportsMap: Record<string, unknown>, subpath: string, generatedExport: unknown, ) { if (!Object.prototype.hasOwnProperty.call(exportsMap, subpath)) { exportsMap[subpath] = generatedExport return } if (isDeepStrictEqual(exportsMap[subpath], generatedExport)) { return } throw new Error( `Cannot generate the threadless WASI root export ${subpath}: package.json already defines that subpath. Remove or rename the existing export before running pre-publish.`, ) } function createLegacyRootExport( packageJson: CommonPackageJsonFields, rootDir: string, ) { const main = resolveLegacyPackageTarget( rootDir, packageJson.main ?? 'index.js', ) const rootExport: Record<string, string> = {} const legacyPackageJson = packageJson as CommonPackageJsonFields & { typings?: unknown } const typeDef = typeof packageJson.types === 'string' ? packageJson.types : typeof legacyPackageJson.typings === 'string' ? legacyPackageJson.typings : undefined if (typeDef) { rootExport.types = resolveLegacyPackageTarget(rootDir, typeDef) } if (typeof packageJson.browser === 'string') { rootExport.browser = resolveLegacyPackageTarget( rootDir, packageJson.browser, ) } if (typeof packageJson.module === 'string') { const module = resolveLegacyPackageTarget(rootDir, packageJson.module) if (isNodeEsmTarget(module, packageJson.type)) { rootExport.import = module } else { rootExport.module = module rootExport.require = main } } else if (packageJson.type === 'module') { rootExport.import = main } else { rootExport.require = main } // Node ignores the legacy `module` field. CommonJS resolution reaches the // main entry through `node`, while ESM reaches `import` above when present. rootExport.node = main rootExport.default = main return rootExport } function normalizePackageTarget(target: string) { return target.startsWith('./') ? target : `./${target}` } function resolveLegacyPackageTarget(rootDir: string, target: string) { try { const resolvedTarget = createRequire(join(rootDir, 'package.json')).resolve( resolve(rootDir, target), ) const relativeTarget = relative(rootDir, resolvedTarget) if ( relativeTarget.length > 0 && relativeTarget !== '..' && !relativeTarget.startsWith(`..${sep}`) && !resolve(rootDir, relativeTarget).startsWith( `${resolve(rootDir)}${sep}node_modules${sep}`, ) ) { return normalizePackageTarget(relativeTarget.split(sep).join('/')) } } catch { // Preserve the legacy value so root validation can report the missing path. } return normalizePackageTarget(target) } function isNodeEsmTarget( target: string, packageType: CommonPackageJsonFields['type'], ) { const extension = extname(target) return ( extension === '.mjs' || (extension === '.js' && packageType === 'module') ) } function createLegacyDeepImportExports(rootDir: string) { const packedFiles = readNpmPackFiles(rootDir, 'root package') const exportsMap: Record<string, string> = {} const matchingFiles = [...packedFiles] .filter((file) => LEGACY_DEEP_IMPORT_EXTENSIONS.some((extension) => file.endsWith(extension), ), ) .sort() for (const extension of LEGACY_DEEP_IMPORT_EXTENSIONS) { for (const file of matchingFiles.filter((file) => file.endsWith(extension), )) { const target = `./${file}` const extensionless = `./${file.slice(0, -extension.length)}` exportsMap[extensionless] ??= target } } for (const packageManifest of matchingFiles.filter((file) => file.endsWith('/package.json'), )) { const directory = dirname(packageManifest).split(sep).join('/') const target = resolveLegacyPackageTarget(rootDir, directory) if (packedFiles.has(target.slice(2))) { exportsMap[`./${directory}`] ??= target } } for (const extension of LEGACY_DEEP_IMPORT_EXTENSIONS) { for (const file of matchingFiles.filter((file) => file.endsWith(extension), )) { const indexSuffix = `/index${extension}` if (file.endsWith(indexSuffix)) { const directory = `./${file.slice(0, -indexSuffix.length)}` exportsMap[directory] ??= `./${file}` } } } return exportsMap } function collectRootPackagePathReferences( packageJson: CommonPackageJsonFields, ) { const files = new Set<string>() addLegacyPackageFileReference(files, packageJson.main) if (packageJson.main === undefined && packageJson.exports === undefined) { addLegacyPackageFileReference(files, 'index.js') } addLegacyPackageFileReference(files, packageJson.module) addLegacyPackageFileReference(files, packageJson.types) addLegacyPackageFileReference( files, (packageJson as CommonPackageJsonFields & { typings?: unknown }).typings, ) addLegacyPackageFileReference(files, packageJson.browser) collectRootExportFileReferences(files, packageJson.exports) const publishConfig = asRecord(packageJson.publishConfig) if ( publishConfig && Object.prototype.hasOwnProperty.call(publishConfig, 'exports') ) { collectRootExportFileReferences(files, publishConfig.exports) } return [...files] } function addLegacyPackageFileReference(files: Set<string>, target: unknown) { if (typeof target === 'string') { addPackageFileReference(files, normalizePackageTarget(target)) } } function collectRootExportFileReferences( files: Set<string>, exportsField: unknown, ) { const exportsMap = asRecord(exportsField) if ( exportsMap && Object.keys(exportsMap).some((key) => key.startsWith('.')) ) { for (const [subpath, target] of Object.entries(exportsMap)) { if ( !subpath.includes('*') && !THREADLESS_WASI_ROOT_SUBPATHS.has(subpath) ) { collectPackageTargetFileReferences(files, target) } } return } collectPackageTargetFileReferences(files, exportsField) } function collectPackageTargetFileReferences( files: Set<string>, target: unknown, ) { if (typeof target === 'string') { addPackageFileReference(files, target) } else if (Array.isArray(target)) { for (const entry of target) { collectPackageTargetFileReferences(files, entry) } } else if (typeof target === 'object' && target !== null) { for (const entry of Object.values(target)) { collectPackageTargetFileReferences(files, entry) } } } function addPackageFileReference(files: Set<string>, target: string) { if ( !target.startsWith('./') || target.includes('*') || target.endsWith('/') ) { return } const file = target.slice(2).replaceAll('\\', '/') if ( file.length === 0 || file.split('/').some((segment) => segment === '..') ) { return } files.add(file) } interface PackagePath { path: string directory: boolean } function validateRootPackagePaths( rootDir: string, files: string[], ): PackagePath[] { const paths: PackagePath[] = [] for (const file of new Set(files)) { const path = join(rootDir, file) if (!existsSync(path)) { throw new Error(`Root release package is incomplete: missing ${file}`) } paths.push({ path: file, directory: statSync(path).isDirectory() }) } return paths } function readNpmPackFiles(packageDir: string, packageDescription: string) { try { const output = execSync('npm pack --dry-run --json --ignore-scri