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