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,347 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Agent = require('../dispatcher/agent')
|
|
4
|
+
const MockAgent = require('./mock-agent')
|
|
5
|
+
const { SnapshotRecorder } = require('./snapshot-recorder')
|
|
6
|
+
const WrapHandler = require('../handler/wrap-handler')
|
|
7
|
+
const { InvalidArgumentError, UndiciError } = require('../core/errors')
|
|
8
|
+
const { validateSnapshotMode } = require('./snapshot-utils')
|
|
9
|
+
|
|
10
|
+
const kSnapshotRecorder = Symbol('kSnapshotRecorder')
|
|
11
|
+
const kSnapshotMode = Symbol('kSnapshotMode')
|
|
12
|
+
const kSnapshotPath = Symbol('kSnapshotPath')
|
|
13
|
+
const kSnapshotLoaded = Symbol('kSnapshotLoaded')
|
|
14
|
+
const kRealAgent = Symbol('kRealAgent')
|
|
15
|
+
|
|
16
|
+
// Static flag to ensure warning is only emitted once per process
|
|
17
|
+
let warningEmitted = false
|
|
18
|
+
|
|
19
|
+
class SnapshotAgent extends MockAgent {
|
|
20
|
+
constructor (opts = {}) {
|
|
21
|
+
// Emit experimental warning only once
|
|
22
|
+
if (!warningEmitted) {
|
|
23
|
+
process.emitWarning(
|
|
24
|
+
'SnapshotAgent is experimental and subject to change',
|
|
25
|
+
'ExperimentalWarning'
|
|
26
|
+
)
|
|
27
|
+
warningEmitted = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
mode = 'record',
|
|
32
|
+
snapshotPath = null,
|
|
33
|
+
...mockAgentOpts
|
|
34
|
+
} = opts
|
|
35
|
+
|
|
36
|
+
super(mockAgentOpts)
|
|
37
|
+
|
|
38
|
+
validateSnapshotMode(mode)
|
|
39
|
+
|
|
40
|
+
// Validate snapshotPath is provided when required
|
|
41
|
+
if ((mode === 'playback' || mode === 'update') && !snapshotPath) {
|
|
42
|
+
throw new InvalidArgumentError(`snapshotPath is required when mode is '${mode}'`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this[kSnapshotMode] = mode
|
|
46
|
+
this[kSnapshotPath] = snapshotPath
|
|
47
|
+
|
|
48
|
+
this[kSnapshotRecorder] = new SnapshotRecorder({
|
|
49
|
+
snapshotPath: this[kSnapshotPath],
|
|
50
|
+
mode: this[kSnapshotMode],
|
|
51
|
+
maxSnapshots: opts.maxSnapshots,
|
|
52
|
+
autoFlush: opts.autoFlush,
|
|
53
|
+
flushInterval: opts.flushInterval,
|
|
54
|
+
matchHeaders: opts.matchHeaders,
|
|
55
|
+
ignoreHeaders: opts.ignoreHeaders,
|
|
56
|
+
excludeHeaders: opts.excludeHeaders,
|
|
57
|
+
matchBody: opts.matchBody,
|
|
58
|
+
matchQuery: opts.matchQuery,
|
|
59
|
+
caseSensitive: opts.caseSensitive,
|
|
60
|
+
shouldRecord: opts.shouldRecord,
|
|
61
|
+
shouldPlayback: opts.shouldPlayback,
|
|
62
|
+
excludeUrls: opts.excludeUrls
|
|
63
|
+
})
|
|
64
|
+
this[kSnapshotLoaded] = false
|
|
65
|
+
|
|
66
|
+
// For recording/update mode, we need a real agent to make actual requests
|
|
67
|
+
if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update') {
|
|
68
|
+
this[kRealAgent] = new Agent(opts)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Auto-load snapshots in playback/update mode
|
|
72
|
+
if ((this[kSnapshotMode] === 'playback' || this[kSnapshotMode] === 'update') && this[kSnapshotPath]) {
|
|
73
|
+
this.loadSnapshots().catch(() => {
|
|
74
|
+
// Ignore load errors - file might not exist yet
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
dispatch (opts, handler) {
|
|
80
|
+
handler = WrapHandler.wrap(handler)
|
|
81
|
+
const mode = this[kSnapshotMode]
|
|
82
|
+
|
|
83
|
+
if (mode === 'playback' || mode === 'update') {
|
|
84
|
+
// Ensure snapshots are loaded
|
|
85
|
+
if (!this[kSnapshotLoaded]) {
|
|
86
|
+
// Need to load asynchronously, delegate to async version
|
|
87
|
+
return this.#asyncDispatch(opts, handler)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Try to find existing snapshot (synchronous)
|
|
91
|
+
const snapshot = this[kSnapshotRecorder].findSnapshot(opts)
|
|
92
|
+
|
|
93
|
+
if (snapshot) {
|
|
94
|
+
// Use recorded response (synchronous)
|
|
95
|
+
return this.#replaySnapshot(snapshot, handler)
|
|
96
|
+
} else if (mode === 'update') {
|
|
97
|
+
// Make real request and record it (async required)
|
|
98
|
+
return this.#recordAndReplay(opts, handler)
|
|
99
|
+
} else {
|
|
100
|
+
// Playback mode but no snapshot found
|
|
101
|
+
const error = new UndiciError(`No snapshot found for ${opts.method || 'GET'} ${opts.path}`)
|
|
102
|
+
if (handler.onError) {
|
|
103
|
+
handler.onError(error)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
} else if (mode === 'record') {
|
|
109
|
+
// Record mode - make real request and save response (async required)
|
|
110
|
+
return this.#recordAndReplay(opts, handler)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Async version of dispatch for when we need to load snapshots first
|
|
116
|
+
*/
|
|
117
|
+
async #asyncDispatch (opts, handler) {
|
|
118
|
+
await this.loadSnapshots()
|
|
119
|
+
return this.dispatch(opts, handler)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Records a real request and replays the response
|
|
124
|
+
*/
|
|
125
|
+
#recordAndReplay (opts, handler) {
|
|
126
|
+
const responseData = {
|
|
127
|
+
statusCode: null,
|
|
128
|
+
headers: {},
|
|
129
|
+
trailers: {},
|
|
130
|
+
body: []
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const self = this // Capture 'this' context for use within nested handler callbacks
|
|
134
|
+
|
|
135
|
+
const recordingHandler = {
|
|
136
|
+
onRequestStart (controller, context) {
|
|
137
|
+
return handler.onRequestStart(controller, { ...context, history: this.history })
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
onRequestUpgrade (controller, statusCode, headers, socket) {
|
|
141
|
+
return handler.onRequestUpgrade(controller, statusCode, headers, socket)
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
onResponseStart (controller, statusCode, headers, statusMessage) {
|
|
145
|
+
responseData.statusCode = statusCode
|
|
146
|
+
responseData.headers = headers
|
|
147
|
+
return handler.onResponseStart(controller, statusCode, headers, statusMessage)
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
onResponseData (controller, chunk) {
|
|
151
|
+
responseData.body.push(chunk)
|
|
152
|
+
return handler.onResponseData(controller, chunk)
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
onResponseEnd (controller, trailers) {
|
|
156
|
+
responseData.trailers = trailers
|
|
157
|
+
|
|
158
|
+
// Record the interaction using captured 'self' context (fire and forget)
|
|
159
|
+
const responseBody = Buffer.concat(responseData.body)
|
|
160
|
+
self[kSnapshotRecorder].record(opts, {
|
|
161
|
+
statusCode: responseData.statusCode,
|
|
162
|
+
headers: responseData.headers,
|
|
163
|
+
body: responseBody,
|
|
164
|
+
trailers: responseData.trailers
|
|
165
|
+
}).then(() => {
|
|
166
|
+
handler.onResponseEnd(controller, trailers)
|
|
167
|
+
}).catch((error) => {
|
|
168
|
+
handler.onResponseError(controller, error)
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Use composed agent if available (includes interceptors), otherwise use real agent
|
|
174
|
+
const agent = this[kRealAgent]
|
|
175
|
+
return agent.dispatch(opts, recordingHandler)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Replays a recorded response
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} snapshot - The recorded snapshot to replay.
|
|
182
|
+
* @param {Object} handler - The handler to call with the response data.
|
|
183
|
+
* @returns {void}
|
|
184
|
+
*/
|
|
185
|
+
#replaySnapshot (snapshot, handler) {
|
|
186
|
+
try {
|
|
187
|
+
const { response } = snapshot
|
|
188
|
+
|
|
189
|
+
const controller = {
|
|
190
|
+
pause () { },
|
|
191
|
+
resume () { },
|
|
192
|
+
abort (reason) {
|
|
193
|
+
this.aborted = true
|
|
194
|
+
this.reason = reason
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
aborted: false,
|
|
198
|
+
paused: false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
handler.onRequestStart(controller)
|
|
202
|
+
|
|
203
|
+
handler.onResponseStart(controller, response.statusCode, response.headers)
|
|
204
|
+
|
|
205
|
+
// Body is always stored as base64 string
|
|
206
|
+
const body = Buffer.from(response.body, 'base64')
|
|
207
|
+
handler.onResponseData(controller, body)
|
|
208
|
+
|
|
209
|
+
handler.onResponseEnd(controller, response.trailers)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
handler.onError?.(error)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Loads snapshots from file
|
|
217
|
+
*
|
|
218
|
+
* @param {string} [filePath] - Optional file path to load snapshots from.
|
|
219
|
+
* @returns {Promise<void>} - Resolves when snapshots are loaded.
|
|
220
|
+
*/
|
|
221
|
+
async loadSnapshots (filePath) {
|
|
222
|
+
await this[kSnapshotRecorder].loadSnapshots(filePath || this[kSnapshotPath])
|
|
223
|
+
this[kSnapshotLoaded] = true
|
|
224
|
+
|
|
225
|
+
// In playback mode, set up MockAgent interceptors for all snapshots
|
|
226
|
+
if (this[kSnapshotMode] === 'playback') {
|
|
227
|
+
this.#setupMockInterceptors()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Saves snapshots to file
|
|
233
|
+
*
|
|
234
|
+
* @param {string} [filePath] - Optional file path to save snapshots to.
|
|
235
|
+
* @returns {Promise<void>} - Resolves when snapshots are saved.
|
|
236
|
+
*/
|
|
237
|
+
async saveSnapshots (filePath) {
|
|
238
|
+
return this[kSnapshotRecorder].saveSnapshots(filePath || this[kSnapshotPath])
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Sets up MockAgent interceptors based on recorded snapshots.
|
|
243
|
+
*
|
|
244
|
+
* This method creates MockAgent interceptors for each recorded snapshot,
|
|
245
|
+
* allowing the SnapshotAgent to fall back to MockAgent's standard intercept
|
|
246
|
+
* mechanism in playback mode. Each interceptor is configured to persist
|
|
247
|
+
* (remain active for multiple requests) and responds with the recorded
|
|
248
|
+
* response data.
|
|
249
|
+
*
|
|
250
|
+
* Called automatically when loading snapshots in playback mode.
|
|
251
|
+
*
|
|
252
|
+
* @returns {void}
|
|
253
|
+
*/
|
|
254
|
+
#setupMockInterceptors () {
|
|
255
|
+
for (const snapshot of this[kSnapshotRecorder].getSnapshots()) {
|
|
256
|
+
const { request, responses, response } = snapshot
|
|
257
|
+
const url = new URL(request.url)
|
|
258
|
+
|
|
259
|
+
const mockPool = this.get(url.origin)
|
|
260
|
+
|
|
261
|
+
// Handle both new format (responses array) and legacy format (response object)
|
|
262
|
+
const responseData = responses ? responses[0] : response
|
|
263
|
+
if (!responseData) continue
|
|
264
|
+
|
|
265
|
+
mockPool.intercept({
|
|
266
|
+
path: url.pathname + url.search,
|
|
267
|
+
method: request.method,
|
|
268
|
+
headers: request.headers,
|
|
269
|
+
body: request.body
|
|
270
|
+
}).reply(responseData.statusCode, responseData.body, {
|
|
271
|
+
headers: responseData.headers,
|
|
272
|
+
trailers: responseData.trailers
|
|
273
|
+
}).persist()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Gets the snapshot recorder
|
|
279
|
+
* @return {SnapshotRecorder} - The snapshot recorder instance
|
|
280
|
+
*/
|
|
281
|
+
getRecorder () {
|
|
282
|
+
return this[kSnapshotRecorder]
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Gets the current mode
|
|
287
|
+
* @return {import('./snapshot-utils').SnapshotMode} - The current snapshot mode
|
|
288
|
+
*/
|
|
289
|
+
getMode () {
|
|
290
|
+
return this[kSnapshotMode]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Clears all snapshots
|
|
295
|
+
* @returns {void}
|
|
296
|
+
*/
|
|
297
|
+
clearSnapshots () {
|
|
298
|
+
this[kSnapshotRecorder].clear()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Resets call counts for all snapshots (useful for test cleanup)
|
|
303
|
+
* @returns {void}
|
|
304
|
+
*/
|
|
305
|
+
resetCallCounts () {
|
|
306
|
+
this[kSnapshotRecorder].resetCallCounts()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Deletes a specific snapshot by request options
|
|
311
|
+
* @param {import('./snapshot-recorder').SnapshotRequestOptions} requestOpts - Request options to identify the snapshot
|
|
312
|
+
* @return {Promise<boolean>} - Returns true if the snapshot was deleted, false if not found
|
|
313
|
+
*/
|
|
314
|
+
deleteSnapshot (requestOpts) {
|
|
315
|
+
return this[kSnapshotRecorder].deleteSnapshot(requestOpts)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Gets information about a specific snapshot
|
|
320
|
+
* @returns {import('./snapshot-recorder').SnapshotInfo|null} - Snapshot information or null if not found
|
|
321
|
+
*/
|
|
322
|
+
getSnapshotInfo (requestOpts) {
|
|
323
|
+
return this[kSnapshotRecorder].getSnapshotInfo(requestOpts)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Replaces all snapshots with new data (full replacement)
|
|
328
|
+
* @param {Array<{hash: string; snapshot: import('./snapshot-recorder').SnapshotEntryshotEntry}>|Record<string, import('./snapshot-recorder').SnapshotEntry>} snapshotData - New snapshot data to replace existing snapshots
|
|
329
|
+
* @returns {void}
|
|
330
|
+
*/
|
|
331
|
+
replaceSnapshots (snapshotData) {
|
|
332
|
+
this[kSnapshotRecorder].replaceSnapshots(snapshotData)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Closes the agent, saving snapshots and cleaning up resources.
|
|
337
|
+
*
|
|
338
|
+
* @returns {Promise<void>}
|
|
339
|
+
*/
|
|
340
|
+
async close () {
|
|
341
|
+
await this[kSnapshotRecorder].close()
|
|
342
|
+
await this[kRealAgent]?.close()
|
|
343
|
+
await super.close()
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = SnapshotAgent
|