Files
orca/mobile/scripts/native-build-cache.cjs
T
Neil 4b347681bc feat(mobile): skip dev-client rebuilds when the Expo native fingerprint is unchanged (#33410)
Add `pnpm native ensure <ios|android>`, which installs a cached dev-client
build keyed by the Expo fingerprint and only runs `expo run` on a miss.
The cache lives in the user cache dir so every worktree shares it, and
app.config.js registers it as Expo's buildCacheProvider so plain
`expo run` reuses hits too. A report-only PR job flags fingerprint
changes against the base in the step summary.
2026-10-24 23:08:43 -07:00

189 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict'
// One dev-client build cache, keyed by Expo fingerprint, shared by every worktree on the machine.
// app.config.js registers this module as Expo's `buildCacheProvider`, so `expo run:<platform>`
// installs a cached build on a hit; scripts/native-client.mjs is the only writer. CommonJS because
// Expo CLI loads providers with require().
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { createRequire } = require('node:module')
const CACHE_DIR_ENV = 'ORCA_MOBILE_NATIVE_BUILD_CACHE_DIR'
// `store` is set by native-client.mjs after it regenerated the native project for the current
// fingerprint. Plain `expo run` never stores: a stale ios/ or android/ directory would otherwise be
// cached under a fingerprint it was not built from.
const CACHE_MODE_ENV = 'ORCA_MOBILE_NATIVE_BUILD_CACHE_MODE'
const ENTRIES_KEPT_PER_PLATFORM = 3
const STAGING_PREFIX = '.staging-'
const STALE_STAGING_MS = 24 * 60 * 60 * 1000
const ARTIFACT_EXTENSIONS = ['.app', '.apk']
function resolveCacheDir(env = process.env, platform = process.platform, homedir = os.homedir()) {
if (env[CACHE_DIR_ENV]) {
return path.resolve(env[CACHE_DIR_ENV])
}
let base
if (platform === 'darwin') {
base = path.join(homedir, 'Library', 'Caches')
} else if (platform === 'win32') {
base = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local')
} else {
base = env.XDG_CACHE_HOME || path.join(homedir, '.cache')
}
return path.join(base, 'orca-mobile', 'native-builds')
}
function buildVariant(platform, runOptions = {}) {
return platform === 'ios'
? (runOptions.configuration ?? 'Debug')
: (runOptions.variant ?? 'debug')
}
function cacheEntryKey(platform, fingerprintHash, variant) {
return `${platform}-${fingerprintHash}-${variant}`
}
function findCachedBuild(cacheDir, key) {
const entryDir = path.join(cacheDir, key)
let names
try {
names = fs.readdirSync(entryDir)
} catch {
return null
}
const artifact = names.find((name) => ARTIFACT_EXTENSIONS.includes(path.extname(name)))
if (!artifact) {
return null
}
// Pruning keeps the most recently used entries.
const now = new Date()
try {
fs.utimesSync(entryDir, now, now)
} catch {}
return path.join(entryDir, artifact)
}
function storeBuild(cacheDir, key, buildPath) {
fs.mkdirSync(cacheDir, { recursive: true })
const artifactName = path.basename(buildPath)
// Copy into a staging directory and rename, so a concurrent reader never sees a partial build.
const staging = fs.mkdtempSync(path.join(cacheDir, `${STAGING_PREFIX}${key}-`))
const entryDir = path.join(cacheDir, key)
try {
fs.cpSync(buildPath, path.join(staging, artifactName), {
recursive: true,
verbatimSymlinks: true
})
fs.rmSync(entryDir, { recursive: true, force: true })
fs.renameSync(staging, entryDir)
} catch (error) {
fs.rmSync(staging, { recursive: true, force: true })
// Another worktree finished the same fingerprint first; its entry is equivalent.
const concurrent = findCachedBuild(cacheDir, key)
if (concurrent) {
return concurrent
}
throw error
}
pruneCache(cacheDir, key.split('-')[0])
return path.join(entryDir, artifactName)
}
function pruneCache(cacheDir, platform, keep = ENTRIES_KEPT_PER_PLATFORM, now = Date.now()) {
let dirents
try {
dirents = fs.readdirSync(cacheDir, { withFileTypes: true })
} catch {
return []
}
const removed = []
const entries = []
for (const dirent of dirents) {
if (!dirent.isDirectory()) {
continue
}
const fullPath = path.join(cacheDir, dirent.name)
const mtimeMs = fs.statSync(fullPath).mtimeMs
if (dirent.name.startsWith(STAGING_PREFIX)) {
if (now - mtimeMs > STALE_STAGING_MS) {
fs.rmSync(fullPath, { recursive: true, force: true })
removed.push(dirent.name)
}
} else if (dirent.name.startsWith(`${platform}-`)) {
entries.push({ name: dirent.name, fullPath, mtimeMs })
}
}
entries.sort((a, b) => b.mtimeMs - a.mtimeMs)
for (const entry of entries.slice(keep)) {
fs.rmSync(entry.fullPath, { recursive: true, force: true })
removed.push(entry.name)
}
return removed
}
// Resolved through expo, not a direct dependency, so it is the exact version Expo CLI runs.
function loadFingerprintModule(projectRoot) {
const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
return createRequire(projectRequire.resolve('expo/package.json'))('@expo/fingerprint')
}
function createNativeFingerprint(projectRoot, platform) {
return loadFingerprintModule(projectRoot).createFingerprintAsync(projectRoot, {
platforms: [platform],
silent: true
})
}
// Expo CLI hashes once to look up and again to store; memoize so both use the pre-build sources.
const fingerprintHashes = new Map()
function log(message) {
console.log(`› [native-build-cache] ${message}`)
}
// The provider hooks Expo CLI calls, plus the helpers native-client.mjs shares.
module.exports = {
CACHE_DIR_ENV,
CACHE_MODE_ENV,
buildVariant,
cacheEntryKey,
createNativeFingerprint,
findCachedBuild,
loadFingerprintModule,
pruneCache,
resolveCacheDir,
storeBuild,
calculateFingerprintHash({ projectRoot, platform }) {
const memoKey = `${projectRoot}\0${platform}`
if (!fingerprintHashes.has(memoKey)) {
fingerprintHashes.set(
memoKey,
createNativeFingerprint(projectRoot, platform).then((fingerprint) => fingerprint.hash)
)
}
return fingerprintHashes.get(memoKey)
},
async resolveBuildCache({ platform, fingerprintHash, runOptions }) {
if (process.env[CACHE_MODE_ENV] === 'store') {
return null
}
const key = cacheEntryKey(platform, fingerprintHash, buildVariant(platform, runOptions))
const cached = findCachedBuild(resolveCacheDir(), key)
log(
cached
? `HIT ${key}: installing the cached build`
: `MISS ${key}: building (run \`pnpm native ensure ${platform}\` to cache it)`
)
return cached
},
async uploadBuildCache({ platform, fingerprintHash, buildPath, runOptions }) {
if (process.env[CACHE_MODE_ENV] !== 'store') {
return null
}
const key = cacheEntryKey(platform, fingerprintHash, buildVariant(platform, runOptions))
const stored = storeBuild(resolveCacheDir(), key, buildPath)
log(`stored ${key}`)
return stored
}
}