Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
83ba74f
feat(wip): net interceptor
kettanaito Aug 2, 2025
1e3b90f
chore: add socket recorder
kettanaito Aug 4, 2025
b31ddda
chore: instant replay and mirror getters for passthrough
kettanaito Aug 4, 2025
584a215
chore: imply passthrough state from existence of passthrough socket
kettanaito Aug 5, 2025
b557d5c
test: add `net` socket tests
kettanaito Aug 5, 2025
2bc1c9f
chore: allow returning undefined from `onEntry`
kettanaito Aug 5, 2025
32fcd0f
fix: patch `node:net` early; add `http` tests
kettanaito Aug 5, 2025
7e3793c
chore: skip proxying `net.createConnection`
kettanaito Aug 5, 2025
2b2896f
fix(MockSocket): use symbols to circumvent `this` change
kettanaito Aug 5, 2025
ab83bfb
fix(SocketRecorder): ignore setters for internal properties
kettanaito Aug 5, 2025
2075b57
fix(MockSocket): connect immediately, defer `connect` event emission
kettanaito Aug 5, 2025
5429699
test: add undici test
kettanaito Aug 5, 2025
6a5b0a6
chore: remove unused test
kettanaito Aug 5, 2025
2b982f0
chore(http): clean up
kettanaito Aug 5, 2025
8452422
chore: move http interceptor to `/interceptors`
kettanaito Aug 5, 2025
2c7f4e9
fix(http): implement response handling
kettanaito Aug 5, 2025
dd422e3
fix: invoke proxy implementation directly
kettanaito Aug 5, 2025
a9f6932
fix: do not call `socket.connect()` in the proxy
kettanaito Aug 5, 2025
31fa254
fix: pausing recorder, handling passthrough http
kettanaito Aug 6, 2025
72fdb4e
docs: update `socket.connect()` notice
kettanaito Aug 6, 2025
35203dc
test(readable-stream): migrate, response error case failing
kettanaito Aug 6, 2025
24e21cd
fix: request headers, `FetchResponse` order issue
kettanaito Aug 6, 2025
ed9c6bc
test: migrate more tests to `http`
kettanaito Aug 6, 2025
dd9855e
test: migrate the rest `http` tests
kettanaito Aug 6, 2025
e262b98
chore: use `vitest/globals`
kettanaito Aug 6, 2025
eea97bd
test: add unit tests for `normalizeNetConnectArgs`
kettanaito Aug 6, 2025
674b8aa
test(readable-stream): add real server
kettanaito Aug 6, 2025
8fc3755
fix: unpause the recorder, dedupe `.once` records
kettanaito Aug 6, 2025
b54b39b
chore: remove comment, remove unused test
kettanaito Aug 7, 2025
4d4f851
fix: add response stream handling
kettanaito Aug 7, 2025
97f678b
fix: use `Writable` for dummy response stream
kettanaito Aug 7, 2025
535facd
fix: free request parser on `close`
kettanaito Aug 7, 2025
e375e22
fix: request body reads, response piping
kettanaito Aug 7, 2025
df151b7
fix: response stream error handling
kettanaito Aug 8, 2025
288bc56
fix: set `options.secure` for tls connections
kettanaito Aug 8, 2025
abdb4fe
fix: protocol resolution for tls connections
kettanaito Aug 8, 2025
d285a5e
test: use `https.globalAgent` for HTTPS `ClientRequest`
kettanaito Aug 8, 2025
25f7eb8
fix: implement `connectOptionsToUrl`
kettanaito Aug 8, 2025
d2c36d3
docs: mention forgoing stream piping
kettanaito Aug 8, 2025
371f98c
chore: remove unused import
kettanaito Aug 8, 2025
6318aea
fix: free parsers correctly
kettanaito Aug 8, 2025
35142dc
fix: remove socket argument from freeing the parser
kettanaito Aug 8, 2025
e7abf9f
chore: move `mockConnect` and `respondWith` into the interceptor
kettanaito Aug 8, 2025
4f8cfe4
chore(wip): continue
kettanaito Oct 7, 2025
e0e7306
Merge branch 'main' into feat/net-interceptor
kettanaito Nov 14, 2025
bc85d60
chore: migrate to the new `RequestController` API
kettanaito Nov 14, 2025
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
38 changes: 24 additions & 14 deletions _http_common.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,36 @@ declare var HTTPParser: {
export interface HTTPParser<ParserType extends number> {
new (): HTTPParser<ParserType>

[HTTPParser.kOnMessageBegin]: () => void
[HTTPParser.kOnHeaders]: HeadersCallback
[HTTPParser.kOnHeadersComplete]: ParserType extends 0
? RequestHeadersCompleteCallback
: ResponseHeadersCompleteCallback
[HTTPParser.kOnBody]: (chunk: Buffer) => void
[HTTPParser.kOnMessageComplete]: () => void
[HTTPParser.kOnExecute]: () => void
[HTTPParser.kOnTimeout]: () => void
[HTTPParser.kOnMessageBegin]?: (() => void) | null
[HTTPParser.kOnHeaders]?: HeadersCallback
[HTTPParser.kOnHeadersComplete]?: ParserType extends 0
? RequestHeadersCompleteCallback | null
: ResponseHeadersCompleteCallback | null
[HTTPParser.kOnBody]?: ((chunk: Buffer) => void) | null
[HTTPParser.kOnMessageComplete]?: (() => void) | null
[HTTPParser.kOnExecute]?: (() => void) | null
[HTTPParser.kOnTimeout]?: (() => void) | null

_consumed?: boolean
_headers?: Array<unknown>
_url: string
maxHeaderPairs: number
socket?: typeof import('node:net').Socket | null
incoming?: typeof import('node:http').IncomingMessage | null
outgoing?: typeof import('node:http').OutgoingMessage | null
onIncoming?: (() => void) | null
joinDuplicateHeaders?: unknown

initialize(type: ParserType, asyncResource: object): void
execute(buffer: Buffer): void
finish(): void
free(): void
unconsume(): void
remove(): void
close(): void
free(): boolean
}

export type HeadersCallback = (
rawHeaders: Array<string>,
url: string
) => void
export type HeadersCallback = (rawHeaders: Array<string>, url: string) => void

export type RequestHeadersCompleteCallback = (
versionMajor: number,
Expand Down
14 changes: 13 additions & 1 deletion src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export function baseUrlFromConnectionOptions(options: any): URL {
return new URL(options.href)
}

const protocol = options.port === 443 ? 'https:' : 'http:'
const protocol = getProtocolByOptions(options)
const host = options.host

const url = new URL(`${protocol}//${host}`)
Expand All @@ -24,3 +24,15 @@ export function baseUrlFromConnectionOptions(options: any): URL {

return url
}

function getProtocolByOptions(options: any): string {
if (options.protocol) {
return options.protocol
}

if (options.secure) {
return 'https:'
}

return options.port === 443 ? 'https:' : 'http:'
}
243 changes: 243 additions & 0 deletions src/interceptors/http/http-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import {
HTTPParser,
type HeadersCallback,
type RequestHeadersCompleteCallback,
type ResponseHeadersCompleteCallback,
} from '_http_common'
import net from 'node:net'
import { Readable } from 'node:stream'
import { invariant } from 'outvariant'
import { FetchResponse } from '../../utils/fetchUtils'
import type { NetworkConnectionOptions } from '../net/utils/normalize-net-connect-args'

type HttpParserKind = typeof HTTPParser.REQUEST | typeof HTTPParser.RESPONSE

interface ParserHooks<ParserKind extends HttpParserKind> {
onMessageBegin?: () => void
onHeaders?: HeadersCallback
onHeadersComplete?: ParserKind extends typeof HTTPParser.REQUEST
? RequestHeadersCompleteCallback
: ResponseHeadersCompleteCallback
onBody?: (chunk: Buffer) => void
onMessageComplete?: () => void
onExecute?: () => void
onTimeout?: () => void
}

export class HttpParser<ParserKind extends HttpParserKind> {
static REQUEST = HTTPParser.REQUEST
static RESPONSE = HTTPParser.RESPONSE

#parser: HTTPParser<ParserKind>

constructor(kind: ParserKind, hooks: ParserHooks<ParserKind>) {
this.#parser = new HTTPParser()
this.#parser.initialize(kind, {})
this.#parser[HTTPParser.kOnMessageBegin] = hooks.onMessageBegin
this.#parser[HTTPParser.kOnHeaders] = hooks.onHeaders
this.#parser[HTTPParser.kOnHeadersComplete] = hooks.onHeadersComplete
this.#parser[HTTPParser.kOnBody] = hooks.onBody
this.#parser[HTTPParser.kOnMessageComplete] = hooks.onMessageComplete
this.#parser[HTTPParser.kOnExecute] = hooks.onExecute
this.#parser[HTTPParser.kOnTimeout] = hooks.onTimeout
}

public execute(buffer: Buffer): void {
this.#parser.execute(buffer)
}

/**
* @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_common.js#L180
*/
public free(socket?: net.Socket): void {
if (this.#parser._consumed) {
this.#parser.unconsume()
}

this.#parser._headers = []
this.#parser._url = ''
this.#parser.socket = null
this.#parser.incoming = null
this.#parser.outgoing = null
this.#parser.maxHeaderPairs = 2000
this.#parser[HTTPParser.kOnMessageBegin] = null
this.#parser[HTTPParser.kOnExecute] = null
this.#parser[HTTPParser.kOnTimeout] = null
this.#parser._consumed = false
this.#parser.onIncoming = null
this.#parser.joinDuplicateHeaders = null

this.#parser.remove()
this.#parser.free()

if (socket) {
// @ts-expect-error Node.js internals.
socket.parser = null
}
}
}

export class HttpRequestParser extends HttpParser<typeof HttpParser.REQUEST> {
#requestRawHeadersBuffer: Array<string>
#requestBodyStream?: Readable | null

constructor(options: {
requestOptions: NetworkConnectionOptions & {
method: string
baseUrl: URL
}
onRequest: (request: Request) => void
}) {
super(HttpParser.REQUEST, {
onHeaders: (rawHeaders) => {
this.#requestRawHeadersBuffer.push(...rawHeaders)
},
onHeadersComplete: (
versionMajor,
versionMinor,
rawHeaders,
_,
path,
__,
___,
____,
shouldKeepAlive
) => {
const method = options.requestOptions.method?.toUpperCase() || 'GET'
const url = new URL(path || '', options.requestOptions.baseUrl)

const headers = FetchResponse.parseRawHeaders([
...this.#requestRawHeadersBuffer,
...(rawHeaders || []),
])

const canHaveBody = method !== 'GET' && method !== 'HEAD'

// Translate the basic authorization to request headers.
// Constructing a Request instance with a URL containing auth is no-op.
if (url.username || url.password) {
if (!headers.has('authorization')) {
headers.set(
'authorization',
`Basic ${url.username}:${url.password}`
)
}
url.username = ''
url.password = ''
}

this.#requestBodyStream = new Readable({
/**
* @note Provide the `read()` method so a `Readable` could be
* used as the actual request body (the stream calls "read()").
*/
read() {},
})

const request = new Request(url, {
method,
headers,
credentials: 'same-origin',
// @ts-expect-error Undocumented Fetch property.
duplex: canHaveBody ? 'half' : undefined,
body: canHaveBody
? (Readable.toWeb(this.#requestBodyStream) as any)
: null,
})

/**
* @note Here we used to skip the request handling altogether
* if the "INTERNAL_REQUEST_ID_HEADER_NAME" request header is present.
* That prevented the nested interceptors (XHR -> ClientRequest) from
* conflicting. Do we still need this?
*
* @todo Forgo the old deduplication algo because it's intrusive.
* @see https://github.com/mswjs/interceptors/issues/378
*/

options.onRequest(request)
},
onBody: (chunk) => {
invariant(
this.#requestBodyStream,
'Failed to write to a request stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub.'
)

this.#requestBodyStream.push(chunk)
},
onMessageComplete: () => {
this.#requestBodyStream?.push(null)
},
})

this.#requestRawHeadersBuffer = []
}

public free(socket?: net.Socket): void {
super.free(socket)
this.#requestRawHeadersBuffer = []
this.#requestBodyStream = null
}
}

export class HttpResponseParser extends HttpParser<typeof HttpParser.RESPONSE> {
#responseRawHeadersBuffer: Array<string>
#responseBodyStream?: Readable | null

constructor(options: { onResponse: (response: Response) => void }) {
super(HttpParser.RESPONSE, {
onHeaders: (rawHeaders) => {
this.#responseRawHeadersBuffer.push(...rawHeaders)
},
onHeadersComplete: (
versionMajor,
versionMinor,
rawHeaders,
method,
url,
status,
statusText
) => {
const headers = FetchResponse.parseRawHeaders([
...this.#responseRawHeadersBuffer,
...(rawHeaders || []),
])

const response = new FetchResponse(
FetchResponse.isResponseWithBody(status)
? (Readable.toWeb(
(this.#responseBodyStream = new Readable({ read() {} }))
) as any)
: null,
{
url,
status,
statusText,
headers,
}
)

options.onResponse(response)
},
onBody: (chunk) => {
invariant(
this.#responseBodyStream,
'Failed to read from a response stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub.'
)

this.#responseBodyStream.push(chunk)
},
onMessageComplete: () => {
this.#responseBodyStream?.push(null)
},
})

this.#responseRawHeadersBuffer = []
}

public free(socket?: net.Socket): void {
super.free(socket)
this.#responseRawHeadersBuffer = []
this.#responseBodyStream = null
}
}
Loading
Loading