undici 7.12.0 → 7.13.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 +11 -9
- 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 +67 -71
- package/lib/handler/redirect-handler.js +10 -0
- package/lib/interceptor/dump.js +2 -1
- package/lib/mock/mock-agent.js +10 -4
- package/lib/mock/snapshot-agent.js +333 -0
- package/lib/mock/snapshot-recorder.js +517 -0
- package/lib/web/fetch/body.js +0 -1
- package/lib/web/fetch/formdata-parser.js +0 -3
- package/lib/web/fetch/formdata.js +0 -4
- package/lib/web/webidl/index.js +1 -1
- package/package.json +1 -1
- package/types/agent.d.ts +0 -4
- package/types/client.d.ts +0 -2
- package/types/dispatcher.d.ts +0 -6
- package/types/h2c-client.d.ts +0 -2
- package/types/index.d.ts +3 -1
- package/types/mock-interceptor.d.ts +0 -1
- package/types/snapshot-agent.d.ts +107 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { writeFile, readFile, mkdir } = require('node:fs/promises')
|
|
4
|
+
const { dirname, resolve } = require('node:path')
|
|
5
|
+
const { InvalidArgumentError, UndiciError } = require('../core/errors')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Formats a request for consistent snapshot storage
|
|
9
|
+
* Caches normalized headers to avoid repeated processing
|
|
10
|
+
*/
|
|
11
|
+
function formatRequestKey (opts, cachedSets, matchOptions = {}) {
|
|
12
|
+
const url = new URL(opts.path, opts.origin)
|
|
13
|
+
|
|
14
|
+
// Cache normalized headers if not already done
|
|
15
|
+
const normalized = opts._normalizedHeaders || normalizeHeaders(opts.headers)
|
|
16
|
+
if (!opts._normalizedHeaders) {
|
|
17
|
+
opts._normalizedHeaders = normalized
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
method: opts.method || 'GET',
|
|
22
|
+
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
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Filters headers based on matching configuration
|
|
30
|
+
*/
|
|
31
|
+
function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) {
|
|
32
|
+
if (!headers || typeof headers !== 'object') return {}
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
matchHeaders = null,
|
|
36
|
+
caseSensitive = false
|
|
37
|
+
} = matchOptions
|
|
38
|
+
|
|
39
|
+
const filtered = {}
|
|
40
|
+
const { ignoreSet, excludeSet, matchSet } = cachedSets
|
|
41
|
+
|
|
42
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
43
|
+
const headerKey = caseSensitive ? key : key.toLowerCase()
|
|
44
|
+
|
|
45
|
+
// Skip if in exclude list (for security)
|
|
46
|
+
if (excludeSet.has(headerKey)) continue
|
|
47
|
+
|
|
48
|
+
// Skip if in ignore list (for matching)
|
|
49
|
+
if (ignoreSet.has(headerKey)) continue
|
|
50
|
+
|
|
51
|
+
// If matchHeaders is specified, only include those headers
|
|
52
|
+
if (matchHeaders && Array.isArray(matchHeaders)) {
|
|
53
|
+
if (!matchSet.has(headerKey)) continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
filtered[headerKey] = value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return filtered
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Filters headers for storage (only excludes sensitive headers)
|
|
64
|
+
*/
|
|
65
|
+
function filterHeadersForStorage (headers, matchOptions = {}) {
|
|
66
|
+
if (!headers || typeof headers !== 'object') return {}
|
|
67
|
+
|
|
68
|
+
const {
|
|
69
|
+
excludeHeaders = [],
|
|
70
|
+
caseSensitive = false
|
|
71
|
+
} = matchOptions
|
|
72
|
+
|
|
73
|
+
const filtered = {}
|
|
74
|
+
const excludeSet = new Set(excludeHeaders.map(h => caseSensitive ? h : h.toLowerCase()))
|
|
75
|
+
|
|
76
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
77
|
+
const headerKey = caseSensitive ? key : key.toLowerCase()
|
|
78
|
+
|
|
79
|
+
// Skip if in exclude list (for security)
|
|
80
|
+
if (excludeSet.has(headerKey)) continue
|
|
81
|
+
|
|
82
|
+
filtered[headerKey] = value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return filtered
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Creates cached header sets for performance
|
|
90
|
+
*/
|
|
91
|
+
function createHeaderSetsCache (matchOptions = {}) {
|
|
92
|
+
const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = null, caseSensitive = false } = matchOptions
|
|
93
|
+
|
|
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
|
+
}
|
|
102
|
+
|
|
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
|
+
}
|
|
125
|
+
|
|
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)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return normalized
|
|
136
|
+
}
|
|
137
|
+
|
|
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
|
+
}
|
|
150
|
+
|
|
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
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
class SnapshotRecorder {
|
|
175
|
+
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
|
|
182
|
+
this.flushInterval = options.flushInterval || 30000 // 30 seconds default
|
|
183
|
+
this._flushTimer = null
|
|
184
|
+
this._flushTimeout = null
|
|
185
|
+
|
|
186
|
+
// Matching configuration
|
|
187
|
+
this.matchOptions = {
|
|
188
|
+
matchHeaders: options.matchHeaders || null, // null means match all headers
|
|
189
|
+
ignoreHeaders: options.ignoreHeaders || [],
|
|
190
|
+
excludeHeaders: options.excludeHeaders || [],
|
|
191
|
+
matchBody: options.matchBody !== false, // default: true
|
|
192
|
+
matchQuery: options.matchQuery !== false, // default: true
|
|
193
|
+
caseSensitive: options.caseSensitive || false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Cache processed header sets to avoid recreating them on every request
|
|
197
|
+
this._headerSetsCache = createHeaderSetsCache(this.matchOptions)
|
|
198
|
+
|
|
199
|
+
// Request filtering callbacks
|
|
200
|
+
this.shouldRecord = options.shouldRecord || null // function(requestOpts) -> boolean
|
|
201
|
+
this.shouldPlayback = options.shouldPlayback || null // function(requestOpts) -> boolean
|
|
202
|
+
|
|
203
|
+
// URL pattern filtering
|
|
204
|
+
this.excludeUrls = options.excludeUrls || [] // Array of regex patterns or strings
|
|
205
|
+
|
|
206
|
+
// Start auto-flush timer if enabled
|
|
207
|
+
if (this.autoFlush && this.snapshotPath) {
|
|
208
|
+
this._startAutoFlush()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Records a request-response interaction
|
|
214
|
+
*/
|
|
215
|
+
async record (requestOpts, response) {
|
|
216
|
+
// 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
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check URL exclusion patterns
|
|
224
|
+
const url = new URL(requestOpts.path, requestOpts.origin).toString()
|
|
225
|
+
if (isUrlExcluded(url, this.excludeUrls)) {
|
|
226
|
+
return // Skip recording
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions)
|
|
230
|
+
const hash = createRequestHash(request)
|
|
231
|
+
|
|
232
|
+
// Extract response data - always store body as base64
|
|
233
|
+
const normalizedHeaders = normalizeHeaders(response.headers)
|
|
234
|
+
const responseData = {
|
|
235
|
+
statusCode: response.statusCode,
|
|
236
|
+
headers: filterHeadersForStorage(normalizedHeaders, this.matchOptions),
|
|
237
|
+
body: Buffer.isBuffer(response.body)
|
|
238
|
+
? response.body.toString('base64')
|
|
239
|
+
: Buffer.from(String(response.body || '')).toString('base64'),
|
|
240
|
+
trailers: response.trailers
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 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)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Support sequential responses - if snapshot exists, add to responses array
|
|
250
|
+
const existingSnapshot = this.snapshots.get(hash)
|
|
251
|
+
if (existingSnapshot && existingSnapshot.responses) {
|
|
252
|
+
existingSnapshot.responses.push(responseData)
|
|
253
|
+
existingSnapshot.timestamp = new Date().toISOString()
|
|
254
|
+
} else {
|
|
255
|
+
this.snapshots.set(hash, {
|
|
256
|
+
request,
|
|
257
|
+
responses: [responseData], // Always store as array for consistency
|
|
258
|
+
callCount: 0,
|
|
259
|
+
timestamp: new Date().toISOString()
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Auto-flush if enabled
|
|
264
|
+
if (this.autoFlush && this.snapshotPath) {
|
|
265
|
+
this._scheduleFlush()
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Finds a matching snapshot for the given request
|
|
271
|
+
* Returns the appropriate response based on call count for sequential responses
|
|
272
|
+
*/
|
|
273
|
+
findSnapshot (requestOpts) {
|
|
274
|
+
// 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
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Check URL exclusion patterns
|
|
282
|
+
const url = new URL(requestOpts.path, requestOpts.origin).toString()
|
|
283
|
+
if (isUrlExcluded(url, this.excludeUrls)) {
|
|
284
|
+
return undefined // Skip playback
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions)
|
|
288
|
+
const hash = createRequestHash(request)
|
|
289
|
+
const snapshot = this.snapshots.get(hash)
|
|
290
|
+
|
|
291
|
+
if (!snapshot) return undefined
|
|
292
|
+
|
|
293
|
+
// 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
|
+
}
|
|
304
|
+
|
|
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
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return snapshot
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Loads snapshots from file
|
|
322
|
+
*/
|
|
323
|
+
async loadSnapshots (filePath) {
|
|
324
|
+
const path = filePath || this.snapshotPath
|
|
325
|
+
if (!path) {
|
|
326
|
+
throw new InvalidArgumentError('Snapshot path is required')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const data = await readFile(resolve(path), 'utf8')
|
|
331
|
+
const parsed = JSON.parse(data)
|
|
332
|
+
|
|
333
|
+
// Convert array format back to Map
|
|
334
|
+
if (Array.isArray(parsed)) {
|
|
335
|
+
this.snapshots.clear()
|
|
336
|
+
for (const { hash, snapshot } of parsed) {
|
|
337
|
+
this.snapshots.set(hash, snapshot)
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
// Legacy object format
|
|
341
|
+
this.snapshots = new Map(Object.entries(parsed))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
this.loaded = true
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error.code === 'ENOENT') {
|
|
347
|
+
// File doesn't exist yet - that's ok for recording mode
|
|
348
|
+
this.snapshots.clear()
|
|
349
|
+
this.loaded = true
|
|
350
|
+
} else {
|
|
351
|
+
throw new UndiciError(`Failed to load snapshots from ${path}`, { cause: error })
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Saves snapshots to file
|
|
358
|
+
*/
|
|
359
|
+
async saveSnapshots (filePath) {
|
|
360
|
+
const path = filePath || this.snapshotPath
|
|
361
|
+
if (!path) {
|
|
362
|
+
throw new InvalidArgumentError('Snapshot path is required')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const resolvedPath = resolve(path)
|
|
366
|
+
|
|
367
|
+
// Ensure directory exists
|
|
368
|
+
await mkdir(dirname(resolvedPath), { recursive: true })
|
|
369
|
+
|
|
370
|
+
// Convert Map to serializable format
|
|
371
|
+
const data = Array.from(this.snapshots.entries()).map(([hash, snapshot]) => ({
|
|
372
|
+
hash,
|
|
373
|
+
snapshot
|
|
374
|
+
}))
|
|
375
|
+
|
|
376
|
+
await writeFile(resolvedPath, JSON.stringify(data, null, 2), 'utf8', { flush: true })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Clears all recorded snapshots
|
|
381
|
+
*/
|
|
382
|
+
clear () {
|
|
383
|
+
this.snapshots.clear()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Gets all recorded snapshots
|
|
388
|
+
*/
|
|
389
|
+
getSnapshots () {
|
|
390
|
+
return Array.from(this.snapshots.values())
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Gets snapshot count
|
|
395
|
+
*/
|
|
396
|
+
size () {
|
|
397
|
+
return this.snapshots.size
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Resets call counts for all snapshots (useful for test cleanup)
|
|
402
|
+
*/
|
|
403
|
+
resetCallCounts () {
|
|
404
|
+
for (const snapshot of this.snapshots.values()) {
|
|
405
|
+
snapshot.callCount = 0
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Deletes a specific snapshot by request options
|
|
411
|
+
*/
|
|
412
|
+
deleteSnapshot (requestOpts) {
|
|
413
|
+
const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions)
|
|
414
|
+
const hash = createRequestHash(request)
|
|
415
|
+
return this.snapshots.delete(hash)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Gets information about a specific snapshot
|
|
420
|
+
*/
|
|
421
|
+
getSnapshotInfo (requestOpts) {
|
|
422
|
+
const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions)
|
|
423
|
+
const hash = createRequestHash(request)
|
|
424
|
+
const snapshot = this.snapshots.get(hash)
|
|
425
|
+
|
|
426
|
+
if (!snapshot) return null
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
hash,
|
|
430
|
+
request: snapshot.request,
|
|
431
|
+
responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0),
|
|
432
|
+
callCount: snapshot.callCount || 0,
|
|
433
|
+
timestamp: snapshot.timestamp
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Replaces all snapshots with new data (full replacement)
|
|
439
|
+
*/
|
|
440
|
+
replaceSnapshots (snapshotData) {
|
|
441
|
+
this.snapshots.clear()
|
|
442
|
+
|
|
443
|
+
if (Array.isArray(snapshotData)) {
|
|
444
|
+
for (const { hash, snapshot } of snapshotData) {
|
|
445
|
+
this.snapshots.set(hash, snapshot)
|
|
446
|
+
}
|
|
447
|
+
} else if (snapshotData && typeof snapshotData === 'object') {
|
|
448
|
+
// Legacy object format
|
|
449
|
+
this.snapshots = new Map(Object.entries(snapshotData))
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Starts the auto-flush timer
|
|
455
|
+
*/
|
|
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
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Stops the auto-flush timer
|
|
468
|
+
*/
|
|
469
|
+
_stopAutoFlush () {
|
|
470
|
+
if (this._flushTimer) {
|
|
471
|
+
clearInterval(this._flushTimer)
|
|
472
|
+
this._flushTimer = null
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Schedules a flush (debounced to avoid excessive writes)
|
|
478
|
+
*/
|
|
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(() => {
|
|
485
|
+
this.saveSnapshots().catch(() => {
|
|
486
|
+
// Ignore flush errors
|
|
487
|
+
})
|
|
488
|
+
this._flushTimeout = null
|
|
489
|
+
}, 1000) // 1 second debounce
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Cleanup method to stop timers
|
|
494
|
+
*/
|
|
495
|
+
destroy () {
|
|
496
|
+
this._stopAutoFlush()
|
|
497
|
+
if (this._flushTimeout) {
|
|
498
|
+
clearTimeout(this._flushTimeout)
|
|
499
|
+
this._flushTimeout = null
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Async close method that saves all recordings and performs cleanup
|
|
505
|
+
*/
|
|
506
|
+
async close () {
|
|
507
|
+
// Save any pending recordings if we have a snapshot path
|
|
508
|
+
if (this.snapshotPath && this.snapshots.size > 0) {
|
|
509
|
+
await this.saveSnapshots()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Perform cleanup
|
|
513
|
+
this.destroy()
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, isUrlExcluded, createHeaderSetsCache }
|
package/lib/web/fetch/body.js
CHANGED
|
@@ -10,7 +10,6 @@ const {
|
|
|
10
10
|
} = require('./util')
|
|
11
11
|
const { FormData, setFormDataState } = require('./formdata')
|
|
12
12
|
const { webidl } = require('../webidl')
|
|
13
|
-
const { Blob } = require('node:buffer')
|
|
14
13
|
const assert = require('node:assert')
|
|
15
14
|
const { isErrored, isDisturbed } = require('node:stream')
|
|
16
15
|
const { isArrayBuffer } = require('node:util/types')
|
|
@@ -6,9 +6,6 @@ const { HTTP_TOKEN_CODEPOINTS, isomorphicDecode } = require('./data-url')
|
|
|
6
6
|
const { makeEntry } = require('./formdata')
|
|
7
7
|
const { webidl } = require('../webidl')
|
|
8
8
|
const assert = require('node:assert')
|
|
9
|
-
const { File: NodeFile } = require('node:buffer')
|
|
10
|
-
|
|
11
|
-
const File = globalThis.File ?? NodeFile
|
|
12
9
|
|
|
13
10
|
const formDataNameBuffer = Buffer.from('form-data; name="')
|
|
14
11
|
const filenameBuffer = Buffer.from('filename')
|
|
@@ -3,12 +3,8 @@
|
|
|
3
3
|
const { iteratorMixin } = require('./util')
|
|
4
4
|
const { kEnumerableProperty } = require('../../core/util')
|
|
5
5
|
const { webidl } = require('../webidl')
|
|
6
|
-
const { File: NativeFile } = require('node:buffer')
|
|
7
6
|
const nodeUtil = require('node:util')
|
|
8
7
|
|
|
9
|
-
/** @type {globalThis['File']} */
|
|
10
|
-
const File = globalThis.File ?? NativeFile
|
|
11
|
-
|
|
12
8
|
// https://xhr.spec.whatwg.org/#formdata
|
|
13
9
|
class FormData {
|
|
14
10
|
#state = []
|
package/lib/web/webidl/index.js
CHANGED
|
@@ -509,7 +509,7 @@ webidl.is.USVString = function (value) {
|
|
|
509
509
|
webidl.is.ReadableStream = webidl.util.MakeTypeAssertion(ReadableStream)
|
|
510
510
|
webidl.is.Blob = webidl.util.MakeTypeAssertion(Blob)
|
|
511
511
|
webidl.is.URLSearchParams = webidl.util.MakeTypeAssertion(URLSearchParams)
|
|
512
|
-
webidl.is.File = webidl.util.MakeTypeAssertion(
|
|
512
|
+
webidl.is.File = webidl.util.MakeTypeAssertion(File)
|
|
513
513
|
webidl.is.URL = webidl.util.MakeTypeAssertion(URL)
|
|
514
514
|
webidl.is.AbortSignal = webidl.util.MakeTypeAssertion(AbortSignal)
|
|
515
515
|
webidl.is.MessagePort = webidl.util.MakeTypeAssertion(MessagePort)
|
package/package.json
CHANGED
package/types/agent.d.ts
CHANGED
|
@@ -22,14 +22,10 @@ declare namespace Agent {
|
|
|
22
22
|
export interface Options extends Pool.Options {
|
|
23
23
|
/** Default: `(origin, opts) => new Pool(origin, opts)`. */
|
|
24
24
|
factory?(origin: string | URL, opts: Object): Dispatcher;
|
|
25
|
-
/** Integer. Default: `0` */
|
|
26
|
-
maxRedirections?: number;
|
|
27
25
|
|
|
28
26
|
interceptors?: { Agent?: readonly Dispatcher.DispatchInterceptor[] } & Pool.Options['interceptors']
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
export interface DispatchOptions extends Dispatcher.DispatchOptions {
|
|
32
|
-
/** Integer. */
|
|
33
|
-
maxRedirections?: number;
|
|
34
30
|
}
|
|
35
31
|
}
|
package/types/client.d.ts
CHANGED
|
@@ -71,8 +71,6 @@ export declare namespace Client {
|
|
|
71
71
|
/** TODO */
|
|
72
72
|
maxCachedSessions?: number;
|
|
73
73
|
/** TODO */
|
|
74
|
-
maxRedirections?: number;
|
|
75
|
-
/** TODO */
|
|
76
74
|
connect?: Partial<buildConnector.BuildOptions> | buildConnector.connector;
|
|
77
75
|
/** TODO */
|
|
78
76
|
maxRequestsPerClient?: number;
|
package/types/dispatcher.d.ts
CHANGED
|
@@ -135,8 +135,6 @@ declare namespace Dispatcher {
|
|
|
135
135
|
signal?: AbortSignal | EventEmitter | null;
|
|
136
136
|
/** This argument parameter is passed through to `ConnectData` */
|
|
137
137
|
opaque?: TOpaque;
|
|
138
|
-
/** Default: 0 */
|
|
139
|
-
maxRedirections?: number;
|
|
140
138
|
/** Default: false */
|
|
141
139
|
redirectionLimitReached?: boolean;
|
|
142
140
|
/** Default: `null` */
|
|
@@ -147,8 +145,6 @@ declare namespace Dispatcher {
|
|
|
147
145
|
opaque?: TOpaque;
|
|
148
146
|
/** Default: `null` */
|
|
149
147
|
signal?: AbortSignal | EventEmitter | null;
|
|
150
|
-
/** Default: 0 */
|
|
151
|
-
maxRedirections?: number;
|
|
152
148
|
/** Default: false */
|
|
153
149
|
redirectionLimitReached?: boolean;
|
|
154
150
|
/** Default: `null` */
|
|
@@ -172,8 +168,6 @@ declare namespace Dispatcher {
|
|
|
172
168
|
protocol?: string;
|
|
173
169
|
/** Default: `null` */
|
|
174
170
|
signal?: AbortSignal | EventEmitter | null;
|
|
175
|
-
/** Default: 0 */
|
|
176
|
-
maxRedirections?: number;
|
|
177
171
|
/** Default: false */
|
|
178
172
|
redirectionLimitReached?: boolean;
|
|
179
173
|
/** Default: `null` */
|
package/types/h2c-client.d.ts
CHANGED
|
@@ -51,8 +51,6 @@ export declare namespace H2CClient {
|
|
|
51
51
|
/** TODO */
|
|
52
52
|
maxCachedSessions?: number;
|
|
53
53
|
/** TODO */
|
|
54
|
-
maxRedirections?: number;
|
|
55
|
-
/** TODO */
|
|
56
54
|
connect?: Omit<Partial<buildConnector.BuildOptions>, 'allowH2'> | buildConnector.connector;
|
|
57
55
|
/** TODO */
|
|
58
56
|
maxRequestsPerClient?: number;
|
package/types/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import Agent from './agent'
|
|
|
13
13
|
import MockClient from './mock-client'
|
|
14
14
|
import MockPool from './mock-pool'
|
|
15
15
|
import MockAgent from './mock-agent'
|
|
16
|
+
import { SnapshotAgent } from './snapshot-agent'
|
|
16
17
|
import { MockCallHistory, MockCallHistoryLog } from './mock-call-history'
|
|
17
18
|
import mockErrors from './mock-errors'
|
|
18
19
|
import ProxyAgent from './proxy-agent'
|
|
@@ -33,7 +34,7 @@ export * from './content-type'
|
|
|
33
34
|
export * from './cache'
|
|
34
35
|
export { Interceptable } from './mock-interceptor'
|
|
35
36
|
|
|
36
|
-
export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, MockClient, MockPool, MockAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent, H2CClient }
|
|
37
|
+
export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, MockClient, MockPool, MockAgent, SnapshotAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent, H2CClient }
|
|
37
38
|
export default Undici
|
|
38
39
|
|
|
39
40
|
declare namespace Undici {
|
|
@@ -58,6 +59,7 @@ declare namespace Undici {
|
|
|
58
59
|
const MockClient: typeof import('./mock-client').default
|
|
59
60
|
const MockPool: typeof import('./mock-pool').default
|
|
60
61
|
const MockAgent: typeof import('./mock-agent').default
|
|
62
|
+
const SnapshotAgent: typeof import('./snapshot-agent').SnapshotAgent
|
|
61
63
|
const MockCallHistory: typeof import('./mock-call-history').MockCallHistory
|
|
62
64
|
const MockCallHistoryLog: typeof import('./mock-call-history').MockCallHistoryLog
|
|
63
65
|
const mockErrors: typeof import('./mock-errors').default
|
|
@@ -69,7 +69,6 @@ declare namespace MockInterceptor {
|
|
|
69
69
|
headers?: Headers | Record<string, string>;
|
|
70
70
|
origin?: string;
|
|
71
71
|
body?: BodyInit | Dispatcher.DispatchOptions['body'] | null;
|
|
72
|
-
maxRedirections?: number;
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
export type MockResponseDataHandler<TData extends object = object> = (
|