undici 7.12.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.
@@ -0,0 +1,580 @@
1
+ 'use strict'
2
+
3
+ const { writeFile, readFile, mkdir } = require('node:fs/promises')
4
+ const { dirname, resolve } = require('node:path')
5
+ const { setTimeout, clearTimeout } = require('node:timers')
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
+ */
81
+
82
+ /**
83
+ * Formats a request for consistent snapshot storage
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
90
+ */
91
+ function formatRequestKey (opts, headerFilters, matchOptions = {}) {
92
+ const url = new URL(opts.path, opts.origin)
93
+
94
+ // Cache normalized headers if not already done
95
+ const normalized = opts._normalizedHeaders || normalizeHeaders(opts.headers)
96
+ if (!opts._normalizedHeaders) {
97
+ opts._normalizedHeaders = normalized
98
+ }
99
+
100
+ return {
101
+ method: opts.method || 'GET',
102
+ url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`,
103
+ headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
104
+ body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : ''
105
+ }
106
+ }
107
+
108
+ /**
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
114
+ */
115
+ function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) {
116
+ if (!headers || typeof headers !== 'object') return {}
117
+
118
+ const {
119
+ caseSensitive = false
120
+ } = matchOptions
121
+
122
+ const filtered = {}
123
+ const { ignore, exclude, match } = headerFilters
124
+
125
+ for (const [key, value] of Object.entries(headers)) {
126
+ const headerKey = caseSensitive ? key : key.toLowerCase()
127
+
128
+ // Skip if in exclude list (for security)
129
+ if (exclude.has(headerKey)) continue
130
+
131
+ // Skip if in ignore list (for matching)
132
+ if (ignore.has(headerKey)) continue
133
+
134
+ // If matchHeaders is specified, only include those headers
135
+ if (match.size !== 0) {
136
+ if (!match.has(headerKey)) continue
137
+ }
138
+
139
+ filtered[headerKey] = value
140
+ }
141
+
142
+ return filtered
143
+ }
144
+
145
+ /**
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
151
+ */
152
+ function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) {
153
+ if (!headers || typeof headers !== 'object') return {}
154
+
155
+ const {
156
+ caseSensitive = false
157
+ } = matchOptions
158
+
159
+ const filtered = {}
160
+ const { exclude: excludeSet } = headerFilters
161
+
162
+ for (const [key, value] of Object.entries(headers)) {
163
+ const headerKey = caseSensitive ? key : key.toLowerCase()
164
+
165
+ // Skip if in exclude list (for security)
166
+ if (excludeSet.has(headerKey)) continue
167
+
168
+ filtered[headerKey] = value
169
+ }
170
+
171
+ return filtered
172
+ }
173
+
174
+ /**
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
180
+ */
181
+ function createRequestHash (formattedRequest) {
182
+ const parts = [
183
+ formattedRequest.method,
184
+ formattedRequest.url
185
+ ]
186
+
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]]
194
+
195
+ // Add header name
196
+ parts.push(key)
197
+
198
+ // Add all values for this header, sorted for consistency
199
+ for (const value of values.sort()) {
200
+ parts.push(String(value))
201
+ }
202
+ }
203
+ }
204
+
205
+ // Add body
206
+ parts.push(formattedRequest.body)
207
+
208
+ const content = parts.join('|')
209
+
210
+ return hashId(content)
211
+ }
212
+
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
+ */
239
+ constructor (options = {}) {
240
+ this.#snapshotPath = options.snapshotPath
241
+ this.#maxSnapshots = options.maxSnapshots || Infinity
242
+ this.#autoFlush = options.autoFlush || false
243
+ this.flushInterval = options.flushInterval || 30000 // 30 seconds default
244
+ this._flushTimer = null
245
+
246
+ // Matching configuration
247
+ /** @type {Required<SnapshotRecorderMatchOptions>} */
248
+ this.matchOptions = {
249
+ matchHeaders: options.matchHeaders || [], // empty means match all headers
250
+ ignoreHeaders: options.ignoreHeaders || [],
251
+ excludeHeaders: options.excludeHeaders || [],
252
+ matchBody: options.matchBody !== false, // default: true
253
+ matchQuery: options.matchQuery !== false, // default: true
254
+ caseSensitive: options.caseSensitive || false
255
+ }
256
+
257
+ // Cache processed header sets to avoid recreating them on every request
258
+ this.#headerFilters = createHeaderFilters(this.matchOptions)
259
+
260
+ // Request filtering callbacks
261
+ this.shouldRecord = options.shouldRecord || (() => true) // function(requestOpts) -> boolean
262
+ this.shouldPlayback = options.shouldPlayback || (() => true) // function(requestOpts) -> boolean
263
+
264
+ // URL pattern filtering
265
+ this.#isUrlExcluded = isUrlExcludedFactory(options.excludeUrls) // Array of regex patterns or strings
266
+
267
+ // Start auto-flush timer if enabled
268
+ if (this.#autoFlush && this.#snapshotPath) {
269
+ this.#startAutoFlush()
270
+ }
271
+ }
272
+
273
+ /**
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
278
+ */
279
+ async record (requestOpts, response) {
280
+ // Check if recording should be filtered out
281
+ if (!this.shouldRecord(requestOpts)) {
282
+ return // Skip recording
283
+ }
284
+
285
+ // Check URL exclusion patterns
286
+ const url = new URL(requestOpts.path, requestOpts.origin).toString()
287
+ if (this.#isUrlExcluded(url)) {
288
+ return // Skip recording
289
+ }
290
+
291
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
292
+ const hash = createRequestHash(request)
293
+
294
+ // Extract response data - always store body as base64
295
+ const normalizedHeaders = normalizeHeaders(response.headers)
296
+
297
+ /** @type {SnapshotEntryResponse} */
298
+ const responseData = {
299
+ statusCode: response.statusCode,
300
+ headers: filterHeadersForStorage(normalizedHeaders, this.#headerFilters, this.matchOptions),
301
+ body: Buffer.isBuffer(response.body)
302
+ ? response.body.toString('base64')
303
+ : Buffer.from(String(response.body || '')).toString('base64'),
304
+ trailers: response.trailers
305
+ }
306
+
307
+ // Remove oldest snapshot if we exceed maxSnapshots limit
308
+ if (this.#snapshots.size >= this.#maxSnapshots && !this.#snapshots.has(hash)) {
309
+ const oldestKey = this.#snapshots.keys().next().value
310
+ this.#snapshots.delete(oldestKey)
311
+ }
312
+
313
+ // Support sequential responses - if snapshot exists, add to responses array
314
+ const existingSnapshot = this.#snapshots.get(hash)
315
+ if (existingSnapshot && existingSnapshot.responses) {
316
+ existingSnapshot.responses.push(responseData)
317
+ existingSnapshot.timestamp = new Date().toISOString()
318
+ } else {
319
+ this.#snapshots.set(hash, {
320
+ request,
321
+ responses: [responseData], // Always store as array for consistency
322
+ callCount: 0,
323
+ timestamp: new Date().toISOString()
324
+ })
325
+ }
326
+
327
+ // Auto-flush if enabled
328
+ if (this.#autoFlush && this.#snapshotPath) {
329
+ this.#scheduleFlush()
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Finds a matching snapshot for the given request
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
339
+ */
340
+ findSnapshot (requestOpts) {
341
+ // Check if playback should be filtered out
342
+ if (!this.shouldPlayback(requestOpts)) {
343
+ return undefined // Skip playback
344
+ }
345
+
346
+ // Check URL exclusion patterns
347
+ const url = new URL(requestOpts.path, requestOpts.origin).toString()
348
+ if (this.#isUrlExcluded(url)) {
349
+ return undefined // Skip playback
350
+ }
351
+
352
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
353
+ const hash = createRequestHash(request)
354
+ const snapshot = this.#snapshots.get(hash)
355
+
356
+ if (!snapshot) return undefined
357
+
358
+ // Handle sequential responses
359
+ const currentCallCount = snapshot.callCount || 0
360
+ const responseIndex = Math.min(currentCallCount, snapshot.responses.length - 1)
361
+ snapshot.callCount = currentCallCount + 1
362
+
363
+ return {
364
+ ...snapshot,
365
+ response: snapshot.responses[responseIndex]
366
+ }
367
+ }
368
+
369
+ /**
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
373
+ */
374
+ async loadSnapshots (filePath) {
375
+ const path = filePath || this.#snapshotPath
376
+ if (!path) {
377
+ throw new InvalidArgumentError('Snapshot path is required')
378
+ }
379
+
380
+ try {
381
+ const data = await readFile(resolve(path), 'utf8')
382
+ const parsed = JSON.parse(data)
383
+
384
+ // Convert array format back to Map
385
+ if (Array.isArray(parsed)) {
386
+ this.#snapshots.clear()
387
+ for (const { hash, snapshot } of parsed) {
388
+ this.#snapshots.set(hash, snapshot)
389
+ }
390
+ } else {
391
+ // Legacy object format
392
+ this.#snapshots = new Map(Object.entries(parsed))
393
+ }
394
+ } catch (error) {
395
+ if (error.code === 'ENOENT') {
396
+ // File doesn't exist yet - that's ok for recording mode
397
+ this.#snapshots.clear()
398
+ } else {
399
+ throw new UndiciError(`Failed to load snapshots from ${path}`, { cause: error })
400
+ }
401
+ }
402
+ }
403
+
404
+ /**
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
409
+ */
410
+ async saveSnapshots (filePath) {
411
+ const path = filePath || this.#snapshotPath
412
+ if (!path) {
413
+ throw new InvalidArgumentError('Snapshot path is required')
414
+ }
415
+
416
+ const resolvedPath = resolve(path)
417
+
418
+ // Ensure directory exists
419
+ await mkdir(dirname(resolvedPath), { recursive: true })
420
+
421
+ // Convert Map to serializable format
422
+ const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
423
+ hash,
424
+ snapshot
425
+ }))
426
+
427
+ await writeFile(resolvedPath, JSON.stringify(data, null, 2), { flush: true })
428
+ }
429
+
430
+ /**
431
+ * Clears all recorded snapshots
432
+ * @returns {void}
433
+ */
434
+ clear () {
435
+ this.#snapshots.clear()
436
+ }
437
+
438
+ /**
439
+ * Gets all recorded snapshots
440
+ * @return {Array<SnapshotEntry>} - Array of all recorded snapshots
441
+ */
442
+ getSnapshots () {
443
+ return Array.from(this.#snapshots.values())
444
+ }
445
+
446
+ /**
447
+ * Gets snapshot count
448
+ * @return {number} - Number of recorded snapshots
449
+ */
450
+ size () {
451
+ return this.#snapshots.size
452
+ }
453
+
454
+ /**
455
+ * Resets call counts for all snapshots (useful for test cleanup)
456
+ * @returns {void}
457
+ */
458
+ resetCallCounts () {
459
+ for (const snapshot of this.#snapshots.values()) {
460
+ snapshot.callCount = 0
461
+ }
462
+ }
463
+
464
+ /**
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
468
+ */
469
+ deleteSnapshot (requestOpts) {
470
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
471
+ const hash = createRequestHash(request)
472
+ return this.#snapshots.delete(hash)
473
+ }
474
+
475
+ /**
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
479
+ */
480
+ getSnapshotInfo (requestOpts) {
481
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
482
+ const hash = createRequestHash(request)
483
+ const snapshot = this.#snapshots.get(hash)
484
+
485
+ if (!snapshot) return null
486
+
487
+ return {
488
+ hash,
489
+ request: snapshot.request,
490
+ responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), // .response for legacy snapshots
491
+ callCount: snapshot.callCount || 0,
492
+ timestamp: snapshot.timestamp
493
+ }
494
+ }
495
+
496
+ /**
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}
500
+ */
501
+ replaceSnapshots (snapshotData) {
502
+ this.#snapshots.clear()
503
+
504
+ if (Array.isArray(snapshotData)) {
505
+ for (const { hash, snapshot } of snapshotData) {
506
+ this.#snapshots.set(hash, snapshot)
507
+ }
508
+ } else if (snapshotData && typeof snapshotData === 'object') {
509
+ // Legacy object format
510
+ this.#snapshots = new Map(Object.entries(snapshotData))
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Starts the auto-flush timer
516
+ * @returns {void}
517
+ */
518
+ #startAutoFlush () {
519
+ return this.#scheduleFlush()
520
+ }
521
+
522
+ /**
523
+ * Stops the auto-flush timer
524
+ * @returns {void}
525
+ */
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
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Schedules a flush (debounced to avoid excessive writes)
539
+ */
540
+ #scheduleFlush () {
541
+ this.#flushTimeout = setTimeout(() => {
542
+ this.saveSnapshots().catch(() => {
543
+ // Ignore flush errors
544
+ })
545
+ if (this.#autoFlush) {
546
+ this.#flushTimeout?.refresh()
547
+ } else {
548
+ this.#flushTimeout = null
549
+ }
550
+ }, 1000) // 1 second debounce
551
+ }
552
+
553
+ /**
554
+ * Cleanup method to stop timers
555
+ * @returns {void}
556
+ */
557
+ destroy () {
558
+ this.#stopAutoFlush()
559
+ if (this.#flushTimeout) {
560
+ clearTimeout(this.#flushTimeout)
561
+ this.#flushTimeout = null
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Async close method that saves all recordings and performs cleanup
567
+ * @returns {Promise<void>}
568
+ */
569
+ async close () {
570
+ // Save any pending recordings if we have a snapshot path
571
+ if (this.#snapshotPath && this.#snapshots.size !== 0) {
572
+ await this.saveSnapshots()
573
+ }
574
+
575
+ // Perform cleanup
576
+ this.destroy()
577
+ }
578
+ }
579
+
580
+ module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters }