undici 7.13.0 → 7.14.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.
@@ -2,13 +2,93 @@
2
2
 
3
3
  const { writeFile, readFile, mkdir } = require('node:fs/promises')
4
4
  const { dirname, resolve } = require('node:path')
5
+ const { setTimeout, clearTimeout } = require('node:timers')
5
6
  const { InvalidArgumentError, UndiciError } = require('../core/errors')
7
+ const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require('./snapshot-utils')
8
+
9
+ /**
10
+ * @typedef {Object} SnapshotRequestOptions
11
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
12
+ * @property {string} path - Request path
13
+ * @property {string} origin - Request origin (base URL)
14
+ * @property {import('./snapshot-utils').Headers|import('./snapshot-utils').UndiciHeaders} headers - Request headers
15
+ * @property {import('./snapshot-utils').NormalizedHeaders} _normalizedHeaders - Request headers as a lowercase object
16
+ * @property {string|Buffer} [body] - Request body (optional)
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} SnapshotEntryRequest
21
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
22
+ * @property {string} url - Full URL of the request
23
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
24
+ * @property {string|Buffer} [body] - Request body (optional)
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} SnapshotEntryResponse
29
+ * @property {number} statusCode - HTTP status code of the response
30
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized response headers as a lowercase object
31
+ * @property {string} body - Response body as a base64url encoded string
32
+ * @property {Object} [trailers] - Optional response trailers
33
+ */
34
+
35
+ /**
36
+ * @typedef {Object} SnapshotEntry
37
+ * @property {SnapshotEntryRequest} request - The request object
38
+ * @property {Array<SnapshotEntryResponse>} responses - Array of response objects
39
+ * @property {number} callCount - Number of times this snapshot has been called
40
+ * @property {string} timestamp - ISO timestamp of when the snapshot was created
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} SnapshotRecorderMatchOptions
45
+ * @property {Array<string>} [matchHeaders=[]] - Headers to match (empty array means match all headers)
46
+ * @property {Array<string>} [ignoreHeaders=[]] - Headers to ignore for matching
47
+ * @property {Array<string>} [excludeHeaders=[]] - Headers to exclude from matching
48
+ * @property {boolean} [matchBody=true] - Whether to match request body
49
+ * @property {boolean} [matchQuery=true] - Whether to match query properties
50
+ * @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} SnapshotRecorderOptions
55
+ * @property {string} [snapshotPath] - Path to save/load snapshots
56
+ * @property {import('./snapshot-utils').SnapshotMode} [mode='record'] - Mode: 'record' or 'playback'
57
+ * @property {number} [maxSnapshots=Infinity] - Maximum number of snapshots to keep
58
+ * @property {boolean} [autoFlush=false] - Whether to automatically flush snapshots to disk
59
+ * @property {number} [flushInterval=30000] - Auto-flush interval in milliseconds (default: 30 seconds)
60
+ * @property {Array<string|RegExp>} [excludeUrls=[]] - URLs to exclude from recording
61
+ * @property {function} [shouldRecord=null] - Function to filter requests for recording
62
+ * @property {function} [shouldPlayback=null] - Function to filter requests
63
+ */
64
+
65
+ /**
66
+ * @typedef {Object} SnapshotFormattedRequest
67
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
68
+ * @property {string} url - Full URL of the request (with query parameters if matchQuery is true)
69
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
70
+ * @property {string} body - Request body (optional, only if matchBody is true)
71
+ */
72
+
73
+ /**
74
+ * @typedef {Object} SnapshotInfo
75
+ * @property {string} hash - Hash key for the snapshot
76
+ * @property {SnapshotEntryRequest} request - The request object
77
+ * @property {number} responseCount - Number of responses recorded for this request
78
+ * @property {number} callCount - Number of times this snapshot has been called
79
+ * @property {string} timestamp - ISO timestamp of when the snapshot was created
80
+ */
6
81
 
7
82
  /**
8
83
  * Formats a request for consistent snapshot storage
9
84
  * Caches normalized headers to avoid repeated processing
85
+ *
86
+ * @param {SnapshotRequestOptions} opts - Request options
87
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached header sets for performance
88
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers and body
89
+ * @returns {SnapshotFormattedRequest} - Formatted request object
10
90
  */
