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.
- package/README.md +16 -14
- package/docs/docs/api/DiagnosticsChannel.md +25 -1
- package/docs/docs/api/ProxyAgent.md +1 -1
- package/docs/docs/api/SnapshotAgent.md +616 -0
- package/index.js +2 -0
- package/lib/api/readable.js +48 -26
- package/lib/core/util.js +0 -1
- package/lib/dispatcher/proxy-agent.js +68 -73
- package/lib/handler/cache-handler.js +22 -4
- package/lib/handler/redirect-handler.js +10 -0
- package/lib/interceptor/cache.js +2 -2
- package/lib/interceptor/dump.js +2 -1
- package/lib/mock/mock-agent.js +10 -4
- package/lib/mock/snapshot-agent.js +347 -0
- package/lib/mock/snapshot-recorder.js +580 -0
- 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/body.js +0 -1
- package/lib/web/fetch/formdata-parser.js +0 -3
- package/lib/web/fetch/formdata.js +1 -5
- package/lib/web/fetch/response.js +8 -4
- package/lib/web/webidl/index.js +1 -1
- package/lib/web/websocket/stream/websocketstream.js +2 -2
- package/lib/web/websocket/websocket.js +11 -4
- package/package.json +5 -5
- package/types/agent.d.ts +0 -4
- package/types/client.d.ts +0 -2
- package/types/dispatcher.d.ts +0 -6
- package/types/eventsource.d.ts +6 -1
- package/types/h2c-client.d.ts +0 -2
- package/types/index.d.ts +6 -1
- package/types/mock-interceptor.d.ts +0 -1
- package/types/snapshot-agent.d.ts +107 -0
|
@@ -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 }
|