@napi-rs/cli
Version:
Cli tools for napi-rs
637 lines (588 loc) • 19 kB
text/typescript
import {
lstat,
mkdtemp,
readFile,
readdir,
rm,
rmdir,
writeFile,
} from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { basename, isAbsolute, join, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const directBufferDependency = '^6.0.3'
import {
applyDefaultCreateNpmDirsOptions,
type CreateNpmDirsOptions,
} from '../def/create-npm-dirs.js'
import {
commitFileSystemTransaction,
createWasmModuleTypeDef,
debugFactory,
MINIMUM_WASI_NODE_VERSION,
parseTriple,
readNapiConfig,
pick,
resolvePackageReconciliationPaths,
restrictWasiNodeEngine,
wasiLoaderSuffix,
wasiTargetHasThreads,
withFileSystemReconciliation,
type FileSystemTransactionWrite,
type Target,
type CommonPackageJsonFields,
} from '../utils/index.js'
const debug = debugFactory('create-npm-dirs')
const MANAGED_WASI_PACKAGE_TARGETS = new Map([
['wasm32-wasi', 'wasm32-wasip1-threads'],
['wasm32-wasip1', 'wasm32-wasip1'],
])
const MANAGED_WASI_PACKAGE_DIRS = [...MANAGED_WASI_PACKAGE_TARGETS.keys()]
export interface PackageMeta {
'dist-tags': { [index: string]: string }
}
const WASM_RUNTIME_PACKAGE_NAME = '@napi-rs/wasm-runtime'
interface PendingMetadataWrite {
content: string
destination: string
}
interface ManagedPackageDirectory {
name: string
path: string
}
interface OwnedWasiPackage {
binaryName: string
packageName: string
target: Target
}
async function getLatestWasmRuntimeVersion() {
const npmRegistryBase =
process.env.npm_config_registry?.replace(/\/?$/, '/') ??
'https://registry.npmjs.org/'
const packageMetadataUrl = `${npmRegistryBase}${WASM_RUNTIME_PACKAGE_NAME}`
let response: Response
try {
response = await fetch(packageMetadataUrl)
} catch (error) {
throw new Error(
`Failed to fetch ${packageMetadataUrl} while resolving ${WASM_RUNTIME_PACKAGE_NAME}. Check your network connection and npm registry availability.`,
{ cause: error },
)
}
if (!response.ok) {
throw new Error(
`Failed to fetch ${packageMetadataUrl} while resolving ${WASM_RUNTIME_PACKAGE_NAME}: npm registry responded with ${response.status} ${response.statusText || 'Unknown Status'}`,
)
}
let packageMeta: PackageMeta
try {
packageMeta = (await response.json()) as PackageMeta
} catch (error) {
throw new Error(
`Failed to parse npm registry metadata for ${WASM_RUNTIME_PACKAGE_NAME} from ${packageMetadataUrl}`,
{ cause: error },
)
}
const latestVersion = packageMeta['dist-tags']?.latest
if (typeof latestVersion !== 'string' || latestVersion.trim().length === 0) {
throw new Error(
`npm registry metadata for ${WASM_RUNTIME_PACKAGE_NAME} from ${packageMetadataUrl} did not include a latest dist-tag`,
)
}
return latestVersion.trim()
}
function assertSafeManagedPathSegment(value: string, label: string) {
if (
value.length === 0 ||
value === '.' ||
value === '..' ||
value.includes('\0') ||
value.includes('/') ||
value.includes('\\') ||
isAbsolute(value) ||
basename(value) !== value
) {
throw new Error(
`${label} must be a single filesystem path segment: ${value}`,
)
}
}
async function lstatIfExists(path: string) {
try {
return await lstat(path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return
}
throw error
}
}
async function assertSafeTargetDirectory(path: string) {
const stats = await lstatIfExists(path)
if (!stats) {
return
}
if (stats.isSymbolicLink()) {
throw new Error(
`npm target directory must not be a symbolic link or junction: ${path}`,
)
}
if (!stats.isDirectory()) {
throw new Error(`npm target path is not a directory: ${path}`)
}
}
async function resolveManagedPackageDirectories(
options: ReturnType<typeof applyDefaultCreateNpmDirsOptions>,
initialPaths: ReturnType<typeof resolvePackageReconciliationPaths>,
targets: Target[],
) {
const directoryNames = [
...new Set([
...targets.map((target) => target.platformArchABI),
...MANAGED_WASI_PACKAGE_DIRS,
]),
]
for (const directoryName of directoryNames) {
assertSafeManagedPathSegment(
directoryName,
`Target output identity ${directoryName}`,
)
}
const requestedNpmPath = resolve(options.cwd, options.npmDir)
const requestedTargetPaths = directoryNames.map((directoryName) =>
join(requestedNpmPath, directoryName),
)
await Promise.all(requestedTargetPaths.map(assertSafeTargetDirectory))
const resolvedPaths = resolvePackageReconciliationPaths(
options.cwd,
options.packageJsonPath,
[options.npmDir, ...requestedTargetPaths],
)
if (resolvedPaths.boundary !== initialPaths.boundary) {
throw new Error(
`Managed npm target paths changed the reconciliation boundary from ${initialPaths.boundary} to ${resolvedPaths.boundary}`,
)
}
return new Map(
directoryNames.map((name, index) => [
name,
{
name,
path: resolvedPaths.managedPaths[index + 1],
} satisfies ManagedPackageDirectory,
]),
)
}
function managedWasiGeneratedFiles(binaryName: string, packageDir: string) {
assertSafeManagedPathSegment(binaryName, 'Configured binary name')
const targetTriple = MANAGED_WASI_PACKAGE_TARGETS.get(packageDir)
if (!targetTriple) {
return new Set<string>()
}
const target = parseTriple(targetTriple)
const loaderSuffix = wasiLoaderSuffix(packageDir)
const files = new Set([
`${binaryName}.${packageDir}.wasm`,
`${binaryName}.${packageDir}.debug.wasm`,
`${binaryName}.${loaderSuffix}.cjs`,
`${binaryName}.${loaderSuffix}.d.cts`,
`${binaryName}.${loaderSuffix}-browser.js`,
])
if (wasiTargetHasThreads(target)) {
files.add('wasi-worker.mjs')
files.add('wasi-worker-browser.mjs')
} else {
for (const file of [
`${binaryName}.${packageDir}.wasm.d.ts`,
`${binaryName}.${packageDir}.wasm.d.mts`,
`${binaryName}.${packageDir}.workerd.mjs`,
`${binaryName}.${packageDir}.workerd.d.mts`,
`${binaryName}.${loaderSuffix}-deferred.js`,
`${binaryName}.${loaderSuffix}-deferred.d.ts`,
]) {
files.add(file)
}
}
return files
}
function asJsonRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
async function inspectOwnedWasiPackage(
directory: ManagedPackageDirectory,
): Promise<OwnedWasiPackage | undefined> {
const targetTriple = MANAGED_WASI_PACKAGE_TARGETS.get(directory.name)
if (!targetTriple) {
return
}
const manifestPath = join(directory.path, 'package.json')
const stats = await lstatIfExists(manifestPath)
if (!stats?.isFile()) {
return
}
let manifest: Record<string, unknown> | undefined
try {
manifest = asJsonRecord(JSON.parse(await readFile(manifestPath, 'utf8')))
} catch {
return
}
if (!manifest || typeof manifest.name !== 'string') {
return
}
const packageNameSuffix = `-${directory.name}`
if (
!manifest.name.endsWith(packageNameSuffix) ||
manifest.name.length === packageNameSuffix.length ||
typeof manifest.version !== 'string' ||
typeof manifest.main !== 'string' ||
!Array.isArray(manifest.files) ||
!manifest.files.every((file) => typeof file === 'string') ||
manifest.type !== 'module'
) {
return
}
const loaderSuffix = wasiLoaderSuffix(directory.name)
const mainSuffix = `.${loaderSuffix}.cjs`
if (
!manifest.main.endsWith(mainSuffix) ||
manifest.main.length === mainSuffix.length
) {
return
}
const binaryName = manifest.main.slice(0, -mainSuffix.length)
try {
assertSafeManagedPathSegment(binaryName, 'Managed WASI binary name')
} catch {
return
}
const target = parseTriple(targetTriple)
const requiredFiles = [
`${binaryName}.${directory.name}.wasm`,
`${binaryName}.${loaderSuffix}.cjs`,
`${binaryName}.${loaderSuffix}.d.cts`,
`${binaryName}.${loaderSuffix}-browser.js`,
...(wasiTargetHasThreads(target)
? ['wasi-worker.mjs', 'wasi-worker-browser.mjs']
: [
`${binaryName}.${loaderSuffix}-deferred.js`,
`${binaryName}.${loaderSuffix}-deferred.d.ts`,
`${binaryName}.${directory.name}.wasm.d.ts`,
]),
]
const files = new Set(manifest.files)
if (
manifest.types !== `${binaryName}.${loaderSuffix}.d.cts` ||
manifest.browser !== `${binaryName}.${loaderSuffix}-browser.js` ||
!requiredFiles.every((file) => files.has(file))
) {
return
}
return {
binaryName,
packageName: manifest.name.slice(0, -packageNameSuffix.length),
target,
}
}
async function collectStaleWasiPackageRemovals(
directory: ManagedPackageDirectory,
binaryName: string,
) {
let entries
try {
entries = await readdir(directory.path, { withFileTypes: true })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return []
}
throw error
}
const entriesByName = new Map(entries.map((entry) => [entry.name, entry]))
const owner = await inspectOwnedWasiPackage(directory)
const generatedFiles = managedWasiGeneratedFiles(binaryName, directory.name)
if (owner) {
for (const file of managedWasiGeneratedFiles(
owner.binaryName,
directory.name,
)) {
generatedFiles.add(file)
}
}
const removals: string[] = []
for (const file of generatedFiles) {
const entry = entriesByName.get(file)
if (!entry) {
continue
}
if (!entry.isFile()) {
throw new Error(
`Managed WASI package file must be a regular file: ${join(directory.path, file)}`,
)
}
removals.push(join(directory.path, file))
}
if (owner) {
removals.push(join(directory.path, 'package.json'))
const readmePath = join(directory.path, 'README.md')
if (
entriesByName.get('README.md')?.isFile() &&
(await readFile(readmePath, 'utf8')) ===
readme(owner.packageName, owner.target)
) {
removals.push(readmePath)
}
}
return removals
}
async function publishPackageMetadata(
reconciliationRoot: string,
pendingWrites: PendingMetadataWrite[],
removals: string[],
) {
const stagingRoot = await mkdtemp(
join(tmpdir(), 'napi-rs-create-npm-dirs-stage-'),
)
try {
const writes: FileSystemTransactionWrite[] = []
for (const [index, pendingWrite] of pendingWrites.entries()) {
const source = join(stagingRoot, String(index))
await writeFile(source, pendingWrite.content)
writes.push({
destination: pendingWrite.destination,
source,
})
}
if (writes.length > 0 || removals.length > 0) {
await commitFileSystemTransaction(reconciliationRoot, writes, removals)
}
} finally {
await rm(stagingRoot, { force: true, recursive: true })
}
}
async function removeEmptyStalePackageDirectories(
directories: ManagedPackageDirectory[],
) {
for (const directory of directories) {
try {
await rmdir(directory.path)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') {
debug.warn(
`Failed to remove empty stale npm package directory ${directory.path}: ${String(error)}`,
)
}
}
}
}
async function createNpmDirsUnlocked(
options: ReturnType<typeof applyDefaultCreateNpmDirsOptions>,
initialPaths: ReturnType<typeof resolvePackageReconciliationPaths>,
) {
const packageJsonPath = initialPaths.packageJsonPath
debug(`Read content from [${options.configPath ?? packageJsonPath}]`)
const { targets, binaryName, packageName, packageJson, wasm } =
await readNapiConfig(
packageJsonPath,
options.configPath ? resolve(options.cwd, options.configPath) : undefined,
)
assertSafeManagedPathSegment(binaryName, 'Configured binary name')
const packageDirectories = await resolveManagedPackageDirectories(
options,
initialPaths,
targets,
)
const configuredPackageDirs = new Set(
targets.map((target) => target.platformArchABI),
)
const staleWasiDirectories = MANAGED_WASI_PACKAGE_DIRS.filter(
(packageDir) => !configuredPackageDirs.has(packageDir),
).map((packageDir) => packageDirectories.get(packageDir)!)
const staleWasiRemovals = (
await Promise.all(
staleWasiDirectories.map((directory) =>
collectStaleWasiPackageRemovals(directory, binaryName),
),
)
).flat()
const wasmRuntimeVersion = targets.some((target) => target.arch === 'wasm32')
? await getLatestWasmRuntimeVersion()
: undefined
const pendingWrites: PendingMetadataWrite[] = []
for (const target of targets) {
const targetDir = packageDirectories.get(target.platformArchABI)!.path
debug('Plan npm package dir: %i', targetDir)
const binaryFileName =
target.arch === 'wasm32'
? `${binaryName}.${target.platformArchABI}.wasm`
: `${binaryName}.${target.platformArchABI}.node`
let wasmModuleTypeDef: string | undefined
const scopedPackageJson: CommonPackageJsonFields = {
name: `${packageName}-${target.platformArchABI}`,
version: packageJson.version,
// WASI modules execute inside a normal host Node/browser/workerd process.
// Marking them as cpu=wasm32 makes npm reject direct installation and
// silently skip the package when it is an optional dependency on x64 or
// arm64 hosts.
cpu:
target.arch !== 'universal' && target.arch !== 'wasm32'
? [target.arch]
: undefined,
main: binaryFileName,
files: [binaryFileName],
...pick(
packageJson,
'description',
'keywords',
'author',
'authors',
'homepage',
'license',
'engines',
'repository',
'bugs',
),
}
if (packageJson.publishConfig) {
scopedPackageJson.publishConfig = pick(
packageJson.publishConfig,
'registry',
'access',
)
}
if (target.arch !== 'wasm32') {
scopedPackageJson.os = [target.platform]
} else {
const loaderSuffix = wasiLoaderSuffix(target.platformArchABI)
const entry = `${binaryName}.${loaderSuffix}.cjs`
const loaderTypeDef = `${binaryName}.${loaderSuffix}.d.cts`
scopedPackageJson.main = entry
scopedPackageJson.types = loaderTypeDef
scopedPackageJson.browser = `${binaryName}.${loaderSuffix}-browser.js`
scopedPackageJson.type = 'module'
scopedPackageJson.files?.push(
entry,
loaderTypeDef,
scopedPackageJson.browser,
)
if (wasiTargetHasThreads(target)) {
// worker scripts are only referenced by the threaded loaders
scopedPackageJson.files?.push(
`wasi-worker.mjs`,
`wasi-worker-browser.mjs`,
)
} else {
const deferredEntry = `${binaryName}.${loaderSuffix}-deferred.js`
const deferredTypeDef = `${binaryName}.${loaderSuffix}-deferred.d.ts`
wasmModuleTypeDef = `${binaryFileName}.d.ts`
// the deferred workerd-safe loader is only emitted for non-threaded
// WASI builds (mirrors `hasThreads` in `writeWasiBinding`)
scopedPackageJson.files?.push(
deferredEntry,
deferredTypeDef,
wasmModuleTypeDef,
)
scopedPackageJson.exports = {
'.': {
types: `./${loaderTypeDef}`,
browser: `./${scopedPackageJson.browser}`,
require: `./${entry}`,
default: `./${entry}`,
},
'./workerd': {
types: `./${deferredTypeDef}`,
default: `./${deferredEntry}`,
},
'./wasm': {
types: `./${wasmModuleTypeDef}`,
default: `./${binaryFileName}`,
},
'./wasm.wasm': {
types: `./${wasmModuleTypeDef}`,
default: `./${binaryFileName}`,
},
'./package.json': './package.json',
}
}
scopedPackageJson.engines = {
...scopedPackageJson.engines,
node: scopedPackageJson.engines?.node
? restrictWasiNodeEngine(scopedPackageJson.engines.node)
: MINIMUM_WASI_NODE_VERSION,
}
const emnapiVersion = require('emnapi/package.json').version
scopedPackageJson.dependencies = {
// Runtime minor releases can target a different emnapi generation.
// Keep generated packages on the resolved minor while allowing fixes.
'@napi-rs/wasm-runtime': `~${wasmRuntimeVersion}`,
'@emnapi/core': emnapiVersion,
'@emnapi/runtime': emnapiVersion,
...(wasm?.browser?.buffer === true &&
(wasm.browser.fs !== true || !wasiTargetHasThreads(target))
? { buffer: directBufferDependency }
: {}),
}
}
if (target.abi === 'gnu') {
scopedPackageJson.libc = ['glibc']
} else if (target.abi === 'musl') {
scopedPackageJson.libc = ['musl']
}
const targetPackageJson = join(targetDir, 'package.json')
pendingWrites.push({
content: JSON.stringify(scopedPackageJson, null, 2) + '\n',
destination: targetPackageJson,
})
if (wasmModuleTypeDef) {
pendingWrites.push({
content: createWasmModuleTypeDef(),
destination: join(targetDir, wasmModuleTypeDef),
})
}
const targetReadme = join(targetDir, 'README.md')
pendingWrites.push({
content: readme(packageName, target),
destination: targetReadme,
})
debug.info(`${packageName} -${target.platformArchABI} created`)
}
for (const { content, destination } of pendingWrites) {
debug('Writing file %i', destination)
if (options.dryRun) {
debug(content)
}
}
for (const removal of staleWasiRemovals) {
debug('Removing stale managed file %i', removal)
}
if (options.dryRun) {
return
}
await publishPackageMetadata(
initialPaths.boundary,
pendingWrites,
staleWasiRemovals,
)
await removeEmptyStalePackageDirectories(staleWasiDirectories)
}
export async function createNpmDirs(userOptions: CreateNpmDirsOptions) {
const options = applyDefaultCreateNpmDirsOptions(userOptions)
const resolvedPaths = resolvePackageReconciliationPaths(
options.cwd,
options.packageJsonPath,
[options.npmDir],
)
if (options.dryRun) {
return createNpmDirsUnlocked(options, resolvedPaths)
}
return withFileSystemReconciliation(resolvedPaths.boundary, () =>
createNpmDirsUnlocked(options, resolvedPaths),
)
}
function readme(packageName: string, target: Target) {
return `# \`${packageName}-${target.platformArchABI}\`
This is the **${target.triple}** binary for \`${packageName}\`
`
}