@mswjs/interceptors
Advanced tools
Comparing version 0.31.1 to 0.32.0
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); | ||
var _chunkLTEXDYJ6js = require('./chunk-LTEXDYJ6.js'); | ||
var _chunk2COJKQQBjs = require('./chunk-2COJKQQB.js'); | ||
@@ -19,3 +19,3 @@ | ||
var _chunkE4AC7YACjs = require('./chunk-E4AC7YAC.js'); | ||
var _chunkBFLYGQ6Djs = require('./chunk-BFLYGQ6D.js'); | ||
@@ -39,3 +39,3 @@ // src/utils/getCleanUrl.ts | ||
exports.BatchInterceptor = _chunkLTEXDYJ6js.BatchInterceptor; exports.INTERNAL_REQUEST_ID_HEADER_NAME = _chunkE4AC7YACjs.INTERNAL_REQUEST_ID_HEADER_NAME; exports.IS_PATCHED_MODULE = _chunkEIBTX65Ojs.IS_PATCHED_MODULE; exports.Interceptor = _chunkE4AC7YACjs.Interceptor; exports.InterceptorReadyState = _chunkE4AC7YACjs.InterceptorReadyState; exports.createRequestId = _chunkE4AC7YACjs.createRequestId; exports.decodeBuffer = _chunkLK6DILFKjs.decodeBuffer; exports.deleteGlobalSymbol = _chunkE4AC7YACjs.deleteGlobalSymbol; exports.encodeBuffer = _chunkLK6DILFKjs.encodeBuffer; exports.getCleanUrl = getCleanUrl; exports.getGlobalSymbol = _chunkE4AC7YACjs.getGlobalSymbol; exports.isResponseWithoutBody = _chunkE4AC7YACjs.isResponseWithoutBody; | ||
exports.BatchInterceptor = _chunk2COJKQQBjs.BatchInterceptor; exports.INTERNAL_REQUEST_ID_HEADER_NAME = _chunkBFLYGQ6Djs.INTERNAL_REQUEST_ID_HEADER_NAME; exports.IS_PATCHED_MODULE = _chunkEIBTX65Ojs.IS_PATCHED_MODULE; exports.Interceptor = _chunkBFLYGQ6Djs.Interceptor; exports.InterceptorReadyState = _chunkBFLYGQ6Djs.InterceptorReadyState; exports.createRequestId = _chunkBFLYGQ6Djs.createRequestId; exports.decodeBuffer = _chunkLK6DILFKjs.decodeBuffer; exports.deleteGlobalSymbol = _chunkBFLYGQ6Djs.deleteGlobalSymbol; exports.encodeBuffer = _chunkLK6DILFKjs.encodeBuffer; exports.getCleanUrl = getCleanUrl; exports.getGlobalSymbol = _chunkBFLYGQ6Djs.getGlobalSymbol; exports.isResponseWithoutBody = _chunkBFLYGQ6Djs.isResponseWithoutBody; | ||
//# sourceMappingURL=index.js.map |
@@ -1,23 +0,92 @@ | ||
import http from 'http'; | ||
import https from 'https'; | ||
import { Emitter } from 'strict-event-emitter'; | ||
import { H as HttpRequestEventMap, f as Interceptor } from '../../Interceptor-88ee47c0.js'; | ||
import { f as Interceptor, H as HttpRequestEventMap } from '../../Interceptor-88ee47c0.js'; | ||
import net from 'node:net'; | ||
import '@open-draft/deferred-promise'; | ||
import '@open-draft/logger'; | ||
import 'strict-event-emitter'; | ||
type Protocol = 'http' | 'https'; | ||
type WriteCallback = (error?: Error | null) => void; | ||
type ClientRequestEmitter = Emitter<HttpRequestEventMap>; | ||
type ClientRequestModules = Map<Protocol, typeof http | typeof https>; | ||
/** | ||
* Intercept requests made via the `ClientRequest` class. | ||
* Such requests include `http.get`, `https.request`, etc. | ||
*/ | ||
interface MockSocketOptions { | ||
write: (chunk: Buffer | string, encoding: BufferEncoding | undefined, callback?: WriteCallback) => void; | ||
read: (chunk: Buffer, encoding: BufferEncoding | undefined) => void; | ||
} | ||
declare class MockSocket extends net.Socket { | ||
protected readonly options: MockSocketOptions; | ||
connecting: boolean; | ||
constructor(options: MockSocketOptions); | ||
connect(): this; | ||
write(...args: Array<unknown>): boolean; | ||
end(...args: Array<unknown>): this; | ||
push(chunk: any, encoding?: BufferEncoding): boolean; | ||
} | ||
type HttpConnectionOptions = any; | ||
type MockHttpSocketRequestCallback = (args: { | ||
requestId: string; | ||
request: Request; | ||
socket: MockHttpSocket; | ||
}) => void; | ||
type MockHttpSocketResponseCallback = (args: { | ||
requestId: string; | ||
request: Request; | ||
response: Response; | ||
isMockedResponse: boolean; | ||
socket: MockHttpSocket; | ||
}) => Promise<void>; | ||
interface MockHttpSocketOptions { | ||
connectionOptions: HttpConnectionOptions; | ||
createConnection: () => net.Socket; | ||
onRequest: MockHttpSocketRequestCallback; | ||
onResponse: MockHttpSocketResponseCallback; | ||
} | ||
declare class MockHttpSocket extends MockSocket { | ||
private connectionOptions; | ||
private createConnection; | ||
private baseUrl; | ||
private onRequest; | ||
private onResponse; | ||
private responseListenersPromise?; | ||
private writeBuffer; | ||
private request?; | ||
private requestParser; | ||
private requestStream?; | ||
private shouldKeepAlive?; | ||
private responseType; | ||
private responseParser; | ||
private responseStream?; | ||
constructor(options: MockHttpSocketOptions); | ||
emit(event: string | symbol, ...args: any[]): boolean; | ||
destroy(error?: Error | undefined): this; | ||
/** | ||
* Establish this Socket connection as-is and pipe | ||
* its data/events through this Socket. | ||
*/ | ||
passthrough(): void; | ||
/** | ||
* Convert the given Fetch API `Response` instance to an | ||
* HTTP message and push it to the socket. | ||
*/ | ||
respondWith(response: Response): Promise<void>; | ||
/** | ||
* Close this socket connection with the given error. | ||
*/ | ||
errorWith(error: Error): void; | ||
private mockConnect; | ||
private flushWriteBuffer; | ||
private onRequestStart; | ||
private onRequestBody; | ||
private onRequestEnd; | ||
private onResponseStart; | ||
private onResponseBody; | ||
private onResponseEnd; | ||
} | ||
declare class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> { | ||
static interceptorSymbol: symbol; | ||
private modules; | ||
static symbol: symbol; | ||
constructor(); | ||
protected setup(): void; | ||
private onRequest; | ||
onResponse: MockHttpSocketResponseCallback; | ||
} | ||
export { ClientRequestEmitter, ClientRequestInterceptor, ClientRequestModules }; | ||
export { ClientRequestInterceptor }; |
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); | ||
var _chunkIS3CIGXUjs = require('../../chunk-IS3CIGXU.js'); | ||
require('../../chunk-MQJ3JOOK.js'); | ||
require('../../chunk-E4AC7YAC.js'); | ||
var _chunkRVYTRD6Zjs = require('../../chunk-RVYTRD6Z.js'); | ||
require('../../chunk-UXCYRE4F.js'); | ||
require('../../chunk-BFLYGQ6D.js'); | ||
exports.ClientRequestInterceptor = _chunkIS3CIGXUjs.ClientRequestInterceptor; | ||
exports.ClientRequestInterceptor = _chunkRVYTRD6Zjs.ClientRequestInterceptor; | ||
//# sourceMappingURL=index.js.map |
@@ -7,3 +7,3 @@ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); | ||
var _chunkMQJ3JOOKjs = require('../../chunk-MQJ3JOOK.js'); | ||
var _chunkUXCYRE4Fjs = require('../../chunk-UXCYRE4F.js'); | ||
@@ -14,3 +14,3 @@ | ||
var _chunkE4AC7YACjs = require('../../chunk-E4AC7YAC.js'); | ||
var _chunkBFLYGQ6Djs = require('../../chunk-BFLYGQ6D.js'); | ||
@@ -33,3 +33,3 @@ // src/interceptors/fetch/index.ts | ||
// src/interceptors/fetch/index.ts | ||
var _FetchInterceptor = class extends _chunkE4AC7YACjs.Interceptor { | ||
var _FetchInterceptor = class extends _chunkBFLYGQ6Djs.Interceptor { | ||
constructor() { | ||
@@ -49,7 +49,7 @@ super(_FetchInterceptor.symbol); | ||
var _a; | ||
const requestId = _chunkE4AC7YACjs.createRequestId.call(void 0, ); | ||
const requestId = _chunkBFLYGQ6Djs.createRequestId.call(void 0, ); | ||
const resolvedInput = typeof input === "string" && typeof location !== "undefined" && !canParseUrl(input) ? new URL(input, location.origin) : input; | ||
const request = new Request(resolvedInput, init); | ||
this.logger.info("[%s] %s", request.method, request.url); | ||
const { interactiveRequest, requestController } = _chunkMQJ3JOOKjs.toInteractiveRequest.call(void 0, request); | ||
const { interactiveRequest, requestController } = _chunkUXCYRE4Fjs.toInteractiveRequest.call(void 0, request); | ||
this.logger.info( | ||
@@ -105,3 +105,3 @@ 'emitting the "request" event for %d listener(s)...', | ||
async () => { | ||
const listenersFinished = _chunkMQJ3JOOKjs.emitAsync.call(void 0, this.emitter, "request", { | ||
const listenersFinished = _chunkUXCYRE4Fjs.emitAsync.call(void 0, this.emitter, "request", { | ||
request: interactiveRequest, | ||
@@ -138,3 +138,3 @@ requestId | ||
if (resolverResult.error instanceof Response) { | ||
if (_chunkE4AC7YACjs.isResponseError.call(void 0, resolverResult.error)) { | ||
if (_chunkBFLYGQ6Djs.isResponseError.call(void 0, resolverResult.error)) { | ||
errorWith(createNetworkError(resolverResult.error)); | ||
@@ -146,3 +146,3 @@ } else { | ||
if (this.emitter.listenerCount("unhandledException") > 0) { | ||
await _chunkMQJ3JOOKjs.emitAsync.call(void 0, this.emitter, "unhandledException", { | ||
await _chunkUXCYRE4Fjs.emitAsync.call(void 0, this.emitter, "unhandledException", { | ||
error: resolverResult.error, | ||
@@ -160,3 +160,3 @@ request, | ||
} | ||
respondWith(_chunkE4AC7YACjs.createServerErrorResponse.call(void 0, resolverResult.error)); | ||
respondWith(_chunkBFLYGQ6Djs.createServerErrorResponse.call(void 0, resolverResult.error)); | ||
return responsePromise; | ||
@@ -167,3 +167,3 @@ } | ||
this.logger.info("received mocked response:", mockedResponse); | ||
if (_chunkE4AC7YACjs.isResponseError.call(void 0, mockedResponse)) { | ||
if (_chunkBFLYGQ6Djs.isResponseError.call(void 0, mockedResponse)) { | ||
this.logger.info( | ||
@@ -170,0 +170,0 @@ "received a network error response, rejecting the request promise..." |
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); | ||
var _chunkAPT7KA3Bjs = require('../../chunk-APT7KA3B.js'); | ||
var _chunkPYD4E2EJjs = require('../../chunk-PYD4E2EJ.js'); | ||
require('../../chunk-LK6DILFK.js'); | ||
require('../../chunk-EIBTX65O.js'); | ||
require('../../chunk-MQJ3JOOK.js'); | ||
require('../../chunk-E4AC7YAC.js'); | ||
require('../../chunk-UXCYRE4F.js'); | ||
require('../../chunk-BFLYGQ6D.js'); | ||
exports.XMLHttpRequestInterceptor = _chunkAPT7KA3Bjs.XMLHttpRequestInterceptor; | ||
exports.XMLHttpRequestInterceptor = _chunkPYD4E2EJjs.XMLHttpRequestInterceptor; | ||
//# sourceMappingURL=index.js.map |
import { ClientRequestInterceptor } from '../interceptors/ClientRequest/index.js'; | ||
import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest/index.js'; | ||
import 'http'; | ||
import 'https'; | ||
import 'strict-event-emitter'; | ||
import '../Interceptor-88ee47c0.js'; | ||
import '@open-draft/deferred-promise'; | ||
import '@open-draft/logger'; | ||
import 'strict-event-emitter'; | ||
import 'node:net'; | ||
@@ -10,0 +9,0 @@ /** |
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); | ||
var _chunkIS3CIGXUjs = require('../chunk-IS3CIGXU.js'); | ||
var _chunkRVYTRD6Zjs = require('../chunk-RVYTRD6Z.js'); | ||
var _chunkAPT7KA3Bjs = require('../chunk-APT7KA3B.js'); | ||
var _chunkPYD4E2EJjs = require('../chunk-PYD4E2EJ.js'); | ||
require('../chunk-LK6DILFK.js'); | ||
require('../chunk-EIBTX65O.js'); | ||
require('../chunk-MQJ3JOOK.js'); | ||
require('../chunk-E4AC7YAC.js'); | ||
require('../chunk-UXCYRE4F.js'); | ||
require('../chunk-BFLYGQ6D.js'); | ||
// src/presets/node.ts | ||
var node_default = [ | ||
new (0, _chunkIS3CIGXUjs.ClientRequestInterceptor)(), | ||
new (0, _chunkAPT7KA3Bjs.XMLHttpRequestInterceptor)() | ||
new (0, _chunkRVYTRD6Zjs.ClientRequestInterceptor)(), | ||
new (0, _chunkPYD4E2EJjs.XMLHttpRequestInterceptor)() | ||
]; | ||
@@ -17,0 +17,0 @@ |
@@ -9,4 +9,3 @@ import { ChildProcess } from 'child_process'; | ||
import 'strict-event-emitter'; | ||
import 'http'; | ||
import 'https'; | ||
import 'node:net'; | ||
@@ -13,0 +12,0 @@ interface SerializedRequest { |
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); | ||
var _chunkLTEXDYJ6js = require('./chunk-LTEXDYJ6.js'); | ||
var _chunk2COJKQQBjs = require('./chunk-2COJKQQB.js'); | ||
var _chunkIS3CIGXUjs = require('./chunk-IS3CIGXU.js'); | ||
var _chunkRVYTRD6Zjs = require('./chunk-RVYTRD6Z.js'); | ||
var _chunkAPT7KA3Bjs = require('./chunk-APT7KA3B.js'); | ||
var _chunkPYD4E2EJjs = require('./chunk-PYD4E2EJ.js'); | ||
require('./chunk-LK6DILFK.js'); | ||
@@ -15,9 +15,9 @@ require('./chunk-EIBTX65O.js'); | ||
var _chunkMQJ3JOOKjs = require('./chunk-MQJ3JOOK.js'); | ||
var _chunkUXCYRE4Fjs = require('./chunk-UXCYRE4F.js'); | ||
var _chunkE4AC7YACjs = require('./chunk-E4AC7YAC.js'); | ||
var _chunkBFLYGQ6Djs = require('./chunk-BFLYGQ6D.js'); | ||
// src/RemoteHttpInterceptor.ts | ||
var RemoteHttpInterceptor = class extends _chunkLTEXDYJ6js.BatchInterceptor { | ||
var RemoteHttpInterceptor = class extends _chunk2COJKQQBjs.BatchInterceptor { | ||
constructor() { | ||
@@ -27,4 +27,4 @@ super({ | ||
interceptors: [ | ||
new (0, _chunkIS3CIGXUjs.ClientRequestInterceptor)(), | ||
new (0, _chunkAPT7KA3Bjs.XMLHttpRequestInterceptor)() | ||
new (0, _chunkRVYTRD6Zjs.ClientRequestInterceptor)(), | ||
new (0, _chunkPYD4E2EJjs.XMLHttpRequestInterceptor)() | ||
] | ||
@@ -96,3 +96,3 @@ }); | ||
} | ||
var _RemoteHttpResolver = class extends _chunkE4AC7YACjs.Interceptor { | ||
var _RemoteHttpResolver = class extends _chunkBFLYGQ6Djs.Interceptor { | ||
constructor(options) { | ||
@@ -125,3 +125,3 @@ super(_RemoteHttpResolver.symbol); | ||
}); | ||
const { interactiveRequest, requestController } = _chunkMQJ3JOOKjs.toInteractiveRequest.call(void 0, capturedRequest); | ||
const { interactiveRequest, requestController } = _chunkUXCYRE4Fjs.toInteractiveRequest.call(void 0, capturedRequest); | ||
this.emitter.once("request", () => { | ||
@@ -132,3 +132,3 @@ if (requestController.responsePromise.state === "pending") { | ||
}); | ||
await _chunkMQJ3JOOKjs.emitAsync.call(void 0, this.emitter, "request", { | ||
await _chunkUXCYRE4Fjs.emitAsync.call(void 0, this.emitter, "request", { | ||
request: interactiveRequest, | ||
@@ -135,0 +135,0 @@ requestId: requestJson.id |
{ | ||
"name": "@mswjs/interceptors", | ||
"description": "Low-level HTTP/HTTPS/XHR/fetch request interception library.", | ||
"version": "0.31.1", | ||
"version": "0.32.0", | ||
"main": "./lib/node/index.js", | ||
@@ -123,3 +123,3 @@ "module": "./lib/node/index.mjs", | ||
"@types/jest": "^27.0.3", | ||
"@types/node": "^16.11.26", | ||
"@types/node": "^18.19.31", | ||
"@types/node-fetch": "2.5.12", | ||
@@ -126,0 +126,0 @@ "@types/supertest": "^2.0.11", |
@@ -14,5 +14,5 @@ [![Latest version](https://img.shields.io/npm/v/@mswjs/interceptors.svg)](https://www.npmjs.com/package/@mswjs/interceptors) | ||
While there are a lot of network communication mocking libraries, they tend to use request interception as an implementation detail, giving you a high-level API that includes request matching, timeouts, retries, and so forth. | ||
While there are a lot of network mocking libraries, they tend to use request interception as an implementation detail, giving you a high-level API that includes request matching, timeouts, recording, and so forth. | ||
This library is a strip-to-bone implementation that provides as little abstraction as possible to execute arbitrary logic upon any request. It's primarily designed as an underlying component for high-level API mocking solutions such as [Mock Service Worker](https://github.com/mswjs/msw). | ||
This library is a barebones implementation that provides as little abstraction as possible to execute arbitrary logic upon any request. It's primarily designed as an underlying component for high-level API mocking solutions such as [Mock Service Worker](https://github.com/mswjs/msw). | ||
@@ -24,45 +24,59 @@ ### How is this library different? | ||
```js | ||
import http from 'http' | ||
import http from 'node:http' | ||
function applyMock() { | ||
// Store the original request module. | ||
const originalHttpRequest = http.request | ||
// Store the original request function. | ||
const originalHttpRequest = http.request | ||
// Rewrite the request module entirely. | ||
http.request = function (...args) { | ||
// Decide whether to handle this request before | ||
// the actual request happens. | ||
if (shouldMock(args)) { | ||
// If so, never create a request, respond to it | ||
// using the mocked response from this blackbox. | ||
return coerceToResponse.bind(this, mock) | ||
} | ||
// Override the request function entirely. | ||
http.request = function (...args) { | ||
// Decide if the outgoing request matches a predicate. | ||
if (predicate(args)) { | ||
// If it does, never create a request, respond to it | ||
// using the mocked response from this blackbox. | ||
return coerceToResponse.bind(this, mock) | ||
} | ||
// Otherwise, construct the original request | ||
// and perform it as-is (receives the original response). | ||
return originalHttpRequest(...args) | ||
} | ||
// Otherwise, construct the original request | ||
// and perform it as-is. | ||
return originalHttpRequest(...args) | ||
} | ||
``` | ||
This library deviates from such implementation and uses _class extensions_ instead of module rewrites. Such deviation is necessary because, unlike other solutions that include request matching and can determine whether to mock requests _before_ they actually happen, this library is not opinionated about the mocked/bypassed nature of the requests. Instead, it _intercepts all requests_ and delegates the decision of mocking to the end consumer. | ||
The core philosophy of Interceptors is to _run as much of the underlying network code as possible_. Strange for a network mocking library, isn't it? Turns out, respecting the system's integrity and executing more of the network code leads to more resilient tests and also helps to uncover bugs in the code that would otherwise go unnoticed. | ||
Interceptors heavily rely on _class extension_ instead of function and module overrides. By extending the native network code, it can surgically insert the interception and mocking pieces only where necessary, leaving the rest of the system intact. | ||
```js | ||
class NodeClientRequest extends ClientRequest { | ||
async end(...args) { | ||
// Check if there's a mocked response for this request. | ||
// You control this in the "resolver" function. | ||
const mockedResponse = await resolver(request) | ||
class XMLHttpRequestProxy extends XMLHttpRequest { | ||
async send() { | ||
// Call the request listeners and see if any of them | ||
// returns a mocked response for this request. | ||
const mockedResponse = await waitForRequestListeners({ request }) | ||
// If there is a mocked response, use it to respond to this | ||
// request, finalizing it afterward as if it received that | ||
// response from the actual server it connected to. | ||
// If there is a mocked response, use it. This actually | ||
// transitions the XMLHttpRequest instance into the correct | ||
// response state (below is a simplified illustration). | ||
if (mockedResponse) { | ||
this.respondWith(mockedResponse) | ||
this.finish() | ||
// Handle the response headers. | ||
this.request.status = mockedResponse.status | ||
this.request.statusText = mockedResponse.statusText | ||
this.request.responseUrl = mockedResponse.url | ||
this.readyState = 2 | ||
this.trigger('readystatechange') | ||
// Start streaming the response body. | ||
this.trigger('loadstart') | ||
this.readyState = 3 | ||
this.trigger('readystatechange') | ||
await streamResponseBody(mockedResponse) | ||
// Finish the response. | ||
this.trigger('load') | ||
this.trigger('loadend') | ||
this.readyState = 4 | ||
return | ||
} | ||
// Otherwise, perform the original "ClientRequest.prototype.end" call. | ||
return super.end(...args) | ||
// Otherwise, perform the original "XMLHttpRequest.prototype.send" call. | ||
return super.send(...args) | ||
} | ||
@@ -72,7 +86,9 @@ } | ||
By extending the native modules, this library actually constructs requests as soon as they are constructed by the consumer. This enables all the request input validation and transformations done natively by Node.js—something that traditional solutions simply cannot do (they replace `http.ClientRequest` entirely). The class extension allows to fully utilize Node.js internals instead of polyfilling them, which results in more resilient mocks. | ||
> The request interception algorithms differ dramatically based on the request API. Interceptors acommodate for them all, bringing the intercepted requests to a common ground—the Fetch API `Request` instance. The same applies for responses, where a Fetch API `Response` instance is translated to the appropriate response format. | ||
This library aims to provide _full specification compliance_ with the APIs and protocols it extends. | ||
## What this library does | ||
This library extends (or patches, where applicable) the following native modules: | ||
This library extends the following native modules: | ||
@@ -83,6 +99,7 @@ - `http.get`/`http.request` | ||
- `fetch` | ||
- `WebSocket` | ||
Once extended, it intercepts and normalizes all requests to the Fetch API `Request` instances. This way, no matter the request source (`http.ClientRequest`, `XMLHttpRequest`, `window.Request`, etc), you always get a specification-compliant request instance to work with. | ||
You can respond to the intercepted request by constructing a Fetch API Response instance. Instead of designing custom abstractions, this library respects the Fetch API specification and takes the responsibility to coerce a single response declaration to the appropriate response formats based on the request-issuing modules (like `http.OutgoingMessage` to respond to `http.ClientRequest`, or updating `XMLHttpRequest` response-related properties). | ||
You can respond to the intercepted HTTP request by constructing a Fetch API Response instance. Instead of designing custom abstractions, this library respects the Fetch API specification and takes the responsibility to coerce a single response declaration to the appropriate response formats based on the request-issuing modules (like `http.OutgoingMessage` to respond to `http.ClientRequest`, or updating `XMLHttpRequest` response-related properties). | ||
@@ -92,3 +109,3 @@ ## What this library doesn't do | ||
- Does **not** provide any request matching logic; | ||
- Does **not** decide how to handle requests. | ||
- Does **not** handle requests by default. | ||
@@ -95,0 +112,0 @@ ## Getting started |
@@ -1,61 +0,220 @@ | ||
import http from 'http' | ||
import https from 'https' | ||
import type { Emitter } from 'strict-event-emitter' | ||
import { HttpRequestEventMap } from '../../glossary' | ||
import http from 'node:http' | ||
import https from 'node:https' | ||
import { until } from '@open-draft/until' | ||
import { Interceptor } from '../../Interceptor' | ||
import { get } from './http.get' | ||
import { request } from './http.request' | ||
import { NodeClientOptions, Protocol } from './NodeClientRequest' | ||
import type { HttpRequestEventMap } from '../../glossary' | ||
import { | ||
kRequestId, | ||
MockHttpSocketRequestCallback, | ||
MockHttpSocketResponseCallback, | ||
} from './MockHttpSocket' | ||
import { MockAgent, MockHttpsAgent } from './agents' | ||
import { emitAsync } from '../../utils/emitAsync' | ||
import { toInteractiveRequest } from '../../utils/toInteractiveRequest' | ||
import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' | ||
import { isNodeLikeError } from '../../utils/isNodeLikeError' | ||
import { createServerErrorResponse } from '../../utils/responseUtils' | ||
export type ClientRequestEmitter = Emitter<HttpRequestEventMap> | ||
export type ClientRequestModules = Map<Protocol, typeof http | typeof https> | ||
/** | ||
* Intercept requests made via the `ClientRequest` class. | ||
* Such requests include `http.get`, `https.request`, etc. | ||
*/ | ||
export class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> { | ||
static interceptorSymbol = Symbol('http') | ||
private modules: ClientRequestModules | ||
static symbol = Symbol('client-request-interceptor') | ||
constructor() { | ||
super(ClientRequestInterceptor.interceptorSymbol) | ||
this.modules = new Map() | ||
this.modules.set('http', http) | ||
this.modules.set('https', https) | ||
super(ClientRequestInterceptor.symbol) | ||
} | ||
protected setup(): void { | ||
const logger = this.logger.extend('setup') | ||
const { get: originalGet, request: originalRequest } = http | ||
const { get: originalHttpsGet, request: originalHttpsRequest } = https | ||
for (const [protocol, requestModule] of this.modules) { | ||
const { request: pureRequest, get: pureGet } = requestModule | ||
const onRequest = this.onRequest.bind(this) | ||
const onResponse = this.onResponse.bind(this) | ||
this.subscriptions.push(() => { | ||
requestModule.request = pureRequest | ||
requestModule.get = pureGet | ||
http.request = new Proxy(http.request, { | ||
apply: (target, thisArg, args: Parameters<typeof http.request>) => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'http:', | ||
args | ||
) | ||
const mockAgent = new MockAgent({ | ||
customAgent: options.agent, | ||
onRequest, | ||
onResponse, | ||
}) | ||
options.agent = mockAgent | ||
logger.info('native "%s" module restored!', protocol) | ||
return Reflect.apply(target, thisArg, [url, options, callback]) | ||
}, | ||
}) | ||
http.get = new Proxy(http.get, { | ||
apply: (target, thisArg, args: Parameters<typeof http.get>) => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'http:', | ||
args | ||
) | ||
const mockAgent = new MockAgent({ | ||
customAgent: options.agent, | ||
onRequest, | ||
onResponse, | ||
}) | ||
options.agent = mockAgent | ||
return Reflect.apply(target, thisArg, [url, options, callback]) | ||
}, | ||
}) | ||
// | ||
// HTTPS. | ||
// | ||
https.request = new Proxy(https.request, { | ||
apply: (target, thisArg, args: Parameters<typeof https.request>) => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
args | ||
) | ||
const mockAgent = new MockHttpsAgent({ | ||
customAgent: options.agent, | ||
onRequest, | ||
onResponse, | ||
}) | ||
options.agent = mockAgent | ||
return Reflect.apply(target, thisArg, [url, options, callback]) | ||
}, | ||
}) | ||
https.get = new Proxy(https.get, { | ||
apply: (target, thisArg, args: Parameters<typeof https.get>) => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
args | ||
) | ||
const mockAgent = new MockHttpsAgent({ | ||
customAgent: options.agent, | ||
onRequest, | ||
onResponse, | ||
}) | ||
options.agent = mockAgent | ||
return Reflect.apply(target, thisArg, [url, options, callback]) | ||
}, | ||
}) | ||
this.subscriptions.push(() => { | ||
http.get = originalGet | ||
http.request = originalRequest | ||
https.get = originalHttpsGet | ||
https.request = originalHttpsRequest | ||
}) | ||
} | ||
private onRequest: MockHttpSocketRequestCallback = async ({ | ||
request, | ||
socket, | ||
}) => { | ||
const requestId = Reflect.get(request, kRequestId) | ||
const { interactiveRequest, requestController } = | ||
toInteractiveRequest(request) | ||
// TODO: Abstract this bit. We are using it everywhere. | ||
this.emitter.once('request', ({ requestId: pendingRequestId }) => { | ||
if (pendingRequestId !== requestId) { | ||
return | ||
} | ||
if (requestController.responsePromise.state === 'pending') { | ||
this.logger.info( | ||
'request has not been handled in listeners, executing fail-safe listener...' | ||
) | ||
requestController.responsePromise.resolve(undefined) | ||
} | ||
}) | ||
const listenerResult = await until(async () => { | ||
await emitAsync(this.emitter, 'request', { | ||
requestId, | ||
request: interactiveRequest, | ||
}) | ||
const options: NodeClientOptions = { | ||
emitter: this.emitter, | ||
logger: this.logger, | ||
return await requestController.responsePromise | ||
}) | ||
if (listenerResult.error) { | ||
// Treat thrown Responses as mocked responses. | ||
if (listenerResult.error instanceof Response) { | ||
socket.respondWith(listenerResult.error) | ||
return | ||
} | ||
// @ts-ignore | ||
requestModule.request = | ||
// Force a line break. | ||
request(protocol, options) | ||
// Allow mocking Node-like errors. | ||
if (isNodeLikeError(listenerResult.error)) { | ||
socket.errorWith(listenerResult.error) | ||
return | ||
} | ||
// @ts-ignore | ||
requestModule.get = | ||
// Force a line break. | ||
get(protocol, options) | ||
// Emit the "unhandledException" event to allow the client | ||
// to opt-out from the default handling of exceptions | ||
// as 500 error responses. | ||
if (this.emitter.listenerCount('unhandledException') > 0) { | ||
await emitAsync(this.emitter, 'unhandledException', { | ||
error: listenerResult.error, | ||
request, | ||
requestId, | ||
controller: { | ||
respondWith: socket.respondWith.bind(socket), | ||
errorWith: socket.errorWith.bind(socket), | ||
}, | ||
}) | ||
logger.info('native "%s" module patched!', protocol) | ||
// After the listeners are done, if the socket is | ||
// not connecting anymore, the response was mocked. | ||
// If the socket has been destroyed, the error was mocked. | ||
// Treat both as the result of the listener's call. | ||
if (!socket.connecting || socket.destroyed) { | ||
return | ||
} | ||
} | ||
// Unhandled exceptions in the request listeners are | ||
// synonymous to unhandled exceptions on the server. | ||
// Those are represented as 500 error responses. | ||
socket.respondWith(createServerErrorResponse(listenerResult.error)) | ||
return | ||
} | ||
const mockedResponse = listenerResult.data | ||
if (mockedResponse) { | ||
/** | ||
* @note The `.respondWith()` method will handle "Response.error()". | ||
* Maybe we should make all interceptors do that? | ||
*/ | ||
socket.respondWith(mockedResponse) | ||
return | ||
} | ||
socket.passthrough() | ||
} | ||
public onResponse: MockHttpSocketResponseCallback = async ({ | ||
requestId, | ||
request, | ||
response, | ||
isMockedResponse, | ||
}) => { | ||
// Return the promise to when all the response event listeners | ||
// are finished. | ||
return emitAsync(this.emitter, 'response', { | ||
requestId, | ||
request, | ||
response, | ||
isMockedResponse, | ||
}) | ||
} | ||
} |
@@ -9,7 +9,6 @@ import { it, expect } from 'vitest' | ||
it('handles [string, callback] input', () => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
const [url, options, callback] = normalizeClientRequestArgs('https:', [ | ||
'https://mswjs.io/resource', | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -35,8 +34,7 @@ // URL string must be converted to a URL instance. | ||
} | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
const [url, options, callback] = normalizeClientRequestArgs('https:', [ | ||
'https://mswjs.io/resource', | ||
initialOptions, | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -54,7 +52,6 @@ // URL must be created from the string. | ||
it('handles [URL, callback] input', () => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
const [url, options, callback] = normalizeClientRequestArgs('https:', [ | ||
new URL('https://mswjs.io/resource'), | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -75,7 +72,6 @@ // URL must be preserved. | ||
it('handles [Absolute Legacy URL, callback] input', () => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
const [url, options, callback] = normalizeClientRequestArgs('https:', [ | ||
parse('https://cherry:durian@mswjs.io:12345/resource?apple=banana'), | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -102,8 +98,7 @@ // URL must be preserved. | ||
it('handles [Relative Legacy URL, RequestOptions without path set, callback] input', () => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'http:', | ||
const [url, options, callback] = normalizeClientRequestArgs('http:', [ | ||
parse('/resource?apple=banana'), | ||
{ host: 'mswjs.io' }, | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -125,8 +120,7 @@ // Correct WHATWG URL generated. | ||
it('handles [Relative Legacy URL, RequestOptions with path set, callback] input', () => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'http:', | ||
const [url, options, callback] = normalizeClientRequestArgs('http:', [ | ||
parse('/resource?apple=banana'), | ||
{ host: 'mswjs.io', path: '/other?cherry=durian' }, | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -148,7 +142,6 @@ // Correct WHATWG URL generated. | ||
it('handles [Relative Legacy URL, callback] input', () => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'http:', | ||
const [url, options, callback] = normalizeClientRequestArgs('http:', [ | ||
parse('/resource?apple=banana'), | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -165,2 +158,3 @@ // Correct WHATWG URL generated. | ||
// Callback must be preserved. | ||
expect(callback).toBeTypeOf('function') | ||
expect(callback?.name).toEqual('cb') | ||
@@ -170,6 +164,5 @@ }) | ||
it('handles [Relative Legacy URL] input', () => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'http:', | ||
parse('/resource?apple=banana') | ||
) | ||
const [url, options, callback] = normalizeClientRequestArgs('http:', [ | ||
parse('/resource?apple=banana'), | ||
]) | ||
@@ -190,4 +183,3 @@ // Correct WHATWG URL generated. | ||
it('handles [URL, RequestOptions, callback] input', () => { | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
const [url, options, callback] = normalizeClientRequestArgs('https:', [ | ||
new URL('https://mswjs.io/resource'), | ||
@@ -200,4 +192,4 @@ { | ||
}, | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -223,2 +215,3 @@ // URL must be preserved. | ||
// Callback must be preserved. | ||
expect(callback).toBeTypeOf('function') | ||
expect(callback?.name).toEqual('cb') | ||
@@ -228,9 +221,8 @@ }) | ||
it('handles [URL, RequestOptions] where options have custom "hostname"', () => { | ||
const [url, options] = normalizeClientRequestArgs( | ||
'http:', | ||
const [url, options] = normalizeClientRequestArgs('http:', [ | ||
new URL('http://example.com/path-from-url'), | ||
{ | ||
hostname: 'host-from-options.com', | ||
} | ||
) | ||
}, | ||
]) | ||
expect(url.href).toBe('http://host-from-options.com/path-from-url') | ||
@@ -244,4 +236,3 @@ expect(options).toMatchObject({ | ||
it('handles [URL, RequestOptions] where options contain "host" and "path" and "port"', () => { | ||
const [url, options] = normalizeClientRequestArgs( | ||
'http:', | ||
const [url, options] = normalizeClientRequestArgs('http:', [ | ||
new URL('http://example.com/path-from-url?a=b&c=d'), | ||
@@ -252,4 +243,4 @@ { | ||
port: 1234, | ||
} | ||
) | ||
}, | ||
]) | ||
// Must remove the query string since it's not specified in "options.path" | ||
@@ -265,9 +256,8 @@ expect(url.href).toBe('http://host-from-options.com:1234/path-from-options') | ||
it('handles [URL, RequestOptions] where options contain "path" with query string', () => { | ||
const [url, options] = normalizeClientRequestArgs( | ||
'http:', | ||
const [url, options] = normalizeClientRequestArgs('http:', [ | ||
new URL('http://example.com/path-from-url?a=b&c=d'), | ||
{ | ||
path: '/path-from-options?foo=bar&baz=xyz', | ||
} | ||
) | ||
}, | ||
]) | ||
expect(url.href).toBe('http://example.com/path-from-options?foo=bar&baz=xyz') | ||
@@ -294,7 +284,6 @@ expect(options).toMatchObject({ | ||
} | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
const [url, options, callback] = normalizeClientRequestArgs('https:', [ | ||
initialOptions, | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -305,5 +294,6 @@ // URL must be derived from request options. | ||
// Request options must be preserved. | ||
expect(options).toEqual(initialOptions) | ||
expect(options).toMatchObject(initialOptions) | ||
// Callback must be preserved. | ||
expect(callback).toBeTypeOf('function') | ||
expect(callback?.name).toEqual('cb') | ||
@@ -313,7 +303,6 @@ }) | ||
it('handles [Empty RequestOptions, callback] input', () => { | ||
const [_, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
const [_, options, callback] = normalizeClientRequestArgs('https:', [ | ||
{}, | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -342,7 +331,6 @@ expect(options.protocol).toEqual('https:') | ||
} | ||
const [url, options, callback] = normalizeClientRequestArgs( | ||
'https:', | ||
const [url, options, callback] = normalizeClientRequestArgs('https:', [ | ||
initialOptions, | ||
function cb() {} | ||
) | ||
function cb() {}, | ||
]) | ||
@@ -355,3 +343,3 @@ // URL must be derived from request options. | ||
// Request options must be preserved. | ||
expect(options).toEqual(initialOptions) | ||
expect(options).toMatchObject(initialOptions) | ||
@@ -362,2 +350,3 @@ // Options protocol must be inferred from the request issuing module. | ||
// Callback must be preserved. | ||
expect(callback).toBeTypeOf('function') | ||
expect(callback?.name).toEqual('cb') | ||
@@ -367,6 +356,5 @@ }) | ||
it('sets fallback Agent based on the URL protocol', () => { | ||
const [url, options] = normalizeClientRequestArgs( | ||
'https:', | ||
'https://github.com' | ||
) | ||
const [url, options] = normalizeClientRequestArgs('https:', [ | ||
'https://github.com', | ||
]) | ||
const agent = options.agent as HttpsAgent | ||
@@ -380,7 +368,6 @@ | ||
it('does not set any fallback Agent given "agent: false" option', () => { | ||
const [, options] = normalizeClientRequestArgs( | ||
'https:', | ||
const [, options] = normalizeClientRequestArgs('https:', [ | ||
'https://github.com', | ||
{ agent: false } | ||
) | ||
{ agent: false }, | ||
]) | ||
@@ -391,7 +378,6 @@ expect(options.agent).toEqual(false) | ||
it('sets the default Agent for HTTP request', () => { | ||
const [, options] = normalizeClientRequestArgs( | ||
'http:', | ||
const [, options] = normalizeClientRequestArgs('http:', [ | ||
'http://github.com', | ||
{} | ||
) | ||
{}, | ||
]) | ||
@@ -402,7 +388,6 @@ expect(options._defaultAgent).toEqual(httpGlobalAgent) | ||
it('sets the default Agent for HTTPS request', () => { | ||
const [, options] = normalizeClientRequestArgs( | ||
'https:', | ||
const [, options] = normalizeClientRequestArgs('https:', [ | ||
'https://github.com', | ||
{} | ||
) | ||
{}, | ||
]) | ||
@@ -413,4 +398,3 @@ expect(options._defaultAgent).toEqual(httpsGlobalAgent) | ||
it('preserves a custom default Agent when set', () => { | ||
const [, options] = normalizeClientRequestArgs( | ||
'https:', | ||
const [, options] = normalizeClientRequestArgs('https:', [ | ||
'https://github.com', | ||
@@ -422,4 +406,4 @@ { | ||
_defaultAgent: httpGlobalAgent, | ||
} | ||
) | ||
}, | ||
]) | ||
@@ -430,4 +414,3 @@ expect(options._defaultAgent).toEqual(httpGlobalAgent) | ||
it('merges URL-based RequestOptions with the custom RequestOptions', () => { | ||
const [url, options] = normalizeClientRequestArgs( | ||
'https:', | ||
const [url, options] = normalizeClientRequestArgs('https:', [ | ||
'https://github.com/graphql', | ||
@@ -437,4 +420,4 @@ { | ||
pfx: 'PFX_KEY', | ||
} | ||
) | ||
}, | ||
]) | ||
@@ -455,9 +438,8 @@ expect(url.href).toEqual('https://github.com/graphql') | ||
it('respects custom "options.path" over URL path', () => { | ||
const [url, options] = normalizeClientRequestArgs( | ||
'http:', | ||
const [url, options] = normalizeClientRequestArgs('http:', [ | ||
new URL('http://example.com/path-from-url'), | ||
{ | ||
path: '/path-from-options', | ||
} | ||
) | ||
}, | ||
]) | ||
@@ -472,9 +454,8 @@ expect(url.href).toBe('http://example.com/path-from-options') | ||
it('respects custom "options.path" over URL path with query string', () => { | ||
const [url, options] = normalizeClientRequestArgs( | ||
'http:', | ||
const [url, options] = normalizeClientRequestArgs('http:', [ | ||
new URL('http://example.com/path-from-url?a=b&c=d'), | ||
{ | ||
path: '/path-from-options', | ||
} | ||
) | ||
}, | ||
]) | ||
@@ -490,6 +471,5 @@ // Must replace both the path and the query string. | ||
it('preserves URL query string', () => { | ||
const [url, options] = normalizeClientRequestArgs( | ||
'http:', | ||
new URL('http://example.com/resource?a=b&c=d') | ||
) | ||
const [url, options] = normalizeClientRequestArgs('http:', [ | ||
new URL('http://example.com/resource?a=b&c=d'), | ||
]) | ||
@@ -496,0 +476,0 @@ expect(url.href).toBe('http://example.com/resource?a=b&c=d') |
@@ -5,3 +5,3 @@ import { | ||
IncomingMessage, | ||
} from 'http' | ||
} from 'node:http' | ||
import { | ||
@@ -11,4 +11,14 @@ RequestOptions, | ||
globalAgent as httpsGlobalAgent, | ||
} from 'https' | ||
import { Url as LegacyURL, parse as parseUrl } from 'url' | ||
} from 'node:https' | ||
import { | ||
/** | ||
* @note Use the Node.js URL instead of the global URL | ||
* because environments like JSDOM may override the global, | ||
* breaking the compatibility with Node.js. | ||
* @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555 | ||
*/ | ||
URL, | ||
Url as LegacyURL, | ||
parse as parseUrl, | ||
} from 'node:url' | ||
import { Logger } from '@open-draft/logger' | ||
@@ -98,3 +108,3 @@ import { getRequestOptionsByUrl } from '../../../utils/getRequestOptionsByUrl' | ||
options: ResolvedRequestOptions, | ||
callback?: HttpRequestCallback | ||
callback?: HttpRequestCallback, | ||
] | ||
@@ -108,3 +118,3 @@ | ||
defaultProtocol: string, | ||
...args: ClientRequestArgs | ||
args: ClientRequestArgs | ||
): NormalizedClientRequestArgs { | ||
@@ -179,12 +189,10 @@ let url: URL | ||
return isObject(args[1]) | ||
? normalizeClientRequestArgs( | ||
defaultProtocol, | ||
? normalizeClientRequestArgs(defaultProtocol, [ | ||
{ path: legacyUrl.path, ...args[1] }, | ||
args[2] | ||
) | ||
: normalizeClientRequestArgs( | ||
defaultProtocol, | ||
args[2], | ||
]) | ||
: normalizeClientRequestArgs(defaultProtocol, [ | ||
{ path: legacyUrl.path }, | ||
args[1] as HttpRequestCallback | ||
) | ||
args[1] as HttpRequestCallback, | ||
]) | ||
} | ||
@@ -198,11 +206,10 @@ | ||
return args[1] === undefined | ||
? normalizeClientRequestArgs(defaultProtocol, resolvedUrl) | ||
? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl]) | ||
: typeof args[1] === 'function' | ||
? normalizeClientRequestArgs(defaultProtocol, resolvedUrl, args[1]) | ||
: normalizeClientRequestArgs( | ||
defaultProtocol, | ||
resolvedUrl, | ||
args[1], | ||
args[2] | ||
) | ||
? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl, args[1]]) | ||
: normalizeClientRequestArgs(defaultProtocol, [ | ||
resolvedUrl, | ||
args[1], | ||
args[2], | ||
]) | ||
} | ||
@@ -212,3 +219,3 @@ // Handle a given "RequestOptions" object as-is | ||
else if (isObject(args[0])) { | ||
options = args[0] as any | ||
options = { ... args[0] as any } | ||
logger.info('first argument is RequestOptions:', options) | ||
@@ -276,3 +283,14 @@ | ||
/** | ||
* @note If the user-provided URL is not a valid URL in Node.js, | ||
* (e.g. the one provided by the JSDOM polyfills), case it to | ||
* string. Otherwise, this throws on Node.js incompatibility | ||
* (`ERR_INVALID_ARG_TYPE` on the connection listener) | ||
* @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555 | ||
*/ | ||
if (!(url instanceof URL)) { | ||
url = (url as any).toString() | ||
} | ||
return [url, options, callback] | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
596
1170071
195
15547
13