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
116 changes: 106 additions & 10 deletions src/interceptors/ClientRequest/MockHttpSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export class MockHttpSocket extends MockSocket {
private shouldKeepAlive?: boolean

private socketState: 'unknown' | 'mock' | 'passthrough' = 'unknown'
private hasEmittedEarlyConnect = false
private hasEmittedEarlySecureConnect = false
private isInternalCall = false
private responseParser: HTTPParser<1>
private responseStream?: Readable
private originalSocket?: net.Socket
Expand Down Expand Up @@ -149,6 +152,23 @@ export class MockHttpSocket extends MockSocket {
}
}

/**
* Override _read to track when Node.js internals are making calls.
* Without this, calls to `_read` on the socket will set up listeners
* to `connect` events which will never actually get called because
* `hasEmittedEarlyConnect` will skip emitting `connect` once we've
* detected that a user has tried listening to `connect` events before
* the socket was ready for it.
*/
public _read(size: number): void {
this.isInternalCall = true
try {
super._read(size)
} finally {
this.isInternalCall = false
}
}

public emit(event: string | symbol, ...args: any[]): boolean {
const emitEvent = super.emit.bind(this, event as any, ...args)

Expand All @@ -159,6 +179,46 @@ export class MockHttpSocket extends MockSocket {

return emitEvent()
}


/**
* Override the 'once' method to detect when clients are waiting for connection events.
* This prevents deadlock when clients wait for 'secureConnect' before writing data. Some
* HTTP clients like the Stripe SDK like to do this. Since the interceptor waits for
* `write` to be called before it knows if something is to be mocked or bypassed, this
* causes a deadlock because it'll never emit `secureConnect` until it knows.
*/
public once(event: string, listener: (...args: any[]) => void): this {
// Only emit early connection events for user code, not Node.js internals
// Node.js calls once('connect') from Socket._read which sets isInternalCall flag
if (!this.isInternalCall && this.connecting && this.socketState === 'unknown') {
if (event === 'secureConnect' && this.baseUrl.protocol === 'https:' && !this.hasEmittedEarlySecureConnect) {
this.hasEmittedEarlySecureConnect = true
// Schedule the emission for the next tick to allow the listener to be attached first
setImmediate(() => {
if (this.socketState === 'unknown') {
this.connecting = false // Mark as connected
// Emit connect first if not already emitted
if (!this.hasEmittedEarlyConnect) {
this.hasEmittedEarlyConnect = true
this.emit('connect')
}
this.emit('secureConnect')
}
})
} else if (event === 'connect' && !this.hasEmittedEarlyConnect) {
this.hasEmittedEarlyConnect = true
// Schedule the emission for the next tick to allow the listener to be attached first
setImmediate(() => {
if (this.socketState === 'unknown') {
this.connecting = false // Mark as connected
this.emit('connect')
}
})
}
}
return super.once(event as any, listener as any)
}

public destroy(error?: Error | undefined): this {
// Destroy the response parser when the socket gets destroyed.
Expand All @@ -178,6 +238,13 @@ export class MockHttpSocket extends MockSocket {
* its data/events through this Socket.
*/
public passthrough(): void {
// If we scheduled an early connect event but haven't emitted it yet,
// emit it now before going to passthrough mode so Node.js can apply setTimeout
if (this.hasEmittedEarlyConnect && this.connecting) {
this.connecting = false
this.emit('connect')
}

this.socketState = 'passthrough'

if (this.destroyed) {
Expand All @@ -186,6 +253,11 @@ export class MockHttpSocket extends MockSocket {

const socket = this.createConnection()
this.originalSocket = socket

// If a timeout was set on this socket before passthrough, apply it to the original socket
if (this.timeout !== undefined && this.timeout > 0) {
socket.setTimeout(this.timeout)
}

/**
* @note Inherit the original socket's connection handle.
Expand All @@ -202,12 +274,10 @@ export class MockHttpSocket extends MockSocket {
}

// If the developer destroys the socket, destroy the original connection.
this.once('error', (error) => {
this.once('error', (error: Error) => {
socket.destroy(error)
})

this.address = socket.address.bind(socket)

// Flush the buffered "socket.write()" calls onto
// the original socket instance (i.e. write request body).
// Exhaust the "requestBuffer" in case this Socket
Expand Down Expand Up @@ -274,10 +344,20 @@ export class MockHttpSocket extends MockSocket {
socket
.on('lookup', (...args) => this.emit('lookup', ...args))
.on('connect', () => {

this.connecting = socket.connecting
this.emit('connect')

// Don't re-emit 'connect' if already emitted early
if (!this.hasEmittedEarlyConnect) {
this.emit('connect')
}
})
.on('secureConnect', () => {
// Don't re-emit 'secureConnect' if already emitted early
if (!this.hasEmittedEarlySecureConnect) {
this.emit('secureConnect')
}
})
.on('secureConnect', () => this.emit('secureConnect'))
.on('secure', () => this.emit('secure'))
.on('session', (session) => this.emit('session', session))
.on('ready', () => this.emit('ready'))
Expand All @@ -300,6 +380,22 @@ export class MockHttpSocket extends MockSocket {
.on('end', () => this.emit('end'))
}

// If address is called on a passthrough socket before the original socket is set, it won't
// return anything. This can happen because sockets fire `connect` early to avoid a causing
// a deadlock with some HTTP clients that like to wait for `connect` or `secureConnect`
// before calling `write`.
public address(): net.AddressInfo | {} {
if (this.originalSocket) {
return this.originalSocket.address()
}

return {
address: this.connectionOptions.hostname || this.connectionOptions.host || '127.0.0.1',
family: this.connectionOptions.family === 6 ? 'IPv6' : 'IPv4',
port: this.connectionOptions.port || 80
}
}

/**
* Convert the given Fetch API `Response` instance to an
* HTTP message and push it to the socket.
Expand Down Expand Up @@ -509,8 +605,8 @@ export class MockHttpSocket extends MockSocket {
}

private onRequestStart: RequestHeadersCompleteCallback = (
versionMajor,
versionMinor,
_versionMajor,
_versionMinor,
rawHeaders,
_,
path,
Expand Down Expand Up @@ -631,10 +727,10 @@ export class MockHttpSocket extends MockSocket {
}

private onResponseStart: ResponseHeadersCompleteCallback = (
versionMajor,
versionMinor,
_versionMajor,
_versionMinor,
rawHeaders,
method,
_method,
url,
status,
statusText
Expand Down
Loading