OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / lib / node.js

360 lines · javascript ref:
// 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 }