/** * Webhook notifier — fire HTTP POST callbacks on crawl lifecycle events. * * Supports: crawl.started, crawl.progress, crawl.completed, crawl.failed * Payload format compatible with Slack, Discord, and generic REST webhooks. * Retries 3x on failure with exponential backoff. */ export type CrawlEventType = 'crawl.started' | 'crawl.progress' | 'crawl.completed' | 'crawl.failed'; export interface WebhookPayload { event: CrawlEventType; jobId: string; projectId: string; tenantId: string; timestamp: string; data: Record; } export interface WebhookNotifierOpts { url: string; secret?: string; // HMAC-SHA256 signing secret — adds X-ZeTa-Signature header headers?: Record; timeoutMs?: number; // default 10s maxRetries?: number; // default 3 } async function hmacSign(secret: string, payload: string): Promise { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)); return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); } export class WebhookNotifier { private readonly opts: Required> & { secret?: string }; constructor(opts: WebhookNotifierOpts) { this.opts = { url: opts.url, secret: opts.secret, headers: opts.headers ?? {}, timeoutMs: opts.timeoutMs ?? 10_000, maxRetries: opts.maxRetries ?? 3, }; } async notify( event: CrawlEventType, jobId: string, projectId: string, tenantId: string, data: Record = {}, ): Promise { const payload: WebhookPayload = { event, jobId, projectId, tenantId, timestamp: new Date().toISOString(), data, }; const body = JSON.stringify(payload); const headers: Record = { 'Content-Type': 'application/json', 'User-Agent': 'ZeTa-Crawler/1.0', ...this.opts.headers, }; if (this.opts.secret) { headers['X-ZeTa-Signature'] = `sha256=${await hmacSign(this.opts.secret, body)}`; } let lastErr: any; for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) { try { const res = await fetch(this.opts.url, { method: 'POST', headers, body, signal: AbortSignal.timeout(this.opts.timeoutMs), }); if (res.ok) return; lastErr = new Error(`Webhook HTTP ${res.status}`); } catch (e) { lastErr = e; } if (attempt < this.opts.maxRetries) { await new Promise(r => setTimeout(r, 1_000 * Math.pow(2, attempt))); } } console.error(`[webhook] Failed to deliver ${event} after ${this.opts.maxRetries + 1} attempts:`, lastErr?.message); } /** Slack-compatible message format */ static slackPayload(event: CrawlEventType, data: Record): Record { const emoji = event === 'crawl.completed' ? '✅' : event === 'crawl.failed' ? '❌' : '🔄'; return { text: `${emoji} *ZeTa Crawl Event: ${event}*`, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `${emoji} *${event}*\n${Object.entries(data).map(([k, v]) => `• *${k}*: ${v}`).join('\n')}` } }, ], }; } }