Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
162 commits
Select commit Hold shift + click to select a range
d28a355
feat: add `SocketInterceptor`
kettanaito Feb 17, 2026
984eaab
feat: `HttpRequestInterceptor` over `SocketInterceptor`
kettanaito Feb 17, 2026
4edc893
feat(wip): tcpwrap-based interception
kettanaito Feb 19, 2026
74f9102
feat(wip): implement server socket proxy
kettanaito Feb 19, 2026
51b0a37
feat: implement `ConnectionController`
kettanaito Feb 19, 2026
d90a127
feat(wip): implement `ConnectionController.passthrough()`
kettanaito Feb 19, 2026
b974517
chore: remove unused classes
kettanaito Feb 19, 2026
4419f46
chore: document `MockSocket`
kettanaito Feb 19, 2026
5c8446f
fix: flush client socket pending data to real socket
kettanaito Feb 19, 2026
1fc56b5
fix: implement "_writeGeneric" and handle mock/passthrough properly
kettanaito Feb 19, 2026
12b06b4
fix: support streaming
kettanaito Feb 19, 2026
b21f61d
fix: support chunked response streams
kettanaito Feb 19, 2026
57874e2
chore: use `debug` for logging
kettanaito Feb 19, 2026
eabc273
fix: handle an array of `clientSocket._pendingData`
kettanaito Feb 19, 2026
bd53015
chore: add `readyState` enums as static on `MockSocket`
kettanaito Feb 20, 2026
fc0d147
feat(wipe): support `tls.connect`
kettanaito Feb 20, 2026
59ef8ba
fix(wip): support tls connections
kettanaito Feb 20, 2026
05fc369
chore: implement `MockSocket.wrapTlsSocket`
kettanaito Feb 20, 2026
3701b14
test: rewrite the agent patching test suite
kettanaito Feb 20, 2026
426aadb
fix(wip): attempt to fix mocked https (breaks passthrough though)
kettanaito Feb 20, 2026
bdf21b4
docs: write down some findings about tls
kettanaito Feb 20, 2026
06fe040
feat(wip): implement `MockTlsSocketController`
kettanaito Feb 22, 2026
4f80cd2
chore: implement tls controller by extending tcp controller
kettanaito Feb 22, 2026
4c505d5
fix: proper tcp and tls handling
kettanaito Feb 22, 2026
330cd37
fix(wip): mocked tcp/tls scenarios
kettanaito Feb 22, 2026
8164d4a
fix(tcp): use 127.0.0.1 to bypass dns lookup for mocked connections
kettanaito Feb 22, 2026
eaf845c
fix: "response" event for mock/passthrough
kettanaito Feb 23, 2026
70f65cc
fix: do not emit "response" for error responses
kettanaito Feb 23, 2026
8424224
fix: reset socket controller when "free" is emitted
kettanaito Feb 23, 2026
b84348d
test: switch more tests to use new interceptor
kettanaito Feb 23, 2026
950d6fd
chore: delete object recorder implementation
kettanaito Feb 23, 2026
cbbe954
chore: use `Reflect.set` for readonly `socket.connecting`
kettanaito Feb 23, 2026
5470871
chore: rename `mock-socket` to `socket-controller`
kettanaito Feb 23, 2026
038e3c5
fix: allow modifying `_pendingData` for http request headers
kettanaito Feb 23, 2026
ce82b6f
test: migrate test
kettanaito Feb 23, 2026
bbd83f6
test: add socket reuse http compliance test
kettanaito Feb 23, 2026
f8d69f2
fix: support reusing the same socket
kettanaito Feb 23, 2026
212f39d
test: rename the test file
kettanaito Feb 23, 2026
8a643ea
chore: implement #push
kettanaito Feb 23, 2026
859c266
chore: clear internal refs on socket close
kettanaito Feb 23, 2026
43fe2ef
fix: emit `secureConnect` for tls sockets correctly
kettanaito Feb 23, 2026
14d0e45
test: migrate remaining tests to new interceptor
kettanaito Feb 23, 2026
4350441
fix: treat write `callback` as optional
kettanaito Feb 23, 2026
e9dbbcc
fix: use `ServerResponse` for mock response handling
kettanaito Feb 24, 2026
a481667
fix: support calling end in a write callback
kettanaito Feb 24, 2026
71aefd7
fix: support `socket.address()`
kettanaito Feb 24, 2026
8287643
fix: implement `getSession` and `getCipher` on tls handle
kettanaito Feb 24, 2026
53fa1c5
test: fix unit socket test to remove the socket file
kettanaito Feb 24, 2026
75942fb
chore: use node 22 and 24 for ci
kettanaito Feb 24, 2026
413cb34
test: migrate other tests to the new interceptor
kettanaito Feb 24, 2026
c8dac74
feat: support `CONNECT` requests
kettanaito Feb 24, 2026
a12e915
docs: document socket reusage and `CONNECT` requests
kettanaito Feb 24, 2026
a9c63e6
docs: remove the write->end limitation
kettanaito Feb 24, 2026
d12b021
fix(wip): support full proxy flows with `CONNECT`
kettanaito Feb 25, 2026
38d9137
fix(http): set `response.url` to `request.url`
kettanaito Feb 25, 2026
392f024
feat(wip): request `initiator` via `async_hooks`
kettanaito Feb 25, 2026
7f43fb8
test: add http module import compliance tests
kettanaito Feb 25, 2026
f1ae30c
fix: get request method from the parser, not options
kettanaito Feb 25, 2026
0b1d9a8
chore: replace `waitForClientRequest` with `toWebResponse`
kettanaito Feb 25, 2026
42ad868
feat: support raw `undici` via support of write after connect
kettanaito Feb 26, 2026
af3524b
fix: skip `buffer` encoded first messages
kettanaito Feb 26, 2026
1dea6a0
fix: call "connect" listeners for "connect->write" issue
kettanaito Feb 26, 2026
b09260f
chore: remove protected `mockConnection`
kettanaito Feb 26, 2026
4c7a842
test: add req.end after socket connect test
kettanaito Feb 26, 2026
1ba3701
docs: mention end after connect risk
kettanaito Feb 26, 2026
7fa3dad
fix: forward the `keylog` tls event
kettanaito Feb 26, 2026
1906637
fix: forward `OCSPResponse` tls event
kettanaito Feb 26, 2026
465462c
test(xhr): add the OPTIONS preflight request test
kettanaito Feb 26, 2026
1ea5407
fix(tls): remove `_start` connect listeners on passthrough
kettanaito Feb 26, 2026
15c97eb
fix(tls): always close the socket #onRealSocketError
kettanaito Feb 28, 2026
38127e9
chore: always assume `https:` protocol for tls.TLSSocket
kettanaito Feb 28, 2026
a73e6af
test(fetch): improve the test cases
kettanaito Feb 28, 2026
0c9befe
fix(fetch): set `initiator` to the `request`
kettanaito Feb 28, 2026
f210c4e
chore: add `applyPatch` to simplify adding/restoring patches
kettanaito Feb 28, 2026
aba4cff
test: fix `fetch` initiator test
kettanaito Feb 28, 2026
1a2d6de
feat: remove `getRawRequest`
kettanaito Feb 28, 2026
7af1ed4
test: rename the test suite
kettanaito Feb 28, 2026
e47b7ce
chore: remove manual `vitest` imports in tests
kettanaito Feb 28, 2026
c496894
fix(net): trigger passthrough if no "connection" listeners
kettanaito Feb 28, 2026
e348dec
fix: interpret server-side `socket.end()`
kettanaito Feb 28, 2026
957f1c4
test: add "connect" compliance tests
kettanaito Feb 28, 2026
d081546
test: add "error before connect" test case
kettanaito Feb 28, 2026
c33afbd
chore: unfold net tests
kettanaito Feb 28, 2026
bc803c7
chore: remove `utils/node`
kettanaito Feb 28, 2026
5e03a99
test: add `controller.errorWith` tests
kettanaito Feb 28, 2026
6747c43
chore: use `imports` aliases in tests
kettanaito Feb 28, 2026
e67f97e
fix: delay write/end proxies until socket connects
kettanaito Feb 28, 2026
f58e813
chore: disable `fail-fast` on ci for better observability
kettanaito Feb 28, 2026
6a532a8
fix: error on handling already handled connection
kettanaito Feb 28, 2026
971185a
test: add server `socket.end()` test
kettanaito Feb 28, 2026
0513339
test: tidy up net tests
kettanaito Feb 28, 2026
89a3499
chore: remove `controller.errorWith` in favor of `socket.destroy`
kettanaito Mar 1, 2026
f5b83fa
fix(wip): forward passthrough writes to the server
kettanaito Mar 1, 2026
4ed4a5b
fix(wip): `_writeGeneric` for each readyState, server `data` forwarding
kettanaito Mar 2, 2026
83ef4be
fix: opt out from write buffering, buffer in-memory
kettanaito Mar 2, 2026
98d0e74
fix: rely on `#bufferedWrites` for write after connect check
kettanaito Mar 2, 2026
ce10f14
fix(applyPatch): use `Symbol.for` to account for cross-environment
kettanaito Mar 2, 2026
5106566
chore: remove unused close argument
kettanaito Mar 3, 2026
fda4d8a
fix(fetchUtils): patch `response.clone` to survive `setUrl` changes
kettanaito Mar 3, 2026
fa092cd
fix(FetchResponse): copy raw headers from the source
kettanaito Mar 3, 2026
3bf7d9c
fix(FetchResponse): return `Response.error()` as-is
kettanaito Mar 3, 2026
e1fc64d
fix(fetch): copy raw headers
kettanaito Mar 3, 2026
44131a3
fix: use looser `Symbol.for` for interceptor symbols
kettanaito Mar 3, 2026
ba9eea0
fix(RemoteHttpInterceptor): set `initiator` to `null`
kettanaito Mar 3, 2026
c3a4ccc
chore: remove `sleep` in favor of `setTimeout`
kettanaito Mar 3, 2026
496de4a
chore: remove old `ClientRequestInterceptor`
kettanaito Mar 3, 2026
8844c39
chore: remove unused `fetch` test utility
kettanaito Mar 3, 2026
d3ef923
chore: remove `createXMLHttpRequest` test helper
kettanaito Mar 3, 2026
d5ab8f0
chore: clean up with knip
kettanaito Mar 3, 2026
e216231
fix: use `HttpRequestInterceptor` in granular interceptors
kettanaito Mar 3, 2026
74a0072
chore: remove unused `MockSocket`
kettanaito Mar 3, 2026
28fd3da
fix: set `initiator` on `XMLHttpRequest`, vitest browser mode
kettanaito Mar 3, 2026
602f940
chore: use vitest browser mode for neutral tests
kettanaito Mar 3, 2026
6779532
test(xhr): add granular mocked response body tests
kettanaito Mar 3, 2026
5f997ef
fix(xhr): follow redirects
kettanaito Mar 4, 2026
b860212
chore: improve xhr tests
kettanaito Mar 6, 2026
4d99ad9
chore(testServer): response for GET requests, fix `https.url`
kettanaito Mar 6, 2026
e33c557
test(http-https): polish the test suite
kettanaito Mar 6, 2026
10d3ca8
chore(wip): use happy-dom
kettanaito Mar 6, 2026
3e11d9b
fix(xhr): set `total` to `0` in `loadstart`
kettanaito Mar 6, 2026
f17ba56
fix(xhr): do not transition to LOADING for empty responses
kettanaito Mar 6, 2026
4215286
fix(xhr): implement `respondWith` as per spec
kettanaito Mar 6, 2026
23fb88d
fix(xhr): skip "progress" even on `processResponseEndOfBody`
kettanaito Mar 6, 2026
d077c5b
fix(http): await forwarded request/response events
kettanaito Mar 6, 2026
9603532
fix(net): add `readyState` guard to pending `_writeGeneric`
kettanaito Mar 6, 2026
e11cf82
fix(xhr): support synchronous requests
kettanaito Mar 6, 2026
f334a1c
chore: huge improvements in xhr testing
kettanaito Mar 6, 2026
db45069
fix(xhr): implement chunked response handling
kettanaito Mar 7, 2026
9af13cd
chore: improve test setup
kettanaito Mar 7, 2026
1f1d0f3
chore: remove obsolete regression test
kettanaito Mar 7, 2026
20a9ba3
fix(socket): fire write callback if not passthrough
kettanaito Mar 7, 2026
97756d9
test(xhr): improve the upload test
kettanaito Mar 7, 2026
c7bf3db
test(xhr): set `content-length` in tests for correct `total`
kettanaito Mar 7, 2026
ee5d7bf
fix(node): await request/response event emission
kettanaito Mar 7, 2026
c419a5f
fix(xhr): await the emitted `response` event
kettanaito Mar 8, 2026
b569b16
fix(xhr): warn for sync requests in the browser, ignore everywhere
kettanaito Mar 8, 2026
249c95a
test: add interceptor dispose test case
kettanaito Mar 8, 2026
a7a98cb
test: remove obsolete xhr.browser.test.ts
kettanaito Mar 8, 2026
58cb07d
fix(xhr): translate `withCredentials` to fetch api `credentials`
kettanaito Mar 8, 2026
d5cafa1
test(xhr): add `request.responseType` for array buffer
kettanaito Mar 8, 2026
bebcf26
test(xhr): add remaining `request.responseType` test suites
kettanaito Mar 8, 2026
e44b385
chore: remove obsolete test suites
kettanaito Mar 8, 2026
170861e
fix(xhr): use `FetchRequest` to support modifying non-configurable re…
kettanaito Mar 8, 2026
3a11bb8
test(xhr): use ACAO header on mocked responses, drop explicit `OPTION…
kettanaito Mar 8, 2026
654c9d1
test(xhr): fix "events" test
kettanaito Mar 9, 2026
e5f5591
chore(vitest): set `content-type` as plain text, if not set
kettanaito Mar 9, 2026
202d438
test(xhr): keep fixing tests
kettanaito Mar 9, 2026
ad2d5f3
test(xhr): rewrite the xml response test
kettanaito Mar 9, 2026
8f3de04
test(xhr): migrate `readyState` enum test to browser-only
kettanaito Mar 9, 2026
97f7c1b
fix(xhr): forward `unhandledException` event to the interceptor
kettanaito Mar 9, 2026
5eff57f
fix(fetch): forward `unhandledException` events to the interceptor
kettanaito Mar 9, 2026
472a36f
test(xhr): fix `xhr-event-callback-null` test suite
kettanaito Mar 9, 2026
09bf906
test: remove `xhr-no-response` suite
kettanaito Mar 9, 2026
164c338
fix: translate `request` changes from upstream interceptors
kettanaito Mar 9, 2026
b6d2a1f
test(xhr): remove `xhr-add-event-listener` test (already tested)
kettanaito Mar 9, 2026
c6035ce
feat: add `/http` export path
kettanaito Mar 9, 2026
e0d4412
test(miniflare): fix the imports
kettanaito Mar 9, 2026
d5c838f
fix(wip): fixing a bunch of failed tests
kettanaito Mar 12, 2026
29ec8f4
fix(wip): proxy events between interceptors
kettanaito Mar 12, 2026
415fd57
fix: proper emitter reassignment on running instances
kettanaito Mar 17, 2026
dc679f8
fix(http): read mocked response stream manually
kettanaito Mar 17, 2026
c5cc770
fix(http): call original socket destroy for clean destroy paths
kettanaito Mar 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ jobs:
build:
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
node: [20, 22]
node: [22, 24]
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -31,16 +32,16 @@ jobs:
run: pnpm install

- name: Unit tests
run: pnpm test:unit
run: pnpm test -- --project=unit

- name: Build
run: pnpm build

- name: Node.js tests
run: pnpm test:node
run: pnpm test -- --project=node

- name: Install Playwright browsers
run: npx playwright install chromium --with-deps --only-shell

- name: Browser tests
run: pnpm test:browser
run: pnpm test -- --project=browser
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ package-lock.json
.idea
/test-results
/tmp
__screenshots__
19 changes: 3 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,6 @@ You can respond to the intercepted HTTP request by constructing a Fetch API Resp
- Does **not** provide any request matching logic;
- Does **not** handle requests by default.

## Limitations

- Interceptors will hang indefinitely if you call `req.end()` in the `connect` event listener of the respective `socket`:

```ts
req.on('socket', (socket) => {
socket.on('connect', () => {
// ❌ While this is allowed in Node.js, this cannot be handled in Interceptors.
req.end()
})
})
```

> This limitation is intrinsic to the interception algorithm used by the library. In order for it to emit the `connect` event on the socket, the library must know if you've handled the request in any way (e.g. responded with a mocked response or errored it). For that, it emits the `request` event on the interceptor where you can handle the request. Since you can consume the request stream in the `request` event, it waits until the request body stream is complete (i.e. until `req.end()` is called). This creates a catch 22 that causes this limitation.

## Getting started

```bash
Expand Down Expand Up @@ -297,10 +282,12 @@ Note that a single request _can only be handled once_. You may want to introduce
Requests must be responded to within the same tick as the request listener. This means you cannot respond to a request using `setTimeout`, as this will delegate the callback to the next tick. If you wish to introduce asynchronous side-effects in the listener, consider making it an `async` function, awaiting any side-effects you need.