11
- function formatRequestKey (opts, cachedSets, matchOptions = {}) {
91
+ function formatRequestKey (opts, headerFilters, matchOptions = {}) {
12
92
  const url = new URL(opts.path, opts.origin)
13
93
 
14
94
  // Cache normalized headers if not already done
@@ -20,37 +100,40 @@ function formatRequestKey (opts, cachedSets, matchOptions = {}) {
20
100
  return {
21
101
  method: opts.method || 'GET',
22
102
  url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`,
23
- headers: filterHeadersForMatching(normalized, cachedSets, matchOptions),
24
- body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : undefined
103
+ headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
104
+ body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : ''
25
105
  }
26
106
  }
27
107
 
28
108
  /**
29
109
  * Filters headers based on matching configuration
110
+ *
111
+ * @param {import('./snapshot-utils').Headers} headers - Headers to filter
112
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
113
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
30
114
  */
31
- function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) {
115
+ function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) {
32
116
  if (!headers || typeof headers !== 'object') return {}
33
117
 
34
118
  const {
35
- matchHeaders = null,
36
119
  caseSensitive = false
37
120
  } = matchOptions
38
121
 
39
122
  const filtered = {}
40
- const { ignoreSet, excludeSet, matchSet } = cachedSets
123
+ const { ignore, exclude, match } = headerFilters
41
124
 
42
125
  for (const [key, value] of Object.entries(headers)) {
43
126
  const headerKey = caseSensitive ? key : key.toLowerCase()
44
127
 
45
128
  // Skip if in exclude list (for security)
46
- if (excludeSet.has(headerKey)) continue
129
+ if (exclude.has(headerKey)) continue
47
130
 
48
131
  // Skip if in ignore list (for matching)
49
- if (ignoreSet.has(headerKey)) continue
132
+ if (ignore.has(headerKey)) continue
50
133
 
51
134
  // If matchHeaders is specified, only include those headers
52
- if (matchHeaders && Array.isArray(matchHeaders)) {
53
- if (!matchSet.has(headerKey)) continue
135
+ if (match.size !== 0) {
136
+ if (!match.has(headerKey)) continue
54
137
  }
55
138
 
56
139
  filtered[headerKey] = value
@@ -61,17 +144,20 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) {
61
144
 
62
145
  /**
63
146
  * Filters headers for storage (only excludes sensitive headers)
147
+ *
148
+ * @param {import('./snapshot-utils').Headers} headers - Headers to filter
149
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
150
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
64
151
  */
65
- function filterHeadersForStorage (headers, matchOptions = {}) {
152
+ function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) {
66
153
  if (!headers || typeof headers !== 'object') return {}
67
154
 
68
155
  const {
69
- excludeHeaders = [],
70
156
  caseSensitive = false
71
157
  } = matchOptions
72
158
 
73
159
  const filtered = {}
74
- const excludeSet = new Set(excludeHeaders.map(h => caseSensitive ? h : h.toLowerCase()))
160
+ const { exclude: excludeSet } = headerFilters
75
161
 
76
162
  for (const [key, value] of Object.entries(headers)) {
77
163
  const headerKey = caseSensitive ? key : key.toLowerCase()
@@ -86,106 +172,81 @@ function filterHeadersForStorage (headers, matchOptions = {}) {
86
172
  }
87
173
 
88
174
  /**
89
- * Creates cached header sets for performance
175
+ * Creates a hash key for request matching
176
+ * Properly orders headers to avoid conflicts and uses crypto hashing when available
177
+ *
178
+ * @param {SnapshotFormattedRequest} formattedRequest - Request object
179
+ * @returns {string} - Base64url encoded hash of the request
90
180
  */
91
- function createHeaderSetsCache (matchOptions = {}) {
92
- const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = null, caseSensitive = false } = matchOptions
181
+ function createRequestHash (formattedRequest) {
182
+ const parts = [
183
+ formattedRequest.method,
184
+ formattedRequest.url
185
+ ]
93
186
 
94
- return {
95
- ignoreSet: new Set(ignoreHeaders.map(h => caseSensitive ? h : h.toLowerCase())),
96
- excludeSet: new Set(excludeHeaders.map(h => caseSensitive ? h : h.toLowerCase())),
97
- matchSet: matchHeaders && Array.isArray(matchHeaders)
98
- ? new Set(matchHeaders.map(h => caseSensitive ? h : h.toLowerCase()))
99
- : null
100
- }
101
- }
187
+ // Process headers in a deterministic way to avoid conflicts
188
+ if (formattedRequest.headers && typeof formattedRequest.headers === 'object') {
189
+ const headerKeys = Object.keys(formattedRequest.headers).sort()
190
+ for (const key of headerKeys) {
191
+ const values = Array.isArray(formattedRequest.headers[key])
192
+ ? formattedRequest.headers[key]
193
+ : [formattedRequest.headers[key]]
102
194
 
103
- /**
104
- * Normalizes headers for consistent comparison
105
- */
106
- function normalizeHeaders (headers) {
107
- if (!headers) return {}
108
-
109
- const normalized = {}
110
-
111
- // Handle array format (undici internal format: [name, value, name, value, ...])
112
- if (Array.isArray(headers)) {
113
- for (let i = 0; i < headers.length; i += 2) {
114
- const key = headers[i]
115
- const value = headers[i + 1]
116
- if (key && value !== undefined) {
117
- // Convert Buffers to strings if needed
118
- const keyStr = Buffer.isBuffer(key) ? key.toString() : String(key)
119
- const valueStr = Buffer.isBuffer(value) ? value.toString() : String(value)
120
- normalized[keyStr.toLowerCase()] = valueStr
121
- }
122
- }
123
- return normalized
124
- }
195
+ // Add header name
196
+ parts.push(key)
125
197
 
126
- // Handle object format
127
- if (headers && typeof headers === 'object') {
128
- for (const [key, value] of Object.entries(headers)) {
129
- if (key && typeof key === 'string') {
130
- normalized[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value)
198
+ // Add all values for this header, sorted for consistency
199
+ for (const value of values.sort()) {
200
+ parts.push(String(value))
131
201
  }
132
202
  }
133
203
  }
134
204
 
135
- return normalized
136
- }
205
+ // Add body
206
+ parts.push(formattedRequest.body)
137
207
 
138
- /**
139
- * Creates a hash key for request matching
140
- */
141
- function createRequestHash (request) {
142
- const parts = [
143
- request.method,
144
- request.url,
145
- JSON.stringify(request.headers, Object.keys(request.headers).sort()),
146
- request.body || ''
147
- ]
148
- return Buffer.from(parts.join('|')).toString('base64url')
149
- }
208
+ const content = parts.join('|')
150
209
 
151
- /**
152
- * Checks if a URL matches any of the exclude patterns
153
- */
154
- function isUrlExcluded (url, excludePatterns = []) {
155
- if (!excludePatterns.length) return false
156
-
157
- for (const pattern of excludePatterns) {
158
- if (typeof pattern === 'string') {
159
- // Simple string match (case-insensitive)
160
- if (url.toLowerCase().includes(pattern.toLowerCase())) {
161
- return true
162
- }
163
- } else if (pattern instanceof RegExp) {
164
- // Regex pattern match
165
- if (pattern.test(url)) {
166
- return true
167
- }
168
- }
169
- }
170
-
171
- return false
210
+ return hashId(content)
172
211
  }
173
212
 
174
213
  class SnapshotRecorder {
214
+ /** @type {NodeJS.Timeout | null} */
215
+ #flushTimeout
216
+
217
+ /** @type {import('./snapshot-utils').IsUrlExcluded} */
218
+ #isUrlExcluded
219
+
220
+ /** @type {Map<string, SnapshotEntry>} */
221
+ #snapshots = new Map()
222
+
223
+ /** @type {string|undefined} */
224
+ #snapshotPath
225
+
226
+ /** @type {number} */
227
+ #maxSnapshots = Infinity
228
+
229
+ /** @type {boolean} */
230
+ #autoFlush = false
231
+
232
+ /** @type {import('./snapshot-utils').HeaderFilters} */
233
+ #headerFilters
234
+
235
+ /**
236
+ * Creates a new SnapshotRecorder instance
237
+ * @param {SnapshotRecorderOptions&SnapshotRecorderMatchOptions} [options={}] - Configuration options for the recorder
238
+ */
175
239
  constructor (options = {}) {
176
- this.snapshots = new Map()
177
- this.snapshotPath = options.snapshotPath
178
- this.mode = options.mode || 'record'
179
- this.loaded = false
180
- this.maxSnapshots = options.maxSnapshots || Infinity
181
- this.autoFlush = options.autoFlush || false
240
+ this.#snapshotPath = options.snapshotPath
241
+ this.#maxSnapshots = options.maxSnapshots || Infinity
242
+ this.#autoFlush = options.autoFlush || false
182
243
  this.flushInterval = options.flushInterval || 30000 // 30 seconds default
183
244
  this._flushTimer = null
184
- this._flushTimeout = null
185
245
 
186
246
  // Matching configuration
247
+ /** @type {Required<SnapshotRecorderMatchOptions>} */
187
248
  this.matchOptions = {
188
- matchHeaders: options.matchHeaders || null, // null means match all headers
249
+ matchHeaders: options.matchHeaders || [], // empty means match all headers
189
250
  ignoreHeaders: options.ignoreHeaders || [],
190
251
  excludeHeaders: options.excludeHeaders || [],
191
252
  matchBody: options.matchBody !== false, // default: true
@@ -194,46 +255,49 @@ class SnapshotRecorder {
194
255
  }
195
256
 
196
257
  // Cache processed header sets to avoid recreating them on every request
197
- this._headerSetsCache = createHeaderSetsCache(this.matchOptions)
258
+ this.#headerFilters = createHeaderFilters(this.matchOptions)
198
259
 
199
260
  // Request filtering callbacks
200
- this.shouldRecord = options.shouldRecord || null // function(requestOpts) -> boolean
201
- this.shouldPlayback = options.shouldPlayback || null // function(requestOpts) -> boolean
261
+ this.shouldRecord = options.shouldRecord || (() => true) // function(requestOpts) -> boolean
262
+ this.shouldPlayback = options.shouldPlayback || (() => true) // function(requestOpts) -> boolean
202
263
 
203
264
  // URL pattern filtering
204
- this.excludeUrls = options.excludeUrls || [] // Array of regex patterns or strings
265
+ this.#isUrlExcluded = isUrlExcludedFactory(options.excludeUrls) // Array of regex patterns or strings
205
266
 
206
267
  // Start auto-flush timer if enabled
207
- if (this.autoFlush && this.snapshotPath) {
208
- this._startAutoFlush()
268
+ if (this.#autoFlush && this.#snapshotPath) {
269
+ this.#startAutoFlush()
209
270
  }
210
271
  }
211
272
 
212
273
  /**
213
274
  * Records a request-response interaction
275
+ * @param {SnapshotRequestOptions} requestOpts - Request options
276
+ * @param {SnapshotEntryResponse} response - Response data to record
277
+ * @return {Promise<void>} - Resolves when the recording is complete
214
278
  */
215
279
  async record (requestOpts, response) {
216
280
  // Check if recording should be filtered out
217
- if (this.shouldRecord && typeof this.shouldRecord === 'function') {
218
- if (!this.shouldRecord(requestOpts)) {
219
- return // Skip recording
220
- }
281
+ if (!this.shouldRecord(requestOpts)) {
282
+ return // Skip recording
221
283
  }
222
284
 
223
285
  // Check URL exclusion patterns
224
286
  const url = new URL(requestOpts.path, requestOpts.origin).toString()
225
- if (isUrlExcluded(url, this.excludeUrls)) {
287
+ if (this.#isUrlExcluded(url)) {
226
288
  return // Skip recording
227
289
  }
228
290
 
229
- const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions)
291
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
230
292
  const hash = createRequestHash(request)
231
293
 
232
294
  // Extract response data - always store body as base64
233
295
  const normalizedHeaders = normalizeHeaders(response.headers)
296
+
297
+ /** @type {SnapshotEntryResponse} */
234
298
  const responseData = {
235
299
  statusCode: response.statusCode,
236
- headers: filterHeadersForStorage(normalizedHeaders, this.matchOptions),
300
+ headers: filterHeadersForStorage(normalizedHeaders, this.#headerFilters, this.matchOptions),
237
301
  body: Buffer.isBuffer(response.body)
238
302
  ? response.body.toString('base64')
239
303
  : Buffer.from(String(response.body || '')).toString('base64'),
@@ -241,18 +305,18 @@ class SnapshotRecorder {
241
305
  }
242
306
 
243
307
  // Remove oldest snapshot if we exceed maxSnapshots limit
244
- if (this.snapshots.size >= this.maxSnapshots && !this.snapshots.has(hash)) {
245
- const oldestKey = this.snapshots.keys().next().value
246
- this.snapshots.delete(oldestKey)
308
+ if (this.#snapshots.size >= this.#maxSnapshots && !this.#snapshots.has(hash)) {
309
+ const oldestKey = this.#snapshots.keys().next().value
310
+ this.#snapshots.delete(oldestKey)
247
311
  }
248
312
 
249
313
  // Support sequential responses - if snapshot exists, add to responses array
250
- const existingSnapshot = this.snapshots.get(hash)
314
+ const existingSnapshot = this.#snapshots.get(hash)
251
315
  if (existingSnapshot && existingSnapshot.responses) {
252
316
  existingSnapshot.responses.push(responseData)
253
317
  existingSnapshot.timestamp = new Date().toISOString()
254
318
  } else {
255
- this.snapshots.set(hash, {
319
+ this.#snapshots.set(hash, {
256
320
  request,
257
321
  responses: [responseData], // Always store as array for consistency
258
322
  callCount: 0,
@@ -261,67 +325,54 @@ class SnapshotRecorder {
261
325
  }
262
326
 
263
327
  // Auto-flush if enabled
264
- if (this.autoFlush && this.snapshotPath) {
265
- this._scheduleFlush()
328
+ if (this.#autoFlush && this.#snapshotPath) {
329
+ this.#scheduleFlush()
266
330
  }
267
331
  }
268
332
 
269
333
  /**
270
334
  * Finds a matching snapshot for the given request
271
335
  * Returns the appropriate response based on call count for sequential responses
336
+ *
337
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
338
+ * @returns {SnapshotEntry&Record<'response', SnapshotEntryResponse>|undefined} - Matching snapshot response or undefined if not found
272
339
  */
273
340
  findSnapshot (requestOpts) {
274
341
  // Check if playback should be filtered out
275
- if (this.shouldPlayback && typeof this.shouldPlayback === 'function') {
276
- if (!this.shouldPlayback(requestOpts)) {
277
- return undefined // Skip playback
278
- }
342
+ if (!this.shouldPlayback(requestOpts)) {
343
+ return undefined // Skip playback
279
344
  }
280
345
 
281
346
  // Check URL exclusion patterns
282
347
  const url = new URL(requestOpts.path, requestOpts.origin).toString()
283
- if (isUrlExcluded(url, this.excludeUrls)) {
348
+ if (this.#isUrlExcluded(url)) {
284
349
  return undefined // Skip playback
285
350
  }
286
351
 
287
- const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions)
352
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
288
353
  const hash = createRequestHash(request)
289
- const snapshot = this.snapshots.get(hash)
354
+ const snapshot = this.#snapshots.get(hash)
290
355
 
291
356
  if (!snapshot) return undefined
292
357
 
293
358
  // Handle sequential responses
294
- if (snapshot.responses && Array.isArray(snapshot.responses)) {
295
- const currentCallCount = snapshot.callCount || 0
296
- const responseIndex = Math.min(currentCallCount, snapshot.responses.length - 1)
297
- snapshot.callCount = currentCallCount + 1
298
-
299
- return {
300
- ...snapshot,
301
- response: snapshot.responses[responseIndex]
302
- }
303
- }
359
+ const currentCallCount = snapshot.callCount || 0
360
+ const responseIndex = Math.min(currentCallCount, snapshot.responses.length - 1)
361
+ snapshot.callCount = currentCallCount + 1
304
362
 
305
- // Legacy format compatibility - convert single response to array format
306
- if (snapshot.response && !snapshot.responses) {
307
- snapshot.responses = [snapshot.response]
308
- snapshot.callCount = 1
309
- delete snapshot.response
310
-
311
- return {
312
- ...snapshot,
313
- response: snapshot.responses[0]
314
- }
363
+ return {
364
+ ...snapshot,
365
+ response: snapshot.responses[responseIndex]
315
366
  }
316
-
317
- return snapshot
318
367
  }
319
368
 
320
369
  /**
321
370
  * Loads snapshots from file
371
+ * @param {string} [filePath] - Optional file path to load snapshots from
372
+ * @return {Promise<void>} - Resolves when snapshots are loaded
322
373
  */
323
374
  async loadSnapshots (filePath) {
324
- const path = filePath || this.snapshotPath
375
+ const path = filePath || this.#snapshotPath
325
376
  if (!path) {
326
377
  throw new InvalidArgumentError('Snapshot path is required')
327
378
  }
@@ -332,21 +383,18 @@ class SnapshotRecorder {
332
383
 
333
384
  // Convert array format back to Map
334
385
  if (Array.isArray(parsed)) {
335
- this.snapshots.clear()
386
+ this.#snapshots.clear()
336
387
  for (const { hash, snapshot } of parsed) {
337
- this.snapshots.set(hash, snapshot)
388
+ this.#snapshots.set(hash, snapshot)
338
389
  }
339
390
  } else {
340
391
  // Legacy object format
341
- this.snapshots = new Map(Object.entries(parsed))
392
+ this.#snapshots = new Map(Object.entries(parsed))
342
393
  }
343
-
344
- this.loaded = true
345
394
  } catch (error) {
346
395
  if (error.code === 'ENOENT') {
347
396
  // File doesn't exist yet - that's ok for recording mode
348
- this.snapshots.clear()
349
- this.loaded = true
397
+ this.#snapshots.clear()
350
398
  } else {
351
399
  throw new UndiciError(`Failed to load snapshots from ${path}`, { cause: error })
352
400
  }
@@ -355,9 +403,12 @@ class SnapshotRecorder {
355
403
 
356
404
  /**
357
405
  * Saves snapshots to file
406
+ *
407
+ * @param {string} [filePath] - Optional file path to save snapshots
408
+ * @returns {Promise<void>} - Resolves when snapshots are saved
358
409
  */
359
410
  async saveSnapshots (filePath) {
360
- const path = filePath || this.snapshotPath
411
+ const path = filePath || this.#snapshotPath
361
412
  if (!path) {
362
413
  throw new InvalidArgumentError('Snapshot path is required')
363
414
  }
@@ -368,67 +419,75 @@ class SnapshotRecorder {
368
419
  await mkdir(dirname(resolvedPath), { recursive: true })
369
420
 
370
421
  // Convert Map to serializable format
371
- const data = Array.from(this.snapshots.entries()).map(([hash, snapshot]) => ({
422
+ const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
372
423
  hash,
373
424
  snapshot
374
425
  }))
375
426
 
376
- await writeFile(resolvedPath, JSON.stringify(data, null, 2), 'utf8', { flush: true })
427
+ await writeFile(resolvedPath, JSON.stringify(data, null, 2), { flush: true })
377
428
  }
378
429
 
379
430
  /**
380
431
  * Clears all recorded snapshots
432
+ * @returns {void}
381
433
  */
382
434
  clear () {
383
- this.snapshots.clear()
435
+ this.#snapshots.clear()
384
436
  }
385
437
 
386
438
  /**
387
439
  * Gets all recorded snapshots
440
+ * @return {Array<SnapshotEntry>} - Array of all recorded snapshots
388
441
  */
389
442
  getSnapshots () {
390
- return Array.from(this.snapshots.values())
443
+ return Array.from(this.#snapshots.values())
391
444
  }
392
445
 
393
446
  /**
394
447
  * Gets snapshot count
448
+ * @return {number} - Number of recorded snapshots
395
449
  */
396
450
  size () {
397
- return this.snapshots.size
451
+ return this.#snapshots.size
398
452
  }
399
453
 
400
454
  /**
401
455
  * Resets call counts for all snapshots (useful for test cleanup)
456
+ * @returns {void}
402
457
  */
403
458
  resetCallCounts () {
404
- for (const snapshot of this.snapshots.values()) {
459
+ for (const snapshot of this.#snapshots.values()) {
405
460
  snapshot.callCount = 0
406
461
  }
407
462
  }
408
463
 
409
464
  /**
410
465
  * Deletes a specific snapshot by request options
466
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
467
+ * @returns {boolean} - True if snapshot was deleted, false if not found
411
468
  */
412
469
  deleteSnapshot (requestOpts) {
413
- const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions)
470
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
414
471
  const hash = createRequestHash(request)
415
- return this.snapshots.delete(hash)
472
+ return this.#snapshots.delete(hash)
416
473
  }
417
474
 
418
475
  /**
419
476
  * Gets information about a specific snapshot
477
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
478
+ * @returns {SnapshotInfo|null} - Snapshot information or null if not found
420
479
  */
421
480
  getSnapshotInfo (requestOpts) {
422
- const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions)
481
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
423
482
  const hash = createRequestHash(request)
424
- const snapshot = this.snapshots.get(hash)
483
+ const snapshot = this.#snapshots.get(hash)
425
484
 
426
485
  if (!snapshot) return null
427
486
 
428
487
  return {
429
488
  hash,
430
489
  request: snapshot.request,
431
- responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0),
490
+ responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), // .response for legacy snapshots
432
491
  callCount: snapshot.callCount || 0,
433
492
  timestamp: snapshot.timestamp
434
493
  }
@@ -436,76 +495,80 @@ class SnapshotRecorder {
436
495
 
437
496
  /**
438
497
  * Replaces all snapshots with new data (full replacement)
498
+ * @param {Array<{hash: string; snapshot: SnapshotEntry}>|Record<string, SnapshotEntry>} snapshotData - New snapshot data to replace existing ones
499
+ * @returns {void}
439
500
  */
440
501
  replaceSnapshots (snapshotData) {
441
- this.snapshots.clear()
502
+ this.#snapshots.clear()
442
503
 
443
504
  if (Array.isArray(snapshotData)) {
444
505
  for (const { hash, snapshot } of snapshotData) {
445
- this.snapshots.set(hash, snapshot)
506
+ this.#snapshots.set(hash, snapshot)
446
507
  }
447
508
  } else if (snapshotData && typeof snapshotData === 'object') {
448
509
  // Legacy object format
449
- this.snapshots = new Map(Object.entries(snapshotData))
510
+ this.#snapshots = new Map(Object.entries(snapshotData))
450
511
  }
451
512
  }
452
513
 
453
514
  /**
454
515
  * Starts the auto-flush timer
516
+ * @returns {void}
455
517
  */
456
- _startAutoFlush () {
457
- if (!this._flushTimer) {
458
- this._flushTimer = setInterval(() => {
459
- this.saveSnapshots().catch(() => {
460
- // Ignore flush errors - they shouldn't interrupt normal operation
461
- })
462
- }, this.flushInterval)
463
- }
518
+ #startAutoFlush () {
519
+ return this.#scheduleFlush()
464
520
  }
465
521
 
466
522
  /**
467
523
  * Stops the auto-flush timer
524
+ * @returns {void}
468
525
  */
469
- _stopAutoFlush () {
470
- if (this._flushTimer) {
471
- clearInterval(this._flushTimer)
472
- this._flushTimer = null
526
+ #stopAutoFlush () {
527
+ if (this.#flushTimeout) {
528
+ clearTimeout(this.#flushTimeout)
529
+ // Ensure any pending flush is completed
530
+ this.saveSnapshots().catch(() => {
531
+ // Ignore flush errors
532
+ })
533
+ this.#flushTimeout = null
473
534
  }
474
535
  }
475
536
 
476
537
  /**
477
538
  * Schedules a flush (debounced to avoid excessive writes)
478
539
  */
479
- _scheduleFlush () {
480
- // Simple debouncing - clear existing timeout and set new one
481
- if (this._flushTimeout) {
482
- clearTimeout(this._flushTimeout)
483
- }
484
- this._flushTimeout = setTimeout(() => {
540
+ #scheduleFlush () {
541
+ this.#flushTimeout = setTimeout(() => {
485
542
  this.saveSnapshots().catch(() => {
486
543
  // Ignore flush errors
487
544
  })
488
- this._flushTimeout = null
545
+ if (this.#autoFlush) {
546
+ this.#flushTimeout?.refresh()
547
+ } else {
548
+ this.#flushTimeout = null
549
+ }
489
550
  }, 1000) // 1 second debounce
490
551
  }
491
552
 
492
553
  /**
493
554
  * Cleanup method to stop timers
555
+ * @returns {void}
494
556
  */
495
557
  destroy () {
496
- this._stopAutoFlush()
497
- if (this._flushTimeout) {
498
- clearTimeout(this._flushTimeout)
499
- this._flushTimeout = null
558
+ this.#stopAutoFlush()
559
+ if (this.#flushTimeout) {
560
+ clearTimeout(this.#flushTimeout)
561
+ this.#flushTimeout = null
500
562
  }
501
563
  }
502
564
 
503
565
  /**
504
566
  * Async close method that saves all recordings and performs cleanup
567
+ * @returns {Promise<void>}
505
568
  */
506
569
  async close () {
507
570
  // Save any pending recordings if we have a snapshot path
508
- if (this.snapshotPath && this.snapshots.size > 0) {
571
+ if (this.#snapshotPath && this.#snapshots.size !== 0) {
509
572
  await this.saveSnapshots()
510
573
  }
511
574
 
@@ -514,4 +577,4 @@ class SnapshotRecorder {
514
577
  }
515
578
  }
516
579
 
517
- module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, isUrlExcluded, createHeaderSetsCache }
580
+ module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters }