UNPKG

@napi-rs/cli

Version:

Cli tools for napi-rs

637 lines (588 loc) 19 kB
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}\` ` }