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.
- package/README.md +5 -5
- package/docs/docs/api/DiagnosticsChannel.md +25 -1
- package/lib/dispatcher/proxy-agent.js +1 -2
- package/lib/handler/cache-handler.js +22 -4
- package/lib/interceptor/cache.js +2 -2
- package/lib/mock/snapshot-agent.js +73 -59
- package/lib/mock/snapshot-recorder.js +254 -191
- package/lib/mock/snapshot-utils.js +158 -0
- package/lib/util/cache.js +3 -3
- package/lib/web/cache/cache.js +4 -4
- package/lib/web/eventsource/eventsource.js +17 -2
- package/lib/web/fetch/formdata.js +1 -1
- package/lib/web/fetch/response.js +8 -4
- package/lib/web/websocket/stream/websocketstream.js +2 -2
- package/lib/web/websocket/websocket.js +11 -4
- package/package.json +5 -5
- package/types/eventsource.d.ts +6 -1
- package/types/index.d.ts +4 -1
|
@@ -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,
|
|
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,
|
|
24
|
-
body: matchOptions.matchBody !== false && opts.body ? String(opts.body) :
|
|
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,
|
|
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 {
|
|
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 (
|
|
129
|
+
if (exclude.has(headerKey)) continue
|
|
47
130
|
|
|
48
131
|
// Skip if in ignore list (for matching)
|
|
49
|
-
if (
|
|
132
|
+
if (ignore.has(headerKey)) continue
|
|
50
133
|
|
|
51
134
|
// If matchHeaders is specified, only include those headers
|
|
52
|
-
if (
|
|
53
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
92
|
-
const
|
|
181
|
+
function createRequestHash (formattedRequest) {
|
|
182
|
+
const parts = [
|
|
183
|
+
formattedRequest.method,
|
|
184
|
+
formattedRequest.url
|
|
185
|
+
]
|
|
93
186
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
this
|
|
178
|
-
this
|
|
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 ||
|
|
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
|
|
258
|
+
this.#headerFilters = createHeaderFilters(this.matchOptions)
|
|
198
259
|
|
|
199
260
|
// Request filtering callbacks
|
|
200
|
-
this.shouldRecord = options.shouldRecord ||
|
|
201
|
-
this.shouldPlayback = options.shouldPlayback ||
|
|
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
|
|
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
|
|
208
|
-
this
|
|
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
|
|
218
|
-
|
|
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
|
|
287
|
+
if (this.#isUrlExcluded(url)) {
|
|
226
288
|
return // Skip recording
|
|
227
289
|
}
|
|
228
290
|
|
|
229
|
-
const request = formatRequestKey(requestOpts, this
|
|
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
|
|
245
|
-
const oldestKey = this
|
|
246
|
-
this
|
|
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
|
|
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
|
|
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
|
|
265
|
-
this
|
|
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
|
|
276
|
-
|
|
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
|
|
348
|
+
if (this.#isUrlExcluded(url)) {
|
|
284
349
|
return undefined // Skip playback
|
|
285
350
|
}
|
|
286
351
|
|
|
287
|
-
const request = formatRequestKey(requestOpts, this
|
|
352
|
+
const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
|
|
288
353
|
const hash = createRequestHash(request)
|
|
289
|
-
const snapshot = this
|
|
354
|
+
const snapshot = this.#snapshots.get(hash)
|
|
290
355
|
|
|
291
356
|
if (!snapshot) return undefined
|
|
292
357
|
|
|
293
358
|
// Handle sequential responses
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
snapshot.responses
|
|
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
|
|
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
|
|
386
|
+
this.#snapshots.clear()
|
|
336
387
|
for (const { hash, snapshot } of parsed) {
|
|
337
|
-
this
|
|
388
|
+
this.#snapshots.set(hash, snapshot)
|
|
338
389
|
}
|
|
339
390
|
} else {
|
|
340
391
|
// Legacy object format
|
|
341
|
-
this
|
|
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
|
|
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
|
|
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
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
470
|
+
const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
|
|
414
471
|
const hash = createRequestHash(request)
|
|
415
|
-
return this
|
|
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
|
|
481
|
+
const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
|
|
423
482
|
const hash = createRequestHash(request)
|
|
424
|
-
const snapshot = this
|
|
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
|
|
502
|
+
this.#snapshots.clear()
|
|
442
503
|
|
|
443
504
|
if (Array.isArray(snapshotData)) {
|
|
444
505
|
for (const { hash, snapshot } of snapshotData) {
|
|
445
|
-
this
|
|
506
|
+
this.#snapshots.set(hash, snapshot)
|
|
446
507
|
}
|
|
447
508
|
} else if (snapshotData && typeof snapshotData === 'object') {
|
|
448
509
|
// Legacy object format
|
|
449
|
-
this
|
|
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
|
-
|
|
457
|
-
|
|
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
|
-
|
|
470
|
-
if (this
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
|
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
|
|
497
|
-
if (this
|
|
498
|
-
clearTimeout(this
|
|
499
|
-
this
|
|
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
|
|
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,
|
|
580
|
+
module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters }
|