@mswjs/interceptors 0.32.1 → 0.33.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.
Files changed (121) hide show
  1. package/README.md +35 -7
  2. package/lib/browser/{chunk-732REFPX.mjs → chunk-5ETVT6GU.mjs} +28 -79
  3. package/lib/browser/chunk-5ETVT6GU.mjs.map +1 -0
  4. package/lib/browser/chunk-6MBJUL74.js +142 -0
  5. package/lib/browser/chunk-6MBJUL74.js.map +1 -0
  6. package/lib/browser/chunk-7A4UJNSW.mjs +196 -0
  7. package/lib/browser/chunk-7A4UJNSW.mjs.map +1 -0
  8. package/lib/browser/{chunk-PSX5J3RF.js → chunk-7GVJEW45.js} +30 -81
  9. package/lib/browser/chunk-7GVJEW45.js.map +1 -0
  10. package/lib/browser/{chunk-2CRB3JAQ.js → chunk-FXSPMSSQ.js} +1 -1
  11. package/lib/browser/chunk-FXSPMSSQ.js.map +1 -0
  12. package/lib/browser/{chunk-OMISYKWR.mjs → chunk-GGUENBDN.mjs} +1 -1
  13. package/lib/browser/chunk-GGUENBDN.mjs.map +1 -0
  14. package/lib/browser/chunk-NU2MPFD6.mjs +142 -0
  15. package/lib/browser/chunk-NU2MPFD6.mjs.map +1 -0
  16. package/lib/browser/chunk-VRKVKT62.js +196 -0
  17. package/lib/browser/chunk-VRKVKT62.js.map +1 -0
  18. package/lib/browser/glossary-7d7adb4b.d.ts +66 -0
  19. package/lib/browser/index.d.ts +1 -1
  20. package/lib/browser/index.js +2 -2
  21. package/lib/browser/index.mjs +1 -1
  22. package/lib/browser/interceptors/XMLHttpRequest/index.d.ts +2 -6
  23. package/lib/browser/interceptors/XMLHttpRequest/index.js +4 -4
  24. package/lib/browser/interceptors/XMLHttpRequest/index.mjs +3 -3
  25. package/lib/browser/interceptors/fetch/index.d.ts +1 -1
  26. package/lib/browser/interceptors/fetch/index.js +4 -4
  27. package/lib/browser/interceptors/fetch/index.mjs +3 -3
  28. package/lib/browser/presets/browser.d.ts +1 -1
  29. package/lib/browser/presets/browser.js +6 -6
  30. package/lib/browser/presets/browser.mjs +4 -4
  31. package/lib/node/{BatchInterceptor-2badedde.d.ts → BatchInterceptor-13d40c95.d.ts} +1 -1
  32. package/lib/node/{Interceptor-88ee47c0.d.ts → Interceptor-a31b1217.d.ts} +35 -13
  33. package/lib/node/RemoteHttpInterceptor.d.ts +2 -2
  34. package/lib/node/RemoteHttpInterceptor.js +55 -52
  35. package/lib/node/RemoteHttpInterceptor.js.map +1 -1
  36. package/lib/node/RemoteHttpInterceptor.mjs +53 -50
  37. package/lib/node/RemoteHttpInterceptor.mjs.map +1 -1
  38. package/lib/node/{chunk-5JMJ55U7.js → chunk-2MWIWEWV.js} +144 -113
  39. package/lib/node/chunk-2MWIWEWV.js.map +1 -0
  40. package/lib/node/{chunk-2COJKQQB.js → chunk-42632LKH.js} +3 -3
  41. package/lib/node/chunk-5WWNCLB3.js +196 -0
  42. package/lib/node/chunk-5WWNCLB3.js.map +1 -0
  43. package/lib/node/{chunk-TGTPXCLF.mjs → chunk-BUCULLYM.mjs} +1 -1
  44. package/lib/node/{chunk-TGTPXCLF.mjs.map → chunk-BUCULLYM.mjs.map} +1 -1
  45. package/lib/node/{chunk-OJ6O4LSC.mjs → chunk-BZ3Y7YV5.mjs} +1 -1
  46. package/lib/node/chunk-BZ3Y7YV5.mjs.map +1 -0
  47. package/lib/node/{chunk-3OJLYEWA.mjs → chunk-CU3YXMM4.mjs} +138 -107
  48. package/lib/node/chunk-CU3YXMM4.mjs.map +1 -0
  49. package/lib/node/{chunk-PNWPIDEL.mjs → chunk-HGQLG7KE.mjs} +2 -2
  50. package/lib/node/{chunk-EIBTX65O.js → chunk-IDEEMJ3F.js} +1 -1
  51. package/lib/node/chunk-IDEEMJ3F.js.map +1 -0
  52. package/lib/node/chunk-KY3RJ2M3.mjs +196 -0
  53. package/lib/node/chunk-KY3RJ2M3.mjs.map +1 -0
  54. package/lib/node/{chunk-PYD4E2EJ.js → chunk-P6QG76R3.js} +34 -85
  55. package/lib/node/chunk-P6QG76R3.js.map +1 -0
  56. package/lib/node/{chunk-DV4PBH4D.mjs → chunk-TOV4TYIX.mjs} +29 -80
  57. package/lib/node/chunk-TOV4TYIX.mjs.map +1 -0
  58. package/lib/node/{chunk-BFLYGQ6D.js → chunk-YGM3BCJU.js} +1 -1
  59. package/lib/node/chunk-YGM3BCJU.js.map +1 -0
  60. package/lib/node/index.d.ts +2 -2
  61. package/lib/node/index.js +4 -4
  62. package/lib/node/index.mjs +3 -3
  63. package/lib/node/interceptors/ClientRequest/index.d.ts +2 -2
  64. package/lib/node/interceptors/ClientRequest/index.js +4 -4
  65. package/lib/node/interceptors/ClientRequest/index.mjs +3 -3
  66. package/lib/node/interceptors/XMLHttpRequest/index.d.ts +2 -6
  67. package/lib/node/interceptors/XMLHttpRequest/index.js +5 -5
  68. package/lib/node/interceptors/XMLHttpRequest/index.mjs +4 -4
  69. package/lib/node/interceptors/fetch/index.d.ts +1 -1
  70. package/lib/node/interceptors/fetch/index.js +52 -123
  71. package/lib/node/interceptors/fetch/index.js.map +1 -1
  72. package/lib/node/interceptors/fetch/index.mjs +50 -121
  73. package/lib/node/interceptors/fetch/index.mjs.map +1 -1
  74. package/lib/node/presets/node.d.ts +1 -1
  75. package/lib/node/presets/node.js +7 -7
  76. package/lib/node/presets/node.mjs +5 -5
  77. package/package.json +2 -2
  78. package/src/InterceptorError.ts +7 -0
  79. package/src/RemoteHttpInterceptor.ts +62 -57
  80. package/src/RequestController.test.ts +49 -0
  81. package/src/RequestController.ts +81 -0
  82. package/src/glossary.ts +4 -6
  83. package/src/interceptors/ClientRequest/MockHttpSocket.ts +22 -16
  84. package/src/interceptors/ClientRequest/index.test.ts +2 -33
  85. package/src/interceptors/ClientRequest/index.ts +32 -82
  86. package/src/interceptors/ClientRequest/utils/recordRawHeaders.ts +170 -0
  87. package/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +1 -1
  88. package/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts +27 -108
  89. package/src/interceptors/XMLHttpRequest/index.ts +0 -6
  90. package/src/interceptors/fetch/index.ts +52 -169
  91. package/src/utils/handleRequest.ts +213 -0
  92. package/src/utils/responseUtils.ts +4 -4
  93. package/lib/browser/chunk-2CRB3JAQ.js.map +0 -1
  94. package/lib/browser/chunk-732REFPX.mjs.map +0 -1
  95. package/lib/browser/chunk-MAEPOYB6.mjs +0 -213
  96. package/lib/browser/chunk-MAEPOYB6.mjs.map +0 -1
  97. package/lib/browser/chunk-MQJ3JOOK.js +0 -49
  98. package/lib/browser/chunk-MQJ3JOOK.js.map +0 -1
  99. package/lib/browser/chunk-OMISYKWR.mjs.map +0 -1
  100. package/lib/browser/chunk-OUWBQF3Z.mjs +0 -49
  101. package/lib/browser/chunk-OUWBQF3Z.mjs.map +0 -1
  102. package/lib/browser/chunk-PSX5J3RF.js.map +0 -1
  103. package/lib/browser/chunk-WBHIW62P.js +0 -213
  104. package/lib/browser/chunk-WBHIW62P.js.map +0 -1
  105. package/lib/browser/glossary-1c204f45.d.ts +0 -44
  106. package/lib/node/chunk-3OJLYEWA.mjs.map +0 -1
  107. package/lib/node/chunk-5JMJ55U7.js.map +0 -1
  108. package/lib/node/chunk-BFLYGQ6D.js.map +0 -1
  109. package/lib/node/chunk-DV4PBH4D.mjs.map +0 -1
  110. package/lib/node/chunk-EIBTX65O.js.map +0 -1
  111. package/lib/node/chunk-KWV3JXSI.mjs +0 -49
  112. package/lib/node/chunk-KWV3JXSI.mjs.map +0 -1
  113. package/lib/node/chunk-OJ6O4LSC.mjs.map +0 -1
  114. package/lib/node/chunk-PYD4E2EJ.js.map +0 -1
  115. package/lib/node/chunk-UXCYRE4F.js +0 -49
  116. package/lib/node/chunk-UXCYRE4F.js.map +0 -1
  117. package/src/utils/getRawFetchHeaders.test.ts +0 -50
  118. package/src/utils/getRawFetchHeaders.ts +0 -56
  119. package/src/utils/toInteractiveRequest.ts +0 -23
  120. /package/lib/node/{chunk-2COJKQQB.js.map → chunk-42632LKH.js.map} +0 -0
  121. /package/lib/node/{chunk-PNWPIDEL.mjs.map → chunk-HGQLG7KE.mjs.map} +0 -0
