Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules
coverage
dist
types
deno.lock
.vscode
.DS_Store
.eslintcache
Expand Down
39 changes: 37 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Returns Promise<Socket> (the upstream proxy socket)
- `deleteLength` applies to both `DELETE` and `OPTIONS` without content length; it sets `content-length: 0` and removes `transfer-encoding`.
- `proxyReq` event is intentionally skipped when request has `expect` header (`100-continue` advisory coverage).
- `selfHandleResponse: true` skips outgoing passes and auto-pipe; callers must finish the response in `proxyRes`.
- `proxyTimeout` aborts upstream request and surfaces timeout errors (tested as `ECONNRESET`).
- `proxyTimeout` aborts upstream request and surfaces timeout errors (tested as `ECONNRESET`). The timeout callback manually emits `'error'` on the outgoing `proxyReq` because bun's `proxyReq.destroy()` does not emit `'error'` on its own; `createErrorHandler` dedupes via a `fired` flag so node (which _does_ emit from destroy) only surfaces the error once.
- `followRedirects: true | number` enables native redirect following (301/302/303/307/308). `true` = max 5 hops, number = custom max.
- On 301/302/303 redirects, method changes to GET and request body is dropped.
- On 307/308 redirects, original method and body are preserved (body is buffered on first request for replay).
Expand Down Expand Up @@ -121,9 +121,11 @@ Returns Promise<Socket> (the upstream proxy socket)
- `agent` enables connection pooling/reuse via a custom `http.Agent`. Defaults to `false` (no agent).
- `followRedirects` enables automatic redirect following. `true` = max 5 hops; number = custom max. On 301/302/303 method changes to GET and body is dropped. On 307/308 method and body are preserved (body is buffered). Sensitive headers (`authorization`, `cookie`) are stripped on cross-origin redirects.
- `ssl` passes TLS options to `https.request` (e.g. `{ rejectUnauthorized: false }`).
- `AbortSignal` support is wired through `init.signal` (standard `RequestInit`), aborting the underlying `http.request`.
- `AbortSignal` support is wired through `init.signal` (standard `RequestInit`), aborting the underlying `http.request`. We drive the abort ourselves (manual `abort` listener + direct `reject`) rather than passing `signal` to `http.request`, because bun's `http.request({ signal })` silently ignores both pre-aborted and in-flight aborts.
- Multi-value request headers are preserved as arrays (not flattened by the `Headers` API).
- Body types `ArrayBuffer`, `TypedArray`, and `Blob` are properly converted to `Buffer` before sending.
- `ReadableStream` bodies are converted via `Readable.from(asyncIterator)` (not `Readable.fromWeb`) so controller errors surface as `'error'` events — `Readable.fromWeb()` swallows them on bun.
- Request timeouts reject the outer promise directly from the `setTimeout` callback in addition to calling `req.destroy(err)`, because bun's `req.destroy(err)` does not emit `'error'` on the request.

### `proxyUpgrade` semantics

Expand Down Expand Up @@ -167,6 +169,39 @@ pnpm test # Lint + typecheck + tests with coverage
- `followRedirects` is natively implemented (no external dependency). See behavioral notes below.
- HTTPS tests rely on local fixtures in `test/fixtures/agent2-*.pem`.

### Runtime compatibility gates (bun / deno)

Bun and Deno each have `node:http` / web-stream compatibility gaps that break a
handful of tests. Known limitations the proxy cannot work around in userland:

- **Bun client-abort propagation through `req.pipe(proxyReq)`**: once the pipe
is active, bun no longer emits `'close'` on `req` / `req.socket` / `res` /
`res.socket` when the downstream client drops the TCP connection, so the
proxy has no signal to destroy the upstream `proxyReq`. Tests that assert
this propagation (`should abort proxy request when client disconnects`,
`should abort upstream request when client disconnects via res close`) are
gated with `it.skipIf(isBun)`.

Shared flags live in `test/_utils.ts`:

```ts
import { isBun, isDeno } from "./_utils.ts";

// Bun: short note on the specific runtime limitation.
it.skipIf(isBun)("...", async () => {
/* ... */
});

// Bun & Deno: ...
it.skipIf(isBun || isDeno)("...", async () => {
/* ... */
});
```

Always leave a comment naming the exact limitation so the gate can be
re-evaluated when the runtime ships a fix. Do **not** redefine `isBun` /
`isDeno` inline — always import from `_utils.ts`.

## Tooling

