158 lines
4.1 KiB
TypeScript
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));
|
|
}
|