crawlforge-mcp-server 4.10.0 → 5.0.1

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 (61) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +19 -3
  3. package/package.json +10 -12
  4. package/server.js +298 -212
  5. package/src/cli/commands/init.js +11 -5
  6. package/src/cli/commands/stealth.js +3 -2
  7. package/src/core/ActionExecutor.js +117 -33
  8. package/src/core/AgentOrchestrator.js +8 -2
  9. package/src/core/AuthManager.js +51 -17
  10. package/src/core/ChangeTracker.js +26 -10
  11. package/src/core/JobManager.js +9 -1
  12. package/src/core/LocalizationManager.js +19 -6
  13. package/src/core/MonitorScheduler.js +13 -4
  14. package/src/core/ResearchOrchestrator.js +173 -35
  15. package/src/core/SnapshotManager.js +167 -165
  16. package/src/core/StealthBrowserManager.js +25 -3
  17. package/src/core/WebhookDispatcher.js +19 -14
  18. package/src/core/analysis/ContentAnalyzer.js +52 -7
  19. package/src/core/crawlers/BFSCrawler.js +46 -15
  20. package/src/core/processing/BrowserProcessor.js +19 -1
  21. package/src/core/processing/PDFProcessor.js +129 -65
  22. package/src/core/queue/QueueManager.js +3 -2
  23. package/src/schemas/toolOutputSchemas.js +269 -0
  24. package/src/server/auth/oauth.js +37 -7
  25. package/src/server/specHygiene.js +192 -0
  26. package/src/server/taskSupport.js +233 -0
  27. package/src/server/toolFilter.js +98 -0
  28. package/src/server/transports/streamableHttp.js +148 -11
  29. package/src/server/withAuth.js +11 -4
  30. package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
  31. package/src/tools/advanced/batchScrape/index.js +128 -27
  32. package/src/tools/advanced/batchScrape/worker.js +55 -5
  33. package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
  34. package/src/tools/basic/_fetch.js +125 -70
  35. package/src/tools/basic/extractLinks.js +14 -12
  36. package/src/tools/basic/scrapeStructured.js +21 -4
  37. package/src/tools/crawl/crawlDeep.js +110 -48
  38. package/src/tools/crawl/mapSite.js +25 -6
  39. package/src/tools/extract/_fetchAndParse.js +98 -1
  40. package/src/tools/extract/extractContent.js +7 -4
  41. package/src/tools/extract/extractStructured.js +125 -84
  42. package/src/tools/extract/extractWithLlm.js +10 -2
  43. package/src/tools/extract/processDocument.js +54 -6
  44. package/src/tools/extract/summarizeContent.js +7 -1
  45. package/src/tools/llmstxt/generateLLMsTxt.js +11 -6
  46. package/src/tools/research/deepResearch.js +51 -31
  47. package/src/tools/scrape/_brandingExtractor.js +49 -11
  48. package/src/tools/scrape/unifiedScrape.js +27 -17
  49. package/src/tools/search/providers/searxng.js +5 -1
  50. package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
  51. package/src/tools/search/ranking/ResultRanker.js +17 -2
  52. package/src/tools/search/searchWeb.js +31 -14
  53. package/src/tools/templates/TemplateRegistry.js +7 -1
  54. package/src/tools/tracking/trackChanges/index.js +123 -29
  55. package/src/tools/tracking/trackChanges/schema.js +2 -2
  56. package/src/utils/CircuitBreaker.js +11 -9
  57. package/src/utils/contentUtils.js +66 -53
  58. package/src/utils/secretMask.js +1 -1
  59. package/src/utils/sitemapParser.js +11 -9
  60. package/src/utils/ssrfGuard.js +212 -40
  61. 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,21 @@ 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
