@mswjs/interceptors 0.31.1 → 0.32.1

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.
Files changed (69) hide show
  1. package/README.md +56 -39
  2. package/lib/node/RemoteHttpInterceptor.d.ts +1 -2
  3. package/lib/node/RemoteHttpInterceptor.js +11 -11
  4. package/lib/node/RemoteHttpInterceptor.mjs +5 -5
  5. package/lib/node/{chunk-LTEXDYJ6.js → chunk-2COJKQQB.js} +3 -3
  6. package/lib/node/chunk-3OJLYEWA.mjs +963 -0
  7. package/lib/node/chunk-3OJLYEWA.mjs.map +1 -0
  8. package/lib/node/chunk-5JMJ55U7.js +963 -0
  9. package/lib/node/chunk-5JMJ55U7.js.map +1 -0
  10. package/lib/node/{chunk-E4AC7YAC.js → chunk-BFLYGQ6D.js} +4 -2
  11. package/lib/node/{chunk-KSHIDGUL.mjs → chunk-DV4PBH4D.mjs} +3 -3
  12. package/lib/node/{chunk-OUWBQF3Z.mjs → chunk-KWV3JXSI.mjs} +14 -14
  13. package/lib/node/chunk-KWV3JXSI.mjs.map +1 -0
  14. package/lib/node/{chunk-6FRASLM3.mjs → chunk-PNWPIDEL.mjs} +2 -2
  15. package/lib/node/{chunk-APT7KA3B.js → chunk-PYD4E2EJ.js} +13 -13
  16. package/lib/node/{chunk-Q7POAM5N.mjs → chunk-TGTPXCLF.mjs} +3 -1
  17. package/lib/node/{chunk-MQJ3JOOK.js → chunk-UXCYRE4F.js} +14 -14
  18. package/lib/node/chunk-UXCYRE4F.js.map +1 -0
  19. package/lib/node/index.js +3 -3
  20. package/lib/node/index.mjs +2 -2
  21. package/lib/node/interceptors/ClientRequest/index.d.ts +83 -14
  22. package/lib/node/interceptors/ClientRequest/index.js +4 -4
  23. package/lib/node/interceptors/ClientRequest/index.mjs +3 -3
  24. package/lib/node/interceptors/XMLHttpRequest/index.js +4 -4
  25. package/lib/node/interceptors/XMLHttpRequest/index.mjs +3 -3
  26. package/lib/node/interceptors/fetch/index.js +10 -10
  27. package/lib/node/interceptors/fetch/index.mjs +2 -2
  28. package/lib/node/presets/node.d.ts +2 -3
  29. package/lib/node/presets/node.js +6 -6
  30. package/lib/node/presets/node.mjs +4 -4
  31. package/package.json +2 -2
  32. package/src/interceptors/ClientRequest/MockHttpSocket.ts +595 -0
  33. package/src/interceptors/ClientRequest/agents.ts +78 -0
  34. package/src/interceptors/ClientRequest/index.test.ts +14 -12
  35. package/src/interceptors/ClientRequest/index.ts +200 -41
  36. package/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts +78 -98
  37. package/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts +40 -22
  38. package/src/interceptors/Socket/MockSocket.test.ts +264 -0
  39. package/src/interceptors/Socket/MockSocket.ts +59 -0
  40. package/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts +26 -0
  41. package/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts +52 -0
  42. package/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts +33 -0
  43. package/src/interceptors/Socket/utils/parseRawHeaders.ts +10 -0
  44. package/lib/node/chunk-IS3CIGXU.js +0 -909
  45. package/lib/node/chunk-IS3CIGXU.js.map +0 -1
  46. package/lib/node/chunk-MQJ3JOOK.js.map +0 -1
  47. package/lib/node/chunk-OMOWHUE6.mjs +0 -909
  48. package/lib/node/chunk-OMOWHUE6.mjs.map +0 -1
  49. package/lib/node/chunk-OUWBQF3Z.mjs.map +0 -1
  50. package/src/interceptors/ClientRequest/NodeClientRequest.test.ts +0 -206
  51. package/src/interceptors/ClientRequest/NodeClientRequest.ts +0 -680
  52. package/src/interceptors/ClientRequest/http.get.ts +0 -30
  53. package/src/interceptors/ClientRequest/http.request.ts +0 -27
  54. package/src/interceptors/ClientRequest/utils/cloneIncomingMessage.test.ts +0 -26
  55. package/src/interceptors/ClientRequest/utils/cloneIncomingMessage.ts +0 -74
  56. package/src/interceptors/ClientRequest/utils/createRequest.test.ts +0 -144
  57. package/src/interceptors/ClientRequest/utils/createRequest.ts +0 -51
  58. package/src/interceptors/ClientRequest/utils/createResponse.test.ts +0 -53
  59. package/src/interceptors/ClientRequest/utils/createResponse.ts +0 -55
  60. package/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.test.ts +0 -41
  61. package/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts +0 -53
  62. package/src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.test.ts +0 -36
  63. package/src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.ts +0 -39
  64. /package/lib/node/{chunk-LTEXDYJ6.js.map → chunk-2COJKQQB.js.map} +0 -0
  65. /package/lib/node/{chunk-E4AC7YAC.js.map → chunk-BFLYGQ6D.js.map} +0 -0
  66. /package/lib/node/{chunk-KSHIDGUL.mjs.map → chunk-DV4PBH4D.mjs.map} +0 -0
  67. /package/lib/node/{chunk-6FRASLM3.mjs.map → chunk-PNWPIDEL.mjs.map} +0 -0
  68. /package/lib/node/{chunk-APT7KA3B.js.map → chunk-PYD4E2EJ.js.map} +0 -0
  69. /package/lib/node/{chunk-Q7POAM5N.mjs.map → chunk-TGTPXCLF.mjs.map} +0 -0
