outbound-webhook-dispatcher.ts218 lines · main
| 1 | import { createHmac } from 'node:crypto'; |
| 2 | import { lookup } from 'node:dns/promises'; |
| 3 | |
| 4 | import { env } from '../env.js'; |
| 5 | import { log } from '../lib/logger.js'; |
| 6 | import { getDb } from '../db/client.js'; |
| 7 | import { webhookSubscribers } from '../db/schema.js'; |
| 8 | import { eq } from 'drizzle-orm'; |
| 9 | import { |
| 10 | claimDueDeliveries, |
| 11 | decryptSubscriberSecret, |
| 12 | recordDeliveryResult, |
| 13 | } from '../services/outbound-webhooks.js'; |
| 14 | |
| 15 | /** |
| 16 | * Outbound webhook dispatcher. Every TICK_MS: |
| 17 | * 1. claim up to BATCH_SIZE pending deliveries whose next_attempt_at |
| 18 | * has passed. |
| 19 | * 2. for each delivery: decrypt the subscriber's secret, sign the |
| 20 | * payload, POST to target_url. |
| 21 | * 3. record the outcome — on success mark `ok`; on failure schedule |
| 22 | * the next attempt with exponential backoff up to MAX_ATTEMPTS, |
| 23 | * then mark `failed`. |
| 24 | * |
| 25 | * Concurrency: deliveries to different subscribers fan out in parallel. |
| 26 | * Subscriber-level ordering is best-effort — strict per-subscriber |
| 27 | * serialisation would need explicit locking, and outbound webhooks are |
| 28 | * intentionally at-least-once with customer-side dedupe on event_id. |
| 29 | */ |
| 30 | |
| 31 | const TICK_MS = 30_000; |
| 32 | const BATCH_SIZE = 50; |
| 33 | const HTTP_TIMEOUT_MS = 10_000; |
| 34 | const MAX_ERROR_LEN = 500; |
| 35 | // Match the constant in services/outbound-webhooks.ts (we don't want a |
| 36 | // runtime import cycle just to share a number). |
| 37 | const MAX_ATTEMPTS = 5; |
| 38 | |
| 39 | let timer: ReturnType<typeof setInterval> | null = null; |
| 40 | let inflight = false; |
| 41 | |
| 42 | async function tick(): Promise<void> { |
| 43 | if (inflight) { |
| 44 | log.warn('outbound_webhook_dispatcher_tick_skipped_inflight'); |
| 45 | return; |
| 46 | } |
| 47 | inflight = true; |
| 48 | const now = new Date(); |
| 49 | try { |
| 50 | const due = await claimDueDeliveries(now, BATCH_SIZE); |
| 51 | if (due.length === 0) return; |
| 52 | log.info('outbound_webhook_dispatcher_tick', { dueCount: due.length }); |
| 53 | await Promise.all(due.map((d) => fireOne(d, now))); |
| 54 | } catch (err) { |
| 55 | log.error('outbound_webhook_dispatcher_tick_failed', { |
| 56 | message: err instanceof Error ? err.message : String(err), |
| 57 | }); |
| 58 | } finally { |
| 59 | inflight = false; |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | async function fireOne( |
| 64 | delivery: Awaited<ReturnType<typeof claimDueDeliveries>>[number], |
| 65 | now: Date, |
| 66 | ): Promise<void> { |
| 67 | const attemptCount = Number(delivery.attemptCount); |
| 68 | const db = getDb(); |
| 69 | const subRows = await db |
| 70 | .select() |
| 71 | .from(webhookSubscribers) |
| 72 | .where(eq(webhookSubscribers.id, delivery.subscriberId)) |
| 73 | .limit(1); |
| 74 | const subscriber = subRows[0]; |
| 75 | if (!subscriber || !subscriber.enabled || subscriber.deletedAt) { |
| 76 | // Subscriber was paused or deleted between claim and dispatch. Force |
| 77 | // the row to its terminal `failed` state by passing the |
| 78 | // last-retry-slot attempt count to the recorder. No HTTP attempted. |
| 79 | await recordDeliveryResult({ |
| 80 | deliveryId: delivery.id, |
| 81 | attemptCount: MAX_ATTEMPTS - 1, |
| 82 | statusCode: null, |
| 83 | durationMs: 0, |
| 84 | errorMessage: 'subscriber disabled or deleted between claim and dispatch', |
| 85 | ok: false, |
| 86 | ranAt: now, |
| 87 | }); |
| 88 | return; |
| 89 | } |
| 90 | |
| 91 | // IP allowlist check |
| 92 | if (subscriber.allowedIps) { |
| 93 | const allowed = subscriber.allowedIps.split(',').map((s) => s.trim()).filter(Boolean); |
| 94 | if (allowed.length > 0) { |
| 95 | let resolved: string; |
| 96 | try { |
| 97 | const hostname = new URL(subscriber.targetUrl).hostname; |
| 98 | resolved = await lookup(hostname).then((r) => r.address); |
| 99 | } catch { |
| 100 | await recordDeliveryResult({ |
| 101 | deliveryId: delivery.id, |
| 102 | attemptCount: MAX_ATTEMPTS - 1, |
| 103 | statusCode: null, |
| 104 | durationMs: 0, |
| 105 | errorMessage: 'ip_allowlist: dns resolution failed', |
| 106 | ok: false, |
| 107 | ranAt: now, |
| 108 | }); |
| 109 | return; |
| 110 | } |
| 111 | if (!allowed.some((a) => ipMatches(resolved, a))) { |
| 112 | await recordDeliveryResult({ |
| 113 | deliveryId: delivery.id, |
| 114 | attemptCount: MAX_ATTEMPTS - 1, |
| 115 | statusCode: null, |
| 116 | durationMs: 0, |
| 117 | errorMessage: `ip_allowlist: resolved ${resolved} not in allowlist`, |
| 118 | ok: false, |
| 119 | ranAt: now, |
| 120 | }); |
| 121 | return; |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | const secret = decryptSubscriberSecret(subscriber); |
| 127 | const body = JSON.stringify(delivery.payload); |
| 128 | const timestamp = String(now.getTime()); |
| 129 | const signature = `v1=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`; |
| 130 | |
| 131 | let statusCode: number | null = null; |
| 132 | let errorMessage: string | null = null; |
| 133 | let ok = false; |
| 134 | |
| 135 | const t0 = Date.now(); |
| 136 | try { |
| 137 | const res = await fetch(subscriber.targetUrl, { |
| 138 | method: 'POST', |
| 139 | headers: { |
| 140 | 'content-type': 'application/json', |
| 141 | 'x-briven-signature': signature, |
| 142 | 'x-briven-timestamp': timestamp, |
| 143 | 'x-briven-event': delivery.eventType, |
| 144 | 'x-briven-event-id': delivery.eventId, |
| 145 | 'user-agent': `briven-webhook/1.0 (${env.BRIVEN_API_ORIGIN})`, |
| 146 | }, |
| 147 | body, |
| 148 | signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), |
| 149 | }); |
| 150 | statusCode = res.status; |
| 151 | ok = res.status >= 200 && res.status < 300; |
| 152 | if (!ok) { |
| 153 | const text = await res.text().catch(() => ''); |
| 154 | errorMessage = `${res.status}: ${text.slice(0, MAX_ERROR_LEN)}`; |
| 155 | } |
| 156 | } catch (err) { |
| 157 | errorMessage = (err instanceof Error ? err.message : String(err)).slice(0, MAX_ERROR_LEN); |
| 158 | } |
| 159 | const durationMs = Date.now() - t0; |
| 160 | |
| 161 | await recordDeliveryResult({ |
| 162 | deliveryId: delivery.id, |
| 163 | attemptCount, |
| 164 | statusCode, |
| 165 | durationMs, |
| 166 | errorMessage, |
| 167 | ok, |
| 168 | ranAt: now, |
| 169 | }); |
| 170 | } |
| 171 | |
| 172 | export function startOutboundWebhookDispatcher(): void { |
| 173 | if (timer) return; |
| 174 | if (!env.BRIVEN_DATABASE_URL) { |
| 175 | log.warn('outbound_webhook_dispatcher_skipped_no_db'); |
| 176 | return; |
| 177 | } |
| 178 | // 75s after boot — sits between schedule (45s) and retention (90s) |
| 179 | // so the three workers don't all hit the connection pool together. |
| 180 | setTimeout(() => { |
| 181 | void tick(); |
| 182 | timer = setInterval(() => { |
| 183 | void tick(); |
| 184 | }, TICK_MS); |
| 185 | }, 75_000).unref?.(); |
| 186 | log.info('outbound_webhook_dispatcher_armed', { tickMs: TICK_MS, batch: BATCH_SIZE }); |
| 187 | } |
| 188 | |
| 189 | export function stopOutboundWebhookDispatcher(): void { |
| 190 | if (timer) { |
| 191 | clearInterval(timer); |
| 192 | timer = null; |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | function ipMatches(ip: string, pattern: string): boolean { |
| 197 | if (pattern.includes('/')) { |
| 198 | const [base, bits] = pattern.split('/'); |
| 199 | const mask = parseInt(bits!, 10); |
| 200 | const ipNum = ipv4ToNumber(ip); |
| 201 | const baseNum = ipv4ToNumber(base!); |
| 202 | if (ipNum == null || baseNum == null) return false; |
| 203 | const shift = 32 - mask; |
| 204 | return (ipNum >> shift) === (baseNum >> shift); |
| 205 | } |
| 206 | return ip === pattern; |
| 207 | } |
| 208 | |
| 209 | function ipv4ToNumber(ip: string): number | null { |
| 210 | const parts = ip.split('.').map(Number); |
| 211 | if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) { |
| 212 | return null; |
| 213 | } |
| 214 | return (parts[0]! << 24) | (parts[1]! << 16) | (parts[2]! << 8) | parts[3]!; |
| 215 | } |
| 216 | |
| 217 | // Exported for tests. |
| 218 | export const _internals = { tick, fireOne, ipMatches }; |