Skip to content
Open
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
63 changes: 39 additions & 24 deletions src/interceptors/ClientRequest/MockHttpSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,36 +263,51 @@ export class MockHttpSocket extends MockSocket {
}
}

// Forward TLS Socket properties onto this Socket instance
// in the case of a TLS/SSL connection.
if (Reflect.get(socket, 'encrypted')) {
const tlsProperties = [
'encrypted',
'authorized',
'getProtocol',
'getSession',
'isSessionReused',
'getCipher',
]

tlsProperties.forEach((propertyName) => {
Object.defineProperty(this, propertyName, {
enumerable: true,
get: () => {
const value = Reflect.get(socket, propertyName)
return typeof value === 'function' ? value.bind(socket) : value
},
})
})
}

socket
.on('lookup', (...args) => this.emit('lookup', ...args))
.on('connect', () => {
this.connecting = socket.connecting
this.emit('connect')
})
.on('secureConnect', () => this.emit('secureConnect'))
.on('secureConnect', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I remember it right that listening to the request.on('socket') won't give you any TLS properties until secureConnect is emitted on the socket? Is that the correct way to access those properties (like socket.encrypted)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MockHttpSocket provides mock TLS values in its constructor so they're available immediately. For passthrough, mock values remain accessible until they're replaced with real values on secureConnect

Regarding the use of Reflect.get, my understanding is that it's been used to avoid TypeScript issues. createConnection() returns net.Socket (which doesn't have an encrypted), but at runtime this is actually a TLSSocket. We could potentially cast to TLSSocket here and access it as socket.encrypted? It's a slightly more type-safe approach

/**
* Forward TLS Socket properties onto this Socket instance
* after the TLS handshake completes. This ensures the real socket
* has valid TLS information before we start forwarding it.
*
* We do this on 'secureConnect' rather than immediately in passthrough()
* because TLSSocket.encrypted is true even before the socket connects,
* but getCipher(), getProtocol() etc. return undefined until the
* TLS handshake completes. By waiting until secureConnect, we allow
* the mock TLS properties (set in constructor) to remain accessible
* until real values are available.
*/
invariant(
Reflect.get(socket, 'encrypted'),
'Expected socket to have property `encrypted`'
)

const tlsProperties = [
'encrypted',
'authorized',
'getProtocol',
'getSession',
'isSessionReused',
'getCipher',
]

tlsProperties.forEach((propertyName) => {
Object.defineProperty(this, propertyName, {
enumerable: true,
get: () => {
const value = Reflect.get(socket, propertyName)
return typeof value === 'function' ? value.bind(socket) : value
},
})
})

this.emit('secureConnect')
})
.on('secure', () => this.emit('secure'))
.on('session', (session) => this.emit('session', session))
.on('ready', () => this.emit('ready'))
Expand Down
10 changes: 10 additions & 0 deletions src/utils/handleRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ interface HandleRequestOptions {
export async function handleRequest(
options: HandleRequestOptions
): Promise<void> {
/**
* @note If there are no "request" event listeners, passthrough immediately
* without going through the full async machinery. This reduces the number
* of microtask checkpoints, which helps avoid timing issues with
* high-concurrency requests (e.g., EPIPE errors with Unix sockets).
*/
if (options.emitter.listenerCount('request') === 0) {
return options.controller.passthrough()
}

const handleResponse = async (
response: Response | Error | Record<string, any>
) => {
Expand Down
64 changes: 64 additions & 0 deletions test/modules/http/compliance/http-passthrough-early-return.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @vitest-environment node
import { it, expect, beforeAll, afterAll } from 'vitest'
import http from 'node:http'
import path from 'node:path'
import { promisify } from 'node:util'
import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest'
import { waitForClientRequest } from '../../../helpers'

const HTTP_SOCKET_PATH = path.join(__dirname, './test-early-return.sock')

const httpServer = http.createServer((req, res) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have @open-draft/test-server installed already. Should use that one.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is @open-draft/test-server compatible with UNIX sockets? I followed the pattern from http-unix-socket.test.ts here

res.writeHead(200)
res.end('ok')
})

const interceptor = new ClientRequestInterceptor()

beforeAll(async () => {
await new Promise<void>((resolve) => {
httpServer.listen(HTTP_SOCKET_PATH, resolve)
})
interceptor.apply()
})

afterAll(async () => {
interceptor.dispose()
await promisify(httpServer.close.bind(httpServer))()
})

/**
* When no request listeners are registered, the interceptor should call
* passthrough() immediately without going through the full async machinery.
* This prevents timing issues with high-concurrency Unix socket requests.
* @see https://github.com/mswjs/interceptors/issues/760
*/
it('performs passthrough over a Unix socket when no request listeners are attached', async () => {
const request = http.request({
socketPath: HTTP_SOCKET_PATH,
path: '/resource',
})
request.end()
const { res, text } = await waitForClientRequest(request)

expect(res.statusCode).toBe(200)
await expect(text()).resolves.toBe('ok')
})

it('handles concurrent Unix socket requests when no listeners are attached', async () => {
const requests = Array.from({ length: 20 }, () => {
const req = http.request({
socketPath: HTTP_SOCKET_PATH,
path: '/resource',
})
req.end()
return waitForClientRequest(req)
})

const results = await Promise.all(requests)

for (const { res, text } of results) {
expect(res.statusCode).toBe(200)
await expect(text()).resolves.toBe('ok')
}
})