// Runnable atproto MEMBRANE — the standalone app around lib/membrane.js. Two surfaces:
// 1. the bsky-facing HTTP+WS membrane (lib/membrane.js) on cfg.port
// 2. a UDS control plane (control.js) at cfg.uds — the pgh readiness/management surface,
// which also registers with pgh-sidecar so `pgh app status atproto-bridge` resolves us.
//
// The membrane itself is a module since 0.9.52 so the favela gateway (hyper-social #80) can run it
// beside ap-bridge in one process. This file owns what only a standalone process may own: the
// control socket and its sidecar name, the OTA exit, and the signal handlers.
import '../lib/bare-globals.js' // FIRST — polyfills TextEncoder/TextDecoder under Bare
import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import { fileURLToPath } from '#node:url'
import { loadConfig } from '../lib/config.js'
import { startMembrane } from '../lib/membrane.js'
import { startControl } from '../lib/control.js'
import { createShutdown } from '../lib/shutdown.js'
const cfg = loadConfig()
// App version (for sidecar registration) — best-effort from the deploy-root package.json.
let version = null
try {
const here = path.dirname(fileURLToPath(import.meta.url))
version = JSON.parse(fs.readFileSync(path.join(here, '..', '..', 'package.json'), 'utf8')).version
} catch {}
const membrane = await startMembrane(cfg)
// UDS control plane + pgh-sidecar registration.
const control = await startControl({ uds: cfg.uds, name: process.env.PGH_APP_NAME || 'atproto-bridge', version, peers: membrane.peers })
// OTA — the app half of the push contract. The sidecar does the FULL install (tree fetch + dep closure +
// materialize + symlink swap) and THEN calls this; our only job is to exit cleanly so the supervisor
// respawns against the new tree. Same shape as pear-git-v3/server.js.
//
// ⚠ DELIBERATELY NOT pgh-runtime's fetch path. pgh-runtime only fetches ROOT trees, not dep closures, so
// having the app pull its own update produces a BROKEN install — the exact failure that took the
// membrane down on 2026-07-31 (a missing dep, MODULE_NOT_FOUND, respawn loop). The sidecar already did
// the work correctly; duplicating it is how you get half of it.
//
// Exit 75 AFTER responding, so the caller sees ok rather than a dropped connection, and after a clean
// shutdown so no store is left locked for the process that replaces us.
if (control && control.svc && !control.svc.hasHandler('release-ready')) {
control.svc.handle('release-ready', async (ctx, params = {}) => {
const ver = (params && params.version) || '?'
console.log(JSON.stringify({ msg: 'ota: release-ready — sidecar installed; exiting 75 for supervisor respawn', version: ver }))
setImmediate(() => { shutdown(75) })
return { ok: true, willRespawn: true }
})
console.log(JSON.stringify({ msg: 'ota: release-ready handler armed (push-based; sidecar installs, we respawn)' }))
}
console.log(JSON.stringify({ msg: 'control listening', uds: cfg.uds }))
// Bounded and timed (lib/shutdown.js): the caller's exit code wins, every step logs its duration, and
// a deadline exits anyway, naming the step it was stuck on. 0.9.50's OTA took ~50 s and exited 0.
// Control first, then the membrane's own steps in their original order (front-store last).
const shutdown = createShutdown({
exit: (code) => process.exit(code),
steps: [
['control', () => control.close()],
...membrane.steps
]
})
process.on('SIGINT', () => shutdown(0))
process.on('SIGTERM', () => shutdown(0))