undici 7.12.0 → 7.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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