Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ClientRequest): support addRequest from custom http agents #666

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions src/interceptors/ClientRequest/MockHttpSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Expand All @@ -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<MockHttpSocketEventsMap>

private connectionOptions: HttpConnectionOptions
private createConnection: () => net.Socket
private baseUrl: URL

private onRequest: MockHttpSocketRequestCallback
private onResponse: MockHttpSocketResponseCallback
private responseListenersPromise?: Promise<void>

private writeBuffer: Array<NormalizedSocketWriteArgs> = []
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -504,7 +520,7 @@ export class MockHttpSocket extends MockSocket {
return
}

this.onRequest({
this[kEmitter].emit('request', {
requestId,
request: this.request,
socket: this,
Expand Down Expand Up @@ -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) {
Expand Down
92 changes: 82 additions & 10 deletions src/interceptors/ClientRequest/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ 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' {
interface Agent {
createConnection(options: any, callback: any): net.Socket
addRequest(request: http.ClientRequest, options: http.RequestOptions): void
}
}

Expand All @@ -19,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
Expand All @@ -33,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({
Expand All @@ -44,12 +87,29 @@ 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
}

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 && 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)
}
}

export class MockHttpsAgent extends https.Agent {
Expand All @@ -66,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({
Expand All @@ -77,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)
}
}
25 changes: 22 additions & 3 deletions src/interceptors/ClientRequest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,12 +30,22 @@ export class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> {
}

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<typeof http.request>) => {
const [url, options, callback] = normalizeClientRequestArgs(
Expand Down Expand Up @@ -70,6 +85,8 @@ export class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> {
// HTTPS.
//

https.Agent = DefaultMockHttpsAgent

https.request = new Proxy(https.request, {
apply: (target, thisArg, args: Parameters<typeof https.request>) => {
const [url, options, callback] = normalizeClientRequestArgs(
Expand Down Expand Up @@ -112,9 +129,11 @@ export class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> {
recordRawFetchHeaders()

this.subscriptions.push(() => {
http.Agent = OriginalAgent
http.get = originalGet
http.request = originalRequest

https.Agent = OriginalHttpsAgent
https.get = originalHttpsGet
https.request = originalHttpsRequest

Expand Down
90 changes: 90 additions & 0 deletions test/modules/http/regressions/http-agent-add-request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* @see https://github.com/mswjs/msw/issues/2338
*/
// @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'
import { DeferredPromise } from '@open-draft/deferred-promise'

const interceptor = new ClientRequestInterceptor()

beforeAll(() => {
interceptor.apply()
})

afterEach(() => {
interceptor.removeAllListeners()
})

afterAll(() => {
interceptor.dispose()
})

it('respects a custom "addRequest" method on the http agent', async () => {
const interceptedRequestPromise = new DeferredPromise<Request>()

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 http.Agent {
addRequest(request: http.ClientRequest) {
request.setHeader('cookie', 'key=value')
}
}

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',
})
)
})

it('respects a custom "addRequest" method on the https agent', async () => {
const interceptedRequestPromise = new DeferredPromise<Request>()

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',
})
)
})
Loading
Loading