/** * Reusable webhook dispatcher with HMAC-SHA256 signing and exponential backoff retry. * Extracted from platform-service's dispatcher for cross-product reuse. */ import { randomUUID, createHmac } from 'node:crypto'; import type { WebhookTarget, WebhookPayload, DeliveryAttempt, DeliveryResult, DispatchOptions, } from './types.js'; const DEFAULT_MAX_RETRIES = 3; const DEFAULT_TIMEOUT_MS = 5_000; const DEFAULT_BACKOFF = [10_000, 60_000, 300_000]; /** * Sign a webhook payload body with HMAC-SHA256. */ export function signPayload(body: string, secret: string): string { return createHmac('sha256', secret).update(body).digest('hex'); } /** * Dispatch an event to a single webhook target with retry and signing. */ export async function deliverToTarget( target: WebhookTarget, event: string, data: Record, productId: string, options?: DispatchOptions ): Promise { const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; const backoff = options?.backoffIntervals ?? DEFAULT_BACKOFF; const deliveryId = randomUUID(); const timestamp = new Date().toISOString(); const payload: WebhookPayload = { id: deliveryId, event, productId, timestamp, data, }; const body = JSON.stringify(payload); const signature = signPayload(body, target.secret); const attempts: DeliveryAttempt[] = []; for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { const delay = backoff[attempt - 1] ?? backoff[backoff.length - 1]; await sleep(delay); } const start = Date.now(); const attemptRecord: DeliveryAttempt = { attemptNumber: attempt + 1, durationMs: 0, attemptedAt: new Date().toISOString(), }; try { const res = await fetch(target.url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webhook-Signature': `sha256=${signature}`, 'X-Webhook-Timestamp': timestamp, 'X-Webhook-Event': event, 'X-Webhook-Delivery-Id': deliveryId, }, body, signal: AbortSignal.timeout(timeoutMs), }); attemptRecord.durationMs = Date.now() - start; attemptRecord.responseCode = res.status; if (res.ok) { attempts.push(attemptRecord); const result: DeliveryResult = { deliveryId, targetId: target.id, event, status: 'success', attempts, completedAt: new Date().toISOString(), }; await options?.onDelivery?.(result); return result; } attemptRecord.error = `HTTP ${res.status}`; } catch (err) { attemptRecord.durationMs = Date.now() - start; attemptRecord.error = err instanceof Error ? err.message : String(err); } attempts.push(attemptRecord); } const result: DeliveryResult = { deliveryId, targetId: target.id, event, status: 'failed', attempts, completedAt: new Date().toISOString(), }; await options?.onDelivery?.(result); return result; } /** * Dispatch an event to all matching targets. * Fire-and-forget: errors are collected in results, never thrown. */ export async function dispatchToTargets( targets: WebhookTarget[], event: string, data: Record, productId: string, options?: DispatchOptions ): Promise { const matching = targets.filter( t => t.enabled && (t.events.length === 0 || t.events.includes(event)) ); if (matching.length === 0) return []; const results = await Promise.allSettled( matching.map(target => deliverToTarget(target, event, data, productId, options)) ); return results.map(r => r.status === 'fulfilled' ? r.value : { deliveryId: randomUUID(), targetId: 'unknown', event, status: 'failed' as const, attempts: [], completedAt: new Date().toISOString(), } ); } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); }