OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / index.js

89 lines · javascript ref:
// ap-bridge — the ActivityPub membrane. See DESIGN.md.
//
// The standalone app around lib/bridge.js. The bridge itself is a module since 0.10.15 so the favela
// gateway (hyper-social #80) can run it beside the atproto membrane in one process. This file owns
// what only a standalone process may own: the admin CLI subcommand, the control socket and its
// sidecar name, the OTA exit, and the signal handlers.

import './lib/bare-globals.js' // FIRST — deps touch TextEncoder at module load
import fs from 'bare-fs'
import process from 'bare-process'
import { join } from 'bare-path'
import { loadConfig, assertUsable } from './lib/config.js'
import { startApBridge } from './lib/bridge.js'

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

// Best-effort, for sidecar registration metadata only. Never fatal: a missing package.json must not
// stop the bridge serving.
function pkgVersion () {
  try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null }
}

async function main () {
  // ⛔ THE CLI IS A SUBCOMMAND OF THIS ENTRY, checked before anything is opened. pgh installs one
  // launcher per app (`pgh.bin` is a single name) and it execs this file with "$@", so `ap-bridge
  // admin invite` arrives here. A second bin/ would have been a file nobody could install.
  //
  // ⚠ Before the daemon boots, and it RETURNS rather than falling through — running the CLI must not
  // also bind the fediverse listener of a bridge that is already running.
  if (process.argv[2] === 'admin') {
    const { runAdminCli } = await import('./lib/admin-cli.js')
    // ⛔ THE SAME DERIVATION THE DAEMON USES, via loadConfig (which expands ~ and ${HOME}) and the
    // identical fallback in lib/bridge.js. If the CLI and the daemon disagreed about dataDir by one
    // character the CLI would report "no control socket" on a bridge that is running and serving it.
    //
    // ⚠ loadConfig, NOT assertUsable — the CLI must work on a bridge whose keys or host are
    // misconfigured. Refusing to hand out a recovery invite because the thing needing recovery is
    // broken would be exactly backwards.
    const cfg = loadConfig()
    const dir = cfg.dataDir || join(process.env.HOME || '.', '.config', 'ap-bridge')
    process.exit(await runAdminCli(process.argv.slice(3), { uds: join(dir, 'admin.sock') }))
  }

  const config = assertUsable(loadConfig())
  const version = pkgVersion()
  const bridge = await startApBridge(config, { log, version })

  // Every stop step, in order, then exit with the caller's code. A step that never settles must not
  // strand the process — the supervisor would otherwise wait out its full grace period on every OTA —
  // so the same 5 s cap the old shutdown had still exits regardless.
  let stopping = null
  const shutdown = (code = 0) => {
    if (stopping) return stopping
    log({ msg: 'shutting down', code })
    const t = setTimeout(() => process.exit(code), 5000)
    stopping = (async () => {
      for (const [name, fn] of bridge.steps) {
        try { await fn() } catch (err) { log({ msg: 'shutdown step failed', step: name, error: err && err.message }) }
      }
      clearTimeout(t)
      process.exit(code)
    })()
    return stopping
  }
  process.on('SIGINT', () => shutdown(0))
  process.on('SIGTERM', () => shutdown(0))

  // OTA + control surface. Best-effort: if it cannot come up we keep federating (see lib/control.js).
  const { startControl } = await import('./lib/control.js')
  await startControl({
    onInvite: bridge.onInvite,
    udsPath: process.env.AP_SOCK || join(config.dataDir, 'svc.sock'),
    name: process.env.PGH_APP_NAME || 'ap-bridge',
    version,
    status: bridge.status,
    // Exit 75 AFTER the RPC has answered — the supervisor's respawn signal. Close the server first
    // so the port is free for the replacement rather than racing it.
    onRelease: () => shutdown(75),
    onNotifications: bridge.onNotifications,
    onReproject: bridge.onReproject,
    log
  })
}

main().catch((err) => {
  log({ msg: 'FATAL', error: err.message, stack: err.stack })
  process.exit(1)
})