Skip to content

Commit 31fa254

Browse files
committed
fix: pausing recorder, handling passthrough http
1 parent a9f6932 commit 31fa254

File tree

4 files changed

+159
-103
lines changed

4 files changed

+159
-103
lines changed

src/interceptors/http/index.ts

Lines changed: 74 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -37,70 +37,85 @@ export class HttpRequestInterceptor extends Interceptor<HttpRequestEventMap> {
3737
})
3838

3939
socketInterceptor.on('connection', ({ options, socket }) => {
40-
socket.once('write', (chunk, encoding) => {
41-
const firstFrame = chunk.toString()
40+
socket.runInternally(() => {
41+
socket.once('write', (chunk, encoding) => {
42+
const firstFrame = chunk.toString()
4243

43-
if (!firstFrame.includes('HTTP/')) {
44-
return
45-
}
46-
47-
// Get the request method from the first frame because it's faster
48-
// and we need this before initiating the HTTP parser.
49-
const method = firstFrame.split(' ')[0]
44+
if (!firstFrame.includes('HTTP/')) {
45+
return
46+
}
5047

51-
invariant(
52-
method != null,
53-
'Failed to handle HTTP request: expected a valid HTTP method but got %s',
54-
method,
55-
options
56-
)
48+
// Get the request method from the first frame because it's faster
49+
// and we need this before initiating the HTTP parser.
50+
const method = firstFrame.split(' ')[0]
5751

58-
const requestParser = createHttpRequestParserStream({
59-
requestOptions: {
52+
invariant(
53+
method != null,
54+
'Failed to handle HTTP request: expected a valid HTTP method but got %s',
6055
method,
61-
...options,
62-
},
63-
onRequest: async (request) => {
64-
const requestId = createRequestId()
65-
const controller = new RequestController(request)
66-
67-
const isRequestHandled = await handleRequest({
68-
request,
69-
requestId,
70-
controller,
71-
emitter: this.emitter,
72-
async onResponse(response) {
73-
await respondWith({
74-
socket,
75-
connectionOptions: options,
76-
request,
77-
response,
78-
})
79-
},
80-
async onRequestError(response) {
81-
await respondWith({
82-
socket,
83-
connectionOptions: options,
84-
request,
85-
response,
86-
})
87-
},
88-
onError(error) {
89-
if (error instanceof Error) {
90-
socket.destroy(error)
91-
}
92-
},
93-
})
94-
95-
if (!isRequestHandled) {
96-
socket.passthrough()
97-
}
98-
},
56+
options
57+
)
58+
59+
const requestParser = createHttpRequestParserStream({
60+
requestOptions: {
61+
method,
62+
...options,
63+
},
64+
onRequest: async (request) => {
65+
const requestId = createRequestId()
66+
const controller = new RequestController(request)
67+
68+
const isRequestHandled = await handleRequest({
69+
request,
70+
requestId,
71+
controller,
72+
emitter: this.emitter,
73+
async onResponse(response) {
74+
await respondWith({
75+
socket,
76+
connectionOptions: options,
77+
request,
78+
response,
79+
})
80+
},
81+
async onRequestError(response) {
82+
await respondWith({
83+
socket,
84+
connectionOptions: options,
85+
request,
86+
response,
87+
})
88+
},
89+
onError(error) {
90+
if (error instanceof Error) {
91+
socket.destroy(error)
92+
}
93+
},
94+
})
95+
96+
if (!isRequestHandled) {
97+
const passthroughSocket = socket.passthrough()
98+
99+
/**
100+
* @note Creating a passthroughsocket does NOT trigger the "socket" event
101+
* from `http.ClientRequest` where the request, parser, and socket get
102+
* associated. Recreate that association on the passthrough socket manually.
103+
* @see https://github.com/nodejs/node/blob/134625d76139b4b3630d5baaf2efccae01ede564/lib/_http_client.js#L890
104+
*/
105+
// @ts-expect-error Internal Node.js property.
106+
passthroughSocket._httpMessage = socket._httpMessage
107+
// @ts-expect-error Internal Node.js property.c
108+
passthroughSocket.parser = socket.parser
109+
// @ts-expect-error Internal Node.js property.
110+
passthroughSocket.parser.socket = passthroughSocket
111+
}
112+
},
113+
})
114+
115+
// Write the header again because at this point it's already been written.
116+
requestParser.write(toBuffer(chunk, encoding))
117+
socket.pipe(requestParser)
99118
})
100-
101-
// Write the header again because at this point it's already been written.
102-
requestParser.write(toBuffer(chunk, encoding))
103-
socket.pipe(requestParser)
104119
})
105120
})
106121
}

src/interceptors/net/mock-socket.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class MockSocket extends net.Socket {
3434
onEntry: (entry) => {
3535
if (
3636
entry.type === 'apply' &&
37-
entry.metadata.property === 'passthrough'
37+
['runInternally', 'passthrough'].includes(entry.metadata.property)
3838
) {
3939
return false
4040
}
@@ -54,6 +54,7 @@ export class MockSocket extends net.Socket {
5454
}
5555
},
5656
})
57+
5758
return this[kRecorder].socket
5859
}
5960

@@ -76,31 +77,54 @@ export class MockSocket extends net.Socket {
7677
const [chunk, encoding, callback] = normalizeSocketWriteArgs(
7778
args as WriteArgs
7879
)
79-
this.emit('write', chunk, encoding, callback)
80+
this.runInternally(() => {
81+
this.emit('write', chunk, encoding, callback)
82+
})
8083
return true
8184
}
8285

8386
public push(chunk: any, encoding?: BufferEncoding): boolean {
84-
this.emit('push', chunk, encoding)
87+
this.runInternally(() => {
88+
this.emit('push', chunk, encoding)
89+
})
8590
return super.push(chunk, encoding)
8691
}
8792

8893
public end(...args: any) {
8994
const [chunk, encoding, callback] = normalizeSocketWriteArgs(
9095
args as WriteArgs
9196
)
92-
this.emit('write', chunk, encoding, callback)
97+
this.runInternally(() => {
98+
this.emit('write', chunk, encoding, callback)
99+
})
93100
return super.end.apply(this, args)
94101
}
95102

103+
/**
104+
* Invokes the given callback without its actions being recorded.
105+
* Use this for internal logic that must not be replayed on the passthrough socket.
106+
*/
107+
public runInternally(callback: () => void) {
108+
try {
109+
this[kRecorder].pause()
110+
callback()
111+
} finally {
112+
this[kRecorder].resume()
113+
}
114+
}
115+
96116
/**
97117
* Establishes the actual connection behind this socket.
98118
* Replays all the consumer interaction on the passthrough socket
99119
* and mirrors all the subsequent mock socket interactions onto the passthrough socket.
100120
*/
101-
public passthrough(): void {
121+
public passthrough(): net.Socket {
102122
const socket = this.options.createConnection()
103123
this[kRecorder].replay(socket)
104124
this[kPassthroughSocket] = socket
125+
126+
socket.on('error', () => console.log('ERR!'))
127+
128+
return socket
105129
}
106130
}

src/interceptors/net/socket-recorder.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const kSocketRecorder = Symbol('kSocketRecorder')
55
export interface SocketRecorder<T extends net.Socket> {
66
socket: T
77
replay: (newSocket: net.Socket) => void
8+
pause: () => void
9+
resume: () => void
810
}
911

1012
export interface SocketRecorderEntry {
@@ -29,6 +31,7 @@ export function createSocketRecorder<T extends net.Socket>(
2931
) => void
3032
}
3133
): SocketRecorder<T> {
34+
let isRecording = true
3235
const entries: Array<SocketRecorderEntry> = []
3336

3437
Object.defineProperty(socket, kSocketRecorder, {
@@ -38,6 +41,10 @@ export function createSocketRecorder<T extends net.Socket>(
3841
})
3942

4043
const addEntry = (entry: SocketRecorderEntry) => {
44+
if (!isRecording) {
45+
return
46+
}
47+
4148
if (options?.onEntry?.(entry) !== false) {
4249
entries.push(entry)
4350
}
@@ -61,7 +68,11 @@ export function createSocketRecorder<T extends net.Socket>(
6168
type: 'apply',
6269
metadata: { property },
6370
replay(newSocket) {
64-
fn.apply(newSocket, argArray)
71+
Reflect.apply(
72+
newSocket[property as keyof net.Socket] as Function,
73+
newSocket,
74+
argArray
75+
)
6576
},
6677
})
6778
}
@@ -110,6 +121,12 @@ export function createSocketRecorder<T extends net.Socket>(
110121
}
111122
entries.length = 0
112123
},
124+
pause() {
125+
isRecording = false
126+
},
127+
resume() {
128+
isRecording = true
129+
},
113130
}
114131
}
115132

0 commit comments

Comments
 (0)