@mswjs/interceptors 0.25.6 → 0.25.7

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.
@@ -3,7 +3,7 @@
3
3
  var _chunkUF7QIAQ5js = require('./chunk-UF7QIAQ5.js');
4
4
 
5
5
 
6
- var _chunk44QGFZITjs = require('./chunk-44QGFZIT.js');
6
+ var _chunkYVNH3GJ5js = require('./chunk-YVNH3GJ5.js');
7
7
 
8
8
 
9
9
  var _chunkJCWVLTP7js = require('./chunk-JCWVLTP7.js');
@@ -24,7 +24,7 @@ var RemoteHttpInterceptor = class extends _chunkUF7QIAQ5js.BatchInterceptor {
24
24
  super({
25
25
  name: "remote-interceptor",
26
26
  interceptors: [
27
- new (0, _chunk44QGFZITjs.ClientRequestInterceptor)(),
27
+ new (0, _chunkYVNH3GJ5js.ClientRequestInterceptor)(),
28
28
  new (0, _chunkJCWVLTP7js.XMLHttpRequestInterceptor)()
29
29
  ]
30
30
  });
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-UBEFEZXT.mjs";
4
4
  import {
5
5
  ClientRequestInterceptor
6
- } from "./chunk-Z7O2DO3X.mjs";
6
+ } from "./chunk-G5IEXC7T.mjs";
7
7
  import {
8
8
  XMLHttpRequestInterceptor
9
9
  } from "./chunk-FB53TMYN.mjs";
@@ -167,12 +167,52 @@ function createRequest(clientRequest) {
167
167
  });
168
168
  }
169
169
 
170
+ // src/utils/getValueBySymbol.ts
171
+ function getValueBySymbol(symbolName, source) {
172
+ const ownSymbols = Object.getOwnPropertySymbols(source);
173
+ const symbol = ownSymbols.find((symbol2) => {
174
+ return symbol2.description === symbolName;
175
+ });
176
+ if (symbol) {
177
+ return Reflect.get(source, symbol);
178
+ }
179
+ return;
180
+ }
181
+
182
+ // src/utils/isObject.ts
183
+ function isObject(value) {
184
+ return Object.prototype.toString.call(value) === "[object Object]";
185
+ }
186
+
187
+ // src/utils/getRawFetchHeaders.ts
188
+ function getRawFetchHeaders(headers) {
189
+ const headersList = getValueBySymbol("headers list", headers);
190
+ if (!headersList) {
191
+ return;
192
+ }
193
+ const headersMap = getValueBySymbol("headers map", headersList);
194
+ if (!headersMap || !isHeadersMapWithRawHeaderNames(headersMap)) {
195
+ return;
196
+ }
197
+ const rawHeaders = /* @__PURE__ */ new Map();
198
+ headersMap.forEach(({ name, value }) => {
199
+ rawHeaders.set(name, value);
200
+ });
201
+ return rawHeaders;
202
+ }
203
+ function isHeadersMapWithRawHeaderNames(headersMap) {
204
+ return Array.from(
205
+ headersMap.values()
206
+ ).every((value) => {
207
+ return isObject(value) && "name" in value;
208
+ });
209
+ }
210
+
170
211
  // src/interceptors/ClientRequest/NodeClientRequest.ts