| Tool | Command | Notes |
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"prepack": "pnpm run build",
"release": "pnpm test && pnpm build && changelogen --release && npm publish && git push --follow-tags",
"test": "pnpm lint && pnpm typecheck && vitest run --coverage",
"vitest": "vitest",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
Expand All @@ -29,7 +30,6 @@
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"@types/semver": "^7.7.1",
"@types/sse": "^0.0.0",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "^7.0.0-dev.20260421.1",
"@vitest/coverage-v8": "^4.1.5",
Expand All @@ -45,7 +45,6 @@
"semver": "^7.7.4",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sse": "^0.0.8",
"typescript": "^6.0.3",
"undici": "^8.1.0",
"vitest": "^4.1.5",
Expand Down
27 changes: 0 additions & 27 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 42 additions & 5 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,33 @@ function _toNodeStream(body: BodyInit | null | undefined): Readable | Buffer | u
return Buffer.from(body as ArrayBuffer);
}
if (body instanceof ReadableStream) {
return Readable.fromWeb(body as import("node:stream/web").ReadableStream);
return Readable.from(_webStreamToAsyncIterator(body));
}
if (body instanceof Blob) {
return Readable.fromWeb(body.stream() as import("node:stream/web").ReadableStream);
return Readable.from(_webStreamToAsyncIterator(body.stream()));
}
return Buffer.from(String(body));
}

// `Readable.fromWeb()` does not forward a `ReadableStream` controller error as
// an `'error'` event on the wrapped Node `Readable` under bun, so we drive
// the reader ourselves and let `Readable.from()` surface async-iterator
// exceptions as standard `'error'` events.
async function* _webStreamToAsyncIterator(
stream: ReadableStream<Uint8Array>,
): AsyncGenerator<Uint8Array> {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) return;
yield value;
}
} finally {
reader.releaseLock();
}
}

/** Normalize any body type to Buffer (or undefined) for redirect replay. */
async function _bufferBody(body: BodyInit | null | undefined): Promise<Buffer | undefined> {
if (!body) {
Expand Down Expand Up @@ -304,8 +323,12 @@ function _sendRequest(
reqOpts.port = addr.port;
}

if (opts.signal) {
reqOpts.signal = opts.signal;
// Bun's `http.request({ signal })` silently ignores both pre-aborted and
// in-flight aborts — we drive the abort ourselves on all runtimes for
// consistent behavior.
if (opts.signal?.aborted) {
reject(opts.signal.reason ?? new DOMException("aborted", "AbortError"));
return;
}

if (opts.ssl) {
Expand Down Expand Up @@ -366,9 +389,23 @@ function _sendRequest(

req.on("error", reject);

if (opts.signal) {
const onAbort = () => {
const err = opts.signal!.reason ?? new DOMException("aborted", "AbortError");
req.destroy(err);
reject(err);
};
opts.signal.addEventListener("abort", onAbort, { once: true });
req.on("close", () => opts.signal!.removeEventListener("abort", onAbort));
}

if (opts.timeout) {
req.setTimeout(opts.timeout, () => {
req.destroy(new Error("Proxy request timed out"));
// Also reject directly — `req.destroy(err)` does not emit `'error'`
// on bun, so relying on `req.on("error", reject)` alone hangs.
const err = new Error("Proxy request timed out");
req.destroy(err);
reject(err);
});
}

Expand Down
17 changes: 17 additions & 0 deletions src/middleware/web-incoming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export const stream = defineProxyMiddleware((req, res, options, server, head, ca
if (options.proxyTimeout) {
proxyReq.setTimeout(options.proxyTimeout, function () {
proxyReq.destroy();
// Bun's `proxyReq.destroy()` does not emit an `'error'` event on the
// request, so surface `ECONNRESET` manually. `createErrorHandler`
// dedupes duplicate fires that node may also dispatch from destroy.
proxyReq.emit("error", Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }));
});
}

Expand All @@ -128,7 +132,15 @@ export const stream = defineProxyMiddleware((req, res, options, server, head, ca
proxyReq.on("error", proxyError);

function createErrorHandler(proxyReq: ClientRequest, url: URL | ProxyTargetDetailed) {
let fired = false;
return function proxyError(err: Error) {
// Dedupe: `proxyTimeout` manually dispatches a synthetic `ECONNRESET`
// for bun (where `proxyReq.destroy()` does not emit `'error'`), and on
// node the same destroy also emits — we only want to surface the error
// once per request.
if (fired) return;
fired = true;

if (!req.socket?.writable && (err as NodeJS.ErrnoException).code === "ECONNRESET") {
server.emit("econnreset", err, req, res, url);
return proxyReq.destroy();
Expand Down Expand Up @@ -232,6 +244,11 @@ export const stream = defineProxyMiddleware((req, res, options, server, head, ca
if (options.proxyTimeout) {
redirectReq.setTimeout(options.proxyTimeout, () => {
redirectReq.destroy();
// Same bun workaround as the initial request path above.
redirectReq.emit(
"error",
Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }),
);
});
}

Expand Down
9 changes: 9 additions & 0 deletions test/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import net from "node:net";
import type { AddressInfo } from "node:net";
import * as httpProxy from "../src/index.ts";

/**
* Bun and Deno each have `node:http`/web-stream compatibility gaps that break
* individual tests. Gate affected tests with `it.skipIf(isBun)` / `it.skipIf(isDeno)`
* and add a short comment describing the runtime limitation. Re-enable when
* the upstream runtime fixes it.
*/
export const isBun = !!(globalThis as { Bun?: unknown }).Bun;
export const isDeno = !!(globalThis as { Deno?: unknown }).Deno;

export function listenOn(server: http.Server | https.Server | net.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", reject);
Expand Down
Loading
Loading