// Adapted from favela-surface-android/scripts/stage-addons.mjs (2026-10-02): the loader-bundle union
// and the apk-addons.json asset are removed (they serve favela's OTA, which v1 does not have);
// staging, the demanded-addon check and the ELF NEEDED keep-list are unchanged.
// Stage exactly the native addons the packed worklet bundle DEMANDS — no more.
//
// ⛔ WHY THIS EXISTS: bare-link walks the PACKAGE graph (every dependency whose package.json says
// `addon: true`), while bare-pack resolves the MODULE graph reachable from lib/node.js. Those are not
// the same set, and the difference is not small: measured on 0.0.1, bare-link staged 61 arm64 libs
// while the bundle demanded 40. The 21 extra were 50 MB of APK — including four stale rocksdb-native
// versions (3.15.0, 3.15.2, 3.16.0, 3.17.1) reached only through the symlinked sibling checkouts,
// which each carry their own independently-installed node_modules. The bundle itself was already
// clean: favela-android's `overrides` pin one rocksdb, so only 3.17.2 is reachable from the entry.
//
// So the fix is NOT to re-point the pack at a deduped tree — the pack was never the problem. It is to
// stop shipping addons nothing loads.
//
// THE BUNDLE IS THE CONTRACT. Its header names each addon as `linked:lib<name>.<version>.so`, and at
// runtime bare-kit dlopen's exactly those. Anything staged beyond that list is dead weight; anything
// missing from it is a dlopen failure on device. Deriving the set from the bundle means it cannot
// drift as dependencies move — a hardcoded exclude list would go stale silently, which is the failure
// mode worth designing against.
//
// ⛔ THIS SCRIPT DOES NOT PRUNE, AND MUST NOT. bare-kit's own android/build.gradle declares
// task link(type: Exec) { commandLine "node", "link.mjs" }
// preBuild.dependsOn link
// so gradle re-stages all four hosts before EVERY build. Anything deleted here is restored before
// packaging — measured: pruning to 40 libs produced a byte-identical APK. The exclusion therefore
// lives at packaging time, in app/build.gradle's jniLibs.excludes, where nothing can restage behind
// it. What this script does is populate the dir (so build.gradle can enumerate it at configure time)
// and FAIL FAST if the bundle demands an addon that has no prebuild.
//
// Run AFTER `npm run pack:worklet` (it reads the bundle) and BEFORE the gradle build. Idempotent.
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import link from 'bare-link'
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
// ⚠ THIS COMMENT USED TO SAY app/worklet.bundle, AND IT IS NOW WRONG. The Metro-asset route it
// describes never worked: `import bundle from './worklet.bundle'` yields Metro's numeric ASSET ID, not
// bytes, so bare-kit rejected it with "Source must be a string or TypedArray. Received type number (1)".
// The bundle now ships as an ANDROID asset and app/worklet.js reads its bytes at runtime.
//
// Reading the old path left this script staging against whatever stale bundle happened to sit there —
// it reported "40 demanded" while the real bundle demanded 38, i.e. it was answering about a build that
// no longer existed. Same stale-path class as the one fixed in android/app/build.gradle.
const bundlePath = path.join(root, 'android/app/src/main/assets/worklet.bundle')
// The WIRED staging dir is bare-kit's own module, not the app's: react-native-bare-kit/android/
// build.gradle declares `jniLibs.srcDirs "src/main/addons"` relative to ITSELF. A previous
// scripts/link-addons.mjs staged into android/app/src/main/addons, which no sourceSets references —
// proven inert (APKs built with and without it were byte-identical). Do not "fix" this path back.
const out = path.join(root, 'node_modules/react-native-bare-kit/android/src/main/addons')
// arm64 only, matching abiFilters in app/build.gradle. An emulator build (spikes 3/5) wants
// `--hosts android-x64` here AND the matching abiFilters change.
const hostArg = process.argv.indexOf('--hosts')
const hosts = hostArg === -1 ? ['android-arm64'] : process.argv[hostArg + 1].split(',')
// Bundle layout (bare-bundle toBuffer): ASCII decimal header length, then that many bytes of JSON.
// Parsed by hand so this script needs no dependency bare-pack does not already bring.
function demandedAddons (file) {
const buf = fs.readFileSync(file)
const nl = buf.indexOf(10)
if (nl === -1) throw new Error('malformed bundle: no header length delimiter')
const len = parseInt(buf.subarray(0, nl).toString(), 10)
if (!Number.isInteger(len) || len <= 0) throw new Error('malformed bundle: bad header length')
const header = JSON.parse(buf.subarray(nl, nl + len).toString().trim())
if (!Array.isArray(header.addons)) throw new Error('malformed bundle: no addons list')
return new Set(header.addons.map((a) => a.replace(/^linked:/, '')))
}
if (!fs.existsSync(bundlePath)) {
console.error('no worklet bundle at ' + path.relative(root, bundlePath))
console.error('run `npm run pack:worklet` first — this script stages what that bundle demands.')
process.exit(1)
}
const want = demandedAddons(bundlePath)
console.log(`bundle demands ${want.size} addon(s)`)
for await (const _ of link(root, { hosts, out })) { /* bare-link stages the superset */ }
// ⛔ VERIFY THE ABIs WE SHIP, NOT WHATEVER DIRECTORIES EXIST. This loop used to check every directory
// under `out`, and `out` lives in node_modules, which the CI copies wholesale from the live checkout —
// so it still holds armeabi-v7a/x86/x86_64 from before the arm64-only decision (62 .so each). Staging
// only refreshes arm64, so the moment a demanded addon's version CHANGED, the stale dirs no longer had
// the file under its new name and the build failed on three ABIs this APK does not contain:
//
// DEMANDED BUT NOT STAGED: armeabi-v7a/libbare-dns.2.2.0.so, x86/…, x86_64/…
//
// (bare-link names prebuilds lib<name>.<version>.so, so a dependency bump renames the file — which is
// what turned dormant leftovers into a build failure on the bare-dns 2.1.4 → 2.2.0 upgrade.)
//
// abiFilters keeps those ABIs out of the APK regardless, so a missing .so there cannot dlopen-fail on
// any device we ship to. Checking them was asserting on leftovers, not on the build.
const ABI_FOR_HOST = {
'android-arm64': 'arm64-v8a',
'android-arm': 'armeabi-v7a',
'android-ia32': 'x86',
'android-x64': 'x86_64'
}
const shipped = new Set(hosts.map((h) => ABI_FOR_HOST[h] || h))
// Report the split per ABI and verify the contract. Only .so files are considered — bare-kit also
// consumes *.classes.jar from this dir.
const missing = []
// A shipped ABI with no directory at all is the failure this check exists for, and an empty `out`
// would otherwise pass silently — the loop below would simply iterate over nothing.
for (const abi of shipped) {
if (!fs.existsSync(path.join(out, abi))) missing.push(`${abi}/ (nothing staged for a shipped ABI)`)
}
for (const abi of fs.readdirSync(out)) {
const abiDir = path.join(out, abi)
if (!fs.statSync(abiDir).isDirectory()) continue
const present = new Set()
let excess = 0
let excessBytes = 0
for (const file of fs.readdirSync(abiDir)) {
if (!file.endsWith('.so')) continue
if (want.has(file)) { present.add(file); continue }
excess++
excessBytes += fs.statSync(path.join(abiDir, file)).size
}
// NB: excessBytes is the UNSTRIPPED on-disk size. AGP strips native debug symbols when packaging a
// release, so the APK saving is smaller than this — measured 50.8 MB staged vs 38.1 MB of actual
// arm64 APK entries. Quote the APK delta, not this, when reporting size wins.
console.log(
` ${abi}: ${present.size}/${want.size} demanded staged, ` +
`${excess} excess excluded at packaging (${(excessBytes / 1048576).toFixed(1)} MB on disk, pre-strip)` +
(shipped.has(abi) ? '' : ' — NOT SHIPPED (abiFilters), not verified')
)
// A demanded addon with no staged .so is a dlopen failure at runtime, on a device, with no
// build-time signal. Fail here instead — but only for an ABI that reaches a device.
if (!shipped.has(abi)) continue
for (const w of want) if (!present.has(w)) missing.push(`${abi}/${w}`)
}
if (missing.length) {
console.error('\nDEMANDED BUT NOT STAGED — these would fail to dlopen on device:')
for (const m of missing) console.error(' ' + m)
process.exit(1)
}
// ── NATIVE DEPENDENCY CLOSURE ────────────────────────────────────────────────────────────────────────
//
// ⛔ THE BUNDLE HEADER IS NOT THE WHOLE TRUTH. It names the addons the JS MODULE graph demands. A
// demanded addon can link against ANOTHER .so that no JS module imports — `libbare-fs.4.7.3.so` needs
// `libbare-buffer.3.6.1.so`, which appears nowhere in the header because only 3.6.2 is imported from JS.
//
// android/app/build.gradle excluded every staged lib absent from the header, and its comment claimed
// that "can shrink the APK but never break a load". It broke exactly one load: pairing died with
// ADDON_NOT_FOUND on bare-fs 4.7.3 — resolution SUCCEEDED and produced the right linked name, then
// dlopen failed on the missing transitive dependency, which bare reports as a resolution error.
//
// So the keep-set is the demanded addons PLUS the transitive closure of their ELF NEEDED entries. This
// is written to a file gradle reads, rather than reimplementing ELF parsing in Groovy.
import { execFileSync } from 'child_process'
function neededOf (soPath) {
try {
const out = execFileSync('readelf', ['-d', soPath], { encoding: 'utf8' })
return [...out.matchAll(/NEEDED\).*?\[([^\]]+)\]/g)].map((m) => m[1])
} catch { return [] } // no readelf → keep everything (see below); never silently narrow the set
}
let readelfOk = true
try { execFileSync('readelf', ['--version'], { stdio: 'ignore' }) } catch { readelfOk = false }
const keepByAbi = {}
for (const abi of fs.readdirSync(out)) {
const dir = path.join(out, abi)
if (!fs.statSync(dir).isDirectory()) continue
const present = new Set(fs.readdirSync(dir).filter((f) => f.endsWith('.so')))
const keep = new Set([...want].filter((d) => present.has(d)))
// Walk NEEDED transitively over what is actually staged.
const queue = [...keep]
while (queue.length) {
const so = queue.pop()
for (const dep of neededOf(path.join(dir, so))) {
if (present.has(dep) && !keep.has(dep)) { keep.add(dep); queue.push(dep) }
}
}
keepByAbi[abi] = [...keep].sort()
const extra = keepByAbi[abi].filter((k) => !want.has(k))
if (extra.length) console.log(` ${abi}: +${extra.length} kept as NATIVE dependencies of demanded addons — ${extra.join(', ')}`)
}
if (!readelfOk) {
console.warn(' ⚠ readelf unavailable — cannot compute the native closure. Writing NO keep-list so gradle keeps everything; a smaller APK is not worth an unloadable addon.')
}
const keepPath = path.join(root, 'android/app/addon-keep.json')
fs.writeFileSync(keepPath, JSON.stringify(readelfOk ? keepByAbi : {}, null, 2) + '\n')
console.log(' keep-list ->', path.relative(root, keepPath))