// 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 './ipc-client'
import { createLifecycle } from './lifecycle'
import { planBoot } from './boot-plan'
import { encodeUtf8, createUtf8Decoder } from './utf8'
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.
// ⛔ THE DECISION LIVES IN host/boot-plan.js, WHERE IT CAN BE TESTED. This is the path that decides
// what EXECUTES on a device with no adb and no UI left if the node cannot boot, and it was the one
// uncovered file in the repo — uncovered because this module imports react-native-bare-kit and
// react-native-fs at module scope and so cannot load under bare. Everything below is now RN glue:
// the filesystem surface planBoot needs, injected.
//
// ⚠ Keep this a FAITHFUL adapter. A behaviour implemented here rather than there is a behaviour with
// no test, which is exactly the state this replaced.
const t0 = Date.now()
const plan = await planBoot({
storage,
io: {
readAsset: (name) => RNFS.readFileAssets(name, 'utf8'),
size: async (path) => (await RNFS.stat(path)).size,
exists: (path) => RNFS.exists(path),
readJson: async (path) => { try { return JSON.parse(await RNFS.readFile(path, 'utf8')) } catch { return null } },
writeText: (path, text) => RNFS.writeFile(path, text, 'utf8'),
copyAsset: (name, dest) => RNFS.copyFileAssets(name, dest),
unlink: (path) => RNFS.unlink(path),
dnsServers,
log: (m) => console.log(m)
}
})
const { entryPath, startArgs } = plan
const bundleHash = plan.bundleHash
// 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. This line is the only evidence of what was actually handed over.
console.log('[worklet] entry=' + entryPath + ' bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') +
' addons=' + (plan.addonsDir || 'none') + ' in ' + (Date.now() - t0) + 'ms')
// Path + null source => NativeBareKit.startFile: the runtime reads it, not us.
worklet.start(entryPath, null, startArgs)
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(() => {})
// ⛔ REWRITTEN, not ported. favela-android asked `hostedDids()` and rendered the DID list via
// `lib/describe.js`. Neither exists here: that op was removed from `ipc-client.js` with the rest of
// favela-android's typed API, and this node has no DIDs — it serves a favela feed. Ported verbatim,
// this line called a missing op through a missing module, which is what broke the Metro bundle.
//
// ⚠ Keeps describe.js's rule: never say more than is known, and never read the same in the healthy
// and the wrong case. `serving` is claimed only when the node reports a URL it actually bound.
client.whenReady()
.then(() => client.request('status'))
.then((s) => {
if (!s || !s.ready || !s.url) return describe('starting')
const feed = String(s.feed || '')
return describe('serving · ' + (feed.length > 12 ? feed.slice(0, 8) + '…' + feed.slice(-4) : feed))
})
.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()
}
/**
* Apply a staged bundle — by restarting the PROCESS, not the thread.
*
* The decision lives in `apply-plan.js` so it can be tested off-device; this is the glue that hands
* it the real native module and the real worklet restart. See that file for why the process is what
* has to go.
*/
export async function applyUpdate () {
const { planApply } = await import('./apply-plan.js')
return planApply({
native: NativeModules.FavelaNode,
restartNode,
log: (m) => console.log(JSON.stringify(m))
})
}