Skip to main content
Glama
waldzellai

Exa Websets MCP Server

by waldzellai
WebhookSender.ts15.5 kB
/** * Webhook Sender Implementation * * Handles webhook delivery with retry logic, timeout handling, and comprehensive * tracking of delivery attempts and failures. */ import { EventEmitter } from 'events'; import { WebsetEvent, Webhook } from '../types/websets.js'; import { WebhookSubscription } from './WebhookRegistry.js'; import { createWebhookHeaders, generateWebhookSecret } from '../utils/security.js'; /** * Webhook delivery configuration */ export interface WebhookSenderConfig { /** Request timeout in milliseconds */ timeout: number; /** Maximum number of retry attempts */ maxRetries: number; /** Base retry delay in milliseconds */ retryDelay: number; /** Maximum retry delay in milliseconds */ maxRetryDelay: number; /** Whether to use exponential backoff */ exponentialBackoff: boolean; /** Maximum concurrent deliveries */ maxConcurrency: number; /** User agent string for requests */ userAgent: string; /** Whether to verify SSL certificates */ verifySsl: boolean; /** Webhook signing secret (generated if not provided) */ signingSecret?: string; } /** * Webhook delivery attempt */ export interface WebhookDeliveryAttempt { /** Unique attempt ID */ id: string; /** Webhook ID */ webhookId: string; /** Event being delivered */ event: WebsetEvent; /** Attempt number (1-based) */ attemptNumber: number; /** When the attempt was made */ attemptedAt: Date; /** HTTP status code (if request completed) */ statusCode?: number; /** Response body (if available) */ responseBody?: string; /** Response headers */ responseHeaders?: Record<string, string>; /** Duration of the request in milliseconds */ duration: number; /** Whether the delivery was successful */ success: boolean; /** Error message if delivery failed */ error?: string; /** Next retry time (if applicable) */ nextRetryAt?: Date; } /** * Webhook delivery result */ export interface WebhookDeliveryResult { /** Whether delivery was successful */ success: boolean; /** All delivery attempts made */ attempts: WebhookDeliveryAttempt[]; /** Final error if delivery failed */ finalError?: Error; /** Total duration of all attempts */ totalDuration: number; } /** * Webhook sender statistics */ export interface WebhookSenderStats { /** Total deliveries attempted */ totalDeliveries: number; /** Successful deliveries */ successfulDeliveries: number; /** Failed deliveries */ failedDeliveries: number; /** Currently active deliveries */ activeDeliveries: number; /** Average delivery time in milliseconds */ averageDeliveryTime: number; /** Success rate (0-1) */ successRate: number; } /** * Default webhook sender configuration */ const DEFAULT_WEBHOOK_SENDER_CONFIG: WebhookSenderConfig = { timeout: 30000, maxRetries: 3, retryDelay: 1000, maxRetryDelay: 30000, exponentialBackoff: true, maxConcurrency: 10, userAgent: 'Websets-Webhook-Sender/1.0', verifySsl: true, }; /** * Webhook sender for delivering webhooks with retry logic */ export class WebhookSender extends EventEmitter { private readonly config: WebhookSenderConfig; private readonly activeDeliveries = new Map<string, Promise<WebhookDeliveryResult>>(); private readonly stats: WebhookSenderStats = { totalDeliveries: 0, successfulDeliveries: 0, failedDeliveries: 0, activeDeliveries: 0, averageDeliveryTime: 0, successRate: 0, }; private readonly deliveryTimes: number[] = []; private isShuttingDown = false; private readonly signingSecret: string; constructor(config: Partial<WebhookSenderConfig> = {}) { super(); this.config = { ...DEFAULT_WEBHOOK_SENDER_CONFIG, ...config }; this.signingSecret = this.config.signingSecret || generateWebhookSecret(); } /** * Send a webhook for an event * @param subscription The webhook subscription * @param event The event to send * @returns Promise that resolves to delivery result */ async sendWebhook( subscription: WebhookSubscription, event: WebsetEvent ): Promise<WebhookDeliveryResult> { if (this.isShuttingDown) { throw new Error('Webhook sender is shutting down'); } if (this.activeDeliveries.size >= this.config.maxConcurrency) { throw new Error(`Maximum concurrent deliveries reached (${this.config.maxConcurrency})`); } const deliveryId = `${subscription.webhook.id}-${event.id}-${Date.now()}`; // Create delivery promise const deliveryPromise = this.performDelivery(subscription, event, deliveryId); // Track active delivery this.activeDeliveries.set(deliveryId, deliveryPromise); this.stats.activeDeliveries = this.activeDeliveries.size; try { const result = await deliveryPromise; return result; } finally { this.activeDeliveries.delete(deliveryId); this.stats.activeDeliveries = this.activeDeliveries.size; } } /** * Perform the actual webhook delivery with retries * @param subscription The webhook subscription * @param event The event to send * @param deliveryId Unique delivery ID * @returns Promise that resolves to delivery result */ private async performDelivery( subscription: WebhookSubscription, event: WebsetEvent, deliveryId: string ): Promise<WebhookDeliveryResult> { const startTime = Date.now(); const attempts: WebhookDeliveryAttempt[] = []; let lastError: Error | undefined; this.stats.totalDeliveries++; this.emit('deliveryStarted', subscription, event, deliveryId); for (let attemptNumber = 1; attemptNumber <= this.config.maxRetries + 1; attemptNumber++) { const attempt = await this.makeDeliveryAttempt( subscription, event, attemptNumber, deliveryId ); attempts.push(attempt); this.emit('deliveryAttempt', attempt); if (attempt.success) { // Successful delivery const totalDuration = Date.now() - startTime; this.stats.successfulDeliveries++; this.recordDeliveryTime(totalDuration); this.updateSuccessRate(); const result: WebhookDeliveryResult = { success: true, attempts, totalDuration, }; this.emit('deliverySuccess', subscription, event, result); return result; } // Delivery failed lastError = new Error(attempt.error || 'Unknown delivery error'); // Check if we should retry if (attemptNumber <= this.config.maxRetries) { const retryDelay = this.calculateRetryDelay(attemptNumber); attempt.nextRetryAt = new Date(Date.now() + retryDelay); this.emit('deliveryRetry', attempt, retryDelay); await this.sleep(retryDelay); } } // All attempts failed const totalDuration = Date.now() - startTime; this.stats.failedDeliveries++; this.updateSuccessRate(); const result: WebhookDeliveryResult = { success: false, attempts, finalError: lastError, totalDuration, }; this.emit('deliveryFailed', subscription, event, result); return result; } /** * Make a single delivery attempt * @param subscription The webhook subscription * @param event The event to send * @param attemptNumber The attempt number * @param deliveryId Unique delivery ID * @returns Promise that resolves to delivery attempt */ private async makeDeliveryAttempt( subscription: WebhookSubscription, event: WebsetEvent, attemptNumber: number, deliveryId: string ): Promise<WebhookDeliveryAttempt> { const attemptId = `${deliveryId}-${attemptNumber}`; const startTime = Date.now(); const attempt: WebhookDeliveryAttempt = { id: attemptId, webhookId: subscription.webhook.id, event, attemptNumber, attemptedAt: new Date(), duration: 0, success: false, }; try { // Prepare webhook payload const payload = this.createWebhookPayload(event, subscription.webhook); // Make HTTP request const response = await this.makeHttpRequest( subscription.webhook.url, payload, subscription.webhook.secret ); attempt.statusCode = response.status; attempt.responseBody = response.body; attempt.responseHeaders = response.headers; attempt.duration = Date.now() - startTime; // Consider 2xx status codes as successful attempt.success = response.status >= 200 && response.status < 300; if (!attempt.success) { attempt.error = `HTTP ${response.status}: ${response.statusText}`; } } catch (error) { attempt.duration = Date.now() - startTime; attempt.error = error instanceof Error ? error.message : 'Unknown error'; attempt.success = false; } return attempt; } /** * Create webhook payload * @param event The event data * @param webhook The webhook configuration * @returns Webhook payload */ private createWebhookPayload(event: WebsetEvent, webhook: Webhook): any { return { id: `evt_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`, object: 'event', type: event.type, data: event.data, created: Math.floor(Date.now() / 1000), livemode: false, // Assuming sandbox mode for now pending_webhooks: 1, request: { id: null, idempotency_key: null, }, }; } /** * Make HTTP request to webhook URL * @param url The webhook URL * @param payload The payload to send * @param secret Optional webhook secret for signature * @returns Promise that resolves to HTTP response */ private async makeHttpRequest( url: string, payload: any, secret?: string ): Promise<{ status: number; statusText: string; body: string; headers: Record<string, string>; }> { const body = JSON.stringify(payload); const webhookId = payload.id || `webhook_${Date.now()}`; const effectiveSecret = secret || this.signingSecret; // Create secure webhook headers with HMAC signature const securityHeaders = createWebhookHeaders(body, effectiveSecret, webhookId); const headers: Record<string, string> = { 'Content-Type': 'application/json', 'User-Agent': this.config.userAgent, ...securityHeaders, }; // Add signature if secret is provided if (secret) { const signature = await this.generateSignature(body, secret); headers['X-Webhook-Signature-256'] = signature; } // Create AbortController for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); try { const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal, // Note: In Node.js, you might need to configure SSL verification // This is a simplified implementation }); const responseBody = await response.text(); const responseHeaders: Record<string, string> = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); return { status: response.status, statusText: response.statusText, body: responseBody, headers: responseHeaders, }; } finally { clearTimeout(timeoutId); } } /** * Generate HMAC-SHA256 signature for webhook * @param payload The payload to sign * @param secret The webhook secret * @returns Promise that resolves to signature */ private async generateSignature(payload: string, secret: string): Promise<string> { // This is a simplified implementation // In a real implementation, you would use Node.js crypto module const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign( 'HMAC', key, encoder.encode(payload) ); const hashArray = Array.from(new Uint8Array(signature)); const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); return `sha256=${hashHex}`; } /** * Calculate retry delay based on attempt number * @param attemptNumber The current attempt number * @returns Delay in milliseconds */ private calculateRetryDelay(attemptNumber: number): number { let delay = this.config.retryDelay; if (this.config.exponentialBackoff) { delay = Math.min( this.config.retryDelay * Math.pow(2, attemptNumber - 1), this.config.maxRetryDelay ); } // Add jitter to prevent thundering herd const jitter = Math.random() * 0.1 * delay; return delay + jitter; } /** * Sleep for specified duration * @param ms Duration in milliseconds * @returns Promise that resolves after delay */ private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Record delivery time for statistics * @param duration Delivery duration in milliseconds */ private recordDeliveryTime(duration: number): void { this.deliveryTimes.push(duration); // Keep only last 1000 delivery times if (this.deliveryTimes.length > 1000) { this.deliveryTimes.shift(); } this.stats.averageDeliveryTime = this.deliveryTimes.reduce((sum, time) => sum + time, 0) / this.deliveryTimes.length; } /** * Update success rate statistics */ private updateSuccessRate(): void { const totalCompleted = this.stats.successfulDeliveries + this.stats.failedDeliveries; this.stats.successRate = totalCompleted > 0 ? this.stats.successfulDeliveries / totalCompleted : 0; } /** * Get current sender statistics * @returns Current statistics */ getStats(): WebhookSenderStats { return { ...this.stats }; } /** * Get active deliveries count * @returns Number of active deliveries */ getActiveDeliveriesCount(): number { return this.activeDeliveries.size; } /** * Gracefully shutdown the sender * @param timeout Maximum time to wait for shutdown in milliseconds * @returns Promise that resolves when shutdown is complete */ async shutdown(timeout: number = 30000): Promise<void> { this.isShuttingDown = true; // Wait for all active deliveries to complete const startTime = Date.now(); while (this.activeDeliveries.size > 0 && (Date.now() - startTime) < timeout) { await this.sleep(100); } if (this.activeDeliveries.size > 0) { console.warn(`Webhook sender shutdown with ${this.activeDeliveries.size} deliveries still active`); } this.emit('shutdown'); } /** * Health check for the sender * @returns Health status information */ healthCheck(): { healthy: boolean; activeDeliveries: number; successRate: number; averageDeliveryTime: number; } { return { healthy: !this.isShuttingDown && this.activeDeliveries.size < this.config.maxConcurrency, activeDeliveries: this.activeDeliveries.size, successRate: this.stats.successRate, averageDeliveryTime: this.stats.averageDeliveryTime, }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/waldzellai/exa-mcp-server-websets'

If you have feedback or need assistance with the MCP directory API, please join our Discord server