@napi-rs/cli
Version:
Cli tools for napi-rs
1,397 lines (1,259 loc) • 46.2 kB
text/typescript
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { basename, parse, join, resolve } from 'node:path'
import * as colors from 'colorette'
import type { BuildOptions as RawBuildOptions } from '../def/build.js'
import {
CLI_VERSION,
copyFileAsync,
type Crate,
debugFactory,
DEFAULT_TYPE_DEF_HEADER,
fileExists,
getSystemDefaultTarget,
getNapiDeriveDependentCrates,
getTargetLinker,
mkdirAsync,
type NapiConfig,
parseMetadata,
parseTriple,
processTypeDef,
readFileAsync,
readNapiConfig,
type Target,
targetToEnvVar,
tryInstallCargoBinary,
unlinkAsync,
writeFileAsync,
dirExistsAsync,
readdirAsync,
type CargoWorkspaceMetadata,
} from '../utils/index.js'
import { createCjsBinding, createEsmBinding } from './templates/index.js'
import {
createWasiBinding,
createWasiBrowserBinding,
} 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)
type OutputKind = 'js' | 'dts' | 'node' | 'exe' | 'wasm'
type Output = { kind: OutputKind; path: string }
type BuildOptions = RawBuildOptions & { cargoOptions?: string[] }
type ParsedBuildOptions = Omit<BuildOptions, 'cwd'> & { cwd: string }
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 metadata = await parseMetadata(manifestPath, {
features: options.features,
allFeatures: options.allFeatures,
noDefaultFeatures: options.noDefaultFeatures,
})
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 outputDir: string
private readonly targetDir: string
private readonly enableTypeDef: boolean = false
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.outputDir = resolve(
this.options.cwd,
options.outputDir ?? this.crateDir,
)
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)
)
}
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)
}
}
return this.pickBinary()
.setPackage()
.setFeatures()
.setTarget()
.pickCrossToolchain()
.setEnvs()
.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 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 setEnvs() {
// TYPE DEF
if (this.enableTypeDef) {
this.envs.NAPI_TYPE_DEF_TMP_FOLDER =
this.generateIntermediateTypeDefFolder()
this.setForceBuildEnvs(this.envs.NAPI_TYPE_DEF_TMP_FOLDER)
}
// 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'
}
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 emnapi = join(
require.resolve('emnapi'),
'..',
'lib',
'wasm32-wasip1-threads',
)
this.envs.EMNAPI_LINK_DIR = emnapi
const emnapiVersion = require('emnapi/package.json').version
const projectRequire = createRequire(join(this.options.cwd, 'package.json'))
const emnapiCoreVersion = projectRequire('@emnapi/core').version
const emnapiRuntimeVersion = projectRequire('@emnapi/runtime').version
if (
emnapiVersion !== emnapiCoreVersion ||
emnapiVersion !== emnapiRuntimeVersion
) {
throw new Error(
`emnapi version mismatch: emnapi@${emnapiVersion}, /core@${emnapiCoreVersion}, /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'),
)
this.setEnvIfNotExists(
'TARGET_CFLAGS',
`--target=wasm32-wasip1-threads --sysroot=${WASI_SDK_PATH}/share/wasi-sysroot -pthread -mllvm -wasm-enable-sjlj`,
)
this.setEnvIfNotExists(
'TARGET_CXXFLAGS',
`--target=wasm32-wasip1-threads --sysroot=${WASI_SDK_PATH}/share/wasi-sysroot -pthread -mllvm -wasm-enable-sjlj`,
)
this.setEnvIfNotExists(
`TARGET_LDFLAGS`,
`-fuse-ld=${WASI_SDK_PATH}/bin/wasm-ld --target=wasm32-wasip1-threads`,
)
}
}
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-objdump`
const objCopyPath = `${ndkPath}/llvm/bin/llvm-objcopy`
const nmPath = `${ndkPath}/llvm/bin/llvm-nm`
const binPath = `${ndkPath}/llvm/bin`
const libPath = `${ndkPath}/llvm/lib`
this.setEnvIfNotExists('LIBCLANG_PATH', libPath)
this.setEnvIfNotExists('DEP_ATOMIC', 'clang_rt.builtins')
this.setEnvIfNotExists(linkerName, ccPath)
this.setEnvIfNotExists('TARGET_CC', ccPath)
this.setEnvIfNotExists('TARGET_CXX', cxxPath)
this.setEnvIfNotExists('TARGET_AR', arPath)
this.setEnvIfNotExists('TARGET_RANLIB', ranPath)
this.setEnvIfNotExists('TARGET_AS', asPath)
this.setEnvIfNotExists('TARGET_LD', ldPath)
this.setEnvIfNotExists('TARGET_STRIP', stripPath)
this.setEnvIfNotExists('TARGET_OBJDUMP', objDumpPath)
this.setEnvIfNotExists('TARGET_OBJCOPY', objCopyPath)
this.setEnvIfNotExists('TARGET_NM', nmPath)
this.envs.PATH = `${binPath}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}`
}
private setFeatures() {
const args = []
if (this.options.allFeatures && this.options.noDefaultFeatures) {
throw new Error(
'Cannot specify --all-features and --no-default-features together',
)
}
if (this.options.allFeatures) {
args.push('--all-features')
} else if (this.options.noDefaultFeatures) {
args.push('--no-default-features')
}
if (this.options.features) {
args.push('--features', ...this.options.features)
}
debug('Set features flags: ')
debug(' %O', args)
this.args.push(...args)
return this
}
private setBypassArgs() {
if (this.options.release) {
this.args.push('--release')
}
if (this.options.verbose) {
this.args.push('--verbose')
}
if (this.options.targetDir) {
this.args.push('--target-dir', this.options.targetDir)
}
if (this.options.profile) {
this.args.push('--profile', this.options.profile)
}
if (this.options.manifestPath) {
this.args.push('--manifest-path', this.options.manifestPath)
}
if (this.options.cargoOptions?.length) {
this.args.push(...this.options.cargoOptions)
}
return this
}
private generateIntermediateTypeDefFolder() {
let folder = join(
this.targetDir,
'napi-rs',
`${this.crate.name}-${createHash('sha256')
.update(this.crate.manifest_path)
.update(CLI_VERSION)
.digest('hex')
.substring(0, 8)}`,
)
if (!this.options.dtsCache) {
rmSync(folder, { recursive: true, force: true })
folder += `_${Date.now()}`
}
mkdirAsync(folder, { recursive: true })
return folder
}
private async postBuild() {
try {
debug(`Try to create output directory:`)
debug(' %i', this.outputDir)
await mkdirAsync(this.outputDir, { recursive: true })
debug(`Output directory created`)
} catch (e) {
throw new Error(`Failed to create output directory ${this.outputDir}`, {
cause: e,
})
}
const wasmBinaryName = await this.copyArtifact()
// only for cdylib
if (this.cdyLibName) {
const idents = await this.generateTypeDef()
const jsOutput = await this.writeJsBinding(idents)
const wasmBindingsOutput = await this.writeWasiBinding(
wasmBinaryName,
idents,
)
if (jsOutput) {
this.outputs.push(jsOutput)
}
if (wasmBindingsOutput) {
this.outputs.push(...wasmBindingsOutput)
}
}
return this.outputs
}
private async copyArtifact() {
const [srcName, destName, wasmBinaryName] = this.getArtifactNames()
if (!srcName || !destName) {
return
}
const profile =
this.options.profile ?? (this.options.release ? 'release' : 'debug')
const src = join(this.targetDir, this.target.triple, profile, srcName)
debug(`Copy artifact from: [${src}]`)
const dest = join(this.outputDir, destName)
const isWasm = dest.endsWith('.wasm')
try {
if (await fileExists(dest)) {
debug('Old artifact found, remove it first')
await unlinkAsync(dest)
}
debug('Copy artifact to:')
debug(' %i', dest)
if (isWasm) {
const { ModuleConfig } = await import('@napi-rs/wasm-tools')
debug('Generate debug wasm module')
try {
const debugWasmModule = new ModuleConfig()
.generateDwarf(true)
.generateNameSection(true)
.generateProducersSection(true)
.preserveCodeTransform(true)
.strictValidate(false)
.parse(await readFileAsync(src))
const debugWasmBinary = debugWasmModule.emitWasm(true)
await writeFileAsync(
dest.replace(/\.wasm$/, '.debug.wasm'),
debugWasmBinary,
)
debug('Generate release wasm module')
const releaseWasmModule = new ModuleConfig()
.generateDwarf(false)
.generateNameSection(false)
.generateProducersSection(false)
.preserveCodeTransform(false)
.strictValidate(false)
.onlyStableFeatures(false)
.parse(debugWasmBinary)
const releaseWasmBinary = releaseWasmModule.emitWasm(false)
await writeFileAsync(dest, releaseWasmBinary)
} catch (e) {
debug.warn(
`Failed to generate debug wasm module: ${(e as any).message ?? e}`,
)
await copyFileAsync(src, dest)
}
} else {
await copyFileAsync(src, dest)
}
this.outputs.push({
kind: dest.endsWith('.node') ? 'node' : isWasm ? 'wasm' : 'exe',
path: dest,
})
return wasmBinaryName ? join(this.outputDir, wasmBinaryName) : null
} catch (e) {
throw new Error('Failed to copy artifact', { cause: e })
}
}
private getArtifactNames() {
if (this.cdyLibName) {
const cdyLib = this.cdyLibName.replace(/-/g, '_')
const wasiTarget = this.config.targets.find((t) => t.platform === 'wasi')
const srcName =
this.target.platform === 'darwin'
? `lib${cdyLib}.dylib`
: this.target.platform === 'win32'
? `${cdyLib}.dll`
: this.target.platform === 'wasi' || this.target.platform === 'wasm'
? `${cdyLib}.wasm`
: `lib${cdyLib}.so`
let destName = this.config.binaryName
// add platform suffix to binary name
// index[.linux-x64-gnu].node
// ^^^^^^^^^^^^^^
if (this.options.platform) {
destName += `.${this.target.platformArchABI}`
}
if (srcName.endsWith('.wasm')) {
destName += '.wasm'
} else {
destName += '.node'
}
return [
srcName,
destName,
wasiTarget
? `${this.config.binaryName}.${wasiTarget.platformArchABI}.wasm`
: null,
]
} else if (this.binName) {
const srcName =
this.target.platform === 'win32' ? `${this.binName}.exe` : this.binName
return [srcName, srcName]
}
return []
}
private async generateTypeDef() {
const typeDefDir = this.envs.NAPI_TYPE_DEF_TMP_FOLDER
if (!this.enableTypeDef) {
return []
}
const { exports, dts } = await generateTypeDef({
typeDefDir,
noDtsHeader: this.options.noDtsHeader,
dtsHeader: this.options.dtsHeader,
configDtsHeader: this.config.dtsHeader,
configDtsHeaderFile: this.config.dtsHeaderFile,
constEnum: this.options.constEnum ?? this.config.constEnum,
runtimeStringEnum:
this.options.runtimeStringEnum ?? this.config.runtimeStringEnum,
cwd: this.options.cwd,
})
const dest = join(this.outputDir, this.options.dts ?? 'index.d.ts')
try {
debug('Writing type def to:')
debug(' %i', dest)
await writeFileAsync(dest, dts, 'utf-8')
} catch (e) {
debug.error('Failed to write type def file')
debug.error(e as Error)
}
if (exports.length > 0) {
const dest = join(this.outputDir, this.options.dts ?? 'index.d.ts')
this.outputs.push({ kind: 'dts', path: dest })
}
return exports
}
private async writeJsBinding(idents: string[]) {
return writeJsBinding({
platform: this.options.platform,
noJsBinding: this.options.noJsBinding,
idents,
jsBinding: this.options.jsBinding,
esm: this.options.esm,
binaryName: this.config.binaryName,
packageName: this.options.jsPackageName ?? this.config.packageName,
version: process.env.npm_new_version ?? this.config.packageJson.version,
outputDir: this.outputDir,
})
}
private async writeWasiBinding(
distFileName: string | undefined | null,
idents: string[],
) {
if (distFileName) {
const { name, dir } = parse(distFileName)
const bindingPath = join(dir, `${this.config.binaryName}.wasi.cjs`)
const browserBindingPath = join(
dir,
`${this.config.binaryName}.wasi-browser.js`,
)
const workerPath = join(dir, 'wasi-worker.mjs')
const browserWorkerPath = join(dir, 'wasi-worker-browser.mjs')
const browserEntryPath = join(dir, 'browser.js')
const exportsCode =
`module.exports = __napiModule.exports\n` +
idents
.map(
(ident) =>
`module.exports.${ident} = __napiModule.exports.${ident}`,
)
.join('\n')
await writeFileAsync(
bindingPath,
createWasiBinding(
name,
this.config.packageName,
this.config.wasm?.initialMemory,
this.config.wasm?.maximumMemory,
) +
exportsCode +
'\n',
'utf8',
)
await writeFileAsync(
browserBindingPath,
createWasiBrowserBinding(
name,
this.config.wasm?.initialMemory,
this.config.wasm?.maximumMemory,
this.config.wasm?.browser?.fs,
this.config.wasm?.browser?.asyncInit,
this.config.wasm?.browser?.buffer,
this.config.wasm?.browser?.errorEvent,
) +
`export default __napiModule.exports\n` +
idents
.map(
(ident) =>
`export const ${ident} = __napiModule.exports.${ident}`,
)
.join('\n') +
'\n',
'utf8',
)
await writeFileAsync(workerPath, WASI_WORKER_TEMPLATE, 'utf8')
await writeFileAsync(
browserWorkerPath,
createWasiBrowserWorkerBinding(
this.config.wasm?.browser?.fs ?? false,
this.config.wasm?.browser?.errorEvent ?? false,
),
'utf8',
)
await writeFileAsync(
browserEntryPath,
`export * from '${this.config.packageName}-wasm32-wasi'\n`,
)
return [
{ kind: 'js', path: bindingPath },
{ kind: 'js', path: browserBindingPath },
{ kind: 'js', path: workerPath },
{ kind: 'js', path: browserWorkerPath },
{ kind: 'js', path: browserEntryPath },
] satisfies Output[]
}
return []
}
private setEnvIfNotExists(env: string, value: string) {
if (!process.env[env]) {
this.envs[env] = value
}
}
}
export interface WriteJsBindingOptions {
platform?: boolean
noJsBinding?: boolean
idents: string[]
jsBinding?: string
esm?: boolean
binaryName: string
packageName: string
version: string
outputDir: string
}
export async function writeJsBinding(
options: WriteJsBindingOptions,
): Promise<Output | undefined> {
if (
!options.platform ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
options.noJsBinding ||
options.idents.length === 0
) {
return
}
const name = options.jsBinding ?? 'index.js'
const createBinding = options.esm ? createEsmBinding : createCjsBinding
const binding = createBinding(
options.binaryName,
options.packageName,
options.idents,
// in npm preversion hook
options.version,
)
try {
const dest = join(options.outputDir, name)
debug('Writing js binding to:')
debug(' %i', dest)
await writeFileAsync(dest, binding, 'utf-8')
return { kind: 'js', path: dest } satisfies Output
} catch (e) {
throw new Error('Failed to write js binding file', { cause: e })
}
}
export interface GenerateTypeDefOptions {
typeDefDir: string
noDtsHeader?: boolean
dtsHeader?: string
dtsHeaderFile?: string
configDtsHeader?: string
configDtsHeaderFile?: string
constEnum?: boolean
runtimeStringEnum?: boolean
cwd: string
}
/**
* Walk the napi-derive intermediate type-def directory, render every entry
* into TypeScript via {@link processTypeDef}, and return the concatenated
* `.d.ts` source plus the list of identifiers to re-export from
* `index.js`.
*/
export async function generateTypeDef(
options: GenerateTypeDefOptions,
): Promise<{ exports: string[]; dts: string }> {
if (!(await dirExistsAsync(options.typeDefDir))) {
return { exports: [], dts: '' }
}
let header = ''
let dts = ''
let exports: string[] = []
if (!options.noDtsHeader) {
const dtsHeader = options.dtsHeader ?? options.configDtsHeader
const dtsHeaderFile = options.dtsHeaderFile ?? options.configDtsHeaderFile
// An explicit API header file takes precedence over the config file;
// either file takes precedence over inline header text.
if (dtsHeaderFile) {
try {
header = await readFileAsync(join(options.cwd, dtsHeaderFile), 'utf-8')
} catch (e) {
debug.warn(`Failed to read dts header file ${dtsHeaderFile}`, e)
}
} else if (dtsHeader) {
header = dtsHeader
} else {
header = DEFAULT_TYPE_DEF_HEADER
}
}
const files = await readdirAsync(options.typeDefDir, { withFileTypes: true })
if (!files.length) {
debug('No type def files found. Skip generating dts file.')
return { exports: [], dts: '' }
}
const typeDefFiles = files
.filter((file) => file.isFile())
.sort((a, b) => a.name.localeCompare(b.name))
const constEnum = options.constEnum ?? true
const runtimeStringEnum = options.runtimeStringEnum ?? false
if (runtimeStringEnum && constEnum) {
debug.warn(
'`--runtime-string-enum` has no effect when `--const-enum` is enabled (the default). Pass `--no-const-enum` to activate runtime string enum emission.',
)
}
const processedTypeDefs = await Promise.all(
typeDefFiles.map((file) =>
processTypeDef(
join(options.typeDefDir, file.name),
constEnum,
runtimeStringEnum,
),
),
)
dts = processedTypeDefs.map(({ dts }) => dts).join('')
exports = processedTypeDefs.flatMap(({ exports }) => exports)
if (dts.indexOf('ExternalObject<') > -1) {
header += `
export declare class ExternalObject<T> {
readonly '': {
readonly '': unique symbol
[K: symbol]: T
}
}
`
}
if (dts.indexOf('TypedArray') > -1) {
header += `
export type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array
`
}
dts = header + dts
return {
exports,
dts,
}
}