OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host / bin / favela-host.js

128 lines · javascript ref:
#!/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) })