/** * HTTP receiver daemon for the build bus * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 3). * * Verifies signed webhook envelopes against a per-receiver shared * secret, then re-emits the contained PublishEvent onto the local * SQLite event bus. From there, the local-bus dispatcher (or this * process's own hook dispatcher — see hook-dispatcher.ts) can react. * * Local-bus event shape: * * type: 'build-bus.publish' (constant; filters happen on payload) * payload: the verified PublishEvent * dedupKey: event.eventId (the bus silently dedupes * duplicate inserts — no extra * table needed) * * Endpoint: POST / accepts the WebhookEnvelope JSON. Any other path * returns 404. Any other method returns 405. GET /health returns * 200 for liveness probes. */ import { type Bus, defineEvents, openBus } from '@celilo/event-bus'; import type { WebhookEnvelope } from '@celilo/event-bus/build-bus'; import { verifyEnvelope } from '@celilo/event-bus/build-bus'; import { getEventBusPath } from '../../config/paths'; const NO_SCHEMAS = defineEvents({}); export interface ReceiverServerOptions { /** Port to listen on. Default: 8123. */ port?: number; /** Shared HMAC secret. Publishers signing webhooks at this endpoint must use the same string. */ secret: string; /** * SQLite event-bus path override (mostly for tests). Production * uses `getEventBusPath()` so the receiver emits into the same * bus the rest of celilo reads. */ busPath?: string; /** * Optional hook fired after every successful emit. Used by the * combined daemon to trigger the hook dispatcher in-process * without an extra polling layer. */ onEvent?: (envelope: WebhookEnvelope) => void | Promise; /** Override for tests; default is the global fetch. */ now?: () => number; } export interface ReceiverServer { port: number; /** URL clients should POST to. */ url: string; stop(): Promise; } /** * Start the HTTP receiver. Resolves once the listener is bound. * Caller is responsible for keeping the server alive — typically * the CLI command's `await new Promise(() => {})` pattern. */ export function startReceiverServer(opts: ReceiverServerOptions): ReceiverServer { const port = opts.port ?? 8123; const busPath = opts.busPath ?? getEventBusPath(); // Empty registry — we emit to a known type ('build-bus.publish') // without going through any registered schema. Use emitRaw. const bus: Bus = openBus({ dbPath: busPath, events: NO_SCHEMAS }); const server = Bun.serve({ port, fetch: async (req) => { const url = new URL(req.url); if (req.method === 'GET' && url.pathname === '/health') { return new Response('ok', { status: 200 }); } if (url.pathname !== '/') { return new Response('not found', { status: 404 }); } if (req.method !== 'POST') { return new Response('method not allowed', { status: 405 }); } let envelope: WebhookEnvelope; try { envelope = (await req.json()) as WebhookEnvelope; } catch (err) { return new Response( JSON.stringify({ ok: false, reason: 'malformed', detail: err instanceof Error ? err.message : String(err), }), { status: 400, headers: { 'content-type': 'application/json' } }, ); } const outcome = verifyEnvelope(envelope, { secret: opts.secret, now: opts.now?.(), }); if (!outcome.ok) { return new Response(JSON.stringify(outcome), { status: outcome.reason === 'malformed' ? 400 : 401, headers: { 'content-type': 'application/json' }, }); } try { bus.emitRaw('build-bus.publish', envelope.event, { dedupKey: envelope.event.eventId, emittedBy: 'build-bus.receiver', }); } catch (err) { // emit failures (DB locked, schema mismatch) get a 500 so the // publisher retries. We don't want to drop on the floor. return new Response( JSON.stringify({ ok: false, reason: 'emit-failed', detail: err instanceof Error ? err.message : String(err), }), { status: 500, headers: { 'content-type': 'application/json' } }, ); } if (opts.onEvent) { // Best-effort: hook dispatch failures shouldn't 5xx the // webhook (the publisher already won). Surface via the // process's own logging. try { await opts.onEvent(envelope); } catch (err) { console.warn( `[build-bus] onEvent handler failed: ${err instanceof Error ? err.message : String(err)}`, ); } } return new Response(JSON.stringify({ ok: true, eventId: outcome.eventId }), { status: 200, headers: { 'content-type': 'application/json' }, }); }, }); const actualPort = server.port ?? port; return { port: actualPort, url: `http://localhost:${actualPort}/`, async stop() { server.stop(true); bus.close(); }, }; }