UNPKG

@napi-rs/cli

Version:

Cli tools for napi-rs

1,518 lines (1,401 loc) 81.9 kB
import { spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { mkdtemp as mkdtempAsync, rm } from 'node:fs/promises' import { createRequire } from 'node:module' import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep, } from 'node:path' import * as colors from 'colorette' import type { BuildOptions as RawBuildOptions } from '../def/build.js' import { CLI_VERSION, commitFileSystemTransaction, copyFileAtomic, type Crate, debugFactory, DEFAULT_TYPE_DEF_HEADER, fileExists, getSystemDefaultTarget, getNapiDeriveDependentCrates, getPackageReconciliationRoot, getTargetLinker, mkdirAsync, type NapiConfig, parseMetadata, parseTriple, processTypeDefs, readFileAsync, readNapiConfig, rebaseDeclarationSpecifiers, removeNodeStreamWebTypeImports, rewriteUnboundNodeGlobalTypeQueries, rewriteTypeImportReferences, type Target, targetToEnvVar, tryInstallCargoBinary, unlinkAsync, wasiLoaderSuffix, wasiTargetHasThreads, writeFileAtomic, withFileSystemReconciliation, dirExistsAsync, readdirAsync, type CargoWorkspaceMetadata, } from '../utils/index.js' import { createCjsBinding, createEsmBinding } from './templates/index.js' import { createWasiBinding, createWasiBrowserBinding, createWasiDeferredBrowserBinding, createWasiDeferredBrowserBindingTypeDef, } from './templates/load-wasi-template.js' import { createWasiBrowserWorkerBinding, WASI_WORKER_TEMPLATE, } from './templates/wasi-worker-template.js' const debug = debugFactory('build') const require = createRequire(import.meta.url) const MANAGED_WASI_FLAVORS = [ { platformArchABI: 'wasm32-wasi', loaderSuffix: 'wasi', hasThreads: true }, { platformArchABI: 'wasm32-wasip1', loaderSuffix: 'wasip1', hasThreads: false, }, ] as const type OutputKind = 'js' | 'dts' | 'node' | 'exe' | 'wasm' type Output = { kind: OutputKind; path: string } type WasiBindingMetadata = { exports: string[] bindingTypeDef?: string } type BuildOptions = RawBuildOptions & { cargoOptions?: string[] } type ParsedBuildOptions = Omit<BuildOptions, 'cwd'> & { cwd: string } export const WASI_ARTIFACT_METADATA_PREFIX = '// napi-rs-artifact-metadata:' type CargoConfigFingerprint = readonly [path: string, hash: string] export function getCargoDependencyGraphFingerprint( metadata: CargoWorkspaceMetadata, rootPackageId: string, ): string { const packages = new Map(metadata.packages.map((pkg) => [pkg.id, pkg])) const nodes = new Map( (metadata.resolve?.nodes ?? []).map((node) => [node.id, node]), ) const pending = [rootPackageId] const visited = new Set<string>() const graph = [] while (pending.length > 0) { const id = pending.pop()! if (visited.has(id)) { continue } visited.add(id) const node = nodes.get(id) const pkg = packages.get(id) const dependencies = (node?.deps ?? []) .map((dependency) => ({ name: dependency.name, pkg: dependency.pkg, kinds: dependency.dep_kinds .map(({ kind, target }) => ({ kind, target })) .sort( (left, right) => (left.kind ?? '').localeCompare(right.kind ?? '') || (left.target ?? '').localeCompare(right.target ?? ''), ), })) .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)), ) for (const dependency of dependencies) { pending.push(dependency.pkg) } graph.push({ id, manifestPath: pkg?.manifest_path, resolvedFeatures: [...(node?.features ?? [])].sort(), dependencies, declaredDependencies: (pkg?.dependencies ?? []) .map((dependency) => ({ name: dependency.name, source: dependency.source, requirement: dependency.req, kind: dependency.kind, rename: dependency.rename, optional: dependency.optional, usesDefaultFeatures: dependency.uses_default_features, features: [...dependency.features].sort(), target: dependency.target, registry: dependency.registry, })) .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)), ), declaredFeatures: Object.entries(pkg?.features ?? {}) .map(([name, features]) => [name, [...features].sort()] as const) .sort(([left], [right]) => left.localeCompare(right)), }) } graph.sort((left, right) => left.id.localeCompare(right.id)) return createHash('sha256').update(JSON.stringify(graph)).digest('hex') } export function getTypeDefCacheFolder(options: { targetDir: string crateName: string manifestPath: string targetTriple: string profile: string features?: string[] allFeatures?: boolean noDefaultFeatures?: boolean cargoOptions?: string[] rustFlags?: Record<string, string | undefined> cargoProfileEnv?: Record<string, string | undefined> cargoConfig?: CargoConfigFingerprint[] cargoDependencyGraph?: string }) { const features = [ ...new Set( (options.features ?? []).flatMap((feature) => feature.split(/[,\s]+/).filter(Boolean), ), ), ].sort() const rustFlags = Object.entries(options.rustFlags ?? {}) .filter((entry): entry is [string, string] => entry[1] !== undefined) .sort(([left], [right]) => left.localeCompare(right)) const cargoConfig = [...(options.cargoConfig ?? [])].sort(([left], [right]) => left.localeCompare(right), ) const cargoProfileEnv = Object.entries(options.cargoProfileEnv ?? {}) .filter((entry): entry is [string, string] => entry[1] !== undefined) .sort(([left], [right]) => left.localeCompare(right)) const identity = JSON.stringify({ version: 4, cliVersion: CLI_VERSION, manifestPath: options.manifestPath, cargoDependencyGraph: options.cargoDependencyGraph, targetTriple: options.targetTriple, profile: options.profile, features: { selected: features, all: options.allFeatures === true, noDefault: options.noDefaultFeatures === true, }, cargoOptions: options.cargoOptions ?? [], rustFlags, cargoProfileEnv, cargoConfig, }) const hash = createHash('sha256') .update(identity) .digest('hex') .substring(0, 16) return join(options.targetDir, 'napi-rs', `${options.crateName}-${hash}`) } export function createWasiCompilerFlags( wasiSdkPath: string, wasiTarget: string, hasThreads: boolean, shellEscapedFlags = true, ) { const compileArguments = [ `--target=${wasiTarget}`, `--sysroot=${join(wasiSdkPath, 'share', 'wasi-sysroot')}`, ...(hasThreads ? ['-pthread'] : []), '-mllvm', '-wasm-enable-sjlj', ] const linkerArguments = [ `-fuse-ld=${join(wasiSdkPath, 'bin', 'wasm-ld')}`, `--target=${wasiTarget}`, ] return { compileFlags: joinCompilerArguments(compileArguments, shellEscapedFlags), linkerFlags: joinCompilerArguments(linkerArguments, shellEscapedFlags), } } export function createArtifactDestinationName( binaryName: string, target: Target, sourceName: string, platform: boolean, ) { let destinationName = binaryName if (platform || target.platform === 'wasi') { destinationName += `.${target.platformArchABI}` } return `${destinationName}.${sourceName.endsWith('.wasm') ? 'wasm' : 'node'}` } function joinCompilerArguments( arguments_: string[], shellEscapedFlags: boolean, ) { if (!shellEscapedFlags) { const argumentWithWhitespace = arguments_.find((argument) => /\s/.test(argument), ) if (argumentWithWhitespace) { throw new Error( `CC_SHELL_ESCAPED_FLAGS disables shell parsing, so the WASI SDK argument cannot contain whitespace: ${argumentWithWhitespace}`, ) } return arguments_.join(' ') } return arguments_ .map((argument) => `'${argument.replaceAll("'", "'\\''")}'`) .join(' ') } function envBoolean(value: string | undefined) { return ( value !== undefined && value !== '' && value !== '0' && value !== 'false' && value !== 'no' ) } function createWasiArtifactMetadata( rootEntry: string | null, binaryName: string, exports: string[], ) { return `${WASI_ARTIFACT_METADATA_PREFIX}${JSON.stringify({ version: 2, rootEntry, exports, managedRootEntries: [ 'browser.js', ...(rootEntry ? [rootEntry] : []), `${binaryName}.wasm`, `${binaryName}.debug.wasm`, ], })}\n` } function parseExistingWasiArtifactMetadata(content: string) { const firstLine = content.split(/\r?\n/, 1)[0] if (!firstLine.startsWith(WASI_ARTIFACT_METADATA_PREFIX)) { return undefined } try { const metadata = JSON.parse( firstLine.slice(WASI_ARTIFACT_METADATA_PREFIX.length), ) as Record<string, unknown> if ( (metadata.version !== 1 && metadata.version !== 2) || (metadata.rootEntry !== null && typeof metadata.rootEntry !== 'string') ) { return undefined } const managedRootEntries = metadata.version === 1 ? ['browser.js', ...(metadata.rootEntry ? [metadata.rootEntry] : [])] : metadata.managedRootEntries if ( !Array.isArray(managedRootEntries) || !managedRootEntries.every((entry) => typeof entry === 'string') ) { return undefined } const exports = metadata.exports return { managedRootEntries, exports: Array.isArray(exports) && exports.every((entry) => typeof entry === 'string') ? exports : undefined, } } catch { return undefined } } function resolveManagedOutputPath( outputDir: string, entry: string, description: string, ) { if (!entry || isAbsolute(entry)) { throw new Error( `${description} must be a non-empty relative path: ${entry}`, ) } const outputRoot = resolve(outputDir) const path = resolve(outputRoot, entry) const relativePath = relative(outputRoot, path) if ( relativePath === '' || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath) ) { throw new Error(`${description} escapes its output directory: ${entry}`) } return path } export function selectWasiBrowserTarget( buildTarget: Target, configuredTargets: Target[], emittedTargets: Target[], ) { if (buildTarget.platform === 'wasi') { return buildTarget } const configuredWasiTargets = configuredTargets.filter( (target) => target.platform === 'wasi', ) return ( configuredWasiTargets.find((target) => !wasiTargetHasThreads(target)) ?? configuredWasiTargets[0] ?? emittedTargets.find((target) => !wasiTargetHasThreads(target)) ?? emittedTargets[0] ) } export function createWasiBrowserEntry( packageName: string, platformArchABI: string, idents: string[], ) { const packageSpecifier = `${packageName}-${platformArchABI}` return ( `export * from '${packageSpecifier}'\n` + (idents.length === 0 ? `export { default } from '${packageSpecifier}'\n` : '') ) } export function createWasiDeferredBindingTypeDef( bindingModuleSpecifier: string, hasTypeDef: boolean, ) { const typeDef = createWasiDeferredBrowserBindingTypeDef( bindingModuleSpecifier, ) if (hasTypeDef) { return typeDef } const rootBindingType = `typeof import('${bindingModuleSpecifier}')` if (!typeDef.includes(rootBindingType)) { throw new Error( 'The deferred WASI type definition no longer contains its root binding type', ) } return typeDef.replace(rootBindingType, 'Record<string, unknown>') } export function prepareWasiBindingTypeDef( source: string, sourcePath: string, destinationPath: string, hasThreads: boolean, packageType?: 'module' | 'commonjs', ) { if ( sourcePath.endsWith('.d.mts') || (sourcePath.endsWith('.d.ts') && packageType === 'module') ) { throw new Error( `Cannot emit the CommonJS WASI declaration ${destinationPath} from the ESM declaration ${sourcePath}. Use a .d.cts --dts path for WASI builds in module packages.`, ) } const targetSource = hasThreads ? source : rewriteUnboundNodeGlobalTypeQueries( removeNodeStreamWebTypeImports(source), ) return rebaseDeclarationSpecifiers(targetSource, sourcePath, destinationPath) } export function collectStaleWasiBuildOutputNames( binaryName: string, buildTarget: Target, configuredTargets: Target[], ) { const staleNames = new Set<string>() const retainedFlavors = new Set( [ ...configuredTargets.filter((target) => target.platform === 'wasi'), buildTarget, ].map((target) => target.platformArchABI), ) for (const flavor of MANAGED_WASI_FLAVORS) { const regenerated = flavor.platformArchABI === buildTarget.platformArchABI if (!regenerated && retainedFlavors.has(flavor.platformArchABI)) { continue } for (const suffix of [ `${flavor.loaderSuffix}.cjs`, `${flavor.loaderSuffix}.d.cts`, `${flavor.loaderSuffix}-browser.js`, `${flavor.loaderSuffix}-deferred.js`, `${flavor.loaderSuffix}-deferred.d.ts`, ...(!regenerated ? [ `${flavor.platformArchABI}.wasm`, `${flavor.platformArchABI}.debug.wasm`, ] : []), ]) { staleNames.add(`${binaryName}.${suffix}`) } } const regeneratesWorkers = wasiTargetHasThreads(buildTarget) const retainsThreadedFlavor = MANAGED_WASI_FLAVORS.some( (flavor) => flavor.hasThreads && retainedFlavors.has(flavor.platformArchABI), ) if (regeneratesWorkers || !retainsThreadedFlavor) { staleNames.add('wasi-worker.mjs') staleNames.add('wasi-worker-browser.mjs') } return staleNames } export function removeWasmCustomSection( binary: Uint8Array, sectionName: string, ): Uint8Array { if ( binary.length < 8 || binary[0] !== 0x00 || binary[1] !== 0x61 || binary[2] !== 0x73 || binary[3] !== 0x6d ) { throw new Error('Invalid WebAssembly module header') } const retainedChunks: Uint8Array[] = [] let retainedStart = 0 let offset = 8 let removed = false while (offset < binary.length) { const sectionStart = offset const sectionId = binary[offset++] const sectionSize = readWasmU32(binary, offset) const payloadStart = sectionSize.nextOffset const sectionEnd = payloadStart + sectionSize.value if (sectionEnd > binary.length) { throw new Error('Invalid WebAssembly section size') } let shouldRemove = false if (sectionId === 0) { const nameSize = readWasmU32(binary, payloadStart) const nameEnd = nameSize.nextOffset + nameSize.value if (nameEnd > sectionEnd) { throw new Error('Invalid WebAssembly custom section name') } shouldRemove = Buffer.from(binary.subarray(nameSize.nextOffset, nameEnd)).toString( 'utf8', ) === sectionName } if (shouldRemove) { retainedChunks.push(binary.subarray(retainedStart, sectionStart)) retainedStart = sectionEnd removed = true } offset = sectionEnd } if (!removed) { return binary } retainedChunks.push(binary.subarray(retainedStart)) const outputLength = retainedChunks.reduce( (length, chunk) => length + chunk.length, 0, ) const output = new Uint8Array(outputLength) let outputOffset = 0 for (const chunk of retainedChunks) { output.set(chunk, outputOffset) outputOffset += chunk.length } return output } function readWasmU32(binary: Uint8Array, start: number) { let value = 0 let shift = 0 let offset = start while (offset < binary.length && shift <= 28) { const byte = binary[offset++] value += (byte & 0x7f) * 2 ** shift if ((byte & 0x80) === 0) { return { value, nextOffset: offset } } shift += 7 } throw new Error('Invalid WebAssembly unsigned LEB128 value') } export async function buildProject(rawOptions: BuildOptions) { debug('napi build command receive options: %O', rawOptions) const options: ParsedBuildOptions = { dtsCache: true, ...rawOptions, cwd: rawOptions.cwd ?? process.cwd(), } // Reject invalid cross-compilation flag combinations before anything with // a side effect runs (`cargo metadata`, cargo binary auto-installs, // toolchain downloads, ...), so the user always gets the validation error // rather than an unrelated failure from those steps. validateCrossCompileFlags(options) if (options.useNapiCross) { // Check the host constraint before resolving the target: without an // explicit `--target` (or `CARGO_BUILD_TARGET`) the target resolution // spawns `rustc -vV`, and on an unsupported host without Rust on the // `PATH` that spawn failure would mask the actual validation error. validateNapiCrossHost() validateNapiCrossSupport(resolveTarget(options.target).triple) } const resolvePath = (...paths: string[]) => resolve(options.cwd, ...paths) const manifestPath = resolvePath(options.manifestPath ?? 'Cargo.toml') const metadataTarget = options.target ?? process.env.CARGO_BUILD_TARGET const metadata = await parseMetadata(manifestPath, { cwd: options.cwd, featurePackage: options.package, features: options.features, allFeatures: options.allFeatures, noDefaultFeatures: options.noDefaultFeatures, cargoOptions: options.cargoOptions, filterPlatform: metadataTarget ? parseTriple(metadataTarget).triple : undefined, }) const crate = metadata.packages.find((p) => { // package with given name if (options.package) { return p.name === options.package } else { return p.manifest_path === manifestPath } }) if (!crate) { throw new Error( 'Unable to find crate to build. It seems you are trying to build a crate in a workspace, try using `--package` option to specify the package to build.', ) } const config = await readNapiConfig( resolvePath(options.packageJsonPath ?? 'package.json'), options.configPath ? resolvePath(options.configPath) : undefined, ) const builder = new Builder(metadata, crate, config, options) return builder.build() } /** * Resolve the target triple the build will run against, following the same * precedence the build itself uses: the explicit `--target` option, then the * `CARGO_BUILD_TARGET` environment variable, then the host default target. */ function resolveTarget(targetOption?: string): Target { return targetOption ? parseTriple(targetOption) : process.env.CARGO_BUILD_TARGET ? parseTriple(process.env.CARGO_BUILD_TARGET) : getSystemDefaultTarget() } /** * Validate the combination of the cross-compilation related flags. * * `--use-cross`, `--use-napi-cross` and `--cross-compile` (`-x`) are three * mutually exclusive cross-compilation mechanisms; combining any two of them * leaves both active at once and produces broken builds, so it is rejected * upfront before any side effect (like auto-installing cargo binaries or * downloading toolchains) happens. * * `--cross-compile` with a `windows-gnu` target is rejected as well: on a * non-Windows host it routes the build to `cargo xwin build`, but * `cargo-xwin` only sets up MSVC toolchains — for `windows-gnu` targets it * silently does nothing and the build dies much later with a cryptic * ``error: linker `x86_64-w64-mingw32-gcc` not found`` that never mentions * `cargo-xwin`. Only an explicitly requested target (`--target` or * `CARGO_BUILD_TARGET`) is inspected, so this validation never has to spawn * `rustc -vV`; a non-Windows host's default target can never be * `windows-gnu` anyway. */ export function validateCrossCompileFlags( options: { useCross?: boolean crossCompile?: boolean useNapiCross?: boolean watch?: boolean target?: string }, hostPlatform: string = process.platform, ): void { const enabledCrossFlags = [ options.useCross ? '`--use-cross`' : null, options.useNapiCross ? '`--use-napi-cross`' : null, options.crossCompile ? '`--cross-compile` (`-x`)' : null, ].filter((flag): flag is string => flag !== null) if (enabledCrossFlags.length > 1) { throw new Error( `${enabledCrossFlags.join(' and ')} cannot be used together. Please pick exactly one cross-compilation mechanism: \`--use-cross\`, \`--use-napi-cross\`, or \`--cross-compile\` (\`-x\`).`, ) } if (options.watch && options.useCross) { throw new Error( '`--watch` cannot be used with `--use-cross`. `cargo watch` only supports the plain `cargo build` flow, please drop one of the two flags.', ) } if (options.watch && options.crossCompile) { throw new Error( '`--watch` cannot be used with `--cross-compile` (`-x`). `cargo watch` only supports the plain `cargo build` flow, please drop one of the two flags.', ) } // On a Windows host `--cross-compile` never picks `cargo-xwin` (it falls // back to a plain `cargo build`), so windows-gnu targets are only broken // on non-Windows hosts. if (options.crossCompile && hostPlatform !== 'win32') { const explicitTarget = options.target ?? process.env.CARGO_BUILD_TARGET if (explicitTarget) { const target = parseTriple(explicitTarget) if (target.platform === 'win32' && target.abi?.startsWith('gnu')) { const msvcTriple = explicitTarget.replace(/gnu(llvm)?$/, 'msvc') // `*-windows-gnu` links with a mingw-w64 GCC toolchain, while // `*-windows-gnullvm` needs an LLVM toolchain (llvm-mingw): `rustc` // has no default working linker for it without one on the `PATH`. const toolchainHint = target.abi === 'gnullvm' ? `an llvm-mingw (LLVM/Clang) toolchain on the \`PATH\` (\`rustc\` has no default working linker for ${explicitTarget} without one; \`napi-build\` additionally needs \`libnode.dll\` via the \`LIBNODE_PATH\` environment variable)` : `a mingw-w64 toolchain (\`rustc\` uses the \`${explicitTarget.split('-')[0]}-w64-mingw32-gcc\` linker; \`napi-build\` additionally needs \`libnode.dll\` via the \`LIBNODE_PATH\` environment variable)` throw new Error( `\`--cross-compile\` (\`-x\`) does not support the target ${explicitTarget}: \`cargo-xwin\` only handles MSVC targets and the build would fail at link time. Drop \`-x\` and build with ${toolchainHint}, or target ${msvcTriple} instead.`, ) } } } } /** * Validate that the current host can run the `@napi-rs/cross-toolchain` * pre-built toolchains at all: they only run on Linux x64 and Linux arm64 * hosts. This check is separate from (and must run before) the per-target * validation, because resolving the target may need to spawn `rustc`. */ function validateNapiCrossHost( hostPlatform: string = process.platform, hostArch: string = process.arch, ): asserts hostArch is 'x64' | 'arm64' { if ( hostPlatform !== 'linux' || (hostArch !== 'x64' && hostArch !== 'arm64') ) { throw new Error( `\`--use-napi-cross\` requires a Linux x64 or Linux arm64 host, but the current host is ${hostPlatform}-${hostArch}. Please use \`--cross-compile\` (\`-x\`) or \`--use-cross\` to cross compile on this host.`, ) } } /** * Validate that `--use-napi-cross` can actually handle the requested target * on the current host. The supported target set is read from the * `@napi-rs/cross-toolchain` package, and the pre-built toolchains only run * on Linux x64 and Linux arm64 hosts. */ export function validateNapiCrossSupport( targetTriple: string, hostPlatform: string = process.platform, hostArch: string = process.arch, ): void { validateNapiCrossHost(hostPlatform, hostArch) const toolchains: Record< 'x64' | 'arm64', Record<string, string | undefined> > = require('@napi-rs/cross-toolchain') const supportedTargets = Object.keys(toolchains[hostArch]) if (!supportedTargets.includes(targetTriple)) { throw new Error( `\`--use-napi-cross\` does not support the target ${targetTriple}. Supported targets: ${supportedTargets.join(', ')}. Please use \`--cross-compile\` (\`-x\`) or \`--use-cross\` for this target.`, ) } } /** * Compute the environment variables that route a `--use-napi-cross` build * through the `@napi-rs/cross-toolchain` toolchain extracted at * `toolchainPath`. * * Follows the same rule as `Builder#setEnvIfNotExists`: a variable the user * already set in `env` wins and is not returned, and a present-but-empty * variable counts as unset (falsy semantics). The only exceptions are the * clang-specific `TARGET_CFLAGS`/`TARGET_CXXFLAGS`, which prepend the sysroot * flags to the user's value, and `PATH`, which always gets the toolchain * `bin` directory prepended. * * Exported for tests. */ export function napiCrossToolchainEnvs( toolchainPath: string, targetTriple: string, env: NodeJS.ProcessEnv = process.env, ): Record<string, string> { const alias: Record<string, string> = { 's390x-unknown-linux-gnu': 's390x-ibm-linux-gnu', } const envs: Record<string, string> = {} const setEnvIfNotExists = (name: string, value: string) => { if (!env[name]) { envs[name] = value } } const upperCaseTarget = targetToEnvVar(targetTriple) const crossTargetName = alias[targetTriple] ?? targetTriple setEnvIfNotExists( `CARGO_TARGET_${upperCaseTarget}_LINKER`, join(toolchainPath, 'bin', `${crossTargetName}-gcc`), ) setEnvIfNotExists( 'TARGET_SYSROOT', join(toolchainPath, crossTargetName, 'sysroot'), ) setEnvIfNotExists( 'TARGET_AR', join(toolchainPath, 'bin', `${crossTargetName}-ar`), ) setEnvIfNotExists( 'TARGET_RANLIB', join(toolchainPath, 'bin', `${crossTargetName}-ranlib`), ) setEnvIfNotExists( 'TARGET_READELF', join(toolchainPath, 'bin', `${crossTargetName}-readelf`), ) setEnvIfNotExists( 'TARGET_C_INCLUDE_PATH', join(toolchainPath, crossTargetName, 'sysroot', 'usr', 'include/'), ) setEnvIfNotExists( 'TARGET_CC', join(toolchainPath, 'bin', `${crossTargetName}-gcc`), ) setEnvIfNotExists( 'TARGET_CXX', join(toolchainPath, 'bin', `${crossTargetName}-g++`), ) // `setEnvIfNotExists` skips `envs` when the user already set the variable // in their environment, so read the effective value back from `env` first // — with the same falsy semantics: a present-but-empty `TARGET_SYSROOT` // counts as unset and falls back to the downloaded sysroot (`??` would // keep the empty string and produce a broken `--sysroot=`). const targetSysroot = env.TARGET_SYSROOT || envs.TARGET_SYSROOT setEnvIfNotExists('BINDGEN_EXTRA_CLANG_ARGS', `--sysroot=${targetSysroot}`) // cc-rs parses the env value before executing it (`env_tool` in cc's // lib.rs): when the WHOLE value exists on the filesystem (`check_exe`) // it is the compiler as-is — that is how it supports spaces in paths // like `/opt/LLVM 18/bin/clang` — and only otherwise is the value split // on whitespace; then, when the first token is a known wrapper // (`sccache clang`) the second token is the compiler that actually runs, // otherwise the first token is and the rest are arguments // (`clang -target …`). Mirror that ordering, filesystem probe included: // a pure whole-value basename heuristic would misread argument forms // that merely END in `clang`, like `gcc --sysroot=/opt/clang`, and // inject clang-only flags into a gcc compile. Match on the executable // name so path-qualified (`/usr/bin/clang`), triple-prefixed // (`aarch64-linux-gnu-clang`) and versioned (`clang-18`) compilers are // all recognized — but not clang-family tools that are not compilers // (`clang-format`). const ccWrappers = new Set([ 'ccache', 'distcc', 'sccache', 'icecc', 'cachepot', 'buildcache', 'kache', ]) const clangExecutableName = /(^|-)clang(\+\+)?(-\d+)?$/ // cc-rs's `check_exe` only probes for existence (plus an `.exe` retry on // Windows, where this napi-cross path never runs); requiring a regular // file additionally keeps directories from being taken for compilers. const isExistingFile = (path: string): boolean => { try { return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false } catch { return false } } const isClangCompiler = (value: string | undefined): boolean => { if (!value) { return false } const trimmed = value.trim() if (isExistingFile(trimmed)) { return clangExecutableName.test(basename(trimmed)) } const [first, second] = trimmed.split(/\s+/) const compiler = first && ccWrappers.has(basename(first)) ? second : first return ( compiler !== undefined && clangExecutableName.test(basename(compiler)) ) } // Detect clang on the EFFECTIVE target compiler: the user's TARGET_CC if // set, otherwise the toolchain gcc exported above (`envs.TARGET_CC`). // cc-rs prefers TARGET_CC over CC for cross builds, so a plain `CC=clang` // never runs here and must not trigger the clang-only `--gcc-toolchain=` // flag (gcc hard-errors on it). The trailing `env.CC`/`env.CXX` terms are // unreachable today (exactly one of the first two is always truthy) but // keep the check correct if the toolchain default ever becomes conditional. if (isClangCompiler(env.TARGET_CC || envs.TARGET_CC || env.CC)) { const TARGET_CFLAGS = env.TARGET_CFLAGS || '' envs.TARGET_CFLAGS = `--sysroot=${targetSysroot} --gcc-toolchain=${toolchainPath} ${TARGET_CFLAGS}` } if (isClangCompiler(env.TARGET_CXX || envs.TARGET_CXX || env.CXX)) { const TARGET_CXXFLAGS = env.TARGET_CXXFLAGS || '' envs.TARGET_CXXFLAGS = `--sysroot=${targetSysroot} --gcc-toolchain=${toolchainPath} ${TARGET_CXXFLAGS}` } envs.PATH = env.PATH ? `${toolchainPath}/bin:${env.PATH}` : `${toolchainPath}/bin` return envs } class Builder { private readonly args: string[] = [] private readonly envs: Record<string, string> = {} private readonly outputs: Output[] = [] private readonly target: Target private readonly crateDir: string private readonly finalOutputDir: string private outputDir: string private readonly reconciliationRoot: string private readonly targetDir: string private readonly enableTypeDef: boolean = false private readonly stagedOutputDestinations = new Map<string, string>() private typeDefWithTypeImports: string | undefined constructor( private readonly metadata: CargoWorkspaceMetadata, private readonly crate: Crate, private readonly config: NapiConfig, private readonly options: ParsedBuildOptions, ) { this.target = resolveTarget(options.target) this.crateDir = parse(crate.manifest_path).dir this.finalOutputDir = resolve( this.options.cwd, options.outputDir ?? this.crateDir, ) this.outputDir = this.finalOutputDir this.reconciliationRoot = getPackageReconciliationRoot( this.options.cwd, this.options.packageJsonPath, ) this.targetDir = options.targetDir ?? process.env.CARGO_BUILD_TARGET_DIR ?? metadata.target_directory this.enableTypeDef = this.crate.dependencies.some( (dep) => dep.name === 'napi-derive' && (dep.uses_default_features || dep.features.includes('type-def')), ) if (!this.enableTypeDef) { const requirementWarning = '`napi-derive` crate is not used or `type-def` feature is not enabled for `napi-derive` crate' debug.warn( `${requirementWarning}. Will skip binding generation for \`.node\`, \`.wasi\` and \`.d.ts\` files.`, ) if ( this.options.dts || this.options.dtsHeader || this.config.dtsHeader || this.config.dtsHeaderFile ) { debug.warn( `${requirementWarning}. \`dts\` related options are enabled but will be ignored.`, ) } } } get cdyLibName() { if (this.options.bin) { return } return this.crate.targets.find((t) => t.crate_types.includes('cdylib')) ?.name } get binName() { return ( this.options.bin ?? // only available if not cdylib or bin name specified (this.cdyLibName ? null : this.crate.targets.find((t) => t.crate_types.includes('bin'))?.name) ) } async build() { // Backstop only: `buildProject()` already validated these before running // anything with a side effect (see the top of `buildProject`). Kept here // so a directly constructed `Builder` cannot skip the validation. validateCrossCompileFlags(this.options) if (this.options.useNapiCross) { validateNapiCrossSupport(this.target.triple) } if (this.options.bin) { debug.warn( `Building Cargo binary target ${this.binName}; the result will be an executable, not a Node.js addon.`, ) } else if (!this.cdyLibName) { const warning = 'Missing `crate-type = ["cdylib"]` in [lib] config. The build result will not be available as node addon.' if (this.binName) { debug.warn(warning) } else { throw new Error(warning) } } this.pickBinary() .setPackage() .setFeatures() .setTarget() .pickCrossToolchain() await this.setEnvs() return this.setBypassArgs().exec() } private pickCrossToolchain() { if (!this.options.useNapiCross) { return this } try { const { version, download } = require('@napi-rs/cross-toolchain') const toolchainPath = join( homedir(), '.napi-rs', 'cross-toolchain', version, this.target.triple, ) mkdirSync(toolchainPath, { recursive: true }) if (existsSync(join(toolchainPath, 'package.json'))) { debug(`Toolchain ${toolchainPath} exists, skip extracting`) } else { const tarArchive = download(process.arch, this.target.triple) tarArchive.unpack(toolchainPath) } Object.assign( this.envs, napiCrossToolchainEnvs(toolchainPath, this.target.triple), ) } catch (e) { throw new Error( `Failed to set up the \`--use-napi-cross\` toolchain for ${this.target.triple}: ${(e as Error).message}. Check filesystem permissions and network connectivity to the npm registry, then retry, or use \`--cross-compile\` (\`-x\`) / \`--use-cross\` instead.`, { cause: e }, ) } return this } private exec() { debug(`Start building crate: ${this.crate.name}`) debug(' %i', `cargo ${this.args.join(' ')}`) const controller = new AbortController() const watch = this.options.watch const buildTask = new Promise<void>((resolve, reject) => { const cargoOverride = process.env.CARGO if ( cargoOverride && (this.options.useCross || this.options.crossCompile) ) { const expectedBinary = this.options.useCross ? 'cross' : 'cargo' const requestedFlag = this.options.useCross ? '`--use-cross`' : '`--cross-compile` (`-x`)' debug.warn( `The \`CARGO\` environment variable is set to \`${cargoOverride}\`; it will be spawned instead of the \`${expectedBinary}\` binary that ${requestedFlag} relies on. Unset \`CARGO\` if this is not intended.`, ) } const command = cargoOverride ?? (this.options.useCross ? 'cross' : 'cargo') const buildProcess = spawn(command, this.args, { env: { ...process.env, ...this.envs }, stdio: watch ? ['inherit', 'inherit', 'pipe'] : 'inherit', cwd: this.options.cwd, signal: controller.signal, }) buildProcess.once('exit', (code) => { if (code === 0) { debug('%i', `Build crate ${this.crate.name} successfully!`) resolve() } else { reject(new Error(`Build failed with exit code ${code}`)) } }) buildProcess.once('error', (e) => { reject(new Error(`Build failed with error: ${e.message}`, { cause: e })) }) // watch mode only, they are piped through stderr buildProcess.stderr?.on('data', (data) => { const output = data.toString() console.error(output) if (/Finished\s(`dev`|`release`)/.test(output)) { this.postBuild().catch(() => {}) } }) }) return { task: buildTask.then(() => this.postBuild()), abort: () => controller.abort(), } } private async collectStaleBuildOutputPaths() { const stalePaths = new Set<string>() const managedRootEntries = await this.readManagedWasiRootEntries() for (const name of collectStaleWasiBuildOutputNames( this.config.binaryName, this.target, this.config.targets, )) { stalePaths.add(join(this.outputDir, name)) } const hasWasiOutput = this.target.platform === 'wasi' || this.config.targets.some((target) => target.platform === 'wasi') || managedRootEntries.size > 0 if (hasWasiOutput) { stalePaths.add(join(this.outputDir, 'browser.js')) stalePaths.add(join(this.outputDir, `${this.config.binaryName}.wasm`)) stalePaths.add( join(this.outputDir, `${this.config.binaryName}.debug.wasm`), ) if (this.options.platform && !this.options.noJsBinding) { stalePaths.add( join(this.outputDir, this.options.jsBinding ?? 'index.js'), ) } } for (const entry of managedRootEntries) { stalePaths.add( resolveManagedOutputPath(this.outputDir, entry, 'WASI root entry'), ) } return stalePaths } private async unlinkIfExists(path: string) { try { await unlinkAsync(path) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error } } } private async readManagedWasiRootEntries() { const entries = new Set<string>() for (const { loaderSuffix } of MANAGED_WASI_FLAVORS) { const path = join( this.outputDir, `${this.config.binaryName}.${loaderSuffix}.cjs`, ) if (!(await fileExists(path))) { continue } const metadata = parseExistingWasiArtifactMetadata( await readFileAsync(path, 'utf8'), ) for (const entry of metadata?.managedRootEntries ?? []) { entries.add(entry) } } return entries } private async readExistingWasiBindingMetadata(wasiTarget: Target) { const loaderSuffix = wasiLoaderSuffix(wasiTarget.platformArchABI) const bindingPath = join( this.finalOutputDir, `${this.config.binaryName}.${loaderSuffix}.cjs`, ) const bindingTypeDefPath = join( this.finalOutputDir, `${this.config.binaryName}.${loaderSuffix}.d.cts`, ) if ( !(await fileExists(bindingPath)) || !(await fileExists(bindingTypeDefPath)) ) { return undefined } const artifactMetadata = parseExistingWasiArtifactMetadata( await readFileAsync(bindingPath, 'utf8'), ) if (artifactMetadata?.exports === undefined) { return undefined } return { exports: artifactMetadata.exports, bindingTypeDef: await readFileAsync(bindingTypeDefPath, 'utf8'), } } private pickBinary() { let set = false if (this.options.watch) { if (process.env.CI) { debug.warn('Watch mode is not supported in CI environment') } else { debug('Use %i', 'cargo-watch') tryInstallCargoBinary('cargo-watch', 'watch') // yarn napi watch --target x86_64-unknown-linux-gnu // ===> // cargo watch [...] -- cargo build --target x86_64-unknown-linux-gnu this.args.push( 'watch', '--why', '-i', '*.{js,ts,node}', '-w', this.crateDir, '--', 'cargo', 'build', ) set = true } } if (this.options.crossCompile) { if (this.target.platform === 'win32') { if (process.platform === 'win32') { debug.warn( 'You are trying to cross compile to win32 platform on win32 platform which is unnecessary.', ) } else { // use cargo-xwin to cross compile to win32 platform debug('Use %i', 'cargo-xwin') tryInstallCargoBinary('cargo-xwin', 'xwin') this.args.push('xwin', 'build') if (this.target.arch === 'ia32') { this.envs.XWIN_ARCH = 'x86' } set = true } } else { // use cargo-zigbuild to cross compile to other platforms debug('Use %i', 'cargo-zigbuild') tryInstallCargoBinary('cargo-zigbuild', 'zigbuild') this.args.push('zigbuild') set = true } } if (!set) { this.args.push('build') } return this } private setPackage() { const args = [] if (this.options.package) { args.push('--package', this.options.package) } if (this.binName) { args.push('--bin', this.binName) } if (args.length) { debug('Set package flags: ') debug(' %O', args) this.args.push(...args) } return this } private setTarget() { debug('Set compiling target to: ') debug(' %i', this.target.triple) this.args.push('--target', this.target.triple) return this } private async setEnvs() { // RUSTFLAGS let rustflags = process.env.RUSTFLAGS ?? process.env.CARGO_BUILD_RUSTFLAGS ?? '' if ( this.target.abi?.includes('musl') && !rustflags.includes('target-feature=-crt-static') ) { rustflags += ' -C target-feature=-crt-static' } if (this.options.strip && !rustflags.includes('link-arg=-s')) { rustflags += ' -C link-arg=-s' } // TYPE DEF if (this.enableTypeDef) { this.envs.NAPI_TYPE_DEF_TMP_FOLDER = await this.generateIntermediateTypeDefFolder(rustflags) this.setForceBuildEnvs(this.envs.NAPI_TYPE_DEF_TMP_FOLDER) } if (rustflags.length) { this.envs.RUSTFLAGS = rustflags } // END RUSTFLAGS // LINKER const linker = this.options.crossCompile ? void 0 : getTargetLinker(this.target.triple) // TODO: // directly set CARGO_TARGET_<target>_LINKER will cover .cargo/config.toml // will detect by cargo config when it becomes stable // see: https://github.com/rust-lang/cargo/issues/9301 const linkerEnv = `CARGO_TARGET_${targetToEnvVar( this.target.triple, )}_LINKER` if (linker && !process.env[linkerEnv] && !this.envs[linkerEnv]) { this.envs[linkerEnv] = linker } if (this.target.platform === 'android') { this.setAndroidEnv() } if (this.target.platform === 'wasi') { this.setWasiEnv() } if (this.target.platform === 'openharmony') { this.setOpenHarmonyEnv() } debug('Set envs: ') Object.entries(this.envs).forEach(([k, v]) => { debug(' %i', `${k}=${v}`) }) return this } private setForceBuildEnvs(typeDefTmpFolder: string) { // dynamically check all napi-rs deps and set `NAPI_FORCE_BUILD_{uppercase(snake_case(name))} = timestamp` getNapiDeriveDependentCrates(this.metadata).forEach((crate) => { if (!existsSync(join(typeDefTmpFolder, crate.name))) { this.envs[ `NAPI_FORCE_BUILD_${crate.name.replace(/-/g, '_').toUpperCase()}` ] = Date.now().toString() } }) } private setAndroidEnv() { // Native Android hosts and `cross` provide their own Android toolchains. if (process.platform === 'android' || this.options.useCross) { return } const { ANDROID_NDK_LATEST_HOME } = process.env if (!ANDROID_NDK_LATEST_HOME) { throw new Error( `${colors.red( 'ANDROID_NDK_LATEST_HOME', )} environment variable is required when building an Android target from a non-Android host`, ) } const targetArch = this.target.arch === 'arm' ? 'armv7a' : 'aarch64' const targetPlatform = this.target.arch === 'arm' ? 'androideabi24' : 'android24' const hostPlatform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux' Object.assign(this.envs, { CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/${targetArch}-linux-android24-clang`, CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/${targetArch}-linux-androideabi24-clang`, TARGET_CC: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/${targetArch}-linux-${targetPlatform}-clang`, TARGET_CXX: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/${targetArch}-linux-${targetPlatform}-clang++`, TARGET_AR: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/llvm-ar`, TARGET_RANLIB: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/llvm-ranlib`, ANDROID_NDK: ANDROID_NDK_LATEST_HOME, PATH: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}`, }) } private setWasiEnv() { const hasThreads = wasiTargetHasThreads(this.target) const wasiTarget = hasThreads ? 'wasm32-wasip1-threads' : 'wasm32-wasip1' const emnapi = join(require.resolve('emnapi'), '..', 'lib', wasiTarget) const emnapiVersion = require('emnapi/package.json').version // Keep this in sync with `emnapi_link_library` in `crates/build/src/wasi.rs`. const emnapiArchive = join( emnapi, hasThreads ? 'libemnapi-napi-rs-mt.a' : 'libemnapi-basic-napi-rs.a', ) if (!existsSync(emnapiArchive)) { throw new Error( `emnapi@${emnapiVersion} is missing the ${wasiTarget} archive required by napi-rs at ${emnapiArchive}. Install emnapi v2 with support for this target.`, ) } this.envs.EMNAPI_LINK_DIR = emnapi const projectRequire = createRequire( resolve(this.options.cwd, 'package.json'), ) const projectPackageVersion = (name: string): string => { try { return projectRequire(`${name}/package.json`).version } catch { // emnapi v1 packages do not list `./package.json` in their export // maps, so fall back to the module's own `version` export to keep the // descriptive version-mismatch error below reachable for stale // installs instead of a bare ERR_PACKAGE_PATH_NOT_EXPORTED. return projectRequire(name).version } } const emnapiCoreVersion = projectPackageVersion('@emnapi/core') const emnapiRuntimeVersion = projectPackageVersion('@emnapi/runtime') if ( emnapiVersion !== emnapiCoreVersion || emnapiVersion !== emnapiRuntimeVersion ) { throw new Error( `emnapi version mismatch: emnapi@${emnapiVersion}, @emnapi/core@${emnapiCoreVersion}, @emnapi/runtime@${emnapiRuntimeVersion}. Please ensure all emnapi packages are the same version.`, ) } const { WASI_SDK_PATH } = process.env if (WASI_SDK_PATH && existsSync(WASI_SDK_PATH)) { this.envs.CARGO_TARGET_WASM32_WASI_PREVIEW1_THREADS_LINKER = join( WASI_SDK_PATH, 'bin', 'wasm-ld', ) this.envs.CARGO_TARGET_WASM32_WASIP1_LINKER = join( WASI_SDK_PATH, 'bin', 'wasm-ld', ) this.envs.CARGO_TARGET_WASM32_WASIP1_THREADS_LINKER = join( WASI_SDK_PATH, 'bin', 'wasm-ld', ) this.envs.CARGO_TARGET_WASM32_WASIP2_LINKER = join( WASI_SDK_PATH, 'bin', 'wasm-ld', ) this.setEnvIfNotExists('TARGET_CC', join(WASI_SDK_PATH, 'bin', 'clang')) this.setEnvIfNotExists( 'TARGET_CXX', join(WASI_SDK_PATH, 'bin', 'clang++'), ) this.setEnvIfNotExists('TARGET_AR', join(WASI_SDK_PATH, 'bin', 'ar')) this.setEnvIfNotExists( 'TARGET_RANLIB', join(WASI_SDK_PATH, 'bin', 'ranlib'), ) const { compileFlags, linkerFlags } = createWasiCompilerFlags( WASI_SDK_PATH, wasiTarget, hasThreads, process.env.CC_SHELL_ESCAPED_FLAGS ? envBoolean(process.env.CC_SHELL_ESCAPED_FLAGS) : true, ) this.setEnvIfNotExists('CC_SHELL_ESCAPED_FLAGS', '1') this.setEnvIfNotExists('TARGET_CFLAGS', compileFlags) this.setEnvIfNotExists('TARGET_CXXFLAGS', compileFlags) this.setEnvIfNotExists(`TARGET_LDFLAGS`, linkerFlags) } } private setOpenHarmonyEnv() { const { OHOS_SDK_PATH, OHOS_SDK_NATIVE } = process.env const ndkPath = OHOS_SDK_PATH ? `${OHOS_SDK_PATH}/native` : OHOS_SDK_NATIVE // @ts-expect-error if (!ndkPath && process.platform !== 'openharmony') { debug.warn( `${colors.red('OHOS_SDK_PATH')} or ${colors.red('OHOS_SDK_NATIVE')} environment variable is missing`, ) return } const linkerName = `CARGO_TARGET_${this.target.triple.toUpperCase().replace(/-/g, '_')}_LINKER` const ranPath = `${ndkPath}/llvm/bin/llvm-ranlib` const arPath = `${ndkPath}/llvm/bin/llvm-ar` const ccPath = `${ndkPath}/llvm/bin/${this.target.triple}-clang` const cxxPath = `${ndkPath}/llvm/bin/${this.target.triple}-clang++` const asPath = `${ndkPath}/llvm/bin/llvm-as` const ldPath = `${ndkPath}/llvm/bin/ld.lld` const stripPath = `${ndkPath}/llvm/bin/llvm-strip` const objDumpPath = `${ndkPath}/llvm/bin/llvm-ob