/** * Webhook fan-out for the build bus ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 2). * * Given a PublishEvent and a subscriber list, this module: * 1. Filters subscribers whose match rule fires for the event. * 2. Builds a signed WebhookEnvelope per subscriber (each gets * its own HMAC signature with the subscriber's per-target * secret). * 3. Fires concurrent HTTP POSTs with exponential-backoff retry. * 4. Returns per-subscriber DeliveryResults so the caller can * surface failures to the operator. * * Best-effort by design — fan-out failures do NOT fail the publish. * The spec calls for delivery to be at-least-once with operator- * visible failure (§9), not a publish blocker. */ import { type DeliveryResult, type PublishEvent, type Subscriber, type WebhookEnvelope, signEvent, subscribersFor, } from '@celilo/event-bus/build-bus'; export interface FanOutOptions { /** Total attempts (1 = no retry). Default: 4. */ maxAttempts?: number; /** Per-attempt request timeout. Default: 10s. */ timeoutMs?: number; /** First retry waits this long; subsequent doubles. Default: 500ms. */ baseBackoffMs?: number; /** * Override the fetch implementation. Tests inject a stub so they * don't have to actually open sockets. Production uses the global * fetch. */ fetch?: typeof fetch; /** * "Now" injection for deterministic durationMs in tests. Default: * Date.now. */ now?: () => number; } const DEFAULT_MAX_ATTEMPTS = 4; const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_BASE_BACKOFF_MS = 500; /** * Fan out a single event to every matching subscriber. Returns one * DeliveryResult per matched subscriber. Subscribers whose match * rules don't fire are silently filtered out and don't appear in * the result. */ export async function fanOut( event: PublishEvent, subscribers: Subscriber[], opts: FanOutOptions = {}, ): Promise { const matched = subscribersFor(event, subscribers); return Promise.all(matched.map((s) => deliverOne(event, s, opts))); } async function deliverOne( event: PublishEvent, subscriber: Subscriber, opts: FanOutOptions, ): Promise { const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const baseBackoffMs = opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS; const fetchFn = opts.fetch ?? fetch; const now = opts.now ?? Date.now; const envelope: WebhookEnvelope = { event, signature: signEvent(event, subscriber.secret), }; const body = JSON.stringify(envelope); const startedAt = now(); let lastError: string | undefined; let lastStatus: number | undefined; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchFn(subscriber.url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-celilo-signature': envelope.signature, 'x-celilo-event-id': event.eventId, }, body, signal: controller.signal, }); lastStatus = response.status; if (response.ok) { clearTimeout(timer); return { subscriber, ok: true, status: response.status, attempts: attempt, durationMs: now() - startedAt, }; } lastError = `HTTP ${response.status}`; // 4xx is a client error — won't recover by retrying. Don't burn // attempts on it (saves time + makes the operator-visible // error actionable). if (response.status >= 400 && response.status < 500) { clearTimeout(timer); return { subscriber, ok: false, status: response.status, attempts: attempt, error: `HTTP ${response.status} (no retry on 4xx)`, durationMs: now() - startedAt, }; } } catch (err) { lastError = err instanceof Error ? err.message : String(err); } finally { clearTimeout(timer); } if (attempt < maxAttempts) { const backoff = baseBackoffMs * 2 ** (attempt - 1); await new Promise((r) => setTimeout(r, backoff)); } } return { subscriber, ok: false, status: lastStatus, attempts: maxAttempts, error: lastError ?? 'unknown delivery failure', durationMs: now() - startedAt, }; } /** * Render a single delivery result for the operator. Operator * surface in `celilo publish` summary + `celilo subscribers status`. */ export function formatDeliveryResult(result: DeliveryResult): string { const target = result.subscriber.name ? `${result.subscriber.name} (${result.subscriber.url})` : result.subscriber.url; if (result.ok) { return `✓ ${target} — ${result.status} in ${result.durationMs}ms (${result.attempts} attempt${result.attempts === 1 ? '' : 's'})`; } return `✗ ${target} — ${result.error} (${result.attempts} attempt${result.attempts === 1 ? '' : 's'}, ${result.durationMs}ms)`; }