diff --git a/.gitignore b/.gitignore index 4762fa5..242acad 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules coverage dist types +deno.lock .vscode .DS_Store .eslintcache diff --git a/AGENTS.md b/AGENTS.md index 69dd640..575b84a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Returns Promise (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). @@ -121,9 +121,11 @@ Returns Promise (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 @@ -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 | diff --git a/package.json b/package.json index ac3c536..e23c49e 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2642962..2f472a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: '@types/semver': specifier: ^7.7.1 version: 7.7.1 - '@types/sse': - specifier: ^0.0.0 - version: 0.0.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -71,9 +68,6 @@ importers: socket.io-client: specifier: ^4.8.3 version: 4.8.3 - sse: - specifier: ^0.0.8 - version: 0.0.8 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1016,9 +1010,6 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/sse@0.0.0': - resolution: {integrity: sha512-5gFwbzDsaCze6/SYNwOmk/F24+gtXwTX3at12rJMDo7+VLi3jI83j5h5IkdXEvGakTkVIF9VQBxWOensz+LBWQ==} - '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1986,10 +1977,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - options@0.0.6: - resolution: {integrity: sha512-bOj3L1ypm++N+n7CEbbe473A414AB7z+amKYshRb//iuL3MpdDCLhPnw6aVTdKB9g5ZRVHIEp8eUln6L2NUStg==} - engines: {node: '>=0.4.0'} - oxfmt@0.46.0: resolution: {integrity: sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2252,10 +2239,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sse@0.0.8: - resolution: {integrity: sha512-cviG7JH31TUhZeaEVhac3zTzA+2FwA7qvHziAHpb7mC7RNVJ/RbHN+6LIGsS2ugP4o2H15DWmrSMK+91CboIcg==} - engines: {node: '>=0.4.0'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3145,10 +3128,6 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 25.5.0 - '@types/sse@0.0.0': - dependencies: - '@types/node': 25.5.0 - '@types/unist@3.0.3': {} '@types/ws@8.18.1': @@ -4407,8 +4386,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - options@0.0.6: {} - oxfmt@0.46.0: dependencies: tinypool: 2.1.0 @@ -4753,10 +4730,6 @@ snapshots: split2@4.2.0: {} - sse@0.0.8: - dependencies: - options: 0.0.6 - stackback@0.0.2: {} std-env@3.10.0: {} diff --git a/src/fetch.ts b/src/fetch.ts index 9996029..f511f17 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -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, +): AsyncGenerator { + 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 { if (!body) { @@ -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) { @@ -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); }); } diff --git a/src/middleware/web-incoming.ts b/src/middleware/web-incoming.ts index 97ebcea..36b744f 100644 --- a/src/middleware/web-incoming.ts +++ b/src/middleware/web-incoming.ts @@ -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" })); }); } @@ -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(); @@ -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" }), + ); }); } diff --git a/test/_utils.ts b/test/_utils.ts index 74aeb73..a00c92b 100644 --- a/test/_utils.ts +++ b/test/_utils.ts @@ -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 { return new Promise((resolve, reject) => { server.once("error", reject); diff --git a/test/http-proxy.test.ts b/test/http-proxy.test.ts index 20f72d2..0704a0d 100644 --- a/test/http-proxy.test.ts +++ b/test/http-proxy.test.ts @@ -4,10 +4,9 @@ import http from "node:http"; import net from "node:net"; import * as ws from "ws"; import * as io from "socket.io"; -import SSE from "sse"; import ioClient from "socket.io-client"; import type { AddressInfo } from "node:net"; -import { listenOn, proxyListen } from "./_utils.ts"; +import { isBun, isDeno, listenOn, proxyListen } from "./_utils.ts"; // Source: https://github.com/http-party/node-http-proxy/blob/master/test/lib-http-proxy-test.js @@ -61,7 +60,16 @@ describe("http-proxy", () => { describe("#createProxyServer using the web-incoming passes", () => { it("should proxy sse", async () => { - const source = http.createServer(); + const source = http.createServer((_req, res) => { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + res.write(":ok\n\n"); + res.write("data: Hello over SSE\n\n"); + res.end(); + }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ @@ -69,12 +77,6 @@ describe("http-proxy", () => { }); const proxyPort = await proxyListen(proxy); - const sse = new SSE(source, { path: "/" }); - sse.on("connection", (client) => { - client.send("Hello over SSE"); - client.close(); - }); - const options = { hostname: "127.0.0.1", port: proxyPort, @@ -98,7 +100,10 @@ describe("http-proxy", () => { await promise; }); - it("should close downstream SSE stream when upstream aborts", async () => { + // Deno: upstream `res.socket.destroy()` does not propagate through deno's + // `node:http` shim into a `'close'` event on the downstream `res`, so the + // client never observes the upstream abort and the test hangs. + it.skipIf(isDeno)("should close downstream SSE stream when upstream aborts", async () => { const source = http.createServer((_, res) => { res.writeHead(200, { "content-type": "text/event-stream", @@ -164,61 +169,71 @@ describe("http-proxy", () => { expect(gotFirstChunk).toBe(true); }); - it("should destroy upstream proxy request when client aborts", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - - // Track whether the upstream request was properly destroyed - let upstreamReqDestroyed = false; + // Bun: `http.ServerResponse` does not emit `'close'` when the underlying + // client socket is destroyed, so the proxy's `res.on("close", ...)` hook + // never fires and the upstream `proxyReq.destroy()` is never called. + // Deno: neither `req.close`, `res.close`, nor `req.socket.close` fires on + // the server when the client destroys its socket — deno's `node:http` + // server does not propagate client disconnects to the request/response + // objects, so the proxy has no signal to destroy the upstream request. + it.skipIf(isBun || isDeno)( + "should destroy upstream proxy request when client aborts", + async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Track whether the upstream request was properly destroyed + let upstreamReqDestroyed = false; + + const source = http.createServer((req, res) => { + // SSE-like long-lived response + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.write(":ok\n\n"); - const source = http.createServer((req, res) => { - // SSE-like long-lived response - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", + req.socket.on("close", () => { + upstreamReqDestroyed = true; + }); }); - res.write(":ok\n\n"); + const sourcePort = await listenOn(source); - req.socket.on("close", () => { - upstreamReqDestroyed = true; + const proxy = httpProxy.createProxyServer({ + target: "http://127.0.0.1:" + sourcePort, }); - }); - const sourcePort = await listenOn(source); + const proxyPort = await proxyListen(proxy); - const proxy = httpProxy.createProxyServer({ - target: "http://127.0.0.1:" + sourcePort, - }); - const proxyPort = await proxyListen(proxy); + const timeout = setTimeout(() => { + reject(new Error("Timed out: upstream request was not destroyed after client abort")); + }, 2000); - const timeout = setTimeout(() => { - reject(new Error("Timed out: upstream request was not destroyed after client abort")); - }, 2000); - - // Make a request and abort it after receiving the first chunk - const clientReq = http.request( - { hostname: "127.0.0.1", port: proxyPort, method: "GET" }, - (res) => { - res.once("data", () => { - // Client received data, now abort the connection - clientReq.destroy(); - }); - }, - ); - clientReq.end(); + // Make a request and abort it after receiving the first chunk + const clientReq = http.request( + { hostname: "127.0.0.1", port: proxyPort, method: "GET" }, + (res) => { + res.once("data", () => { + // Client received data, now abort the connection + clientReq.destroy(); + }); + }, + ); + clientReq.end(); - // Poll for upstream destruction - const check = setInterval(() => { - if (upstreamReqDestroyed) { - clearInterval(check); - clearTimeout(timeout); - source.close(); - proxy.close(() => resolve()); - } - }, 20); + // Poll for upstream destruction + const check = setInterval(() => { + if (upstreamReqDestroyed) { + clearInterval(check); + clearTimeout(timeout); + source.close(); + proxy.close(() => resolve()); + } + }, 20); - await promise; - expect(upstreamReqDestroyed).toBe(true); - }); + await promise; + expect(upstreamReqDestroyed).toBe(true); + }, + ); it("should make the request on pipe and finish it", async () => { const source = http.createServer(); @@ -258,50 +273,56 @@ describe("http-proxy", () => { }); describe("#createProxyServer using the web-incoming passes", () => { - it("should make the request, handle response and finish it", async () => { - const source = http.createServer((req, res) => { - expect(req.method).to.eql("GET"); - expect(Number.parseInt(req.headers.host!.split(":")[1]!)).to.eql(proxyPort); - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("Hello from " + (source.address()! as any).port); - }); - const sourcePort = await listenOn(source); + // Bun & Deno: `http.IncomingMessage.rawHeaders` is always lowercased, so + // `preserveHeaderKeyCase: true` has no original casing to preserve and the + // case-sensitive `rawHeaders.indexOf("Content-Type")` check fails. + it.skipIf(isBun || isDeno)( + "should make the request, handle response and finish it", + async () => { + const source = http.createServer((req, res) => { + expect(req.method).to.eql("GET"); + expect(Number.parseInt(req.headers.host!.split(":")[1]!)).to.eql(proxyPort); + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Hello from " + (source.address()! as any).port); + }); + const sourcePort = await listenOn(source); - const proxy = httpProxy.createProxyServer({ - target: "http://127.0.0.1:" + sourcePort, - preserveHeaderKeyCase: true, - }); - const proxyPort = await proxyListen(proxy); + const proxy = httpProxy.createProxyServer({ + target: "http://127.0.0.1:" + sourcePort, + preserveHeaderKeyCase: true, + }); + const proxyPort = await proxyListen(proxy); + + const { promise, resolve } = Promise.withResolvers(); + http + .request( + { + hostname: "127.0.0.1", + port: proxyPort, + method: "GET", + }, + (res) => { + expect(res.statusCode).to.eql(200); + expect(res.headers["content-type"]).to.eql("text/plain"); + if (res.rawHeaders != undefined) { + expect(res.rawHeaders.indexOf("Content-Type")).not.to.eql(-1); + expect(res.rawHeaders.indexOf("text/plain")).not.to.eql(-1); + } - const { promise, resolve } = Promise.withResolvers(); - http - .request( - { - hostname: "127.0.0.1", - port: proxyPort, - method: "GET", - }, - (res) => { - expect(res.statusCode).to.eql(200); - expect(res.headers["content-type"]).to.eql("text/plain"); - if (res.rawHeaders != undefined) { - expect(res.rawHeaders.indexOf("Content-Type")).not.to.eql(-1); - expect(res.rawHeaders.indexOf("text/plain")).not.to.eql(-1); - } - - res.on("data", (data) => { - expect(data.toString()).to.eql("Hello from " + sourcePort); - }); + res.on("data", (data) => { + expect(data.toString()).to.eql("Hello from " + sourcePort); + }); - res.on("end", () => { - source.close(); - proxy.close(resolve); - }); - }, - ) - .end(); - await promise; - }); + res.on("end", () => { + source.close(); + proxy.close(resolve); + }); + }, + ) + .end(); + await promise; + }, + ); }); describe("#createProxyServer() method with error response", () => { @@ -336,7 +357,12 @@ describe("http-proxy", () => { }); describe("#createProxyServer setting the correct timeout value", () => { - it("should hang up the socket at the timeout", async () => { + // Bun: `req.socket.setTimeout(ms, cb)` registers the callback but never + // invokes it, so the `timeout` middleware never destroys the socket. + // Deno: the abort path works, but errors surface as `DOMException` + // `ABORT_ERR` (code 20) instead of node-style `"ECONNRESET"`, failing + // the `err.code` assertion. + it.skipIf(isBun || isDeno)("should hang up the socket at the timeout", async () => { const { promise, resolve } = Promise.withResolvers(); const source = http.createServer(function (_req, res) { @@ -483,7 +509,10 @@ describe("http-proxy", () => { }); describe("#createProxyServer using the ws-incoming passes", () => { - it("should proxy the websockets stream", async () => { + // Bun: the socket yielded by `http.Server`'s `'upgrade'` event silently + // discards writes — `socket.write("HTTP/1.1 101 ...")` returns `true` but + // the bytes never reach the client, so the WS handshake never completes. + it.skipIf(isBun)("should proxy the websockets stream", async () => { const destiny = new ws.WebSocketServer({ port: 0 }); await new Promise((r) => destiny.on("listening", r)); const sourcePort = (destiny.address() as AddressInfo).port; @@ -519,7 +548,9 @@ describe("http-proxy", () => { await promise; }); - it("should emit error on proxy error", async () => { + // Bun & Deno: `ws.WebSocket` emits DOM-style `ErrorEvent` objects (from the + // global `WebSocket`) instead of Node `Error`, so `instanceof Error` fails. + it.skipIf(isBun || isDeno)("should emit error on proxy error", async () => { const { promise, resolve } = Promise.withResolvers(); const proxy = httpProxy.createProxyServer({ @@ -556,69 +587,81 @@ describe("http-proxy", () => { await promise; }); - it("should close client socket if upstream is closed before upgrade", async () => { - const { resolve, promise } = Promise.withResolvers(); - - const server = http.createServer(); - server.on("upgrade", function (req, socket, head) { - const response = ["HTTP/1.1 404 Not Found", "Content-type: text/html", "", ""]; - socket.write(response.join("\r\n")); - socket.end(); - }); - const sourcePort = await listenOn(server); - - const proxy = httpProxy.createProxyServer({ - // note: we don't ever listen on this port - target: "ws://127.0.0.1:" + sourcePort, - ws: true, - }); - const proxyPort = await proxyListen(proxy); - const proxyServer = proxy; - const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); - - client.on("open", () => { - client.send("hello there"); - }); + // Bun & Deno: same upgrade-socket-write limitation as above — the 101 bytes + // written by the proxy never reach the client, and `ws.WebSocket` emits + // DOM `ErrorEvent` instead of `Error`, failing `instanceof Error`. + it.skipIf(isBun || isDeno)( + "should close client socket if upstream is closed before upgrade", + async () => { + const { resolve, promise } = Promise.withResolvers(); + + const server = http.createServer(); + server.on("upgrade", function (req, socket, head) { + const response = ["HTTP/1.1 404 Not Found", "Content-type: text/html", "", ""]; + socket.write(response.join("\r\n")); + socket.end(); + }); + const sourcePort = await listenOn(server); - client.on("error", (err) => { - expect(err).toBeInstanceOf(Error); - proxyServer.close(resolve); - }); + const proxy = httpProxy.createProxyServer({ + // note: we don't ever listen on this port + target: "ws://127.0.0.1:" + sourcePort, + ws: true, + }); + const proxyPort = await proxyListen(proxy); + const proxyServer = proxy; + const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); - await promise; - }); + client.on("open", () => { + client.send("hello there"); + }); - it("should not crash when upstream response errors during non-upgrade pipe", async () => { - // Regression: https://github.com/http-party/node-http-proxy/pull/1439 - const { resolve, promise } = Promise.withResolvers(); + client.on("error", (err) => { + expect(err).toBeInstanceOf(Error); + proxyServer.close(resolve); + }); - const server = http.createServer((req, res) => { - res.writeHead(502); - res.write("partial"); - setTimeout(() => req.socket.destroy(), 10); - }); - const sourcePort = await listenOn(server); + await promise; + }, + ); + + // Bun & Deno: the proxy's non-upgrade response is piped to the upgrade socket + // returned from `http.Server`, and those writes are silently dropped — + // the client never receives the 502 payload and the flow hangs. + it.skipIf(isBun || isDeno)( + "should not crash when upstream response errors during non-upgrade pipe", + async () => { + // Regression: https://github.com/http-party/node-http-proxy/pull/1439 + const { resolve, promise } = Promise.withResolvers(); + + const server = http.createServer((req, res) => { + res.writeHead(502); + res.write("partial"); + setTimeout(() => req.socket.destroy(), 10); + }); + const sourcePort = await listenOn(server); - const proxy = httpProxy.createProxyServer({ - target: "ws://127.0.0.1:" + sourcePort, - ws: true, - }); + const proxy = httpProxy.createProxyServer({ + target: "ws://127.0.0.1:" + sourcePort, + ws: true, + }); - proxy.on("error", () => { - // Error handler - the fix ensures this is called instead of crashing - }); + proxy.on("error", () => { + // Error handler - the fix ensures this is called instead of crashing + }); - const proxyPort = await proxyListen(proxy); + const proxyPort = await proxyListen(proxy); - const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); - client.on("error", () => {}); - client.on("close", () => { - proxy.close(resolve); - }); + const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); + client.on("error", () => {}); + client.on("close", () => { + proxy.close(resolve); + }); - await promise; - server.close(); - }); + await promise; + server.close(); + }, + ); it("should proxy a socket.io stream", async () => { const { resolve, promise } = Promise.withResolvers(); @@ -660,45 +703,54 @@ describe("http-proxy", () => { await promise; }); - it("should emit open and close events when socket.io client connects and disconnects", async () => { - const { resolve, promise } = Promise.withResolvers(); + // Bun: socket.io eventually upgrades to WebSocket, but the proxy's 101 + // response is dropped by bun's upgrade socket, so `'open'`/`'close'` are + // never emitted by the proxy. + it.skipIf(isBun)( + "should emit open and close events when socket.io client connects and disconnects", + async () => { + const { resolve, promise } = Promise.withResolvers(); - const server = http.createServer(); - const sourcePort = await listenOn(server); + const server = http.createServer(); + const sourcePort = await listenOn(server); - const proxy = httpProxy.createProxyServer({ - target: "ws://127.0.0.1:" + sourcePort, - ws: true, - }); - const proxyPort = await proxyListen(proxy); - const proxyServer = proxy; - const destiny = new io.Server(server); - - function startSocketIo() { - const client = ioClient("ws://127.0.0.1:" + proxyPort); - client.on("connect", () => { - client.disconnect(); + const proxy = httpProxy.createProxyServer({ + target: "ws://127.0.0.1:" + sourcePort, + ws: true, }); - } - let count = 0; + const proxyPort = await proxyListen(proxy); + const proxyServer = proxy; + const destiny = new io.Server(server); + + function startSocketIo() { + const client = ioClient("ws://127.0.0.1:" + proxyPort); + client.on("connect", () => { + client.disconnect(); + }); + } + let count = 0; - proxyServer.on("open", () => { - count += 1; - }); + proxyServer.on("open", () => { + count += 1; + }); - proxyServer.on("close", () => { - destiny.close(); - server.close(); - proxyServer.close(() => {}); - expect(count).toBe(1); - resolve(); - }); + proxyServer.on("close", () => { + destiny.close(); + server.close(); + proxyServer.close(() => {}); + expect(count).toBe(1); + resolve(); + }); - startSocketIo(); - await promise; - }); + startSocketIo(); + await promise; + }, + ); - it("should pass all set-cookie headers to client", async () => { + // Bun: the proxy's 101 Switching Protocols response (including the + // Set-Cookie headers) is silently dropped by bun's upgrade socket, so + // the client never opens and the `'upgrade'` event never fires. + it.skipIf(isBun)("should pass all set-cookie headers to client", async () => { const { resolve, promise } = Promise.withResolvers(); const destiny = new ws.WebSocketServer({ port: 0 }); @@ -731,7 +783,10 @@ describe("http-proxy", () => { await promise; }); - it("should detect a proxyReq event and modify headers", async () => { + // Bun: the proxy's 101 Switching Protocols response is silently dropped + // by bun's upgrade socket, so the WS client never opens and the exchange + // that would assert on the forwarded `x-special-proxy-header` can't run. + it.skipIf(isBun)("should detect a proxyReq event and modify headers", async () => { const { promise, resolve } = Promise.withResolvers(); const destiny = new ws.WebSocketServer({ port: 0 }); @@ -775,81 +830,91 @@ describe("http-proxy", () => { await promise; }); - it("should forward frames with single frame payload (including on node 4.x)", async () => { - const { resolve, promise } = await Promise.withResolvers(); - const payload = Array.from({ length: 65_529 }).join("0"); - - const destiny = new ws.WebSocketServer({ port: 0 }); - await new Promise((r) => destiny.on("listening", r)); - const sourcePort = (destiny.address() as AddressInfo).port; - - const proxy = httpProxy.createProxyServer({ - target: "ws://127.0.0.1:" + sourcePort, - ws: true, - }); - const proxyPort = await proxyListen(proxy); - const proxyServer = proxy; - - const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); - - client.on("open", () => { - client.send(payload); - }); + // Bun: the proxy's 101 Switching Protocols response is silently dropped + // by bun's upgrade socket, so no frames can be exchanged through the proxy. + it.skipIf(isBun)( + "should forward frames with single frame payload (including on node 4.x)", + async () => { + const { resolve, promise } = await Promise.withResolvers(); + const payload = Array.from({ length: 65_529 }).join("0"); + + const destiny = new ws.WebSocketServer({ port: 0 }); + await new Promise((r) => destiny.on("listening", r)); + const sourcePort = (destiny.address() as AddressInfo).port; + + const proxy = httpProxy.createProxyServer({ + target: "ws://127.0.0.1:" + sourcePort, + ws: true, + }); + const proxyPort = await proxyListen(proxy); + const proxyServer = proxy; - client.on("message", (msg) => { - expect(msg.toString("utf8")).toBe("Hello over websockets"); - client.close(); - destiny.close(); - proxyServer.close(resolve); - }); + const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); - destiny.on("connection", (socket) => { - socket.on("message", (msg) => { - expect(msg.toString("utf8")).toBe(payload); - socket.send("Hello over websockets"); + client.on("open", () => { + client.send(payload); }); - }); - await promise; - }); - - it("should forward continuation frames with big payload (including on node 4.x)", async () => { - const { promise, resolve } = Promise.withResolvers(); - const payload = Array.from({ length: 65_530 }).join("0"); + client.on("message", (msg) => { + expect(msg.toString("utf8")).toBe("Hello over websockets"); + client.close(); + destiny.close(); + proxyServer.close(resolve); + }); - const destiny = new ws.WebSocketServer({ port: 0 }); - await new Promise((r) => destiny.on("listening", r)); - const sourcePort = (destiny.address() as AddressInfo).port; + destiny.on("connection", (socket) => { + socket.on("message", (msg) => { + expect(msg.toString("utf8")).toBe(payload); + socket.send("Hello over websockets"); + }); + }); - const proxy = httpProxy.createProxyServer({ - target: "ws://127.0.0.1:" + sourcePort, - ws: true, - }); - const proxyPort = await proxyListen(proxy); - const proxyServer = proxy; + await promise; + }, + ); + + // Bun: the proxy's 101 Switching Protocols response is silently dropped + // by bun's upgrade socket, so no frames can be exchanged through the proxy. + it.skipIf(isBun)( + "should forward continuation frames with big payload (including on node 4.x)", + async () => { + const { promise, resolve } = Promise.withResolvers(); + const payload = Array.from({ length: 65_530 }).join("0"); + + const destiny = new ws.WebSocketServer({ port: 0 }); + await new Promise((r) => destiny.on("listening", r)); + const sourcePort = (destiny.address() as AddressInfo).port; + + const proxy = httpProxy.createProxyServer({ + target: "ws://127.0.0.1:" + sourcePort, + ws: true, + }); + const proxyPort = await proxyListen(proxy); + const proxyServer = proxy; - const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); + const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); - client.on("open", () => { - client.send(payload); - }); + client.on("open", () => { + client.send(payload); + }); - client.on("message", (msg) => { - expect(msg.toString("utf8")).toBe("Hello over websockets"); - client.close(); - destiny.close(); - proxyServer.close(resolve); - }); + client.on("message", (msg) => { + expect(msg.toString("utf8")).toBe("Hello over websockets"); + client.close(); + destiny.close(); + proxyServer.close(resolve); + }); - destiny.on("connection", (socket) => { - socket.on("message", (msg) => { - expect(msg.toString("utf8")).toBe(payload); - socket.send("Hello over websockets"); + destiny.on("connection", (socket) => { + socket.on("message", (msg) => { + expect(msg.toString("utf8")).toBe(payload); + socket.send("Hello over websockets"); + }); }); - }); - await promise; - }); + await promise; + }, + ); it("should not crash when client socket errors before upstream upgrade (issue #79)", async () => { const { promise, resolve } = Promise.withResolvers(); diff --git a/test/middleware/web-incoming.test.ts b/test/middleware/web-incoming.test.ts index 4bac177..8105e50 100644 --- a/test/middleware/web-incoming.test.ts +++ b/test/middleware/web-incoming.test.ts @@ -10,7 +10,7 @@ import { stubMiddlewareOptions, stubProxyServer, } from "../_stubs.ts"; -import { listenOn, proxyListen } from "../_utils.ts"; +import { isBun, listenOn, proxyListen } from "../_utils.ts"; // Source: https://github.com/http-party/node-http-proxy/blob/master/test/lib-http-proxy-passes-web-incoming-test.js @@ -157,6 +157,10 @@ describe("#stream middleware direct tests", () => { connection: { remoteAddress: "127.0.0.1" }, socket: { remoteAddress: "127.0.0.1", destroyed: false }, }); + // Real `IncomingMessage` for `GET` ends immediately after headers; without + // this, piping an unended `PassThrough` into the failing proxyReq on bun + // silences the `ECONNREFUSED` event (no writes ever complete). + stubReq.end(); const stubRes = Object.assign(new (await import("node:stream")).PassThrough(), { headersSent: false, finished: false, @@ -662,7 +666,12 @@ describe("#createProxyServer.web() using own http server", () => { }); describe("#client abort propagation", () => { - it("should abort proxy request when client disconnects", async () => { + // Bun: `req.pipe(proxyReq)` silently suppresses subsequent `'close'` events + // on `req` / `req.socket` / `res` / `res.socket` when the downstream client + // aborts the TCP connection — every signal we could use to detect the + // disconnect and destroy the upstream proxyReq is lost once the pipe is + // active. No userland workaround; tracked against bun's node:http compat. + it.skipIf(isBun)("should abort proxy request when client disconnects", async () => { const { resolve, promise } = Promise.withResolvers(); // Target server that waits long enough for client to abort @@ -1000,37 +1009,43 @@ describe("#req-aborted-memory-leak", () => { await promise; }); - it("should abort upstream request when client disconnects via res close", async () => { - const { promise, resolve } = Promise.withResolvers(); - - let upstreamAborted = false; - const source = http.createServer((req, res) => { - res.writeHead(200, { "content-type": "text/plain" }); - res.write("start"); - req.on("close", () => { - upstreamAborted = true; + // Bun: same pipe-suppresses-close-events limitation as `#client abort + // propagation` — the downstream abort is never observable from the proxy + // handler once `req.pipe(proxyReq)` is active. + it.skipIf(isBun)( + "should abort upstream request when client disconnects via res close", + async () => { + const { promise, resolve } = Promise.withResolvers(); + + let upstreamAborted = false; + const source = http.createServer((req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.write("start"); + req.on("close", () => { + upstreamAborted = true; + }); }); - }); - const sourcePort = await listenOn(source); + const sourcePort = await listenOn(source); - const proxy = httpProxy.createProxyServer({ - target: `http://127.0.0.1:${sourcePort}`, - }); - const proxyPort = await proxyListen(proxy); + const proxy = httpProxy.createProxyServer({ + target: `http://127.0.0.1:${sourcePort}`, + }); + const proxyPort = await proxyListen(proxy); - const clientReq = http.get(`http://127.0.0.1:${proxyPort}/stream`, (res) => { - res.once("data", () => { - // Client received first chunk; now abort - clientReq.destroy(); + const clientReq = http.get(`http://127.0.0.1:${proxyPort}/stream`, (res) => { + res.once("data", () => { + // Client received first chunk; now abort + clientReq.destroy(); - setTimeout(() => { - expect(upstreamAborted).to.eql(true); - source.close(); - proxy.close(resolve); - }, 100); + setTimeout(() => { + expect(upstreamAborted).to.eql(true); + source.close(); + proxy.close(resolve); + }, 100); + }); }); - }); - await promise; - }); + await promise; + }, + ); });