node-nim
Version:
NetEase IM nodejs wrapper based on NetEase IM C++ SDK
574 lines (515 loc) • 20 kB
JavaScript
const childProcess = require('child_process')
const fs = require('fs')
const os = require('os')
const path = require('path')
const repoRoot = path.resolve(__dirname, '../..')
const nodeNimRoot = path.resolve(__dirname, '..')
function parseArgs(argv) {
const options = {}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--ohos-install-dir') {
const value = argv[++i]
if (!value || value.startsWith('--')) {
throw new Error('--ohos-install-dir requires a value')
}
options.ohosInstallDir = value
} else if (arg === '--output') {
const value = argv[++i]
if (!value || value.startsWith('--')) {
throw new Error('--output requires a value')
}
options.output = value
} else if (arg === '--sdk-version') {
const value = argv[++i]
if (!value || value.startsWith('--')) {
throw new Error('--sdk-version requires a value')
}
options.sdkVersion = value
} else if (arg === '--skip-npm-install') {
options.skipNpmInstall = true
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
return options
}
function resolvePath(value) {
return path.isAbsolute(value) ? value : path.join(repoRoot, value)
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
}
function removeDir(dir) {
fs.rmSync(dir, { recursive: true, force: true })
}
function removeDirContents(dir, preserveNames = new Set()) {
if (!fs.existsSync(dir)) {
return
}
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (preserveNames.has(entry.name)) {
continue
}
fs.rmSync(path.join(dir, entry.name), { recursive: true, force: true })
}
}
function copyDir(src, dest, options = {}) {
const stat = fs.statSync(src)
if (!stat.isDirectory()) {
throw new Error(`${src} is not a directory`)
}
ensureDir(dest)
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (entry.name === '.DS_Store') {
continue
}
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (options.filter && !options.filter(srcPath, entry)) {
continue
}
if (entry.isDirectory()) {
copyDir(srcPath, destPath, options)
} else if (entry.isFile()) {
fs.copyFileSync(srcPath, destPath)
}
}
}
function replaceInFiles(root, predicate, replacer) {
if (!fs.existsSync(root)) {
return
}
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name)
if (entry.isDirectory()) {
replaceInFiles(entryPath, predicate, replacer)
} else if (entry.isFile() && predicate(entryPath)) {
const before = fs.readFileSync(entryPath, 'utf8')
const after = replacer(before, entryPath)
if (after !== before) {
fs.writeFileSync(entryPath, after)
}
}
}
}
function replaceInFilesWithRestore(root, predicate, replacer) {
const originals = new Map()
replaceInFiles(root, predicate, (content, file) => {
const replaced = replacer(content, file)
if (replaced !== content) {
originals.set(file, content)
}
return replaced
})
return () => {
for (const [file, content] of originals) {
fs.writeFileSync(file, content)
}
}
}
function unique(values) {
return values.filter(Boolean).filter((value, index, array) => array.indexOf(value) === index)
}
function isFile(file) {
try {
return fs.statSync(file).isFile()
} catch (error) {
return false
}
}
function isDir(dir) {
try {
return fs.statSync(dir).isDirectory()
} catch (error) {
return false
}
}
function isExecutable(file) {
try {
fs.accessSync(file, fs.constants.X_OK)
return true
} catch (error) {
return false
}
}
function prependPath(dirs) {
const existing = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
process.env.PATH = unique([...dirs.filter(isDir), ...existing]).join(path.delimiter)
}
function hasPathSeparator(command) {
return command.includes('/') || command.includes('\\')
}
function resolveExecutable(command, extraDirs = []) {
if (hasPathSeparator(command)) {
const executable = path.isAbsolute(command) ? command : path.resolve(repoRoot, command)
return isExecutable(executable) ? executable : ''
}
const pathDirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
for (const dir of unique([...extraDirs, ...pathDirs])) {
const candidate = path.join(dir, command)
if (isExecutable(candidate)) {
return candidate
}
}
return ''
}
function getMacOhosEnvironment(sdkVersion) {
if (process.platform !== 'darwin') {
return null
}
const home = os.homedir()
const commandLineToolHomes = unique([
process.env.COMMAND_LINE_TOOLS_HOME,
process.env.DEVECO_TOOLS_HOME,
path.join(home, 'Downloads/command-line-tools')
])
const toolHomeCandidates = unique([
process.env.DEVECO_TOOLS_HOME,
process.env.DEVECO_STUDIO_HOME ? path.join(process.env.DEVECO_STUDIO_HOME, 'Contents/tools') : '',
'/Applications/DevEco-Studio.app/Contents/tools',
'/Applications/DevEco Studio.app/Contents/tools',
...commandLineToolHomes
])
const openHarmonyHomes = unique([
process.env.OPENHARMONY_HOME,
path.join(home, 'Library/OpenHarmony')
])
const baseSdkHomes = unique([
process.env.OHOS_BASE_SDK_HOME,
...openHarmonyHomes.map((dir) => path.join(dir, 'Sdk')),
...commandLineToolHomes.map((dir) => path.join(dir, 'sdk/default/openharmony')),
path.join(home, 'Library/Huawei/Sdk')
])
const nativeHomeCandidates = unique([
process.env.OHOS_NATIVE_HOME,
...baseSdkHomes.map((dir) => path.basename(dir) === 'openharmony'
? path.join(dir, 'native')
: path.join(dir, sdkVersion, 'native'))
])
const nativeHome = nativeHomeCandidates.find((dir) => isFile(path.join(dir, 'build/cmake/ohos.toolchain.cmake'))) || ''
const sdkHome = nativeHome ? path.dirname(nativeHome) : ''
const baseSdkHome = nativeHome && path.basename(sdkHome) === sdkVersion
? path.dirname(sdkHome)
: sdkHome
const toolHome = toolHomeCandidates.find((dir) => {
return isExecutable(path.join(dir, 'bin/ohpm')) ||
isExecutable(path.join(dir, 'bin/hvigorw')) ||
isExecutable(path.join(dir, 'ohpm/bin/ohpm')) ||
isExecutable(path.join(dir, 'hvigor/bin/hvigorw'))
}) || ''
if (baseSdkHome) {
process.env.OHOS_BASE_SDK_HOME = baseSdkHome
if (baseSdkHome.endsWith('/Sdk')) {
process.env.OPENHARMONY_HOME = path.dirname(baseSdkHome)
}
}
if (nativeHome) {
process.env.OHOS_NATIVE_HOME = nativeHome
}
if (toolHome) {
process.env.COMMAND_LINE_TOOLS_HOME = toolHome
process.env.DEVECO_TOOLS_HOME = toolHome
}
prependPath([
nativeHome ? path.join(nativeHome, 'llvm/bin') : '',
sdkHome ? path.join(sdkHome, 'toolchains') : '',
toolHome ? path.join(toolHome, 'bin') : '',
toolHome ? path.join(toolHome, 'node/bin') : '',
toolHome ? path.join(toolHome, 'hvigor/bin') : '',
toolHome ? path.join(toolHome, 'ohpm/bin') : ''
])
return {
nativeHome,
nativeHomeCandidates,
toolHome,
toolHomeCandidates
}
}
function formatMissingDependencies(missing) {
return [
'Missing required tools for OHPM HAR packaging:',
...missing.map((item) => {
const details = item.details && item.details.length > 0
? `\n Checked:\n${item.details.map((detail) => ` - ${detail}`).join('\n')}`
: ''
return `- ${item.name}${details}`
})
].join('\n')
}
function resolvePackagingTools(options) {
const macEnv = getMacOhosEnvironment(options.sdkVersion)
const missing = []
if (process.platform === 'darwin' && !macEnv.nativeHome) {
missing.push({
name: `OpenHarmony native SDK ${options.sdkVersion}`,
details: macEnv.nativeHomeCandidates.map((dir) => path.join(dir, 'build/cmake/ohos.toolchain.cmake'))
})
}
const npx = resolveExecutable('npx')
if (!npx) {
missing.push({
name: 'npx',
details: ['Install Node.js with npm, or add npx to PATH']
})
}
if (!options.skipNpmInstall && !resolveExecutable('npm')) {
missing.push({
name: 'npm',
details: ['Install Node.js with npm, or pass --skip-npm-install after dependencies are installed']
})
}
const ohpm = resolveExecutable('ohpm')
if (!ohpm) {
missing.push({
name: 'ohpm',
details: process.platform === 'darwin' && macEnv.toolHomeCandidates
? macEnv.toolHomeCandidates.flatMap((dir) => [
path.join(dir, 'bin/ohpm'),
path.join(dir, 'ohpm/bin/ohpm')
])
: [
'/home/conan/command-line-tools/bin/ohpm',
'/home/conan/command-line-tools/ohpm/bin/ohpm',
'Add ohpm to PATH'
]
})
}
const hvigorwCommand = process.env.HVIGORW || 'hvigorw'
const hvigorw = resolveExecutable(hvigorwCommand)
if (!hvigorw) {
missing.push({
name: hvigorwCommand,
details: process.platform === 'darwin' && macEnv.toolHomeCandidates
? macEnv.toolHomeCandidates.flatMap((dir) => [
path.join(dir, 'bin/hvigorw'),
path.join(dir, 'hvigor/bin/hvigorw')
])
: [
'/home/conan/command-line-tools/bin/hvigorw',
'/home/conan/command-line-tools/hvigor/bin/hvigorw',
'Set HVIGORW or add hvigorw to PATH'
]
})
}
if (missing.length > 0) {
throw new Error(formatMissingDependencies(missing))
}
return { ohpm, hvigorw }
}
function run(command, args, cwd) {
console.log(`[node-nim][ohpm] ${command} ${args.join(' ')}`)
childProcess.execFileSync(command, args, {
cwd,
stdio: 'inherit',
shell: process.platform === 'win32'
})
}
function runGit(args) {
try {
return childProcess.execFileSync('git', args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
} catch (error) {
return ''
}
}
function isSemverCore(version) {
return /^\d+\.\d+\.\d+$/.test(version)
}
function resolveSdkDisplayVersion(sdkVersion) {
const sdkDisplayVersions = {
17: '5.0.5(17)'
}
return sdkDisplayVersions[sdkVersion] || sdkVersion
}
function resolveSdkApiVersion(sdkVersion) {
const value = String(sdkVersion).trim()
const displayMatch = value.match(/\((\d+)\)$/)
if (displayMatch) {
return displayMatch[1]
}
if (/^\d+$/.test(value)) {
return value
}
if (!value) {
throw new Error(`Invalid OHOS SDK version: ${sdkVersion}`)
}
throw new Error(`OHOS SDK version must be an API level, got: ${sdkVersion}`)
}
function getPackageVersion() {
const gitTag = runGit(['describe', '--abbrev=0', '--tags', '--exact-match'])
const gitBranch = runGit(['rev-parse', '--abbrev-ref', 'HEAD'])
const latestTag = runGit(['describe', '--tags', '--abbrev=0'])
const gitCommitCount = runGit(['rev-list', '--count', 'HEAD', `^${latestTag}~1`])
console.log(`[node-nim][ohpm] Git tag: ${gitTag}`)
console.log(`[node-nim][ohpm] Git branch: ${gitBranch}`)
console.log(`[node-nim][ohpm] Git commit count: ${gitCommitCount}`)
console.log(`[node-nim][ohpm] Latest tag: ${latestTag}`)
if (gitTag) {
return gitTag
}
if (!gitBranch) {
throw new Error('Unable to derive package version: missing git branch')
}
if (!gitCommitCount) {
throw new Error('Unable to derive package version: missing git commit count')
}
let channel = 'latest'
if (gitBranch.startsWith('release') || gitBranch.startsWith('hotfix')) {
channel = 'beta'
} else if (gitBranch.startsWith('feature')) {
channel = 'alpha'
}
const branchVersion = gitBranch.replace(/.*\//, '').replace(/-.*/, '')
const baseVersion = isSemverCore(branchVersion) ? branchVersion : latestTag
if (!baseVersion) {
throw new Error(`Unable to derive package version from git branch or latest git tag: ${gitBranch}`)
}
return `${baseVersion}-${channel}.${gitCommitCount}`
}
function copyNativeLibraries(nativeLibDir, abiDir, expectedBridge) {
if (!fs.existsSync(nativeLibDir)) {
throw new Error(`Missing native lib dir: ${nativeLibDir}`)
}
const soFiles = fs.readdirSync(nativeLibDir).filter((name) => name.endsWith('.so'))
if (soFiles.length === 0) {
throw new Error(`No .so files found in ${nativeLibDir}`)
}
const legacyBridge = expectedBridge.replace(/-/g, '_')
if (soFiles.includes(legacyBridge)) {
throw new Error(`Unexpected OHOS native bridge ${legacyBridge}; rebuild native output as ${expectedBridge}`)
}
if (!soFiles.includes(expectedBridge)) {
throw new Error(`Missing OHOS native bridge ${path.join(nativeLibDir, expectedBridge)}`)
}
ensureDir(abiDir)
for (const file of soFiles) {
fs.copyFileSync(path.join(nativeLibDir, file), path.join(abiDir, file))
}
}
function main() {
const args = parseArgs(process.argv.slice(2))
if (!args.ohosInstallDir) {
throw new Error('Missing required argument: --ohos-install-dir')
}
const version = getPackageVersion()
const sdkVersion = args.sdkVersion || process.env.OHPM_SDK_VERSION || '17'
const sdkApiVersion = resolveSdkApiVersion(sdkVersion)
const sdkDisplayVersion = resolveSdkDisplayVersion(sdkVersion)
const tools = resolvePackagingTools({
sdkVersion: sdkApiVersion,
skipNpmInstall: args.skipNpmInstall
})
const projectDir = resolvePath(process.env.OHPM_PROJECT_DIR || 'node-nim/ohpm')
const distDir = resolvePath(process.env.OHPM_DIST_DIR || 'node-nim/ohpm/dist')
const moduleDir = path.join(projectDir, 'node-nim')
const tsDir = path.join(moduleDir, 'ts')
const typesDir = path.join(moduleDir, 'types')
const abiDir = path.join(moduleDir, 'libs/arm64-v8a')
const legacyAbiDir = path.join(moduleDir, 'src/main/libs/arm64-v8a')
const legacyLibRootDir = path.join(moduleDir, 'src/main/libs')
const loaderFile = path.join(tsDir, 'loader.ts')
const ohosInstallDir = resolvePath(args.ohosInstallDir)
const nativeLibDir = path.join(ohosInstallDir, 'lib')
const harOutput = resolvePath(args.output || process.env.HAR_OUTPUT || `node-nim/ohpm/dist/node-nim-${version}.har`)
const tscOutDir = path.join(distDir, 'tsc-out')
const nativeTypePackage = 'libnode-nim.so'
console.log(`[node-nim][ohpm] Package version: ${version}`)
console.log(`[node-nim][ohpm] OHOS SDK version: ${sdkApiVersion}`)
console.log(`[node-nim][ohpm] OHOS SDK display version: ${sdkDisplayVersion}`)
console.log(`[node-nim][ohpm] OHOS install directory: ${ohosInstallDir}`)
if (process.env.OHOS_NATIVE_HOME) {
console.log(`[node-nim][ohpm] OHOS_NATIVE_HOME: ${process.env.OHOS_NATIVE_HOME}`)
}
if (process.env.DEVECO_TOOLS_HOME) {
console.log(`[node-nim][ohpm] DEVECO_TOOLS_HOME: ${process.env.DEVECO_TOOLS_HOME}`)
}
removeDir(distDir)
removeDir(path.join(projectDir, 'package'))
removeDir(path.join(projectDir, '.hvigor'))
removeDir(path.join(projectDir, 'oh_modules'))
removeDir(path.join(projectDir, 'build'))
removeDir(path.join(moduleDir, 'build'))
removeDir(path.join(moduleDir, 'oh_modules'))
removeDir(path.join(moduleDir, 'BuildProfile.ets'))
removeDir(path.join(projectDir, 'oh-package-lock.json5'))
removeDir(path.join(moduleDir, 'oh-package-lock.json5'))
ensureDir(distDir)
ensureDir(tsDir)
if (!fs.existsSync(loaderFile)) {
throw new Error(`Missing OHPM loader: ${loaderFile}`)
}
removeDirContents(tsDir, new Set(['loader.ts']))
removeDir(typesDir)
removeDir(abiDir)
removeDir(legacyAbiDir)
removeDir(legacyLibRootDir)
ensureDir(abiDir)
copyDir(path.join(nodeNimRoot, 'ts'), tsDir, {
filter: (srcPath, entry) => !(entry.isFile() && path.relative(path.join(nodeNimRoot, 'ts'), srcPath) === 'loader.ts')
})
if (!args.skipNpmInstall) {
run('npm', ['install', '--ignore-scripts', '--no-package-lock', '--include=dev'], nodeNimRoot)
}
run('npx', ['tsc', '--emitDeclarationOnly', '--declaration', '--declarationDir', typesDir, '--outDir', tscOutDir], nodeNimRoot)
const tsExt = (file) => file.endsWith('.ts') || file.endsWith('.d.ts')
replaceInFiles(tsDir, tsExt, (content) => content.replace(/from 'ts\/node-nim'/g, "from '../node-nim'"))
replaceInFiles(typesDir, tsExt, (content) => content.replace(/from 'ts\/node-nim'/g, "from '../node-nim'"))
fs.writeFileSync(path.join(typesDir, 'loader.d.ts'), [
`import type { NodeNimNativeSdk } from '${nativeTypePackage}'`,
'',
'declare const sdk: NodeNimNativeSdk',
'',
'export { sdk }',
'export default sdk',
''
].join('\n'))
const placeholderExt = (file) => ['.json5', '.ets', '.ts', '.json'].includes(path.extname(file))
copyNativeLibraries(nativeLibDir, abiDir, nativeTypePackage)
const nativeBridge = path.join(abiDir, nativeTypePackage)
if (!fs.existsSync(nativeBridge)) {
throw new Error(`Missing OHOS native bridge ${nativeBridge}`)
}
const nodeAddon = path.join(abiDir, 'node-nim.node')
if (fs.existsSync(nodeAddon)) {
throw new Error('node-nim.node must not enter the HAR package')
}
const restoreProjectFiles = replaceInFilesWithRestore(projectDir, placeholderExt, (content) => {
return content
.replace(/\{\{VERSION\}\}/g, version)
.replace(/"\{\{OHOS_SDK_VERSION\}\}"/g, sdkApiVersion)
.replace(/\{\{OHOS_SDK_VERSION\}\}/g, sdkApiVersion)
})
try {
run(tools.ohpm, ['install', '--all'], projectDir)
run(tools.hvigorw, ['assembleHar', '--no-daemon'], projectDir)
const outputDir = path.join(moduleDir, 'build/default/outputs/default')
const harFiles = fs.existsSync(outputDir)
? fs.readdirSync(outputDir).filter((name) => name.endsWith('.har'))
: []
if (harFiles.length !== 1) {
throw new Error(`Expected one HAR file in ${outputDir}, found ${harFiles.length}`)
}
const harFile = path.join(outputDir, harFiles[0])
if (!fs.existsSync(harFile)) {
throw new Error(`No HAR file generated at ${harFile}`)
}
ensureDir(path.dirname(harOutput))
fs.copyFileSync(harFile, harOutput)
if (fs.statSync(harOutput).size === 0) {
throw new Error(`Generated HAR is empty: ${harOutput}`)
}
} finally {
restoreProjectFiles()
}
console.log(`[node-nim][ohpm] HAR output: ${harOutput}`)
}
try {
main()
} catch (error) {
console.error(`[node-nim][ohpm] ${error.message}`)
process.exit(1)
}