+
87
+ // metadata/temp must live under the EFFECTIVE storage dir: anchoring them
88
+ // to defaultBaseDir when a custom storageDir is passed split content from
89
+ // metadata and shared one metadata dir across all custom-dir instances.
90
+ const baseDir = options.storageDir || defaultBaseDir;
91
+
80
92
  this.options = {
81
- storageDir: options.storageDir || './snapshots',
82
- metadataDir: options.metadataDir || './snapshots/metadata',
83
- tempDir: options.tempDir || './snapshots/temp',
93
+ storageDir: baseDir,
94
+ metadataDir: options.metadataDir || path.join(baseDir, 'metadata'),
95
+ tempDir: options.tempDir || path.join(baseDir, 'temp'),
84
96
  enableCompression: options.enableCompression !== false,
85
97
  enableDeltaStorage: options.enableDeltaStorage !== false,
86
98
  enableEncryption: options.enableEncryption || false,
@@ -88,6 +100,12 @@ export class SnapshotManager extends EventEmitter {
88
100
  maxConcurrentOperations: options.maxConcurrentOperations || 10,
89
101
  cacheEnabled: options.cacheEnabled !== false,
90
102
  cacheSize: options.cacheSize || 100,
103
+ // metadataCache holds one (now content-stripped) entry per stored
104
+ // snapshot and doubles as the in-memory index querySnapshots/
105
+ // cleanupSnapshots scan — so it can't be capped as tightly as
106
+ // snapshotCache without breaking query/retention correctness. This is
107
+ // a safety ceiling against truly unbounded growth, not a hot-cache size.
108
+ metadataCacheSize: options.metadataCacheSize || 10000,
91
109
  ...options
92
110
  };
93
111
 
@@ -123,37 +141,48 @@ export class SnapshotManager extends EventEmitter {
123
141
 
124
142
  // Cleanup timer
125
143
  this.cleanupTimer = null;
126
-
127
- this.initialize();
144
+
145
+ // Not started here — construction must stay synchronous and side-effect
146
+ // free (no directories touched, nothing to leave unhandled-rejected).
147
+ // ensureInitialized() lazily creates and memoizes this on first real use.
148
+ this._initPromise = null;
128
149
  }
129
-
150
+
151
+ /**
152
+ * Lazily runs (and memoizes) initialize(), awaited by every public
153
+ * storage-touching method below. Replaces the old pattern of firing
154
+ * initialize() unawaited from the constructor, which could turn a
155
+ * directory-creation failure into an opaque unhandled rejection (emit()ing
156
+ * 'error' before any caller has had a chance to attach a listener).
157
+ */
158
+ async ensureInitialized() {
159
+ if (!this._initPromise) {
160
+ this._initPromise = this.initialize();
161
+ }
162
+ return this._initPromise;
163
+ }
164
+
130
165
  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;
166
+ // Create storage directories
167
+ await this.createDirectories();
168
+
169
+ // Load existing snapshot metadata
170
+ await this.loadMetadata();
171
+
172
+ // Start cleanup timer if enabled
173
+ if (this.retentionPolicy.autoCleanup) {
174
+ this.startCleanupTimer();
156
175
  }
176
+
177
+ // Initialize cache
178
+ if (this.options.cacheEnabled) {
179
+ await this.initializeCache();
180
+ }
181
+
182
+ this.emit('initialized', {
183
+ totalSnapshots: this.stats.totalSnapshots,
184
+ storageSize: this.stats.totalStorageSize
185
+ });
157
186
  }
158
187
 
159
188
  /**
@@ -168,6 +197,8 @@ export class SnapshotManager extends EventEmitter {
168
197
  const operationId = this.generateOperationId();
169
198
 
170
199
  try {
200
+ await this.ensureInitialized();
201
+
171
202
  // Validate content is not null/undefined
172
203
  if (content === null || content === undefined) {
173
204
  throw new Error('Content cannot be null or undefined');
@@ -182,13 +213,7 @@ export class SnapshotManager extends EventEmitter {
182
213
 
183
214
  const snapshotId = this.generateSnapshotId(url, metadata.timestamp || Date.now());
184
215
  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
-
216
+
192
217
  // Prepare snapshot data
193
218
  const snapshot = {
194
219
  id: snapshotId,
@@ -217,28 +242,16 @@ export class SnapshotManager extends EventEmitter {
217
242
 
218
243
  let finalContent = content;
219
244
  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 &&
245
+ // Delta storage is intentionally disabled: createDelta() never stored real
246
+ // diff data, so retrieval silently returned the wrong (base) content.
247
+ // Every snapshot is now persisted in full (optionally gzip-compressed below).
248
+ const isDelta = false;
249
+
250
+ // Apply compression if enabled (per-call `options.enableCompression`
251
+ // overrides the instance default when explicitly provided) and above
252
+ // threshold.
253
+ const compressionEnabled = options.enableCompression ?? this.options.enableCompression;
254
+ if (compressionEnabled &&
242
255
  finalContent.length > this.retentionPolicy.compressionThreshold) {
243
256
 
244
257
  const compressed = await gzipAsync(finalContent);
@@ -316,12 +329,14 @@ export class SnapshotManager extends EventEmitter {
316
329
  */
