From ceb649a58bfe9dc753c8a46b247d07ab8db9f093 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 30 Oct 2024 16:29:10 +0100 Subject: [PATCH 1/2] fix: support `addRequest` from custom http agents --- src/interceptors/ClientRequest/agents.ts | 13 +++++ .../http-agent-add-request.test.ts | 56 +++++++++++++++++++ ...ncurrent-different-response-source.test.ts | 4 +- 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 test/modules/http/regressions/http-agent-add-request.test.ts diff --git a/src/interceptors/ClientRequest/agents.ts b/src/interceptors/ClientRequest/agents.ts index 5436f94c9..e647c60ab 100644 --- a/src/interceptors/ClientRequest/agents.ts +++ b/src/interceptors/ClientRequest/agents.ts @@ -10,6 +10,7 @@ import { declare module 'node:http' { interface Agent { createConnection(options: any, callback: any): net.Socket + addRequest(request: http.ClientRequest, options: http.RequestOptions): void } } @@ -50,6 +51,18 @@ export class MockAgent extends http.Agent { return socket } + + addRequest(request: http.ClientRequest, options: http.RequestOptions) { + // First, execute the base class method. + super.addRequest.apply(this, [request, options]) + + // If there's a custom HTTP agent, call its `addRequest` method. + // This way, if the agent has side effects that affect the request, + // those will be applied to the intercepted request instance as well. + if (this.customAgent instanceof http.Agent) { + this.customAgent.addRequest(request, options) + } + } } export class MockHttpsAgent extends https.Agent { diff --git a/test/modules/http/regressions/http-agent-add-request.test.ts b/test/modules/http/regressions/http-agent-add-request.test.ts new file mode 100644 index 000000000..1ad32f55b --- /dev/null +++ b/test/modules/http/regressions/http-agent-add-request.test.ts @@ -0,0 +1,56 @@ +/** + * @see https://github.com/mswjs/msw/issues/2338 + */ +// @vitest-environment node +import http from 'node:http' +import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' +import { DeferredPromise } from '@open-draft/deferred-promise' + +const interceptor = new ClientRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +/** + * A custom HTTP agent that adds a "cookie" header to + * any outgoing request. + */ +class CustomAgent extends http.Agent { + addRequest(request: http.ClientRequest) { + request.setHeader('cookie', 'key=value') + } +} + +it('respects a custom "addRequest" method on the http agent', async () => { + const interceptedRequestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + interceptedRequestPromise.resolve(request) + controller.respondWith(new Response()) + }) + + const request = http.get('http://localhost/resource', { + agent: new CustomAgent(), + }) + await waitForClientRequest(request) + + const interceptedRequest = await interceptedRequestPromise + + // Must have the cookie header set by the custom agent. + expect(Object.fromEntries(interceptedRequest.headers)).toEqual( + expect.objectContaining({ + cookie: 'key=value', + }) + ) +}) diff --git a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts index c0b34c8bb..317793223 100644 --- a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts +++ b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' From 6810632c84feb8c39d47ee1058a644261801a0f5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Nov 2024 11:38:46 +0100 Subject: [PATCH 2/2] chore(wip): wip --- .../ClientRequest/MockHttpSocket.ts | 40 +++++++-- src/interceptors/ClientRequest/agents.ts | 89 +++++++++++++++---- src/interceptors/ClientRequest/index.ts | 25 +++++- .../http-agent-add-request.test.ts | 54 ++++++++--- 4 files changed, 172 insertions(+), 36 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 545faee94..80a45507e 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -7,6 +7,7 @@ import { import { STATUS_CODES, IncomingMessage, ServerResponse } from 'node:http' import { Readable } from 'node:stream' import { invariant } from 'outvariant' +import { Emitter } from 'strict-event-emitter' import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { MockSocket } from '../Socket/MockSocket' import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs' @@ -19,6 +20,7 @@ import { } from '../../utils/responseUtils' import { createRequestId } from '../../createRequestId' import { getRawFetchHeaders } from './utils/recordRawHeaders' +import { emitAsync } from '../../utils/emitAsync' type HttpConnectionOptions = any @@ -39,19 +41,33 @@ export type MockHttpSocketResponseCallback = (args: { interface MockHttpSocketOptions { connectionOptions: HttpConnectionOptions createConnection: () => net.Socket - onRequest: MockHttpSocketRequestCallback - onResponse: MockHttpSocketResponseCallback } export const kRequestId = Symbol('kRequestId') +export const kEmitter = Symbol('kEmitter') + +type MockHttpSocketEventsMap = { + request: [ + args: { requestId: string; request: Request; socket: MockHttpSocket } + ] + response: [ + args: { + response: Response + isMockedResponse: boolean + requestId: string + request: Request + socket: MockHttpSocket + } + ] +} export class MockHttpSocket extends MockSocket { + public [kEmitter]: Emitter + private connectionOptions: HttpConnectionOptions private createConnection: () => net.Socket private baseUrl: URL - private onRequest: MockHttpSocketRequestCallback - private onResponse: MockHttpSocketResponseCallback private responseListenersPromise?: Promise private writeBuffer: Array = [] @@ -84,10 +100,10 @@ export class MockHttpSocket extends MockSocket { }, }) + this[kEmitter] = new Emitter() + this.connectionOptions = options.connectionOptions this.createConnection = options.createConnection - this.onRequest = options.onRequest - this.onResponse = options.onResponse this.baseUrl = baseUrlFromConnectionOptions(this.connectionOptions) @@ -504,7 +520,7 @@ export class MockHttpSocket extends MockSocket { return } - this.onRequest({ + this[kEmitter].emit('request', { requestId, request: this.request, socket: this, @@ -574,13 +590,21 @@ export class MockHttpSocket extends MockSocket { return } - this.responseListenersPromise = this.onResponse({ + this.responseListenersPromise = emitAsync(this[kEmitter], 'response', { response, isMockedResponse: this.responseType === 'mock', requestId: Reflect.get(this.request, kRequestId), request: this.request, socket: this, }) + + // this.responseListenersPromise = this.onResponse({ + // response, + // isMockedResponse: this.responseType === 'mock', + // requestId: Reflect.get(this.request, kRequestId), + // request: this.request, + // socket: this, + // }) } private onResponseBody(chunk: Buffer) { diff --git a/src/interceptors/ClientRequest/agents.ts b/src/interceptors/ClientRequest/agents.ts index e647c60ab..3af4c207d 100644 --- a/src/interceptors/ClientRequest/agents.ts +++ b/src/interceptors/ClientRequest/agents.ts @@ -2,9 +2,10 @@ import net from 'node:net' import http from 'node:http' import https from 'node:https' import { + kEmitter, MockHttpSocket, - type MockHttpSocketRequestCallback, - type MockHttpSocketResponseCallback, + MockHttpSocketRequestCallback, + MockHttpSocketResponseCallback, } from './MockHttpSocket' declare module 'node:http' { @@ -20,6 +21,46 @@ interface MockAgentOptions { onResponse: MockHttpSocketResponseCallback } +export class DefaultMockAgent extends http.Agent { + public addRequest( + request: http.ClientRequest, + options: http.RequestOptions + ): void { + this.createConnection(request, () => {}) + } + + public createConnection( + options: http.RequestOptions, + callback: (...args: any[]) => void + ): net.Socket { + // Create a passthrough socket. + return new MockHttpSocket({ + connectionOptions: options, + createConnection: super.createConnection.bind(this, options, callback), + }) + } +} + +export class DefaultMockHttpsAgent extends https.Agent { + public addRequest( + request: http.ClientRequest, + options: http.RequestOptions + ): void { + this.createConnection(request, () => {}) + } + + public createConnection( + options: http.RequestOptions, + callback: (...args: any[]) => void + ): net.Socket { + // Create a passthrough socket. + return new MockHttpSocket({ + connectionOptions: options, + createConnection: super.createConnection.bind(this, options, callback), + }) + } +} + export class MockAgent extends http.Agent { private customAgent?: http.RequestOptions['agent'] private onRequest: MockHttpSocketRequestCallback @@ -34,8 +75,9 @@ export class MockAgent extends http.Agent { public createConnection(options: any, callback: any) { const createConnection = - (this.customAgent instanceof http.Agent && - this.customAgent.createConnection) || + (this.customAgent && + this.customAgent instanceof http.Agent && + this.customAgent.createConnection.bind(this.customAgent)) || super.createConnection const socket = new MockHttpSocket({ @@ -45,23 +87,28 @@ export class MockAgent extends http.Agent { options, callback ), - onRequest: this.onRequest.bind(this), - onResponse: this.onResponse.bind(this), }) + // Forward requests and responses from this socket + // to the interceptor and the end user. + socket[kEmitter].on('request', this.onRequest) + socket[kEmitter].on('response', this.onResponse) + return socket } - addRequest(request: http.ClientRequest, options: http.RequestOptions) { - // First, execute the base class method. - super.addRequest.apply(this, [request, options]) - + public addRequest(request: http.ClientRequest, options: http.RequestOptions) { // If there's a custom HTTP agent, call its `addRequest` method. // This way, if the agent has side effects that affect the request, // those will be applied to the intercepted request instance as well. - if (this.customAgent instanceof http.Agent) { + if (this.customAgent && this.customAgent instanceof DefaultMockAgent) { this.customAgent.addRequest(request, options) } + + // Call the original `addRequest` method to trigger the request flow: + // addRequest -> createSocket -> createConnection. + // Without this, the socket will pend forever. + return super.addRequest(request, options) } } @@ -79,8 +126,9 @@ export class MockHttpsAgent extends https.Agent { public createConnection(options: any, callback: any) { const createConnection = - (this.customAgent instanceof https.Agent && - this.customAgent.createConnection) || + (this.customAgent && + this.customAgent instanceof http.Agent && + this.customAgent.createConnection.bind(this.customAgent)) || super.createConnection const socket = new MockHttpSocket({ @@ -90,10 +138,21 @@ export class MockHttpsAgent extends https.Agent { options, callback ), - onRequest: this.onRequest.bind(this), - onResponse: this.onResponse.bind(this), }) + // Forward requests and responses from this socket + // to the interceptor and the end user. + socket[kEmitter].on('request', this.onRequest) + socket[kEmitter].on('response', this.onResponse) + return socket } + + public addRequest(request: http.ClientRequest, options: http.RequestOptions) { + if (this.customAgent && this.customAgent instanceof DefaultMockHttpsAgent) { + this.customAgent.addRequest(request, options) + } + + return super.addRequest(request, options) + } } diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index c5dac7a33..fe9bd5d75 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -7,7 +7,12 @@ import { MockHttpSocketRequestCallback, MockHttpSocketResponseCallback, } from './MockHttpSocket' -import { MockAgent, MockHttpsAgent } from './agents' +import { + DefaultMockAgent, + DefaultMockHttpsAgent, + MockAgent, + MockHttpsAgent, +} from './agents' import { RequestController } from '../../RequestController' import { emitAsync } from '../../utils/emitAsync' import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' @@ -25,12 +30,22 @@ export class ClientRequestInterceptor extends Interceptor { } protected setup(): void { - const { get: originalGet, request: originalRequest } = http - const { get: originalHttpsGet, request: originalHttpsRequest } = https + const { + Agent: OriginalAgent, + get: originalGet, + request: originalRequest, + } = http + const { + Agent: OriginalHttpsAgent, + get: originalHttpsGet, + request: originalHttpsRequest, + } = https const onRequest = this.onRequest.bind(this) const onResponse = this.onResponse.bind(this) + http.Agent = DefaultMockAgent + http.request = new Proxy(http.request, { apply: (target, thisArg, args: Parameters) => { const [url, options, callback] = normalizeClientRequestArgs( @@ -70,6 +85,8 @@ export class ClientRequestInterceptor extends Interceptor { // HTTPS. // + https.Agent = DefaultMockHttpsAgent + https.request = new Proxy(https.request, { apply: (target, thisArg, args: Parameters) => { const [url, options, callback] = normalizeClientRequestArgs( @@ -112,9 +129,11 @@ export class ClientRequestInterceptor extends Interceptor { recordRawFetchHeaders() this.subscriptions.push(() => { + http.Agent = OriginalAgent http.get = originalGet http.request = originalRequest + https.Agent = OriginalHttpsAgent https.get = originalHttpsGet https.request = originalHttpsRequest diff --git a/test/modules/http/regressions/http-agent-add-request.test.ts b/test/modules/http/regressions/http-agent-add-request.test.ts index 1ad32f55b..0962b7d20 100644 --- a/test/modules/http/regressions/http-agent-add-request.test.ts +++ b/test/modules/http/regressions/http-agent-add-request.test.ts @@ -3,6 +3,7 @@ */ // @vitest-environment node import http from 'node:http' +import https from 'node:https' import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' @@ -22,16 +23,6 @@ afterAll(() => { interceptor.dispose() }) -/** - * A custom HTTP agent that adds a "cookie" header to - * any outgoing request. - */ -class CustomAgent extends http.Agent { - addRequest(request: http.ClientRequest) { - request.setHeader('cookie', 'key=value') - } -} - it('respects a custom "addRequest" method on the http agent', async () => { const interceptedRequestPromise = new DeferredPromise() @@ -40,6 +31,16 @@ it('respects a custom "addRequest" method on the http agent', async () => { controller.respondWith(new Response()) }) + /** + * A custom HTTP agent that adds a "cookie" header to + * any outgoing request. + */ + class CustomAgent extends http.Agent { + addRequest(request: http.ClientRequest) { + request.setHeader('cookie', 'key=value') + } + } + const request = http.get('http://localhost/resource', { agent: new CustomAgent(), }) @@ -54,3 +55,36 @@ it('respects a custom "addRequest" method on the http agent', async () => { }) ) }) + +it('respects a custom "addRequest" method on the https agent', async () => { + const interceptedRequestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + interceptedRequestPromise.resolve(request) + controller.respondWith(new Response()) + }) + + /** + * A custom HTTP agent that adds a "cookie" header to + * any outgoing request. + */ + class CustomAgent extends https.Agent { + addRequest(request: http.ClientRequest) { + request.setHeader('cookie', 'key=value') + } + } + + const request = https.get('https://localhost/resource', { + agent: new CustomAgent(), + }) + await waitForClientRequest(request) + + const interceptedRequest = await interceptedRequestPromise + + // Must have the cookie header set by the custom agent. + expect(Object.fromEntries(interceptedRequest.headers)).toEqual( + expect.objectContaining({ + cookie: 'key=value', + }) + ) +})