@napi-rs/cli
Version:
Cli tools for napi-rs
1,688 lines (1,601 loc) • 90.4 kB
text/typescript
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