OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway / bin / favela-gateway.js

130 lines · javascript ref:
// favela-gateway — the entry pgh runs (`bare bin/favela-gateway.js`). See lib/gateway.js and
// hyper-social #80.
//
// This file owns the PROCESS: the admin CLI passthrough, the one control socket and its sidecar name,
// the OTA exit, the signal handlers and the bounded shutdown. The roles own nothing of that.
//
// ⛔ ITS OWN CONTROL SOCKET AND ITS OWN SIDECAR NAME. Not atproto-bridge's svc.sock, not ap-bridge's,
// and neither old name is registered: the sidecar pushes release-ready to the path an app NAME is
// registered under, so a later atproto-bridge or ap-bridge release would otherwise restart this
// process. (favela-host learned this against favela-peer's svc.sock.)
//
// ⛔ THE SAME CONFIG LOADERS THE STANDALONE APPS USE, on the same env names. Pointing the gateway at a
// host is setting the env the two apps had; nothing is reinterpreted on the way.

import process from 'bare-process'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
import { Service } from 'hyper-svc'
import { loadConfig as loadMembraneConfig } from 'atproto-bridge/pds/lib/config.js'
import { createShutdown } from 'atproto-bridge/pds/lib/shutdown.js'
import { maintainSidecarRegistration } from 'atproto-bridge/pds/lib/sidecar.js'
import { loadConfig as loadApConfig, assertUsable } from 'ap-bridge/lib/config.js'
import net from 'bare-net'
import { startGateway } from '../lib/gateway.js'
import { runCli } from '../lib/cli.js'

const log = (o) => console.log(JSON.stringify({ t: new Date().toISOString(), ...o }))

function pkgVersion () {
  try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url).pathname, 'utf8')).version } catch { return null }
}

// ⛔ `favela-gateway admin …` is ap-bridge's operator CLI on the same admin.sock, checked BEFORE
// anything opens — running it must not also bind the listeners of a gateway that is already running.
if (process.argv[2] === 'admin') {
  const { runAdminCli } = await import('ap-bridge/lib/admin-cli.js')
  const cfg = loadApConfig()
  const dir = cfg.dataDir || path.join(process.env.HOME || '.', '.config', 'ap-bridge')
  process.exit(await runAdminCli(process.argv.slice(3), { uds: path.join(dir, 'admin.sock') }))
}

// ⛔ ANY OTHER ARGUMENT MEANS CLIENT, and a client never boots. pgh's supervisor starts the daemon with no
// arguments; everything an operator types (`favela-gateway metrics`, `… probe-front did=…`, `… help`) goes
// to the RUNNING gateway's control socket instead. An audit agent once ran `favela-peer --help` and booted
// a second daemon on a live store — that cannot happen here.
const sockPath = process.env.GATEWAY_SOCK || path.join(os.homedir(), '.config', 'favela-gateway', 'svc.sock')
if (process.argv.length > 2) {
  process.exit(await runCli(process.argv.slice(2), { sock: sockPath }))
}

// ⛔ AND THE DAEMON REFUSES TO START BESIDE A RUNNING ONE. A bare `favela-gateway` typed by hand would
// otherwise open the same stores and swarm keypairs as the live gateway — two writers on one identity.
function socketIsLive (p) {
  return new Promise((resolve) => {
    if (!fs.existsSync(p)) return resolve(false)
    const probe = net.connect(p)
    const done = (v) => { try { probe.destroy() } catch {}; resolve(v) }
    probe.on('connect', () => done(true))
    probe.on('error', () => done(false))
    setTimeout(() => done(false), 500)
  })
}
if (await socketIsLive(sockPath)) {
  console.error(`favela-gateway: a gateway is already running on ${sockPath} — not starting a second one. Try: favela-gateway metrics`)
  process.exit(1)
}

const roles = String(process.env.GATEWAY_ROLES || 'membrane,fediverse').split(',').map((s) => s.trim()).filter(Boolean)
const sock = sockPath
const name = process.env.PGH_APP_NAME || 'favela-gateway'
const version = pkgVersion()

// A role's config that cannot load (a missing key path, an unusable host) is that role's failure, not
// the gateway's: it is handed through as an Error, which startGateway reports as the role being down.
const configs = {}
if (roles.includes('membrane')) {
  try { configs.membrane = loadMembraneConfig() } catch (err) { configs.membrane = new Error('membrane config: ' + err.message) }
}
if (roles.includes('fediverse')) {
  try { configs.fediverse = assertUsable(loadApConfig()) } catch (err) { configs.fediverse = new Error('fediverse config: ' + err.message) }
}

// The lens's feeds: `id=roomKey` pairs, the shape favela-web's FAVELA_COMMUNITY_ROOMS used.
if (roles.includes('lens')) {
  const feeds = String(process.env.LENS_FEEDS || '').split(',').map((s) => s.trim()).filter(Boolean).map((pair) => {
    const [id, roomKey] = pair.split('=')
    return { id: (id || '').trim(), roomKey: (roomKey || '').trim() }
  }).filter((f) => f.id && /^[0-9a-f]{64}$/i.test(f.roomKey))
  // LENS_LABELER_SOCK: the labeler DID's hpp control socket on this host. Set → the roster is projected
  // into `<feed>-member` labels there (hpp refuses unless that DID has `labeler: true`). Unset → none.
  const labelerSock = String(process.env.LENS_LABELER_SOCK || '').trim() || null
  configs.lens = feeds.length ? { feeds, labelerSock } : new Error('lens config: LENS_FEEDS has no id=roomKey pair')
}

// Alert state lives beside the control socket unless told otherwise.
const stateDir = process.env.GATEWAY_STATE_DIR || path.dirname(sock)
const gateway = await startGateway({ roles, log, version, watch: true, stateDir, ...configs })

// The one control socket. A stale socket file from a killed process blocks bind; we only get here once
// the supervisor decided no live instance owns this app, so an existing file is orphaned.
fs.mkdirSync(path.dirname(sock), { recursive: true })
try { if (fs.existsSync(sock)) fs.unlinkSync(sock) } catch {}
const svc = new Service({ name, uds: sock })
for (const [verb, fn] of Object.entries(gateway.handlers)) svc.handle(verb, async (ctx, params) => fn(params))

// OTA — the app half of the push contract: the sidecar did the full install; respond ok FIRST, then a
// clean bounded shutdown, then exit 75 so the supervisor respawns on the new tree.
svc.handle('release-ready', async (ctx, params = {}) => {
  log({ msg: 'ota: release-ready — sidecar installed; exiting 75 for supervisor respawn', version: (params && params.version) || '?' })
  setImmediate(() => { shutdown(75) })
  return { ok: true, willRespawn: true }
})
await svc.listen()
const stopRegistration = maintainSidecarRegistration({ udsPath: sock, name, runtime: 'bare', version })
log({ msg: 'gateway up', roles: Object.keys(gateway.roles), down: gateway.down, control: sock, version })

// Bounded and timed (atproto-bridge's createShutdown): the caller's exit code wins, every step logs its
// duration, and a deadline exits anyway naming the step it was stuck on.
const shutdown = createShutdown({
  exit: (code) => process.exit(code),
  log,
  steps: [
    ['control', async () => { stopRegistration(); try { await svc.close() } catch {} }],
    ...gateway.steps
  ]
})
process.on('SIGINT', () => shutdown(0))
process.on('SIGTERM', () => shutdown(0))