171
212
  var _NodeClientRequest = class extends ClientRequest {
172
213
  constructor([url, requestOptions, callback], options) {
173
214
  super(requestOptions, callback);
174
215
  this.chunks = [];
175
- this.responseSource = "mock";
176
216
  this.logger = options.logger.extend(
177
217
  `request ${requestOptions.method} ${url.href}`
178
218
  );
@@ -181,6 +221,7 @@ var _NodeClientRequest = class extends ClientRequest {
181
221
  requestOptions,
182
222
  callback
183
223
  });
224
+ this.state = 0 /* Idle */;
184
225
  this.url = url;
185
226
  this.emitter = options.emitter;
186
227
  this.requestBuffer = null;
@@ -219,6 +260,7 @@ var _NodeClientRequest = class extends ClientRequest {
219
260
  const [chunk, encoding, callback] = normalizeClientRequestEndArgs(...args);
220
261
  this.logger.info("normalized arguments:", { chunk, encoding, callback });
221
262
  this.writeRequestBodyChunk(chunk, encoding || void 0);
263
+ this.state = 2 /* Sent */;
222
264
  const capturedRequest = createRequest(this);
223
265
  const { interactiveRequest, requestController } = toInteractiveRequest(capturedRequest);
224
266
  Object.defineProperty(capturedRequest, "respondWith", {
@@ -244,6 +286,7 @@ var _NodeClientRequest = class extends ClientRequest {
244
286
  'emitting the "request" event for %d listener(s)...',
245
287
  this.emitter.listenerCount("request")
246
288
  );
289
+ this.state = 3 /* MockLookupStart */;
247
290
  await emitAsync(this.emitter, "request", {
248
291
  request: interactiveRequest,
249
292
  requestId
@@ -254,6 +297,7 @@ var _NodeClientRequest = class extends ClientRequest {
254
297
  return mockedResponse;
255
298
  }).then((resolverResult) => {
256
299
  this.logger.info("the listeners promise awaited!");
300
+ this.state = 4 /* MockLookupEnd */;
257
301
  if (!this.headersSent) {
258
302
  for (const [headerName, headerValue] of capturedRequest.headers) {
259
303
  this.setHeader(headerName, headerValue);
@@ -286,7 +330,6 @@ var _NodeClientRequest = class extends ClientRequest {
286
330
  return this;
287
331
  }
288
332
  const responseClone = mockedResponse.clone();
289
- this.responseSource = "mock";
290
333
  this.respondWith(mockedResponse);
291
334
  this.logger.info(
292
335
  mockedResponse.status,
@@ -342,12 +385,17 @@ var _NodeClientRequest = class extends ClientRequest {
342
385
  const error = data[0];
343
386
  const errorCode = error.code || "";
344
387
  this.logger.info("error:\n", error);
345
- if (this.responseSource === "mock" && _NodeClientRequest.suppressErrorCodes.includes(errorCode)) {
346
- if (!this.capturedError) {
347
- this.capturedError = error;
348
- this.logger.info("captured the first error:", this.capturedError);
388
+ if (_NodeClientRequest.suppressErrorCodes.includes(errorCode)) {
389
+ if (this.state < 4 /* MockLookupEnd */) {
390
+ if (!this.capturedError) {
391
+ this.capturedError = error;
392
+ this.logger.info("captured the first error:", this.capturedError);
393
+ }
394
+ return false;
395
+ }
396
+ if (this.state === 5 /* ResponseReceived */ && this.responseType === "mock") {
397
+ return false;
349
398
  }
350
- return false;
351
399
  }
352
400
  }
353
401
  return super.emit(event, ...data);
@@ -359,7 +407,8 @@ var _NodeClientRequest = class extends ClientRequest {
359
407
  * up the request with `super.end()`.
360
408
  */
361
409
  passthrough(chunk, encoding, callback) {
362
- this.responseSource = "bypass";
410
+ this.state = 5 /* ResponseReceived */;
411
+ this.responseType = "passthrough";
363
412
  if (this.capturedError) {
364
413
  this.emit("error", this.capturedError);
365
414
  return this;
@@ -390,6 +439,8 @@ var _NodeClientRequest = class extends ClientRequest {
390
439
  */
391
440
  respondWith(mockedResponse) {
392
441
  this.logger.info("responding with a mocked response...", mockedResponse);
442
+ this.state = 5 /* ResponseReceived */;
443
+ this.responseType = "mock";
393
444
  Object.defineProperties(this, {
394
445
  writableFinished: { value: true },
395
446
  writableEnded: { value: true }
@@ -398,9 +449,10 @@ var _NodeClientRequest = class extends ClientRequest {
398
449
  const { status, statusText, headers, body } = mockedResponse;
399
450
  this.response.statusCode = status;
400
451
  this.response.statusMessage = statusText;
401
- if (headers) {
452
+ const rawHeaders = getRawFetchHeaders(headers) || headers;
453
+ if (rawHeaders) {
402
454
  this.response.headers = {};
403
- headers.forEach((headerValue, headerName) => {
455
+ rawHeaders.forEach((headerValue, headerName) => {
404
456
  this.response.rawHeaders.push(headerName, headerValue);
405
457
  const insensitiveHeaderName = headerName.toLowerCase();
406
458
  const prevHeaders = this.response.headers[insensitiveHeaderName];
@@ -456,7 +508,9 @@ NodeClientRequest.suppressErrorCodes = [
456
508
  "ENOTFOUND",
457
509
  "ECONNREFUSED",
458
510
  "ECONNRESET",
459
- "EAI_AGAIN"
511
+ "EAI_AGAIN",
512
+ "ENETUNREACH",
513
+ "EHOSTUNREACH"
460
514
  ];
461
515
 
462
516
  // src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts
@@ -616,11 +670,6 @@ function cloneObject(obj) {
616
670
  return isPlainObject(obj) ? enumerableProperties : Object.assign(Object.getPrototypeOf(obj), enumerableProperties);
617
671
  }
618
672
 
619
- // src/utils/isObject.ts
620
- function isObject(value) {
621
- return Object.prototype.toString.call(value) === "[object Object]";
622
- }
623
-
624
673
  // src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts
625
674
  var logger5 = new Logger5("http normalizeClientRequestArgs");
626
675
  function resolveRequestOptions(args, url) {
@@ -663,6 +712,11 @@ function normalizeClientRequestArgs(defaultProtocol, ...args) {
663
712
  let callback;
664
713
  logger5.info("arguments", args);
665
714
  logger5.info("using default protocol:", defaultProtocol);
715
+ if (args.length === 0) {
716
+ const url2 = new URL("http://localhost");
717
+ const options2 = resolveRequestOptions(args, url2);
718
+ return [url2, options2];
719
+ }
666
720
  if (typeof args[0] === "string") {
667
721
  logger5.info("first argument is a location string:", args[0]);
668
722
  url = new URL(args[0]);
@@ -167,12 +167,52 @@ function createRequest(clientRequest) {
167
167
  });
168
168
  }
169
169
 
170
+ // src/utils/getValueBySymbol.ts
171
+ function getValueBySymbol(symbolName, source) {
172
+ const ownSymbols = Object.getOwnPropertySymbols(source);
173
+ const symbol = ownSymbols.find((symbol2) => {
174
+ return symbol2.description === symbolName;
175
+ });
176
+ if (symbol) {
177
+ return Reflect.get(source, symbol);
178
+ }
179
+ return;
180
+ }
181
+
182
+ // src/utils/isObject.ts
183
+ function isObject(value) {
184
+ return Object.prototype.toString.call(value) === "[object Object]";
185
+ }
186
+
187
+ // src/utils/getRawFetchHeaders.ts
188
+ function getRawFetchHeaders(headers) {
189
+ const headersList = getValueBySymbol("headers list", headers);
190
+ if (!headersList) {
191
+ return;
192
+ }
193
+ const headersMap = getValueBySymbol("headers map", headersList);
194
+ if (!headersMap || !isHeadersMapWithRawHeaderNames(headersMap)) {
195
+ return;
196
+ }
197
+ const rawHeaders = /* @__PURE__ */ new Map();
198
+ headersMap.forEach(({ name, value }) => {
199
+ rawHeaders.set(name, value);
200
+ });
201
+ return rawHeaders;
202
+ }
203
+ function isHeadersMapWithRawHeaderNames(headersMap) {
204
+ return Array.from(
205
+ headersMap.values()
206
+ ).every((value) => {
207
+ return isObject(value) && "name" in value;
208
+ });
209
+ }
210
+
170
211
  // src/interceptors/ClientRequest/NodeClientRequest.ts
171
212
  var _NodeClientRequest = class extends _http.ClientRequest {
172
213
  constructor([url, requestOptions, callback], options) {
173
214
  super(requestOptions, callback);
174
215
  this.chunks = [];
175
- this.responseSource = "mock";
176
216
  this.logger = options.logger.extend(
177
217
  `request ${requestOptions.method} ${url.href}`
178
218
  );
@@ -181,6 +221,7 @@ var _NodeClientRequest = class extends _http.ClientRequest {
181
221
  requestOptions,
182
222
  callback
183
223
  });
224
+ this.state = 0 /* Idle */;
184
225
  this.url = url;
185
226
  this.emitter = options.emitter;
186
227
  this.requestBuffer = null;
@@ -219,6 +260,7 @@ var _NodeClientRequest = class extends _http.ClientRequest {
219
260
  const [chunk, encoding, callback] = normalizeClientRequestEndArgs(...args);
220
261
  this.logger.info("normalized arguments:", { chunk, encoding, callback });
221
262
  this.writeRequestBodyChunk(chunk, encoding || void 0);
263
+ this.state = 2 /* Sent */;
222
264
  const capturedRequest = createRequest(this);
223
265
  const { interactiveRequest, requestController } = _chunk5PTPJLB7js.toInteractiveRequest.call(void 0, capturedRequest);
224
266
  Object.defineProperty(capturedRequest, "respondWith", {
@@ -244,6 +286,7 @@ var _NodeClientRequest = class extends _http.ClientRequest {
244
286
  'emitting the "request" event for %d listener(s)...',
245
287
  this.emitter.listenerCount("request")
246
288
  );
289
+ this.state = 3 /* MockLookupStart */;
247
290
  await _chunk5PTPJLB7js.emitAsync.call(void 0, this.emitter, "request", {
248
291
  request: interactiveRequest,
249
292
  requestId
@@ -254,6 +297,7 @@ var _NodeClientRequest = class extends _http.ClientRequest {
254
297
  return mockedResponse;
255
298
  }).then((resolverResult) => {
256
299
  this.logger.info("the listeners promise awaited!");
300
+ this.state = 4 /* MockLookupEnd */;
257
301
  if (!this.headersSent) {
258
302
  for (const [headerName, headerValue] of capturedRequest.headers) {
259
303
  this.setHeader(headerName, headerValue);
@@ -286,7 +330,6 @@ var _NodeClientRequest = class extends _http.ClientRequest {
286
330
  return this;
287
331
  }
288
332
  const responseClone = mockedResponse.clone();
289
- this.responseSource = "mock";
290
333
  this.respondWith(mockedResponse);
291
334
  this.logger.info(
292
335
  mockedResponse.status,
@@ -342,12 +385,17 @@ var _NodeClientRequest = class extends _http.ClientRequest {
342
385
  const error = data[0];
343
386
  const errorCode = error.code || "";
344
387
  this.logger.info("error:\n", error);
345
- if (this.responseSource === "mock" && _NodeClientRequest.suppressErrorCodes.includes(errorCode)) {
346
- if (!this.capturedError) {
347
- this.capturedError = error;
348
- this.logger.info("captured the first error:", this.capturedError);
388
+ if (_NodeClientRequest.suppressErrorCodes.includes(errorCode)) {
389
+ if (this.state < 4 /* MockLookupEnd */) {
390
+ if (!this.capturedError) {
391
+ this.capturedError = error;
392
+ this.logger.info("captured the first error:", this.capturedError);
393
+ }
394
+ return false;
395
+ }
396
+ if (this.state === 5 /* ResponseReceived */ && this.responseType === "mock") {
397
+ return false;
349
398
  }
350
- return false;
351
399
  }
352
400
  }
353
401
  return super.emit(event, ...data);
@@ -359,7 +407,8 @@ var _NodeClientRequest = class extends _http.ClientRequest {
359
407
  * up the request with `super.end()`.
360
408
  */
361
409
  passthrough(chunk, encoding, callback) {
362
- this.responseSource = "bypass";
410
+ this.state = 5 /* ResponseReceived */;
411
+ this.responseType = "passthrough";
363
412
  if (this.capturedError) {
364
413
  this.emit("error", this.capturedError);
365
414
  return this;
@@ -390,6 +439,8 @@ var _NodeClientRequest = class extends _http.ClientRequest {
390
439
  */
391
440
  respondWith(mockedResponse) {
392
441
  this.logger.info("responding with a mocked response...", mockedResponse);
442
+ this.state = 5 /* ResponseReceived */;
443
+ this.responseType = "mock";
393
444
  Object.defineProperties(this, {
394
445
  writableFinished: { value: true },
395
446
  writableEnded: { value: true }
@@ -398,9 +449,10 @@ var _NodeClientRequest = class extends _http.ClientRequest {
398
449
  const { status, statusText, headers, body } = mockedResponse;
399
450
  this.response.statusCode = status;
400
451
  this.response.statusMessage = statusText;
401
- if (headers) {
452
+ const rawHeaders = getRawFetchHeaders(headers) || headers;
453
+ if (rawHeaders) {
402
454
  this.response.headers = {};
403
- headers.forEach((headerValue, headerName) => {
455
+ rawHeaders.forEach((headerValue, headerName) => {
404
456
  this.response.rawHeaders.push(headerName, headerValue);
405
457
  const insensitiveHeaderName = headerName.toLowerCase();
406
458
  const prevHeaders = this.response.headers[insensitiveHeaderName];
@@ -456,7 +508,9 @@ NodeClientRequest.suppressErrorCodes = [
456
508
  "ENOTFOUND",
457
509
  "ECONNREFUSED",
458
510
  "ECONNRESET",
459
- "EAI_AGAIN"
511
+ "EAI_AGAIN",
512
+ "ENETUNREACH",
513
+ "EHOSTUNREACH"
460
514
  ];
461
515
 
462
516
  // src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts
@@ -616,11 +670,6 @@ function cloneObject(obj) {
616
670
  return isPlainObject(obj) ? enumerableProperties : Object.assign(Object.getPrototypeOf(obj), enumerableProperties);
617
671
  }
618
672
 
619
- // src/utils/isObject.ts
620
- function isObject(value) {
621
- return Object.prototype.toString.call(value) === "[object Object]";
622
- }
623
-
624
673
  // src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts
625
674
  var logger5 = new (0, _logger.Logger)("http normalizeClientRequestArgs");
626
675
  function resolveRequestOptions(args, url) {
@@ -663,6 +712,11 @@ function normalizeClientRequestArgs(defaultProtocol, ...args) {
663
712
  let callback;
664
713
  logger5.info("arguments", args);
665
714
  logger5.info("using default protocol:", defaultProtocol);
715
+ if (args.length === 0) {
716
+ const url2 = new URL("http://localhost");
717
+ const options2 = resolveRequestOptions(args, url2);
718
+ return [url2, options2];
719
+ }
666
720
  if (typeof args[0] === "string") {
667
721
  logger5.info("first argument is a location string:", args[0]);
668
722
  url = new URL(args[0]);
@@ -1,9 +1,9 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
2
 
3
- var _chunk44QGFZITjs = require('../../chunk-44QGFZIT.js');
3
+ var _chunkYVNH3GJ5js = require('../../chunk-YVNH3GJ5.js');
4
4
  require('../../chunk-OGN3ZR35.js');
5
5
  require('../../chunk-5PTPJLB7.js');
6
6
  require('../../chunk-3XFLRXRY.js');
7
7
 
8
8
 
9
- exports.ClientRequestInterceptor = _chunk44QGFZITjs.ClientRequestInterceptor;
9
+ exports.ClientRequestInterceptor = _chunkYVNH3GJ5js.ClientRequestInterceptor;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  ClientRequestInterceptor
3
- } from "../../chunk-Z7O2DO3X.mjs";
3
+ } from "../../chunk-G5IEXC7T.mjs";
4
4
  import "../../chunk-3IYIKC3X.mjs";
5
5
  import "../../chunk-YQGTMMOZ.mjs";
6
6
  import "../../chunk-GM3YBSM3.mjs";
@@ -1,6 +1,6 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
2
 
3
- var _chunk44QGFZITjs = require('../chunk-44QGFZIT.js');
3
+ var _chunkYVNH3GJ5js = require('../chunk-YVNH3GJ5.js');
4
4
 
5
5
 
6
6
  var _chunkJCWVLTP7js = require('../chunk-JCWVLTP7.js');
@@ -12,7 +12,7 @@ require('../chunk-3XFLRXRY.js');
12
12
 
13
13
  // src/presets/node.ts
14
14
  var node_default = [
15
- new (0, _chunk44QGFZITjs.ClientRequestInterceptor)(),
15
+ new (0, _chunkYVNH3GJ5js.ClientRequestInterceptor)(),
16
16
  new (0, _chunkJCWVLTP7js.XMLHttpRequestInterceptor)()
17
17
  ];
18
18
 
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  ClientRequestInterceptor
3
- } from "../chunk-Z7O2DO3X.mjs";
3
+ } from "../chunk-G5IEXC7T.mjs";
4
4
  import {
5
5
  XMLHttpRequestInterceptor
6
6
  } from "../chunk-FB53TMYN.mjs";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mswjs/interceptors",
3
3
  "description": "Low-level HTTP/HTTPS/XHR/fetch request interception library.",
4
- "version": "0.25.6",
4
+ "version": "0.25.7",
5
5
  "main": "./lib/node/index.js",
6
6
  "module": "./lib/node/index.mjs",
7
7
  "types": "./lib/node/index.d.ts",
@@ -146,112 +146,6 @@ it('performs the request as-is given resolver returned no mocked response', asyn
146
146
  expect(text).toBe('original-response')
147
147
  })
148
148
 
149
- it('emits the ENOTFOUND error connecting to a non-existing hostname given no mocked response', async () => {
150
- const emitter = new Emitter<HttpRequestEventMap>()
151
- const request = new NodeClientRequest(
152
- normalizeClientRequestArgs('http:', 'http://non-existing-url.com'),
153
- { emitter, logger }
154
- )
155
- request.end()
156
-
157
- const errorReceived = new DeferredPromise<NodeJS.ErrnoException>()
158
- request.on('error', async (error) => {
159
- errorReceived.resolve(error)
160
- })
161
- const error = await errorReceived
162
-
163
- expect(error.code).toBe('ENOTFOUND')
164
- expect(error.syscall).toBe('getaddrinfo')
165
- })
166
-
167
- it('emits the ECONNREFUSED error connecting to an inactive server given no mocked response', async () => {
168
- const emitter = new Emitter<HttpRequestEventMap>()
169
- const request = new NodeClientRequest(
170
- normalizeClientRequestArgs('http:', 'http://127.0.0.1:12345'),
171
- {
172
- emitter,
173
- logger,
174
- }
175
- )
176
-
177
- request.end()
178
-
179
- const errorReceived = new DeferredPromise<ErrorConnectionRefused>()
180
- request.on('error', async (error: ErrorConnectionRefused) => {
181
- errorReceived.resolve(error)
182
- })
183
- request.end()
184
-
185
- const error = await errorReceived
186
-
187
- expect(error.code).toBe('ECONNREFUSED')
188
- expect(error.syscall).toBe('connect')
189
- expect(error.address).toBe('127.0.0.1')
190
- expect(error.port).toBe(12345)
191
- })
192
-
193
- it('does not emit ENOTFOUND error connecting to an inactive server given mocked response', async () => {
194
- const emitter = new Emitter<HttpRequestEventMap>()
195
- const handleError = vi.fn()
196
- const request = new NodeClientRequest(
197
- normalizeClientRequestArgs('http:', 'http://non-existing-url.com'),
198
- { emitter, logger }
199
- )
200
-
201
- emitter.on('request', async ({ request }) => {
202
- await sleep(250)
203
- request.respondWith(
204
- new Response(null, { status: 200, statusText: 'Works' })
205
- )
206
- })
207
-
208
- request.end()
209
-
210
- request.on('error', handleError)
211
-
212
- const responseReceived = new DeferredPromise<IncomingMessage>()
213
- request.on('response', (response) => {
214
- responseReceived.resolve(response)
215
- })
216
- const response = await responseReceived
217
-
218
- expect(handleError).not.toHaveBeenCalled()
219
- expect(response.statusCode).toBe(200)
220
- expect(response.statusMessage).toBe('Works')
221
- })
222
-
223
- it('does not emit ECONNREFUSED error connecting to an inactive server given mocked response', async () => {
224
- const emitter = new Emitter<HttpRequestEventMap>()
225
- const handleError = vi.fn()
226
- const request = new NodeClientRequest(
227
- normalizeClientRequestArgs('http:', 'http://localhost:9876'),
228
- {
229
- emitter,
230
- logger,
231
- }
232
- )
233
-
234
- emitter.on('request', async ({ request }) => {
235
- await sleep(250)
236
- request.respondWith(
237
- new Response(null, { status: 200, statusText: 'Works' })
238
- )
239
- })
240
-
241
- request.on('error', handleError)
242
- request.end()
243
-
244
- const responseReceived = new DeferredPromise<IncomingMessage>()
245
- request.on('response', (response) => {
246
- responseReceived.resolve(response)
247
- })
248
- const response = await responseReceived
249
-
250
- expect(handleError).not.toHaveBeenCalled()
251
- expect(response.statusCode).toBe(200)
252
- expect(response.statusMessage).toBe('Works')
253
- })
254
-
255
149
  it('sends the request body to the server given no mocked response', async () => {
256
150
  const emitter = new Emitter<HttpRequestEventMap>()
257
151
  const request = new NodeClientRequest(
@@ -19,9 +19,22 @@ import { createRequest } from './utils/createRequest'
19
19
  import { toInteractiveRequest } from '../../utils/toInteractiveRequest'
20
20
  import { uuidv4 } from '../../utils/uuid'
21
21
  import { emitAsync } from '../../utils/emitAsync'
22
+ import { getRawFetchHeaders } from '../../utils/getRawFetchHeaders'
22
23
 
23
24
  export type Protocol = 'http' | 'https'
24
25
 
26
+ enum HttpClientInternalState {
27
+ // Have the concept of an idle request because different
28
+ // request methods can kick off request sending
29
+ // (e.g. ".end()" or ".flushHeaders()").
30
+ Idle,
31
+ Sending,
32
+ Sent,
33
+ MockLookupStart,
34
+ MockLookupEnd,
35
+ ResponseReceived,
36
+ }
37
+
25
38
  export interface NodeClientOptions {
26
39
  emitter: ClientRequestEmitter
27
40
  logger: Logger
@@ -37,8 +50,15 @@ export class NodeClientRequest extends ClientRequest {
37
50
  'ECONNREFUSED',
38
51
  'ECONNRESET',
39
52
  'EAI_AGAIN',
53
+ 'ENETUNREACH',
54
+ 'EHOSTUNREACH',
40
55
  ]
41
56
 
57
+ /**
58
+ * Internal state of the request.
59
+ */
60
+ private state: HttpClientInternalState
61
+ private responseType?: 'mock' | 'passthrough'
42
62
  private response: IncomingMessage
43
63
  private emitter: ClientRequestEmitter
44
64
  private logger: Logger
@@ -46,7 +66,6 @@ export class NodeClientRequest extends ClientRequest {
46
66
  chunk?: string | Buffer
47
67
  encoding?: BufferEncoding
48
68
  }> = []
49
- private responseSource: 'mock' | 'bypass' = 'mock'
50
69
  private capturedError?: NodeJS.ErrnoException
51
70
 
52
71
  public url: URL
@@ -68,6 +87,7 @@ export class NodeClientRequest extends ClientRequest {
68
87
  callback,
69
88
  })
70
89
 
90
+ this.state = HttpClientInternalState.Idle
71
91
  this.url = url
72
92
  this.emitter = options.emitter
73
93
 
@@ -138,6 +158,19 @@ export class NodeClientRequest extends ClientRequest {
138
158
  // Write the last request body chunk passed to the "end()" method.
139
159
  this.writeRequestBodyChunk(chunk, encoding || undefined)
140
160
 
161
+ /**
162
+ * @note Mark the request as sent immediately when invoking ".end()".
163
+ * In Node.js, calling ".end()" will flush the remaining request body
164
+ * and mark the request as "finished" immediately ("end" is synchronous)
165
+ * but we delegate that property update to:
166
+ *
167
+ * - respondWith(), in the case of mocked responses;
168
+ * - super.end(), in the case of bypassed responses.
169
+ *
170
+ * For that reason, we have to keep an internal flag for a finished request.
171
+ */
172
+ this.state = HttpClientInternalState.Sent
173
+
141
174
  const capturedRequest = createRequest(this)
142
175
  const { interactiveRequest, requestController } =
143
176
  toInteractiveRequest(capturedRequest)
@@ -192,6 +225,8 @@ export class NodeClientRequest extends ClientRequest {
192
225
  this.emitter.listenerCount('request')
193
226
  )
194
227
 
228
+ this.state = HttpClientInternalState.MockLookupStart
229
+
195
230
  await emitAsync(this.emitter, 'request', {
196
231
  request: interactiveRequest,
197
232
  requestId,
@@ -206,6 +241,8 @@ export class NodeClientRequest extends ClientRequest {
206
241
  }).then((resolverResult) => {
207
242
  this.logger.info('the listeners promise awaited!')
208
243
 
244
+ this.state = HttpClientInternalState.MockLookupEnd
245
+
209
246
  /**
210
247
  * @fixme We are in the "end()" method that still executes in parallel
211
248
  * to our mocking logic here. This can be solved by migrating to the
@@ -267,8 +304,6 @@ export class NodeClientRequest extends ClientRequest {
267
304
 
268
305
  const responseClone = mockedResponse.clone()
269
306
 
270
- this.responseSource = 'mock'
271
-
272
307
  this.respondWith(mockedResponse)
273
308
  this.logger.info(
274
309
  mockedResponse.status,
@@ -349,20 +384,28 @@ export class NodeClientRequest extends ClientRequest {
349
384
 
350
385
  this.logger.info('error:\n', error)
351
386
 
352
- // Suppress certain errors while using the "mock" source.
353
- // For example, no need to destroy this request if it connects
354
- // to a non-existing hostname but has a mocked response.
355
- if (
356
- this.responseSource === 'mock' &&
357
- NodeClientRequest.suppressErrorCodes.includes(errorCode)
358
- ) {
359
- // Capture the first emitted error in order to replay
360
- // it later if this request won't have any mocked response.
361
- if (!this.capturedError) {
362
- this.capturedError = error
363
- this.logger.info('captured the first error:', this.capturedError)
387
+ // Suppress only specific Node.js connection errors.
388
+ if (NodeClientRequest.suppressErrorCodes.includes(errorCode)) {
389
+ // Until we aren't sure whether the request will be
390
+ // passthrough, capture the first emitted connection
391
+ // error in case we have to replay it for this request.
392
+ if (this.state < HttpClientInternalState.MockLookupEnd) {
393
+ if (!this.capturedError) {
394
+ this.capturedError = error
395
+ this.logger.info('captured the first error:', this.capturedError)
396
+ }
397
+ return false
398
+ }
399
+
400
+ // Ignore any connection errors once we know the request
401
+ // has been resolved with a mocked response. Don't capture
402
+ // them as they won't ever be replayed.
403
+ if (
404
+ this.state === HttpClientInternalState.ResponseReceived &&
405
+ this.responseType === 'mock'
406
+ ) {
407
+ return false
364
408
  }
365
- return false
366
409
  }
367
410
  }
368
411
 
@@ -380,9 +423,8 @@ export class NodeClientRequest extends ClientRequest {
380
423
  encoding?: BufferEncoding | null,
381
424
  callback?: ClientRequestEndCallback | null
382
425
  ): this {
383
- // Set the response source to "bypass".
384
- // Any errors emitted past this point are not suppressed.
385
- this.responseSource = 'bypass'
426
+ this.state = HttpClientInternalState.ResponseReceived
427
+ this.responseType = 'passthrough'
386
428
 
387
429
  // Propagate previously captured errors.
388
430
  // For example, a ECONNREFUSED error when connecting to a non-existing host.
@@ -430,6 +472,9 @@ export class NodeClientRequest extends ClientRequest {
430
472
  private respondWith(mockedResponse: Response): void {
431
473
  this.logger.info('responding with a mocked response...', mockedResponse)
432
474
 
475
+ this.state = HttpClientInternalState.ResponseReceived
476
+ this.responseType = 'mock'
477
+
433
478
  /**
434
479
  * Mark the request as finished right before streaming back the response.
435
480
  * This is not entirely conventional but this will allow the consumer to
@@ -448,10 +493,14 @@ export class NodeClientRequest extends ClientRequest {
448
493
  this.response.statusCode = status
449
494
  this.response.statusMessage = statusText
450
495
 
451
- if (headers) {
496
+ // Try extracting the raw headers from the headers instance.
497
+ // If not possible, fallback to the headers instance as-is.
498
+ const rawHeaders = getRawFetchHeaders(headers) || headers
499
+
500
+ if (rawHeaders) {
452
501
  this.response.headers = {}
453
502
 
454
- headers.forEach((headerValue, headerName) => {
503
+ rawHeaders.forEach((headerValue, headerName) => {
455
504
  /**
456
505
  * @note Make sure that multi-value headers are appended correctly.
457
506
  */
@@ -23,6 +23,8 @@ const logger = new Logger('http normalizeClientRequestArgs')
23
23
  export type HttpRequestCallback = (response: IncomingMessage) => void
24
24
 
25
25
  export type ClientRequestArgs =
26
+ // Request without any arguments is also possible.
27
+ | []
26
28
  | [string | URL | LegacyURL, HttpRequestCallback?]
27
29
  | [string | URL | LegacyURL, RequestOptions, HttpRequestCallback?]
28
30
  | [RequestOptions, HttpRequestCallback?]
@@ -109,6 +111,14 @@ export function normalizeClientRequestArgs(
109
111
  logger.info('arguments', args)
110
112
  logger.info('using default protocol:', defaultProtocol)
111
113
 
114
+ // Support "http.request()" calls without any arguments.
115
+ // That call results in a "GET http://localhost" request.
116
+ if (args.length === 0) {
117
+ const url = new URL('http://localhost')
118
+ const options = resolveRequestOptions(args, url)
119
+ return [url, options]
120
+ }
121
+
112
122
  // Convert a url string into a URL instance
113
123
  // and derive request options from it.
114
124
  if (typeof args[0] === 'string') {
@@ -0,0 +1,50 @@
1
+ import { it, expect } from 'vitest'
2
+ import { getRawFetchHeaders } from './getRawFetchHeaders'
3
+
4
+ it('returns undefined given a non-Headers object', () => {
5
+ expect(getRawFetchHeaders({} as Headers)).toBeUndefined()
6
+ })
7
+
8
+ it('returns an empty Map given an empty Headers instance', () => {
9
+ expect(getRawFetchHeaders(new Headers())).toEqual(new Map())
10
+ })
11
+
12
+ it('returns undefined for headers map on older Node.js versions', () => {
13
+ // Emulate the Headers symbol structure on older
14
+ // versions of Node.js (e.g. 18.8.0).
15
+ const headers = {
16
+ [Symbol('headers list')]: {
17
+ [Symbol('headers map')]: new Map([['header-name', 'header-value']]),
18
+ },
19
+ }
20
+ expect(getRawFetchHeaders(headers as unknown as Headers)).toBeUndefined()
21
+ })
22
+
23
+ it('returns raw headers from the given Headers instance', () => {
24
+ expect(
25
+ getRawFetchHeaders(
26
+ new Headers([
27
+ ['lowercase-header', 'one'],
28
+ ['UPPERCASE-HEADER', 'TWO'],
29
+ ['MiXeD-cAsE-hEaDeR', 'ThReE'],
30
+ ])
31
+ )
32
+ ).toEqual(
33
+ new Map([
34
+ ['lowercase-header', 'one'],
35
+ ['UPPERCASE-HEADER', 'TWO'],
36
+ ['MiXeD-cAsE-hEaDeR', 'ThReE'],
37
+ ])
38
+ )
39
+ })
40
+
41
+ it('returns raw headers for a header with multiple values', () => {
42
+ expect(
43
+ getRawFetchHeaders(
44
+ new Headers([
45
+ ['Set-CookiE', 'a=b'],
46
+ ['Set-CookiE', 'c=d'],
47
+ ])
48
+ )
49
+ ).toEqual(new Map([['Set-CookiE', 'a=b, c=d']]))
50
+ })
@@ -0,0 +1,56 @@
1
+ import { getValueBySymbol } from './getValueBySymbol'
2
+ import { isObject } from './isObject'
3
+
4
+ type RawHeadersMap = Map<string, string>
5
+ type HeadersMapHeader = { name: string; value: string }
6
+
7
+ /**
8
+ * Returns raw headers from the given `Headers` instance.
9
+ * @example
10
+ * const headers = new Headers([
11
+ * ['X-HeadeR-NamE', 'Value']
12
+ * ])
13
+ * getRawFetchHeaders(headers)
14
+ * // { 'X-HeadeR-NamE': 'Value' }
15
+ */
16
+ export function getRawFetchHeaders(
17
+ headers: Headers
18
+ ): RawHeadersMap | undefined {
19
+ const headersList = getValueBySymbol<object>('headers list', headers)
20
+
21
+ if (!headersList) {
22
+ return
23
+ }
24
+
25
+ const headersMap = getValueBySymbol<
26
+ Map<string, string> | Map<string, HeadersMapHeader>
27
+ >('headers map', headersList)
28
+
29
+ /**
30
+ * @note Older versions of Node.js (e.g. 18.8.0) keep headers map
31
+ * as Map<normalizedHeaderName, value> without any means to tap
32
+ * into raw header values. Detect that and return undefined.
33
+ */
34
+ if (!headersMap || !isHeadersMapWithRawHeaderNames(headersMap)) {
35
+ return
36
+ }
37
+
38
+ // Raw headers is a map of { rawHeaderName: rawHeaderValue }
39
+ const rawHeaders: RawHeadersMap = new Map<string, string>()
40
+
41
+ headersMap.forEach(({ name, value }) => {
42
+ rawHeaders.set(name, value)
43
+ })
44
+
45
+ return rawHeaders
46
+ }
47
+
48
+ function isHeadersMapWithRawHeaderNames(
49
+ headersMap: Map<string, string> | Map<string, HeadersMapHeader>
50
+ ): headersMap is Map<string, HeadersMapHeader> {
51
+ return Array.from(
52
+ headersMap.values() as Iterable<string | HeadersMapHeader>
53
+ ).every((value) => {
54
+ return isObject<HeadersMapHeader>(value) && 'name' in value
55
+ })
56
+ }
@@ -0,0 +1,14 @@
1
+ import { it, expect } from 'vitest'
2
+ import { getValueBySymbol } from './getValueBySymbol'
3
+
4
+ it('returns undefined given a non-existing symbol', () => {
5
+ expect(getValueBySymbol('non-existing', {})).toBeUndefined()
6
+ })
7
+
8
+ it('returns value behind the given symbol', () => {
9
+ const symbol = Symbol('kInternal')
10
+
11
+ expect(getValueBySymbol('kInternal', { [symbol]: null })).toBe(null)
12
+ expect(getValueBySymbol('kInternal', { [symbol]: true })).toBe(true)
13
+ expect(getValueBySymbol('kInternal', { [symbol]: 'value' })).toBe('value')
14
+ })
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Returns the value behind the symbol with the given name.
3
+ */
4
+ export function getValueBySymbol<T>(
5
+ symbolName: string,
6
+ source: object
7
+ ): T | undefined {
8
+ const ownSymbols = Object.getOwnPropertySymbols(source)
9
+
10
+ const symbol = ownSymbols.find((symbol) => {
11
+ return symbol.description === symbolName
12
+ })
13
+
14
+ if (symbol) {
15
+ return Reflect.get(source, symbol)
16
+ }
17
+
18
+ return
19
+ }