```js
import { setTimeout } from 'node:timers/promises'

// Respond to all requests with a 500 response
// delayed by 500ms.
interceptor.on('request', async ({ controller }) => {
await sleep(500)
await setTimeout(500)
controller.respondWith(new Response(null, { status: 500 }))
})
```
Expand Down
20 changes: 14 additions & 6 deletions XMLHttpRequest/package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
{
"main": "../lib/node/interceptors/XMLHttpRequest/index.cjs",
"module": "../lib/node/interceptors/XMLHttpRequest/index.mjs",
"browser": "../lib/browser/interceptors/XMLHttpRequest/index.mjs",
"main": "../lib/node/interceptors/XMLHttpRequest/node.cjs",
"module": "../lib/node/interceptors/XMLHttpRequest/node.mjs",
"browser": "../lib/browser/interceptors/XMLHttpRequest/web.mjs",
"exports": {
".": {
"browser": "./../lib/browser/interceptors/XMLHttpRequest/index.mjs",
"import": "./../lib/node/interceptors/XMLHttpRequest/index.mjs",
"default": "./../lib/node/interceptors/XMLHttpRequest/index.cjs"
"browser": "./../lib/browser/interceptors/XMLHttpRequest/web.mjs",
"import": "./../lib/node/interceptors/XMLHttpRequest/node.mjs",
"default": "./../lib/node/interceptors/XMLHttpRequest/node.cjs"
},
"./node": {
"import": "./../lib/node/interceptors/XMLHttpRequest/node.mjs",
"default": "./../lib/node/interceptors/XMLHttpRequest/node.cjs"
},
"./web": {
"import": "./../lib/browser/interceptors/XMLHttpRequest/web.mjs",
"default": "./../lib/browser/interceptors/XMLHttpRequest/web.cjs"
}
}
}
16 changes: 9 additions & 7 deletions _http_common.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Socket } from 'node:net'
import type { IncomingMessage, OutgoingMessage } from 'node:http'

