crawlforge-mcp-server 4.9.0 → 5.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.
Files changed (60) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +19 -3
  3. package/package.json +10 -12
  4. package/server.js +315 -214
  5. package/src/core/ActionExecutor.js +117 -33
  6. package/src/core/AgentOrchestrator.js +8 -2
  7. package/src/core/AuthManager.js +51 -17
  8. package/src/core/ChangeTracker.js +26 -10
  9. package/src/core/JobManager.js +9 -1
  10. package/src/core/LocalizationManager.js +19 -6
  11. package/src/core/ResearchOrchestrator.js +173 -35
  12. package/src/core/SnapshotManager.js +162 -165
  13. package/src/core/StealthBrowserManager.js +25 -3
  14. package/src/core/WebhookDispatcher.js +19 -14
  15. package/src/core/analysis/ContentAnalyzer.js +52 -7
  16. package/src/core/crawlers/BFSCrawler.js +27 -3
  17. package/src/core/processing/BrowserProcessor.js +19 -1
  18. package/src/core/processing/PDFProcessor.js +129 -65
  19. package/src/core/queue/QueueManager.js +3 -2
  20. package/src/schemas/toolOutputSchemas.js +269 -0
  21. package/src/server/auth/oauth.js +37 -7
  22. package/src/server/specHygiene.js +192 -0
  23. package/src/server/taskSupport.js +233 -0
  24. package/src/server/toolFilter.js +98 -0
  25. package/src/server/transports/streamableHttp.js +148 -11
  26. package/src/server/withAuth.js +11 -4
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
  28. package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
  29. package/src/tools/advanced/batchScrape/index.js +128 -27
  30. package/src/tools/advanced/batchScrape/worker.js +55 -5
  31. package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
  32. package/src/tools/basic/_fetch.js +125 -70
  33. package/src/tools/basic/extractLinks.js +14 -12
  34. package/src/tools/basic/scrapeStructured.js +21 -4
  35. package/src/tools/crawl/crawlDeep.js +110 -48
  36. package/src/tools/crawl/mapSite.js +25 -6
  37. package/src/tools/extract/_fetchAndParse.js +98 -1
  38. package/src/tools/extract/extractContent.js +7 -4
  39. package/src/tools/extract/extractStructured.js +125 -84
  40. package/src/tools/extract/extractWithLlm.js +10 -2
  41. package/src/tools/extract/processDocument.js +54 -6
  42. package/src/tools/extract/summarizeContent.js +7 -1
  43. package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
  44. package/src/tools/research/deepResearch.js +51 -31
  45. package/src/tools/scrape/_brandingExtractor.js +49 -11
  46. package/src/tools/scrape/unifiedScrape.js +27 -17
  47. package/src/tools/search/providers/searxng.js +5 -1
  48. package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
  49. package/src/tools/search/ranking/ResultRanker.js +17 -2
  50. package/src/tools/search/searchWeb.js +31 -14
  51. package/src/tools/search/serpRank.js +23 -0
  52. package/src/tools/templates/TemplateRegistry.js +7 -1
  53. package/src/tools/tracking/trackChanges/index.js +87 -26
  54. package/src/tools/tracking/trackChanges/schema.js +2 -2
  55. package/src/utils/CircuitBreaker.js +11 -9
  56. package/src/utils/contentUtils.js +66 -53
  57. package/src/utils/secretMask.js +1 -1
  58. package/src/utils/sitemapParser.js +11 -9
  59. package/src/utils/ssrfGuard.js +212 -40
  60. package/src/utils/urlNormalizer.js +2 -2
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { promises as fs } from 'fs';
8
8
  import path from 'path';
9
+ import os from 'os';
9
10
  import { createHash } from 'crypto';
10
11
  import { gzip, gunzip } from 'zlib';
11
12
  import { promisify } from 'util';
