// ap-bridge as a module: started and stopped inside one process, with no exit.
//
// ⛔ WHY. The favela gateway (hyper-social #80) runs ap-bridge beside the atproto membrane in ONE
// process. index.js's main() bound two ports, an admin socket, a FeedHolder (Corestore + swarm) and an
// admin-identity Corestore, then shut down by closing only the public server and calling
// process.exit — process exit was what released everything else. A role in a shared process cannot
// rely on that: stopping it has to free every port, socket and store lock it took.
import test from 'brittle'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
import http from 'bare-http1'
import { startApBridge } from '../lib/bridge.js'
import { TEST_PRIVATE_KEY, TEST_PUBLIC_KEY } from './fixtures/keys.js'
function dataDir () {
// ⚠ SHORT: admin.sock lives in here, and a unix socket path past ~107 bytes does not bind.
const d = fs.mkdtempSync(path.join('/tmp', 'apb-'))
fs.writeFileSync(path.join(d, 'actor.key'), TEST_PRIVATE_KEY)
fs.writeFileSync(path.join(d, 'actor.pub'), TEST_PUBLIC_KEY)
return d
}
function configFor (dir, { port = 0, adminPort = 0 } = {}) {
return {
host: 'ap.test',
username: 'p2p',
bind: '127.0.0.1',
port,
adminPort,
adminBind: '127.0.0.1',
dataDir: dir,
privateKeyPath: path.join(dir, 'actor.key'),
publicKeyPath: path.join(dir, 'actor.pub'),
registration: 'open',
backfill: 0,
did: null,
favelaSocket: null
}
}
function get (port, p) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path: p, method: 'GET' }, (res) => {
let body = ''
res.on('data', (c) => { body += c })
res.on('end', () => resolve({ status: res.statusCode, body }))
})
req.on('error', reject)
req.end()
})
}
async function stop (b) { for (const [, fn] of b.steps) await fn() }
test('★ start → serve → stop → start again on the same ports and data dir, in one process', async (t) => {
const dir = dataDir()
const one = await startApBridge(configFor(dir), { bootstrap: [], log: () => {} })
t.ok(one.port > 0 && one.adminPort > 0, 'bound the public and the admin port')
t.is((await get(one.port, '/ap/actor')).status, 200, 'the actor resolves')
t.ok(fs.existsSync(path.join(dir, 'admin.sock')), 'the admin recovery socket is up')
const key = one.bridgeKey
t.ok(key, 'the registration door is open over the swarm')
await stop(one)
t.absent(fs.existsSync(path.join(dir, 'admin.sock')) && await socketAnswers(path.join(dir, 'admin.sock')), 'the admin socket no longer answers')
// Same ports, same data dir: the listeners are released and the stores' locks are let go.
const two = await startApBridge(configFor(dir, { port: one.port, adminPort: one.adminPort }), { bootstrap: [], log: () => {} })
t.teardown(() => stop(two))
t.is(two.port, one.port, 'the public port binds again')
t.is(two.adminPort, one.adminPort, 'and the admin port')
t.is((await get(two.port, '/ap/actor')).status, 200, 'and serves')
t.is(two.bridgeKey, key, '⛔ the published bridgeKey is the same — registrants find this bridge by it')
})
test('the stop steps are named, and the control verbs are plain handlers', async (t) => {
const b = await startApBridge(configFor(dataDir()), { bootstrap: [], log: () => {} })
t.teardown(() => stop(b))
const names = b.steps.map(([n]) => n)
for (const n of ['server', 'admin-server', 'admin-control', 'holder']) t.ok(names.includes(n), 'stops ' + n)
t.is(names[0], 'outbound', 'the delivery drain stops first; what is owed is already on disk')
for (const verb of ['status', 'invite', 'notifications', 'reproject']) t.is(typeof b.handlers[verb], 'function', verb)
t.is(typeof (await b.handlers.invite()), 'string', 'invite mints a token (returned once, stored as a hash)')
})
function socketAnswers (p) {
return new Promise((resolve) => {
import('bare-pipe').then(({ default: Pipe }) => {
const s = new Pipe()
s.on('error', () => resolve(false))
s.connect(p, () => { s.destroy(); resolve(true) })
}).catch(() => resolve(false))
})
}
// ── metrics: what the gateway's panel and CLI show for the fediverse role ─────────────────────────────
test('★ metrics: users, queue, inbound and registrations, read from what the bridge already keeps', async (t) => {
const b = await startApBridge(configFor(dataDir()), { bootstrap: [], log: () => {} })
t.teardown(() => stop(b))
const m = await b.metrics()
t.alike(m.users.map((u) => u.username), ['p2p'], 'the primary, by name')
t.is(m.users[0].primary, true)
t.is(m.users[0].followers, 0, 'followers counted')
t.is(m.users[0].delivered, 0, 'delivered counted from the publisher state')
t.alike(m.queue, { pending: 0, dead: 0, oldestPendingAt: null, failing: [] }, 'an empty delivery queue')
t.alike(m.inbound, { count: 0, lastReceivedAt: null })
t.is(m.registrations, 0)
})