crawlforge-mcp-server 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +315 -0
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/package.json +115 -0
- package/server.js +1963 -0
- package/setup.js +112 -0
- package/src/constants/config.js +615 -0
- package/src/core/ActionExecutor.js +1104 -0
- package/src/core/AlertNotificationSystem.js +601 -0
- package/src/core/AuthManager.js +315 -0
- package/src/core/ChangeTracker.js +2306 -0
- package/src/core/JobManager.js +687 -0
- package/src/core/LLMsTxtAnalyzer.js +753 -0
- package/src/core/LocalizationManager.js +1615 -0
- package/src/core/PerformanceManager.js +828 -0
- package/src/core/ResearchOrchestrator.js +1327 -0
- package/src/core/SnapshotManager.js +1037 -0
- package/src/core/StealthBrowserManager.js +1795 -0
- package/src/core/WebhookDispatcher.js +745 -0
- package/src/core/analysis/ContentAnalyzer.js +749 -0
- package/src/core/analysis/LinkAnalyzer.js +972 -0
- package/src/core/cache/CacheManager.js +821 -0
- package/src/core/connections/ConnectionPool.js +553 -0
- package/src/core/crawlers/BFSCrawler.js +845 -0
- package/src/core/integrations/PerformanceIntegration.js +377 -0
- package/src/core/llm/AnthropicProvider.js +135 -0
- package/src/core/llm/LLMManager.js +415 -0
- package/src/core/llm/LLMProvider.js +97 -0
- package/src/core/llm/OpenAIProvider.js +127 -0
- package/src/core/processing/BrowserProcessor.js +986 -0
- package/src/core/processing/ContentProcessor.js +505 -0
- package/src/core/processing/PDFProcessor.js +448 -0
- package/src/core/processing/StreamProcessor.js +673 -0
- package/src/core/queue/QueueManager.js +98 -0
- package/src/core/workers/WorkerPool.js +585 -0
- package/src/core/workers/worker.js +743 -0
- package/src/monitoring/healthCheck.js +600 -0
- package/src/monitoring/metrics.js +761 -0
- package/src/optimization/wave3-optimizations.js +932 -0
- package/src/security/security-patches.js +120 -0
- package/src/security/security-tests.js +355 -0
- package/src/security/wave3-security.js +652 -0
- package/src/tools/advanced/BatchScrapeTool.js +1089 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +669 -0
- package/src/tools/crawl/crawlDeep.js +449 -0
- package/src/tools/crawl/mapSite.js +400 -0
- package/src/tools/extract/analyzeContent.js +624 -0
- package/src/tools/extract/extractContent.js +329 -0
- package/src/tools/extract/processDocument.js +503 -0
- package/src/tools/extract/summarizeContent.js +376 -0
- package/src/tools/llmstxt/generateLLMsTxt.js +570 -0
- package/src/tools/research/deepResearch.js +706 -0
- package/src/tools/search/adapters/duckduckgoSearch.js +398 -0
- package/src/tools/search/adapters/googleSearch.js +236 -0
- package/src/tools/search/adapters/searchProviderFactory.js +96 -0
- package/src/tools/search/queryExpander.js +543 -0
- package/src/tools/search/ranking/ResultDeduplicator.js +676 -0
- package/src/tools/search/ranking/ResultRanker.js +497 -0
- package/src/tools/search/searchWeb.js +482 -0
- package/src/tools/tracking/trackChanges.js +1355 -0
- package/src/utils/CircuitBreaker.js +515 -0
- package/src/utils/ErrorHandlingConfig.js +342 -0
- package/src/utils/HumanBehaviorSimulator.js +569 -0
- package/src/utils/Logger.js +568 -0
- package/src/utils/MemoryMonitor.js +173 -0
- package/src/utils/RetryManager.js +386 -0
- package/src/utils/contentUtils.js +588 -0
- package/src/utils/domainFilter.js +612 -0
- package/src/utils/inputValidation.js +766 -0
- package/src/utils/rateLimiter.js +196 -0
- package/src/utils/robotsChecker.js +91 -0
- package/src/utils/securityMiddleware.js +416 -0
- package/src/utils/sitemapParser.js +678 -0
- package/src/utils/ssrfProtection.js +640 -0
- package/src/utils/urlNormalizer.js +168 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebhookDispatcher - Webhook infrastructure for event notifications
|
|
3
|
+
* Features: queuing, retry logic, HMAC security, health monitoring
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import { EventEmitter } from 'events';
|
|
8
|
+
import { promises as fs } from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import RetryManager from '../utils/RetryManager.js';
|
|
11
|
+
|
|
12
|
+
export class WebhookDispatcher extends EventEmitter {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
super();
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
queueDir = './webhooks',
|
|
18
|
+
maxRetries = 3,
|
|
19
|
+
retryDelay = 5000,
|
|
20
|
+
timeout = 30000,
|
|
21
|
+
enablePersistence = true,
|
|
22
|
+
maxQueueSize = 10000,
|
|
23
|
+
healthCheckInterval = 60000, // 1 minute
|
|
24
|
+
enableHealthMonitoring = true,
|
|
25
|
+
batchSize = 50,
|
|
26
|
+
enableBatching = false,
|
|
27
|
+
signingSecret = null,
|
|
28
|
+
defaultHeaders = {},
|
|
29
|
+
enableLogging = true
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
this.queueDir = queueDir;
|
|
33
|
+
this.maxRetries = maxRetries;
|
|
34
|
+
this.retryDelay = retryDelay;
|
|
35
|
+
this.timeout = timeout;
|
|
36
|
+
this.enablePersistence = enablePersistence;
|
|
37
|
+
this.maxQueueSize = maxQueueSize;
|
|
38
|
+
this.healthCheckInterval = healthCheckInterval;
|
|
39
|
+
this.enableHealthMonitoring = enableHealthMonitoring;
|
|
40
|
+
this.batchSize = batchSize;
|
|
41
|
+
this.enableBatching = enableBatching;
|
|
42
|
+
this.signingSecret = signingSecret;
|
|
43
|
+
this.defaultHeaders = defaultHeaders;
|
|
44
|
+
this.enableLogging = enableLogging;
|
|
45
|
+
|
|
46
|
+
// Webhook queue - in memory and persistent
|
|
47
|
+
this.queue = [];
|
|
48
|
+
this.processing = false;
|
|
49
|
+
this.webhookUrls = new Map(); // url -> configuration
|
|
50
|
+
this.failedUrls = new Map(); // url -> failure info
|
|
51
|
+
|
|
52
|
+
// Statistics
|
|
53
|
+
this.stats = {
|
|
54
|
+
totalEvents: 0,
|
|
55
|
+
successfulDeliveries: 0,
|
|
56
|
+
failedDeliveries: 0,
|
|
57
|
+
retriedDeliveries: 0,
|
|
58
|
+
averageResponseTime: 0,
|
|
59
|
+
healthyUrls: 0,
|
|
60
|
+
unhealthyUrls: 0,
|
|
61
|
+
lastUpdated: Date.now()
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Health monitoring
|
|
65
|
+
this.healthChecks = new Map(); // url -> health status
|
|
66
|
+
this.responseTimes = new Map(); // url -> response time history
|
|
67
|
+
|
|
68
|
+
// Retry manager
|
|
69
|
+
this.retryManager = new RetryManager({
|
|
70
|
+
maxRetries: this.maxRetries,
|
|
71
|
+
baseDelay: this.retryDelay,
|
|
72
|
+
strategy: 'exponential',
|
|
73
|
+
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
|
|
74
|
+
onRetry: (error, attempt, delay, context) => {
|
|
75
|
+
this.stats.retriedDeliveries++;
|
|
76
|
+
if (this.enableLogging) {
|
|
77
|
+
console.log('Webhook retry ' + attempt + ' for ' + context.url + ' after ' + delay + 'ms: ' + error.message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Initialize storage
|
|
83
|
+
if (this.enablePersistence) {
|
|
84
|
+
this.initStorage();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Start health monitoring
|
|
88
|
+
if (this.enableHealthMonitoring) {
|
|
89
|
+
this.startHealthMonitoring();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Start processing queue
|
|
93
|
+
this.startProcessing();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Initialize persistent storage
|
|
98
|
+
*/
|
|
99
|
+
async initStorage() {
|
|
100
|
+
try {
|
|
101
|
+
await fs.mkdir(this.queueDir, { recursive: true });
|
|
102
|
+
await this.loadPersistedEvents();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('Failed to initialize webhook storage:', error);
|
|
105
|
+
this.enablePersistence = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Register webhook URL with configuration
|
|
111
|
+
* @param {string|Object} urlOrConfig - Webhook URL string or config object with url property
|
|
112
|
+
* @param {Object} config - Webhook configuration (when first param is URL string)
|
|
113
|
+
*/
|
|
114
|
+
registerWebhook(urlOrConfig, config = {}) {
|
|
115
|
+
let url, actualConfig;
|
|
116
|
+
|
|
117
|
+
// Handle both signatures: registerWebhook(url, config) and registerWebhook({url, ...config})
|
|
118
|
+
if (typeof urlOrConfig === 'string') {
|
|
119
|
+
url = urlOrConfig;
|
|
120
|
+
actualConfig = config;
|
|
121
|
+
} else if (urlOrConfig && typeof urlOrConfig === 'object' && urlOrConfig.url) {
|
|
122
|
+
url = urlOrConfig.url;
|
|
123
|
+
actualConfig = { ...urlOrConfig };
|
|
124
|
+
delete actualConfig.url; // Remove url from config since it's handled separately
|
|
125
|
+
} else {
|
|
126
|
+
throw new Error('Invalid webhook configuration: URL is required');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const webhookConfig = {
|
|
130
|
+
id: this.generateEventId(), // Add unique ID
|
|
131
|
+
url,
|
|
132
|
+
enabled: actualConfig.enabled !== false,
|
|
133
|
+
events: actualConfig.events || ['*'], // * means all events
|
|
134
|
+
headers: Object.assign({}, this.defaultHeaders, actualConfig.headers || {}),
|
|
135
|
+
timeout: actualConfig.timeout || this.timeout,
|
|
136
|
+
maxRetries: actualConfig.maxRetries || this.maxRetries,
|
|
137
|
+
retryDelay: actualConfig.retryDelay || this.retryDelay,
|
|
138
|
+
signingSecret: actualConfig.signingSecret || actualConfig.secret || this.signingSecret, // support 'secret' alias
|
|
139
|
+
metadata: actualConfig.metadata || {},
|
|
140
|
+
createdAt: Date.now(),
|
|
141
|
+
lastUsed: null
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
this.webhookUrls.set(url, webhookConfig);
|
|
145
|
+
this.healthChecks.set(url, {
|
|
146
|
+
status: 'unknown',
|
|
147
|
+
lastCheck: null,
|
|
148
|
+
consecutiveFailures: 0,
|
|
149
|
+
averageResponseTime: 0,
|
|
150
|
+
lastError: null
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
this.emit('webhookRegistered', url, webhookConfig);
|
|
154
|
+
return webhookConfig;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Unregister webhook URL
|
|
159
|
+
* @param {string} url - Webhook URL
|
|
160
|
+
*/
|
|
161
|
+
unregisterWebhook(url) {
|
|
162
|
+
const removed = this.webhookUrls.delete(url);
|
|
163
|
+
this.healthChecks.delete(url);
|
|
164
|
+
this.responseTimes.delete(url);
|
|
165
|
+
this.failedUrls.delete(url);
|
|
166
|
+
|
|
167
|
+
if (removed) {
|
|
168
|
+
this.emit('webhookUnregistered', url);
|
|
169
|
+
}
|
|
170
|
+
return removed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Dispatch webhook event
|
|
175
|
+
* @param {string} eventType - Event type
|
|
176
|
+
* @param {Object} payload - Event payload
|
|
177
|
+
* @param {Object} options - Dispatch options
|
|
178
|
+
* @returns {Promise<Array>} Array of dispatch results
|
|
179
|
+
*/
|
|
180
|
+
async dispatch(eventType, payload, options = {}) {
|
|
181
|
+
const {
|
|
182
|
+
urls = null, // Specific URLs to send to, null for all registered
|
|
183
|
+
immediate = false, // Skip queue and send immediately
|
|
184
|
+
priority = 0, // Higher number = higher priority
|
|
185
|
+
metadata = {}
|
|
186
|
+
} = options;
|
|
187
|
+
|
|
188
|
+
const event = {
|
|
189
|
+
id: this.generateEventId(),
|
|
190
|
+
type: eventType,
|
|
191
|
+
payload,
|
|
192
|
+
timestamp: Date.now(),
|
|
193
|
+
priority,
|
|
194
|
+
metadata,
|
|
195
|
+
attempts: 0,
|
|
196
|
+
createdAt: Date.now()
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
this.stats.totalEvents++;
|
|
200
|
+
|
|
201
|
+
// Determine target URLs
|
|
202
|
+
const targetUrls = urls || Array.from(this.webhookUrls.keys());
|
|
203
|
+
const filteredUrls = targetUrls.filter(url => {
|
|
204
|
+
const config = this.webhookUrls.get(url);
|
|
205
|
+
return config &&
|
|
206
|
+
config.enabled &&
|
|
207
|
+
(config.events.includes('*') || config.events.includes(eventType));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (filteredUrls.length === 0) {
|
|
211
|
+
this.emit('noTargets', event, eventType);
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Create dispatch tasks
|
|
216
|
+
const tasks = filteredUrls.map(url => {
|
|
217
|
+
const urlString = typeof url === 'string' ? url : String(url);
|
|
218
|
+
const cleanUrl = urlString.replace(/[^a-zA-Z0-9]/g, '_');
|
|
219
|
+
return Object.assign({}, event, {
|
|
220
|
+
url: urlString,
|
|
221
|
+
id: event.id + '_' + cleanUrl
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (immediate) {
|
|
226
|
+
// Process immediately
|
|
227
|
+
const results = [];
|
|
228
|
+
for (const task of tasks) {
|
|
229
|
+
try {
|
|
230
|
+
const result = await this.deliverWebhook(task);
|
|
231
|
+
results.push(result);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
results.push({
|
|
234
|
+
success: false,
|
|
235
|
+
url: task.url,
|
|
236
|
+
error: error.message,
|
|
237
|
+
timestamp: Date.now()
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return results;
|
|
242
|
+
} else {
|
|
243
|
+
// Add to queue
|
|
244
|
+
await this.enqueueEvents(tasks);
|
|
245
|
+
this.emit('eventsQueued', tasks.length, eventType);
|
|
246
|
+
return tasks.map(t => ({ queued: true, url: t.url, eventId: t.id }));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Add events to queue
|
|
252
|
+
* @param {Array} events - Events to queue
|
|
253
|
+
*/
|
|
254
|
+
async enqueueEvents(events) {
|
|
255
|
+
// Check queue size limit
|
|
256
|
+
if (this.queue.length + events.length > this.maxQueueSize) {
|
|
257
|
+
const excess = (this.queue.length + events.length) - this.maxQueueSize;
|
|
258
|
+
// Remove oldest events to make room
|
|
259
|
+
this.queue.splice(0, excess);
|
|
260
|
+
this.emit('queueOverflow', excess);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Add to queue with priority sorting
|
|
264
|
+
this.queue.push(...events);
|
|
265
|
+
this.queue.sort((a, b) => b.priority - a.priority);
|
|
266
|
+
|
|
267
|
+
// Persist if enabled
|
|
268
|
+
if (this.enablePersistence) {
|
|
269
|
+
await this.persistQueue();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
this.emit('eventsEnqueued', events.length);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Process webhook queue
|
|
277
|
+
*/
|
|
278
|
+
async processQueue() {
|
|
279
|
+
if (this.processing || this.queue.length === 0) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this.processing = true;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const batchSize = this.enableBatching ? this.batchSize : 1;
|
|
287
|
+
const batch = this.queue.splice(0, batchSize);
|
|
288
|
+
|
|
289
|
+
if (this.enableBatching && batch.length > 1) {
|
|
290
|
+
await this.processBatch(batch);
|
|
291
|
+
} else {
|
|
292
|
+
for (const event of batch) {
|
|
293
|
+
await this.processEvent(event);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Update persistence after processing
|
|
298
|
+
if (this.enablePersistence && batch.length > 0) {
|
|
299
|
+
await this.persistQueue();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error('Queue processing error:', error);
|
|
304
|
+
this.emit('processingError', error);
|
|
305
|
+
} finally {
|
|
306
|
+
this.processing = false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Process individual webhook event
|
|
312
|
+
* @param {Object} event - Webhook event
|
|
313
|
+
*/
|
|
314
|
+
async processEvent(event) {
|
|
315
|
+
try {
|
|
316
|
+
event.attempts++;
|
|
317
|
+
const result = await this.deliverWebhook(event);
|
|
318
|
+
|
|
319
|
+
this.stats.successfulDeliveries++;
|
|
320
|
+
this.emit('webhookDelivered', event, result);
|
|
321
|
+
|
|
322
|
+
} catch (error) {
|
|
323
|
+
event.lastError = error.message;
|
|
324
|
+
|
|
325
|
+
// Check if we should retry
|
|
326
|
+
if (event.attempts < this.maxRetries) {
|
|
327
|
+
// Re-queue for retry with exponential backoff
|
|
328
|
+
const delay = Math.min(
|
|
329
|
+
this.retryDelay * Math.pow(2, event.attempts - 1),
|
|
330
|
+
60000 // Max 1 minute delay
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
this.queue.unshift(event); // Add to front for priority
|
|
335
|
+
}, delay);
|
|
336
|
+
|
|
337
|
+
this.emit('webhookRetry', event, error, delay);
|
|
338
|
+
} else {
|
|
339
|
+
this.stats.failedDeliveries++;
|
|
340
|
+
this.recordFailure(event.url, error);
|
|
341
|
+
this.emit('webhookFailed', event, error);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Process batch of webhook events
|
|
348
|
+
* @param {Array} batch - Batch of events
|
|
349
|
+
*/
|
|
350
|
+
async processBatch(batch) {
|
|
351
|
+
const promises = batch.map(event => this.processEvent(event));
|
|
352
|
+
await Promise.allSettled(promises);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Deliver webhook to URL
|
|
357
|
+
* @param {Object} event - Webhook event
|
|
358
|
+
* @returns {Promise<Object>} Delivery result
|
|
359
|
+
*/
|
|
360
|
+
async deliverWebhook(event) {
|
|
361
|
+
const config = this.webhookUrls.get(event.url);
|
|
362
|
+
if (!config) {
|
|
363
|
+
throw new Error('Webhook URL ' + event.url + ' not registered');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const startTime = Date.now();
|
|
367
|
+
const headers = Object.assign({}, config.headers);
|
|
368
|
+
|
|
369
|
+
// Add standard headers
|
|
370
|
+
headers['Content-Type'] = 'application/json';
|
|
371
|
+
headers['User-Agent'] = 'WebhookDispatcher/1.0';
|
|
372
|
+
headers['X-Webhook-Event'] = event.type;
|
|
373
|
+
headers['X-Webhook-ID'] = event.id;
|
|
374
|
+
headers['X-Webhook-Timestamp'] = event.timestamp.toString();
|
|
375
|
+
|
|
376
|
+
// Add HMAC signature if secret provided
|
|
377
|
+
if (config.signingSecret) {
|
|
378
|
+
const signature = this.generateSignature(event.payload, config.signingSecret);
|
|
379
|
+
headers['X-Webhook-Signature'] = signature;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Create request body
|
|
383
|
+
const body = JSON.stringify({
|
|
384
|
+
event: event.type,
|
|
385
|
+
id: event.id,
|
|
386
|
+
timestamp: event.timestamp,
|
|
387
|
+
data: event.payload,
|
|
388
|
+
metadata: event.metadata
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Execute with retry logic
|
|
392
|
+
const result = await this.retryManager.execute(async () => {
|
|
393
|
+
const response = await fetch(event.url, {
|
|
394
|
+
method: 'POST',
|
|
395
|
+
headers,
|
|
396
|
+
body,
|
|
397
|
+
timeout: config.timeout
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (!response.ok) {
|
|
401
|
+
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return response;
|
|
405
|
+
}, { url: event.url });
|
|
406
|
+
|
|
407
|
+
const duration = Date.now() - startTime;
|
|
408
|
+
this.recordSuccess(event.url, duration);
|
|
409
|
+
|
|
410
|
+
// Update webhook last used time
|
|
411
|
+
config.lastUsed = Date.now();
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
success: true,
|
|
415
|
+
url: event.url,
|
|
416
|
+
status: result.status,
|
|
417
|
+
duration,
|
|
418
|
+
timestamp: Date.now()
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Generate HMAC signature for webhook security
|
|
424
|
+
* @param {Object} payload - Webhook payload
|
|
425
|
+
* @param {string} secret - Signing secret
|
|
426
|
+
* @returns {string} HMAC signature
|
|
427
|
+
*/
|
|
428
|
+
generateSignature(payload, secret) {
|
|
429
|
+
const body = JSON.stringify(payload);
|
|
430
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
431
|
+
hmac.update(body);
|
|
432
|
+
return 'sha256=' + hmac.digest('hex');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Record successful delivery
|
|
437
|
+
* @param {string} url - Webhook URL
|
|
438
|
+
* @param {number} duration - Response time in milliseconds
|
|
439
|
+
*/
|
|
440
|
+
recordSuccess(url, duration) {
|
|
441
|
+
const health = this.healthChecks.get(url);
|
|
442
|
+
if (health) {
|
|
443
|
+
health.status = 'healthy';
|
|
444
|
+
health.lastCheck = Date.now();
|
|
445
|
+
health.consecutiveFailures = 0;
|
|
446
|
+
|
|
447
|
+
// Update response time
|
|
448
|
+
if (!this.responseTimes.has(url)) {
|
|
449
|
+
this.responseTimes.set(url, []);
|
|
450
|
+
}
|
|
451
|
+
const times = this.responseTimes.get(url);
|
|
452
|
+
times.push(duration);
|
|
453
|
+
if (times.length > 100) times.shift(); // Keep last 100 measurements
|
|
454
|
+
|
|
455
|
+
health.averageResponseTime = times.reduce((a, b) => a + b, 0) / times.length;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Update global stats
|
|
459
|
+
this.updateAverageResponseTime(duration);
|
|
460
|
+
this.updateStats();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Record delivery failure
|
|
465
|
+
* @param {string} url - Webhook URL
|
|
466
|
+
* @param {Error} error - Failure error
|
|
467
|
+
*/
|
|
468
|
+
recordFailure(url, error) {
|
|
469
|
+
const health = this.healthChecks.get(url);
|
|
470
|
+
if (health) {
|
|
471
|
+
health.status = 'unhealthy';
|
|
472
|
+
health.lastCheck = Date.now();
|
|
473
|
+
health.consecutiveFailures++;
|
|
474
|
+
health.lastError = error.message;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Track failed URLs
|
|
478
|
+
if (!this.failedUrls.has(url)) {
|
|
479
|
+
this.failedUrls.set(url, {
|
|
480
|
+
firstFailure: Date.now(),
|
|
481
|
+
failureCount: 0,
|
|
482
|
+
lastError: null
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const failureInfo = this.failedUrls.get(url);
|
|
487
|
+
failureInfo.failureCount++;
|
|
488
|
+
failureInfo.lastError = error.message;
|
|
489
|
+
failureInfo.lastFailure = Date.now();
|
|
490
|
+
|
|
491
|
+
this.updateStats();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Start health monitoring
|
|
496
|
+
*/
|
|
497
|
+
startHealthMonitoring() {
|
|
498
|
+
if (this.healthMonitoringTimer) {
|
|
499
|
+
clearInterval(this.healthMonitoringTimer);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
this.healthMonitoringTimer = setInterval(() => {
|
|
503
|
+
this.performHealthChecks();
|
|
504
|
+
}, this.healthCheckInterval);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Perform health checks on all registered webhooks
|
|
509
|
+
*/
|
|
510
|
+
async performHealthChecks() {
|
|
511
|
+
const urls = Array.from(this.webhookUrls.keys());
|
|
512
|
+
const healthCheckPromises = urls.map(url => this.healthCheckUrl(url));
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
await Promise.allSettled(healthCheckPromises);
|
|
516
|
+
this.updateStats();
|
|
517
|
+
this.emit('healthCheckComplete', this.getHealthSummary());
|
|
518
|
+
} catch (error) {
|
|
519
|
+
this.emit('healthCheckError', error);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Perform health check on specific URL
|
|
525
|
+
* @param {string} url - Webhook URL
|
|
526
|
+
*/
|
|
527
|
+
async healthCheckUrl(url) {
|
|
528
|
+
const config = this.webhookUrls.get(url);
|
|
529
|
+
if (!config || !config.enabled) return;
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const startTime = Date.now();
|
|
533
|
+
const response = await fetch(url, {
|
|
534
|
+
method: 'HEAD',
|
|
535
|
+
timeout: config.timeout / 2, // Use half timeout for health checks
|
|
536
|
+
headers: {
|
|
537
|
+
'User-Agent': 'WebhookDispatcher-HealthCheck/1.0'
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const duration = Date.now() - startTime;
|
|
542
|
+
|
|
543
|
+
if (response.ok || response.status === 405) { // 405 Method Not Allowed is OK for HEAD
|
|
544
|
+
this.recordSuccess(url, duration);
|
|
545
|
+
} else {
|
|
546
|
+
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
} catch (error) {
|
|
550
|
+
this.recordFailure(url, error);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Start queue processing
|
|
556
|
+
*/
|
|
557
|
+
startProcessing() {
|
|
558
|
+
if (this.processingTimer) {
|
|
559
|
+
clearInterval(this.processingTimer);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Process queue every 100ms
|
|
563
|
+
this.processingTimer = setInterval(() => {
|
|
564
|
+
this.processQueue();
|
|
565
|
+
}, 100);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Stop processing
|
|
570
|
+
*/
|
|
571
|
+
stopProcessing() {
|
|
572
|
+
if (this.processingTimer) {
|
|
573
|
+
clearInterval(this.processingTimer);
|
|
574
|
+
this.processingTimer = null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (this.healthMonitoringTimer) {
|
|
578
|
+
clearInterval(this.healthMonitoringTimer);
|
|
579
|
+
this.healthMonitoringTimer = null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Persist queue to disk
|
|
585
|
+
*/
|
|
586
|
+
async persistQueue() {
|
|
587
|
+
if (!this.enablePersistence) return;
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
const queuePath = path.join(this.queueDir, 'queue.json');
|
|
591
|
+
const data = JSON.stringify(this.queue, null, 2);
|
|
592
|
+
await fs.writeFile(queuePath, data, 'utf8');
|
|
593
|
+
} catch (error) {
|
|
594
|
+
console.error('Failed to persist webhook queue:', error);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Load persisted events
|
|
600
|
+
*/
|
|
601
|
+
async loadPersistedEvents() {
|
|
602
|
+
if (!this.enablePersistence) return;
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
const queuePath = path.join(this.queueDir, 'queue.json');
|
|
606
|
+
const data = await fs.readFile(queuePath, 'utf8');
|
|
607
|
+
const events = JSON.parse(data);
|
|
608
|
+
|
|
609
|
+
if (Array.isArray(events)) {
|
|
610
|
+
this.queue = events;
|
|
611
|
+
this.emit('queueLoaded', events.length);
|
|
612
|
+
}
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (error.code !== 'ENOENT') {
|
|
615
|
+
console.error('Failed to load persisted webhook queue:', error);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Update average response time statistic
|
|
622
|
+
* @param {number} responseTime - Response time in milliseconds
|
|
623
|
+
*/
|
|
624
|
+
updateAverageResponseTime(responseTime) {
|
|
625
|
+
const currentAverage = this.stats.averageResponseTime;
|
|
626
|
+
const totalDeliveries = this.stats.successfulDeliveries;
|
|
627
|
+
|
|
628
|
+
if (totalDeliveries === 1) {
|
|
629
|
+
this.stats.averageResponseTime = responseTime;
|
|
630
|
+
} else {
|
|
631
|
+
this.stats.averageResponseTime =
|
|
632
|
+
((currentAverage * (totalDeliveries - 1)) + responseTime) / totalDeliveries;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Update statistics
|
|
638
|
+
*/
|
|
639
|
+
updateStats() {
|
|
640
|
+
let healthy = 0;
|
|
641
|
+
let unhealthy = 0;
|
|
642
|
+
|
|
643
|
+
for (const health of this.healthChecks.values()) {
|
|
644
|
+
if (health.status === 'healthy') healthy++;
|
|
645
|
+
else if (health.status === 'unhealthy') unhealthy++;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
this.stats.healthyUrls = healthy;
|
|
649
|
+
this.stats.unhealthyUrls = unhealthy;
|
|
650
|
+
this.stats.lastUpdated = Date.now();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Get health summary
|
|
655
|
+
* @returns {Object} Health summary
|
|
656
|
+
*/
|
|
657
|
+
getHealthSummary() {
|
|
658
|
+
const summary = {
|
|
659
|
+
totalUrls: this.webhookUrls.size,
|
|
660
|
+
healthyUrls: 0,
|
|
661
|
+
unhealthyUrls: 0,
|
|
662
|
+
unknownUrls: 0,
|
|
663
|
+
details: {}
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
for (const [url, health] of this.healthChecks) {
|
|
667
|
+
summary.details[url] = {
|
|
668
|
+
status: health.status,
|
|
669
|
+
consecutiveFailures: health.consecutiveFailures,
|
|
670
|
+
averageResponseTime: health.averageResponseTime,
|
|
671
|
+
lastCheck: health.lastCheck,
|
|
672
|
+
lastError: health.lastError
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
summary[health.status + 'Urls']++;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return summary;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Generate unique event ID
|
|
683
|
+
* @returns {string} Event ID
|
|
684
|
+
*/
|
|
685
|
+
generateEventId() {
|
|
686
|
+
return crypto.randomUUID();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Get comprehensive statistics
|
|
691
|
+
* @returns {Object} Statistics object
|
|
692
|
+
*/
|
|
693
|
+
getStats() {
|
|
694
|
+
return Object.assign({}, this.stats, {
|
|
695
|
+
queueSize: this.queue.length,
|
|
696
|
+
registeredUrls: this.webhookUrls.size,
|
|
697
|
+
failedUrlsCount: this.failedUrls.size,
|
|
698
|
+
processing: this.processing,
|
|
699
|
+
retryManagerStats: this.retryManager.getStats()
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Get failed URLs information
|
|
705
|
+
* @returns {Object} Failed URLs mapping
|
|
706
|
+
*/
|
|
707
|
+
getFailedUrls() {
|
|
708
|
+
return Object.fromEntries(this.failedUrls);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Clear failed URLs tracking
|
|
713
|
+
* @param {string} url - Specific URL to clear, or null for all
|
|
714
|
+
*/
|
|
715
|
+
clearFailedUrls(url = null) {
|
|
716
|
+
if (url) {
|
|
717
|
+
this.failedUrls.delete(url);
|
|
718
|
+
} else {
|
|
719
|
+
this.failedUrls.clear();
|
|
720
|
+
}
|
|
721
|
+
this.emit('failedUrlsCleared', url);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Cleanup resources
|
|
726
|
+
*/
|
|
727
|
+
destroy() {
|
|
728
|
+
// Stop timers
|
|
729
|
+
this.stopProcessing();
|
|
730
|
+
|
|
731
|
+
// Clear data
|
|
732
|
+
this.queue = [];
|
|
733
|
+
this.webhookUrls.clear();
|
|
734
|
+
this.healthChecks.clear();
|
|
735
|
+
this.responseTimes.clear();
|
|
736
|
+
this.failedUrls.clear();
|
|
737
|
+
|
|
738
|
+
// Remove event listeners
|
|
739
|
+
this.removeAllListeners();
|
|
740
|
+
|
|
741
|
+
this.emit('destroyed');
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export default WebhookDispatcher;
|