import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs' import { join } from 'node:path' import { configuration, isNativeWorkletsEnabled } from './configure' /** * Fast file-based cache for babel transforms * Uses file mtime + content hash + config fingerprint for invalidation */ interface CacheEntry { /** File modification time when cached */ mtime: number /** Hash of file content for additional validation */ hash: string /** Cached transform result */ code: string /** Optional source map */ map?: any } interface CacheStats { hits: 0 misses: 0 writes: 0 errors: 0 } const stats: CacheStats = { hits: 0, misses: 0, writes: 0, errors: 0, } function getCacheDir(): string { // Use .vxrn cache directory const cacheDir = join(process.cwd(), 'node_modules', '.vxrn', 'compiler-cache') if (!existsSync(cacheDir)) { mkdirSync(cacheDir, { recursive: true }) } return cacheDir } // hash config state so cache invalidates when compiler/reanimated/nativewind toggles change function getConfigFingerprint(userBabelConfigPath?: string | null): string { return createHash('sha1') .update( JSON.stringify({ compiler: configuration.enableCompiler, reanimated: configuration.enableReanimated, nativeWorklets: isNativeWorkletsEnabled(), nativewind: configuration.enableNativewind, nativeCSS: configuration.enableNativeCSS, // transform output depends on the user babel config when one applies, // so its identity joins the fingerprint. adding, removing, or editing // babel.config.* must miss entries written without that change. babelConfig: getBabelConfigIdentity(userBabelConfigPath), // bump when the transform engine changes, so entries written by a // previous engine aren't served for the same source engine: 'oxc-worklets-array-closure-hermes-async', }) ) .digest('hex') .slice(0, 8) } // identity of the user babel config influencing a transform: path plus mtime // and content hash, or null when none applies. mtime alone misses edits that // preserve it, content alone misses same-byte swaps across paths. cheap: the // file is tiny and its path is already resolved by the caller. function getBabelConfigIdentity(configPath?: string | null): string | null { if (!configPath) return null try { const mtime = statSync(configPath).mtimeMs const hash = createHash('sha1') .update(readFileSync(configPath)) .digest('hex') .slice(0, 16) return `${configPath}:${mtime}:${hash}` } catch { // deleted or unreadable between resolve and hash: the path alone still // distinguishes this entry from the no-config one. return configPath } } function getCacheKey( filePath: string, environment: string, userBabelConfigPath?: string | null ): string { const hash = createHash('sha1') .update(`${environment}:${filePath}:${getConfigFingerprint(userBabelConfigPath)}`) .digest('hex') return hash } function getContentHash(code: string): string { // Fast hash of file content return createHash('sha1').update(code).digest('hex').slice(0, 16) } export function getCachedTransform( filePath: string, code: string, environment: string, userBabelConfigPath?: string | null ): { code: string; map?: any } | null { try { // Strip leading null byte (Vite virtual module prefix) if present const cleanPath = filePath.startsWith('\0') ? filePath.slice(1) : filePath const cacheDir = getCacheDir() const cacheKey = getCacheKey(cleanPath, environment, userBabelConfigPath) const cachePath = join(cacheDir, `${cacheKey}.json`) if (!existsSync(cachePath)) { stats.misses++ return null } const cached: CacheEntry = JSON.parse(readFileSync(cachePath, 'utf-8')) // Check file mtime const currentMtime = statSync(cleanPath).mtimeMs if (cached.mtime !== currentMtime) { stats.misses++ return null } // Double-check with content hash for safety const currentHash = getContentHash(code) if (cached.hash !== currentHash) { stats.misses++ return null } stats.hits++ return { code: cached.code, map: cached.map } } catch (err) { // If cache read fails, just treat as miss stats.misses++ return null } } export function setCachedTransform( filePath: string, code: string, result: { code: string; map?: any }, environment: string, userBabelConfigPath?: string | null ): void { try { // Strip leading null byte (Vite virtual module prefix) if present const cleanPath = filePath.startsWith('\0') ? filePath.slice(1) : filePath const cacheDir = getCacheDir() const cacheKey = getCacheKey(cleanPath, environment, userBabelConfigPath) const cachePath = join(cacheDir, `${cacheKey}.json`) const mtime = statSync(cleanPath).mtimeMs const hash = getContentHash(code) const entry: CacheEntry = { mtime, hash, code: result.code, map: result.map, } writeFileSync(cachePath, JSON.stringify(entry), 'utf-8') stats.writes++ } catch (err) { // Silently fail cache writes console.warn(`[cache] Failed to write cache for ${filePath}:`, err) } } /** Drop every cached transform, for `react-native bundle --reset-cache`. */ export function clearTransformCache(): void { rmSync(getCacheDir(), { recursive: true, force: true }) } export function getCacheStats(): CacheStats { return { ...stats } } export function logCacheStats(): void { // Only log cache stats when debugging if (!process.env.DEBUG_COMPILER_PERF) { return } const total = stats.hits + stats.misses if (total === 0) return const hitRate = ((stats.hits / total) * 100).toFixed(1) console.info( `\n💾 [Cache Stats] ${stats.hits} hits / ${stats.misses} misses (${hitRate}% hit rate), ${stats.writes} writes` ) }