import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync } from 'node:fs' import path from 'node:path' const NATIVE_EXTENSIONS = new Set([ '.c', '.cc', '.cmake', '.cpp', '.gradle', '.h', '.java', '.kts', '.kt', '.m', '.mm', '.pbxproj', '.podspec', '.properties', '.swift', '.toml', '.xcconfig', '.xml', ]) const PACKAGED_ASSET_EXTENSIONS = new Set([ '.bmp', '.gif', '.jpeg', '.jpg', '.mp3', '.mp4', '.otf', '.png', '.svg', '.ttf', '.webp', '.wav', ]) const IGNORED_DIRECTORIES = new Set([ '.cxx', '.git', '.gradle', 'build', 'bundle', 'coverage', 'dist', 'generated', 'node_modules', 'rn-bundles', ]) const IGNORED_FILES = new Set(['local.properties']) function walkFiles(directory, root, extensions, exactNames = new Set()) { if (!existsSync(directory)) return [] const files = [] for (const entry of readdirSync(directory, { withFileTypes: true })) { if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue const absolute = path.join(directory, entry.name) if (entry.isDirectory()) files.push(...walkFiles(absolute, root, extensions, exactNames)) else if ( !IGNORED_FILES.has(entry.name) && (extensions.has(path.extname(entry.name).toLowerCase()) || exactNames.has(entry.name)) ) { files.push({ absolute, relative: path.relative(root, absolute).split(path.sep).join('/') }) } } return files } function packageJson(directory) { const file = path.join(directory, 'package.json') return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null } function runtimeDependencyVersions(root, hostPackage) { const dependencies = hostPackage.dependencies ?? {} const runtime = [] for (const name of Object.keys(dependencies).sort()) { const directory = path.join(root, 'node_modules', ...name.split('/')) const manifest = packageJson(directory) runtime.push(`${name}@${manifest?.version ?? dependencies[name]}`) } return runtime } /** * 开发期 file: 依赖不会因为源码变化自动提升 package version,因此必须把它们的 * 原生源码纳入完整包基线;正式 registry 依赖仍由不可变版本号标识。 */ function localDependencyNativeFiles(root, hostPackage, platform) { const dependencies = hostPackage.dependencies ?? {} const files = [] for (const [name, requested] of Object.entries(dependencies).sort(([left], [right]) => left.localeCompare(right), )) { if (typeof requested !== 'string' || !requested.startsWith('file:')) continue const dependencyRoot = path.resolve(root, requested.slice('file:'.length)) for (const file of walkFiles( path.join(dependencyRoot, platform), dependencyRoot, new Set([...NATIVE_EXTENSIONS, ...PACKAGED_ASSET_EXTENSIONS]), new Set(platform === 'android' ? ['gradlew'] : ['Podfile']), )) { files.push({ ...file, relative: `local-dependency/${name}/${file.relative}` }) } } return files } /** Deterministic identity of code and dependencies that can only change through a full APK. */ export function computeNativeBaseline(root = process.cwd(), platform = 'android') { if (platform !== 'android' && platform !== 'ios') { throw new Error('platform must be android or ios') } const hostPackage = packageJson(root) if (!hostPackage) throw new Error('package.json is missing') const hash = createHash('sha256') hash.update(`platform\0${platform}\0`) for (const dependency of runtimeDependencyVersions(root, hostPackage)) { hash.update(`dependency\0${dependency}\0`) } const nativeFiles = walkFiles( path.join(root, platform), root, new Set([...NATIVE_EXTENSIONS, ...PACKAGED_ASSET_EXTENSIONS]), new Set(platform === 'android' ? ['gradlew'] : ['Podfile']), ) const sourceAssets = [path.join(root, 'src'), path.join(root, 'assets')].flatMap(directory => walkFiles(directory, root, PACKAGED_ASSET_EXTENSIONS), ) const localDependencyFiles = localDependencyNativeFiles(root, hostPackage, platform) for (const file of [...nativeFiles, ...sourceAssets, ...localDependencyFiles].sort((a, b) => a.relative.localeCompare(b.relative), )) { hash.update(`file\0${file.relative}\0`) hash.update(readFileSync(file.absolute)) hash.update('\0') } return `${platform}-sha256:${hash.digest('hex')}` }