surf-cli 2.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.
@@ -0,0 +1,851 @@
1
+ /**
2
+ * Network Storage Module for surf-cli
3
+ *
4
+ * Handles persistent storage of network requests with:
5
+ * - JSONL append-only log
6
+ * - Content-hash dedup for body storage
7
+ * - Auto-cleanup with TTL and size limits
8
+ */
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const crypto = require("crypto");
13
+ const readline = require("readline");
14
+
15
+ // Configuration
16
+ const DEFAULT_BASE = "/tmp/surf";
17
+ const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours
18
+ const DEFAULT_MAX_SIZE = 200 * 1024 * 1024; // 200MB
19
+ const AUTO_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
20
+
21
+ // Lock file for concurrent access
22
+ let writeLock = Promise.resolve();
23
+
24
+ // Runtime override for base path (set via CLI --network-path)
25
+ let runtimeBasePath = null;
26
+
27
+ /**
28
+ * Set base path at runtime (from CLI --network-path flag)
29
+ */
30
+ function setBasePath(newPath) {
31
+ runtimeBasePath = newPath;
32
+ }
33
+
34
+ /**
35
+ * Get base path for network storage
36
+ * Priority: runtime override > SURF_NETWORK_PATH env var > default
37
+ */
38
+ function getBasePath() {
39
+ return runtimeBasePath || process.env.SURF_NETWORK_PATH || DEFAULT_BASE;
40
+ }
41
+
42
+ /**
43
+ * Get path to requests.jsonl
44
+ */
45
+ function getRequestsPath() {
46
+ return path.join(getBasePath(), "requests.jsonl");
47
+ }
48
+
49
+ /**
50
+ * Get path to bodies directory
51
+ */
52
+ function getBodiesPath() {
53
+ return path.join(getBasePath(), "bodies");
54
+ }
55
+
56
+ /**
57
+ * Get path to .meta file
58
+ */
59
+ function getMetaPath() {
60
+ return path.join(getBasePath(), ".meta");
61
+ }
62
+
63
+ /**
64
+ * Ensure all required directories exist
65
+ */
66
+ function ensureDirectories() {
67
+ const base = getBasePath();
68
+ const bodies = getBodiesPath();
69
+
70
+ if (!fs.existsSync(base)) {
71
+ fs.mkdirSync(base, { recursive: true });
72
+ }
73
+ if (!fs.existsSync(bodies)) {
74
+ fs.mkdirSync(bodies, { recursive: true });
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Read meta file
80
+ */
81
+ function readMeta() {
82
+ const metaPath = getMetaPath();
83
+ try {
84
+ if (fs.existsSync(metaPath)) {
85
+ return JSON.parse(fs.readFileSync(metaPath, "utf-8"));
86
+ }
87
+ } catch (err) {
88
+ // Ignore errors, return default
89
+ }
90
+ return { lastCleanup: 0 };
91
+ }
92
+
93
+ /**
94
+ * Write meta file
95
+ */
96
+ function writeMeta(meta) {
97
+ const metaPath = getMetaPath();
98
+ ensureDirectories();
99
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
100
+ }
101
+
102
+ /**
103
+ * Generate unique ID for entries
104
+ */
105
+ function generateId() {
106
+ return `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
107
+ }
108
+
109
+ /**
110
+ * Store body content with content-hash dedup
111
+ * @param {Buffer|string} content - Body content
112
+ * @param {boolean} isRequest - Whether this is request body (vs response)
113
+ * @returns {string} Hash reference
114
+ */
115
+ function storeBody(content, isRequest = false) {
116
+ ensureDirectories();
117
+
118
+ const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
119
+ const hash = crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 16);
120
+ const ext = isRequest ? "req" : "res";
121
+ const bodyPath = path.join(getBodiesPath(), `${hash}.${ext}`);
122
+
123
+ // Only write if doesn't exist (dedup)
124
+ if (!fs.existsSync(bodyPath)) {
125
+ fs.writeFileSync(bodyPath, buffer);
126
+ }
127
+
128
+ return hash;
129
+ }
130
+
131
+ /**
132
+ * Read body by hash
133
+ * @param {string} hash - Body hash
134
+ * @param {boolean} isRequest - Whether this is request body
135
+ * @returns {Buffer|null} Body content or null if not found
136
+ */
137
+ function readBody(hash, isRequest = false) {
138
+ const ext = isRequest ? "req" : "res";
139
+ const bodyPath = path.join(getBodiesPath(), `${hash}.${ext}`);
140
+
141
+ try {
142
+ if (fs.existsSync(bodyPath)) {
143
+ return fs.readFileSync(bodyPath);
144
+ }
145
+ } catch (err) {
146
+ // Ignore errors
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Get file path for body (for external tools)
153
+ * @param {string} hash - Body hash
154
+ * @param {boolean} isRequest - Whether this is request body
155
+ * @returns {string} Absolute path to body file
156
+ */
157
+ function getBodyPath(hash, isRequest = false) {
158
+ const ext = isRequest ? "req" : "res";
159
+ return path.join(getBodiesPath(), `${hash}.${ext}`);
160
+ }
161
+
162
+ /**
163
+ * Append a network entry (thread-safe with file locking)
164
+ * @param {Object} entry - Network entry to append
165
+ * @returns {Promise<Object>} The entry with assigned ID
166
+ */
167
+ async function appendEntry(entry) {
168
+ ensureDirectories();
169
+
170
+ // Serialize writes
171
+ const releasePromise = writeLock;
172
+ let release;
173
+ writeLock = new Promise(r => { release = r; });
174
+
175
+ await releasePromise;
176
+
177
+ try {
178
+ const id = entry.id || generateId();
179
+ const timestamp = entry.timestamp || Date.now();
180
+
181
+ const fullEntry = {
182
+ id,
183
+ timestamp,
184
+ ...entry
185
+ };
186
+
187
+ const line = JSON.stringify(fullEntry) + "\n";
188
+
189
+ // Atomic append using flag 'a'
190
+ fs.appendFileSync(getRequestsPath(), line, { flag: "a" });
191
+
192
+ return fullEntry;
193
+ } finally {
194
+ release();
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Append entry synchronously (for simpler use cases)
200
+ * @param {Object} entry - Network entry to append
201
+ * @returns {Object} The entry with assigned ID
202
+ */
203
+ function appendEntrySync(entry) {
204
+ ensureDirectories();
205
+
206
+ const id = entry.id || generateId();
207
+ const timestamp = entry.timestamp || Date.now();
208
+
209
+ const fullEntry = {
210
+ id,
211
+ timestamp,
212
+ ...entry
213
+ };
214
+
215
+ const line = JSON.stringify(fullEntry) + "\n";
216
+
217
+ // Use a simple lock file for synchronous operations
218
+ const lockPath = path.join(getBasePath(), ".lock");
219
+ let lockFd;
220
+
221
+ try {
222
+ // Try to acquire lock
223
+ lockFd = fs.openSync(lockPath, "wx");
224
+ } catch (err) {
225
+ // Lock exists - check if stale and remove, otherwise proceed without lock
226
+ try {
227
+ const stat = fs.statSync(lockPath);
228
+ if (Date.now() - stat.mtimeMs > 5000) {
229
+ fs.unlinkSync(lockPath);
230
+ try {
231
+ lockFd = fs.openSync(lockPath, "wx");
232
+ } catch (e) {
233
+ // Still can't get lock, proceed without it
234
+ }
235
+ }
236
+ } catch (e) {
237
+ // Lock file gone or inaccessible, proceed without lock
238
+ }
239
+
240
+ if (lockFd === undefined) {
241
+ // Proceed without lock as fallback
242
+ fs.appendFileSync(getRequestsPath(), line, { flag: "a" });
243
+ return fullEntry;
244
+ }
245
+ }
246
+
247
+ try {
248
+ fs.appendFileSync(getRequestsPath(), line, { flag: "a" });
249
+ } finally {
250
+ if (lockFd !== undefined) {
251
+ fs.closeSync(lockFd);
252
+ try {
253
+ fs.unlinkSync(lockPath);
254
+ } catch (e) {}
255
+ }
256
+ }
257
+
258
+ return fullEntry;
259
+ }
260
+
261
+ /**
262
+ * Parse URL to extract origin
263
+ */
264
+ function getOriginFromUrl(url) {
265
+ try {
266
+ const parsed = new URL(url);
267
+ return parsed.origin;
268
+ } catch (e) {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Check if URL matches pattern
275
+ */
276
+ function matchesUrlPattern(url, pattern) {
277
+ if (!pattern) return true;
278
+
279
+ // Support regex patterns
280
+ if (pattern.startsWith("/") && pattern.endsWith("/")) {
281
+ try {
282
+ const regex = new RegExp(pattern.slice(1, -1));
283
+ return regex.test(url);
284
+ } catch (e) {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ // Simple glob-like matching
290
+ if (pattern.includes("*")) {
291
+ const regexPattern = pattern
292
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
293
+ .replace(/\*/g, ".*");
294
+ return new RegExp(regexPattern).test(url);
295
+ }
296
+
297
+ // Simple substring match
298
+ return url.includes(pattern);
299
+ }
300
+
301
+ /**
302
+ * Check if entry matches filters
303
+ */
304
+ function matchesFilters(entry, filters) {
305
+ if (!filters) return true;
306
+
307
+ const {
308
+ origin,
309
+ method,
310
+ status,
311
+ type,
312
+ since,
313
+ hasBody,
314
+ excludeStatic,
315
+ urlPattern
316
+ } = filters;
317
+
318
+ // Filter by origin
319
+ if (origin) {
320
+ const entryOrigin = getOriginFromUrl(entry.url);
321
+ if (entryOrigin !== origin) return false;
322
+ }
323
+
324
+ // Filter by method
325
+ if (method && entry.method !== method.toUpperCase()) {
326
+ return false;
327
+ }
328
+
329
+ // Filter by status
330
+ if (status !== undefined) {
331
+ if (typeof status === "number" && entry.status !== status) return false;
332
+ if (typeof status === "string") {
333
+ const statusStr = String(entry.status);
334
+ if (status.endsWith("xx")) {
335
+ // Range like "2xx", "4xx"
336
+ if (!statusStr.startsWith(status[0])) return false;
337
+ } else if (entry.status !== parseInt(status, 10)) {
338
+ return false;
339
+ }
340
+ }
341
+ }
342
+
343
+ // Filter by content type
344
+ if (type) {
345
+ const contentType = entry.contentType || entry.responseHeaders?.["content-type"] || "";
346
+ if (!contentType.includes(type)) return false;
347
+ }
348
+
349
+ // Filter by timestamp
350
+ if (since && entry.timestamp < since) {
351
+ return false;
352
+ }
353
+
354
+ // Filter by body presence
355
+ if (hasBody !== undefined) {
356
+ const hasResponseBody = !!entry.responseBodyHash;
357
+ if (hasBody !== hasResponseBody) return false;
358
+ }
359
+
360
+ // Exclude static assets
361
+ if (excludeStatic) {
362
+ const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".woff", ".woff2", ".ttf", ".ico"];
363
+ const urlPath = entry.url.split("?")[0].toLowerCase();
364
+ if (staticExts.some(ext => urlPath.endsWith(ext))) return false;
365
+ }
366
+
367
+ // URL pattern matching
368
+ if (urlPattern && !matchesUrlPattern(entry.url, urlPattern)) {
369
+ return false;
370
+ }
371
+
372
+ return true;
373
+ }
374
+
375
+ /**
376
+ * Read entries with filters (streaming for large files)
377
+ * @param {Object} filters - Filter options
378
+ * @returns {Promise<Array>} Matching entries
379
+ */
380
+ async function readEntries(filters = {}) {
381
+ const requestsPath = getRequestsPath();
382
+
383
+ if (!fs.existsSync(requestsPath)) {
384
+ return [];
385
+ }
386
+
387
+ const { last } = filters;
388
+ const entries = [];
389
+
390
+ return new Promise((resolve, reject) => {
391
+ const fileStream = fs.createReadStream(requestsPath, { encoding: "utf-8" });
392
+ const rl = readline.createInterface({
393
+ input: fileStream,
394
+ crlfDelay: Infinity
395
+ });
396
+
397
+ rl.on("line", (line) => {
398
+ if (!line.trim()) return;
399
+
400
+ try {
401
+ const entry = JSON.parse(line);
402
+ if (matchesFilters(entry, filters)) {
403
+ entries.push(entry);
404
+ }
405
+ } catch (err) {
406
+ // Skip malformed lines
407
+ }
408
+ });
409
+
410
+ rl.on("close", () => {
411
+ // Apply 'last' filter after collecting all matches
412
+ if (last && last > 0) {
413
+ resolve(entries.slice(-last));
414
+ } else {
415
+ resolve(entries);
416
+ }
417
+ });
418
+
419
+ rl.on("error", reject);
420
+ });
421
+ }
422
+
423
+ /**
424
+ * Read entries synchronously (for smaller datasets)
425
+ * @param {Object} filters - Filter options
426
+ * @returns {Array} Matching entries
427
+ */
428
+ function readEntriesSync(filters = {}) {
429
+ const requestsPath = getRequestsPath();
430
+
431
+ if (!fs.existsSync(requestsPath)) {
432
+ return [];
433
+ }
434
+
435
+ const { last } = filters;
436
+ const entries = [];
437
+
438
+ const content = fs.readFileSync(requestsPath, "utf-8");
439
+ const lines = content.split("\n");
440
+
441
+ for (const line of lines) {
442
+ if (!line.trim()) continue;
443
+
444
+ try {
445
+ const entry = JSON.parse(line);
446
+ if (matchesFilters(entry, filters)) {
447
+ entries.push(entry);
448
+ }
449
+ } catch (err) {
450
+ // Skip malformed lines
451
+ }
452
+ }
453
+
454
+ if (last && last > 0) {
455
+ return entries.slice(-last);
456
+ }
457
+
458
+ return entries;
459
+ }
460
+
461
+ /**
462
+ * Get single entry by ID
463
+ * @param {string} id - Entry ID
464
+ * @returns {Promise<Object|null>} Entry or null if not found
465
+ */
466
+ async function getEntry(id) {
467
+ const entries = await readEntries();
468
+ return entries.find(e => e.id === id) || null;
469
+ }
470
+
471
+ /**
472
+ * Get single entry by ID (sync)
473
+ * @param {string} id - Entry ID
474
+ * @returns {Object|null} Entry or null if not found
475
+ */
476
+ function getEntrySync(id) {
477
+ const entries = readEntriesSync();
478
+ return entries.find(e => e.id === id) || null;
479
+ }
480
+
481
+ /**
482
+ * Get unique origins with request counts
483
+ * @returns {Promise<Object>} Map of origin -> count
484
+ */
485
+ async function getOrigins() {
486
+ const entries = await readEntries();
487
+ const origins = {};
488
+
489
+ for (const entry of entries) {
490
+ const origin = getOriginFromUrl(entry.url);
491
+ if (origin) {
492
+ origins[origin] = (origins[origin] || 0) + 1;
493
+ }
494
+ }
495
+
496
+ return origins;
497
+ }
498
+
499
+ /**
500
+ * Get unique origins with counts (sync)
501
+ * @returns {Object} Map of origin -> count
502
+ */
503
+ function getOriginsSync() {
504
+ const entries = readEntriesSync();
505
+ const origins = {};
506
+
507
+ for (const entry of entries) {
508
+ const origin = getOriginFromUrl(entry.url);
509
+ if (origin) {
510
+ origins[origin] = (origins[origin] || 0) + 1;
511
+ }
512
+ }
513
+
514
+ return origins;
515
+ }
516
+
517
+ /**
518
+ * Get statistics about stored data
519
+ * @returns {Promise<Object>} Stats object
520
+ */
521
+ async function getStats() {
522
+ const entries = await readEntries();
523
+ const meta = readMeta();
524
+ const origins = {};
525
+ let oldestEntry = Infinity;
526
+ let newestEntry = 0;
527
+
528
+ for (const entry of entries) {
529
+ const origin = getOriginFromUrl(entry.url);
530
+ if (origin) {
531
+ origins[origin] = (origins[origin] || 0) + 1;
532
+ }
533
+ if (entry.timestamp < oldestEntry) oldestEntry = entry.timestamp;
534
+ if (entry.timestamp > newestEntry) newestEntry = entry.timestamp;
535
+ }
536
+
537
+ // Calculate body size
538
+ let totalBodySize = 0;
539
+ const bodiesDir = getBodiesPath();
540
+ if (fs.existsSync(bodiesDir)) {
541
+ const files = fs.readdirSync(bodiesDir);
542
+ for (const file of files) {
543
+ try {
544
+ const stat = fs.statSync(path.join(bodiesDir, file));
545
+ totalBodySize += stat.size;
546
+ } catch (err) {}
547
+ }
548
+ }
549
+
550
+ return {
551
+ totalRequests: entries.length,
552
+ totalBodySize,
553
+ oldestEntry: oldestEntry === Infinity ? null : oldestEntry,
554
+ newestEntry: newestEntry === 0 ? null : newestEntry,
555
+ lastCleanup: meta.lastCleanup || null,
556
+ origins
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Get stats synchronously
562
+ */
563
+ function getStatsSync() {
564
+ const entries = readEntriesSync();
565
+ const meta = readMeta();
566
+ const origins = {};
567
+ let oldestEntry = Infinity;
568
+ let newestEntry = 0;
569
+
570
+ for (const entry of entries) {
571
+ const origin = getOriginFromUrl(entry.url);
572
+ if (origin) {
573
+ origins[origin] = (origins[origin] || 0) + 1;
574
+ }
575
+ if (entry.timestamp < oldestEntry) oldestEntry = entry.timestamp;
576
+ if (entry.timestamp > newestEntry) newestEntry = entry.timestamp;
577
+ }
578
+
579
+ // Calculate body size
580
+ let totalBodySize = 0;
581
+ const bodiesDir = getBodiesPath();
582
+ if (fs.existsSync(bodiesDir)) {
583
+ const files = fs.readdirSync(bodiesDir);
584
+ for (const file of files) {
585
+ try {
586
+ const stat = fs.statSync(path.join(bodiesDir, file));
587
+ totalBodySize += stat.size;
588
+ } catch (err) {}
589
+ }
590
+ }
591
+
592
+ return {
593
+ totalRequests: entries.length,
594
+ totalBodySize,
595
+ oldestEntry: oldestEntry === Infinity ? null : oldestEntry,
596
+ newestEntry: newestEntry === 0 ? null : newestEntry,
597
+ lastCleanup: meta.lastCleanup || null,
598
+ origins
599
+ };
600
+ }
601
+
602
+ /**
603
+ * Cleanup old entries and orphaned bodies
604
+ * @param {Object} options - Cleanup options
605
+ * @returns {Promise<Object>} Cleanup results
606
+ */
607
+ async function cleanup(options = {}) {
608
+ const { ttl = DEFAULT_TTL, maxSize = DEFAULT_MAX_SIZE } = options;
609
+ const now = Date.now();
610
+ const cutoffTime = now - ttl;
611
+
612
+ const requestsPath = getRequestsPath();
613
+ const bodiesDir = getBodiesPath();
614
+
615
+ if (!fs.existsSync(requestsPath)) {
616
+ writeMeta({ lastCleanup: now });
617
+ return { deletedEntries: 0, deletedBodies: 0, freedBytes: 0 };
618
+ }
619
+
620
+ // Read all entries
621
+ let entries = readEntriesSync();
622
+ const originalCount = entries.length;
623
+
624
+ // 1. Delete entries older than TTL
625
+ entries = entries.filter(e => e.timestamp >= cutoffTime);
626
+
627
+ // 2. If still over maxSize, calculate total size and remove oldest
628
+ let totalSize = 0;
629
+ const requestsSize = fs.existsSync(requestsPath) ? fs.statSync(requestsPath).size : 0;
630
+ totalSize += requestsSize;
631
+
632
+ if (fs.existsSync(bodiesDir)) {
633
+ const files = fs.readdirSync(bodiesDir);
634
+ for (const file of files) {
635
+ try {
636
+ totalSize += fs.statSync(path.join(bodiesDir, file)).size;
637
+ } catch (e) {}
638
+ }
639
+ }
640
+
641
+ if (totalSize > maxSize && entries.length > 0) {
642
+ // Sort by timestamp and remove oldest entries until under limit
643
+ entries.sort((a, b) => a.timestamp - b.timestamp);
644
+
645
+ while (entries.length > 0 && totalSize > maxSize) {
646
+ entries.shift();
647
+ // Rough estimate: recalculate after removing some entries
648
+ totalSize = totalSize * (entries.length / (entries.length + 1));
649
+ }
650
+ }
651
+
652
+ // 3. Collect referenced body hashes
653
+ const referencedHashes = new Set();
654
+ for (const entry of entries) {
655
+ if (entry.requestBodyHash) referencedHashes.add(`${entry.requestBodyHash}.req`);
656
+ if (entry.responseBodyHash) referencedHashes.add(`${entry.responseBodyHash}.res`);
657
+ }
658
+
659
+ // 4. Delete orphaned body files
660
+ let deletedBodies = 0;
661
+ let freedBytes = 0;
662
+
663
+ if (fs.existsSync(bodiesDir)) {
664
+ const bodyFiles = fs.readdirSync(bodiesDir);
665
+ for (const file of bodyFiles) {
666
+ if (!referencedHashes.has(file)) {
667
+ const filePath = path.join(bodiesDir, file);
668
+ try {
669
+ const stat = fs.statSync(filePath);
670
+ freedBytes += stat.size;
671
+ fs.unlinkSync(filePath);
672
+ deletedBodies++;
673
+ } catch (e) {}
674
+ }
675
+ }
676
+ }
677
+
678
+ // 5. Rewrite entries file with remaining entries
679
+ const deletedEntries = originalCount - entries.length;
680
+
681
+ if (deletedEntries > 0 || entries.length === 0) {
682
+ // Atomic write: write to temp then rename
683
+ const tempPath = requestsPath + ".tmp";
684
+ const content = entries.map(e => JSON.stringify(e)).join("\n") + (entries.length > 0 ? "\n" : "");
685
+ fs.writeFileSync(tempPath, content);
686
+ fs.renameSync(tempPath, requestsPath);
687
+ }
688
+
689
+ // 6. Update meta
690
+ writeMeta({ lastCleanup: now });
691
+
692
+ return {
693
+ deletedEntries,
694
+ deletedBodies,
695
+ freedBytes,
696
+ remainingEntries: entries.length
697
+ };
698
+ }
699
+
700
+ /**
701
+ * Clear entries with optional filters
702
+ * @param {Object} options - Clear options
703
+ * @returns {Promise<Object>} Clear results
704
+ */
705
+ async function clear(options = {}) {
706
+ const { before, origin: targetOrigin } = options;
707
+
708
+ const requestsPath = getRequestsPath();
709
+ const bodiesDir = getBodiesPath();
710
+
711
+ // If no options, clear everything
712
+ if (!before && !targetOrigin) {
713
+ let deletedEntries = 0;
714
+ let deletedBodies = 0;
715
+
716
+ if (fs.existsSync(requestsPath)) {
717
+ const entries = readEntriesSync();
718
+ deletedEntries = entries.length;
719
+ fs.unlinkSync(requestsPath);
720
+ }
721
+
722
+ if (fs.existsSync(bodiesDir)) {
723
+ const files = fs.readdirSync(bodiesDir);
724
+ for (const file of files) {
725
+ try {
726
+ fs.unlinkSync(path.join(bodiesDir, file));
727
+ deletedBodies++;
728
+ } catch (e) {}
729
+ }
730
+ }
731
+
732
+ return { deletedEntries, deletedBodies };
733
+ }
734
+
735
+ // Selective clear
736
+ if (!fs.existsSync(requestsPath)) {
737
+ return { deletedEntries: 0, deletedBodies: 0 };
738
+ }
739
+
740
+ const entries = readEntriesSync();
741
+ const originalCount = entries.length;
742
+
743
+ const remaining = entries.filter(entry => {
744
+ // Keep if doesn't match clear criteria
745
+ if (before && entry.timestamp >= before) return true;
746
+ if (targetOrigin) {
747
+ const entryOrigin = getOriginFromUrl(entry.url);
748
+ if (entryOrigin !== targetOrigin) return true;
749
+ }
750
+ return false;
751
+ });
752
+
753
+ const deletedEntries = originalCount - remaining.length;
754
+
755
+ // Collect hashes to keep
756
+ const keepHashes = new Set();
757
+ for (const entry of remaining) {
758
+ if (entry.requestBodyHash) keepHashes.add(`${entry.requestBodyHash}.req`);
759
+ if (entry.responseBodyHash) keepHashes.add(`${entry.responseBodyHash}.res`);
760
+ }
761
+
762
+ // Delete orphaned bodies
763
+ let deletedBodies = 0;
764
+ if (fs.existsSync(bodiesDir)) {
765
+ const files = fs.readdirSync(bodiesDir);
766
+ for (const file of files) {
767
+ if (!keepHashes.has(file)) {
768
+ try {
769
+ fs.unlinkSync(path.join(bodiesDir, file));
770
+ deletedBodies++;
771
+ } catch (e) {}
772
+ }
773
+ }
774
+ }
775
+
776
+ // Rewrite entries file
777
+ if (deletedEntries > 0) {
778
+ const tempPath = requestsPath + ".tmp";
779
+ const content = remaining.map(e => JSON.stringify(e)).join("\n") + (remaining.length > 0 ? "\n" : "");
780
+ fs.writeFileSync(tempPath, content);
781
+ fs.renameSync(tempPath, requestsPath);
782
+ }
783
+
784
+ return { deletedEntries, deletedBodies };
785
+ }
786
+
787
+ /**
788
+ * Run cleanup if last cleanup was more than AUTO_CLEANUP_INTERVAL ago
789
+ */
790
+ function maybeAutoCleanup() {
791
+ try {
792
+ const meta = readMeta();
793
+ const now = Date.now();
794
+
795
+ if (now - (meta.lastCleanup || 0) > AUTO_CLEANUP_INTERVAL) {
796
+ // Run cleanup asynchronously to not block module load
797
+ setImmediate(() => {
798
+ cleanup().catch(err => {
799
+ // Ignore cleanup errors
800
+ });
801
+ });
802
+ }
803
+ } catch (err) {
804
+ // Ignore errors during auto-cleanup check
805
+ }
806
+ }
807
+
808
+ // Run auto-cleanup check on module load
809
+ maybeAutoCleanup();
810
+
811
+ module.exports = {
812
+ // Configuration
813
+ getBasePath,
814
+ getRequestsPath,
815
+ getBodiesPath,
816
+ getMetaPath,
817
+
818
+ // Body storage
819
+ storeBody,
820
+ readBody,
821
+ getBodyPath,
822
+
823
+ // Entry operations
824
+ appendEntry,
825
+ appendEntrySync,
826
+ readEntries,
827
+ readEntriesSync,
828
+ getEntry,
829
+ getEntrySync,
830
+
831
+ // Aggregations
832
+ getOrigins,
833
+ getOriginsSync,
834
+ getStats,
835
+ getStatsSync,
836
+
837
+ // Maintenance
838
+ cleanup,
839
+ clear,
840
+ maybeAutoCleanup,
841
+
842
+ // Configuration
843
+ setBasePath,
844
+ getBasePath,
845
+
846
+ // Constants
847
+ DEFAULT_BASE,
848
+ DEFAULT_TTL,
849
+ DEFAULT_MAX_SIZE,
850
+ AUTO_CLEANUP_INTERVAL,
851
+ };