OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge / pds / bin / pds-serve.js

69 lines · javascript ref:
// 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))