// favela-surface-android worklet — a full favela-peer node, with favela-surface rendering its pages.
//
// This runs INSIDE a bare-kit worklet. There is no shell, no $HOME and no env: Android hands the
// app-private directory in as argv, and the React Native side talks to it over bare-kit's IPC.
//
// ── ⛔ One process, and that is the whole design ───────────────────────────────────────────────
//
// On a desktop, favela-surface reaches favela-peer over a unix socket. Here it does not, and not as
// a compromise:
//
// - `favela-peer/lib/rpc.js` exports `dispatch(node, cmd, a)` next to `createServer(node, sock)`.
// `dispatch` IS the interface; the socket is one ADAPTER over it, correct where the two halves
// are genuinely two processes. A worklet is one thread, so there is nothing left to adapt.
// - A pathname unix socket could not be created here anyway. Measured with socat on real
// hardware: `bind(AF_UNIX /path/x.sock)` -> EACCES, because Android SELinux forbids an
// unprivileged domain creating a `sock_file`.
// - Loopback TCP between them would work and is worse: a socket file carries permissions, and a
// loopback port on Android is reachable by every other app on the device.
//
// ⇒ A protocol crosses a boundary. bare-kit spends one where there is a real one — native to JS —
// and pays with a socketpair rather than a pathname socket. Inside the thread, we call the function.
//
// ⚠ The HTTP server below is a genuine boundary: a WebView is a browser and speaks HTTP. It binds
// loopback only, and serves the same pages a desktop gets.
// ⛔ FIRST, before anything that reaches @noble via the atproto stack. Those modules use TextEncoder
// at module scope and Bare has no such global, so the import fails with "TextEncoder is not defined"
// — a shim loaded after the fact is loaded too late. favela-android does the same thing one line up.
import 'hyper-proto-peer/lib/bare-globals.js'
import fs from 'bare-fs'
import process from 'bare-process'
import { join } from 'bare-path'
import Hyperswarm from 'hyperswarm'
import { FavelaNode } from 'favela-peer/lib/node.js'
import { SwarmTransport } from 'favela-peer/lib/transport.js'
import { createUtf8Decoder } from './host/utf8.js'
import { safeStringify } from './host/safe-json.js'
// Stamped at build time by scripts/gen-version.mjs — the bundle carries no readable package.json.
import { VERSION, BUILT_AT, DEPS } from './version.js'
/* global BareKit, Bare */
const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null
// ⛔ Every line goes to the console TOO, never only over IPC. A worklet whose UI has not attached
// yet — or has gone away — still needs its boot story readable in `adb logcat`, and favela-android
// learned that the expensive way: sending only over IPC meant the reason a boot failed existed for
// the two seconds before anyone could ask for it.
function send (obj) {
// ⛔ THE SERIALIZATION IS THE PART THAT KILLED IT. This used to be a bare `JSON.stringify(obj)`
// above the try: the WRITE was guarded and the ENCODE was not. On 2026-09-15 one payload reached
// here holding a Bare timer (Scheduler → _timeouts → Heap → _data → Array[0] → Timeout →
// _scheduler), the TypeError went up into whoever called send on the boot path, and the shell
// latched it — an error screen for ever over a node that had started cleanly and kept logging for
// minutes behind it.
//
// ⚠ A logger must not be able to kill what it is logging, whatever the payload was. `safeStringify`
// also NAMES what it could not encode, so the next occurrence says which field carried the timer
// rather than leaving it to be guessed at — which is how this one was found.
const { line, degraded, dropped } = safeStringify(obj)
console.log(line)
if (degraded) {
const warn = JSON.stringify({ t: 'log', msg: 'send: payload was not fully serializable', dropped })
console.log(warn)
if (IPC) { try { IPC.write(Buffer.from(warn + '\n')) } catch { /* UI gone */ } }
}
if (!IPC) return
try { IPC.write(Buffer.from(line + '\n')) } catch { /* UI gone; the node keeps running */ }
}
const stage = (name, extra = {}) => send({ t: 'log', stage: name, ...extra })
// Held so the shell's `check` verb reaches the running OTA client rather than starting a second one.
let otaHandle = null
let refreshUpdateState = () => null
let lastOtaNote = null
// ── the composition, which now lives in favela-host ─────────────────────────────────────────────
//
// ⛔ MOVED, NOT COPIED. `inProcessClient`, `serveSurface`, `listenPreferring` and `signIn` were
// written here because this was the only place that held both a favela-peer node and favela-surface.
// A desktop had no such place, so the phone could mint a DID and a desktop could not — the parity
// gap. They are platform-neutral and now live in `favela-host`, which both this worklet and the
// desktop entry import. `serveSurface`'s own comment always said so: "On a desktop nothing supplies
// it and the section correctly does not render."
//
// ⚠ Re-exported because the off-device tests import them from this path, and moving an
// implementation is not a reason to rewrite the tests that pin it.
// ⚠ Imported AND re-exported. `export ... from` alone re-exports without creating local bindings, so
// `boot()` below — which calls serveSurface and signIn — failed with "serveSurface is not defined".
import { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE, bootAtproto } from 'favela-host'
export { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE }
// ── boot ──────────────────────────────────────────────────────────────────────────────────────
const state = { ready: false, feed: null, url: null, error: null, browserKey: null }
let surface = null
let node = null
export async function boot (storage) {
fs.mkdirSync(storage, { recursive: true })
stage('storage', { storage })
const swarm = new Hyperswarm()
node = new FavelaNode(storage, { transport: null, swarm })
// ⛔ ready() FIRST: it builds the overlay, and the transport needs it — the bytes ride the same
// connections as the log, so a transport without the overlay replicates half the system. Same
// order favela-peer's own index.js uses, for the same reason.
await node.ready()
stage('node ready', { feed: node.key.toString('hex') })
node.transport = new SwarmTransport(swarm, { overlay: node.overlay })
// ⛔ ONE call, and NOT a hand-rolled list. This used to join `node.peer.feed` and `node._feeds`
// itself, which is precisely the code favela-peer's index.js replaced — "Three things have needed
// re-joining by hand here and each was forgotten in turn, producing a log that replicates for
// nobody." Two were forgotten here: the NAMESPACE and the ROOMS.
//
// ⚠ It hid because this is the BOOT path only. `_openNames` announces the namespace itself when a
// transport already exists, so a namespace joined while the node runs replicates — and silently
// stops after the next restart, because nothing here joined it again.
const announced = await node.announceAll()
stage('swarm joined', { announced })
// ⛔ BEFORE the listener. See `signIn`: claiming over a loopback port on Android could be raced
// by any other app on the device that found it, and claiming through `dispatch` cannot.
const who = await signIn(node, storage)
state.browserKey = who.publicKey
stage(who.claimed ? 'claimed this node' : 'signed in')
// Reads the file the OTA block below writes. A function rather than a value: settings is loaded
// long after boot, and a snapshot taken here would show the state at start-up for ever.
const appUpdate = () => {
try { return JSON.parse(fs.readFileSync(join(storage, 'app-update.json'), 'utf8')) } catch { return null }
}
// Reuse the port this app bound last time so the WebView's URL survives a restart. See serveSurface.
let wantedPort = 0
try { wantedPort = Number(String(fs.readFileSync(join(storage, PORT_FILE), 'utf8')).trim()) || 0 } catch {}
surface = await serveSurface(node, { port: wantedPort, appUpdate })
try { fs.writeFileSync(join(storage, PORT_FILE), String(surface.port)) } catch { /* best effort */ }
state.ready = true
state.feed = node.key.toString('hex')
state.url = surface.url
// ⛔ PROVE it serves rather than assuming the import worked — favela-android found that an import
// succeeding says nothing about a listener answering. A ready the UI cannot use is worse than a
// failure it can read.
send({ t: 'ready', feed: state.feed, url: state.url })
// ⛔ THE DEVICE BECOMES A WRITER OF ITS DID HERE. Until this call the worklet reached exactly two hpp
// entry points — mint and submit — so the phone could create an identity, publish it to the global
// directory, and author nothing under it. The swarm replicates a DID's repo to every device that
// boots it; a device that never boots it holds nothing. `lib/host/repair-store-backend.js` was
// copied into this app assuming this existed, and had no callers.
//
// ⛔ AFTER `ready`, and non-fatal. Updating and signing are background concerns: a phone that cannot
// boot its atproto half must still show its own records. favela-android's rule — "a ready the UI
// cannot use is worse than a failure it can read".
// ⛔ NOT AWAITED, and this is the whole point. `bootAtproto` opens a corestore, a Hyperswarm and
// joins a topic per identity — on a device that actually holds a DID it can take a long time or
// never settle at all. Awaiting it here stopped everything BELOW from running, including the OTA
// block that writes `app-update.json`. So the settings page kept rendering whatever the previous
// boot had written: a stale version, and `staged !== running` for ever — a banner offering an
// update that had in fact already been applied, with no way to clear it from the device.
//
// ⚠ The same class as the rule two lines up. Signing is a BACKGROUND concern, exactly like
// updating: a phone that cannot boot its atproto half must still show its own records and must
// still be able to say which build it is running.
bootAtproto(node, { log: (m, d) => stage(m, d) })
.then((h) => { state.atproto = h })
.catch((e) => stage('atproto boot failed', { error: (e && e.message) || String(e) }))
// ⛔ START OTA, and note this is what makes `lib/host/ota.js` exist at all. It was copied here with
// the rest of the host layer and nothing imported it — so bare-pack, which walks the MODULE graph
// from this entry, never reached it and `pgh-runtime` was absent from the bundle. The file's own
// header records the same defect happening once before: "WHAT WAS HERE BEFORE HAD NEVER RUN."
//
// ⛔ AFTER `ready`, never before. Updating is a background concern and a phone that cannot reach the
// swarm must still show its own records — favela-android learned this as "a ready the UI cannot use
// is worse than a failure it can read". Hence try/catch: OTA is non-fatal, always.
//
// ⚠ `currentTreeHash` is NOT passed, because this app has no shell handoff yet — the RN side does not
// yet tell the worklet which bundle it booted. The consequence is stated rather than discovered:
// pgh-runtime's "already running this tree" test cannot fire, so a boot will log `staged — active on
// next start` naming a bundle it is already running. No bytes move (installTree short-circuits on
// hasTree), but the log reads like an update that never lands, and favela-android lost an evening to
// exactly that. Wiring `shell-handoff.js` is the next piece and closes it.
try {
const { parseShell, shellState, SHELL_FILE, STATE_FILE } = await import('./host/shell-handoff.js')
// ⛔ THE SHELL ALREADY TELLS US THIS AND WE WERE NOT LISTENING. `worklet.js` writes shell.json with
// the bundle hash it actually booted; reading it is what lets pgh-runtime's "already running this
// tree" test fire. Without it every check re-stages the bundle already running and logs
// `staged — active on next start`, which reads as an update that never lands — favela-android lost
// an evening to exactly that misreading.
let shell = { ota: false, pair: false, bundle: null }
try { shell = parseShell(fs.readFileSync(join(storage, SHELL_FILE), 'utf8')) } catch {}
const { startOta } = await import('./host/ota.js')
// ⛔ THE NODE'S LAST WORD IS KEPT, not just logged. `say` lines are the only place that
// distinguishes "no swarm fetcher", "downloading 0.0.6" and "previously failed to boot" — and a
// handset has no log anyone can read, so they went nowhere. Without this, a device could report
// only that nothing happened, which is what several hours of guessing were spent on.
const ota = await startOta({
storage,
// What this APK already ships. Only the shell can answer it — jniLibs are mapped out of the APK,
// never unpacked — so the store skips downloading addons that are already on the device. An older
// shell sends nothing and the store then skips nothing, which costs bandwidth, never correctness.
apkAddons: shell.apkAddons,
say: (m) => {
lastOtaNote = String(m)
send({ t: 'log', msg: m })
// ⛔ RE-WRITE THE STATE WHEN SOMETHING IS STAGED. `writeState()` ran once at boot, and with
// the watch the download lands SECONDS LATER — so app-update.json still said `staged: null`
// and settings rendered "Up to date" with no restart button, over a bundle sitting on disk.
// Seen directly in logcat: staged on the device, null in the state file.
//
// ⚠ Keyed on the message because `say` is the only signal that crosses out of startOta. A
// narrow match, so an unrelated line cannot cause a spurious rewrite.
if (/^ota: staged /.test(String(m))) { try { refreshUpdateState() } catch {} }
},
currentTreeHash: shell.bundle
})
// ⛔ ON DISK, NOT AN IPC REPLY. The settings page is HTML served by favela-surface, not React
// Native, so it must be able to render this SERVER-side. A reply channel would mean two sources of
// truth for the same three fields — favela-android's note, and the reason this is a file.
const writeState = () => {
let staged = null
let stagedBuiltAt = null
try {
const cur = JSON.parse(fs.readFileSync(join(storage, 'bundles', 'current.json'), 'utf8')) || {}
staged = cur.treeHash
// ⛔ THE STAGED BUNDLE'S BUILD TIME, because the page cannot decide without it. chooseBundle
// prefers the ASSET when the app is newer, so a staged-but-older bundle will never be
// applied — and a banner offering to restart for it is a button that does nothing, for ever.
stagedBuiltAt = cur.builtAt || null
} catch {}
// ⛔ THE VERSION IS THE HALF A PERSON CAN ACT ON. Two tree hashes tell you something changed;
// they do not tell you WHICH build you are running, so "is my update installed?" stayed
// unanswerable from the device. `version` is what a release page shows.
const st = {
...shellState({ shell, staged, checkedAt: new Date().toISOString() }),
available: !!ota,
version: VERSION,
builtAt: BUILT_AT,
// ⛔ AND WHAT WENT INTO IT. Every sibling arrives as `file:../x`, so this bundle carries
// whatever was in that tree at pack time while the DESKTOP runs whatever was last published.
// The two drift silently: on 2026-09-15 they were identical only because of the order things
// were published that afternoon, and nothing on either side could have said so. A version
// number answers "which build"; this answers "the same code as my desktop?", which is the
// question actually being asked when someone compares the two.
deps: DEPS,
stagedBuiltAt,
note: lastOtaNote
}
try { fs.writeFileSync(join(storage, STATE_FILE), JSON.stringify(st)) } catch { /* status only */ }
// ⚠ RETURNED as well as written. `storage` is a parameter of boot(), not module scope, so the
// IPC handler cannot rebuild this path — and reaching for it there is a ReferenceError that
// would only appear when someone pressed the button.
return st
}
// ⛔ AN ENV VAR, because favela-surface renders this SERVER-side and must not learn what a worklet
// is. Same contract favela-android uses with favela-web: the shell writes a file, the page reads
// it, and neither knows anything about the other's process model.
process.env.FAVELA_SHELL_STATE = join(storage, STATE_FILE)
writeState()
otaHandle = ota
refreshUpdateState = writeState
} catch (e) {
send({ t: 'log', msg: 'ota unavailable (non-fatal): ' + ((e && e.message) || String(e)) })
}
return state
}
// ── the IPC surface ───────────────────────────────────────────────────────────────────────────
//
// ⛔ Deliberately tiny. favela-android grew a bespoke verb per feature and ended up with a crippled
// cousin of favela-web built from the same parts. Everything a person sees is served as HTML over
// loopback; IPC carries only what HTML cannot: where the surface is, and whether it is up.
async function onRequest (msg) {
const { id, op, params } = msg || {}
try {
let result
switch (op) {
case 'status':
result = { ...state }
break
// ⛔ The WebView cannot be handed a cookie from outside — the session cookie is HttpOnly, so
// `document.cookie` cannot write it and no native API portably can. The cookie must arrive on
// an HTTP response, which is what `/auth/handoff` is for. This mints a session and a ONE-TIME
// token for it, and returns the URL that turns the token into a cookie.
//
// ⚠ The token is spent on first load. A WebView that reloads needs a NEW one, which is why
// this is an op the shell can call again rather than a value handed over once at boot.
case 'session': {
if (!surface) throw new Error('the surface is not serving yet')
if (!state.browserKey) throw new Error('this app has not signed in to its own node')
const id = surface.sessions.create(state.browserKey)
const token = surface.sessions.createHandoff(id)
const next = typeof params === 'object' && params && typeof params.next === 'string' ? params.next : '/'
result = { url: state.url + '/auth/handoff?token=' + token + '&next=' + encodeURIComponent(next) }
break
}
// ⛔ THE SHELL'S `check` VERB LANDS HERE. It drives the RUNNING OTA client — starting a second
// one would open its own swarm and fetcher, and the two would race to stage into one directory.
// Returns the refreshed state so the page can re-render without a round trip through the file.
case 'check-update': {
if (otaHandle && otaHandle.checkNow) await otaHandle.checkNow()
result = refreshUpdateState()
break
}
case 'url':
// The WebView needs a URL and nothing else. A port of 0 at boot means the OS chose one, so
// this is the only place that knows it.
if (!state.url) throw new Error('the surface is not serving yet')
result = { url: state.url }
break
default:
throw new Error('unknown op: ' + op)
}
send({ id, result })
} catch (e) {
send({ id, error: (e && e.message) || String(e) })
}
}
// ⛔ `IPC.on('data')`, NOT `IPC.read(cb)`. BareKit.IPC is a streamx Duplex whose `read()` takes no
// callback; a pump that passed one fired once, read nothing and never re-armed, so a worklet
// consumed zero inbound bytes for its whole life while still logging happily. It looked alive and
// answered nothing. favela-android paid for this line.
//
// The decoder is stateful because chunk boundaries are not character boundaries.
if (IPC) {
let buf = ''
const decode = createUtf8Decoder()
IPC.on('data', (data) => {
buf += decode(data)
let i
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1)
if (!line.trim()) continue
try { onRequest(JSON.parse(line)) } catch { send({ error: 'bad request json' }) }
}
})
}
// Android hands the app-private directory in as the last argv — there is no $HOME and no env here.
// ⚠ Only when actually running as a worklet: imported by a test, this file must not boot anything.
if (IPC && typeof Bare !== 'undefined') {
const storage = Bare.argv[Bare.argv.length - 1] || join('/data/local/tmp', 'favela')
boot(storage).catch((e) => {
state.error = (e && e.message) || String(e)
send({ t: 'fatal', error: (e && e.stack) || String(e) })
})
}
export default { boot, serveSurface, inProcessClient }