// 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)
})