declare var methods: Array<string>

declare var HTTPParser: {
new (): HTTPParser<number>
REQUEST: 0
Expand All @@ -21,15 +23,15 @@ declare var HTTPParser: {
export interface HTTPParser<ParserType extends number> {
new (): HTTPParser<ParserType>

[HTTPParser.kOnMessageBegin]: (() => void) | null
[HTTPParser.kOnHeaders]: HeadersCallback | null
[HTTPParser.kOnHeadersComplete]: ParserType extends 0
[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
[HTTPParser.kOnBody]?: ((chunk: Buffer) => void) | null
[HTTPParser.kOnMessageComplete]?: (() => void) | null
[HTTPParser.kOnExecute]?: (() => void) | null
[HTTPParser.kOnTimeout]?: (() => void) | null

initialize(type: ParserType, asyncResource: object): void
execute(buffer: Buffer): void
Expand Down
57 changes: 57 additions & 0 deletions discoveries/http.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# General

## Reusing sockets

By default, `Agent` will try to reuse any sockets that were not explicitly closed via `Connection: close` request header. Here's what happens when a socket gets reused:

1. `free` is emitted on the socket to notify that it's done with the previous request.
2. `connect` is _not_ emitted on this socket anymore since the connection has already been established. No connection attempt will be made whatsoever (`socket._handle.connect` won't be called).
3. The next request's HTTP message is written into the socket.

> With this in mind, `socket.on('connect', () => request.end())` is a potentially faulty logic. A reused socket will never emit that event and the request will pend indefinitely.

## `CONNECT` requests

While forbidden by the Fetch API specification, `CONNECT` requests are possible in Node.js. Here's what's special about connect requests:

- Are sent to the _running_ server (`options.host` + `options.port)` to notify it about the intent to connect somewhere esle.
- Do _not_ actually establish any connections. Instead, the server handles the same socket instance to establish that connection.
- Never recieve the response (`response` is never emitted.)

```ts
const client = http.request({
// Actual server handling the "CONNECT" request.
host: '127.0.0.1',
port: 1337,
// The (proxy) authority in a "host:port" format.
path: 'www.example.com:80'
})

client.on('connect', (request, socket) => {
// Use "socket" to write to the authority...
})
```

# HTTPS/TLS

### `TLSWrap`

Regular `net.Socket` connections finalize in the `afterConnect` callback [attached](https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/net.js#L1142) to the `TCPWrap` request. A TLS sockets\, while extending `net.Socket` and still relying on `afterConnect` being called to transition the socket into the connected state, _do not_ rely on `_handle.oncomplete`. **It is never called**.

A TLS socket wraps `net.Socket._handle` in a `TLSWrap`, which becomes responsible for the connection. It calls the `afterConnect` callback _internally_, in the C++ code, and there's no public method to trigger it.

> TLS wrap has its own methods, like `onhandshakedone` and `verifyError`.

### Connection event sequence

> For brevity: "wrap" means the TCP socket TLS extends, "socket" means the TLS socket.

1. `tls.connect()`.
2. `new TLSSocket(options.socket)`.
3. `TLSSocket.prototype._wrapHandle` (wraps TCPWrap in TLSWrap).
4. Calls `socket._handle.start()` (or schedules it until wrap emits "connect").
5. Wrap emits "connect".
6. Socket emits "connect".
7. Socket validates the connection (handshake, certificate validation).
7. Socket emits "secureConnect".
7. Socket emits "secure".
22 changes: 22 additions & 0 deletions discoveries/undici.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Undici

## `connect` and writing the HTTP message

Undici's `fetch` waits for the underlying socket to emit the `connect` event _before_ it writes the HTTP message to it. That is different than in `http.request()`, where the HTTP message is written _immediately_ but gets buffered (`socket._pendinData`) and flushed once `connect` is emitted.

```
# Node.js v22
- dispatch() (lib/web/fetch/index.js:2129)
- agent.dispatch()
- Client.dispatch()
- _resume()
- connect()
- client[kConnector](options, cb*)
- Client.connect()
- net.connect(options) // no callback here
- socket.once('connect', cb*) // or "secureConnect"
```

> Note that the `cb` called on `connect` is _internal_. It's not provided as the connection callback to `net.connect()`, which makes it invisible to the interceptor.

Then `connectH1()` calls `writeH1()` to write the HTTP message.
11 changes: 11 additions & 0 deletions http/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"main": "../lib/node/interceptors/node/index.cjs",
"module": "../lib/node/interceptors/node/index.mjs",
"browser": null,
"exports": {
".": {
"import": "./../lib/node/interceptors/node/index.mjs",
"default": "./../lib/node/interceptors/node/index.cjs"
}
}
}
28 changes: 28 additions & 0 deletions net-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## Net-based Interception

Your task is to implement the network packet interception on the `net.Socket` level in Node.js. The underlying `SocketInterceptor` is already implemented at `src/interceptors/net/index.ts`. Below, are the outline of the necessary pieces.

## `SocketInterceptor`

- Responsible for patching `net.createConnection` and `net.connect`. Once patched, any created connection receive a mock socket instance.
- The mock socket instance pretends to successfully establish the connection with whatever connection options provided. This is so connecting to the non-existing hosts works in the mock-first scenario.
- The mock socket is wrapped in a `controller`. Controller is used by a higher-level interceptors to control the underlying socket. Since the data sent over socket is ambiguous, the controller only allows for `.connect()` (to mock the connection), `.passthrough()` (establish the connection as-is), and `.errorWith()` (abort the socket connection).
- Only for the user, a special socket reference is created and exposed as the `socket` argument in the `connection` listener. That special socket is meant to represent the intercepted socket instance _from the server's perspective_. This means that `socket.write()` on the actual socket (e.g. the one created by `net.connect` and then used via `http.ClientRequest`) are translated to the "data" events being emitted on the special server-side `socket`. The server-side socket is used ONLY for this purpose: to observe what the client writes _and_ write data _to_ the client from the `connection` listener as if it has been sent from the "server".

## `HttpRequestInterceptor`

- `src/interceptors/http/index.ts`
- This is a higher-level interceptor that relies on `SocketInterceptor` and routes all the intercepted socket packets through the HTTP request parser (we're using Node's parser).
- If the request parser tells us that the sent packet is an HTTP request message header, the interceptor then proceeds with handling that request. It emits the `request` event for the consumer, and uses the established `RequestController` to control the request flow (`.respondWith`, `.errorWith`, etc). These APIs are already created and functional, they need no change.
- The missing parts in the `HttpRequestInterceptor` is establishing the passthrough connection properly. For that, the socket controller accepted the `createConnection` option that constructs the bypassed socket. That works. But the socket hangs forever.

## Requirements

- Feel free to improve the existing classes but stay strictily within each class' responsibilities. Those must not leak.
- The overall architecture must compose: MockSocket -> SocketController -> Higher-level interceptors.
- Introduce absolutely no workarounds, zero.
- You will need to reference Node.js internals, particularly `net` and `http` modules to implement this functionality properly.
- Feel free to rely on Node.js internals knowledge and be clever, but not hacky.
- Feel free to `@ts-ignore` property access to Node.js internals, such as `socket.parser`.
- Do not comment your code.
- You can verify your changes via `pnpm test:node test/modules/http/response/http-response-delay.test.ts`. All the test cases must pass there.
76 changes: 76 additions & 0 deletions net-outline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
## High-level architecture overview

> The `net.Socket` constructor doesn't have all the context of `net.connect()` to be a viable layer of interception. All the network requests in Node.js go through `net.connect()` (HTTP, SMTP, etc).

```
http.request
Agent.createConnection()
net.Socket
```

```
net.connect()
net.Socket
```

## Pieces

- `SocketInterceptor`. A regular interceptor, patches `net.connect()` and emits the "connection" event to the consumers.
- `MockSocket` returned from the patched `net.connect()`. Always emulates a successful connection, allows spying on the data sent by the client (`socket.write()`) and pushing data to it from the interceptor.
- `SocketController` that, similar to `RequestController`, lets the user decide what to do with the intercepted socket connection. Unlike requests, there's no single `respondWith()` as the nature of data sent via the socket is ambiguous and is for the higher-level interceptors to determine and appropriately handle. There are still, however, methods to error the intercepted connection (`SocketController.errorWith()`) and perform it as-is (`SocketController.passthrough()`).
- A special `net.Socket` representation exposed in the "connection" listener. The `MockSocket` placeholder is _client-side_. To work with the intercepted socket from the server's perspective in the "connection" listener, another, special socket instance has to be provided. It acts as a mirror and allows the user to write data _to_ the client socket via `socket.write()` and listen to the data _sent_ from the client via `socket.on('data')`. This won't be possible with the client placeholder as `socket.on('data')` would represent the data received from the _server_ (and must still be emitted when the interceptor sends mock data to the socket).

This pretty much covers the mock-first scenarios. But when it comes to passthrough, it gets tricky. The passthrough socket will usually be created after some time the connection has been intercepted. During that time, the intercepted socket might have been acted upon (e.g. written to, changed via methods). The passthrough socket would have to be put _in the exact same state_ as the intercepted socket at the moment of calling `SocketController.passthrough()`.

This is where I think about employing an _object recorder_. It will use `Proxy` to record any changes done with the intercepted socket instance and then allow us to replay those changes on the passthrough socket.

## Problems

### Problem 1: Passthrough socket state

Node.js sockets are quite intricate. Even with deduped method/property recording via `AsyncLocalStorage`, it still arrives at the state where a change on the intercepted socket is attempted to be recorded when the recorder should've stopped altogether after `passthrough()`.

### Problem 2: The placeholder `MockSocket`

Even when the passthrough connection is established, the consumers, like `http.Agent`, have already received and stored the placeholder `MockSocket` as their socket. This means that for passthrough scenarios, the `MockSocket` instance would have to become a _proxy socket_, forwarding whatever data or changes from the passthrough socket.

What makes it more complex is that the consumers might still _act_ on the placeholder socket even after passthrough. Those acts would also have to be forwarded to the passthrough socket. It seems that the object recorder over the placeholder socket mustn't stop after passthrough, after all. Instead, it should replay the actions on the passthrough socket immediately if passthrough has been established.

```ts
this.#recorder = new ObjectRecorder(socket, {
filter(entry) {
if (this.#passthroughSocket) {
entry.replay(this.#passthroughSocket)
return false
}
}
})

// ...later on.
public passthrough(): net.Socket {
this.#passthroughSocket = this.options.createConnection()
this.#recorder.pause()
this.#recorder.replay(this.#passthroughSocket)
this.#recorder.resume()
}
```

> Above, I'm using the `filter()` capabilities of the object recorder to immediately replay the recorded action on the passthrough socket, if it exists.

Considering that the recorder should continue recording and replaying events, it looks like I need to implement some sort of _entry buffering_. While the previously recorded changes are being replayed on the passthrough socket, the consumers might produce _new changes_ to the placeholder socket. With the approach above, those changes will be lost. So the recorder have to be put in a buffering state until the replay is done, and then replay any buffered events immediately after that.

> Note: I would love to forego the object recording altogether in favor of something more elegant. I just don't know what that something might be. The actual connection cannot be established as it would error.

## Alternative to object recording

Alternatively, instead of having two separate sockets (placeholder and passthrough), only a _single_ passthrough socket can be used. With this approach, all that I have to do is this:

- Record and silence any errors occurring on the socket. Those are crucial to be replayed if the user decides to passthrough.
- Buffer the data sent from the client instead of sending it to the server. Any writes are still translated to the special `socket` for the "connection" listener.

Then, if passthrough is established, the socket would have to:

- Replay the errors, if any. This is for passing through to non-existing hosts.
- If no errors were emitted, replay all the writes that occurred so the original socket will receive them.

> The danger here are the write callbacks: `socket.write(chunk, encoding, callback)`. Those callbacks would have to be called for the mock-first scenario as consumer logic can and will depend on those callbacks. But calling them _again_ in passthrough will be a mistake.
Loading
Loading