Files
OmniRoute/src/lib/webhookDispatcher.ts
diegosouzapw 80cc7340ac feat: API Endpoints dashboard — interactive catalog, webhooks, OpenAPI viewer
Phase 1: Interactive REST API Catalog
- GET /api/openapi/spec: serves parsed openapi.yaml as JSON catalog
- POST /api/openapi/try: Try It proxy for inline endpoint testing
- Endpoint catalog with tag grouping, search, method badges
- Expand: schemas, auth, curl examples, Try It panel

Phase 2: OpenAPI Spec Viewer
- Spec info header with version, download YAML/JSON, schema browser

Phase 3: Webhooks & Event Subscriptions
- Migration 011: webhooks table
- src/lib/db/webhooks.ts: CRUD + delivery tracking + auto-disable
- src/lib/webhookDispatcher.ts: HMAC-SHA256, retries
- API: CRUD /api/webhooks + test delivery
- Dashboard: add/edit/toggle/test/delete webhook UI

923 tests pass, tsc clean
2026-03-23 22:07:10 -03:00

108 lines
3.0 KiB
TypeScript

/**
* Webhook Dispatcher
* Dispatches events to registered webhooks with HMAC-SHA256 signing and retries
*/
import crypto from "crypto";
export type WebhookEvent =
| "request.completed"
| "request.failed"
| "provider.error"
| "provider.recovered"
| "quota.exceeded"
| "combo.switched"
| "test.ping";
export interface WebhookPayload {
event: WebhookEvent;
timestamp: string;
data: Record<string, any>;
}
function signPayload(payload: string, secret: string): string {
return `sha256=${crypto.createHmac("sha256", secret).update(payload).digest("hex")}`;
}
export async function deliverWebhook(
url: string,
payload: WebhookPayload,
secret?: string | null,
maxRetries = 3
): Promise<{ success: boolean; status: number; error?: string }> {
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "OmniRoute-Webhook/1.0",
"X-Webhook-Event": payload.event,
"X-Webhook-Timestamp": payload.timestamp,
};
if (secret) {
headers["X-Webhook-Signature"] = signPayload(body, secret);
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const res = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res.ok || res.status < 500) {
return { success: res.ok, status: res.status };
}
// Server error — retry with exponential backoff
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
}
} catch (error: any) {
if (attempt === maxRetries) {
return { success: false, status: 0, error: error.message || "Network error" };
}
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}
return { success: false, status: 0, error: "Max retries exceeded" };
}
/**
* Dispatch an event to all matching enabled webhooks
*/
export async function dispatchEvent(event: WebhookEvent, data: Record<string, any>): Promise<void> {
// Lazy import to avoid circular deps
const { getEnabledWebhooks, recordWebhookDelivery, disableWebhooksWithHighFailures } =
await import("./db/webhooks");
const webhooks = getEnabledWebhooks();
const payload: WebhookPayload = {
event,
timestamp: new Date().toISOString(),
data,
};
const deliveries = webhooks
.filter((wh) => {
const events = wh.events;
return events.includes("*") || events.includes(event);
})
.map(async (wh) => {
const result = await deliverWebhook(wh.url, payload, wh.secret);
recordWebhookDelivery(wh.id, result.status, result.success);
return { webhookId: wh.id, ...result };
});
await Promise.allSettled(deliveries);
// Auto-disable webhooks with too many failures
disableWebhooksWithHighFailures(10);
}