learning_ai_common_plat/packages/webhook-dispatch/src/dispatcher.ts

158 lines
4.1 KiB
TypeScript

/**
* 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<string, unknown>,
productId: string,
options?: DispatchOptions
): Promise<DeliveryResult> {
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<string, unknown>,
productId: string,
options?: DispatchOptions
): Promise<DeliveryResult[]> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}