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 } /** 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), ) for (const file of [...nativeFiles, ...sourceAssets].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')}` }