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.
- package/CLAUDE.md +6 -5
- package/README.md +19 -3
- package/package.json +10 -12
- package/server.js +315 -214
- package/src/core/ActionExecutor.js +117 -33
- package/src/core/AgentOrchestrator.js +8 -2
- package/src/core/AuthManager.js +51 -17
- package/src/core/ChangeTracker.js +26 -10
- package/src/core/JobManager.js +9 -1
- package/src/core/LocalizationManager.js +19 -6
- package/src/core/ResearchOrchestrator.js +173 -35
- package/src/core/SnapshotManager.js +162 -165
- package/src/core/StealthBrowserManager.js +25 -3
- package/src/core/WebhookDispatcher.js +19 -14
- package/src/core/analysis/ContentAnalyzer.js +52 -7
- package/src/core/crawlers/BFSCrawler.js +27 -3
- package/src/core/processing/BrowserProcessor.js +19 -1
- package/src/core/processing/PDFProcessor.js +129 -65
- package/src/core/queue/QueueManager.js +3 -2
- package/src/schemas/toolOutputSchemas.js +269 -0
- package/src/server/auth/oauth.js +37 -7
- package/src/server/specHygiene.js +192 -0
- package/src/server/taskSupport.js +233 -0
- package/src/server/toolFilter.js +98 -0
- package/src/server/transports/streamableHttp.js +148 -11
- package/src/server/withAuth.js +11 -4
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
- package/src/tools/advanced/batchScrape/index.js +128 -27
- package/src/tools/advanced/batchScrape/worker.js +55 -5
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/_fetch.js +125 -70
- package/src/tools/basic/extractLinks.js +14 -12
- package/src/tools/basic/scrapeStructured.js +21 -4
- package/src/tools/crawl/crawlDeep.js +110 -48
- package/src/tools/crawl/mapSite.js +25 -6
- package/src/tools/extract/_fetchAndParse.js +98 -1
- package/src/tools/extract/extractContent.js +7 -4
- package/src/tools/extract/extractStructured.js +125 -84
- package/src/tools/extract/extractWithLlm.js +10 -2
- package/src/tools/extract/processDocument.js +54 -6
- package/src/tools/extract/summarizeContent.js +7 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
- package/src/tools/research/deepResearch.js +51 -31
- package/src/tools/scrape/_brandingExtractor.js +49 -11
- package/src/tools/scrape/unifiedScrape.js +27 -17
- package/src/tools/search/providers/searxng.js +5 -1
- package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
- package/src/tools/search/ranking/ResultRanker.js +17 -2
- package/src/tools/search/searchWeb.js +31 -14
- package/src/tools/search/serpRank.js +23 -0
- package/src/tools/templates/TemplateRegistry.js +7 -1
- package/src/tools/tracking/trackChanges/index.js +87 -26
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/CircuitBreaker.js +11 -9
- package/src/utils/contentUtils.js +66 -53
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +11 -9
- package/src/utils/ssrfGuard.js +212 -40
- 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 ||
|
|
82
|
-
metadataDir: options.metadataDir || '
|
|
83
|
-
tempDir: options.tempDir || '
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
719
|
-
|
|
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.
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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:
|
|
2019
|
-
//
|
|
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
|
|
400
|
+
const response = await safeFetch(event.url, {
|
|
399
401
|
method: 'POST',
|
|
400
402
|
headers,
|
|
401
403
|
body,
|
|
402
|
-
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
550
|
+
const response = await safeFetch(url, {
|
|
546
551
|
method: 'HEAD',
|
|
547
|
-
|
|
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
|
}
|