#!/usr/bin/env bare
// favela-host — THE favela app on a desktop, home node or server. `bare bin/favela-host.js`.
//
// One process holding a favela-peer node, favela-surface rendering it, the atproto signer, and the bsky
// projection. It replaces running favela-peer, favela-surface, favela-bridge and hyper-proto-peer as four
// apps (hyper-social #77, decided 2026-10-13): with them separate, a reboot brought back the bridge and
// the signer but not the node or its UI, and the surface then asked for an invite when the real problem
// was that nothing was behind it.
//
// ⛔ THE SAME STORES THE FOUR APPS USE, never a copy. FAVELA_STORAGE is favela-peer's
// (~/.config/favela-peer, so the socket and the `favela` CLI keep working), and PEER_CONFIG / PEER_STORAGE
// are hyper-proto-peer's own config and data. A second store for the same devices would be a second
// writer for the same identity — a fork. Without PEER_CONFIG (a fresh desktop, a handset) the atproto
// half lives under the node's storage, as before.
//
// ⛔ AND NEVER BESIDE THEM. It refuses to start while a favela-peer holds the socket or a
// hyper-proto-peer holds hpp's control socket: two processes on one store is the same fork.
//
// ⛔ THE SOCKET IS SERVED FROM THE SAME COMPOSITION, not from `dispatch` — `favela-bridge` and the
// `favela` CLI reach this over the socket and see exactly the verb set the pages see.
import fs from 'bare-fs'
import os from 'bare-os'
import net from 'bare-net'
import process from 'bare-process'
import { join } from 'bare-path'
import Hyperswarm from 'hyperswarm'
import { createServer } from 'favela-peer/lib/rpc.js'
import { startControl } from 'favela-peer/lib/control.js'
import { boot, inProcessClient } from '../lib/host.js'
const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela')
const sock = process.env.FAVELA_SOCK || join(storage, 'favela.sock')
// ⛔ ITS OWN control socket, not favela-peer's `svc.sock` in the same directory: the sidecar remembers
// favela-peer's registration, and a favela-peer release pushed to a path this app now served would
// restart the wrong process.
const svcSock = process.env.FAVELA_HOST_SVC_SOCK || join(storage, 'favela-host.svc.sock')
const port = process.env.FAVELA_SURFACE_PORT ? Number(process.env.FAVELA_SURFACE_PORT) : null
const host = process.env.FAVELA_SURFACE_HOST || '127.0.0.1'
// hpp's own config and data, when this machine has them. Empty strings count as unset.
const peerConfig = process.env.PEER_CONFIG || null
const peerStorage = process.env.PEER_STORAGE || null
function pkgVersion () {
try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url).pathname, 'utf8')).version } catch { return null }
}
// ⛔ Refuse to start if a daemon already holds a socket. Removing a live socket would leave the running
// daemon unreachable while this one looked healthy — one process, two owners, no error.
function socketIsLive (path) {
return new Promise((resolve) => {
if (!fs.existsSync(path)) return resolve(false)
const probe = net.connect(path)
const done = (v) => { try { probe.destroy() } catch {}; resolve(v) }
probe.on('connect', () => done(true))
probe.on('error', () => done(false))
setTimeout(() => done(false), 500)
})
}
const say = (msg, data = {}) => console.log(JSON.stringify({ msg, ...data }))
async function main () {
fs.mkdirSync(storage, { recursive: true })
if (await socketIsLive(sock)) {
console.error('favela-host: a daemon is already listening on ' + sock + ' — stop favela-peer first (pgh app stop favela-peer)')
process.exit(1)
}
if (peerStorage && await socketIsLive(join(peerStorage, 'control.sock'))) {
console.error('favela-host: hyper-proto-peer is running on ' + peerStorage + ' — stop it first (pgh app stop hyper-proto-peer); ' +
'two processes on one store would be two writers for the same identity')
process.exit(1)
}
// ⛔ CONSTRUCTED HERE, not inside the host: lib/host.js explains why (the Android bundle would carry
// two copies of hyperswarm otherwise). The desktop owns its swarm; the worklet owns its own.
const swarm = new Hyperswarm()
const { node, surface, atproto } = await boot(storage, {
swarm,
onStage: say,
port,
host,
atproto: peerConfig ? { configFile: peerConfig, storage: peerStorage } : {}
})
// The SAME client the pages use — see the header.
const client = inProcessClient(node)
createServer(node, sock, { call: (cmd, args) => client.call(cmd, args), onError: () => {} })
say('favela-host up', { feed: node.key.toString('hex'), storage, sock, url: surface.url, atproto: peerConfig || 'node storage', version: pkgVersion() })
// favela-bridge's --watch now starts inside bootAtproto (lib/atproto.js), the boot this shares with the
// Android worklet, which never had it. One watch per node: starting a second here would race the first.
// Idempotent and BOUNDED: a close that never settles must not keep an OTA from respawning (the
// supervisor respawns only once we exit). The first caller's exit code wins: 75 for release-ready,
// which the supervisor respawns immediately, 0 for a signal.
let control = null
let stopping = false
const shutdown = async (code = 0) => {
if (stopping) return
stopping = true
const deadline = setTimeout(() => { say('shutdown: deadline reached, exiting anyway', { code }); process.exit(code) }, 15000)
try { if (node.projection) node.projection.stop() } catch {}
try { if (control) await control.close() } catch {}
try { surface.server.close() } catch {}
try { const hpp = await Promise.race([atproto, new Promise((resolve) => setTimeout(() => resolve(null), 2000))]); if (hpp) await hpp.close() } catch {}
try { await node.close() } catch {}
try { await swarm.destroy() } catch {}
try { fs.unlinkSync(sock) } catch {}
clearTimeout(deadline)
process.exit(code)
}
process.on('SIGINT', () => shutdown(0))
process.on('SIGTERM', () => shutdown(0))
// OTA: registers this app's control socket with pgh-sidecar so a published release reaches THIS
// process. Fails soft — the app keeps serving if the sidecar is absent.
control = await startControl({
udsPath: svcSock,
name: process.env.PGH_APP_NAME || 'favela-host',
version: pkgVersion(),
status: () => ({ ok: true, name: 'favela-host', version: pkgVersion(), feed: node.key.toString('hex'), url: surface.url }),
onRelease: () => shutdown(75)
})
}
main().catch((err) => { console.error(err); process.exit(1) })