// The RN-side bridge: start the worklet, wire react-native-bare-kit's IPC to WorkletClient, hand the app
// a client. This is the ONLY file that knows react-native-bare-kit exists.
//
// ⛔ SEPARATE FROM lib/ipc-client.js ON PURPOSE. The client is runtime-agnostic and dependency-free so it
// can be tested against the real worklet with no device, no emulator and no Metro (test/ipc.test.mjs
// does exactly that). Importing react-native-bare-kit into it would make the whole data layer
// device-only to verify. Everything RN-specific is confined here.
import { Worklet } from 'react-native-bare-kit'
import RNFS from 'react-native-fs'
import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native'
import { WorkletClient } from '../lib/ipc-client'
import { createLifecycle } from '../lib/lifecycle'
import { chooseBundle } from '../lib/choose-bundle'
import { describeNode } from '../lib/describe'
import { encodeUtf8, createUtf8Decoder } from '../lib/utf8'
import { encodeShell, SHELL_FILE } from '../lib/shell-handoff'
let started = null
let starting = null
let lifecycle = null
// Removed on teardown, because boot() runs AGAIN on restart — leaving them attached would give a
// twice-restarted app three AppState listeners driving one suspend policy.
let subs = []
// NodeService, as lib/lifecycle.js wants to see it. The permission request lives here rather than in the
// policy because it is a UI event: from Android 13 a foreground service still RUNS without
// POST_NOTIFICATIONS, but its notification is not displayed — so the node would be replicating in the
// background with nothing on screen saying so, which is the one thing the notification exists to prevent.
// Denial is not fatal and must not block the service.
const service = {
async start () {
const native = NativeModules.FavelaNode
if (!native) return false
if (Platform.OS === 'android' && Platform.Version >= 33) {
try { await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS) } catch {}
}
return native.start()
},
async stop () {
const native = NativeModules.FavelaNode
if (native) await native.stop()
}
}
// The OS's DNS servers for the ACTIVE network, which is the only place they exist on Android: there is
// no /etc/resolv.conf, `getprop net.dns1` has been empty since Android 8, and netd does not expose the
// list to an app except through ConnectivityManager. Needs no new permission — ACCESS_NETWORK_STATE is
// already in the manifest for the foreground service.
//
// ⚠ A BOOT-TIME SNAPSHOT. Roaming to another network changes the answer and the node will not notice,
// because favela-web captures its resolver at import. That matches how every other favela node behaves
// (they read resolv.conf once, at start), so it is a known limit rather than a regression — the fix is a
// resolver that can be re-pointed, which is a change on the favela-web side.
async function dnsServers () {
const native = NativeModules.FavelaNode
if (!native || typeof native.dnsServers !== 'function') return []
try {
const list = await native.dnsServers()
return Array.isArray(list) ? list.filter((s) => typeof s === 'string' && s) : []
} catch { return [] }
}
// Replace the notification's text once the node can answer for itself. Never speculatively: the shell
// does not know what is being served until it asks, and the shade is the one surface a user reads
// without opening anything.
async function describe (text) {
const native = NativeModules.FavelaNode
if (native && text) { try { await native.describe(text) } catch {} }
}
/**
* Start the node once per app launch and resolve with its client.
*
* The storage path is passed as argv — a phone has no $HOME and no env, so this argv handoff replaces
* favela's FAVELA_STORE_ROOT convention entirely (spec/architecture.md §2).
*
* ⚠ ASYNC because the worklet bundle has to be READ. It cannot be imported: see metro.config.cjs — a
* Metro asset import hands back an asset id (a number), not bytes, and bare-kit rejects it.
*/
export function startNode () {
if (starting) return starting
starting = boot()
return starting
}
async function boot () {
const worklet = new Worklet()
const storage = RNFS.DocumentDirectoryPath
// `readFileAssets` reads out of the APK's assets/ — the only encoding that survives a binary file is
// base64, so it is decoded here. bare-kit takes a string or a TypedArray (index.js:189); the bundle is
// a length-prefixed binary container, so it must be the TypedArray.
// ⛔ THE BUNDLE MUST NOT PASS THROUGH THE JS HEAP. It used to: readFileAssets(…,'base64') materialised
// the whole thing as a base64 string, which is 4/3 of the bundle, and RN's heap has a hard growth
// limit. At a 21 MB bundle it just fit (b64 chars=28201128). At 34.9 MB it died on the device:
//
// Exception in HostFunction: Failed to allocate a 46477264 byte allocation with 8388608 free bytes
// and 22MB until OOM, target footprint 185804240, growth limit 201326592
//
// 46,477,264 is exactly the base64 of 34,857,935 bytes — the handoff, not the payload. bare-kit can
// load the bundle ITSELF (NativeBareKit.startFile, index.js:210) when `source` is null, so copy the
// asset out with a NATIVE copy and hand over a path. Nothing large is ever allocated in JS, and the
// bundle is free to grow — which is what porting favela-web in requires.
//
// Copied once and re-copied only when the size stamp changes (written by prebuild): hashing 35 MB on
// a phone at every launch to detect a change we already know about at build time would be silly.
const t0 = Date.now()
const dest = RNFS.DocumentDirectoryPath + '/worklet.bundle'
const wantSize = Number((await RNFS.readFileAssets('worklet.bundle.size', 'utf8')).trim())
// When the bundle inside THIS APK was built. chooseBundle compares it with the staged bundle's release
// time so that installing a newer APK is not silently overridden by JS staged before it. Absent on an
// APK built before the stamp existed, which chooseBundle reads as "no ordering available".
let assetBuiltAt = null
try { assetBuiltAt = Number((await RNFS.readFileAssets('worklet.bundle.built', 'utf8')).trim()) || null } catch {}
let haveSize = -1
try { haveSize = (await RNFS.stat(dest)).size } catch { /* first run */ }
if (haveSize !== wantSize) {
try { await RNFS.unlink(dest) } catch {}
await RNFS.copyFileAssets('worklet.bundle', dest)
}
// ── WHICH BUNDLE BOOTS ────────────────────────────────────────────────────────────────────────────
// OTA stages new worklet bundles into <storage>/bundles (lib/ota.js, inside the worklet). This side
// decides whether to RUN one — and it is the decision that can brick a handset, because there is no
// adb on a user's phone and no UI left if the node cannot boot.
//
// The watchdog: record the attempt BEFORE starting, clear it once the node reports ready, and treat a
// marker that survived a boot as proof that bundle does not work. chooseBundle is pure and tested
// (test/ota.test.mjs); everything here is the file I/O it asks for, and every failure falls back to the
// APK's own bundle, which is the one known to work because it shipped with this binary.
let bundlePath = dest
let bundleHashChosen = null
try {
const bdir = RNFS.DocumentDirectoryPath + '/bundles'
const readJson = async (p) => { try { return JSON.parse(await RNFS.readFile(p, 'utf8')) } catch { return null } }
const failed = (await readJson(bdir + '/failed.json')) || []
const choice = chooseBundle({
current: await readJson(bdir + '/current.json'),
pending: await readJson(bdir + '/pending.json'),
failed,
assetBuiltAt
})
if (choice.condemn) {
// Last boot started this bundle and never reached ready. Remember it so it is never chosen again —
// otherwise every boot picks it, dies the same way, and the phone is unrecoverable from outside.
console.log('[worklet] bundle ' + choice.condemn.slice(0, 12) + '… failed to boot last time — condemned')
if (!failed.includes(choice.condemn)) failed.push(choice.condemn)
await RNFS.writeFile(bdir + '/failed.json', JSON.stringify(failed), 'utf8')
try { await RNFS.unlink(bdir + '/pending.json') } catch {}
}
if (choice.use === 'staged' && await RNFS.exists(bdir + '/' + choice.file)) {
await RNFS.writeFile(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }), 'utf8')
bundlePath = bdir + '/' + choice.file
bundleHashChosen = choice.hash
console.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')')
}
} catch (e) {
// A broken OTA directory must never stop the app starting: the APK's bundle is always there.
console.log('[worklet] bundle choice failed, using the APK bundle: ' + e.message)
bundlePath = dest
}
const source = null
// The worklet has NO error or exit event (react-native-bare-kit/index.js has neither), so a failure
// inside it is silent from here. These lines are the only evidence of what was actually handed over.
console.log('[worklet] bundle ' + wantSize + ' bytes at ' + dest +
(haveSize === wantSize ? ' (cached)' : ' (copied)') + ' in ' + (Date.now() - t0) + 'ms')
// ── WHAT THIS SIDE KNOWS AND THE NODE CANNOT ─────────────────────────────────────────────────────
// Three facts, written where the node will look for them (lib/shell-handoff.js explains why a file and
// not argv — a positional argument would be read as the storage path by any older bundle):
//
// bundle WHICH bundle is executing. The OTA store knows what is STAGED; only this side knows what
// was started, and without it every boot "staged" the bundle it was already running.
// dns the OS's resolvers for the active network. Android has no /etc/resolv.conf, so without
// this the node turns DNS TXT handle verification off and most handles render invalid.
// ota that THIS shell can service the settings page's update buttons. The shell ships in the
// APK and the bundle arrives by OTA, so a new bundle can find itself inside an old shell;
// the page renders those controls only when this says they will do something.
// pair the same claim for the pairing screen, which moved out of a floating overlay and into
// the settings page — separate flag because the two arrived in different releases.
//
// Written before every start and never appended to, so it always describes the CURRENT boot. Failure
// is non-fatal: the node boots with less knowledge, not not at all.
const bundleHash = bundlePath === dest ? null : (bundleHashChosen || null)
try {
const dns = await dnsServers()
await RNFS.writeFile(storage + '/' + SHELL_FILE, encodeShell({ ota: true, pair: true, bundle: bundleHash, dns }), 'utf8')
console.log('[worklet] shell handoff: bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') + ' dns=' + dns.length)
} catch (e) {
console.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
}
// Path + null source => NativeBareKit.startFile: the runtime reads it, not us.
worklet.start(bundlePath, source, [storage])
console.log('[worklet] start() returned')
// ⛔ KEEP RUNNING WITH THE SCREEN OFF. Immediately after start(), because until this returns the node
// is under react-native-bare-kit's own policy — an AppState listener it registers at import time that
// suspends EVERY worklet on 'background', i.e. on every screen lock. lib/lifecycle.js takes that over
// and ties suspend to whether the foreground service is running instead. Awaited rather than fired off:
// the policy must be settled before the user can background the app, and it costs ~100ms against a
// ~40s boot.
lifecycle = createLifecycle({ worklet, service, log: (m) => console.log('[worklet] ' + m) })
const foreground = await lifecycle.start()
console.log('[worklet] background replication ' + (foreground ? 'ON (foreground service)' : 'OFF — the node will suspend when the app is not on screen'))
subs.push(AppState.addEventListener('change', (state) => { lifecycle.onAppState(state) }))
// The Stop action on the notification. The service drops the exemption and goes away; the node is NOT
// torn down — it keeps running while the app is open and suspends once it is backgrounded, which is
// what "stop replicating in the background" means. Tearing it down here would make one button mean two
// things.
subs.push(DeviceEventEmitter.addListener('favela-node-service-stopped', () => {
console.log('[worklet] foreground service stopped — the node will suspend when the app is not on screen')
lifecycle.onServiceStopped()
}))
const { IPC } = worklet
// ⛔ BYTES ON THE WIRE, TEXT IN THE CLIENT. bare-kit's IPC converts nothing in either direction: it
// pushes a plain Uint8Array up (`.toString()` on which is Array.prototype.toString — the comma-joined
// byte VALUES, not text) and it writes only TypedArrays down. The client speaks strings by design, so
// this file owns the conversion — see lib/utf8.js and test/utf8.test.mjs.
const client = new WorkletClient((line) => IPC.write(encodeUtf8(line)))
// bare-kit's IPC is a stream: chunk boundaries are NOT message boundaries, which is why the client
// buffers partial lines rather than parsing per-chunk (proven in test/ipc.test.mjs). They are not
// character boundaries either, which is why the decoder is stateful.
const decode = createUtf8Decoder()
IPC.on('data', (data) => client.receive(decode(data)))
IPC.on('close', () => client.close('worklet stream closed'))
IPC.on('error', (err) => client.close('worklet stream error: ' + (err && err.message)))
// Once the node is up, let the notification say what it is actually serving rather than the generic
// line the service starts with. Fire-and-forget and non-blocking: boot takes ~40s, a node that cannot
// describe itself still runs, and nothing here may delay the UI appearing.
// ⛔ READY IS THE ONLY PROOF A BUNDLE WORKS. Clearing the pending marker here, and nowhere earlier, is
// what makes the watchdog meaningful: a bundle that starts, throws, and dies still leaves its marker.
client.whenReady()
.then(async () => { try { await RNFS.unlink(RNFS.DocumentDirectoryPath + '/bundles/pending.json') } catch {} })
.catch(() => {})
client.whenReady()
.then(() => client.hostedDids())
.then((r) => describe(describeNode(r && r.dids)))
.catch(() => {})
started = { worklet, client, storage }
return started
}
/**
* Stop the node. Called when the app is being torn down for real, and to RESTART it — NOT on
* backgrounding.
*
* ⚠ Backgrounding must NOT stop the node: that is the whole point of running a peer on the phone, and
* spec/mobile-node.md §5 is explicit that Android will kill it soon enough on its own. Backgrounding is
* handled by lib/lifecycle.js + NodeService (spec/architecture.md §7), never here.
*
* ⛔ EVERY SUBSCRIPTION IS DROPPED. stopNode + startNode is now a supported pair (it is how a staged
* bundle is applied and how a wedged node is recovered), so anything this file attached must come off:
* a listener left behind would still be holding the TERMINATED worklet's lifecycle policy, and the
* second boot's listener would be the second voice deciding when to suspend.
*/
export function stopNode () {
if (!started) return
// The notification goes first: it says the node is replicating in the background, and for the moment
// between terminate() and the service being told, it would be claiming something untrue.
if (lifecycle) { lifecycle.stop().catch(() => {}); lifecycle = null }
for (const sub of subs) { try { sub.remove() } catch {} }
subs = []
try { started.client.close('app teardown') } catch {}
try { started.worklet.terminate() } catch {}
started = null
starting = null
}
/**
* Stop the node and start it again — the ONLY way a staged bundle becomes the running one.
*
* Applying an update to a running node would mean swapping code under itself, so lib/ota.js stages and
* stops. This is the other half, and it lives here because only this side owns the runtime: the worklet
* cannot start a different worklet. On the way back up, chooseBundle picks whatever is staged, which is
* what makes "restart" and "apply the update" the same button.
*/
export async function restartNode () {
stopNode()
return startNode()
}