@mswjs/interceptors 0.42.4 → 0.42.5

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.
@@ -27,6 +27,7 @@ import {
27
27
  } from '../net/socket-controller'
28
28
  import { unwrapPendingData } from '../net/utils/flush-writes'
29
29
  import { FetchResponse } from '../../utils/fetch-utils'
30
+ import { cloneResponse } from '../../utils/clone-response'
30
31
  import { requestContext } from '../../request-context'
31
32
  import { Interceptor } from '#/src/interceptor'
32
33
 
@@ -111,378 +112,394 @@ export class NodeHttpRequestSource extends Interceptor<HttpRequestEventMap> {
111
112
  realSocketDestroy(error, callback)
112
113
  }
113
114
 
114
- /**
115
- * @note Only inspect the first sent packet to determine the protocol.
116
- * A single socket cannot be used for different protocols.
117
- */
118
- socket.on('data', (chunk) => {
119
- if (isHttpConnection === false) {
120
- socketController.decline()
121
- return
115
+ const addRequestDataListener = () => {
116
+ const executeRequestParser = (
117
+ parser: HttpRequestParser,
118
+ chunk: Buffer
119
+ ) => {
120
+ // llhttp pauses permanently at an upgrade boundary. Release the
121
+ // parser after execute returns, outside its native callbacks.
122
+ if (parser.execute(chunk) !== null) {
123
+ socket.removeListener('data', onRequestData)
124
+ parser.free()
125
+ requestParser = undefined
126
+ }
122
127
  }
123
128
 
124
129
  /**
125
- * @note A mocked "CONNECT" request has established a tunnel.
126
- * The data that follows belongs to a new exchange addressed to
127
- * the tunnel target. The parser stopped at the tunnel boundary
128
- * (HTTP upgrade semantics), so tear it down and detect the
129
- * tunneled protocol anew, like on a fresh connection.
130
+ * @note Inspect the first sent packet to determine the protocol,
131
+ * including when entering a mocked "CONNECT" tunnel.
130
132
  */
131
- if (tunnelUrl && requestParser) {
132
- requestParser.free()
133
- requestParser = undefined
134
- isHttpConnection = undefined
133
+ const onRequestData = (chunk: Buffer) => {
134
+ if (isHttpConnection === false) {
135
+ socketController.decline()
136
+ return
137
+ }
135
138
 
136
139
  /**
137
- * @note Retarget the connection to the tunnel authority.
138
- * The exchanges that follow belong to the tunnel target,
139
- * so an unclaimed exchange (HTTP or not) must pass through
140
- * to that target — not to the proxy, which never actually
141
- * established this tunnel — like a real established tunnel
142
- * relays its traffic.
140
+ * @note A mocked "CONNECT" request has established a tunnel.
141
+ * The data that follows belongs to a new exchange addressed to
142
+ * the tunnel target. The previous parser was freed at the upgrade
143
+ * boundary, so detect the tunneled protocol anew.
143
144
  */
144
- socketController.reset({
145
- host: tunnelUrl.hostname,
146
- port: Number(tunnelUrl.port) || 80,
147
- path: null,
148
- })
149
- }
145
+ if (tunnelUrl && !requestParser) {
146
+ isHttpConnection = undefined
150
147
 
151
- if (requestParser) {
152
- requestParser.execute(toBuffer(chunk))
153
- return
154
- }
148
+ /**
149
+ * @note Retarget the connection to the tunnel authority.
150
+ * The exchanges that follow belong to the tunnel target,
151
+ * so an unclaimed exchange (HTTP or not) must pass through
152
+ * to that target — not to the proxy, which never actually
153
+ * established this tunnel — like a real established tunnel
154
+ * relays its traffic.
155
+ */
156
+ socketController.reset({
157
+ host: tunnelUrl.hostname,
158
+ port: Number(tunnelUrl.port) || 80,
159
+ path: null,
160
+ })
161
+ }
155
162
 
156
- const httpMessage = chunk.toString()
157
- const httpMethod = httpMessage.split(' ')[0] || ''
163
+ if (requestParser) {
164
+ executeRequestParser(requestParser, toBuffer(chunk))
165
+ return
166
+ }
158
167
 
159
- // Decline non-HTTP connections so the socket controller can
160
- // pass them through once every subscriber has declined.
161
- if (!METHODS.includes(httpMethod.toUpperCase())) {
162
- isHttpConnection = false
163
- socketController.decline()
164
- return
165
- }
168
+ const httpMessage = chunk.toString()
169
+ const httpMethod = httpMessage.split(' ')[0] || ''
166
170
 
167
- isHttpConnection = true
168
-
169
- const baseUrl =
170
- tunnelUrl ?? connectionOptionsToUrl(connectionOptions, socket)
171
-
172
- httpLogger.verbose('handling http message %o', {
173
- httpMessage,
174
- httpMethod,
175
- baseUrl,
176
- })
177
-
178
- // Get the request initiator from the async context, falling
179
- // back to the context captured at the connection time, then
180
- // to the underlying socket.
181
- const requestContextValue =
182
- requestContext.getStore() ?? connectionRequestContext
183
- const initiator = requestContextValue?.initiator || socket
184
-
185
- requestParser = new HttpRequestParser({
186
- onError: stopParsingRequests,
187
- connectionOptions: {
188
- method: httpMethod,
189
- url: baseUrl,
190
- },
191
- /**
192
- * @note The message boundary ends the current exchange.
193
- * Schedule the controller reset so the next write on this
194
- * (kept-alive) socket opens a new exchange and buffers for
195
- * its own verdict instead of following the settled one
196
- * (e.g. leaking a mocked request to the server of a
197
- * previously passed-through exchange).
198
- */
199
- onMessageComplete: () => {
200
- socketController.scheduleReset()
201
- },
202
- onRequest: async (parsedRequest, requestAbortController) => {
203
- const request =
204
- requestContextValue?.transformRequest?.(parsedRequest) ??
205
- parsedRequest
171
+ // Decline non-HTTP connections so the socket controller can
172
+ // pass them through once every subscriber has declined.
173
+ if (!METHODS.includes(httpMethod.toUpperCase())) {
174
+ isHttpConnection = false
175
+ socketController.decline()
176
+ return
177
+ }
178
+
179
+ isHttpConnection = true
180
+
181
+ const baseUrl =
182
+ tunnelUrl ?? connectionOptionsToUrl(connectionOptions, socket)
206
183
 
184
+ httpLogger.verbose('handling http message %o', {
185
+ httpMessage,
186
+ httpMethod,
187
+ baseUrl,
188
+ })
189
+
190
+ // Get the request initiator from the async context, falling
191
+ // back to the context captured at the connection time, then
192
+ // to the underlying socket.
193
+ const requestContextValue =
194
+ requestContext.getStore() ?? connectionRequestContext
195
+ const initiator = requestContextValue?.initiator || socket
196
+
197
+ requestParser = new HttpRequestParser({
198
+ onError: stopParsingRequests,
199
+ connectionOptions: {
200
+ method: httpMethod,
201
+ url: baseUrl,
202
+ },
207
203
  /**
208
- * @note A subsequent request arriving on a kept-alive socket
209
- * that has already been handled (passed through or mocked).
210
- * Clients like Undici reuse sockets without emitting the
211
- * "free" event, so reset the controller here, at the HTTP
212
- * message boundary, to handle the new request from the
213
- * pending state again.
204
+ * @note The message boundary ends the current exchange.
205
+ * Schedule the controller reset so the next write on this
206
+ * (kept-alive) socket opens a new exchange and buffers for
207
+ * its own verdict instead of following the settled one
208
+ * (e.g. leaking a mocked request to the server of a
209
+ * previously passed-through exchange).
214
210
  */
215
- if (socketController['readyState'] !== SocketController.PENDING) {
216
- socketController.reset()
217
- }
211
+ onMessageComplete: () => {
212
+ socketController.scheduleReset()
213
+ },
214
+ onRequest: async (parsedRequest, requestAbortController) => {
215
+ const request =
216
+ requestContextValue?.transformRequest?.(parsedRequest) ??
217
+ parsedRequest
218
+
219
+ /**
220
+ * @note A subsequent request arriving on a kept-alive socket
221
+ * that has already been handled (passed through or mocked).
222
+ * Clients like Undici reuse sockets without emitting the
223
+ * "free" event, so reset the controller here, at the HTTP
224
+ * message boundary, to handle the new request from the
225
+ * pending state again.
226
+ */
227
+ if (
228
+ socketController['readyState'] !== SocketController.PENDING
229
+ ) {
230
+ socketController.reset()
231
+ }
218
232
 
219
- const requestId = createRequestId()
220
- const requestLogger = requestContextValue?.logger ?? httpLogger
233
+ const requestId = createRequestId()
234
+ const requestLogger = requestContextValue?.logger ?? httpLogger
235
+
236
+ httpLogger.verbose('received a parsed HTTP request %o', {
237
+ method: request.method,
238
+ url: request.url,
239
+ })
240
+
241
+ const requestController = new RequestController(
242
+ request,
243
+ {
244
+ respondWith: async (rawResponse) => {
245
+ httpLogger.verbose('respondWith() %o', {
246
+ status: rawResponse.status,
247
+ statusText: rawResponse.statusText,
248
+ hasBody: rawResponse.body != null,
249
+ })
221
250
 
222
- httpLogger.verbose('received a parsed HTTP request %o', {
223
- method: request.method,
224
- url: request.url,
225
- })
251
+ /**
252
+ * @note The client may destroy the socket (e.g. on request
253
+ * abort) moments before a response arrives. A destroyed
254
+ * socket cannot be claimed and has no one reading it.
255
+ */
256
+ if (socket.destroyed) {
257
+ return
258
+ }
226
259
 
227
- const requestController = new RequestController(
228
- request,
229
- {
230
- respondWith: async (rawResponse) => {
231
- httpLogger.verbose('respondWith() %o', {
232
- status: rawResponse.status,
233
- statusText: rawResponse.statusText,
234
- hasBody: rawResponse.body != null,
235
- })
236
-
237
- /**
238
- * @note The client may destroy the socket (e.g. on request
239
- * abort) moments before a response arrives. A destroyed
240
- * socket cannot be claimed and has no one reading it.
241
- */
242
- if (socket.destroyed) {
243
- return
244
- }
245
-
246
- socketController.claim()
247
-
248
- const response = FetchResponse.from(rawResponse, {
249
- url: request.url,
250
- })
251
-
252
- /**
253
- * @note A successful mocked response to a "CONNECT"
254
- * request establishes a tunnel to the requested authority
255
- * (e.g. "127.0.0.1:80"). The exchange that follows on this
256
- * socket is addressed to that authority, not to the proxy.
257
- */
258
- if (request.method === 'CONNECT' && response.ok) {
259
- tunnelUrl = new URL(`http://${request.url}`)
260
- }
261
-
262
- /**
263
- * @note Clone the response before "respondWith" because it will
264
- * consume its body. This way, we can have a readable response copy
265
- * for the "response" event below.
266
- */
267
- const responseClone = isResponseError(response)
268
- ? null
269
- : response.clone()
270
-
271
- const respond = () => {
272
- return this.respondWith({
273
- socket: socketController[kRawSocket],
274
- request: context.request,
275
- response,
260
+ socketController.claim()
261
+
262
+ const originalResponse = FetchResponse.from(rawResponse, {
263
+ url: request.url,
276
264
  })
277
- }
265
+ const [response, responseClone] =
266
+ !isResponseError(originalResponse) &&
267
+ this.emitter.listenerCount('response') > 0
268
+ ? cloneResponse(originalResponse)
269
+ : [originalResponse, null]
270
+
271
+ /**
272
+ * @note A successful mocked response to a "CONNECT"
273
+ * request establishes a tunnel to the requested authority
274
+ * (e.g. "127.0.0.1:80"). The exchange that follows on this
275
+ * socket is addressed to that authority, not to the proxy.
276
+ */
277
+ if (request.method === 'CONNECT' && response.ok) {
278
+ tunnelUrl = new URL(`http://${request.url}`)
279
+ addRequestDataListener()
280
+ }
278
281
 
279
- if (responseClone) {
280
- await this.emitter.emitAsPromise(
281
- new HttpResponseEvent({
282
- initiator,
283
- requestId,
282
+ const respond = () => {
283
+ return this.respondWith({
284
+ socket: socketController[kRawSocket],
284
285
  request: context.request,
285
- response: responseClone,
286
- responseType: 'mock',
286
+ response,
287
287
  })
288
- )
289
- }
288
+ }
290
289
 
291
- if (socket.connecting) {
292
- // Send a mocked response once the socket connects, just like the real server would.
293
- // This preserves the correct order of events (e.g. connect, then data).
294
- socket.once('connect', respond)
295
- } else {
296
- /**
297
- * @note Reused sockets stay connected between requests and will not
298
- * emit "connect" anymore. If that's the case, respond immediately.
299
- */
300
- await respond()
301
- }
302
- },
303
- errorWith: (reason) => {
304
- if (reason instanceof Error) {
305
- socket.destroy(reason)
306
- }
307
- },
308
- passthrough: () => {
309
- const realSocket = socketController.passthrough(
310
- isHttpConnection === false
311
- ? undefined
312
- : this.#modifyHttpHeaders(context.request)
313
- )
314
-
315
- if (isHttpConnection === false) {
316
- return
317
- }
318
-
319
- if (this.emitter.listenerCount('response') > 0) {
320
- httpLogger.verbose(
321
- 'found "response" listener, corking socket reads'
290
+ if (responseClone) {
291
+ await this.emitter.emitAsPromise(
292
+ new HttpResponseEvent({
293
+ initiator,
294
+ requestId,
295
+ request: context.request,
296
+ response: responseClone,
297
+ responseType: 'mock',
298
+ })
299
+ )
300
+ }
301
+
302
+ if (socket.connecting) {
303
+ // Send a mocked response once the socket connects, just like the real server would.
304
+ // This preserves the correct order of events (e.g. connect, then data).
305
+ socket.once('connect', respond)
306
+ } else {
307
+ /**
308
+ * @note Reused sockets stay connected between requests and will not
309
+ * emit "connect" anymore. If that's the case, respond immediately.
310
+ */
311
+ await respond()
312
+ }
313
+ },
314
+ errorWith: (reason) => {
315
+ if (reason instanceof Error) {
316
+ socket.destroy(reason)
317
+ }
318
+ },
319
+ passthrough: () => {
320
+ const realSocket = socketController.passthrough(
321
+ isHttpConnection === false
322
+ ? undefined
323
+ : this.#modifyHttpHeaders(context.request)
322
324
  )
323
325
 
324
- /**
325
- * Suspend the delivery of the original response to the client
326
- * until the "response" event listeners settle. This guarantees
327
- * that the request promise (e.g. `await fetch()`) does not
328
- * resolve before the listeners are done. The real socket keeps
329
- * emitting data for the response parser meanwhile.
330
- */
331
- socketController.corkReads()
332
-
333
- let responseParserDisposed = false
334
- let responseComplete = false
335
- let hasFinalResponse = false
336
- const responseParser = new HttpResponseParser({
337
- onError: (error) => {
338
- disposeResponseParser(error)
339
- socketController.uncorkReads()
340
- },
341
- onMessageComplete: (status) => {
342
- responseComplete = status >= 200 || status === 101
343
- },
344
- onResponse: async (response) => {
345
- hasFinalResponse =
346
- response.status >= 200 || response.status === 101
347
- httpLogger.verbose(
348
- 'HTTP response parser parsed: %d %s',
349
- response.status,
350
- response.statusText
351
- )
352
-
353
- if (isResponseError(response)) {
326
+ if (isHttpConnection === false) {
327
+ return
328
+ }
329
+
330
+ if (this.emitter.listenerCount('response') > 0) {
331
+ httpLogger.verbose(
332
+ 'found "response" listener, corking socket reads'
333
+ )
334
+
335
+ /**
336
+ * Suspend the delivery of the original response to the client
337
+ * until the "response" event listeners settle. This guarantees
338
+ * that the request promise (e.g. `await fetch()`) does not
339
+ * resolve before the listeners are done. The real socket keeps
340
+ * emitting data for the response parser meanwhile.
341
+ */
342
+ socketController.corkReads()
343
+
344
+ let responseParserDisposed = false
345
+ let responseComplete = false
346
+ let hasFinalResponse = false
347
+ const responseParser = new HttpResponseParser({
348
+ onError: (error) => {
349
+ disposeResponseParser(error)
350
+ socketController.uncorkReads()
351
+ },
352
+ onMessageComplete: (status) => {
353
+ responseComplete = status >= 200 || status === 101
354
+ },
355
+ onResponse: async (response) => {
356
+ hasFinalResponse =
357
+ response.status >= 200 || response.status === 101
354
358
  httpLogger.verbose(
355
- 'response is an error response, uncorking socket reads...'
359
+ 'HTTP response parser parsed: %d %s',
360
+ response.status,
361
+ response.statusText
356
362
  )
357
363
 
358
- socketController.uncorkReads()
359
- return
360
- }
364
+ if (isResponseError(response)) {
365
+ httpLogger.verbose(
366
+ 'response is an error response, uncorking socket reads...'
367
+ )
361
368
 
362
- FetchResponse.setUrl(request.url, response)
363
-
364
- try {
365
- httpLogger.verbose('emitting "response" event')
366
- await this.emitter.emitAsPromise(
367
- new HttpResponseEvent({
368
- initiator,
369
- requestId,
370
- request: context.request,
371
- response,
372
- responseType: 'original',
373
- })
374
- )
375
- } finally {
376
- httpLogger.verbose('uncorking socket reads')
377
- socketController.uncorkReads()
369
+ socketController.uncorkReads()
370
+ return
371
+ }
378
372
 
379
- /**
380
- * @note Informational responses other than
381
- * "101 Switching Protocols" are followed by a final
382
- * response on the same connection. Keep gating that
383
- * final response on the "response" event listeners.
384
- */
385
- if (
386
- !responseParserDisposed &&
387
- response.status < 200 &&
388
- response.status !== 101
389
- ) {
390
- socketController.corkReads()
373
+ FetchResponse.setUrl(request.url, response)
374
+
375
+ try {
376
+ httpLogger.verbose('emitting "response" event')
377
+ await this.emitter.emitAsPromise(
378
+ new HttpResponseEvent({
379
+ initiator,
380
+ requestId,
381
+ request: context.request,
382
+ response,
383
+ responseType: 'original',
384
+ })
385
+ )
386
+ } finally {
387
+ httpLogger.verbose('uncorking socket reads')
388
+ socketController.uncorkReads()
389
+
390
+ /**
391
+ * @note Informational responses other than
392
+ * "101 Switching Protocols" are followed by a final
393
+ * response on the same connection. Keep gating that
394
+ * final response on the "response" event listeners.
395
+ */
396
+ if (
397
+ !responseParserDisposed &&
398
+ response.status < 200 &&
399
+ response.status !== 101
400
+ ) {
401
+ socketController.corkReads()
402
+ }
391
403
  }
392
- }
393
- },
394
- })
404
+ },
405
+ })
395
406
 
396
- const onResponseData = (chunk: Buffer) => {
397
- responseParser.execute(chunk)
407
+ const onResponseData = (chunk: Buffer) => {
408
+ responseParser.execute(chunk)
398
409
 
399
- // Free only after llhttp returns from its callbacks.
400
- if (responseComplete) {
401
- disposeResponseParser()
410
+ // Free only after llhttp returns from its callbacks.
411
+ if (responseComplete) {
412
+ disposeResponseParser()
413
+ }
402
414
  }
403
- }
404
415
 
405
- const onResponseClose = () => {
406
- disposeResponseParser()
416
+ const onResponseEnd = () => {
417
+ disposeResponseParser()
418
+
419
+ // Without a final response, release EOF now. A half-open
420
+ // socket cannot close until the client consumes it.
421
+ if (!hasFinalResponse) {
422
+ socketController.uncorkReads()
423
+ }
424
+ }
407
425
 
408
- // Without a response, no response listener will release
409
- // the buffered EOF/close that rejects the client request.
410
- if (!hasFinalResponse) {
411
- socketController.uncorkReads()
426
+ const disposeResponseParser = (error?: Error) => {
427
+ responseParserDisposed = true
428
+ realSocket.removeListener('data', onResponseData)
429
+ realSocket.removeListener('end', onResponseEnd)
430
+ realSocket.removeListener('close', onResponseEnd)
431
+ responseParser.free(error)
412
432
  }
413
- }
414
433
 
415
- const disposeResponseParser = (error?: Error) => {
416
- responseParserDisposed = true
417
- realSocket.removeListener('data', onResponseData)
418
- realSocket.removeListener('close', onResponseClose)
419
- responseParser.free(error)
434
+ realSocket
435
+ .on('data', onResponseData)
436
+ .once('end', onResponseEnd)
437
+ .once('close', onResponseEnd)
420
438
  }
421
-
422
- realSocket
423
- .on('data', onResponseData)
424
- .once('close', onResponseClose)
425
- }
439
+ },
426
440
  },
427
- },
428
- {
429
- logger: requestLogger,
441
+ {
442
+ logger: requestLogger,
443
+ requestId,
444
+ }
445
+ )
446
+
447
+ invariant(
448
+ socketController['readyState'] === SocketController.PENDING,
449
+ 'CANNOT HANDLE ALREADY HANDLED REQUEST',
450
+ request.method,
451
+ request.url,
452
+ socketController['readyState']
453
+ )
454
+
455
+ /**
456
+ * @note Create a request resolution context.
457
+ * This is so modifications to the "request" in upstream interceptors
458
+ * are correctly picked up by the underlying HTTP interceptor.
459
+ */
460
+ const context: HandleRequestOptions = {
461
+ initiator,
430
462
  requestId,
463
+ request,
464
+ controller: requestController,
465
+ emitter: this.emitter,
466
+ logger: requestLogger,
431
467
  }
432
- )
433
468
 
434
- invariant(
435
- socketController['readyState'] === SocketController.PENDING,
436
- 'CANNOT HANDLE ALREADY HANDLED REQUEST',
437
- request.method,
438
- request.url,
439
- socketController['readyState']
440
- )
469
+ /**
470
+ * @note The client destroying the socket while the request
471
+ * is still pending means the request was aborted (e.g. via
472
+ * `AbortController`). Abort the parsed request so its
473
+ * handling settles and late interactions with the request
474
+ * controller become controlled errors.
475
+ */
476
+ abortPendingRequest = () => {
477
+ if (
478
+ requestController.readyState === RequestController.PENDING
479
+ ) {
480
+ requestAbortController.abort()
481
+ }
482
+ }
441
483
 
442
- /**
443
- * @note Create a request resolution context.
444
- * This is so modifications to the "request" in upstream interceptors
445
- * are correctly picked up by the underlying HTTP interceptor.
446
- */
447
- const context: HandleRequestOptions = {
448
- initiator,
449
- requestId,
450
- request,
451
- controller: requestController,
452
- emitter: this.emitter,
453
- logger: requestLogger,
454
- }
484
+ pendingRequestController = requestController
455
485
 
456
- /**
457
- * @note The client destroying the socket while the request
458
- * is still pending means the request was aborted (e.g. via
459
- * `AbortController`). Abort the parsed request so its
460
- * handling settles and late interactions with the request
461
- * controller become controlled errors.
462
- */
463
- abortPendingRequest = () => {
464
- if (
465
- requestController.readyState === RequestController.PENDING
466
- ) {
467
- requestAbortController.abort()
486
+ try {
487
+ await handleRequest(context)
488
+ } finally {
489
+ pendingRequestController = undefined
490
+ abortPendingRequest = undefined
468
491
  }
469
- }
470
-
471
- pendingRequestController = requestController
492
+ },
493
+ })
472
494
 
473
- try {
474
- await handleRequest(context)
475
- } finally {
476
- pendingRequestController = undefined
477
- abortPendingRequest = undefined
478
- }
479
- },
480
- })
495
+ // Forward the first frame to the parser.
496
+ executeRequestParser(requestParser, toBuffer(chunk))
497
+ }
481
498
 
482
- // Forward the first frame to the parser.
483
- requestParser.execute(toBuffer(chunk))
484
- })
499
+ socket.on('data', onRequestData)
500
+ }
485
501
 
502
+ addRequestDataListener()
486
503
  socket.on('close', () => requestParser?.free())
487
504
  },
488
505
  {