@@ -77,10 +78,16 @@ export class SnapshotManager extends EventEmitter {
77
78
  constructor(options = {}) {
78
79
  super();
79
80
 
81
+ // Default storage location is rooted in a stable, non-cwd-dependent base
82
+ // (~/.crawlforge — same convention as ~/.crawlforge/config.json) rather
83
+ // than process.cwd(), since MCP clients (e.g. Claude Desktop) may launch
84
+ // the server with a cwd the process cannot write to (e.g. '/').
85
+ const defaultBaseDir = path.join(os.homedir(), '.crawlforge', 'snapshots');
86
+
80
87
  this.options = {
81
- storageDir: options.storageDir || './snapshots',
82
- metadataDir: options.metadataDir || './snapshots/metadata',
83
- tempDir: options.tempDir || './snapshots/temp',
88
+ storageDir: options.storageDir || defaultBaseDir,
89
+ metadataDir: options.metadataDir || path.join(defaultBaseDir, 'metadata'),
90
+ tempDir: options.tempDir || path.join(defaultBaseDir, 'temp'),
84
91
  enableCompression: options.enableCompression !== false,
85
92
  enableDeltaStorage: options.enableDeltaStorage !== false,
86
93
  enableEncryption: options.enableEncryption || false,
@@ -88,6 +95,12 @@ export class SnapshotManager extends EventEmitter {
88
95
  maxConcurrentOperations: options.maxConcurrentOperations || 10,
89
96
  cacheEnabled: options.cacheEnabled !== false,
90
97
  cacheSize: options.cacheSize || 100,
98
+ // metadataCache holds one (now content-stripped) entry per stored
99
+ // snapshot and doubles as the in-memory index querySnapshots/
100
+ // cleanupSnapshots scan — so it can't be capped as tightly as
101
+ // snapshotCache without breaking query/retention correctness. This is
102
+ // a safety ceiling against truly unbounded growth, not a hot-cache size.
103
+ metadataCacheSize: options.metadataCacheSize || 10000,
91
104
  ...options
92
105
  };
93
106
 
@@ -123,37 +136,48 @@ export class SnapshotManager extends EventEmitter {
123
136
 
124
137
  // Cleanup timer
125
138
  this.cleanupTimer = null;
126
-
127
- this.initialize();
139
+
140
+ // Not started here — construction must stay synchronous and side-effect
141
+ // free (no directories touched, nothing to leave unhandled-rejected).
142
+ // ensureInitialized() lazily creates and memoizes this on first real use.
143
+ this._initPromise = null;
128
144
  }
129
-
145
+
146
+ /**
147
+ * Lazily runs (and memoizes) initialize(), awaited by every public
148
+ * storage-touching method below. Replaces the old pattern of firing
149
+ * initialize() unawaited from the constructor, which could turn a
150
+ * directory-creation failure into an opaque unhandled rejection (emit()ing
151
+ * 'error' before any caller has had a chance to attach a listener).
152
+ */
153
+ async ensureInitialized() {
154
+ if (!this._initPromise) {
155
+ this._initPromise = this.initialize();
156
+ }
157
+ return this._initPromise;
158
+ }
159
+
130
160
  async initialize() {
131
- try {
132
- // Create storage directories
133
- await this.createDirectories();
134
-
135
- // Load existing snapshot metadata
136
- await this.loadMetadata();
137
-
138
- // Start cleanup timer if enabled
139
- if (this.retentionPolicy.autoCleanup) {
140
- this.startCleanupTimer();
141
- }
142
-
143
- // Initialize cache
144
- if (this.options.cacheEnabled) {
145
- await this.initializeCache();
146
- }
147
-
148
- this.emit('initialized', {
149
- totalSnapshots: this.stats.totalSnapshots,
150
- storageSize: this.stats.totalStorageSize
151
- });
152
-
153
- } catch (error) {
154
- this.emit('error', { operation: 'initialize', error: error.message });
155
- throw error;
161
+ // Create storage directories
162
+ await this.createDirectories();
163
+
164
+ // Load existing snapshot metadata
165
+ await this.loadMetadata();
166
+
167
+ // Start cleanup timer if enabled
168
+ if (this.retentionPolicy.autoCleanup) {
169
+ this.startCleanupTimer();
170
+ }
171
+
172
+ // Initialize cache
173
+ if (this.options.cacheEnabled) {
174
+ await this.initializeCache();
156
175
  }
176
+
177
+ this.emit('initialized', {
178
+ totalSnapshots: this.stats.totalSnapshots,
179
+ storageSize: this.stats.totalStorageSize
180
+ });
157
181
  }
158
182
 
159
183
  /**
@@ -168,6 +192,8 @@ export class SnapshotManager extends EventEmitter {
168
192
  const operationId = this.generateOperationId();
169
193
 
170
194
  try {
195
+ await this.ensureInitialized();
196
+
171
197
  // Validate content is not null/undefined
172
198
  if (content === null || content === undefined) {
173
199
  throw new Error('Content cannot be null or undefined');
@@ -182,13 +208,7 @@ export class SnapshotManager extends EventEmitter {
182
208
 
183
209
  const snapshotId = this.generateSnapshotId(url, metadata.timestamp || Date.now());
184
210
  const contentHash = this.hashContent(content);
185
-
186
- // Check for similar existing snapshots for delta storage
187
- let deltaInfo = null;
188
- if (this.retentionPolicy.enableDeltaStorage) {
189
- deltaInfo = await this.findSimilarSnapshot(url, contentHash, content);
190
- }
191
-
211
+
192
212
  // Prepare snapshot data
193
213
  const snapshot = {
194
214
  id: snapshotId,
@@ -217,28 +237,16 @@ export class SnapshotManager extends EventEmitter {
217
237
 
218
238
  let finalContent = content;
219
239
  let isCompressed = false;
220
- let isDelta = false;
221
-
222
- // Apply delta storage if similar snapshot found (skip if base content is null, e.g. exact hash match)
223
- if (deltaInfo && deltaInfo.content && deltaInfo.similarity > this.retentionPolicy.deltaThreshold) {
224
- const deltaData = this.createDelta(deltaInfo.content, content);
225
- if (deltaData.length < content.length * 0.7) { // Only use delta if it's significantly smaller
226
- finalContent = deltaData;
227
- isDelta = true;
228
-
229
- snapshot.delta = {
230
- enabled: true,
231
- baseSnapshotId: deltaInfo.snapshotId,
232
- deltaData: deltaData,
233
- deltaSize: deltaData.length
234
- };
235
-
236
- this.stats.deltaSnapshots++;
237
- }
238
- }
239
-
240
- // Apply compression if enabled and above threshold
241
- if (this.options.enableCompression &&
240
+ // Delta storage is intentionally disabled: createDelta() never stored real
241
+ // diff data, so retrieval silently returned the wrong (base) content.
242
+ // Every snapshot is now persisted in full (optionally gzip-compressed below).
243
+ const isDelta = false;
244
+
245
+ // Apply compression if enabled (per-call `options.enableCompression`
246
+ // overrides the instance default when explicitly provided) and above
247
+ // threshold.
248
+ const compressionEnabled = options.enableCompression ?? this.options.enableCompression;
249
+ if (compressionEnabled &&
242
250
  finalContent.length > this.retentionPolicy.compressionThreshold) {
243
251
 
244
252
  const compressed = await gzipAsync(finalContent);
@@ -316,12 +324,14 @@ export class SnapshotManager extends EventEmitter {
316
324
  */
317
325
  async retrieveSnapshot(snapshotId, options = {}) {
318
326
  const operationId = this.generateOperationId();
319
-
327
+
320
328
  try {
321
- this.activeOperations.set(operationId, {
322
- type: 'retrieve',
323
- snapshotId,
324
- startTime: Date.now()
329
+ await this.ensureInitialized();
330
+
331
+ this.activeOperations.set(operationId, {
332
+ type: 'retrieve',
333
+ snapshotId,
334
+ startTime: Date.now()
325
335
  });
326
336
 
327
337
  // Check cache first
@@ -394,6 +404,8 @@ export class SnapshotManager extends EventEmitter {
394
404
  */
395
405
  async querySnapshots(query = {}) {
396
406
  try {
407
+ await this.ensureInitialized();
408
+
397
409
  const validated = QuerySchema.parse(query);
398
410
 
399
411
  // Load all metadata that matches URL filter
@@ -531,6 +543,8 @@ export class SnapshotManager extends EventEmitter {
531
543
  };
532
544
 
533
545
  try {
546
+ await this.ensureInitialized();
547
+
534
548
  for (const snapshotId of ids) {
535
549
  try {
536
550
  const metadata = await this.loadSnapshotMetadata(snapshotId);
@@ -579,8 +593,10 @@ export class SnapshotManager extends EventEmitter {
579
593
  */
580
594
  async cleanupSnapshots() {
581
595
  const startTime = Date.now();
582
-
596
+
583
597
  try {
598
+ await this.ensureInitialized();
599
+
584
600
  const cleanupResults = {
585
601
  deletedCount: 0,
586
602
  freedSpace: 0,
@@ -698,29 +714,60 @@ export class SnapshotManager extends EventEmitter {
698
714
  await fs.unlink(filePath);
699
715
  }
700
716
 
701
- async storeMetadata(snapshotId, metadata) {
717
+ /**
718
+ * Strips the full page `content` (and any legacy `delta.deltaData`) from a
719
+ * snapshot before it is persisted to a .meta file or cached in
720
+ * metadataCache — content lives only in the .snap file. Idempotent: safe
721
+ * to call on an object that's already stripped.
722
+ */
723
+ _stripHeavyFields(snapshot) {
724
+ const { content, ...rest } = snapshot;
725
+ if (rest.delta && rest.delta.deltaData !== undefined) {
726
+ const { deltaData, ...deltaRest } = rest.delta;
727
+ rest.delta = deltaRest;
728
+ }
729
+ return rest;
730
+ }
731
+
732
+ /**
733
+ * Bounded insert into metadataCache (same LRU-eviction shape as
734
+ * updateCache()/snapshotCache, sized separately via metadataCacheSize —
735
+ * see the constructor comment for why the two caches need different sizes).
736
+ */
737
+ _cacheMetadata(snapshotId, metadata) {
738
+ if (!this.metadataCache.has(snapshotId) && this.metadataCache.size >= this.options.metadataCacheSize) {
739
+ const oldestKey = this.metadataCache.keys().next().value;
740
+ this.metadataCache.delete(oldestKey);
741
+ }
742
+ this.metadataCache.set(snapshotId, metadata);
743
+ }
744
+
745
+ async storeMetadata(snapshotId, snapshot) {
702
746
  const filePath = path.join(this.options.metadataDir, `${snapshotId}.meta`);
747
+ const metadata = this._stripHeavyFields(snapshot);
703
748
  await fs.writeFile(filePath, JSON.stringify(metadata, null, 2), 'utf8');
704
-
749
+
705
750
  // Update in-memory cache
706
- this.metadataCache.set(snapshotId, metadata);
751
+ this._cacheMetadata(snapshotId, metadata);
707
752
  }
708
-
753
+
709
754
  async loadSnapshotMetadata(snapshotId) {
710
755
  // Check cache first
711
756
  if (this.metadataCache.has(snapshotId)) {
712
757
  return this.metadataCache.get(snapshotId);
713
758
  }
714
-
759
+
715
760
  // Load from disk
716
761
  try {
717
762
  const filePath = path.join(this.options.metadataDir, `${snapshotId}.meta`);
718
- const content = await fs.readFile(filePath, 'utf8');
719
- const metadata = JSON.parse(content);
720
-
763
+ const raw = await fs.readFile(filePath, 'utf8');
764
+ // Defensive strip: .meta files written before this fix may still embed
765
+ // full page content — never let a legacy fat file re-pin it in memory.
766
+ const metadata = this._stripHeavyFields(JSON.parse(raw));
767
+
721
768
  // Cache it
722
- this.metadataCache.set(snapshotId, metadata);
723
-
769
+ this._cacheMetadata(snapshotId, metadata);
770
+
724
771
  return metadata;
725
772
  } catch (error) {
726
773
  return null;
@@ -780,85 +827,16 @@ export class SnapshotManager extends EventEmitter {
780
827
  return hash.digest('hex');
781
828
  }
782
829
 
783
- async findSimilarSnapshot(url, contentHash, content) {
784
- // Find recent snapshots for the same URL
785
- const recentSnapshots = await this.querySnapshots({
786
- url,
787
- limit: 10,
788
- sortBy: 'timestamp',
789
- sortOrder: 'desc',
790
- includeContent: false
791
- });
792
-
793
- for (const snapshot of recentSnapshots.snapshots) {
794
- if (snapshot.metadata.contentHash === contentHash) {
795
- // Exact match
796
- return {
797
- snapshotId: snapshot.id,
798
- similarity: 1.0,
799
- content: null
800
- };
801
- }
802
-
803
- // Load content for similarity comparison
804
- const fullSnapshot = await this.retrieveSnapshot(snapshot.id, { includeContent: true });
805
- const similarity = this.calculateContentSimilarity(content, fullSnapshot.content);
806
-
807
- if (similarity > this.retentionPolicy.deltaThreshold) {
808
- return {
809
- snapshotId: snapshot.id,
810
- similarity,
811
- content: fullSnapshot.content
812
- };
813
- }
814
- }
815
-
816
- return null;
817
- }
818
-
819
- calculateContentSimilarity(content1, content2) {
820
- // Simple similarity calculation based on content length difference
821
- // In production, you might want to use more sophisticated algorithms
822
- const len1 = content1.length;
823
- const len2 = content2.length;
824
-
825
- if (len1 === 0 && len2 === 0) return 1.0;
826
- if (len1 === 0 || len2 === 0) return 0.0;
827
-
828
- const lengthSimilarity = 1 - Math.abs(len1 - len2) / Math.max(len1, len2);
829
-
830
- // Additional similarity checks can be added here
831
- // For example, using diff algorithms or content hashing
832
-
833
- return lengthSimilarity;
834
- }
835
-
836
- createDelta(baseContent, currentContent) {
837
- // Simple delta implementation - in production, consider using proper diff libraries
838
- // This is a placeholder that would create a compressed diff
839
- const deltaObject = {
840
- type: 'diff',
841
- base: baseContent.length,
842
- current: currentContent.length,
843
- // In a real implementation, you'd store the actual diff data
844
- operations: [] // diff operations would go here
845
- };
846
-
847
- return JSON.stringify(deltaObject);
848
- }
849
-
830
+ /**
831
+ * Legacy delta snapshots (written before delta storage was disabled — see
832
+ * storeSnapshot) stored only a byte-count stub with no real diff data, so
833
+ * their original content is unrecoverable. Fail loudly instead of silently
834
+ * returning the base snapshot's (wrong) content.
835
+ */
850
836
  applyDelta(baseContent, deltaData) {
851
- try {
852
- const delta = JSON.parse(deltaData);
853
-
854
- // In a real implementation, you'd apply the diff operations
855
- // For now, return the base content as a fallback
856
- return baseContent;
857
- } catch (error) {
858
- throw new Error(`Failed to apply delta: ${error.message}`);
859
- }
837
+ throw new Error('Cannot reconstruct legacy delta snapshot: no diff data was stored for it. Re-fetch or re-create this snapshot from the source.');
860
838
  }
861
-
839
+
862
840
  async calculateChangeMetrics(previousSnapshot, currentSnapshot) {
863
841
  // Calculate various change metrics between snapshots
864
842
  const metrics = {
@@ -908,16 +886,17 @@ export class SnapshotManager extends EventEmitter {
908
886
  }
909
887
 
910
888
  async initializeCache() {
911
- // Pre-load recent snapshots into cache
912
- const recentSnapshots = await this.querySnapshots({
913
- limit: Math.min(this.options.cacheSize, 50),
914
- sortBy: 'timestamp',
915
- sortOrder: 'desc',
916
- includeContent: false
917
- });
918
-
919
- for (const snapshot of recentSnapshots.snapshots) {
920
- this.metadataCache.set(snapshot.id, snapshot);
889
+ // Reads metadataCache directly (already fully populated by loadMetadata(),
890
+ // which runs earlier in initialize()) rather than going through
891
+ // querySnapshots() — querySnapshots() now awaits ensureInitialized(),
892
+ // which would deadlock against the in-flight initialize() call this
893
+ // method is itself called from.
894
+ const recent = Array.from(this.metadataCache.values())
895
+ .sort((a, b) => b.metadata.timestamp - a.metadata.timestamp)
896
+ .slice(0, Math.min(this.options.cacheSize, 50));
897
+
898
+ for (const snapshot of recent) {
899
+ this._cacheMetadata(snapshot.id, snapshot);
921
900
  }
922
901
  }
923
902
 
@@ -976,6 +955,12 @@ export class SnapshotManager extends EventEmitter {
976
955
  clearInterval(this.cleanupTimer);
977
956
  }
978
957
 
958
+ // .unref() so this timer never blocks process exit on its own — matches
959
+ // CacheManager's cleanupTimer/monitoringTimer. Without it, any process
960
+ // that merely imports a module holding a live SnapshotManager (e.g. the
961
+ // trackChangesTool singleton exported by trackChanges/index.js, which is
962
+ // never explicitly shut down) hangs forever, since a real server always
963
+ // has other things keeping it alive regardless.
979
964
  this.cleanupTimer = setInterval(async () => {
980
965
  try {
981
966
  await this.cleanupSnapshots();
@@ -983,8 +968,9 @@ export class SnapshotManager extends EventEmitter {
983
968
  this.emit('error', { operation: 'scheduledCleanup', error: error.message });
984
969
  }
985
970
  }, this.retentionPolicy.cleanupInterval);
971
+ if (typeof this.cleanupTimer.unref === 'function') this.cleanupTimer.unref();
986
972
  }
987
-
973
+
988
974
  stopCleanupTimer() {
989
975
  if (this.cleanupTimer) {
990
976
  clearInterval(this.cleanupTimer);
@@ -1027,8 +1013,19 @@ export class SnapshotManager extends EventEmitter {
1027
1013
  }
1028
1014
 
1029
1015
  async shutdown() {
1016
+ // initialize() is lazy (see ensureInitialized()) and only starts the
1017
+ // cleanup timer partway through (after the async createDirectories/
1018
+ // loadMetadata steps). If a caller triggered initialization (via any
1019
+ // public method) and then immediately calls shutdown(), we must wait for
1020
+ // that in-flight init to finish before stopping the timer — otherwise it
1021
+ // could start AFTER stopCleanupTimer() already ran, leaking a live timer.
1022
+ // If nothing ever triggered initialization, _initPromise is still null
1023
+ // and there's nothing to wait for (no directories/timer were created).
1024
+ if (this._initPromise) {
1025
+ await this._initPromise.catch(() => {});
1026
+ }
1030
1027
  this.stopCleanupTimer();
1031
-
1028
+
1032
1029
  // Wait for active operations to complete
1033
1030
  const maxWaitTime = 30000; // 30 seconds
1034
1031
  const startTime = Date.now();
@@ -251,6 +251,26 @@ export class StealthBrowserManager {
251
251
  return this.browser;
252
252
  }
253
253
 
254
+ // Guard against concurrent callers both seeing this.browser === null and
255
+ // both launching a Chromium/Camoufox process — the second assignment to
256
+ // this.browser would overwrite the first, orphaning it. Callers that
257
+ // arrive while a launch is already in flight await the same promise.
258
+ if (this._launchPromise) {
259
+ return this._launchPromise;
260
+ }
261
+ this._launchPromise = this._doLaunchStealthBrowser(validatedConfig);
262
+ try {
263
+ return await this._launchPromise;
264
+ } finally {
265
+ this._launchPromise = null;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Actual browser launch, guarded by launchStealthBrowser's in-flight
271
+ * promise so only one launch can be in progress at a time.
272
+ */
273
+ async _doLaunchStealthBrowser(validatedConfig) {
254
274
  // C2: delegate to CamoufoxAdapter when engine === 'camoufox'
255
275
  if (validatedConfig.engine === 'camoufox') {
256
276
  const adapter = new CamoufoxAdapter();
@@ -2015,8 +2035,10 @@ export class CamoufoxAdapter extends BrowserEngine {
2015
2035
  // CRAWLFORGE_BROWSER_BACKEND=local → LocalPlaywrightBackend (default, current behavior)
2016
2036
  // CRAWLFORGE_BROWSER_BACKEND=browserbase → BrowserBaseBackend via CDP
2017
2037
  //
2018
- // Graceful fallback: if BrowserBaseBackend fails to connect (no API key, network error,
2019
- // quota exceeded), StealthBrowserManager.getBrowserBackend() falls back to local.
2038
+ // Graceful fallback: resolveBrowserBackend() below falls back to LocalPlaywrightBackend
2039
+ // when CRAWLFORGE_BROWSER_BACKEND=browserbase but BROWSERBASE_API_KEY is unset.
2040
+ // NOTE: resolveBrowserBackend() is exported but not currently called anywhere in
2041
+ // StealthBrowserManager's own launch path — this backend is defined but unwired.
2020
2042
 
2021
2043
  /**
2022
2044
  * BrowserBackend interface (D3.4).
@@ -2101,7 +2123,7 @@ export class BrowserBaseBackend extends BrowserBackend {
2101
2123
 
2102
2124
  if (!sessionRes.ok) {
2103
2125
  const err = await sessionRes.text().catch(() => '');
2104
- throw new Error();
2126
+ throw new Error(`BrowserBase session create failed: HTTP ${sessionRes.status} ${err}`);
2105
2127
  }
2106
2128
 
2107
2129
  const session = await sessionRes.json();
@@ -8,6 +8,7 @@ import { EventEmitter } from 'events';
8
8
  import { promises as fs } from 'fs';
9
9
  import path from 'path';
10
10
  import RetryManager from '../utils/RetryManager.js';
11
+ import { safeFetch } from '../utils/ssrfGuard.js';
11
12
 
12
13
  export class WebhookDispatcher extends EventEmitter {
13
14
  constructor(options = {}) {
@@ -378,12 +379,6 @@ export class WebhookDispatcher extends EventEmitter {
378
379
  headers['X-Webhook-ID'] = event.id;
379
380
  headers['X-Webhook-Timestamp'] = event.timestamp.toString();
380
381
 
381
- // Add HMAC signature if secret provided
382
- if (config.signingSecret) {
383
- const signature = this.generateSignature(event.payload, config.signingSecret);
384
- headers['X-Webhook-Signature'] = signature;
385
- }
386
-
387
382
  // Create request body
388
383
  const body = JSON.stringify({
389
384
  event: event.type,
@@ -393,17 +388,28 @@ export class WebhookDispatcher extends EventEmitter {
393
388
  metadata: event.metadata
394
389
  });
395
390
 
391
+ // Add HMAC signature if secret provided — sign the exact body being sent
392
+ // so receivers verifying over the raw request body get a matching digest.
393
+ if (config.signingSecret) {
394
+ const signature = this.generateSignature(body, config.signingSecret);
395
+ headers['X-Webhook-Signature'] = signature;
396
+ }
397
+
396
398
  // Execute with retry logic
397
399
  const result = await this.retryManager.execute(async () => {
398
- const response = await fetch(event.url, {
400
+ const response = await safeFetch(event.url, {
399
401
  method: 'POST',
400
402
  headers,
401
403
  body,
402
- timeout: config.timeout
404
+ // fetch/undici has no `timeout` RequestInit option — it was silently
405
+ // ignored, so a hung endpoint stalled the whole delivery queue.
406
+ signal: AbortSignal.timeout(config.timeout)
403
407
  });
404
408
 
405
409
  if (!response.ok) {
406
- throw new Error('HTTP ' + response.status + ': ' + response.statusText);
410
+ const httpError = new Error('HTTP ' + response.status + ': ' + response.statusText);
411
+ httpError.response = { status: response.status };
412
+ throw httpError;
407
413
  }
408
414
 
409
415
  return response;
@@ -426,12 +432,11 @@ export class WebhookDispatcher extends EventEmitter {
426
432
 
427
433
  /**
428
434
  * Generate HMAC signature for webhook security
429
- * @param {Object} payload - Webhook payload
435
+ * @param {string} body - Serialized request body (exact string being sent)
430
436
  * @param {string} secret - Signing secret
431
437
  * @returns {string} HMAC signature
432
438
  */
433
- generateSignature(payload, secret) {
434
- const body = JSON.stringify(payload);
439
+ generateSignature(body, secret) {
435
440
  const hmac = crypto.createHmac('sha256', secret);
436
441
  hmac.update(body);
437
442
  return 'sha256=' + hmac.digest('hex');
@@ -542,9 +547,9 @@ export class WebhookDispatcher extends EventEmitter {
542
547
 
543
548
  try {
544
549
  const startTime = Date.now();
545
- const response = await fetch(url, {
550
+ const response = await safeFetch(url, {
546
551
  method: 'HEAD',
547
- timeout: config.timeout / 2, // Use half timeout for health checks
552
+ signal: AbortSignal.timeout(config.timeout / 2), // Use half timeout for health checks
548
553
  headers: {
549
554
  'User-Agent': 'WebhookDispatcher-HealthCheck/1.0'
550
555
  }