import { execSync } from 'child_process' import { existsSync, mkdirSync } from 'fs' import { tmpdir } from 'os' import { join, parse, sep } from 'path' import { Instance } from 'chalk' import { Command, Option } from 'clipanion' import envPaths from 'env-paths' import { groupBy } from 'lodash-es' import toml from 'toml' import { getNapiConfig } from './consts' import { debugFactory } from './debug' import { createJsBinding } from './js-binding-template' import { getHostTargetTriple, parseTriple } from './parse-triple' import { copyFileAsync, mkdirAsync, readFileAsync, unlinkAsync, writeFileAsync, } from './utils' const debug = debugFactory('build') const chalk = new Instance({ level: 1 }) const ZIG_PLATFORM_TARGET_MAP = { 'x86_64-unknown-linux-musl': 'x86_64-linux-musl', 'x86_64-unknown-linux-gnu': 'x86_64-linux-gnu', // Doesn't support Windows MSVC for now // 'x86_64-pc-windows-gnu': 'x86_64-windows-gnu', // https://github.com/ziglang/zig/issues/1759 // 'x86_64-unknown-freebsd': 'x86_64-freebsd', 'x86_64-apple-darwin': 'x86_64-macos-gnu', 'aarch64-apple-darwin': 'aarch64-macos', 'aarch64-unknown-linux-gnu': 'aarch64-linux-gnu', 'aarch64-unknown-linux-musl': 'aarch64-linux-musl', } function processZigLinkerArgs(platform: string, args: string[]) { if (platform.includes('apple')) { const newArgs = args.filter( (arg, index) => !arg.startsWith('-Wl,-exported_symbols_list') && arg !== '-Wl,-dylib' && arg !== '-liconv' && arg !== '-Wl,-dead_strip' && !(arg === '-framework' && args[index + 1] === 'CoreFoundation') && !(arg === 'CoreFoundation' && args[index - 1] === '-framework'), ) newArgs.push('-Wl,"-undefined=dynamic_lookup"', '-dead_strip', '-lunwind') return newArgs } if (platform.includes('linux')) { return args.map((arg) => { if (arg === '-lgcc_s') { return '-lunwind' } return arg }) } return args } export class BuildCommand extends Command { static usage = Command.Usage({ description: 'Build and copy native module into specified dir', }) static paths = [['build']] appendPlatformToFilename = Option.Boolean(`--platform`, false, { description: `Add platform triple to the .node file. ${chalk.green( '[name].linux-x64-gnu.node', )} for example`, }) isRelease = Option.Boolean(`--release`, false, { description: `Bypass to ${chalk.green('cargo build --release')}`, }) configFileName?: string = Option.String('--config,-c', { description: `napi config path, only JSON format accepted. Default to ${chalk.underline( chalk.green('package.json'), )}`, }) cargoName?: string = Option.String('--cargo-name', { description: `Override the ${chalk.green( 'name', )} field in ${chalk.underline(chalk.yellowBright('Cargo.toml'))}`, }) targetTripleDir = Option.String( '--target', process.env.RUST_TARGET ?? process.env.CARGO_BUILD_TARGET ?? '', { description: `Bypass to ${chalk.green('cargo build --target')}`, }, ) features?: string = Option.String('--features', { description: `Bypass to ${chalk.green('cargo build --features')}`, }) bin?: string = Option.String('--bin', { description: `Bypass to ${chalk.green('cargo build --bin')}`, }) dts?: string = Option.String('--dts', 'index.d.ts', { description: `The filename and path of ${chalk.green( '.d.ts', )} file, relative to cwd`, }) noDtsHeader = Option.Boolean('--no-dts-header', false, { description: `Don't generate ${chalk.green('.d.ts')} header`, }) project = Option.String('-p', { description: `Bypass to ${chalk.green('cargo -p')}`, }) cargoFlags = Option.String('--cargo-flags', '', { description: `All the others flag passed to ${chalk.yellow('cargo build')}`, }) jsBinding = Option.String('--js', 'index.js', { description: `Path to the JS binding file, pass ${chalk.underline( chalk.yellow('false'), )} to disable it. Only affect if ${chalk.green('--target')} is specified.`, }) jsPackageName = Option.String('--js-package-name', { description: `Package name in generated js binding file, Only affect if ${chalk.green( '--target', )} specified and ${chalk.green('--js')} is not false.`, required: false, }) cargoCwd?: string = Option.String('--cargo-cwd', { description: `The cwd of ${chalk.underline( chalk.yellow('Cargo.toml'), )} file`, }) pipe?: string = Option.String('--pipe', { description: `Pipe [${chalk.green( '.js/.ts', )}] files to this command, eg ${chalk.green('prettier -w')}`, }) // https://github.com/napi-rs/napi-rs/issues/297 disableWindowsX32Optimize?: boolean = Option.Boolean( '--disable-windows-x32-optimize', false, { description: `Disable windows x32 ${chalk.green( 'lto', )} and increase ${chalk.green( 'codegen-units', )}. Enabled by default. See ${chalk.underline.blue( 'https://github.com/napi-rs/napi-rs/issues/297', )}`, }, ) destDir = Option.String({ required: false, }) useZig = Option.Boolean(`--zig`, false, { description: `Use ${chalk.green('zig')} as linker ${chalk.yellowBright( '(Experimental)', )}`, }) zigABIVersion = Option.String(`--zig-abi-suffix`, { description: `The suffix of the ${chalk.green( 'zig --target', )} ABI version. Eg. ${chalk.cyan( '--target x86_64-unknown-linux-gnu', )} ${chalk.green('--zig-abi-suffix=2.17')}`, }) isStrip = Option.Boolean(`--strip`, false, { description: `${chalk.green('Strip')} the library for minimum file size`, }) async execute() { const cwd = this.cargoCwd ? join(process.cwd(), this.cargoCwd) : process.cwd() let tomlContentString: string let tomlContent: any try { debug('Start read toml') tomlContentString = await readFileAsync(join(cwd, 'Cargo.toml'), 'utf-8') } catch { throw new TypeError(`Could not find Cargo.toml in ${cwd}`) } try { debug('Start parse toml') tomlContent = toml.parse(tomlContentString) } catch { throw new TypeError('Could not parse the Cargo.toml') } let cargoPackageName: string if (tomlContent.package?.name) { cargoPackageName = tomlContent.package.name } else if (this.cargoName) { cargoPackageName = this.cargoName } else { throw new TypeError('No package.name field in Cargo.toml') } const cargoMetadata = JSON.parse( execSync('cargo metadata --format-version 1', { stdio: 'pipe', maxBuffer: 1024 * 1024 * 10, }).toString('utf8'), ) const packages = cargoMetadata.packages const cargoPackage = packages.find( (p: { name: string }) => p.name === cargoPackageName, ) if ( !this.bin && cargoPackage?.targets?.length === 1 && cargoPackage?.targets[0].kind.length === 1 && cargoPackage?.targets[0].kind[0] === 'bin' ) { this.bin = cargoPackageName } const releaseFlag = this.isRelease ? `--release` : '' const targetFlag = this.targetTripleDir ? `--target ${this.targetTripleDir}` : '' const featuresFlag = this.features ? `--features ${this.features}` : '' const binFlag = this.bin ? `--bin ${this.bin}` : '' const triple = this.targetTripleDir ? parseTriple(this.targetTripleDir) : getHostTargetTriple() debug(`Current triple is: ${chalk.green(triple.raw)}`) const pFlag = this.project ? `-p ${this.project}` : '' const externalFlags = [ releaseFlag, targetFlag, featuresFlag, binFlag, pFlag, this.cargoFlags, ] .filter((flag) => Boolean(flag)) .join(' ') const cargo = process.env.CARGO ?? 'cargo' const cargoCommand = `${cargo} build ${externalFlags}` const intermediateTypeFile = join(tmpdir(), `type_def.${Date.now()}.tmp`) debug(`Run ${chalk.green(cargoCommand)}`) const additionalEnv = {} const rustflags = process.env.RUSTFLAGS ? process.env.RUSTFLAGS.split(' ') : [] if (triple.raw.includes('musl') && !this.bin) { if (!rustflags.includes('target-feature=-crt-static')) { rustflags.push('-C target-feature=-crt-static') } } if (this.isStrip && !rustflags.includes('-C link-arg=-s')) { rustflags.push('-C link-arg=-s') } if (rustflags.length > 0) { additionalEnv['RUSTFLAGS'] = rustflags.join(' ') } if (this.useZig) { const zigTarget = `${ZIG_PLATFORM_TARGET_MAP[triple.raw]}${ this.zigABIVersion ? `.${this.zigABIVersion}` : '' }` if (!zigTarget) { throw new Error(`${triple.raw} can not be cross compiled by zig`) } const paths = envPaths('napi-rs') const shellFileExt = process.platform === 'win32' ? 'bat' : 'sh' const linkerWrapperShell = join( paths.cache, `zig-linker-${triple.raw}.${shellFileExt}`, ) const CCWrapperShell = join( paths.cache, `zig-cc-${triple.raw}.${shellFileExt}`, ) const CXXWrapperShell = join( paths.cache, `zig-cxx-${triple.raw}.${shellFileExt}`, ) const linkerWrapper = join(paths.cache, `zig-cc-${triple.raw}.js`) mkdirSync(paths.cache, { recursive: true }) const forwardArgs = process.platform === 'win32' ? '%*' : '$@' await writeFileAsync( linkerWrapperShell, `#!/bin/sh\nnode ${linkerWrapper} ${forwardArgs}`, { mode: '777', }, ) await writeFileAsync( CCWrapperShell, `#!/bin/sh\nzig cc -target ${zigTarget} ${forwardArgs}`, { mode: '777', }, ) await writeFileAsync( CXXWrapperShell, `#!/bin/sh\nzig c++ -target ${zigTarget} ${forwardArgs}`, { mode: '777', }, ) await writeFileAsync( linkerWrapper, `#!/usr/bin/env node\nconst{writeFileSync} = require('fs')\n${processZigLinkerArgs.toString()}\nconst {status} = require('child_process').spawnSync('zig', ['${ triple.platform === 'win32' ? 'c++' : 'cc' }', ...processZigLinkerArgs('${ triple.raw }', process.argv.slice(2)), '-target', '${zigTarget}'], { stdio: 'inherit', shell: true })\nwriteFileSync('${linkerWrapper.replaceAll( '\\', '/', )}.args.log', processZigLinkerArgs('${ triple.raw }', process.argv.slice(2)).join(' '))\n\nprocess.exit(status || 0)\n`, { mode: '777', }, ) const envTarget = triple.raw.replaceAll('-', '_').toUpperCase() Object.assign(additionalEnv, { CC: CCWrapperShell, CXX: CXXWrapperShell, TARGET_CC: CCWrapperShell, TARGET_CXX: CXXWrapperShell, }) additionalEnv[`CARGO_TARGET_${envTarget}_LINKER`] = linkerWrapperShell } execSync(cargoCommand, { env: { ...process.env, ...additionalEnv, TYPE_DEF_TMP_PATH: intermediateTypeFile, }, stdio: 'inherit', cwd, }) const { binaryName, packageName } = getNapiConfig(this.configFileName) let cargoArtifactName = this.cargoName if (!cargoArtifactName) { if (this.bin) { cargoArtifactName = cargoPackageName } else { cargoArtifactName = cargoPackageName.replace(/-/g, '_') } if (!this.bin && !tomlContent.lib?.['crate-type']?.includes?.('cdylib')) { throw new TypeError( `Missing ${chalk.green('crate-type = ["cdylib"]')} in ${chalk.green( '[lib]', )}`, ) } } if (this.bin) { debug(`Binary name: ${chalk.greenBright(cargoArtifactName)}`) } else { debug(`Dylib name: ${chalk.greenBright(cargoArtifactName)}`) } const platform = triple.platform let libExt = '' debug(`Platform: ${chalk.greenBright(platform)}`) // Platform based massaging for build commands if (!this.bin) { switch (platform) { case 'darwin': libExt = '.dylib' cargoArtifactName = `lib${cargoArtifactName}` break case 'win32': libExt = '.dll' break case 'linux': case 'freebsd': case 'openbsd': case 'android': case 'sunos': cargoArtifactName = `lib${cargoArtifactName}` libExt = '.so' break default: throw new TypeError( 'Operating system not currently supported or recognized by the build script', ) } } const targetRootDir = // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing process.env.CARGO_TARGET_DIR || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing process.env.CARGO_BUILD_TARGET_DIR || (await findUp(cwd)) if (!targetRootDir) { throw new TypeError('No target dir found') } const targetDir = join( this.targetTripleDir, this.isRelease ? 'release' : 'debug', ) const platformName = this.appendPlatformToFilename ? `.${triple.platformArchABI}` : '' debug(`Platform name: ${platformName || chalk.green('[Empty]')}`) const distFileName = this.bin ? cargoArtifactName! : `${binaryName}${platformName}.node` const distModulePath = join(this.destDir ?? '.', distFileName) const parsedDist = parse(distModulePath) if (parsedDist.dir && !existsSync(parsedDist.dir)) { await mkdirAsync(parsedDist.dir, { recursive: true }).catch((e) => { console.warn( chalk.bgYellowBright( `Create dir [${parsedDist.dir}] failed, reason: ${e.message}`, ), ) }) } const sourcePath = join( targetRootDir, 'target', targetDir, `${cargoArtifactName}${libExt}`, ) if (existsSync(distModulePath)) { debug(`remove old binary [${chalk.yellowBright(distModulePath)}]`) await unlinkAsync(distModulePath) } debug(`Write binary content to [${chalk.yellowBright(distModulePath)}]`) await copyFileAsync(sourcePath, distModulePath) if (!this.bin) { const dtsFilePath = join( process.cwd(), this.destDir ?? '.', this.dts ?? 'index.d.ts', ) if (this.pipe) { const pipeCommand = `${this.pipe} ${dtsFilePath}` console.info(`Run ${chalk.green(pipeCommand)}`) try { execSync(pipeCommand, { stdio: 'inherit', env: process.env }) } catch (e) { console.warn( chalk.bgYellowBright('Pipe the dts file to command failed'), e, ) } } const jsBindingFilePath = this.jsBinding && this.jsBinding !== 'false' && this.appendPlatformToFilename ? join(process.cwd(), this.jsBinding) : null const idents = await processIntermediateTypeFile( intermediateTypeFile, dtsFilePath, this.noDtsHeader, ) await writeJsBinding( binaryName, this.jsPackageName ?? packageName, jsBindingFilePath, idents, ) if (this.pipe && jsBindingFilePath) { const pipeCommand = `${this.pipe} ${jsBindingFilePath}` console.info(`Run ${chalk.green(pipeCommand)}`) try { execSync(pipeCommand, { stdio: 'inherit', env: process.env }) } catch (e) { console.warn( chalk.bgYellowBright('Pipe the js binding file to command failed'), e, ) } } } } } async function findUp(dir = process.cwd()): Promise { const dist = join(dir, 'target') if (existsSync(dist)) { return dir } const dirs = dir.split(sep) if (dirs.length < 2) { return null } dirs.pop() return findUp(dirs.join(sep)) } interface TypeDef { kind: 'fn' | 'struct' | 'impl' | 'enum' | 'interface' name: string original_name?: string def: string js_mod?: string js_doc: string } async function processIntermediateTypeFile( source: string, target: string, noDtsHeader: boolean, ): Promise { const idents: string[] = [] if (!existsSync(source)) { debug(`do not find tmp type file. skip type generation`) return idents } const tmpFile = await readFileAsync(source, 'utf8') const lines = tmpFile .split('\n') .map((line) => line.trim()) .filter(Boolean) if (!lines.length) { return idents } const allDefs = lines.map((line) => JSON.parse(line) as TypeDef) function convertDefs(defs: TypeDef[], nested = false): string { const classes = new Map< string, { def: string; js_doc: string; original_name?: string } >() const impls = new Map() let dts = '' const nest = nested ? 2 : 0 defs.forEach((def) => { switch (def.kind) { case 'struct': if (!nested) { idents.push(def.name) } classes.set(def.name, { original_name: def.original_name, def: def.def, js_doc: def.js_doc, }) break case 'impl': const existed = impls.get(def.name) impls.set( def.name, `${existed ? existed + '\n' : ''}${def.js_doc}${def.def}`, ) break case 'interface': dts += indentLines(`${def.js_doc}export interface ${def.name} {`, nest) + '\n' dts += indentLines(def.def, nest + 2) + '\n' dts += indentLines(`}`, nest) + '\n' break case 'enum': if (!nested) { idents.push(def.name) } dts += indentLines(`${def.js_doc}export const enum ${def.name} {`, nest) + '\n' dts += indentLines(def.def, nest + 2) + '\n' dts += indentLines(`}`, nest) + '\n' break default: if (!nested) { idents.push(def.name) } dts += indentLines(`${def.js_doc}${def.def}`, nest) + '\n' } }) for (const [name, { js_doc, def, original_name }] of classes.entries()) { const implDef = impls.get(name) if (original_name && name !== original_name) { dts += indentLines(`export type ${original_name} = ${name}\n`, nest) } dts += indentLines(`${js_doc}export class ${name} {`, nest) if (def) { dts += '\n' + indentLines(def, nest + 2) } if (implDef) { dts += '\n' + indentLines(implDef, nest + 2) } if (def || implDef) { dts += '\n' } else { dts += ` ` } dts += indentLines(`}`, nest) + '\n' } return dts } const topLevelDef = convertDefs(allDefs.filter((def) => !def.js_mod)) const namespaceDefs = Object.entries( groupBy( allDefs.filter((def) => def.js_mod), 'js_mod', ), ).reduce((acc, [mod, defs]) => { idents.push(mod) return acc + `export namespace ${mod} {\n${convertDefs(defs, true)}}\n` }, '') const dtsHeader = noDtsHeader ? '' : `/* tslint:disable */ /* eslint-disable */ /* auto-generated by NAPI-RS */\n ` const externalDef = topLevelDef.indexOf('ExternalObject<') > -1 || namespaceDefs.indexOf('ExternalObject<') > -1 ? `export class ExternalObject { readonly '': { readonly '': unique symbol [K: symbol]: T } }\n` : '' await unlinkAsync(source) await writeFileAsync( target, dtsHeader + externalDef + topLevelDef + namespaceDefs, 'utf8', ) return idents } function indentLines(input: string, spaces: number) { return input .split('\n') .map( (line) => ''.padEnd(spaces, ' ') + (line.startsWith(' *') ? line.trimEnd() : line.trim()), ) .join('\n') } async function writeJsBinding( localName: string, packageName: string, distFileName: string | null, idents: string[], ) { if (distFileName && idents.length) { const template = createJsBinding(localName, packageName) const declareCodes = `const { ${idents.join(', ')} } = nativeBinding\n` const exportsCode = idents.reduce( (acc, cur) => `${acc}\nmodule.exports.${cur} = ${cur}`, '', ) await writeFileAsync( distFileName, template + declareCodes + exportsCode + '\n', 'utf8', ) } }