317
330
  async retrieveSnapshot(snapshotId, options = {}) {
318
331
  const operationId = this.generateOperationId();
319
-
332
+
320
333
  try {
321
- this.activeOperations.set(operationId, {
322
- type: 'retrieve',
323
- snapshotId,
324
- startTime: Date.now()
334
+ await this.ensureInitialized();
335
+
336
+ this.activeOperations.set(operationId, {
337
+ type: 'retrieve',
338
+ snapshotId,
339
+ startTime: Date.now()
325
340
  });
326
341
 
327
342
  // Check cache first
@@ -394,6 +409,8 @@ export class SnapshotManager extends EventEmitter {
394
409
  */
395
410
  async querySnapshots(query = {}) {
396
411
  try {
412
+ await this.ensureInitialized();
413
+
397
414
  const validated = QuerySchema.parse(query);
398
415
 
399
416
  // Load all metadata that matches URL filter
@@ -531,6 +548,8 @@ export class SnapshotManager extends EventEmitter {
531
548
  };
532
549
 
533
550
  try {
551
+ await this.ensureInitialized();
552
+
534
553
  for (const snapshotId of ids) {
535
554
  try {
536
555
  const metadata = await this.loadSnapshotMetadata(snapshotId);
@@ -579,8 +598,10 @@ export class SnapshotManager extends EventEmitter {
579
598
  */
580
599
  async cleanupSnapshots() {
581
600
  const startTime = Date.now();
582
-
601
+
583
602
  try {
603
+ await this.ensureInitialized();
604
+
584
605
  const cleanupResults = {
585
606
  deletedCount: 0,
586
607
  freedSpace: 0,
@@ -698,29 +719,60 @@ export class SnapshotManager extends EventEmitter {
698
719
  await fs.unlink(filePath);
699
720
  }
700
721
 
701
- async storeMetadata(snapshotId, metadata) {
722
+ /**
723
+ * Strips the full page `content` (and any legacy `delta.deltaData`) from a
724
+ * snapshot before it is persisted to a .meta file or cached in
725
+ * metadataCache — content lives only in the .snap file. Idempotent: safe
726
+ * to call on an object that's already stripped.
727
+ */
728
+ _stripHeavyFields(snapshot) {
729
+ const { content, ...rest } = snapshot;
730
+ if (rest.delta && rest.delta.deltaData !== undefined) {
731
+ const { deltaData, ...deltaRest } = rest.delta;
732
+ rest.delta = deltaRest;
733
+ }
734
+ return rest;
735
+ }
736
+
737
+ /**
738
+ * Bounded insert into metadataCache (same LRU-eviction shape as
739
+ * updateCache()/snapshotCache, sized separately via metadataCacheSize —
740
+ * see the constructor comment for why the two caches need different sizes).
741
+ */
742
+ _cacheMetadata(snapshotId, metadata) {
743
+ if (!this.metadataCache.has(snapshotId) && this.metadataCache.size >= this.options.metadataCacheSize) {
744
+ const oldestKey = this.metadataCache.keys().next().value;
745
+ this.metadataCache.delete(oldestKey);
746
+ }
747
+ this.metadataCache.set(snapshotId, metadata);
748
+ }
749
+
750
+ async storeMetadata(snapshotId, snapshot) {
702
751
  const filePath = path.join(this.options.metadataDir, `${snapshotId}.meta`);
752
+ const metadata = this._stripHeavyFields(snapshot);
703
753
  await fs.writeFile(filePath, JSON.stringify(metadata, null, 2), 'utf8');
704
-
754
+
705
755
  // Update in-memory cache
706
- this.metadataCache.set(snapshotId, metadata);
756
+ this._cacheMetadata(snapshotId, metadata);
707
757
  }
708
-
758
+
709
759
  async loadSnapshotMetadata(snapshotId) {
710
760
  // Check cache first
711
761
  if (this.metadataCache.has(snapshotId)) {
712
762
  return this.metadataCache.get(snapshotId);
713
763
  }
714
-
764
+
715
765
  // Load from disk
716
766
  try {
717
767
  const filePath = path.join(this.options.metadataDir, `${snapshotId}.meta`);
718
- const content = await fs.readFile(filePath, 'utf8');
719
- const metadata = JSON.parse(content);
720
-
768
+ const raw = await fs.readFile(filePath, 'utf8');
769
+ // Defensive strip: .meta files written before this fix may still embed
770
+ // full page content — never let a legacy fat file re-pin it in memory.
771
+ const metadata = this._stripHeavyFields(JSON.parse(raw));
772
+
721
773
  // Cache it
722
- this.metadataCache.set(snapshotId, metadata);
723
-
774
+ this._cacheMetadata(snapshotId, metadata);
775
+
724
776
  return metadata;
725
777
  } catch (error) {
726
778
  return null;
@@ -780,85 +832,16 @@ export class SnapshotManager extends EventEmitter {
780
832
  return hash.digest('hex');
781
833
  }
782
834
 
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
-
835
+ /**
836
+ * Legacy delta snapshots (written before delta storage was disabled — see
837
+ * storeSnapshot) stored only a byte-count stub with no real diff data, so
838
+ * their original content is unrecoverable. Fail loudly instead of silently
839
+ * returning the base snapshot's (wrong) content.
840
+ */
850
841
  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
- }
842
+ 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
843
  }
861
-
844
+
862
845
  async calculateChangeMetrics(previousSnapshot, currentSnapshot) {
863
846
  // Calculate various change metrics between snapshots
864
847
  const metrics = {
@@ -908,16 +891,17 @@ export class SnapshotManager extends EventEmitter {
908
891
  }
909
892
 
910
893
  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);
894
+ // Reads metadataCache directly (already fully populated by loadMetadata(),
895
+ // which runs earlier in initialize()) rather than going through
896
+ // querySnapshots() — querySnapshots() now awaits ensureInitialized(),
897
+ // which would deadlock against the in-flight initialize() call this
898
+ // method is itself called from.
899
+ const recent = Array.from(this.metadataCache.values())
900
+ .sort((a, b) => b.metadata.timestamp - a.metadata.timestamp)
901
+ .slice(0, Math.min(this.options.cacheSize, 50));
902
+
903
+ for (const snapshot of recent) {
904
+ this._cacheMetadata(snapshot.id, snapshot);
921
905
  }
922
906
  }
923
907
 
@@ -976,6 +960,12 @@ export class SnapshotManager extends EventEmitter {
976
960
  clearInterval(this.cleanupTimer);
977
961
  }
978
962
 
963
+ // .unref() so this timer never blocks process exit on its own — matches
964
+ // CacheManager's cleanupTimer/monitoringTimer. Without it, any process
965
+ // that merely imports a module holding a live SnapshotManager (e.g. the
966
+ // trackChangesTool singleton exported by trackChanges/index.js, which is
967
+ // never explicitly shut down) hangs forever, since a real server always
968
+ // has other things keeping it alive regardless.
979
969
  this.cleanupTimer = setInterval(async () => {
980
970
  try {
981
971
  await this.cleanupSnapshots();
@@ -983,8 +973,9 @@ export class SnapshotManager extends EventEmitter {
983
973
  this.emit('error', { operation: 'scheduledCleanup', error: error.message });
984
974
  }
985
975
  }, this.retentionPolicy.cleanupInterval);
976
+ if (typeof this.cleanupTimer.unref === 'function') this.cleanupTimer.unref();
986
977
  }
987
-
978
+
988
979
  stopCleanupTimer() {
989
980
  if (this.cleanupTimer) {
990
981
  clearInterval(this.cleanupTimer);
@@ -1027,8 +1018,19 @@ export class SnapshotManager extends EventEmitter {
1027
1018
  }
1028
1019
 
1029
1020
  async shutdown() {
1021
+ // initialize() is lazy (see ensureInitialized()) and only starts the
1022
+ // cleanup timer partway through (after the async createDirectories/
1023
+ // loadMetadata steps). If a caller triggered initialization (via any
1024
+ // public method) and then immediately calls shutdown(), we must wait for
1025
+ // that in-flight init to finish before stopping the timer — otherwise it
1026
+ // could start AFTER stopCleanupTimer() already ran, leaking a live timer.
1027
+ // If nothing ever triggered initialization, _initPromise is still null
1028
+ // and there's nothing to wait for (no directories/timer were created).
1029
+ if (this._initPromise) {
1030
+ await this._initPromise.catch(() => {});
1031
+ }
1030
1032
  this.stopCleanupTimer();
1031
-
1033
+
1032
1034
  // Wait for active operations to complete
1033
1035
  const maxWaitTime = 30000; // 30 seconds
1034
1036
  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
  }