@@ -4,8 +4,8 @@ import { Interceptor } from './Interceptor'
4
4
  import { BatchInterceptor } from './BatchInterceptor'
5
5
  import { ClientRequestInterceptor } from './interceptors/ClientRequest'
6
6
  import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest'
7
- import { toInteractiveRequest } from './utils/toInteractiveRequest'
8
- import { emitAsync } from './utils/emitAsync'
7
+ import { handleRequest } from './utils/handleRequest'
8
+ import { RequestController } from './RequestController'
9
9
 
10
10
  export interface SerializedRequest {
11
11
  id: string
@@ -46,7 +46,7 @@ export class RemoteHttpInterceptor extends BatchInterceptor<
46
46
 
47
47
  let handleParentMessage: NodeJS.MessageListener
48
48
 
49
- this.on('request', async ({ request, requestId }) => {
49
+ this.on('request', async ({ request, requestId, controller }) => {
50
50
  // Send the stringified intercepted request to
51
51
  // the parent process where the remote resolver is established.
52
52
  const serializedRequest = JSON.stringify({
@@ -64,6 +64,7 @@ export class RemoteHttpInterceptor extends BatchInterceptor<
64
64
  'sent serialized request to the child:',
65
65
  serializedRequest
66
66
  )
67
+
67
68
  process.send?.(`request:${serializedRequest}`)
68
69
 
69
70
  const responsePromise = new Promise<void>((resolve) => {
@@ -90,7 +91,12 @@ export class RemoteHttpInterceptor extends BatchInterceptor<
90
91
  headers: responseInit.headers,
91
92
  })
92
93
 
93
- request.respondWith(mockedResponse)
94
+ /**
95
+ * @todo Support "errorWith" as well.
96
+ * This response handling from the child is incomplete.
97
+ */
98
+
99
+ controller.respondWith(mockedResponse)
94
100
  return resolve()
95
101
  }
96
102
  }
@@ -158,69 +164,68 @@ export class RemoteHttpResolver extends Interceptor<HttpRequestEventMap> {
158
164
  serializedRequest,
159
165
  requestReviver
160
166
  ) as RevivedRequest
167
+
161
168
  logger.info('parsed intercepted request', requestJson)
162
169
 
163
- const capturedRequest = new Request(requestJson.url, {
170
+ const request = new Request(requestJson.url, {
164
171
  method: requestJson.method,
165
172
  headers: new Headers(requestJson.headers),
166
173
  credentials: requestJson.credentials,
167
174
  body: requestJson.body,
168
175
  })
169
176
 
170
- const { interactiveRequest, requestController } =
171
- toInteractiveRequest(capturedRequest)
172
-
173
- this.emitter.once('request', () => {
174
- if (requestController.responsePromise.state === 'pending') {
175
- requestController.respondWith(undefined)
176
- }
177
- })
178
-
179
- await emitAsync(this.emitter, 'request', {
180
- request: interactiveRequest,
177
+ const controller = new RequestController(request)
178
+ await handleRequest({
179
+ request,
181
180
  requestId: requestJson.id,
181
+ controller,
182
+ emitter: this.emitter,
183
+ onResponse: async (response) => {
184
+ this.logger.info('received mocked response!', { response })
185
+
186
+ const responseClone = response.clone()
187
+ const responseText = await responseClone.text()
188
+
189
+ // // Send the mocked response to the child process.
190
+ const serializedResponse = JSON.stringify({
191
+ status: response.status,
192
+ statusText: response.statusText,
193
+ headers: Array.from(response.headers.entries()),
194
+ body: responseText,
195
+ } as SerializedResponse)
196
+
197
+ this.process.send(
198
+ `response:${requestJson.id}:${serializedResponse}`,
199
+ (error) => {
200
+ if (error) {
201
+ return
202
+ }
203
+
204
+ // Emit an optimistic "response" event at this point,
205
+ // not to rely on the back-and-forth signaling for the sake of the event.
206
+ this.emitter.emit('response', {
207
+ request,
208
+ requestId: requestJson.id,
209
+ response: responseClone,
210
+ isMockedResponse: true,
211
+ })
212
+ }
213
+ )
214
+
215
+ logger.info(
216
+ 'sent serialized mocked response to the parent:',
217
+ serializedResponse
218
+ )
219
+ },
220
+ onRequestError: (response) => {
221
+ this.logger.info('received a network error!', { response })
222
+ throw new Error('Not implemented')
223
+ },
224
+ onError: (error) => {
225
+ this.logger.info('request has errored!', { error })
226
+ throw new Error('Not implemented')
227
+ },
182
228
  })
183
-
184
- const mockedResponse = await requestController.responsePromise
185
-
186
- if (!mockedResponse) {
187
- return
188
- }
189
-
190
- logger.info('event.respondWith called with:', mockedResponse)
191
- const responseClone = mockedResponse.clone()
192
- const responseText = await mockedResponse.text()
193
-
194
- // Send the mocked response to the child process.
195
- const serializedResponse = JSON.stringify({
196
- status: mockedResponse.status,
197
- statusText: mockedResponse.statusText,
198
- headers: Array.from(mockedResponse.headers.entries()),
199
- body: responseText,
200
- } as SerializedResponse)
201
-
202
- this.process.send(
203
- `response:${requestJson.id}:${serializedResponse}`,
204
- (error) => {
205
- if (error) {
206
- return
207
- }
208
-
209
- // Emit an optimistic "response" event at this point,
210
- // not to rely on the back-and-forth signaling for the sake of the event.
211
- this.emitter.emit('response', {
212
- response: responseClone,
213
- isMockedResponse: true,
214
- request: capturedRequest,
215
- requestId: requestJson.id,
216
- })
217
- }
218
- )
219
-
220
- logger.info(
221
- 'sent serialized mocked response to the parent:',
222
- serializedResponse
223
- )
224
229
  }
225
230
 
226
231
  this.subscriptions.push(() => {
@@ -0,0 +1,49 @@
1
+ import { it, expect } from 'vitest'
2
+ import { kResponsePromise, RequestController } from './RequestController'
3
+
4
+ it('creates a pending response promise on construction', () => {
5
+ const controller = new RequestController(new Request('http://localhost'))
6
+ expect(controller[kResponsePromise]).toBeInstanceOf(Promise)
7
+ expect(controller[kResponsePromise].state).toBe('pending')
8
+ })
9
+
10
+ it('resolves the response promise with the response provided to "respondWith"', async () => {
11
+ const controller = new RequestController(new Request('http://localhost'))
12
+ controller.respondWith(new Response('hello world'))
13
+
14
+ const response = (await controller[kResponsePromise]) as Response
15
+
16
+ expect(response).toBeInstanceOf(Response)
17
+ expect(response.status).toBe(200)
18
+ expect(await response.text()).toBe('hello world')
19
+ })
20
+
21
+ it('resolves the response promise with the error provided to "errorWith"', async () => {
22
+ const controller = new RequestController(new Request('http://localhost'))
23
+ const error = new Error('Oops!')
24
+ controller.errorWith(error)
25
+
26
+ await expect(controller[kResponsePromise]).resolves.toEqual(error)
27
+ })
28
+
29
+ it('throws when calling "respondWith" multiple times', () => {
30
+ const controller = new RequestController(new Request('http://localhost'))
31
+ controller.respondWith(new Response('hello world'))
32
+
33
+ expect(() => {
34
+ controller.respondWith(new Response('second response'))
35
+ }).toThrow(
36
+ 'Failed to respond to the "GET http://localhost/" request: the "request" event has already been handled.'
37
+ )
38
+ })
39
+
40
+ it('throws when calling "errorWith" multiple times', () => {
41
+ const controller = new RequestController(new Request('http://localhost'))
42
+ controller.errorWith(new Error('Oops!'))
43
+
44
+ expect(() => {
45
+ controller.errorWith(new Error('second error'))
46
+ }).toThrow(
47
+ 'Failed to error the "GET http://localhost/" request: the "request" event has already been handled.'
48
+ )
49
+ })
@@ -0,0 +1,81 @@
1
+ import { invariant } from 'outvariant'
2
+ import { DeferredPromise } from '@open-draft/deferred-promise'
3
+ import { InterceptorError } from './InterceptorError'
4
+
5
+ const kRequestHandled = Symbol('kRequestHandled')
6
+ export const kResponsePromise = Symbol('kResponsePromise')
7
+
8
+ export class RequestController {
9
+ /**
10
+ * Internal response promise.
11
+ * Available only for the library internals to grab the
12
+ * response instance provided by the developer.
13
+ * @note This promise cannot be rejected. It's either infinitely
14
+ * pending or resolved with whichever Response was passed to `respondWith()`.
15
+ */
16
+ [kResponsePromise]: DeferredPromise<Response | Error | undefined>;
17
+
18
+ /**
19
+ * Internal flag indicating if this request has been handled.
20
+ * @note The response promise becomes "fulfilled" on the next tick.
21
+ */
22
+ [kRequestHandled]: boolean
23
+
24
+ constructor(private request: Request) {
25
+ this[kRequestHandled] = false
26
+ this[kResponsePromise] = new DeferredPromise()
27
+ }
28
+
29
+ /**
30
+ * Respond to this request with the given `Response` instance.
31
+ * @example
32
+ * controller.respondWith(new Response())
33
+ * controller.respondWith(Response.json({ id }))
34
+ * controller.respondWith(Response.error())
35
+ */
36
+ public respondWith(response: Response): void {
37
+ invariant.as(
38
+ InterceptorError,
39
+ !this[kRequestHandled],
40
+ 'Failed to respond to the "%s %s" request: the "request" event has already been handled.',
41
+ this.request.method,
42
+ this.request.url
43
+ )
44
+
45
+ this[kRequestHandled] = true
46
+ this[kResponsePromise].resolve(response)
47
+
48
+ /**
49
+ * @note The request conrtoller doesn't do anything
50
+ * apart from letting the interceptor await the response
51
+ * provided by the developer through the response promise.
52
+ * Each interceptor implements the actual respondWith/errorWith
53
+ * logic based on that interceptor's needs.
54
+ */
55
+ }
56
+
57
+ /**
58
+ * Error this request with the given error.
59
+ * @example
60
+ * controller.errorWith()
61
+ * controller.errorWith(new Error('Oops!'))
62
+ */
63
+ public errorWith(error?: Error): void {
64
+ invariant.as(
65
+ InterceptorError,
66
+ !this[kRequestHandled],
67
+ 'Failed to error the "%s %s" request: the "request" event has already been handled.',
68
+ this.request.method,
69
+ this.request.url
70
+ )
71
+
72
+ this[kRequestHandled] = true
73
+
74
+ /**
75
+ * @note Resolve the response promise, not reject.
76
+ * This helps us differentiate between unhandled exceptions
77
+ * and intended errors ("errorWith") while waiting for the response.
78
+ */
79
+ this[kResponsePromise].resolve(error)
80
+ }
81
+ }
package/src/glossary.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { InteractiveRequest } from './utils/toInteractiveRequest'
1
+ import type { RequestController } from './RequestController'
2
2
 
3
3
  export const IS_PATCHED_MODULE: unique symbol = Symbol('isPatchedModule')
4
4
 
@@ -7,8 +7,9 @@ export type RequestCredentials = 'omit' | 'include' | 'same-origin'
7
7
  export type HttpRequestEventMap = {
8
8
  request: [
9
9
  args: {
10
- request: InteractiveRequest
10
+ request: Request
11
11
  requestId: string
12
+ controller: RequestController
12
13
  }
13
14
  ]
14
15
  response: [
@@ -24,10 +25,7 @@ export type HttpRequestEventMap = {
24
25
  error: unknown
25
26
  request: Request
26
27
  requestId: string
27
- controller: {
28
- respondWith(response: Response): void
29
- errorWith(error?: Error): void
30
- }
28
+ controller: RequestController
31
29
  }
32
30
  ]
33
31
  }
@@ -4,7 +4,7 @@ import {
4
4
  type RequestHeadersCompleteCallback,
5
5
  type ResponseHeadersCompleteCallback,
6
6
  } from '_http_common'
7
- import { IncomingMessage, ServerResponse } from 'node:http'
7
+ import { STATUS_CODES, IncomingMessage, ServerResponse } from 'node:http'
8
8
  import { Readable } from 'node:stream'
9
9
  import { invariant } from 'outvariant'
10
10
  import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor'
@@ -13,12 +13,12 @@ import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketW
13
13
  import { isPropertyAccessible } from '../../utils/isPropertyAccessible'
14
14
  import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions'
15
15
  import { parseRawHeaders } from '../Socket/utils/parseRawHeaders'
16
- import { getRawFetchHeaders } from '../../utils/getRawFetchHeaders'
17
16
  import {
18
17
  createServerErrorResponse,
19
18
  RESPONSE_STATUS_CODES_WITHOUT_BODY,
20
19
  } from '../../utils/responseUtils'
21
20
  import { createRequestId } from '../../createRequestId'
21
+ import { getRawFetchHeaders } from './utils/recordRawHeaders'
22
22
 
23
23
  type HttpConnectionOptions = any
24
24
 
@@ -185,11 +185,12 @@ export class MockHttpSocket extends MockSocket {
185
185
  const chunkAfterRequestHeaders = chunkString.slice(
186
186
  chunk.indexOf('\r\n\r\n')
187
187
  )
188
- const requestHeaders =
189
- getRawFetchHeaders(this.request!.headers) || this.request!.headers
190
- const requestHeadersString = Array.from(requestHeaders.entries())
188
+ const rawRequestHeaders = getRawFetchHeaders(this.request!.headers)
189
+ const requestHeadersString = rawRequestHeaders
191
190
  // Skip the internal request ID deduplication header.
192
- .filter(([name]) => name !== INTERNAL_REQUEST_ID_HEADER_NAME)
191
+ .filter(([name]) => {
192
+ return name.toLowerCase() !== INTERNAL_REQUEST_ID_HEADER_NAME
193
+ })
193
194
  .map(([name, value]) => `${name}: ${value}`)
194
195
  .join('\r\n')
195
196
 
@@ -304,8 +305,6 @@ export class MockHttpSocket extends MockSocket {
304
305
  read() {},
305
306
  })
306
307
  )
307
- serverResponse.statusCode = response.status
308
- serverResponse.statusMessage = response.statusText
309
308
 
310
309
  /**
311
310
  * @note Remove the `Connection` and `Date` response headers
@@ -319,17 +318,24 @@ export class MockHttpSocket extends MockSocket {
319
318
  serverResponse.removeHeader('connection')
320
319
  serverResponse.removeHeader('date')
321
320
 
321
+ const rawResponseHeaders = getRawFetchHeaders(response.headers)
322
+
323
+ /**
324
+ * @note Call `.writeHead` in order to set the raw response headers
325
+ * in the same case as they were provided by the developer. Using
326
+ * `.setHeader()`/`.appendHeader()` normalizes header names.
327
+ */
328
+ serverResponse.writeHead(
329
+ response.status,
330
+ response.statusText || STATUS_CODES[response.status],
331
+ rawResponseHeaders
332
+ )
333
+
322
334
  // If the developer destroy the socket, gracefully destroy the response.
323
335
  this.once('error', () => {
324
336
  serverResponse.destroy()
325
337
  })
326
338
 
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
339
  if (response.body) {
334
340
  try {
335
341
  const reader = response.body.getReader()
@@ -372,7 +378,7 @@ export class MockHttpSocket extends MockSocket {
372
378
  /**
373
379
  * Close this socket connection with the given error.
374
380
  */
375
- public errorWith(error: Error): void {
381
+ public errorWith(error?: Error): void {
376
382
  this.destroy(error)
377
383
  }
378
384
 
@@ -535,7 +541,7 @@ export class MockHttpSocket extends MockSocket {
535
541
 
536
542
  // Similarly, create a new stream for each response.
537
543
  if (canHaveBody) {
538
- this.responseStream = new Readable()
544
+ this.responseStream = new Readable({ read() {} })
539
545
  }
540
546
 
541
547
  const response = new Response(
@@ -30,43 +30,12 @@ afterAll(async () => {
30
30
  await httpServer.close()
31
31
  })
32
32
 
33
- it('forbids calling "respondWith" multiple times for the same request', async () => {
34
- const requestUrl = httpServer.http.url('/')
35
-
36
- interceptor.on('request', function firstRequestListener({ request }) {
37
- request.respondWith(new Response())
38
- })
39
-
40
- const secondRequestEmitted = new DeferredPromise<void>()
41
- interceptor.on('request', function secondRequestListener({ request }) {
42
- expect(() =>
43
- request.respondWith(new Response(null, { status: 301 }))
44
- ).toThrow(
45
- `Failed to respond to "GET ${requestUrl}" request: the "request" event has already been responded to.`
46
- )
47
-
48
- secondRequestEmitted.resolve()
49
- })
50
-
51
- const request = http.get(requestUrl)
52
- await secondRequestEmitted
53
-
54
- const responseReceived = new DeferredPromise<http.IncomingMessage>()
55
- request.on('response', (response) => {
56
- responseReceived.resolve(response)
57
- })
58
-
59
- const response = await responseReceived
60
- expect(response.statusCode).toBe(200)
61
- expect(response.statusMessage).toBe('OK')
62
- })
63
-
64
33
  it('abort the request if the abort signal is emitted', async () => {
65
34
  const requestUrl = httpServer.http.url('/')
66
35
 
67
- interceptor.on('request', async function delayedResponse({ request }) {
36
+ interceptor.on('request', async function delayedResponse({ controller }) {
68
37
  await sleep(1_000)
69
- request.respondWith(new Response())
38
+ controller.respondWith(new Response())
70
39
  })
71
40
 
72
41
  const abortController = new AbortController()
@@ -1,6 +1,5 @@
1
1
  import http from 'node:http'
2
2
  import https from 'node:https'
3
- import { until } from '@open-draft/until'
4
3
  import { Interceptor } from '../../Interceptor'
5
4
  import type { HttpRequestEventMap } from '../../glossary'
6
5
  import {
@@ -9,11 +8,14 @@ import {
9
8
  MockHttpSocketResponseCallback,
10
9
  } from './MockHttpSocket'
11
10
  import { MockAgent, MockHttpsAgent } from './agents'
11
+ import { RequestController } from '../../RequestController'
12
12
  import { emitAsync } from '../../utils/emitAsync'
13
- import { toInteractiveRequest } from '../../utils/toInteractiveRequest'
14
13
  import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs'
15
- import { isNodeLikeError } from '../../utils/isNodeLikeError'
16
- import { createServerErrorResponse } from '../../utils/responseUtils'
14
+ import { handleRequest } from '../../utils/handleRequest'
15
+ import {
16
+ recordRawFetchHeaders,
17
+ restoreHeadersPrototype,
18
+ } from './utils/recordRawHeaders'
17
19
 
18
20
  export class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> {
19
21
  static symbol = Symbol('client-request-interceptor')
@@ -104,12 +106,19 @@ export class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> {
104
106
  },
105
107
  })
106
108
 
109
+ // Spy on `Header.prototype.set` and `Header.prototype.append` calls
110
+ // and record the raw header names provided. This is to support
111
+ // `IncomingMessage.prototype.rawHeaders`.
112
+ recordRawFetchHeaders()
113
+
107
114
  this.subscriptions.push(() => {
108
115
  http.get = originalGet
109
116
  http.request = originalRequest
110
117
 
111
118
  https.get = originalHttpsGet
112
119
  https.request = originalHttpsRequest
120
+
121
+ restoreHeadersPrototype()
113
122
  })
114
123
  }
115
124
 
@@ -118,88 +127,29 @@ export class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> {
118
127
  socket,
119
128
  }) => {
120
129
  const requestId = Reflect.get(request, kRequestId)
121
- const { interactiveRequest, requestController } =
122
- toInteractiveRequest(request)
123
-
124
- // TODO: Abstract this bit. We are using it everywhere.
125
- this.emitter.once('request', ({ requestId: pendingRequestId }) => {
126
- if (pendingRequestId !== requestId) {
127
- return
128
- }
129
-
130
- if (requestController.responsePromise.state === 'pending') {
131
- this.logger.info(
132
- 'request has not been handled in listeners, executing fail-safe listener...'
133
- )
134
-
135
- requestController.responsePromise.resolve(undefined)
136
- }
137
- })
138
-
139
- const listenerResult = await until(async () => {
140
- await emitAsync(this.emitter, 'request', {
141
- requestId,
142
- request: interactiveRequest,
143
- })
144
-
145
- return await requestController.responsePromise
146
- })
147
-
148
- if (listenerResult.error) {
149
- // Treat thrown Responses as mocked responses.
150
- if (listenerResult.error instanceof Response) {
151
- socket.respondWith(listenerResult.error)
152
- return
153
- }
154
-
155
- // Allow mocking Node-like errors.
156
- if (isNodeLikeError(listenerResult.error)) {
157
- socket.errorWith(listenerResult.error)
158
- return
159
- }
160
-
161
- // Emit the "unhandledException" event to allow the client
162
- // to opt-out from the default handling of exceptions
163
- // as 500 error responses.
164
- if (this.emitter.listenerCount('unhandledException') > 0) {
165
- await emitAsync(this.emitter, 'unhandledException', {
166
- error: listenerResult.error,
167
- request,
168
- requestId,
169
- controller: {
170
- respondWith: socket.respondWith.bind(socket),
171
- errorWith: socket.errorWith.bind(socket),
172
- },
173
- })
130
+ const controller = new RequestController(request)
174
131
 
175
- // After the listeners are done, if the socket is
176
- // not connecting anymore, the response was mocked.
177
- // If the socket has been destroyed, the error was mocked.
178
- // Treat both as the result of the listener's call.
179
- if (!socket.connecting || socket.destroyed) {
180
- return
132
+ const isRequestHandled = await handleRequest({
133
+ request,
134
+ requestId,
135
+ controller,
136
+ emitter: this.emitter,
137
+ onResponse: (response) => {
138
+ socket.respondWith(response)
139
+ },
140
+ onRequestError: (response) => {
141
+ socket.respondWith(response)
142
+ },
143
+ onError: (error) => {
144
+ if (error instanceof Error) {
145
+ socket.errorWith(error)
181
146
  }
182
- }
183
-
184
- // Unhandled exceptions in the request listeners are
185
- // synonymous to unhandled exceptions on the server.
186
- // Those are represented as 500 error responses.
187
- socket.respondWith(createServerErrorResponse(listenerResult.error))
188
- return
189
- }
190
-
191
- const mockedResponse = listenerResult.data
147
+ },
148
+ })
192
149
 
193
- if (mockedResponse) {
194
- /**
195
- * @note The `.respondWith()` method will handle "Response.error()".
196
- * Maybe we should make all interceptors do that?
197
- */
198
- socket.respondWith(mockedResponse)
199
- return
150
+ if (!isRequestHandled) {
151
+ return socket.passthrough()
200
152
  }
201
-
202
- socket.passthrough()
203
153
  }
204
154
 
205
155
  public onResponse: MockHttpSocketResponseCallback = async ({