@@ -0,0 +1,595 @@
1
+ import net from 'node:net'
2
+ import {
3
+ HTTPParser,
4
+ type RequestHeadersCompleteCallback,
5
+ type ResponseHeadersCompleteCallback,
6
+ } from '_http_common'
7
+ import { IncomingMessage, ServerResponse } from 'node:http'
8
+ import { Readable } from 'node:stream'
9
+ import { invariant } from 'outvariant'
10
+ import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor'
11
+ import { MockSocket } from '../Socket/MockSocket'
12
+ import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs'
13
+ import { isPropertyAccessible } from '../../utils/isPropertyAccessible'
14
+ import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions'
15
+ import { parseRawHeaders } from '../Socket/utils/parseRawHeaders'
16
+ import { getRawFetchHeaders } from '../../utils/getRawFetchHeaders'
17
+ import {
18
+ createServerErrorResponse,
19
+ RESPONSE_STATUS_CODES_WITHOUT_BODY,
20
+ } from '../../utils/responseUtils'
21
+ import { createRequestId } from '../../createRequestId'
22
+
23
+ type HttpConnectionOptions = any
24
+
25
+ export type MockHttpSocketRequestCallback = (args: {
26
+ requestId: string
27
+ request: Request
28
+ socket: MockHttpSocket
29
+ }) => void
30
+
31
+ export type MockHttpSocketResponseCallback = (args: {
32
+ requestId: string
33
+ request: Request
34
+ response: Response
35
+ isMockedResponse: boolean
36
+ socket: MockHttpSocket
37
+ }) => Promise<void>
38
+
39
+ interface MockHttpSocketOptions {
40
+ connectionOptions: HttpConnectionOptions
41
+ createConnection: () => net.Socket
42
+ onRequest: MockHttpSocketRequestCallback
43
+ onResponse: MockHttpSocketResponseCallback
44
+ }
45
+
46
+ export const kRequestId = Symbol('kRequestId')
47
+
48
+ export class MockHttpSocket extends MockSocket {
49
+ private connectionOptions: HttpConnectionOptions
50
+ private createConnection: () => net.Socket
51
+ private baseUrl: URL
52
+
53
+ private onRequest: MockHttpSocketRequestCallback
54
+ private onResponse: MockHttpSocketResponseCallback
55
+ private responseListenersPromise?: Promise<void>
56
+
57
+ private writeBuffer: Array<NormalizedSocketWriteArgs> = []
58
+ private request?: Request
59
+ private requestParser: HTTPParser<0>
60
+ private requestStream?: Readable
61
+ private shouldKeepAlive?: boolean
62
+
63
+ private responseType: 'mock' | 'bypassed' = 'bypassed'
64
+ private responseParser: HTTPParser<1>
65
+ private responseStream?: Readable
66
+
67
+ constructor(options: MockHttpSocketOptions) {
68
+ super({
69
+ write: (chunk, encoding, callback) => {
70
+ this.writeBuffer.push([chunk, encoding, callback])
71
+
72
+ if (chunk) {
73
+ this.requestParser.execute(
74
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
75
+ )
76
+ }
77
+ },
78
+ read: (chunk) => {
79
+ if (chunk !== null) {
80
+ this.responseParser.execute(
81
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
82
+ )
83
+ }
84
+ },
85
+ })
86
+
87
+ this.connectionOptions = options.connectionOptions
88
+ this.createConnection = options.createConnection
89
+ this.onRequest = options.onRequest
90
+ this.onResponse = options.onResponse
91
+
92
+ this.baseUrl = baseUrlFromConnectionOptions(this.connectionOptions)
93
+
94
+ // Request parser.
95
+ this.requestParser = new HTTPParser()
96
+ this.requestParser.initialize(HTTPParser.REQUEST, {})
97
+ this.requestParser[HTTPParser.kOnHeadersComplete] =
98
+ this.onRequestStart.bind(this)
99
+ this.requestParser[HTTPParser.kOnBody] = this.onRequestBody.bind(this)
100
+ this.requestParser[HTTPParser.kOnMessageComplete] =
101
+ this.onRequestEnd.bind(this)
102
+
103
+ // Response parser.
104
+ this.responseParser = new HTTPParser()
105
+ this.responseParser.initialize(HTTPParser.RESPONSE, {})
106
+ this.responseParser[HTTPParser.kOnHeadersComplete] =
107
+ this.onResponseStart.bind(this)
108
+ this.responseParser[HTTPParser.kOnBody] = this.onResponseBody.bind(this)
109
+ this.responseParser[HTTPParser.kOnMessageComplete] =
110
+ this.onResponseEnd.bind(this)
111
+
112
+ // Once the socket is finished, nothing can write to it
113
+ // anymore. It has also flushed any buffered chunks.
114
+ this.once('finish', () => this.requestParser.free())
115
+
116
+ if (this.baseUrl.protocol === 'https:') {
117
+ Reflect.set(this, 'encrypted', true)
118
+ // The server certificate is not the same as a CA
119
+ // passed to the TLS socket connection options.
120
+ Reflect.set(this, 'authorized', false)
121
+ Reflect.set(this, 'getProtocol', () => 'TLSv1.3')
122
+ Reflect.set(this, 'getSession', () => undefined)
123
+ Reflect.set(this, 'isSessionReused', () => false)
124
+ }
125
+ }
126
+
127
+ public emit(event: string | symbol, ...args: any[]): boolean {
128
+ const emitEvent = super.emit.bind(this, event as any, ...args)
129
+
130
+ if (this.responseListenersPromise) {
131
+ this.responseListenersPromise.finally(emitEvent)
132
+ return this.listenerCount(event) > 0
133
+ }
134
+
135
+ return emitEvent()
136
+ }
137
+
138
+ public destroy(error?: Error | undefined): this {
139
+ // Destroy the response parser when the socket gets destroyed.
140
+ // Normally, we shoud listen to the "close" event but it
141
+ // can be suppressed by using the "emitClose: false" option.
142
+ this.responseParser.free()
143
+
144
+ if (error) {
145
+ this.emit('error', error)
146
+ }
147
+
148
+ return super.destroy(error)
149
+ }
150
+
151
+ /**
152
+ * Establish this Socket connection as-is and pipe
153
+ * its data/events through this Socket.
154
+ */
155
+ public passthrough(): void {
156
+ if (this.destroyed) {
157
+ return
158
+ }
159
+
160
+ const socket = this.createConnection()
161
+
162
+ // If the developer destroys the socket, destroy the original connection.
163
+ this.once('error', (error) => {
164
+ socket.destroy(error)
165
+ })
166
+
167
+ this.address = socket.address.bind(socket)
168
+
169
+ // Flush the buffered "socket.write()" calls onto
170
+ // the original socket instance (i.e. write request body).
171
+ // Exhaust the "requestBuffer" in case this Socket
172
+ // gets reused for different requests.
173
+ let writeArgs: NormalizedSocketWriteArgs | undefined
174
+ let headersWritten = false
175
+
176
+ while ((writeArgs = this.writeBuffer.shift())) {
177
+ if (writeArgs !== undefined) {
178
+ if (!headersWritten) {
179
+ const [chunk, encoding, callback] = writeArgs
180
+ const chunkString = chunk.toString()
181
+ const chunkBeforeRequestHeaders = chunkString.slice(
182
+ 0,
183
+ chunkString.indexOf('\r\n') + 2
184
+ )
185
+ const chunkAfterRequestHeaders = chunkString.slice(
186
+ chunk.indexOf('\r\n\r\n')
187
+ )
188
+ const requestHeaders =
189
+ getRawFetchHeaders(this.request!.headers) || this.request!.headers
190
+ const requestHeadersString = Array.from(requestHeaders.entries())
191
+ // Skip the internal request ID deduplication header.
192
+ .filter(([name]) => name !== INTERNAL_REQUEST_ID_HEADER_NAME)
193
+ .map(([name, value]) => `${name}: ${value}`)
194
+ .join('\r\n')
195
+
196
+ // Modify the HTTP request message headers
197
+ // to reflect any changes to the request headers
198
+ // from the "request" event listener.
199
+ const headersChunk = `${chunkBeforeRequestHeaders}${requestHeadersString}${chunkAfterRequestHeaders}`
200
+ socket.write(headersChunk, encoding, callback)
201
+ headersWritten = true
202
+ continue
203
+ }
204
+
205
+ socket.write(...writeArgs)
206
+ }
207
+ }
208
+
209
+ // Forward TLS Socket properties onto this Socket instance
210
+ // in the case of a TLS/SSL connection.
211
+ if (Reflect.get(socket, 'encrypted')) {
212
+ const tlsProperties = [
213
+ 'encrypted',
214
+ 'authorized',
215
+ 'getProtocol',
216
+ 'getSession',
217
+ 'isSessionReused',
218
+ ]
219
+
220
+ tlsProperties.forEach((propertyName) => {
221
+ Object.defineProperty(this, propertyName, {
222
+ enumerable: true,
223
+ get: () => {
224
+ const value = Reflect.get(socket, propertyName)
225
+ return typeof value === 'function' ? value.bind(socket) : value
226
+ },
227
+ })
228
+ })
229
+ }
230
+
231
+ socket
232
+ .on('lookup', (...args) => this.emit('lookup', ...args))
233
+ .on('connect', () => {
234
+ this.connecting = socket.connecting
235
+ this.emit('connect')
236
+ })
237
+ .on('secureConnect', () => this.emit('secureConnect'))
238
+ .on('secure', () => this.emit('secure'))
239
+ .on('session', (session) => this.emit('session', session))
240
+ .on('ready', () => this.emit('ready'))
241
+ .on('drain', () => this.emit('drain'))
242
+ .on('data', (chunk) => {
243
+ // Push the original response to this socket
244
+ // so it triggers the HTTP response parser. This unifies
245
+ // the handling pipeline for original and mocked response.
246
+ this.push(chunk)
247
+ })
248
+ .on('error', (error) => {
249
+ Reflect.set(this, '_hadError', Reflect.get(socket, '_hadError'))
250
+ this.emit('error', error)
251
+ })
252
+ .on('resume', () => this.emit('resume'))
253
+ .on('timeout', () => this.emit('timeout'))
254
+ .on('prefinish', () => this.emit('prefinish'))
255
+ .on('finish', () => this.emit('finish'))
256
+ .on('close', (hadError) => this.emit('close', hadError))
257
+ .on('end', () => this.emit('end'))
258
+ }
259
+
260
+ /**
261
+ * Convert the given Fetch API `Response` instance to an
262
+ * HTTP message and push it to the socket.
263
+ */
264
+ public async respondWith(response: Response): Promise<void> {
265
+ // Ignore the mocked response if the socket has been destroyed
266
+ // (e.g. aborted or timed out),
267
+ if (this.destroyed) {
268
+ return
269
+ }
270
+
271
+ // Handle "type: error" responses.
272
+ if (isPropertyAccessible(response, 'type') && response.type === 'error') {
273
+ this.errorWith(new TypeError('Network error'))
274
+ return
275
+ }
276
+
277
+ // First, emit all the connection events
278
+ // to emulate a successful connection.
279
+ this.mockConnect()
280
+ this.responseType = 'mock'
281
+
282
+ // Flush the write buffer to trigger write callbacks
283
+ // if it hasn't been flushed already (e.g. someone started reading request stream).
284
+ this.flushWriteBuffer()
285
+
286
+ // Create a `ServerResponse` instance to delegate HTTP message parsing,
287
+ // Transfer-Encoding, and other things to Node.js internals.
288
+ const serverResponse = new ServerResponse(new IncomingMessage(this))
289
+
290
+ /**
291
+ * Assign a mock socket instance to the server response to
292
+ * spy on the response chunk writes. Push the transformed response chunks
293
+ * to this `MockHttpSocket` instance to trigger the "data" event.
294
+ * @note Providing the same `MockSocket` instance when creating `ServerResponse`
295
+ * does not have the same effect.
296
+ * @see https://github.com/nodejs/node/blob/10099bb3f7fd97bb9dd9667188426866b3098e07/test/parallel/test-http-server-response-standalone.js#L32
297
+ */
298
+ serverResponse.assignSocket(
299
+ new MockSocket({
300
+ write: (chunk, encoding, callback) => {
301
+ this.push(chunk, encoding)
302
+ callback?.()
303
+ },
304
+ read() {},
305
+ })
306
+ )
307
+ serverResponse.statusCode = response.status
308
+ serverResponse.statusMessage = response.statusText
309
+
310
+ /**
311
+ * @note Remove the `Connection` and `Date` response headers
312
+ * injected by `ServerResponse` by default. Those are required
313
+ * from the server but the interceptor is NOT technically a server.
314
+ * It's confusing to add response headers that the developer didn't
315
+ * specify themselves. They can always add these if they wish.
316
+ * @see https://www.rfc-editor.org/rfc/rfc9110#field.date
317
+ * @see https://www.rfc-editor.org/rfc/rfc9110#field.connection
318
+ */
319
+ serverResponse.removeHeader('connection')
320
+ serverResponse.removeHeader('date')
321
+
322
+ // If the developer destroy the socket, gracefully destroy the response.
323
+ this.once('error', () => {
324
+ serverResponse.destroy()
325
+ })
326
+
327
+ // Get the raw headers stored behind the symbol to preserve name casing.
328
+ const headers = getRawFetchHeaders(response.headers) || response.headers
329
+ for (const [name, value] of headers) {
330
+ serverResponse.setHeader(name, value)
331
+ }
332
+
333
+ if (response.body) {
334
+ try {
335
+ const reader = response.body.getReader()
336
+
337
+ while (true) {
338
+ const { done, value } = await reader.read()
339
+
340
+ if (done) {
341
+ serverResponse.end()
342
+ break
343
+ }
344
+
345
+ serverResponse.write(value)
346
+ }
347
+ } catch (error) {
348
+ // Coerce response stream errors to 500 responses.
349
+ this.respondWith(createServerErrorResponse(error))
350
+ return
351
+ }
352
+ } else {
353
+ serverResponse.end()
354
+ }
355
+
356
+ // Close the socket if the connection wasn't marked as keep-alive.
357
+ if (!this.shouldKeepAlive) {
358
+ this.emit('readable')
359
+
360
+ /**
361
+ * @todo @fixme This is likely a hack.
362
+ * Since we push null to the socket, it never propagates to the
363
+ * parser, and the parser never calls "onResponseEnd" to close
364
+ * the response stream. We are closing the stream here manually
365
+ * but that shouldn't be the case.
366
+ */
367
+ this.responseStream?.push(null)
368
+ this.push(null)
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Close this socket connection with the given error.
374
+ */
375
+ public errorWith(error: Error): void {
376
+ this.destroy(error)
377
+ }
378
+
379
+ private mockConnect(): void {
380
+ // Calling this method immediately puts the socket
381
+ // into the connected state.
382
+ this.connecting = false
383
+
384
+ const isIPv6 =
385
+ net.isIPv6(this.connectionOptions.hostname) ||
386
+ this.connectionOptions.family === 6
387
+ const addressInfo = {
388
+ address: isIPv6 ? '::1' : '127.0.0.1',
389
+ family: isIPv6 ? 'IPv6' : 'IPv4',
390
+ port: this.connectionOptions.port,
391
+ }
392
+ // Return fake address information for the socket.
393
+ this.address = () => addressInfo
394
+ this.emit(
395
+ 'lookup',
396
+ null,
397
+ addressInfo.address,
398
+ addressInfo.family === 'IPv6' ? 6 : 4,
399
+ this.connectionOptions.host
400
+ )
401
+ this.emit('connect')
402
+ this.emit('ready')
403
+
404
+ if (this.baseUrl.protocol === 'https:') {
405
+ this.emit('secure')
406
+ this.emit('secureConnect')
407
+
408
+ // A single TLS connection is represented by two "session" events.
409
+ this.emit(
410
+ 'session',
411
+ this.connectionOptions.session ||
412
+ Buffer.from('mock-session-renegotiate')
413
+ )
414
+ this.emit('session', Buffer.from('mock-session-resume'))
415
+ }
416
+ }
417
+
418
+ private flushWriteBuffer(): void {
419
+ let args: NormalizedSocketWriteArgs | undefined
420
+ while ((args = this.writeBuffer.shift())) {
421
+ args?.[2]?.()
422
+ }
423
+ }
424
+
425
+ private onRequestStart: RequestHeadersCompleteCallback = (
426
+ versionMajor,
427
+ versionMinor,
428
+ rawHeaders,
429
+ _,
430
+ path,
431
+ __,
432
+ ___,
433
+ ____,
434
+ shouldKeepAlive
435
+ ) => {
436
+ this.shouldKeepAlive = shouldKeepAlive
437
+
438
+ const url = new URL(path, this.baseUrl)
439
+ const method = this.connectionOptions.method || 'GET'
440
+ const headers = parseRawHeaders(rawHeaders)
441
+ const canHaveBody = method !== 'GET' && method !== 'HEAD'
442
+
443
+ // Translate the basic authorization in the URL to the request header.
444
+ // Constructing a Request instance with a URL containing auth is no-op.
445
+ if (url.username || url.password) {
446
+ if (!headers.has('authorization')) {
447
+ headers.set('authorization', `Basic ${url.username}:${url.password}`)
448
+ }
449
+ url.username = ''
450
+ url.password = ''
451
+ }
452
+
453
+ // Create a new stream for each request.
454
+ // If this Socket is reused for multiple requests,
455
+ // this ensures that each request gets its own stream.
456
+ // One Socket instance can only handle one request at a time.
457
+ if (canHaveBody) {
458
+ this.requestStream = new Readable({
459
+ /**
460
+ * @note Provide the `read()` method so a `Readable` could be
461
+ * used as the actual request body (the stream calls "read()").
462
+ * We control the queue in the onRequestBody/End functions.
463
+ */
464
+ read: () => {
465
+ // If the user attempts to read the request body,
466
+ // flush the write buffer to trigger the callbacks.
467
+ // This way, if the request stream ends in the write callback,
468
+ // it will indeed end correctly.
469
+ this.flushWriteBuffer()
470
+ },
471
+ })
472
+ }
473
+
474
+ const requestId = createRequestId()
475
+ this.request = new Request(url, {
476
+ method,
477
+ headers,
478
+ credentials: 'same-origin',
479
+ // @ts-expect-error Undocumented Fetch property.
480
+ duplex: canHaveBody ? 'half' : undefined,
481
+ body: canHaveBody ? (Readable.toWeb(this.requestStream!) as any) : null,
482
+ })
483
+
484
+ Reflect.set(this.request, kRequestId, requestId)
485
+
486
+ // Skip handling the request that's already being handled
487
+ // by another (parent) interceptor. For example, XMLHttpRequest
488
+ // is often implemented via ClientRequest in Node.js (e.g. JSDOM).
489
+ // In that case, XHR interceptor will bubble down to the ClientRequest
490
+ // interceptor. No need to try to handle that request again.
491
+ /**
492
+ * @fixme Stop relying on the "X-Request-Id" request header
493
+ * to figure out if one interceptor has been invoked within another.
494
+ * @see https://github.com/mswjs/interceptors/issues/378
495
+ */
496
+ if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) {
497
+ this.passthrough()
498
+ return
499
+ }
500
+
501
+ this.onRequest({
502
+ requestId,
503
+ request: this.request,
504
+ socket: this,
505
+ })
506
+ }
507
+
508
+ private onRequestBody(chunk: Buffer): void {
509
+ invariant(
510
+ this.requestStream,
511
+ 'Failed to write to a request stream: stream does not exist'
512
+ )
513
+
514
+ this.requestStream.push(chunk)
515
+ }
516
+
517
+ private onRequestEnd(): void {
518
+ // Request end can be called for requests without body.
519
+ if (this.requestStream) {
520
+ this.requestStream.push(null)
521
+ }
522
+ }
523
+
524
+ private onResponseStart: ResponseHeadersCompleteCallback = (
525
+ versionMajor,
526
+ versionMinor,
527
+ rawHeaders,
528
+ method,
529
+ url,
530
+ status,
531
+ statusText
532
+ ) => {
533
+ const headers = parseRawHeaders(rawHeaders)
534
+ const canHaveBody = !RESPONSE_STATUS_CODES_WITHOUT_BODY.has(status)
535
+
536
+ // Similarly, create a new stream for each response.
537
+ if (canHaveBody) {
538
+ this.responseStream = new Readable()
539
+ }
540
+
541
+ const response = new Response(
542
+ /**
543
+ * @note The Fetch API response instance exposed to the consumer
544
+ * is created over the response stream of the HTTP parser. It is NOT
545
+ * related to the Socket instance. This way, you can read response body
546
+ * in response listener while the Socket instance delays the emission
547
+ * of "end" and other events until those response listeners are finished.
548
+ */
549
+ canHaveBody ? (Readable.toWeb(this.responseStream!) as any) : null,
550
+ {
551
+ status,
552
+ statusText,
553
+ headers,
554
+ }
555
+ )
556
+
557
+ invariant(
558
+ this.request,
559
+ 'Failed to handle a response: request does not exist'
560
+ )
561
+
562
+ /**
563
+ * @fixme Stop relying on the "X-Request-Id" request header
564
+ * to figure out if one interceptor has been invoked within another.
565
+ * @see https://github.com/mswjs/interceptors/issues/378
566
+ */
567
+ if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) {
568
+ return
569
+ }
570
+
571
+ this.responseListenersPromise = this.onResponse({
572
+ response,
573
+ isMockedResponse: this.responseType === 'mock',
574
+ requestId: Reflect.get(this.request, kRequestId),
575
+ request: this.request,
576
+ socket: this,
577
+ })
578
+ }
579
+
580
+ private onResponseBody(chunk: Buffer) {
581
+ invariant(
582
+ this.responseStream,
583
+ 'Failed to write to a response stream: stream does not exist'
584
+ )
585
+
586
+ this.responseStream.push(chunk)
587
+ }
588
+
589
+ private onResponseEnd(): void {
590
+ // Response end can be called for responses without body.
591
+ if (this.responseStream) {
592
+ this.responseStream.push(null)
593
+ }
594
+ }
595
+ }
@@ -0,0 +1,78 @@
1
+ import net from 'node:net'
2
+ import http from 'node:http'
3
+ import https from 'node:https'
4
+ import {
5
+ MockHttpSocket,
6
+ type MockHttpSocketRequestCallback,
7
+ type MockHttpSocketResponseCallback,
8
+ } from './MockHttpSocket'
9
+
10
+ declare module 'node:http' {
11
+ interface Agent {
12
+ createConnection(options: any, callback: any): net.Socket
13
+ }
14
+ }
15
+
16
+ interface MockAgentOptions {
17
+ customAgent?: http.RequestOptions['agent']
18
+ onRequest: MockHttpSocketRequestCallback
19
+ onResponse: MockHttpSocketResponseCallback
20
+ }
21
+
22
+ export class MockAgent extends http.Agent {
23
+ private customAgent?: http.RequestOptions['agent']
24
+ private onRequest: MockHttpSocketRequestCallback
25
+ private onResponse: MockHttpSocketResponseCallback
26
+
27
+ constructor(options: MockAgentOptions) {
28
+ super()
29
+ this.customAgent = options.customAgent
30
+ this.onRequest = options.onRequest
31
+ this.onResponse = options.onResponse
32
+ }
33
+
34
+ public createConnection(options: any, callback: any) {
35
+ const createConnection =
36
+ (this.customAgent instanceof http.Agent &&
37
+ this.customAgent.createConnection) ||
38
+ super.createConnection
39
+
40
+ const socket = new MockHttpSocket({
41
+ connectionOptions: options,
42
+ createConnection: createConnection.bind(this, options, callback),
43
+ onRequest: this.onRequest.bind(this),
44
+ onResponse: this.onResponse.bind(this),
45
+ })
46
+
47
+ return socket
48
+ }
49
+ }
50
+
51
+ export class MockHttpsAgent extends https.Agent {
52
+ private customAgent?: https.RequestOptions['agent']
53
+ private onRequest: MockHttpSocketRequestCallback
54
+ private onResponse: MockHttpSocketResponseCallback
55
+
56
+ constructor(options: MockAgentOptions) {
57
+ super()
58
+ this.customAgent = options.customAgent
59
+ this.onRequest = options.onRequest
60
+ this.onResponse = options.onResponse
61
+ }
62
+
63
+ public createConnection(options: any, callback: any) {
64
+ const createConnection =
65
+ (this.customAgent instanceof https.Agent &&
66
+ this.customAgent.createConnection) ||
67
+ super.createConnection
68
+
69
+ const socket = new MockHttpSocket({
70
+ connectionOptions: options,
71
+ createConnection: createConnection.bind(this, options, callback),
72
+ onRequest: this.onRequest.bind(this),
73
+ onResponse: this.onResponse.bind(this),
74
+ })
75
+
76
+ return socket
77
+ }
78
+ }