Repository: unjs/httpxy Branch: main Commit: 9dc2425ecb48 Files: 56 Total size: 324.4 KB Directory structure: gitextract_lci7uuca/ ├── .editorconfig ├── .github/ │ └── workflows/ │ ├── autofix.yml │ └── checks.yml ├── .gitignore ├── .oxfmtrc.json ├── .oxlintrc.json ├── AGENTS.md ├── CHANGELOG.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── bench/ │ ├── Dockerfile │ ├── bench.ts │ ├── package.json │ ├── src/ │ │ ├── fast-proxy.ts │ │ ├── fastify.ts │ │ ├── http-proxy-3.ts │ │ ├── http-proxy.ts │ │ ├── httpxy-fetch.ts │ │ ├── httpxy-server.ts │ │ └── target.ts │ └── test.ts ├── build.config.mjs ├── package.json ├── playground/ │ └── index.ts ├── pnpm-workspace.yaml ├── renovate.json ├── src/ │ ├── _utils.ts │ ├── fetch.ts │ ├── index.ts │ ├── middleware/ │ │ ├── _utils.ts │ │ ├── web-incoming.ts │ │ ├── web-outgoing.ts │ │ └── ws-incoming.ts │ ├── server.ts │ ├── types.ts │ └── ws.ts ├── test/ │ ├── _stubs.ts │ ├── _utils.test.ts │ ├── _utils.ts │ ├── fetch.test.ts │ ├── fixtures/ │ │ ├── agent2-cert.pem │ │ └── agent2-key.pem │ ├── http-proxy.test.ts │ ├── http2-proxy.test.ts │ ├── https-proxy.test.ts │ ├── index.test.ts │ ├── middleware/ │ │ ├── web-incoming.test.ts │ │ ├── web-outgoing.test.ts │ │ └── ws-incoming.test.ts │ ├── server.test.ts │ ├── types.test-d.ts │ ├── ws-destroyed-socket.test.ts │ └── ws.test.ts ├── tsconfig.json └── vitest.config.mjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true charset = utf-8 [*.js] indent_style = space indent_size = 2 [{package.json,*.yml,*.cjson}] indent_style = space indent_size = 2 ================================================ FILE: .github/workflows/autofix.yml ================================================ name: autofix.ci on: { push: {}, pull_request: {} } permissions: { contents: read } jobs: autofix: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - run: npm i -fg corepack && corepack enable - uses: actions/setup-node@v6 with: { node-version: lts/*, cache: "pnpm" } - run: pnpm install - run: pnpm fmt - uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 with: { commit-message: "chore: apply automated updates" } ================================================ FILE: .github/workflows/checks.yml ================================================ name: checks on: { push: {}, pull_request: {} } jobs: checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - run: npm i -fg corepack && corepack enable - uses: actions/setup-node@v6 with: { node-version: lts/*, cache: "pnpm" } - run: pnpm install - run: pnpm typecheck - run: pnpm vitest --coverage - run: pnpm run lint - uses: codecov/codecov-action@v6 with: { token: "${{ secrets.CODECOV_TOKEN }}" } ================================================ FILE: .gitignore ================================================ node_modules coverage dist types .vscode .DS_Store .eslintcache *.log* *.env* ================================================ FILE: .oxfmtrc.json ================================================ { "$schema": "https://unpkg.com/oxfmt/configuration_schema.json" } ================================================ FILE: .oxlintrc.json ================================================ { "$schema": "https://unpkg.com/oxlint/configuration_schema.json", "plugins": ["unicorn", "typescript", "oxc"], "rules": { "no-unused-vars": "off" } } ================================================ FILE: AGENTS.md ================================================ # httpxy — Agent Guide Full-featured HTTP/WebSocket proxy for Node.js. Zero production dependencies. Originally forked from [http-party/node-http-proxy](https://github.com/http-party/node-http-proxy). ## API - **`createProxyServer(opts)`** / **`ProxyServer`** — Traditional event-driven proxy server with middleware pipeline - **`proxyFetch(addr, input, init?)`** — Web-standard `Request`/`Response` interface for proxying individual requests - **`proxyUpgrade(addr, req, socket, head?, opts?)`** — Standalone WebSocket upgrade proxy without a `ProxyServer` instance ## Source Architecture (`src/`) ``` src/ ├── index.ts — Re-exports (entry point) ├── types.ts — ProxyTarget, ProxyServerOptions, ProxyTargetDetailed ├── server.ts — ProxyServer class (EventEmitter), createProxyServer() ├── fetch.ts — proxyFetch() using Node.js http module → Web Response ├── upgrade.ts — proxyUpgrade() standalone WebSocket upgrade proxy ├── _utils.ts — setupOutgoing(), setupSocket(), joinURL(), cookie/header helpers └── middleware/ ├── _utils.ts — Middleware type definitions (ProxyMiddleware, ProxyOutgoingMiddleware) ├── web-incoming.ts — HTTP request passes: deleteLength → timeout → XHeaders → stream ├── web-outgoing.ts — HTTP response passes: removeChunked → setConnection → setRedirectHostRewrite → writeHeaders → writeStatusCode └── ws-incoming.ts — WebSocket passes: checkMethodAndHeader → XHeaders → stream ``` ### Request flow (HTTP) ``` Client → ProxyServer.web() → web-incoming passes → http.request(target) → target server Target response → web-outgoing passes → client response ``` ### Request flow (WebSocket) ``` Client upgrade → ProxyServer.ws() → ws-incoming passes → http.request(target) Target upgrade → bidirectional socket pipe ``` ### Request flow (proxyFetch) ``` proxyFetch(addr, request) → http.request to addr → Web Response ``` ### Request flow (proxyUpgrade) ``` proxyUpgrade(addr, req, socket, head) → http.request to addr → upgrade → bidirectional socket pipe Returns Promise (the upstream proxy socket) ``` ### Key design patterns - **Middleware pipeline**: Passes are functions that run in order; returning `true` halts the chain - **Event-driven**: `ProxyServer` emits lifecycle events (`start`, `proxyReq`, `proxyRes`, `end`, `error`, `open`, `close`) - **Extensible middleware**: `server.before(type, passName, fn)` / `server.after(type, passName, fn)` to insert custom passes - **Flexible targets**: TCP (`host:port`), Unix socket (`socketPath`), or URL string ## Behavioral Notes (Source + Tests) - `proxy.web()` / `proxy.ws()` return a Promise and can either reject (no `error` listener, request/response error) or resolve after `res.close`. - Per-call options are merged as `{ ...opts, ...server.options }`, so constructor options override per-call options on key conflicts. - String `target`/`forward` values are normalized to `URL` objects before middleware execution. - Missing both `target` and `forward` emits `error` with message `"Must provide a proper URL as target"`. - Middleware names are often empty strings (passes are wrapped arrow functions). Tests use `before("web", "", ...)` / `after("web", "", ...)`. - `ProxyServer.close()` is a no-op before `listen()`, and sets internal `_server` to `undefined` after close callback. ### HTTP middleware semantics - Incoming pass order is fixed: `deleteLength -> timeout -> XHeaders -> stream`. - `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`). - `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). - `proxyRes` event fires only for the final (non-redirect) response; `proxyReq` fires for each request including redirects. - Sensitive headers (`authorization`, `cookie`) are stripped on cross-origin redirects. - When `followRedirects` is enabled, the request body is tee'd (written to proxy request and buffered simultaneously) rather than piped. ### WebSocket middleware semantics - Incoming pass order is fixed: `checkMethodAndHeader -> XHeaders -> stream`. - WS requests must be `GET` with `upgrade: websocket`; otherwise socket is destroyed and the chain stops. - `proxyReqWs`, `open`, `close`, and deprecated `proxySocket` events are part of tested flow. - Upgrade response headers preserve repeated headers like multiple `Set-Cookie` values. ### Outgoing response semantics - Outgoing pass order is fixed: `removeChunked -> setConnection -> setRedirectHostRewrite -> writeHeaders -> writeStatusCode`. - Redirect rewrite applies on `201`, `301`, `302`, `303`, `307`, `308` only, and only when `Location` host matches `target.host`. - `hostRewrite` takes precedence over `autoRewrite`; `protocolRewrite` composes with either. - Cookie rewriting supports string or mapping config (including wildcard `"*"` and empty string for removal). - `preserveHeaderKeyCase` uses `rawHeaders` when available. ### URL/path handling invariants - Path joining does not normalize repeated slashes (`/a/b//c` stays as-is). - `joinURL()` always returns a usable path and avoids duplicate slash insertion. - `toProxy: true` preserves absolute URL in outgoing path as `"/" + req.url`. - `ignorePath` drops request path; with `prependPath: false` the outgoing path becomes `/`. ### `proxyFetch` semantics - `addr` accepts `http://host:port`, `https://host:port`, `unix:/path.sock`, or object form `{ host, port }` / `{ socketPath }`. - Both HTTP and HTTPS upstream targets are supported. HTTPS is auto-detected from the `addr` string protocol. - When `addr` is a URL string with a path (e.g. `http://host:port/api`), the path is prepended to the request path via `joinURL()`. - Redirect mode defaults to `manual`. - Streaming request bodies are supported (`ReadableStream`) and set `duplex: "half"`. - Hop-by-hop response headers `transfer-encoding`, `keep-alive`, `connection` are stripped. - Response body is `null` for `204` and `304`. - Network and request-body-stream errors reject the Promise. - Accepts optional `ProxyFetchOptions` as 4th argument with `timeout`, `xfwd`, `changeOrigin`, `agent`, `followRedirects`, and `ssl`. - `timeout` sets a deadline on the upstream request; rejects with `"Proxy request timed out"` on expiry. - `xfwd` adds `x-forwarded-for`, `x-forwarded-port`, `x-forwarded-proto`, `x-forwarded-host` derived from the input URL (not from a socket, since there is no incoming connection). Existing headers are not overwritten. - `changeOrigin` rewrites the `Host` header to match the resolved target address (host:port for TCP, `localhost` for Unix sockets). Accounts for default ports (80 for HTTP, 443 for HTTPS). - `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`. - 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. ### `proxyUpgrade` semantics - Standalone WebSocket upgrade proxy — no `ProxyServer` instance or `EventEmitter` needed. - `addr` accepts same formats as `proxyFetch`: `http://host:port`, `ws://host:port`, `unix:/path`, or object `{ host, port }` / `{ socketPath }`. - Validates that the request is a valid WS upgrade (`GET` + `upgrade: websocket`); rejects with error and destroys socket otherwise. - `xfwd` is enabled by default (unlike `ProxyServer` where it defaults to `false`). Pass `xfwd: false` to disable. - Supports `xfwd`, `changeOrigin`, `headers`, `ssl`, `secure`, `agent`, `auth`, `prependPath`, `ignorePath`, `toProxy` options via `ProxyUpgradeOptions`. - Returns `Promise` — resolves with the upstream proxy socket on successful upgrade, rejects on connection or socket error. - If the upstream responds without upgrading (e.g., 404), the response is relayed to the client socket. - Uses `setupOutgoing()` and `setupSocket()` from shared utils, consistent with `ProxyServer.ws()`. ## Tests (`test/`) ``` test/ ├── index.test.ts — Main proxy: paths, headers, changeOrigin, xfwd, WebSocket, errors ├── fetch.test.ts — proxyFetch: TCP/Unix, GET/POST, redirects, cookies, 204/304, signal, timeout, xfwd, changeOrigin ├── upgrade.test.ts — proxyUpgrade: WS proxy, addr formats, xfwd, error handling ├── http-proxy.test.ts — Forward, target, WebSocket, socket.io, SSE, timeouts, error events ├── https-proxy.test.ts — HTTPS targets, SSL certs, certificate validation ├── _utils.test.ts — setupOutgoing, setupSocket, path joining, auth, changeOrigin ├── types.test-d.ts — TypeScript type assertions (vitest typecheck) └── middleware/ ├── web-incoming.test.ts — deleteLength, timeout, XHeaders ├── web-outgoing.test.ts — Redirect rewrite, writeHeaders, cookies, status codes └── ws-incoming.test.ts — Method/header validation, error handling ``` ### Running tests ```bash pnpm vitest run test/ # Single test file pnpm vitest run # All tests pnpm test # Lint + typecheck + tests with coverage ``` ### Test expectations and parity - The suite includes legacy parity tests ported from `http-party/node-http-proxy` plus project-specific tests (`test/server.test.ts`, `test/fetch.test.ts`, `test/types.test-d.ts`). - `followRedirects` is natively implemented (no external dependency). See behavioral notes below. - HTTPS tests rely on local fixtures in `test/fixtures/agent2-*.pem`. ## Tooling | Tool | Command | Notes | | --------- | ---------------- | --------------------------------------------- | | Build | `pnpm build` | Uses `unbuild` → CJS + ESM + types in `dist/` | | Dev | `pnpm dev` | Vitest watch mode | | Lint | `pnpm lint` | `oxlint` + `oxfmt --check` | | Format | `pnpm fmt` | `oxlint --fix` + `oxfmt` | | Typecheck | `pnpm typecheck` | `tsgo --noEmit` (native TS preview) | | Test | `pnpm test` | Full: lint + typecheck + vitest with coverage | ## Key Types ```ts type ProxyTarget = string | URL | ProxyTargetDetailed; interface ProxyTargetDetailed { host?: string; port?: number | string; protocol?: string; hostname?: string; socketPath?: string; // TLS: key, passphrase, pfx, cert, ca, ciphers, secureProtocol } interface ProxyServerOptions { target?: ProxyTarget; // Proxy destination forward?: ProxyTarget; // Forward destination ws?: boolean; // Enable WebSocket proxying xfwd?: boolean; // Add x-forwarded-* headers changeOrigin?: boolean; // Rewrite Host header to target // ... 30+ more options for paths, headers, cookies, TLS, timeouts } ``` ## Conventions - ESM only (`"type": "module"`) - Strict TypeScript with `nodenext` module resolution - Internal files prefixed with `_` (e.g., `_utils.ts`) - Tests use `vitest` + `expect.js` assertions - No production dependencies — Node.js built-ins only - Semantic commits: `feat(scope):`, `fix(scope):`, `test:`, `chore:` ## Maintenance - Keep `AGENTS.md` and `README.md` in sync with the current codebase. - When you discover new behavior, constraints, architecture details, or workflows, document them in `AGENTS.md` for future agents. - When implementation or design changes affect user-facing usage or project expectations, update `README.md` in the same change. - Always add or update automated tests for behavior changes and bug fixes, including regression coverage that would fail without the change. ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## v0.5.1 [compare changes](https://github.com/unjs/httpxy/compare/v0.5.0...v0.5.1) ### 🚀 Enhancements - **server:** `listeningCallback` ([#127](https://github.com/unjs/httpxy/pull/127)) ### 🩹 Fixes - Use `agents: false` for ws upgrade ([#129](https://github.com/unjs/httpxy/pull/129)) - Do not set undefined values for headers ([#130](https://github.com/unjs/httpxy/pull/130)) ### 🏡 Chore - Apply automated updates ([b227f65](https://github.com/unjs/httpxy/commit/b227f65)) - Update deps ([6f5ef12](https://github.com/unjs/httpxy/commit/6f5ef12)) ### ❤️ Contributors - Sukka - Daniel Roe ([@danielroe](https://github.com/danielroe)) - Pooya Parsa ([@pi0](https://github.com/pi0)) ## v0.5.0 [compare changes](https://github.com/unjs/httpxy/compare/v0.4.0...v0.5.0) ### 🔥 Performance - ⚠️ Keep-alive, request body stream and faster proxyFetch ([#124](https://github.com/unjs/httpxy/pull/124)) ### 🏡 Chore - Apply automated updates ([04aaaba](https://github.com/unjs/httpxy/commit/04aaaba)) #### ⚠️ Breaking Changes - ⚠️ Keep-alive, request body stream and faster proxyFetch ([#124](https://github.com/unjs/httpxy/pull/124)) ### ❤️ Contributors - Pooya Parsa ([@pi0](https://github.com/pi0)) ## v0.4.0 [compare changes](https://github.com/unjs/httpxy/compare/v0.3.1...v0.4.0) ### 🚀 Enhancements - Http/2 listener support ([#102](https://github.com/unjs/httpxy/pull/102)) - **fetch:** Add proxyFetch options for timeout, xfwd, changeOrigin, agent, followRedirects, HTTPS, and path merging ([efa9711](https://github.com/unjs/httpxy/commit/efa9711)) ### 🩹 Fixes - **web-incoming:** Close downstream stream when upstream SSE aborts ([#103](https://github.com/unjs/httpxy/pull/103)) - Handle relative Location URLs in redirect rewriting ([#20](https://github.com/unjs/httpxy/pull/20), [#104](https://github.com/unjs/httpxy/pull/104)) - **web-outgoing:** Handle invalid response header characters gracefully ([#106](https://github.com/unjs/httpxy/pull/106)) - **web-incoming:** Remove deprecated `req.abort()` and `req.on("aborted")` ([#107](https://github.com/unjs/httpxy/pull/107)) - **web-outgoing:** Handle object target in redirect host rewrite ([#108](https://github.com/unjs/httpxy/pull/108)) - **web-incoming:** Remove deprecated `req.on('aborted')` listener ([#110](https://github.com/unjs/httpxy/pull/110)) - **ws:** Skip writing to closed socket on non-upgrade response ([#114](https://github.com/unjs/httpxy/pull/114)) - **web-incoming:** Guard `req.socket` access in error handler ([#112](https://github.com/unjs/httpxy/pull/112)) - **web-incoming:** Defer pipe until socket connects ([#111](https://github.com/unjs/httpxy/pull/111)) - **server:** Catch synchronous exceptions in middleware passes ([#109](https://github.com/unjs/httpxy/pull/109)) - **web-incoming:** Emit econnreset on client disconnect ([#115](https://github.com/unjs/httpxy/pull/115)) - **ws:** Handle response stream errors on failed WS upgrade ([#116](https://github.com/unjs/httpxy/pull/116)) - **web-outgoing:** Include HTTP 303 in redirect location rewriting ([#119](https://github.com/unjs/httpxy/pull/119)) - **web-outgoing:** Skip empty header names ([#121](https://github.com/unjs/httpxy/pull/121)) - **ssl:** Prevent undefined target values from overwriting ssl options ([#118](https://github.com/unjs/httpxy/pull/118)) - **utils:** Preserve target URL query string in path merging ([#117](https://github.com/unjs/httpxy/pull/117)) - **middleware:** Do not append duplicate x-forwarded-\* header values ([#120](https://github.com/unjs/httpxy/pull/120)) - **web-outgoing:** Strip transfer-encoding on 204/304 ([#122](https://github.com/unjs/httpxy/pull/122)) - **web-incoming:** Use `isSSL` regex for consistent https/wss protocol checks ([#123](https://github.com/unjs/httpxy/pull/123)) - **ws:** Preserve wss:// protocol and fix error handling in proxyUpgrade ([cb01605](https://github.com/unjs/httpxy/commit/cb01605)) ### 📦 Build - ⚠️ Esm-only ([d65b3f7](https://github.com/unjs/httpxy/commit/d65b3f7)) ### 🏡 Chore - Update deps ([743098d](https://github.com/unjs/httpxy/commit/743098d)) #### ⚠️ Breaking Changes - ⚠️ Esm-only ([d65b3f7](https://github.com/unjs/httpxy/commit/d65b3f7)) ### ❤️ Contributors - Pooya Parsa ([@pi0](https://github.com/pi0)) - Guoyangzhen - Sukka - Gabor Koos ## v0.3.1 [compare changes](https://github.com/unjs/httpxy/compare/v0.3.0...v0.3.1) ### 🚀 Enhancements - Standalone `proxyUpgrade` util ([#100](https://github.com/unjs/httpxy/pull/100)) ### 🏡 Chore - Apply automated updates ([d8c97ee](https://github.com/unjs/httpxy/commit/d8c97ee)) ### ✅ Tests - Use stub objects ([2287e56](https://github.com/unjs/httpxy/commit/2287e56)) ### ❤️ Contributors - Pooya Parsa ([@pi0](https://github.com/pi0)) ## v0.3.0 [compare changes](https://github.com/unjs/httpxy/compare/v0.2.2...v0.3.0) ### 🚀 Enhancements - `proxyFetch` ([#98](https://github.com/unjs/httpxy/pull/98)) - **web-incoming:** Implement native `followRedirects` support ([d3d7f39](https://github.com/unjs/httpxy/commit/d3d7f39)) ### 🩹 Fixes - **proxy:** Ensure leading slash on `toProxy` outgoing path ([7759c94](https://github.com/unjs/httpxy/commit/7759c94)) - **server:** Emit proxy error when listener exists, reject only when unhandled ([c9d2c51](https://github.com/unjs/httpxy/commit/c9d2c51)) - **web-incoming:** Destroy request socket on timeout ([40105be](https://github.com/unjs/httpxy/commit/40105be)) - **utils:** Preserve multiple consecutive slashes in request URL ([18e4d0d](https://github.com/unjs/httpxy/commit/18e4d0d)) - **web-incoming:** Abort proxy request when client disconnects ([a5d4996](https://github.com/unjs/httpxy/commit/a5d4996)) - **ws:** Handle client socket errors before upstream upgrade ([aebb5c6](https://github.com/unjs/httpxy/commit/aebb5c6)) ### 💅 Refactors - ⚠️ Remove legacy node `Url` support ([b2e6c92](https://github.com/unjs/httpxy/commit/b2e6c92)) ### 🏡 Chore - Enable strict typescript with nodenext resolution ([0c147a3](https://github.com/unjs/httpxy/commit/0c147a3)) - Format repo ([d7e707f](https://github.com/unjs/httpxy/commit/d7e707f)) - Update readme ([24f8b1a](https://github.com/unjs/httpxy/commit/24f8b1a)) - Add more examples for proxy fetch ([d0cb298](https://github.com/unjs/httpxy/commit/d0cb298)) - Apply automated updates ([d666b65](https://github.com/unjs/httpxy/commit/d666b65)) - Add agents.md ([f497cb0](https://github.com/unjs/httpxy/commit/f497cb0)) - Apply automated updates ([9a8d8eb](https://github.com/unjs/httpxy/commit/9a8d8eb)) - Apply automated updates ([822a0ea](https://github.com/unjs/httpxy/commit/822a0ea)) - Lint ([2d556f9](https://github.com/unjs/httpxy/commit/2d556f9)) - Update deps ([63b750f](https://github.com/unjs/httpxy/commit/63b750f)) ### ✅ Tests - Fix todo items ([8a3732b](https://github.com/unjs/httpxy/commit/8a3732b)) - Increase coverage ([50c0929](https://github.com/unjs/httpxy/commit/50c0929)) - Use random ports only ([9e2d155](https://github.com/unjs/httpxy/commit/9e2d155)) ### 🤖 CI - Update actions ([1fbac92](https://github.com/unjs/httpxy/commit/1fbac92)) #### ⚠️ Breaking Changes - ⚠️ Remove legacy node `Url` support ([b2e6c92](https://github.com/unjs/httpxy/commit/b2e6c92)) ### ❤️ Contributors - Pooya Parsa ([@pi0](https://github.com/pi0)) ## v0.2.2 [compare changes](https://github.com/unjs/httpxy/compare/v0.2.1...v0.2.2) ### 🏡 Chore - Fix build script ([28dc9e6](https://github.com/unjs/httpxy/commit/28dc9e6)) ### ❤️ Contributors - Pooya Parsa ([@pi0](https://github.com/pi0)) ## v0.2.1 [compare changes](https://github.com/unjs/httpxy/compare/v0.2.0...v0.2.1) ### 🌊 Types - Make httpxy's server event type map generic ([#97](https://github.com/unjs/httpxy/pull/97)) ### 🏡 Chore - Update deps ([aecbed3](https://github.com/unjs/httpxy/commit/aecbed3)) ### ❤️ Contributors - Pooya Parsa ([@pi0](https://github.com/pi0)) - Sukka ## v0.2.0 [compare changes](https://github.com/unjs/httpxy/compare/v0.1.7...v0.2.0) ### 💅 Refactors - ⚠️ Code improvements ([#78](https://github.com/unjs/httpxy/pull/78)) ### 🌊 Types - Implement typed proxy server event ([#95](https://github.com/unjs/httpxy/pull/95), [#96](https://github.com/unjs/httpxy/pull/96)) ### 🏡 Chore - Update dev dependencies ([81f5e57](https://github.com/unjs/httpxy/commit/81f5e57)) - Migrate to oxfmt and oxlint ([edd6cff](https://github.com/unjs/httpxy/commit/edd6cff)) ### ✅ Tests - Port tests from node-http-proxy ([#88](https://github.com/unjs/httpxy/pull/88)) #### ⚠️ Breaking Changes - ⚠️ Code improvements ([#78](https://github.com/unjs/httpxy/pull/78)) ### ❤️ Contributors - Pooya Parsa ([@pi0](https://github.com/pi0)) - Sukka ([@SukkaW](https://github.com/SukkaW)) - 翠 ## v0.1.7 [compare changes](https://github.com/unjs/httpxy/compare/v0.1.6...v0.1.7) ### 🩹 Fixes - Preserve double slashes in url ([#70](https://github.com/unjs/httpxy/pull/70)) ### 🏡 Chore - Update deps ([c9c9de8](https://github.com/unjs/httpxy/commit/c9c9de8)) ### ❤️ Contributors - Oskar Lebuda ([@OskarLebuda](http://github.com/OskarLebuda)) - Pooya Parsa ([@pi0](http://github.com/pi0)) ## v0.1.6 [compare changes](https://github.com/unjs/httpxy/compare/v0.1.5...v0.1.6) ### 🩹 Fixes - Omit outgoing port when not required ([#65](https://github.com/unjs/httpxy/pull/65)) ### 📖 Documentation - Remove unsupported `followRedirects` option ([#66](https://github.com/unjs/httpxy/pull/66)) - Improve example ([#16](https://github.com/unjs/httpxy/pull/16)) ### 🏡 Chore - Fix typo in readme ([#36](https://github.com/unjs/httpxy/pull/36)) - Update repo ([64f7465](https://github.com/unjs/httpxy/commit/64f7465)) - Update ci ([b0f08de](https://github.com/unjs/httpxy/commit/b0f08de)) ### ❤️ Contributors - Lsh ([@peterroe](http://github.com/peterroe)) - Kricsleo ([@kricsleo](http://github.com/kricsleo)) - Pooya Parsa ([@pi0](http://github.com/pi0)) - Mohammd Siddiqui ## v0.1.5 [compare changes](https://github.com/unjs/httpxy/compare/v0.1.4...v0.1.5) ### 🩹 Fixes - Handle client `close` event ([#8](https://github.com/unjs/httpxy/pull/8)) ### 🏡 Chore - Update deps ([2888089](https://github.com/unjs/httpxy/commit/2888089)) ### ❤️ Contributors - Pooya Parsa ([@pi0](http://github.com/pi0)) - David Tai ([@didavid61202](http://github.com/didavid61202)) ## v0.1.4 [compare changes](https://github.com/unjs/httpxy/compare/v0.1.2...v0.1.4) ### 🩹 Fixes - Presrve search params from parsed url ([8bbaacc](https://github.com/unjs/httpxy/commit/8bbaacc)) - Add `target` pathname currectly ([#6](https://github.com/unjs/httpxy/pull/6)) ### 💅 Refactors - Fix typo in `defineProxyMiddleware` ([#4](https://github.com/unjs/httpxy/pull/4)) ### 🏡 Chore - **release:** V0.1.2 ([b6bd4a8](https://github.com/unjs/httpxy/commit/b6bd4a8)) - Update dev dependencies ([5704e70](https://github.com/unjs/httpxy/commit/5704e70)) - **release:** V0.1.3 ([4ced1cc](https://github.com/unjs/httpxy/commit/4ced1cc)) ### ❤️ Contributors - Pooya Parsa ([@pi0](http://github.com/pi0)) - Jonasolesen - Gacek1123 ## v0.1.3 [compare changes](https://github.com/unjs/httpxy/compare/v0.1.2...v0.1.3) ### 🩹 Fixes - Presrve search params from parsed url ([8bbaacc](https://github.com/unjs/httpxy/commit/8bbaacc)) ### 💅 Refactors - Fix typo in `defineProxyMiddleware` ([#4](https://github.com/unjs/httpxy/pull/4)) ### 🏡 Chore - **release:** V0.1.2 ([b6bd4a8](https://github.com/unjs/httpxy/commit/b6bd4a8)) - Update dev dependencies ([a41d0c6](https://github.com/unjs/httpxy/commit/a41d0c6)) ### ❤️ Contributors - Pooya Parsa ([@pi0](http://github.com/pi0)) - Gacek1123 ## v0.1.2 [compare changes](https://github.com/unjs/httpxy/compare/v0.1.1...v0.1.2) ### 🩹 Fixes - Presrve search params from parsed url ([8bbaacc](https://github.com/unjs/httpxy/commit/8bbaacc)) ### ❤️ Contributors - Pooya Parsa ([@pi0](http://github.com/pi0)) ## v0.1.1 ### 🚀 Enhancements - Awaitable `.`web`and`.ws` ([e4dad27](https://github.com/unjs/httpxy/commit/e4dad27)) ### 🩹 Fixes - `createProxyServer` options is optional ([75d8e93](https://github.com/unjs/httpxy/commit/75d8e93)) ### 💅 Refactors - Avoid `url.parse` ([4ceca85](https://github.com/unjs/httpxy/commit/4ceca85)) - Hide internal props ([2f30878](https://github.com/unjs/httpxy/commit/2f30878)) ### 📖 Documentation - No need for quote ([9319fab](https://github.com/unjs/httpxy/commit/9319fab)) ### 🏡 Chore - Update readme ([64a7a75](https://github.com/unjs/httpxy/commit/64a7a75)) - Update dependencies ([1e906b9](https://github.com/unjs/httpxy/commit/1e906b9)) ### ❤️ Contributors - Pooya Parsa ([@pi0](http://github.com/pi0)) - Sébastien Chopin ================================================ FILE: CLAUDE.md ================================================ @AGENTS.md ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) Pooya Parsa Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- Based on http-party/node-http-proxy (9b96cd7) Copyright (c) 2010-2016 Charlie Robbins, Jarrett Cruger & the Contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # 🔀 httpxy [![npm version][npm-version-src]][npm-version-href] [![npm downloads][npm-downloads-src]][npm-downloads-href] [![bundle][bundle-src]][bundle-href] [![Codecov][codecov-src]][codecov-href] A Full-Featured HTTP and WebSocket Proxy for Node.js ## Proxy Fetch `proxyFetch` is a proxy utility with web standard (`Request`/`Response`) interfaces. It forwards requests to a specific server address (TCP host/port or Unix socket), bypassing the URL's hostname. ```ts import { proxyFetch } from "httpxy"; // TCP — using a URL string const res = await proxyFetch("http://127.0.0.1:3000", "http://example.com/api/data"); console.log(await res.json()); // Unix socket — using a URL string const res2 = await proxyFetch("unix:/tmp/app.sock", "http://localhost/health"); console.log(await res2.text()); // Or use an object for more control const res3 = await proxyFetch({ host: "127.0.0.1", port: 3000 }, "http://example.com/api/data"); // Using a Request object const req = new Request("http://example.com/api/data", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: "value" }), }); const res4 = await proxyFetch("http://127.0.0.1:3000", req); // Using a URL string with RequestInit const res5 = await proxyFetch("http://127.0.0.1:3000", "http://example.com/api/data", { method: "PUT", headers: { Authorization: "Bearer token" }, body: JSON.stringify({ updated: true }), }); ``` It accepts the same `input` and `init` arguments as the global `fetch`, including `Request` objects and streaming bodies, and returns a standard `Response`. Redirects are handled manually by default. ## Proxy Upgrade `proxyUpgrade` is a standalone WebSocket upgrade proxy. It forwards `upgrade` requests to a target server without needing a `ProxyServer` instance — the WebSocket counterpart to `proxyFetch`. ```ts import { createServer } from "node:http"; import { proxyUpgrade } from "httpxy"; const server = createServer((req, res) => { // Handle regular HTTP requests... }); server.on("upgrade", (req, socket, head) => { proxyUpgrade("http://127.0.0.1:8080", req, socket, head); }); server.listen(3000); ``` It accepts the same `addr` formats as `proxyFetch` (`"http://host:port"`, `"unix:/path"`, or `{ host, port }` / `{ socketPath }`), and returns a `Promise` that resolves with the upstream proxy socket once the WebSocket connection is established. ```ts // With options server.on("upgrade", (req, socket, head) => { proxyUpgrade({ host: "127.0.0.1", port: 8080 }, req, socket, head, { // changeOrigin: true, // rewrite Host header // xfwd: false, // disable x-forwarded-* headers (enabled by default) }); }); ``` ## Proxy Server > [!NOTE] > Proxy server was originally forked from [http-party/node-http-proxy](https://github.com/http-party/node-http-proxy). Create proxy: ```ts import { createServer } from "node:http"; import { createProxyServer } from "httpxy"; const proxy = createProxyServer({}); const server = createServer(async (req, res) => { try { await proxy.web(req, res, { target: address /* address of your proxy server here */, }); } catch (error) { console.error(error); res.statusCode = 500; res.end("Proxy error: " + error.toString()); } }); server.listen(3000, () => { console.log("Proxy is listening on http://localhost:3000"); }); ``` ## Options | Option | Type | Default | Description | | ----------------------- | -------------------------------------- | ---------- | --------------------------------------------------------------------------- | | `target` | `string \| URL \| ProxyTargetDetailed` | — | Target server URL | | `forward` | `string \| URL` | — | Forward server URL (pipes request without the target's response) | | `agent` | `http.Agent \| false` | keep-alive | Shared keep-alive agent by default. Set `false` to disable connection reuse | | `ssl` | `https.ServerOptions` | — | Object passed to `https.createServer()` | | `ws` | `boolean` | `false` | Enable WebSocket proxying | | `xfwd` | `boolean` | `false` | Add `x-forwarded-*` headers | | `secure` | `boolean` | — | Verify SSL certificates | | `toProxy` | `boolean` | `false` | Pass absolute URL as path (proxy-to-proxy) | | `prependPath` | `boolean` | `true` | Prepend the target's path to the proxy path | | `ignorePath` | `boolean` | `false` | Ignore the incoming request path | | `localAddress` | `string` | — | Local interface to bind for outgoing connections | | `changeOrigin` | `boolean` | `false` | Change the `Host` header to match the target URL | | `preserveHeaderKeyCase` | `boolean` | `false` | Keep original letter case of response header keys | | `auth` | `string` | — | Basic authentication (`'user:password'`) for `Authorization` header | | `hostRewrite` | `string` | — | Rewrite the `Location` hostname on redirects (301/302/307/308) | | `autoRewrite` | `boolean` | `false` | Rewrite `Location` host/port on redirects based on the request | | `protocolRewrite` | `string` | — | Rewrite `Location` protocol on redirects (`'http'` or `'https'`) | | `cookieDomainRewrite` | `false \| string \| object` | `false` | Rewrite domain of `Set-Cookie` headers | | `cookiePathRewrite` | `false \| string \| object` | `false` | Rewrite path of `Set-Cookie` headers | | `headers` | `object` | — | Extra headers to add to target requests | | `proxyTimeout` | `number` | `120000` | Timeout (ms) for the proxy request to the target | | `timeout` | `number` | — | Timeout (ms) for the incoming request | | `selfHandleResponse` | `boolean` | `false` | Disable automatic response piping (handle `proxyRes` yourself) | | `followRedirects` | `boolean \| number` | `false` | Follow HTTP redirects from target. `true` = max 5 hops; number = custom max | | `buffer` | `stream.Stream` | — | Stream to use as request body instead of the incoming request | ## Events | Event | Arguments | Description | | ------------ | ---------------------------------------- | ------------------------------------------------------ | | `error` | `(err, req, res, target)` | An error occurred during proxying | | `proxyReq` | `(proxyReq, req, res, options)` | Before request is sent to target (modify headers here) | | `proxyRes` | `(proxyRes, req, res)` | Response received from target | | `proxyReqWs` | `(proxyReq, req, socket, options, head)` | Before WebSocket upgrade request is sent | | `open` | `(proxySocket)` | WebSocket connection opened | | `close` | `(proxyRes, proxySocket, proxyHead)` | WebSocket connection closed | | `start` | `(req, res, target)` | Proxy processing started | | `end` | `(req, res, proxyRes)` | Proxy request completed | ## Examples ### HTTP Proxy ```ts import { createServer } from "node:http"; import { createProxyServer } from "httpxy"; const proxy = createProxyServer({}); const server = createServer(async (req, res) => { await proxy.web(req, res, { target: "http://localhost:8080" }); }); server.listen(3000); ``` ### WebSocket Proxy ```ts import { createServer } from "node:http"; import { createProxyServer } from "httpxy"; const proxy = createProxyServer({ target: "http://localhost:8080", ws: true }); const server = createServer(async (req, res) => { await proxy.web(req, res); }); server.on("upgrade", (req, socket, head) => { proxy.ws(req, socket, { target: "http://localhost:8080" }, head); }); server.listen(3000); ``` ### Modify Request Headers ```ts import { createServer } from "node:http"; import { createProxyServer } from "httpxy"; const proxy = createProxyServer({ target: "http://localhost:8080" }); proxy.on("proxyReq", (proxyReq) => { proxyReq.setHeader("X-Forwarded-By", "httpxy"); }); const server = createServer(async (req, res) => { await proxy.web(req, res); }); server.listen(3000); ``` ### HTTPS Proxy ```ts import { readFileSync } from "node:fs"; import { createProxyServer } from "httpxy"; const proxy = createProxyServer({ ssl: { key: readFileSync("server-key.pem", "utf8"), cert: readFileSync("server-cert.pem", "utf8"), }, target: "https://localhost:8443", secure: false, // allow self-signed certificates }); proxy.listen(3000); ``` ### Standalone Proxy Server ```ts import { createProxyServer } from "httpxy"; const proxy = createProxyServer({ target: "http://localhost:8080", changeOrigin: true, }); proxy.listen(3000); ``` ## Development - Clone this repository - Install latest LTS version of [Node.js](https://nodejs.org/en/) - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` - Install dependencies using `pnpm install` - Run interactive tests using `pnpm dev` ## Acknowledgements Performance optimizations in httpxy were inspired by analysis of [fast-proxy](https://github.com/fastify/fast-proxy) and [@fastify/http-proxy](https://github.com/fastify/fastify-http-proxy). ## License Made with 💛 Published under [MIT License](./LICENSE). [npm-version-src]: https://img.shields.io/npm/v/httpxy?style=flat&colorA=18181B&colorB=F0DB4F [npm-version-href]: https://npmjs.com/package/httpxy [npm-downloads-src]: https://img.shields.io/npm/dm/httpxy?style=flat&colorA=18181B&colorB=F0DB4F [npm-downloads-href]: https://npmjs.com/package/httpxy [codecov-src]: https://img.shields.io/codecov/c/gh/unjs/httpxy/main?style=flat&colorA=18181B&colorB=F0DB4F [codecov-href]: https://codecov.io/gh/unjs/httpxy [bundle-src]: https://img.shields.io/bundlephobia/minzip/httpxy?style=flat&colorA=18181B&colorB=F0DB4F [bundle-href]: https://bundlephobia.com/result?p=httpxy ================================================ FILE: bench/Dockerfile ================================================ FROM node:lts COPY --from=alpine/bombardier /usr/bin/bombardier /usr/local/bin/bombardier RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY bench/package.json bench/ RUN pnpm install --frozen-lockfile COPY src/ src/ COPY bench/ bench/ COPY tsconfig.json ./ ================================================ FILE: bench/bench.ts ================================================ #!/usr/bin/env node import { execSync, execFileSync, execFile as _execFile } from "node:child_process"; import { parseArgs } from "node:util"; import { promisify } from "node:util"; const execFileAsync = promisify(_execFile); // --- Config --- const { values: args } = parseArgs({ options: { duration: { type: "string", short: "d", default: "1s" }, connections: { type: "string", short: "c", default: "128" }, sequential: { type: "boolean", short: "s", default: true }, }, }); const IMAGE = "httpxy-bench"; const DURATION = args.duration!; const CONNECTIONS = Number(args.connections); const SEQUENTIAL = args.sequential!; const POST_BODY = JSON.stringify({ message: "hello world".repeat(30), ts: 1234567890, padding: "x".repeat(1024 - 360), }); // ~1KB const TARGET_PORT = 3000; const PROXIES = [ { name: "httpxy.server", script: "bench/src/httpxy-server.ts", port: 3001 }, { name: "httpxy.proxyFetch", script: "bench/src/httpxy-fetch.ts", port: 3002 }, { name: "fast-proxy", script: "bench/src/fast-proxy.ts", port: 3003 }, { name: "@fastify/http-proxy", script: "bench/src/fastify.ts", port: 3004 }, { name: "http-proxy-3", script: "bench/src/http-proxy-3.ts", port: 3005 }, { name: "http-proxy", script: "bench/src/http-proxy.ts", port: 3006 }, ]; // --- Helpers --- const blue = (s: string) => `\x1B[1;34m${s}\x1B[0m`; const green = (s: string) => `\x1B[1;32m${s}\x1B[0m`; // const red = (s: string) => `\x1B[1;31m${s}\x1B[0m`; const info = (msg: string) => console.log(blue(`=> ${msg}`)); const ok = (msg: string) => console.log(green(` ${msg}`)); // const err = (msg: string) => console.log(red(` ${msg}`)); const containers: string[] = []; function cleanup() { info("Cleaning up..."); if (containers.length === 0) return; try { execSync(`docker rm -f ${containers.join(" ")}`, { stdio: "ignore" }); } catch {} containers.length = 0; } function dockerRun(...args: string[]) { return execFileSync("docker", ["run", "--rm", "--network", "host", ...args], { encoding: "utf8", }).trim(); } function startContainer(name: string, script: string, port: number) { const cid = dockerRun( "-d", "--name", name, "--cpus=1", "--memory=256m", "-e", `PORT=${port}`, "-e", `TARGET=http://127.0.0.1:${TARGET_PORT}`, IMAGE, "node", script, ); containers.push(cid); } let bombCounter = 0; async function bombJson(args: string[]): Promise { const name = `bench-bombardier-${process.pid}-${bombCounter++}`; const { stdout } = await execFileAsync( "docker", [ "run", "--rm", "--network", "host", "--name", name, IMAGE, "bombardier", "--format=json", "--print=result", "--latencies", ...args, ], { encoding: "utf8" }, ); return stdout; } function waitForReady(port: number, retries = 60) { for (let i = 0; i < retries; i++) { try { execSync(`curl -sf -o /dev/null http://127.0.0.1:${port}/`, { stdio: "ignore" }); return; } catch { execSync("sleep 0.3", { stdio: "ignore" }); } } throw new Error(`Timed out waiting for port ${port}`); } // --- Bombardier types --- interface BombardierResult { bytesRead: number; bytesWritten: number; timeTakenSeconds: number; req1xx: number; req2xx: number; req3xx: number; req4xx: number; req5xx: number; others: number; errors?: { description: string; count: number }[]; latency: { mean: number; // nanoseconds stddev: number; max: number; percentiles: Record; // "50", "75", "90", "95", "99" }; rps: { mean: number; stddev: number; max: number; percentiles: Record; }; } interface BenchResult { rps: number; avgLatency: number; // ns p50: number; // ns p99: number; // ns bytesPerSec: number; } // --- Result parsing --- function formatNs(ns: number): string { return ns < 1e6 ? `${(ns / 1e3).toFixed(0)}µs` : `${(ns / 1e6).toFixed(2)}ms`; } function formatThroughput(bytesPerSec: number): string { return bytesPerSec > 1e6 ? `${(bytesPerSec / 1e6).toFixed(1)}MB/s` : `${(bytesPerSec / 1e3).toFixed(0)}KB/s`; } function formatResult(r: BenchResult): string { return `${r.rps.toFixed(0)} req/s | ${formatNs(r.avgLatency)} avg | ${formatThroughput(r.bytesPerSec)}`; } function parseResult(json: string): BenchResult { const { result: r } = JSON.parse(json) as { result: BombardierResult }; const nonOk = r.req1xx + r.req3xx + r.req4xx + r.req5xx + r.others; if (nonOk > 0) { throw new Error( `Non-2xx responses: 1xx=${r.req1xx} 3xx=${r.req3xx} 4xx=${r.req4xx} 5xx=${r.req5xx} other=${r.others}`, ); } if (r.errors && r.errors.length > 0) { const details = r.errors.map((e) => `${e.description}(${e.count})`).join(", "); throw new Error(`Transport errors: ${details}`); } return { rps: r.rps.mean, avgLatency: r.latency.mean, p50: r.latency.percentiles["50"] ?? 0, p99: r.latency.percentiles["99"] ?? 0, bytesPerSec: r.bytesRead / r.timeTakenSeconds, }; } const HEADERS = ["Proxy", "Req/s", "Scale", "Avg", "P50", "P99", "Throughput"]; function printTable(title: string, results: [name: string, result: BenchResult][]) { // Sort by req/s descending results.sort((a, b) => b[1].rps - a[1].rps); const bestRps = Math.max(...results.map(([, r]) => r.rps)); // Build rows const rows = results.map(([name, r]) => { const ratio = bestRps > 0 ? r.rps / bestRps : 0; return [ name, r.rps.toFixed(0), ratio >= 1 ? "1.00x" : `${ratio.toFixed(2)}x`, formatNs(r.avgLatency), formatNs(r.p50), formatNs(r.p99), formatThroughput(r.bytesPerSec), ]; }); // Compute column widths from headers + data const colWidths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length))); const mdRow = (cells: string[]) => `| ${cells.map((c, i) => (i === 0 ? c.padEnd(colWidths[i]!) : c.padStart(colWidths[i]!))).join(" | ")} |`; console.log(); console.log(`### ${title}`); console.log(); console.log(mdRow(HEADERS)); console.log( `| ${colWidths.map((w, i) => (i === 0 ? `:${"-".repeat(w - 1)}` : `${"-".repeat(w - 1)}:`)).join(" | ")} |`, ); for (const row of rows) { console.log(mdRow(row)); } } // --- Main --- process.on("exit", cleanup); process.on("SIGINT", () => process.exit(1)); process.on("SIGTERM", () => process.exit(1)); info("Running validation tests..."); execSync("node bench/test.ts", { stdio: "inherit", cwd: `${import.meta.dirname}/..`, }); ok("All implementations valid"); info("Building image..."); execSync(`docker build -t ${IMAGE} -f bench/Dockerfile .`, { stdio: "inherit", cwd: `${import.meta.dirname}/..`, }); info("Starting target server..."); startContainer("bench-target", "bench/src/target.ts", TARGET_PORT); waitForReady(TARGET_PORT); ok("target ready"); info("Starting proxy servers..."); for (const { name, script, port } of PROXIES) { const containerName = `bench-${name.replaceAll(" ", "-").replaceAll(/[@/]/g, "")}`; startContainer(containerName, script, port); } for (const { name, port } of PROXIES) { waitForReady(port); ok(`${name} ready`); } console.log(); async function runBench(label: string, extraArgs: string[] = []) { info( `Benchmarking ${label} (duration=${DURATION}, connections=${CONNECTIONS}${SEQUENTIAL ? ", sequential" : ""})`, ); const benchOne = async ({ name, port }: (typeof PROXIES)[number]) => { const json = await bombJson([ "-c", String(CONNECTIONS), "-d", DURATION, ...extraArgs, `http://127.0.0.1:${port}/`, ]); const result = parseResult(json); ok(`${name} — ${formatResult(result)}`); return [name, result] as [string, BenchResult]; }; let results: [string, BenchResult][]; if (SEQUENTIAL) { results = []; for (const proxy of PROXIES) { results.push(await benchOne(proxy)); } } else { results = await Promise.all(PROXIES.map(benchOne)); } console.log(); return results; } const getResults = await runBench("GET"); const postResults = await runBench("POST ~1KB JSON", [ "-m", "POST", "-H", "Content-Type: application/json", "-b", POST_BODY, ]); // --- Summary --- console.log(); info("Summary"); console.log(); console.log( `> Duration: **${DURATION}** | Connections: **${CONNECTIONS}** | Mode: **${SEQUENTIAL ? "sequential" : "parallel"}**`, ); printTable("GET (no body)", getResults); console.log(); printTable("POST (~1KB JSON)", postResults); console.log(); info("Done!"); ================================================ FILE: bench/package.json ================================================ { "name": "bench", "private": true, "type": "module", "devDependencies": { "@fastify/http-proxy": "^11.4.2", "@types/http-proxy": "^1.17.17", "fast-proxy": "^2.2.0", "fastify": "^5.8.4", "http-proxy": "^1.18.1", "http-proxy-3": "^1.23.2", "mitata": "^1.0.34" } } ================================================ FILE: bench/src/fast-proxy.ts ================================================ import http from "node:http"; import fastProxy from "fast-proxy"; const PORT = Number(process.env.PORT) || 3003; const TARGET = process.env.TARGET || "http://target:3000"; const { proxy } = fastProxy({ base: TARGET }); const server = http.createServer((req, res) => { proxy(req, res, req.url!, {}); }); server.listen(PORT, "0.0.0.0", () => { console.log(`fast-proxy listening on :${PORT} -> ${TARGET}`); }); ================================================ FILE: bench/src/fastify.ts ================================================ import Fastify from "fastify"; import httpProxy from "@fastify/http-proxy"; const PORT = Number(process.env.PORT) || 3004; const TARGET = process.env.TARGET || "http://target:3000"; const app = Fastify(); await app.register(httpProxy, { upstream: TARGET }); await app.listen({ port: PORT, host: "0.0.0.0" }); console.log(`@fastify/http-proxy listening on :${PORT} -> ${TARGET}`); ================================================ FILE: bench/src/http-proxy-3.ts ================================================ import http from "node:http"; import { createProxyServer } from "http-proxy-3"; const PORT = Number(process.env.PORT) || 3005; const TARGET = process.env.TARGET || "http://target:3000"; const proxy = createProxyServer({ target: TARGET }); const server = http.createServer((req, res) => { proxy.web(req, res); }); server.listen(PORT, "0.0.0.0", () => { console.log(`http-proxy-3 listening on :${PORT} -> ${TARGET}`); }); ================================================ FILE: bench/src/http-proxy.ts ================================================ import http from "node:http"; import httpProxy from "http-proxy"; const PORT = Number(process.env.PORT) || 3006; const TARGET = process.env.TARGET || "http://target:3000"; const proxy = httpProxy.createProxyServer({ target: TARGET }); const server = http.createServer((req, res) => { proxy.web(req, res); }); server.listen(PORT, "0.0.0.0", () => { console.log(`http-proxy listening on :${PORT} -> ${TARGET}`); }); ================================================ FILE: bench/src/httpxy-fetch.ts ================================================ import http from "node:http"; import { proxyFetch } from "../../src/index.ts"; const PORT = Number(process.env.PORT) || 3002; const TARGET = process.env.TARGET || "http://target:3000"; function collectBody(req: http.IncomingMessage): Promise { if (req.method === "GET" || req.method === "HEAD") { return Promise.resolve(undefined); } return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c: Buffer) => chunks.push(c)); req.on("end", () => resolve(chunks.length > 0 ? Buffer.concat(chunks) : undefined)); }); } const server = http.createServer(async (req, res) => { const body = await collectBody(req); const response = await proxyFetch(TARGET, new URL(req.url!, `http://127.0.0.1:${PORT}`), { method: req.method, headers: req.headers as HeadersInit, body: body as any, }); res.writeHead(response.status, Object.fromEntries(response.headers)); if (response.body) { for await (const chunk of response.body) { res.write(chunk); } } res.end(); }); server.listen(PORT, "0.0.0.0", () => { console.log(`httpxy proxyFetch proxy listening on :${PORT} -> ${TARGET}`); }); ================================================ FILE: bench/src/httpxy-server.ts ================================================ import http from "node:http"; import { createProxyServer } from "../../src/index.ts"; const PORT = Number(process.env.PORT) || 3001; const TARGET = process.env.TARGET || "http://target:3000"; const proxy = createProxyServer({ target: TARGET }); const server = http.createServer((req, res) => { proxy.web(req, res); }); server.listen(PORT, "0.0.0.0", () => { console.log(`httpxy server proxy listening on :${PORT} -> ${TARGET}`); }); ================================================ FILE: bench/src/target.ts ================================================ import http from "node:http"; const PORT = Number(process.env.PORT) || 3000; const server = http.createServer((req, res) => { if (req.method === "GET") { res.writeHead(200, { "content-type": "application/json" }); res.end('{"ok":true}'); return; } const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { const body = Buffer.concat(chunks); res.writeHead(200, { "content-type": req.headers["content-type"] || "application/octet-stream", "content-length": String(body.length), }); res.end(body); }); }); server.listen(PORT, "0.0.0.0", () => { console.log(`target listening on :${PORT}`); }); ================================================ FILE: bench/test.ts ================================================ #!/usr/bin/env node import http from "node:http"; import { createProxyServer, proxyFetch } from "../src/index.ts"; import fastProxy from "fast-proxy"; import { createProxyServer as createHttpProxy3 } from "http-proxy-3"; import httpProxyLegacy from "http-proxy"; import Fastify from "fastify"; import httpProxy from "@fastify/http-proxy"; // --- Config --- const TARGET_PORT = 9_900; const HTTPXY_SERVER_PORT = 9_901; const HTTPXY_FETCH_PORT = 9_902; const FAST_PROXY_PORT = 9_903; const FASTIFY_PROXY_PORT = 9_904; const HTTP_PROXY_3_PORT = 9_905; const HTTP_PROXY_PORT = 9_906; const SMALL_BODY = JSON.stringify({ message: "hello world", ts: Date.now() }); const LARGE_BODY = JSON.stringify({ data: Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `item-${i}`, value: Math.random(), tags: ["a", "b", "c"], })), }); // --- Target server (echo) --- function createTargetServer(): Promise { return new Promise((resolve) => { const server = http.createServer((req, res) => { if (req.method === "GET") { res.writeHead(200, { "content-type": "application/json" }); res.end('{"ok":true}'); return; } const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { res.writeHead(200, { "content-type": req.headers["content-type"] || "application/octet-stream", "content-length": String(Buffer.concat(chunks).length), }); res.end(Buffer.concat(chunks)); }); }); server.listen(TARGET_PORT, () => resolve(server)); }); } // --- Proxy servers setup --- const TARGET = `http://127.0.0.1:${TARGET_PORT}`; async function setupHttpxyServer(): Promise { const proxy = createProxyServer({ target: TARGET }); const server = http.createServer((req, res) => { proxy.web(req, res); }); return new Promise((resolve) => { server.listen(HTTPXY_SERVER_PORT, () => resolve(server)); }); } function collectBody(req: http.IncomingMessage): Promise { if (req.method === "GET" || req.method === "HEAD") { return Promise.resolve(undefined); } return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c: Buffer) => chunks.push(c)); req.on("end", () => resolve(chunks.length > 0 ? Buffer.concat(chunks) : undefined)); }); } async function setupHttpxyFetchServer(): Promise { const server = http.createServer(async (req, res) => { const body = await collectBody(req); const response = await proxyFetch( TARGET, new URL(req.url!, `http://127.0.0.1:${HTTPXY_FETCH_PORT}`), { method: req.method, headers: req.headers as HeadersInit, body: body as any, }, ); res.writeHead(response.status, Object.fromEntries(response.headers)); if (response.body) { for await (const chunk of response.body) { res.write(chunk); } } res.end(); }); return new Promise((resolve) => { server.listen(HTTPXY_FETCH_PORT, () => resolve(server)); }); } async function setupFastProxy(): Promise<{ server: http.Server; close: () => void }> { const { proxy, close } = fastProxy({ base: TARGET }); const server = http.createServer((req, res) => { proxy(req, res, req.url!, {}); }); return new Promise((resolve) => { server.listen(FAST_PROXY_PORT, () => resolve({ server, close })); }); } async function setupFastifyProxy(): Promise> { const app = Fastify(); await app.register(httpProxy, { upstream: TARGET }); await app.listen({ port: FASTIFY_PROXY_PORT }); return app; } async function setupHttpProxy3(): Promise { const proxy = createHttpProxy3({ target: TARGET }); const server = http.createServer((req, res) => { proxy.web(req, res); }); return new Promise((resolve) => { server.listen(HTTP_PROXY_3_PORT, () => resolve(server)); }); } async function setupHttpProxyLegacy(): Promise { const proxy = httpProxyLegacy.createProxyServer({ target: TARGET }); const server = http.createServer((req, res) => { proxy.web(req, res); }); return new Promise((resolve) => { server.listen(HTTP_PROXY_PORT, () => resolve(server)); }); } // --- HTTP helpers --- interface HttpResult { status: number; headers: http.IncomingHttpHeaders; body: string; } function httpGet(port: number, path = "/"): Promise { return new Promise((resolve, reject) => { const req = http.get(`http://127.0.0.1:${port}${path}`, (res) => { const chunks: Buffer[] = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body: Buffer.concat(chunks).toString(), }), ); }); req.on("error", reject); }); } function httpPost(port: number, body: string, path = "/"): Promise { return new Promise((resolve, reject) => { const req = http.request( { hostname: "127.0.0.1", port, path, method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body), }, }, (res) => { const chunks: Buffer[] = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body: Buffer.concat(chunks).toString(), }), ); }, ); req.on("error", reject); req.end(body); }); } // --- Main --- async function main() { console.log("Starting servers..."); const targetServer = await createTargetServer(); const httpxyServer = await setupHttpxyServer(); const httpxyFetchServer = await setupHttpxyFetchServer(); const fastProxySetup = await setupFastProxy(); const fastifyApp = await setupFastifyProxy(); const httpProxy3Server = await setupHttpProxy3(); const httpProxyLegacyServer = await setupHttpProxyLegacy(); console.log("Validating proxy implementations..."); const proxies = [ { name: "httpxy server", port: HTTPXY_SERVER_PORT }, { name: "httpxy proxyFetch", port: HTTPXY_FETCH_PORT }, { name: "fast-proxy", port: FAST_PROXY_PORT }, { name: "@fastify/http-proxy", port: FASTIFY_PROXY_PORT }, { name: "http-proxy-3", port: HTTP_PROXY_3_PORT }, { name: "http-proxy", port: HTTP_PROXY_PORT }, ]; let allValid = true; for (const { name, port } of proxies) { const errors: string[] = []; const getRes = await httpGet(port); if (getRes.status !== 200) { errors.push(`GET status=${getRes.status}, expected 200`); } if (getRes.body !== '{"ok":true}') { errors.push(`GET body=${JSON.stringify(getRes.body)}, expected '{"ok":true}'`); } const postSmall = await httpPost(port, SMALL_BODY); if (postSmall.status !== 200) { errors.push(`POST(1KB) status=${postSmall.status}, expected 200`); } if (postSmall.body !== SMALL_BODY) { errors.push( `POST(1KB) body mismatch: got ${postSmall.body.length} bytes, expected ${SMALL_BODY.length}`, ); } const postLarge = await httpPost(port, LARGE_BODY); if (postLarge.status !== 200) { errors.push(`POST(100KB) status=${postLarge.status}, expected 200`); } if (postLarge.body !== LARGE_BODY) { errors.push( `POST(100KB) body mismatch: got ${postLarge.body.length} bytes, expected ${LARGE_BODY.length}`, ); } if (errors.length > 0) { allValid = false; console.log(` FAIL ${name}`); for (const e of errors) { console.log(` - ${e}`); } } else { console.log(` OK ${name}`); } } // Cleanup targetServer.close(); httpxyServer.close(); httpxyFetchServer.close(); fastProxySetup.server.close(); fastProxySetup.close(); await fastifyApp.close(); httpProxy3Server.close(); httpProxyLegacyServer.close(); if (!allValid) { console.error("\nValidation failed."); process.exit(1); } console.log("\nAll implementations valid."); } main().catch((err) => { console.error(err); process.exit(1); }); ================================================ FILE: build.config.mjs ================================================ import { defineBuildConfig } from "obuild/config"; export default defineBuildConfig({ entries: [ { type: "bundle", input: ["./src/index.ts"], }, ], }); ================================================ FILE: package.json ================================================ { "name": "httpxy", "version": "0.5.1", "description": "A full-featured HTTP proxy for Node.js.", "license": "MIT", "repository": "unjs/httpxy", "files": [ "dist" ], "type": "module", "sideEffects": false, "types": "./dist/index.d.mts", "exports": { ".": "./dist/index.mjs" }, "scripts": { "build": "obuild", "dev": "vitest", "lint": "oxlint . && oxfmt --check", "fmt": "oxlint . --fix && oxfmt", "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", "typecheck": "tsgo --noEmit" }, "devDependencies": { "@types/async": "^3.2.25", "@types/concat-stream": "^2.0.3", "@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", "async": "^3.2.6", "changelogen": "^0.6.2", "concat-stream": "^2.0.0", "eslint-config-unjs": "^0.6.2", "expect.js": "^0.3.1", "obuild": "^0.4.33", "ofetch": "^1.5.1", "oxfmt": "^0.46.0", "oxlint": "^1.61.0", "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", "ws": "^8.20.0" }, "packageManager": "pnpm@10.33.0" } ================================================ FILE: playground/index.ts ================================================ import http from "node:http"; import { createProxyServer } from "../src/index.ts"; async function main() { const main = http.createServer((req, res) => { res.end( JSON.stringify({ method: req.method, path: req.url, headers: req.headers, }), ); }); await new Promise((resolve) => { main.listen(3000, "127.0.0.1", resolve); }); const httpProxy = createProxyServer(); const proxy = http.createServer(async (req, res) => { try { await httpProxy.web(req, res, { target: "http://127.0.0.1:3000", }); } catch (error) { console.error(error); res.statusCode = 500; res.end("Proxy error: " + (error as Error).toString()); } }); await new Promise((resolve) => { proxy.listen(3001, "127.0.0.1", resolve); }); console.log("main: http://127.0.0.1:3000"); console.log("proxy: http://127.0.0.1:3001"); } // eslint-disable-next-line unicorn/prefer-top-level-await main(); ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - bench ================================================ FILE: renovate.json ================================================ { "extends": ["github>unjs/renovate-config"] } ================================================ FILE: src/_utils.ts ================================================ import httpNative from "node:http"; import httpsNative from "node:https"; import net from "node:net"; import type { ProxyAddr, ProxyServerOptions, ProxyTarget, ProxyTargetDetailed } from "./types.ts"; import type { Http2ServerRequest } from "node:http2"; const upgradeHeader = /(^|,)\s*upgrade\s*($|,)/i; /** * Default keep-alive agents for connection reuse. */ export const defaultAgents = { http: new httpNative.Agent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 64 }), https: new httpsNative.Agent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 64 }), }; /** * Simple Regex for testing if protocol is https */ export const isSSL = /^https|wss/; /** * Node.js HTTP/2 accepts pseudo headers and it may conflict * with request options. * * Let's just blacklist those potential conflicting pseudo * headers. */ const HTTP2_HEADER_BLACKLIST = [":method", ":path", ":scheme", ":authority"]; /** * Copies the right headers from `options` and `req` to * `outgoing` which is then used to fire the proxied * request. * * Examples: * * common.setupOutgoing(outgoing, options, req) * // => { host: ..., hostname: ...} * * @param outgoing Base object to be filled with required properties * @param options Config object passed to the proxy * @param req Request Object * @param forward String to select forward or target * * @return Outgoing Object with all required properties set * * @api private */ export function setupOutgoing( outgoing: httpNative.RequestOptions & httpsNative.RequestOptions, options: ProxyServerOptions & { target: ProxyTarget; forward?: ProxyTarget; ca?: string; method?: string; }, req: httpNative.IncomingMessage | Http2ServerRequest, forward?: "forward" | "target", ): httpNative.RequestOptions | httpsNative.RequestOptions { outgoing.port = (options[forward || "target"] as URL).port || (isSSL.test((options[forward || "target"] as URL).protocol ?? "http") ? 443 : 80); for (const e of [ "host", "hostname", "socketPath", "pfx", "key", "passphrase", "cert", "ca", "ciphers", "secureProtocol", ] as const) { const value = (options[forward || "target"] as ProxyTargetDetailed)[e]; if (value !== undefined) { outgoing[e] = value as any; } } outgoing.method = options.method || req.method; outgoing.headers = { ...req.headers }; // before clean up HTTP/2 blacklist header, we might wanna override host first if (req.headers?.[":authority"]) { outgoing.headers.host = req.headers[":authority"] as string; } // host override must happen before composing/merging the final outgoing headers if (options.headers) { for (const key of Object.keys(options.headers)) { outgoing.headers[key] = options.headers[key]; } } if (req.httpVersionMajor > 1) { // ignore potential conflicting HTTP/2 pseudo headers for (const header of HTTP2_HEADER_BLACKLIST) { delete outgoing.headers[header]; } } if (options.auth) { outgoing.auth = options.auth; } if (options.ca) { outgoing.ca = options.ca; } if (isSSL.test((options[forward || "target"] as URL).protocol ?? "http")) { outgoing.rejectUnauthorized = options.secure === undefined ? true : options.secure; } if (options.agent !== undefined) { outgoing.agent = options.agent || false; } else if (req.httpVersionMajor > 1 || upgradeHeader.test(req.headers.connection || "")) { // WebSocket upgrades and HTTP/2 incoming requests: agents conflict with // the socket lifecycle (upgrade handoff / stream multiplexing). outgoing.agent = false; } else { // Use default keep-alive agents for connection reuse const targetProto = (options[forward || "target"] as URL).protocol ?? "http"; outgoing.agent = isSSL.test(targetProto) ? defaultAgents.https : defaultAgents.http; } outgoing.localAddress = options.localAddress; // // Remark: If we are false and not upgrading, set the connection: close. This is the right thing to do // as node core doesn't handle this COMPLETELY properly yet. // if (!outgoing.agent) { outgoing.headers = outgoing.headers || {}; if ( typeof outgoing.headers.connection !== "string" || !upgradeHeader.test(outgoing.headers.connection) ) { outgoing.headers.connection = "close"; } } // the final path is target path + relative path requested by user: const target = options[forward || "target"]; const targetPath = target && options.prependPath !== false ? (target as URL).pathname || "" : ""; const targetSearch = target instanceof URL && options.prependPath !== false ? target.search || "" : ""; const reqUrl = req.url || ""; const qIdx = reqUrl.indexOf("?"); const reqPath = qIdx === -1 ? reqUrl : reqUrl.slice(0, qIdx); const reqSearch = qIdx === -1 ? "" : reqUrl.slice(qIdx); const normalizedPath = reqPath ? (reqPath[0] === "/" ? reqPath : "/" + reqPath) : "/"; let outgoingPath = options.toProxy ? "/" + reqUrl : normalizedPath + reqSearch; // // Remark: ignorePath will just straight up ignore whatever the request's // path is. This can be labeled as FOOT-GUN material if you do not know what // you are doing and are using conflicting options. // outgoingPath = options.ignorePath ? "" : outgoingPath; let fullPath = joinURL(targetPath, outgoingPath); // Merge target query string into the outgoing path if (targetSearch) { const hasQuery = fullPath.includes("?"); fullPath = hasQuery ? fullPath.replace("?", targetSearch + "&") : fullPath + targetSearch; } outgoing.path = fullPath; if (options.changeOrigin) { outgoing.headers.host = requiresPort(outgoing.port, (options[forward || "target"] as URL).protocol) && !hasPort(outgoing.host) ? outgoing.host + ":" + outgoing.port : (outgoing.host ?? undefined); } return outgoing; } // From https://github.com/unjs/h3/blob/e8adfa/src/utils/internal/path.ts#L16C1-L36C2 export function joinURL(base: string | undefined, path: string | undefined): string { if (!base || base === "/") { return path || "/"; } if (!path || path === "/") { return base || "/"; } // eslint-disable-next-line unicorn/prefer-at const baseHasTrailing = base[base.length - 1] === "/"; const pathHasLeading = path[0] === "/"; if (baseHasTrailing && pathHasLeading) { return base + path.slice(1); } if (!baseHasTrailing && !pathHasLeading) { return base + "/" + path; } return base + path; } /** * Set the proper configuration for sockets, * set no delay and set keep alive, also set * the timeout to 0. * * Examples: * * common.setupSocket(socket) * // => Socket * * @param socket instance to setup * * @return Return the configured socket. * * @api private */ export function setupSocket(socket: net.Socket): net.Socket { socket.setTimeout(0); socket.setNoDelay(true); socket.setKeepAlive(true, 0); return socket; } /** * Get the port number from the host. Or guess it based on the connection type. * * @param req Incoming HTTP request. * * @return The port number. * * @api private */ export function getPort(req: httpNative.IncomingMessage | Http2ServerRequest): string { const hostHeader = (req.headers[":authority"] as string | undefined) || req.headers.host; const res = hostHeader ? hostHeader.match(/:(\d+)/) : ""; if (res) { return res[1]!; } return hasEncryptedConnection(req) ? "443" : "80"; } /** * Check if the request has an encrypted connection. * * @param req Incoming HTTP request. * * @return Whether the connection is encrypted or not. * * @api private */ export function hasEncryptedConnection( req: httpNative.IncomingMessage | Http2ServerRequest, ): boolean { const socket = req.socket; return !!socket && "encrypted" in socket && socket.encrypted; } /** * Rewrites or removes the domain of a cookie header * * @param header * @param config, mapping of domain to rewritten domain. * '*' key to match any domain, null value to remove the domain. * * @api private */ export function rewriteCookieProperty( header: string, config: Record, property: string, ): string; export function rewriteCookieProperty( header: string | string[], config: Record, property: string, ): string | string[]; export function rewriteCookieProperty( header: string | string[], config: Record, property: string, ): string | string[] { if (Array.isArray(header)) { return header.map(function (headerElement) { return rewriteCookieProperty(headerElement, config, property); }); } return header.replace( new RegExp(String.raw`(;\s*` + property + "=)([^;]+)", "i"), function (match, prefix, previousValue) { let newValue; if (previousValue in config) { newValue = config[previousValue]; } else if ("*" in config) { newValue = config["*"]; } else { // no match, return previous value return match; } // replace or remove value return newValue ? prefix + newValue : ""; }, ); } /** * Parse and validate a proxy address. * * @param addr - URL string (`http://host:port`, `ws://host:port`, `unix:/path`) or a `ProxyAddr` object. * * @api private */ export function parseAddr(addr: string | ProxyAddr): ProxyAddr { if (typeof addr === "string") { if (addr.startsWith("unix:")) { return { socketPath: addr.slice(5) }; } const url = new URL(addr); return { host: url.hostname, port: Number(url.port) || (isSSL.test(url.protocol) ? 443 : 80), }; } if (!addr.socketPath && !addr.port) { throw new Error("ProxyAddr must have either `port` or `socketPath`"); } return addr; } /** * Check the host and see if it potentially has a port in it (keep it simple) * * @returns Whether we have one or not * * @api private */ export function hasPort(host: string | null | undefined): boolean { return host ? !!~host.indexOf(":") : false; } /** * Check if the port is required for the protocol * * Ported from https://github.com/unshiftio/requires-port/blob/master/index.js * * @returns Whether the port is required for the protocol * * @api private */ export function requiresPort(_port: string | number, _protocol: string | undefined): boolean { const protocol = _protocol?.split(":")[0]; const port = +_port; if (!port) return false; switch (protocol) { case "http": case "ws": { return port !== 80; } case "https": case "wss": { return port !== 443; } case "ftp": { return port !== 21; } case "gopher": { return port !== 70; } case "file": { return false; } } return port !== 0; } ================================================ FILE: src/fetch.ts ================================================ import type { IncomingMessage, RequestOptions } from "node:http"; import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; import { Readable } from "node:stream"; import type { ProxyAddr } from "./types.ts"; import { defaultAgents, isSSL, joinURL, parseAddr } from "./_utils.ts"; /** * Options for {@link proxyFetch}. */ export interface ProxyFetchOptions { /** * Timeout in milliseconds for the upstream request. * Rejects with an error if the upstream does not respond within this time. */ timeout?: number; /** * Add `x-forwarded-for`, `x-forwarded-port`, `x-forwarded-proto`, and * `x-forwarded-host` headers derived from the input URL. * Default: `false`. */ xfwd?: boolean; /** * Rewrite the `Host` header to match the target address. * Default: `false` (original host from the input URL is kept). */ changeOrigin?: boolean; /** * HTTP agent for connection pooling / reuse. * Default: `false` (no agent, no keep-alive). */ agent?: any; /** * Follow HTTP redirects from the upstream. * `true` = max 5 hops; number = custom max. * Default: `false` (manual redirect, raw 3xx responses are returned). */ followRedirects?: boolean | number; /** * TLS options forwarded to `https.request` (e.g. `{ rejectUnauthorized: false }`). * Also controls certificate verification — set `rejectUnauthorized: false` to skip. * Default: none. */ ssl?: Record; } /** * Proxy a request to a specific server address (TCP host/port or Unix socket) * using web standard {@link Request}/{@link Response} interfaces. * * Supports both HTTP and HTTPS upstream targets. * * @param addr - The target server address. Can be a URL string (`http://host:port`, `https://host:port`, `unix:/path`), or an object with `host`/`port` for TCP or `socketPath` for Unix sockets. * @param input - The request URL (string or URL) or a {@link Request} object. * @param inputInit - Optional {@link RequestInit} or {@link Request} to override method, headers, and body. * @param opts - Optional proxy options. */ export async function proxyFetch( addr: string | ProxyAddr, input: string | URL | Request, inputInit?: RequestInit | Request, opts?: ProxyFetchOptions, ) { const resolvedAddr = parseAddr(addr); // Detect protocol and base path from addr string let useHTTPS = false; let addrBasePath = ""; if (typeof addr === "string" && !addr.startsWith("unix:")) { const addrURL = new URL(addr); useHTTPS = isSSL.test(addrURL.protocol); if (addrURL.pathname && addrURL.pathname !== "/") { addrBasePath = addrURL.pathname; } } let url: URL; let init: RequestInit | undefined; if (input instanceof Request) { url = new URL(input.url); init = { ...toInit(input), ...toInit(inputInit), }; } else { url = new URL(input); init = toInit(inputInit); } init = { redirect: "manual", ...init, }; if (init.body) { (init as RequestInit & { duplex: string }).duplex = "half"; } // Merge addr base path with request path const requestPath = url.pathname + url.search; const path = addrBasePath ? joinURL(addrBasePath, requestPath) : requestPath; const reqHeaders: Record = {}; if (init.headers) { // Fast path: plain object — direct assign, no iteration needed if (!(init.headers instanceof Headers) && !Array.isArray(init.headers)) { Object.assign(reqHeaders, init.headers); } else { // Headers or [key, value][] — both are iterable pairs for (const [key, value] of init.headers as Iterable<[string, string]>) { const existing = reqHeaders[key]; if (existing === undefined) { reqHeaders[key] = value; } else { reqHeaders[key] = Array.isArray(existing) ? [...existing, value] : [existing, value]; } } } } // Add x-forwarded-* headers derived from the input URL if (opts?.xfwd) { if (!reqHeaders["x-forwarded-for"]) { reqHeaders["x-forwarded-for"] = url.hostname; } if (!reqHeaders["x-forwarded-port"]) { reqHeaders["x-forwarded-port"] = url.port || (url.protocol === "https:" ? "443" : "80"); } if (!reqHeaders["x-forwarded-proto"]) { reqHeaders["x-forwarded-proto"] = url.protocol.replace(":", ""); } if (!reqHeaders["x-forwarded-host"]) { reqHeaders["x-forwarded-host"] = url.host; } } // Rewrite Host header to match the target address if (opts?.changeOrigin) { if (resolvedAddr.socketPath) { reqHeaders.host = "localhost"; } else { const targetHost = resolvedAddr.host || "localhost"; const targetPort = resolvedAddr.port; const defaultPort = useHTTPS ? 443 : 80; reqHeaders.host = targetPort && targetPort !== defaultPort ? `${targetHost}:${targetPort}` : targetHost; } } const maxRedirects = typeof opts?.followRedirects === "number" ? opts.followRedirects : opts?.followRedirects ? 5 : 0; // Buffer body only when redirects need replay; otherwise stream through const body = maxRedirects > 0 ? await _bufferBody(init.body) : _toNodeStream(init.body); // Default to keep-alive agent for connection reuse const agent = opts?.agent !== undefined ? opts.agent || false : useHTTPS ? defaultAgents.https : defaultAgents.http; const res = await _sendRequest( useHTTPS ? httpsRequest : httpRequest, init.method || "GET", path, reqHeaders, resolvedAddr, body, { signal: init.signal || undefined, agent, timeout: opts?.timeout, ssl: opts?.ssl, maxRedirects, redirectCount: 0, originalHeaders: reqHeaders, }, ); // Build Response — use plain header pairs to avoid Headers object overhead const resHeaders: [string, string][] = []; const rawHeaders = res.rawHeaders; for (let i = 0; i < rawHeaders.length; i += 2) { const key = rawHeaders[i]!; const keyLower = key.toLowerCase(); if ( keyLower === "transfer-encoding" || keyLower === "keep-alive" || keyLower === "connection" ) { continue; } resHeaders.push([key, rawHeaders[i + 1]!]); } const hasBody = res.statusCode !== 204 && res.statusCode !== 304; return new Response(hasBody ? (Readable.toWeb(res) as ReadableStream) : null, { status: res.statusCode, statusText: res.statusMessage, headers: resHeaders, }); } // --- Internal --- function toInit(init?: RequestInit | Request): RequestInit | undefined { if (!init) { return undefined; } if (init instanceof Request) { return { method: init.method, headers: init.headers, body: init.body, duplex: init.body ? "half" : undefined, } as RequestInit; } return init; } /** Convert body to a Node.js Readable or Buffer for streaming without buffering. */ function _toNodeStream(body: BodyInit | null | undefined): Readable | Buffer | undefined { if (!body) { return undefined; } if (typeof body === "string") { return Buffer.from(body); } if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { return Buffer.from(body as ArrayBuffer); } if (body instanceof ReadableStream) { return Readable.fromWeb(body as import("node:stream/web").ReadableStream); } if (body instanceof Blob) { return Readable.fromWeb(body.stream() as import("node:stream/web").ReadableStream); } return Buffer.from(String(body)); } /** Normalize any body type to Buffer (or undefined) for redirect replay. */ async function _bufferBody(body: BodyInit | null | undefined): Promise { if (!body) { return undefined; } if (typeof body === "string") { return Buffer.from(body); } if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { return Buffer.from(body as ArrayBuffer); } if (body instanceof ReadableStream) { const readable = Readable.fromWeb(body as import("node:stream/web").ReadableStream); const chunks: Buffer[] = []; for await (const chunk of readable) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks); } if (body instanceof Blob) { return Buffer.from(await body.arrayBuffer()); } return Buffer.from(String(body)); } const _redirectStatuses = new Set([301, 302, 303, 307, 308]); interface _RequestOpts { signal?: AbortSignal; agent?: any; timeout?: number; ssl?: Record; maxRedirects: number; redirectCount: number; originalHeaders: Record; } function _sendRequest( doRequest: typeof httpRequest, method: string, path: string, headers: Record, addr: ProxyAddr, body: Buffer | Readable | undefined, opts: _RequestOpts, ): Promise { return new Promise((resolve, reject) => { const reqOpts: RequestOptions = { method, path, headers, agent: opts.agent, }; if (addr.socketPath) { reqOpts.socketPath = addr.socketPath; } else { reqOpts.hostname = addr.host || "localhost"; reqOpts.port = addr.port; } if (opts.signal) { reqOpts.signal = opts.signal; } if (opts.ssl) { Object.assign(reqOpts, opts.ssl); } const req = doRequest(reqOpts, (res) => { const statusCode = res.statusCode!; if ( opts.maxRedirects > 0 && _redirectStatuses.has(statusCode) && opts.redirectCount < opts.maxRedirects && res.headers.location ) { res.resume(); const currentURL = new URL(path, `http://${addr.host || "localhost"}:${addr.port || 80}`); const location = new URL(res.headers.location, currentURL); const redirectHTTPS = isSSL.test(location.protocol); const preserveMethod = statusCode === 307 || statusCode === 308; const redirectMethod = preserveMethod ? method : "GET"; const redirectHeaders: Record = { ...opts.originalHeaders, }; redirectHeaders.host = location.host; if (location.host !== currentURL.host) { delete redirectHeaders.authorization; delete redirectHeaders.cookie; } if (!preserveMethod) { delete redirectHeaders["content-length"]; delete redirectHeaders["content-type"]; delete redirectHeaders["transfer-encoding"]; } _sendRequest( redirectHTTPS ? httpsRequest : httpRequest, redirectMethod, location.pathname + location.search, redirectHeaders, { host: location.hostname, port: Number(location.port) || (redirectHTTPS ? 443 : 80), }, preserveMethod ? body : undefined, { ...opts, redirectCount: opts.redirectCount + 1 }, ).then(resolve, reject); return; } resolve(res); }); req.on("error", reject); if (opts.timeout) { req.setTimeout(opts.timeout, () => { req.destroy(new Error("Proxy request timed out")); }); } if (body instanceof Readable) { body.on("error", (err) => { req.destroy(err); reject(err); }); body.pipe(req); } else if (body) { req.end(body); } else { req.end(); } }); } ================================================ FILE: src/index.ts ================================================ export type { ProxyAddr, ProxyServerOptions, ProxyTarget, ProxyTargetDetailed } from "./types.ts"; export { ProxyServer, createProxyServer, type ProxyServerEventMap } from "./server.ts"; export { proxyFetch, type ProxyFetchOptions } from "./fetch.ts"; export { proxyUpgrade, type ProxyUpgradeOptions } from "./ws.ts"; ================================================ FILE: src/middleware/_utils.ts ================================================ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Socket } from "node:net"; import type { ProxyServer } from "../server.ts"; import type { ProxyServerOptions, ProxyTargetDetailed } from "../types.ts"; import type { Http2ServerRequest, Http2ServerResponse } from "node:http2"; export type ResOfType = T extends "ws" ? T extends "web" ? ServerResponse | Http2ServerResponse | Socket : Socket : T extends "web" ? ServerResponse | Http2ServerResponse : never; export type ProxyMiddleware = ( req: IncomingMessage | Http2ServerRequest, res: T, opts: ProxyServerOptions & { target: URL | ProxyTargetDetailed; forward: URL; }, server: ProxyServer, head?: Buffer, callback?: (err: any, req: IncomingMessage | Http2ServerRequest, socket: T, url?: any) => void, ) => void | true; export function defineProxyMiddleware( m: ProxyMiddleware, ) { return m; } export type ProxyOutgoingMiddleware = ( req: IncomingMessage | Http2ServerRequest, res: ServerResponse | Http2ServerResponse, proxyRes: IncomingMessage, opts: ProxyServerOptions & { target: URL | ProxyTargetDetailed; forward: URL; }, ) => void | true; export function defineProxyOutgoingMiddleware(m: ProxyOutgoingMiddleware) { return m; } ================================================ FILE: src/middleware/web-incoming.ts ================================================ import type { ClientRequest, IncomingMessage, ServerResponse } from "node:http"; import type { ProxyTargetDetailed } from "../types.ts"; import nodeHTTP from "node:http"; import nodeHTTPS from "node:https"; import { getPort, hasEncryptedConnection, isSSL, setupOutgoing } from "../_utils.ts"; import { webOutgoingMiddleware } from "./web-outgoing.ts"; import { type ProxyMiddleware, defineProxyMiddleware } from "./_utils.ts"; const nativeAgents = { http: nodeHTTP, https: nodeHTTPS }; const redirectStatuses = new Set([301, 302, 303, 307, 308]); /** * Sets `content-length` to '0' if request is of DELETE type. */ export const deleteLength = defineProxyMiddleware((req) => { if ((req.method === "DELETE" || req.method === "OPTIONS") && !req.headers["content-length"]) { req.headers["content-length"] = "0"; delete req.headers["transfer-encoding"]; } }); /** * Sets timeout in request socket if it was specified in options. */ export const timeout = defineProxyMiddleware((req, res, options) => { if (options.timeout) { req.socket.setTimeout(options.timeout, () => { req.socket.destroy(); }); } }); /** * Sets `x-forwarded-*` headers if specified in config. */ export const XHeaders = defineProxyMiddleware((req, res, options) => { if (!options.xfwd) { return; } const encrypted = (req as any).isSpdy || hasEncryptedConnection(req); const values = { for: req.connection.remoteAddress || req.socket.remoteAddress, port: getPort(req), proto: encrypted ? "https" : "http", }; for (const header of ["for", "port", "proto"] as const) { const key = "x-forwarded-" + header; if (!req.headers[key] && values[header] !== undefined) { req.headers[key] = values[header]; } } req.headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || req.headers[":authority"] || req.headers.host || ""; }); /** * Does the actual proxying. If `forward` is enabled fires up * a ForwardStream, same happens for ProxyStream. The request * just dies otherwise. * */ export const stream = defineProxyMiddleware((req, res, options, server, head, callback) => { // And we begin! server.emit("start", req, res, options.target || options.forward); const http = nativeAgents.http; const https = nativeAgents.https; const maxRedirects = typeof options.followRedirects === "number" ? options.followRedirects : options.followRedirects ? 5 : 0; if (options.forward) { // If forward enable, so just pipe the request const forwardReq = (isSSL.test(options.forward.protocol || "http") ? https : http).request( setupOutgoing(options.ssl || {}, options, req, "forward"), ); // error handler (e.g. ECONNRESET, ECONNREFUSED) // Handle errors on incoming request as well as it makes sense to const forwardError = createErrorHandler(forwardReq, options.forward); req.on("error", forwardError); forwardReq.on("error", forwardError); (options.buffer || req).pipe(forwardReq); if (!options.target) { res.end(); return; } } // Request initalization const proxyReq = (isSSL.test(options.target.protocol || "http") ? https : http).request( setupOutgoing(options.ssl || {}, options, req), ); // Enable developers to modify the proxyReq before headers are sent proxyReq.on("socket", (_socket) => { if (server && !proxyReq.getHeader("expect")) { server.emit("proxyReq", proxyReq, req, res, options); } }); // allow outgoing socket to timeout so that we could // show an error page at the initial request if (options.proxyTimeout) { proxyReq.setTimeout(options.proxyTimeout, function () { proxyReq.destroy(); }); } // Abort proxy request when client disconnects res.on("close", function () { if (!res.writableFinished) { proxyReq.destroy(); } }); // handle errors in proxy and incoming request, just like for forward proxy const proxyError = createErrorHandler(proxyReq, options.target); req.on("error", proxyError); proxyReq.on("error", proxyError); function createErrorHandler(proxyReq: ClientRequest, url: URL | ProxyTargetDetailed) { return function proxyError(err: Error) { if (!req.socket?.writable && (err as NodeJS.ErrnoException).code === "ECONNRESET") { server.emit("econnreset", err, req, res, url); return proxyReq.destroy(); } if (callback) { callback(err, req, res, url); } else { server.emit("error", err, req, res, url); } }; } // Buffer request body when following redirects (needed for 307/308 replay) let bodyBuffer: Buffer | undefined; if (maxRedirects > 0) { const chunks: Buffer[] = []; const source = options.buffer || req; source.on("data", (chunk: Buffer) => { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); proxyReq.write(chunk); }); source.on("end", () => { bodyBuffer = Buffer.concat(chunks); proxyReq.end(); }); source.on("error", (err: Error) => { proxyReq.destroy(err); }); } else { proxyReq.on("socket", (socket) => { if (socket.pending) { socket.on("connect", () => (options.buffer || req).pipe(proxyReq)); } else { (options.buffer || req).pipe(proxyReq); } }); } function handleResponse(proxyRes: IncomingMessage, redirectCount: number, currentUrl: URL) { const statusCode = proxyRes.statusCode!; if ( maxRedirects > 0 && redirectStatuses.has(statusCode) && redirectCount < maxRedirects && proxyRes.headers.location ) { // Drain the redirect response body proxyRes.resume(); const location = new URL(proxyRes.headers.location, currentUrl); // 301/302/303 → GET without body; 307/308 → preserve method and body const preserveMethod = statusCode === 307 || statusCode === 308; const redirectMethod = preserveMethod ? req.method || "GET" : "GET"; const isHTTPS = isSSL.test(location.protocol); const agent = isHTTPS ? https : http; // Build headers from original request const redirectHeaders: Record = { ...req.headers }; if (options.headers) { Object.assign(redirectHeaders, options.headers); } redirectHeaders.host = location.host; // Strip sensitive headers on cross-origin redirects if (location.host !== currentUrl.host) { delete redirectHeaders.authorization; delete redirectHeaders.cookie; } // Drop body-related headers when method changes to GET if (!preserveMethod) { delete redirectHeaders["content-length"]; delete redirectHeaders["content-type"]; delete redirectHeaders["transfer-encoding"]; } const redirectOpts: nodeHTTP.RequestOptions = { hostname: location.hostname, port: location.port || (isHTTPS ? 443 : 80), path: location.pathname + location.search, method: redirectMethod, headers: redirectHeaders, agent: options.agent || false, }; if (isHTTPS) { (redirectOpts as nodeHTTPS.RequestOptions).rejectUnauthorized = options.secure === undefined ? true : options.secure; } const redirectReq = agent.request(redirectOpts); if (server && !redirectReq.getHeader("expect")) { server.emit("proxyReq", redirectReq, req, res, options); } if (options.proxyTimeout) { redirectReq.setTimeout(options.proxyTimeout, () => { redirectReq.destroy(); }); } const redirectError = createErrorHandler(redirectReq, location); redirectReq.on("error", redirectError); redirectReq.on("response", (nextRes: IncomingMessage) => { handleResponse(nextRes, redirectCount + 1, location); }); if (preserveMethod && bodyBuffer && bodyBuffer.length > 0) { redirectReq.end(bodyBuffer); } else { redirectReq.end(); } return; } // Non-redirect response (or max redirects exceeded) if (server) { server.emit("proxyRes", proxyRes, req, res); } if (!res.headersSent && !options.selfHandleResponse) { for (const pass of webOutgoingMiddleware) { if (pass(req, res, proxyRes, options)) { break; } } } if (res.finished) { if (server) { server.emit("end", req, res, proxyRes); } } else { res.on("close", function () { proxyRes.destroy(); }); proxyRes.on("close", function () { if (!proxyRes.complete && !res.destroyed) { res.destroy(); } }); proxyRes.on("error", function (err) { if (!res.destroyed) { res.destroy(err); } if (server.listenerCount("error") > 0) { server.emit("error", err, req, res, currentUrl); } }); proxyRes.on("end", function () { if (server) { server.emit("end", req, res, proxyRes); } }); if (!options.selfHandleResponse) { proxyRes.pipe(res); } } } proxyReq.on("response", function (proxyRes) { handleResponse(proxyRes, 0, options.target as URL); }); }); export const webIncomingMiddleware: readonly ProxyMiddleware[] = [ deleteLength, timeout, XHeaders, stream, ] as const; ================================================ FILE: src/middleware/web-outgoing.ts ================================================ import { rewriteCookieProperty } from "../_utils.ts"; import type { ProxyTarget, ProxyTargetDetailed } from "../types.ts"; import { type ProxyOutgoingMiddleware, defineProxyOutgoingMiddleware } from "./_utils.ts"; const redirectRegex = /^201|30([12378])$/; /** * Remove chunked transfer-encoding for HTTP/1.0, HTTP/2, and bodyless (204/304) responses */ export const removeChunked = defineProxyOutgoingMiddleware((req, res, proxyRes) => { // HTTP/1.0 and HTTP/2 do not have transfer-encoding: chunked // 204 and 304 responses MUST NOT contain a message body (RFC 9110) if ( req.httpVersion === "1.0" || req.httpVersionMajor >= 2 || proxyRes.statusCode === 204 || proxyRes.statusCode === 304 ) { delete proxyRes.headers["transfer-encoding"]; } }); /** * If is a HTTP 1.0 request, set the correct connection header * or if connection header not present, then use `keep-alive` * * If is a HTTP/2 request, remove connection header no matter what, * this avoids sending connection header to the underlying http2 client */ export const setConnection = defineProxyOutgoingMiddleware((req, res, proxyRes) => { if (req.httpVersion === "1.0") { proxyRes.headers.connection = req.headers.connection || "close"; } else if (req.httpVersionMajor < 2 && !proxyRes.headers.connection) { proxyRes.headers.connection = req.headers.connection || "keep-alive"; } else if (req.httpVersionMajor >= 2) { delete proxyRes.headers.connection; } }); export const setRedirectHostRewrite = defineProxyOutgoingMiddleware( (req, res, proxyRes, options) => { if ( (options.hostRewrite || options.autoRewrite || options.protocolRewrite) && proxyRes.headers.location && redirectRegex.test(String(proxyRes.statusCode)) ) { const target = _toURL(options.target!); const u = new URL(proxyRes.headers.location, target); // Make sure the redirected host matches the target host before rewriting if (target.host !== u.host) { return; } if (options.hostRewrite) { u.host = options.hostRewrite; } else if (options.autoRewrite) { if (req.headers[":authority"]) { u.host = req.headers[":authority"] as string; } else if (req.headers.host) { u.host = req.headers.host; } } if (options.protocolRewrite) { u.protocol = options.protocolRewrite; } proxyRes.headers.location = u.toString(); } }, ); /** * Copy headers from proxyResponse to response * set each header in response object. * * @param {ClientRequest} Req Request object * @param {IncomingMessage} Res Response object * @param {proxyResponse} Res Response object from the proxy request * @param {Object} Options options.cookieDomainRewrite: Config to rewrite cookie domain * * @api private */ export const writeHeaders = defineProxyOutgoingMiddleware((req, res, proxyRes, options) => { const rewriteCookieDomainConfig = typeof options.cookieDomainRewrite === "string" ? // also test for '' { "*": options.cookieDomainRewrite } : options.cookieDomainRewrite; const rewriteCookiePathConfig = typeof options.cookiePathRewrite === "string" ? // also test for '' { "*": options.cookiePathRewrite } : options.cookiePathRewrite; const preserveHeaderKeyCase = options.preserveHeaderKeyCase; let rawHeaderKeyMap: Record | undefined; const setHeader = function (key: string, header: string | string[] | undefined) { if (header === undefined || !String(key).trim()) { return; } if (rewriteCookieDomainConfig && key.toLowerCase() === "set-cookie") { header = rewriteCookieProperty(header, rewriteCookieDomainConfig, "domain"); } if (rewriteCookiePathConfig && key.toLowerCase() === "set-cookie") { header = rewriteCookieProperty(header, rewriteCookiePathConfig, "path"); } try { res.setHeader(String(key).trim(), header); } catch { // Skip headers with invalid characters (e.g. control chars) // to avoid crashing with ERR_INVALID_CHAR } }; // message.rawHeaders is added in: v0.11.6 // https://nodejs.org/api/http.html#http_message_rawheaders if (preserveHeaderKeyCase && proxyRes.rawHeaders !== undefined) { rawHeaderKeyMap = {}; for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) { const key = proxyRes.rawHeaders[i]!; rawHeaderKeyMap[key.toLowerCase()] = key; } } for (let key of Object.keys(proxyRes.headers)) { const header = proxyRes.headers[key]; if (preserveHeaderKeyCase && rawHeaderKeyMap) { key = rawHeaderKeyMap[key] || key; } setHeader(key, header); } }); /** * Set the statusCode from the proxyResponse */ export const writeStatusCode = defineProxyOutgoingMiddleware((req, res, proxyRes) => { // From Node.js docs: response.writeHead(statusCode[, statusMessage][, headers]) // @ts-expect-error res.statusCode = proxyRes.statusCode; if ( proxyRes.statusMessage && // Only HTTP/1.0 and HTTP/1.1 support statusMessage req.httpVersionMajor < 2 ) { res.statusMessage = proxyRes.statusMessage; } }); export const webOutgoingMiddleware: readonly ProxyOutgoingMiddleware[] = [ removeChunked, setConnection, setRedirectHostRewrite, writeHeaders, writeStatusCode, ] as const; // --- Internal --- function _toURL(target: ProxyTarget): URL { if (target instanceof URL) { return target; } if (typeof target === "string") { return new URL(target); } const protocol = (target as ProxyTargetDetailed).protocol || "http:"; const host = (target as ProxyTargetDetailed).host || (target as ProxyTargetDetailed).hostname || "localhost"; const port = (target as ProxyTargetDetailed).port; return new URL(`${protocol}//${host}${port ? ":" + port : ""}`); } ================================================ FILE: src/middleware/ws-incoming.ts ================================================ import nodeHTTP from "node:http"; import nodeHTTPS from "node:https"; import type { Socket } from "node:net"; import { type ProxyMiddleware, defineProxyMiddleware } from "./_utils.ts"; import { getPort, hasEncryptedConnection, isSSL, setupOutgoing, setupSocket } from "../_utils.ts"; /** * WebSocket requests must have the `GET` method and * the `upgrade:websocket` header */ export const checkMethodAndHeader = defineProxyMiddleware((req, socket) => { if (req.method !== "GET" || !req.headers.upgrade) { socket.destroy(); return true; } if (req.headers.upgrade.toLowerCase() !== "websocket") { socket.destroy(); return true; } }); /** * Sets `x-forwarded-*` headers if specified in config. */ export const XHeaders = defineProxyMiddleware((req, socket, options) => { if (!options.xfwd) { return; } const values = { for: req.connection.remoteAddress || req.socket.remoteAddress, port: getPort(req), proto: hasEncryptedConnection(req) ? "wss" : "ws", }; for (const header of ["for", "port", "proto"] as const) { const key = "x-forwarded-" + header; if (!req.headers[key] && values[header] !== undefined) { req.headers[key] = values[header]; } } }); /** * Does the actual proxying. Make the request and upgrade it * send the Switching Protocols request and pipe the sockets. */ export const stream = defineProxyMiddleware( (req, socket, options, server, head, callback) => { const createHttpHeader = function (line: string, headers: nodeHTTP.OutgoingHttpHeaders) { return ( Object.keys(headers) // eslint-disable-next-line unicorn/no-array-reduce .reduce( function (head, key) { const value = headers[key]; if (!Array.isArray(value)) { head.push(key + ": " + value); return head; } for (const element of value) { head.push(key + ": " + element); } return head; }, [line], ) .join("\r\n") + "\r\n\r\n" ); }; setupSocket(socket); if (head && head.length > 0) { socket.unshift(head); } // Attach error handler early so client socket errors (e.g. ECONNRESET // from an intermediary proxy timeout) are caught before the upstream // upgrade response arrives. (#79) socket.on("error", onSocketError); const proxyReq = (isSSL.test(options.target.protocol || "http") ? nodeHTTPS : nodeHTTP).request( setupOutgoing(options.ssl || {}, options, req), ); // Enable developers to modify the proxyReq before headers are sent if (server) { server.emit("proxyReqWs", proxyReq, req, socket, options, head); } // Error Handler proxyReq.on("error", onOutgoingError); proxyReq.on("response", function (res) { // if upgrade event isn't going to happen, close the socket // guard against writing to an already-destroyed socket // (https://github.com/http-party/node-http-proxy/pull/1433) if (!(res as any).upgrade) { if (!socket.destroyed && socket.writable) { socket.write( createHttpHeader( "HTTP/" + res.httpVersion + " " + res.statusCode + " " + res.statusMessage, res.headers, ), ); res.on("error", onOutgoingError); res.pipe(socket); } else { // Socket already gone — consume response to avoid unhandled stream errors res.resume(); } } }); proxyReq.on("upgrade", function (proxyRes, proxySocket, proxyHead) { proxySocket.on("error", onOutgoingError); // Allow us to listen when the websocket has completed proxySocket.on("end", function () { server.emit("close", proxyRes, proxySocket, proxyHead); }); // Remove the pre-upgrade error handler — it calls proxyReq.destroy() // which is pointless after upgrade and emits a spurious error event. socket.removeListener("error", onSocketError); // The pipe below will end proxySocket if socket closes cleanly, but not // if it errors (eg, vanishes from the net and starts returning // EHOSTUNREACH). We need to do that explicitly. socket.on("error", function () { proxySocket.end(); }); setupSocket(proxySocket); if (proxyHead && proxyHead.length > 0) { proxySocket.unshift(proxyHead); } // // Remark: Handle writing the headers to the socket when switching protocols // Also handles when a header is an array // socket.write(createHttpHeader("HTTP/1.1 101 Switching Protocols", proxyRes.headers)); proxySocket.pipe(socket).pipe(proxySocket); server.emit("open", proxySocket); server.emit("proxySocket", proxySocket); // DEPRECATED. }); proxyReq.end(); // XXX: CHECK IF THIS IS THIS CORRECT // return; function onSocketError(err: Error) { if (callback) { callback(err, req, socket); } else { server.emit("error", err, req, socket); } proxyReq.destroy(); } function onOutgoingError(err: Error) { if (callback) { callback(err, req, socket); } else { server.emit("error", err, req, socket); } socket.end(); } }, ); export const websocketIncomingMiddleware: readonly ProxyMiddleware[] = [ checkMethodAndHeader, XHeaders, stream, ] as const; ================================================ FILE: src/server.ts ================================================ import http from "node:http"; import https from "node:https"; import http2 from "node:http2"; import { EventEmitter } from "node:events"; import { webIncomingMiddleware } from "./middleware/web-incoming.ts"; import { websocketIncomingMiddleware } from "./middleware/ws-incoming.ts"; import type { ProxyServerOptions, ProxyTarget } from "./types.ts"; import type { ProxyMiddleware, ResOfType } from "./middleware/_utils.ts"; import type net from "node:net"; export interface ProxyServerEventMap< Req extends http.IncomingMessage | http2.Http2ServerRequest = http.IncomingMessage, Res extends http.ServerResponse | http2.Http2ServerResponse = http.ServerResponse, > { error: [err: Error, req?: Req, res?: Res | net.Socket, target?: URL | ProxyTarget]; start: [req: Req, res: Res, target: URL | ProxyTarget]; econnreset: [err: Error, req: Req, res: Res, target: URL | ProxyTarget]; proxyReq: [proxyReq: http.ClientRequest, req: Req, res: Res, options: ProxyServerOptions]; proxyReqWs: [ proxyReq: http.ClientRequest, req: Req, socket: net.Socket, options: ProxyServerOptions, head: any, ]; proxyRes: [proxyRes: http.IncomingMessage, req: Req, res: Res]; end: [req: Req, res: Res, proxyRes: http.IncomingMessage]; open: [proxySocket: net.Socket]; /** @deprecated */ proxySocket: [proxySocket: net.Socket]; close: [proxyRes: Req, proxySocket: net.Socket, proxyHead: any]; } // eslint-disable-next-line unicorn/prefer-event-target export class ProxyServer< Req extends http.IncomingMessage | http2.Http2ServerRequest = http.IncomingMessage, Res extends http.ServerResponse | http2.Http2ServerResponse = http.ServerResponse, > extends EventEmitter> { // we use http2.Http2Server to handle HTTP/1.1 HTTPS as well (with allowHTTP1 enabled) private _server?: http.Server | https.Server | http2.Http2SecureServer; _webPasses: ProxyMiddleware[] = [...webIncomingMiddleware]; _wsPasses: ProxyMiddleware[] = [...websocketIncomingMiddleware]; options: ProxyServerOptions; web: (req: Req, res: Res, opts?: ProxyServerOptions, head?: any) => Promise; ws: (req: Req, socket: net.Socket, opts: ProxyServerOptions, head?: any) => Promise; /** * Creates the proxy server with specified options. * @param options - Config object passed to the proxy */ constructor(options: ProxyServerOptions = {}) { super(); this.options = options || {}; this.options.prependPath = options.prependPath !== false; this.web = _createProxyFn("web", this); this.ws = _createProxyFn("ws", this); } /** * A function that wraps the object in a webserver, for your convenience * @param port - Port to listen on * @param hostname - The hostname to listen on * @param listeningListener - A callback function that is called when the server starts listening */ listen(port: number, hostname?: string, listeningListener?: () => void) { const closure = ( req: http.IncomingMessage | http2.Http2ServerRequest, res: http.ServerResponse | http2.Http2ServerResponse, ) => { return this.web(req as Req, res as Res); }; if (this.options.http2) { if (!this.options.ssl) { throw new Error("HTTP/2 requires ssl option"); } this._server = http2.createSecureServer({ ...this.options.ssl, allowHTTP1: true }, closure); } else if (this.options.ssl) { this._server = https.createServer(this.options.ssl, closure); } else { this._server = http.createServer(closure); } if (this.options.ws) { this._server.on("upgrade", (req, socket, head) => { this.ws(req, socket, this.options, head).catch(() => {}); }); } this._server.listen(port, hostname, listeningListener); return this; } /** * A function that closes the inner webserver and stops listening on given port */ close(callback?: () => void) { if (this._server) { // Wrap callback to nullify server after all open connections are closed. this._server.close((...args) => { this._server = undefined; if (callback) { Reflect.apply(callback, undefined, args); } }); } } before( type: Type, passName: string, pass: ProxyMiddleware>, ) { if (type !== "ws" && type !== "web") { throw new Error("type must be `web` or `ws`"); } const passes = this._getPasses(type); let i: false | number = false; for (const [idx, v] of passes.entries()) { if (v.name === passName) { i = idx; } } if (i === false) { throw new Error("No such pass"); } passes.splice(i, 0, pass); } after( type: Type, passName: string, pass: ProxyMiddleware>, ) { if (type !== "ws" && type !== "web") { throw new Error("type must be `web` or `ws`"); } const passes = this._getPasses(type); let i: boolean | number = false; for (const [idx, v] of passes.entries()) { if (v.name === passName) { i = idx; } } if (i === false) { throw new Error("No such pass"); } passes.splice(i++, 0, pass); } /** @internal */ _getPasses(type: Type): ProxyMiddleware>[] { return (type === "ws" ? this._wsPasses : this._webPasses) as unknown as ProxyMiddleware< ResOfType >[]; } } /** * Creates the proxy server. * * Examples: * * httpProxy.createProxyServer({ .. }, 8000) * // => '{ web: [Function], ws: [Function] ... }' * * @param {Object} Options Config object passed to the proxy * * @return {Object} Proxy Proxy object with handlers for `ws` and `web` requests * * @api public */ export function createProxyServer(options: ProxyServerOptions = {}) { return new ProxyServer(options); } // --- Internal --- function _createProxyFn< Type extends "web" | "ws", ProxyServerReq extends http.IncomingMessage | http2.Http2ServerRequest, ProxyServerRes extends http.ServerResponse | http2.Http2ServerResponse, >(type: Type, server: ProxyServer) { return function ( this: ProxyServer, req: ProxyServerReq, res: ResOfType, opts?: ProxyServerOptions, head?: any, ): Promise { const requestOptions = { ...opts, ...server.options }; for (const key of ["target", "forward"] as const) { if (typeof requestOptions[key] === "string") { requestOptions[key] = new URL(requestOptions[key]); } } if (!requestOptions.target && !requestOptions.forward) { this.emit("error", new Error("Must provide a proper URL as target")); return Promise.resolve(); } let _resolve!: () => void; let _reject!: (error: any) => void; const callbackPromise = new Promise((resolve, reject) => { _resolve = resolve; _reject = reject; }); res.on("close", () => { _resolve(); }); res.on("error", (error: any) => { _reject(error); }); for (const pass of server._getPasses(type)) { let stop: void | true; try { stop = pass( req, res, requestOptions as ProxyServerOptions & { target: URL; forward: URL }, server as ProxyServer< http.IncomingMessage | http2.Http2ServerRequest, http.ServerResponse | http2.Http2ServerResponse >, head, (error) => { if (server.listenerCount("error") > 0) { server.emit("error", error, req, res as ProxyServerRes | net.Socket); _resolve(); } else { _reject(error); } }, ); } catch (error) { if (server.listenerCount("error") > 0) { server.emit("error", error as Error, req, res as ProxyServerRes | net.Socket); _resolve(); } else { _reject(error); } break; } // Passes can return a truthy value to halt the loop if (stop) { _resolve(); break; } } return callbackPromise; }; } ================================================ FILE: src/types.ts ================================================ import type * as stream from "node:stream"; export interface ProxyTargetDetailed { host?: string; port?: number | string; protocol?: string; hostname?: string; socketPath?: string; key?: string; passphrase?: string; pfx?: Buffer | string; cert?: string; ca?: string; ciphers?: string; secureProtocol?: string; } export type ProxyTarget = string | URL | ProxyTargetDetailed; /** Resolved proxy address — either TCP (host + port) or Unix socket. */ export type ProxyAddr = | { host?: string; port: number; socketPath?: undefined } | { host?: undefined; port?: undefined; socketPath: string }; export interface ProxyServerOptions { /** URL string to be parsed. */ target?: ProxyTarget; /** URL string to be parsed. */ forward?: ProxyTarget; /** Object to be passed to http(s).request. */ agent?: any; /** Enable HTTP/2 listener, default is `false` */ http2?: boolean; /** Object to be passed to https.createServer() * or http2.createSecureServer() if the `http2` option is enabled */ ssl?: any; /** If you want to proxy websockets. */ ws?: boolean; /** Adds x- forward headers. */ xfwd?: boolean; /** Verify SSL certificate. */ secure?: boolean; /** Explicitly specify if we are proxying to another proxy. */ toProxy?: boolean; /** Specify whether you want to prepend the target's path to the proxy path. */ prependPath?: boolean; /** Specify whether you want to ignore the proxy path of the incoming request. */ ignorePath?: boolean; /** Local interface string to bind for outgoing connections. */ localAddress?: string; /** Changes the origin of the host header to the target URL. */ changeOrigin?: boolean; /** specify whether you want to keep letter case of response header key */ preserveHeaderKeyCase?: boolean; /** Basic authentication i.e. 'user:password' to compute an Authorization header. */ auth?: string; /** Rewrites the location hostname on (301 / 302 / 307 / 308) redirects, Default: null. */ hostRewrite?: string; /** Rewrites the location host/ port on (301 / 302 / 307 / 308) redirects based on requested host/ port.Default: false. */ autoRewrite?: boolean; /** Rewrites the location protocol on (301 / 302 / 307 / 308) redirects to 'http' or 'https'.Default: null. */ protocolRewrite?: string; /** Rewrites domain of set-cookie headers. */ cookieDomainRewrite?: false | string | { [oldDomain: string]: string }; /** Rewrites path of set-cookie headers. Default: false */ cookiePathRewrite?: false | string | { [oldPath: string]: string }; /** Object with extra headers to be added to target requests. */ headers?: { [header: string]: string }; /** Timeout (in milliseconds) when proxy receives no response from target. Default: 120000 (2 minutes) */ proxyTimeout?: number; /** Timeout (in milliseconds) for incoming requests */ timeout?: number; /** If set to true, none of the webOutgoing passes are called and it's your responsibility to appropriately return the response by listening and acting on the proxyRes event */ selfHandleResponse?: boolean; /** Follow HTTP redirects from target. `true` = max 5 hops; number = custom max. */ followRedirects?: boolean | number; /** Buffer */ buffer?: stream.Stream; } ================================================ FILE: src/ws.ts ================================================ import type { IncomingMessage, RequestOptions } from "node:http"; import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; import type { Duplex } from "node:stream"; import type { Socket } from "node:net"; import type { ProxyAddr } from "./types.ts"; import { getPort, hasEncryptedConnection, isSSL, parseAddr, setupOutgoing, setupSocket, } from "./_utils.ts"; /** * Options for {@link proxyUpgrade}. */ export interface ProxyUpgradeOptions { /** * Add `x-forwarded-for`, `x-forwarded-port`, and `x-forwarded-proto` headers. * Default: `true`. */ xfwd?: boolean; /** * Rewrite the `Host` header to match the target. * Default: `false` (original host is kept). */ changeOrigin?: boolean; /** * Extra headers to include in the upstream upgrade request. * Default: none. */ headers?: Record; /** * TLS options forwarded to `https.request`. * Default: none. */ ssl?: Record; /** * Whether to verify upstream TLS certificates. * Default: `true`. */ secure?: boolean; /** * HTTP/HTTPS agent used for the upstream request. * Default: `false` (no keep-alive agent is used). */ agent?: any; /** * Local interface address to bind for upstream connections. * Default: OS-selected local address. */ localAddress?: string; /** * Basic auth credentials in `username:password` format. * Default: none. */ auth?: string; /** * Prepend the target path to the proxied request path. * Default: `true`. */ prependPath?: boolean; /** * Ignore the incoming request path when building the upstream path. * Default: `false` (incoming path is used). */ ignorePath?: boolean; /** * Send absolute URL in request path when proxying to another proxy. * Default: `false` (path-only request target is used). */ toProxy?: boolean; } /** * Proxy a WebSocket upgrade request to a target address without creating a * {@link ProxyServer} instance. Similar to {@link proxyFetch} but for * WebSocket upgrades. * * @param addr - Target server address. Can be a URL string (`http://host:port`, `ws://host:port`, `unix:/path`), or an object with `host`/`port` for TCP or `socketPath` for Unix sockets. * @param req - The incoming HTTP upgrade request. * @param socket - The network socket between the server and client. * @param head - The first packet of the upgraded stream (may be empty). * @param opts - Optional proxy options. * @returns A promise that resolves with the upstream proxy socket once the * WebSocket connection is established, or rejects on error. */ export function proxyUpgrade( addr: string | ProxyAddr, req: IncomingMessage, socket: Duplex, head?: Buffer, opts?: ProxyUpgradeOptions, ): Promise { const resolvedAddr = parseAddr(addr); // Detect SSL from addr string protocol (wss:// or https://) let useSSL = false; if (typeof addr === "string" && !addr.startsWith("unix:")) { useSSL = isSSL.test(new URL(addr).protocol); } // Validate WS upgrade request if (req.method !== "GET" || req.headers.upgrade?.toLowerCase() !== "websocket") { socket.destroy(); return Promise.reject(new Error("Not a valid WebSocket upgrade request")); } // Set x-forwarded-* headers (enabled by default) if (opts?.xfwd !== false) { const xfFor = req.headers["x-forwarded-for"]; const xfPort = req.headers["x-forwarded-port"]; const xfProto = req.headers["x-forwarded-proto"]; req.headers["x-forwarded-for"] = `${xfFor ? `${xfFor},` : ""}${req.socket?.remoteAddress}`; req.headers["x-forwarded-port"] = `${xfPort ? `${xfPort},` : ""}${getPort(req)}`; req.headers["x-forwarded-proto"] = `${xfProto ? `${xfProto},` : ""}${hasEncryptedConnection(req) ? "wss" : "ws"}`; } // Build target URL for setupOutgoing const target = _buildTargetURL(resolvedAddr, useSSL); const requestOptions: ProxyUpgradeOptions & { target: URL } = { ...opts, target, prependPath: opts?.prependPath !== false, }; const outgoing = setupOutgoing( requestOptions.ssl || {}, requestOptions as Parameters[1], req, ); const sock = socket as Socket; return new Promise((resolve, reject) => { let settled = false; setupSocket(sock); if (head && head.length > 0) { sock.unshift(head); } sock.once("error", onSocketError); const doRequest = isSSL.test(target.protocol) ? httpsRequest : httpRequest; const proxyReq = doRequest(outgoing as RequestOptions); proxyReq.once("error", onOutgoingError); proxyReq.once("response", (res) => { // If upgrade event isn't going to happen, relay the response and reject // Guard against writing to an already-destroyed socket // (https://github.com/http-party/node-http-proxy/pull/1433) if (!(res as any).upgrade) { if (!sock.destroyed && sock.writable) { sock.write( _createHttpHeader( `HTTP/${res.httpVersion} ${res.statusCode} ${res.statusMessage}`, res.headers, ), ); res.on("error", onOutgoingError); res.pipe(sock); } else { // Socket already gone — consume response to avoid unhandled stream errors res.resume(); } if (!settled) { settled = true; reject(new Error("Upstream server did not upgrade the connection")); } } }); proxyReq.once("upgrade", (proxyRes, proxySocket, proxyHead) => { proxySocket.once("error", onOutgoingError); sock.removeListener("error", onSocketError); sock.once("error", () => { proxySocket.end(); }); setupSocket(proxySocket); if (proxyHead && proxyHead.length > 0) { proxySocket.unshift(proxyHead); } sock.write(_createHttpHeader("HTTP/1.1 101 Switching Protocols", proxyRes.headers)); proxySocket.pipe(sock).pipe(proxySocket); settled = true; resolve(proxySocket); }); proxyReq.end(); function onSocketError(err: Error) { proxyReq.destroy(); if (!settled) { settled = true; reject(err); } } function onOutgoingError(err: Error) { sock.end(); if (!settled) { settled = true; reject(err); } } }); } // --- Internal --- function _buildTargetURL(addr: ProxyAddr, useSSL = false): URL { const protocol = useSSL ? "https" : "http"; if (addr.socketPath) { const url = new URL(`${protocol}://unix`); (url as any).socketPath = addr.socketPath; return url; } return new URL(`${protocol}://${addr.host || "localhost"}${addr.port ? `:${addr.port}` : ""}`); } function _createHttpHeader( line: string, headers: Record, ): string { let result = line; for (const key of Object.keys(headers)) { const value = headers[key]; if (value === undefined) { continue; } if (Array.isArray(value)) { for (const element of value) { result += `\r\n${key}: ${element}`; } } else { result += `\r\n${key}: ${value}`; } } return `${result}\r\n\r\n`; } ================================================ FILE: test/_stubs.ts ================================================ import type { IncomingMessage, OutgoingHttpHeaders, RequestOptions, ServerResponse, } from "node:http"; import type { RequestOptions as HttpsRequestOptions } from "node:https"; import type { Socket } from "node:net"; import type { ProxyServer } from "../src/server.ts"; import type { ProxyServerOptions, ProxyTargetDetailed } from "../src/types.ts"; // --- setupOutgoing --- export type OutgoingOptions = Omit & { headers?: OutgoingHttpHeaders & Record; }; export function createOutgoing(): OutgoingOptions { return {}; } // --- IncomingMessage stubs --- export function stubIncomingMessage(overrides: Record = {}): IncomingMessage { return { method: "GET", url: "/", headers: {}, httpVersion: "1.1", httpVersionMajor: 1, ...overrides, } as unknown as IncomingMessage; } // --- ServerResponse stub --- export function stubServerResponse(overrides: Record = {}): ServerResponse { return overrides as unknown as ServerResponse; } // --- Socket stub --- export function stubSocket(overrides: Record = {}): Socket { return overrides as unknown as Socket; } // --- Middleware options --- export type MiddlewareOptions = ProxyServerOptions & { target: URL | ProxyTargetDetailed; forward: URL; }; export function stubMiddlewareOptions(overrides: Record = {}): MiddlewareOptions { return overrides as unknown as MiddlewareOptions; } // --- ProxyServer stub --- export function stubProxyServer(overrides: Record = {}): ProxyServer { return overrides as unknown as ProxyServer; } ================================================ FILE: test/_utils.test.ts ================================================ import { describe, it, expect } from "vitest"; import * as common from "../src/_utils.ts"; import { createOutgoing, stubIncomingMessage, stubSocket } from "./_stubs.ts"; // Source: https://github.com/http-party/node-http-proxy/blob/master/test/lib-http-proxy-common-test.js // Difference from http-proxy is that we always ensure leading slash on path describe("lib/http-proxy/common.js", () => { describe("#setupOutgoing", () => { it("should setup the correct headers", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { agent: "?", target: { host: "hey", hostname: "how", socketPath: "are", port: "you", }, // @ts-expect-error headers: { fizz: "bang", overwritten: true }, localAddress: "local.address", auth: "username:pass", }, stubIncomingMessage({ method: "i", url: "am", headers: { pro: "xy", overwritten: "false" }, }), ); expect(outgoing.host).to.eql("hey"); expect(outgoing.hostname).to.eql("how"); expect(outgoing.socketPath).to.eql("are"); expect(outgoing.port).to.eql("you"); expect(outgoing.agent).to.eql("?"); expect(outgoing.method).to.eql("i"); expect(outgoing.path).to.eql("/am"); // leading slash is new in httpxy expect(outgoing.headers!.pro).to.eql("xy"); expect(outgoing.headers!.fizz).to.eql("bang"); expect(outgoing.headers!.overwritten).to.eql(true); expect(outgoing.localAddress).to.eql("local.address"); expect(outgoing.auth).to.eql("username:pass"); }); it("should not overwrite existing ssl options with undefined target values", () => { const outgoing = createOutgoing(); // Simulate options.ssl having cert/key/ca (as passed from web-incoming) Object.assign(outgoing, { cert: "my-cert", key: "my-key", ca: "my-ca", }); common.setupOutgoing( outgoing, { agent: undefined, target: { host: "localhost", hostname: "localhost", port: "8080", // No SSL properties on target — they should NOT overwrite outgoing }, }, stubIncomingMessage({ method: "GET", url: "/", headers: {}, }), ); // SSL options from outgoing (options.ssl) must be preserved expect(outgoing.cert).to.eql("my-cert"); expect(outgoing.key).to.eql("my-key"); expect(outgoing.ca).to.eql("my-ca"); }); it("should not override agentless upgrade header", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { agent: undefined, target: { host: "hey", hostname: "how", socketPath: "are", port: "you", }, headers: { connection: "upgrade" }, }, stubIncomingMessage({ method: "i", url: "am", headers: { pro: "xy", overwritten: "false" }, }), ); expect(outgoing.headers!.connection).to.eql("upgrade"); }); it("should not override agentless connection: contains upgrade", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { agent: undefined, target: { host: "hey", hostname: "how", socketPath: "are", port: "you", }, headers: { connection: "keep-alive, upgrade" }, // this is what Firefox sets }, stubIncomingMessage({ method: "i", url: "am", headers: { pro: "xy", overwritten: "false" }, }), ); expect(outgoing.headers!.connection).to.eql("keep-alive, upgrade"); }); it("should override agentless connection: contains improper upgrade", () => { // sanity check on upgrade regex const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { agent: false, target: { host: "hey", hostname: "how", socketPath: "are", port: "you", }, headers: { connection: "keep-alive, not upgrade" }, }, stubIncomingMessage({ method: "i", url: "am", headers: { pro: "xy", overwritten: "false" }, }), ); expect(outgoing.headers!.connection).to.eql("close"); }); it("should override agentless non-upgrade header to close", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { agent: false, target: { host: "hey", hostname: "how", socketPath: "are", port: "you", }, headers: { connection: "xyz" }, }, stubIncomingMessage({ method: "i", url: "am", headers: { pro: "xy", overwritten: "false" }, }), ); expect(outgoing.headers!.connection).to.eql("close"); }); it("should use default keep-alive agent if none is given", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: "http://localhost" }, stubIncomingMessage({ url: "/", }), ); expect(outgoing.agent).to.eql(common.defaultAgents.http); }); it("should set the agent to false if explicitly set", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: "http://localhost", agent: false }, stubIncomingMessage({ url: "/", }), ); expect(outgoing.agent).to.eql(false); }); it("should not use keep-alive agent for websocket upgrade requests", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: "http://localhost" }, stubIncomingMessage({ url: "/", headers: { connection: "Upgrade", upgrade: "websocket" }, }), ); // WebSocket upgrades take ownership of the socket after 101, // so a keep-alive agent must not be used. expect(outgoing.agent).to.eql(false); }); it("set the port according to the protocol", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { agent: "?", target: { host: "how", hostname: "are", socketPath: "you", protocol: "https:", }, }, stubIncomingMessage({ method: "i", url: "am", headers: { pro: "xy" }, }), ); expect(outgoing.host).to.eql("how"); expect(outgoing.hostname).to.eql("are"); expect(outgoing.socketPath).to.eql("you"); expect(outgoing.agent).to.eql("?"); expect(outgoing.method).to.eql("i"); expect(outgoing.path).to.eql("/am"); expect(outgoing.headers!.pro).to.eql("xy"); expect(outgoing.port).to.eql(443); }); it("should keep the original target path in the outgoing path", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: URL.parse("http://localhost/some-path")! }, stubIncomingMessage({ url: "am", }), ); expect(outgoing.path).to.eql("/some-path/am"); }); it("should keep the original forward path in the outgoing path", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: "http://localhost", forward: URL.parse("http://localhost/some-path")!, }, stubIncomingMessage({ url: "am", }), "forward", ); expect(outgoing.path).to.eql("/some-path/am"); }); it("should properly detect https/wss protocol without the colon", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: { protocol: "https", host: "whatever.com", }, }, stubIncomingMessage({ url: "/" }), ); expect(outgoing.port).to.eql(443); }); it("should not prepend the target path to the outgoing path with prependPath = false", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: URL.parse("http://localhost/hellothere")!, prependPath: false, }, stubIncomingMessage({ url: "hi" }), ); expect(outgoing.path).to.eql("/hi"); }); it("should properly join paths", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: URL.parse("http://localhost/forward")!, }, stubIncomingMessage({ url: "/static/path" }), ); expect(outgoing.path).to.eql("/forward/static/path"); }); it("should preserve multiple consecutive slashes in path (#80)", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: "http://localhost:3004" }, stubIncomingMessage({ url: "//test" }), ); expect(outgoing.path).to.eql("//test"); }); it("should preserve multiple consecutive slashes with query string (#80)", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: "http://localhost:3004" }, stubIncomingMessage({ url: "//test?foo=bar", }), ); expect(outgoing.path).to.eql("//test?foo=bar"); }); it("should preserve target query string when merging paths", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: URL.parse("http://localhost/api?key=val")!, }, stubIncomingMessage({ url: "/endpoint" }), ); expect(outgoing.path).to.eql("/api/endpoint?key=val"); }); it("should merge target and request query strings with &", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: URL.parse("http://localhost/api?key=val")!, }, stubIncomingMessage({ url: "/endpoint?foo=bar" }), ); expect(outgoing.path).to.eql("/api/endpoint?key=val&foo=bar"); }); it("should preserve target query string with no request path", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: URL.parse("http://localhost?project=example&path=")!, }, stubIncomingMessage({ url: "/" }), ); expect(outgoing.path).to.eql("/?project=example&path="); }); it("should not modify the query string", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: URL.parse("http://localhost/forward")!, }, stubIncomingMessage({ url: "/?foo=bar//&target=http://foobar.com/?a=1%26b=2&other=2", }), ); expect(outgoing.path).to.eql( "/forward/?foo=bar//&target=http://foobar.com/?a=1%26b=2&other=2", ); }); // // This is the proper failing test case for the common.join problem // it("should correctly format the toProxy URL", () => { const outgoing = createOutgoing(); const google = "https://google.com"; common.setupOutgoing( outgoing, { target: URL.parse("http://sometarget.com:80")!, toProxy: true, }, stubIncomingMessage({ url: google }), ); expect(outgoing.path).to.eql("/" + google); }); it("should not replace :\\ to :\\\\ when no https word before", () => { const outgoing = createOutgoing(); const google = "https://google.com:/join/join.js"; common.setupOutgoing( outgoing, { target: URL.parse("http://sometarget.com:80")!, toProxy: true, }, stubIncomingMessage({ url: google }), ); expect(outgoing.path).to.eql("/" + google); }); it("should not replace :\\ to \\\\ when no http word before", () => { const outgoing = createOutgoing(); const google = "http://google.com:/join/join.js"; common.setupOutgoing( outgoing, { target: URL.parse("http://sometarget.com:80")!, toProxy: true, }, stubIncomingMessage({ url: google }), ); expect(outgoing.path).to.eql("/" + google); }); describe("when using ignorePath", () => { it("should ignore the path of the `req.url` passed in but use the target path", () => { const outgoing = createOutgoing(); const myEndpoint = "https://whatever.com/some/crazy/path/whoooo"; common.setupOutgoing( outgoing, { target: URL.parse(myEndpoint)!, ignorePath: true, }, stubIncomingMessage({ url: "/more/crazy/pathness" }), ); expect(outgoing.path).to.eql("/some/crazy/path/whoooo"); }); it("and prependPath: false, it should ignore path of target and incoming request", () => { const outgoing = createOutgoing(); const myEndpoint = "https://whatever.com/some/crazy/path/whoooo"; common.setupOutgoing( outgoing, { target: URL.parse(myEndpoint)!, ignorePath: true, prependPath: false, }, stubIncomingMessage({ url: "/more/crazy/pathness" }), ); expect(outgoing.path).to.eql("/"); }); }); describe("when using changeOrigin", () => { it("should correctly set the port to the host when it is a non-standard port using URL.parse", () => { const outgoing = createOutgoing(); const myEndpoint = "https://myCouch.com:6984"; common.setupOutgoing( outgoing, { target: URL.parse(myEndpoint)!, changeOrigin: true, }, stubIncomingMessage({ url: "/" }), ); expect(outgoing.headers!.host).to.eql("mycouch.com:6984"); }); it("should correctly set the port to the host when it is a non-standard port when setting host and port manually (which ignores port)", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: { protocol: "https:", host: "mycouch.com", port: 6984, }, changeOrigin: true, }, stubIncomingMessage({ url: "/" }), ); expect(outgoing.headers!.host).to.eql("mycouch.com:6984"); }); }); it("should pass through https client parameters", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { agent: "?", target: { host: "how", hostname: "are", socketPath: "you", protocol: "https:", pfx: "my-pfx", key: "my-key", passphrase: "my-passphrase", cert: "my-cert", ca: "my-ca", ciphers: "my-ciphers", secureProtocol: "my-secure-protocol", }, }, stubIncomingMessage({ method: "i", url: "am", }), ); expect(outgoing.pfx).eql("my-pfx"); expect(outgoing.key).eql("my-key"); expect(outgoing.passphrase).eql("my-passphrase"); expect(outgoing.cert).eql("my-cert"); expect(outgoing.ca).eql("my-ca"); expect(outgoing.ciphers).eql("my-ciphers"); expect(outgoing.secureProtocol).eql("my-secure-protocol"); }); it("should set ca from top-level options", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: { host: "localhost", protocol: "https:" }, ca: "my-top-level-ca", } as any, stubIncomingMessage({ url: "/" }), ); expect(outgoing.ca).eql("my-top-level-ca"); }); it("should handle overriding the `method` of the http request", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: "https://whooooo.com", method: "POST", }, stubIncomingMessage({ method: "GET", url: "" }), ); expect(outgoing.method).eql("POST"); }); it("should handle empty pathname target", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, { target: URL.parse("http://localhost")! }, stubIncomingMessage({ url: "", }), ); expect(outgoing.path).toBe("/"); }); }); describe("#joinURL", () => { it("should insert slash when base has no trailing slash and path has no leading slash", () => { expect(common.joinURL("foo", "bar")).to.eql("foo/bar"); }); it("should return path when base is undefined", () => { expect(common.joinURL(undefined, "/path")).to.eql("/path"); }); it("should return base when path is undefined", () => { expect(common.joinURL("/base", undefined)).to.eql("/base"); }); it("should return / when both are undefined", () => { expect(common.joinURL(undefined, undefined)).to.eql("/"); }); it("should strip duplicate slash when both have slash", () => { expect(common.joinURL("/base/", "/path")).to.eql("/base/path"); }); it("should concat when base has trailing slash and path has no leading slash", () => { expect(common.joinURL("/base/", "path")).to.eql("/base/path"); }); }); describe("#rewriteCookieProperty", () => { it("should return original cookie when domain is not in config and no wildcard", () => { const cookie = "hello; domain=other.com; path=/"; const result = common.rewriteCookieProperty(cookie, { "specific.com": "new.com" }, "domain"); expect(result).to.eql("hello; domain=other.com; path=/"); }); }); describe("#requiresPort", () => { it("should return false for ftp on port 21", () => { expect(common.requiresPort(21, "ftp")).to.eql(false); }); it("should return true for ftp on non-standard port", () => { expect(common.requiresPort(8021, "ftp")).to.eql(true); }); it("should return false for gopher on port 70", () => { expect(common.requiresPort(70, "gopher")).to.eql(false); }); it("should return true for gopher on non-standard port", () => { expect(common.requiresPort(8070, "gopher")).to.eql(true); }); it("should return false for file protocol", () => { expect(common.requiresPort(0, "file")).to.eql(false); expect(common.requiresPort(8080, "file")).to.eql(false); }); it("should return false for unknown protocol on port 0", () => { expect(common.requiresPort(0, "unknown")).to.eql(false); }); it("should return true for unknown protocol on non-zero port", () => { expect(common.requiresPort(8080, "unknown")).to.eql(true); }); it("should return false when port is falsy", () => { expect(common.requiresPort(0, "http")).to.eql(false); }); it("should handle protocol with colon", () => { expect(common.requiresPort(80, "http:")).to.eql(false); expect(common.requiresPort(8080, "http:")).to.eql(true); }); }); describe("#parseAddr", () => { it("should default to port 80 for http", () => { expect(common.parseAddr("http://localhost")).to.eql({ host: "localhost", port: 80 }); }); it("should default to port 443 for https", () => { expect(common.parseAddr("https://localhost")).to.eql({ host: "localhost", port: 443 }); }); it("should default to port 443 for wss", () => { expect(common.parseAddr("wss://localhost")).to.eql({ host: "localhost", port: 443 }); }); it("should default to port 80 for ws", () => { expect(common.parseAddr("ws://localhost")).to.eql({ host: "localhost", port: 80 }); }); it("should use explicit port over protocol default", () => { expect(common.parseAddr("https://localhost:8443")).to.eql({ host: "localhost", port: 8443 }); }); it("should parse unix socket path", () => { expect(common.parseAddr("unix:/tmp/sock")).to.eql({ socketPath: "/tmp/sock" }); }); it("should pass through a valid ProxyAddr with port", () => { const addr = { host: "127.0.0.1", port: 3000 }; expect(common.parseAddr(addr)).to.eql(addr); }); it("should pass through a valid ProxyAddr with socketPath", () => { const addr = { socketPath: "/tmp/proxy.sock" }; expect(common.parseAddr(addr)).to.eql(addr); }); it("should throw for ProxyAddr missing port and socketPath", () => { expect(() => common.parseAddr({} as any)).toThrowError( /ProxyAddr must have either `port` or `socketPath`/, ); }); }); describe("#setupSocket", () => { it("should setup a socket", () => { const socketConfig = { timeout: undefined as number | undefined, nodelay: false, keepalive: false, }, sock = stubSocket({ setTimeout: function (num: number) { socketConfig.timeout = num; }, setNoDelay: function (bol: boolean) { socketConfig.nodelay = bol; }, setKeepAlive: function (bol: boolean) { socketConfig.keepalive = bol; }, }); const returnValue = common.setupSocket(sock); expect(socketConfig.timeout).to.eql(0); expect(socketConfig.nodelay).to.eql(true); expect(socketConfig.keepalive).to.eql(true); }); }); }); ================================================ FILE: test/_utils.ts ================================================ import http from "node:http"; import https from "node:https"; import net from "node:net"; import type { AddressInfo } from "node:net"; import * as httpProxy from "../src/index.ts"; export function listenOn(server: http.Server | https.Server | net.Server): Promise { return new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { resolve((server.address() as AddressInfo).port); }); }); } export function proxyListen( proxy: ReturnType, ): Promise { return new Promise((resolve, reject) => { proxy.listen(0, "127.0.0.1"); const server = (proxy as any)._server as net.Server; server.once("error", reject); server.once("listening", () => { resolve((server.address() as AddressInfo).port); }); }); } ================================================ FILE: test/fetch.test.ts ================================================ import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { AddressInfo } from "node:net"; import { proxyFetch } from "../src/fetch.ts"; // --- TCP server --- let tcpServer: Server; let tcpPort: number; beforeAll(async () => { tcpServer = createServer((req, res) => { if (req.url === "/json") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); return; } if (req.url === "/slow") { const timer = setTimeout(() => { res.writeHead(200, { "content-type": "text/plain" }); res.end("slow-ok"); }, 5000); req.on("close", () => clearTimeout(timer)); return; } if (req.url === "/echo" && req.method === "POST") { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { res.writeHead(200, { "content-type": "text/plain" }); res.end(Buffer.concat(chunks)); }); return; } if (req.url?.startsWith("/headers")) { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ headers: req.headers, url: req.url })); return; } if (req.url === "/redirect") { res.writeHead(302, { location: "/json" }); res.end(); return; } if (req.url === "/redirect-307") { res.writeHead(307, { location: "/echo" }); res.end(); return; } if (req.url === "/redirect-chain") { res.writeHead(301, { location: "/redirect" }); res.end(); return; } if (req.url === "/multi-cookie") { res.writeHead(200, [ ["set-cookie", "a=1; Path=/"], ["set-cookie", "b=2; Path=/"], ["set-cookie", "c=3; Path=/"], ["content-type", "text/plain"], ]); res.end("ok"); return; } if (req.url === "/no-content") { res.writeHead(204); res.end(); return; } res.writeHead(404); res.end("Not found"); }); await new Promise((resolve) => { tcpServer.listen(0, "127.0.0.1", resolve); }); tcpPort = (tcpServer.address() as AddressInfo).port; }); afterAll(() => { tcpServer?.close(); }); // --- Unix socket server --- let socketServer: Server; const socketPath = join(tmpdir(), `httpxy-test-${process.pid}-${Date.now()}.sock`); beforeAll(async () => { socketServer = createServer((req, res) => { res.writeHead(200, { "content-type": "text/plain" }); res.end("unix-ok"); }); await new Promise((resolve) => { socketServer.listen(socketPath, resolve); }); }); afterAll(() => { socketServer?.close(); }); // --- Tests --- describe("proxyFetch", () => { describe("TCP (host + port)", () => { it("GET request returns JSON", async () => { const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/json`); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("application/json"); expect(await res.json()).toEqual({ ok: true }); }); it("POST with body", async () => { const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/echo`, { method: "POST", body: "hello", }); expect(res.status).toBe(200); expect(await res.text()).toBe("hello"); }); it("forwards custom headers", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/headers`, { headers: { "x-custom": "test-value" } }, ); const body = (await res.json()) as { headers: Record }; expect(body.headers["x-custom"]).toBe("test-value"); }); it("handles redirect manually (no follow)", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/redirect`, ); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/json"); }); it("handles 204 no content", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/no-content`, ); expect(res.status).toBe(204); expect(res.body).toBeNull(); }); it("preserves multiple set-cookie headers", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/multi-cookie`, ); expect(res.status).toBe(200); const cookies = res.headers.getSetCookie(); expect(cookies).toEqual(["a=1; Path=/", "b=2; Path=/", "c=3; Path=/"]); }); it("strips hop-by-hop headers", async () => { const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/json`); expect(res.headers.has("transfer-encoding")).toBe(false); expect(res.headers.has("keep-alive")).toBe(false); expect(res.headers.has("connection")).toBe(false); }); it("handles Request object input", async () => { const req = new Request("http://localhost/json", { headers: { accept: "application/json" }, }); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, req); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); }); it("preserves query string", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/headers?foo=bar`, ); expect(res.status).toBe(200); const body = (await res.json()) as { url: string }; expect(body.url).toBe("/headers?foo=bar"); }); }); describe("Unix socket", () => { it("GET via unix socket", async () => { const res = await proxyFetch({ socketPath }, `http://localhost/anything`); expect(res.status).toBe(200); expect(await res.text()).toBe("unix-ok"); }); }); describe("POST with streaming body", () => { it("pipes ReadableStream body", async () => { const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("streamed")); controller.close(); }, }); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/echo`, { method: "POST", body: stream, }); expect(res.status).toBe(200); expect(await res.text()).toBe("streamed"); }); }); describe("Request as inputInit", () => { it("uses Request object as inputInit", async () => { const initReq = new Request("http://localhost/echo", { method: "POST", body: "from-request-init", }); const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/echo`, initReq, ); expect(res.status).toBe(200); expect(await res.text()).toBe("from-request-init"); }); it("inputInit Request overrides input Request properties", async () => { const input = new Request("http://localhost/echo", { method: "GET" }); const initReq = new Request("http://localhost/echo", { method: "POST", headers: { "x-from": "init-request" }, body: "override-body", }); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, input, initReq); expect(res.status).toBe(200); expect(await res.text()).toBe("override-body"); }); it("inputInit Request with streaming body", async () => { const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("init-stream")); controller.close(); }, }); const initReq = new Request("http://localhost/echo", { method: "POST", body: stream, // @ts-expect-error duplex duplex: "half", }); const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/echo`, initReq, ); expect(res.status).toBe(200); expect(await res.text()).toBe("init-stream"); }); }); describe("Request input with body", () => { it("POST body from Request input", async () => { const req = new Request("http://localhost/echo", { method: "POST", body: "request-body", }); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, req); expect(res.status).toBe(200); expect(await res.text()).toBe("request-body"); }); it("POST streaming body from Request input", async () => { const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("req-stream")); controller.close(); }, }); const req = new Request("http://localhost/echo", { method: "POST", body: stream, // @ts-expect-error duplex duplex: "half", }); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, req); expect(res.status).toBe(200); expect(await res.text()).toBe("req-stream"); }); }); describe("string addr", () => { it("accepts http URL string as addr", async () => { const res = await proxyFetch(`http://127.0.0.1:${tcpPort}`, `http://localhost/json`); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); }); it("accepts unix: string as addr", async () => { const res = await proxyFetch(`unix:${socketPath}`, `http://localhost/anything`); expect(res.status).toBe(200); expect(await res.text()).toBe("unix-ok"); }); }); describe("error handling", () => { it("rejects on connection error", async () => { await expect( proxyFetch({ host: "127.0.0.1", port: 1 }, `http://localhost/`), ).rejects.toThrow(); }); it("rejects on body stream error", async () => { const stream = new ReadableStream({ start(controller) { controller.error(new Error("stream-fail")); }, }); await expect( proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/echo`, { method: "POST", body: stream, }), ).rejects.toThrow("stream-fail"); }); }); describe("signal / abort", () => { it("aborts request with already-aborted signal", async () => { const controller = new AbortController(); controller.abort(); await expect( proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/json`, { signal: controller.signal, }), ).rejects.toThrow(); }); it("aborts in-flight request", async () => { const controller = new AbortController(); setTimeout(() => controller.abort(), 50); await expect( proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/slow`, { signal: controller.signal, }), ).rejects.toThrow(); }); it("succeeds when signal is not aborted", async () => { const controller = new AbortController(); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/json`, { signal: controller.signal, }); expect(res.status).toBe(200); }); }); describe("timeout", () => { it("rejects when upstream does not respond in time", async () => { await expect( proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/slow`, undefined, { timeout: 50, }), ).rejects.toThrow("Proxy request timed out"); }); it("succeeds when response arrives before timeout", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/json`, undefined, { timeout: 5000 }, ); expect(res.status).toBe(200); }); }); describe("xfwd", () => { it("adds x-forwarded-* headers when enabled", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://example.com:3000/headers`, undefined, { xfwd: true }, ); const body = (await res.json()) as { headers: Record }; expect(body.headers["x-forwarded-for"]).toBe("example.com"); expect(body.headers["x-forwarded-port"]).toBe("3000"); expect(body.headers["x-forwarded-proto"]).toBe("http"); expect(body.headers["x-forwarded-host"]).toBe("example.com:3000"); }); it("does not add x-forwarded-* headers by default", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/headers`, ); const body = (await res.json()) as { headers: Record }; expect(body.headers["x-forwarded-for"]).toBeUndefined(); expect(body.headers["x-forwarded-port"]).toBeUndefined(); expect(body.headers["x-forwarded-proto"]).toBeUndefined(); expect(body.headers["x-forwarded-host"]).toBeUndefined(); }); it("does not overwrite existing x-forwarded-* headers", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://example.com/headers`, { headers: { "x-forwarded-for": "10.0.0.1" } }, { xfwd: true }, ); const body = (await res.json()) as { headers: Record }; expect(body.headers["x-forwarded-for"]).toBe("10.0.0.1"); }); }); describe("changeOrigin", () => { it("rewrites Host header to target address", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://original-host.com/headers`, undefined, { changeOrigin: true }, ); const body = (await res.json()) as { headers: Record }; expect(body.headers.host).toBe(`127.0.0.1:${tcpPort}`); }); it("keeps original Host header when changeOrigin is false", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://original-host.com/headers`, { headers: { host: "original-host.com" } }, ); const body = (await res.json()) as { headers: Record }; expect(body.headers.host).toBe("original-host.com"); }); it("uses localhost for unix socket targets", async () => { const unixHeaders = createServer((req, res) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ host: req.headers.host })); }); const tmpSocket = join(tmpdir(), `httpxy-co-${process.pid}-${Date.now()}.sock`); await new Promise((resolve) => unixHeaders.listen(tmpSocket, resolve)); try { const res = await proxyFetch( { socketPath: tmpSocket }, `http://original-host.com/`, undefined, { changeOrigin: true }, ); const body = (await res.json()) as { host: string }; expect(body.host).toBe("localhost"); } finally { unixHeaders.close(); } }); }); describe("agent", () => { it("uses provided agent for connection pooling", async () => { const { Agent } = await import("node:http"); const agent = new Agent({ keepAlive: true }); try { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/json`, undefined, { agent }, ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); } finally { agent.destroy(); } }); }); describe("body types", () => { it("sends ArrayBuffer body", async () => { const buf = new TextEncoder().encode("arraybuffer-body"); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/echo`, { method: "POST", body: buf.buffer, }); expect(res.status).toBe(200); expect(await res.text()).toBe("arraybuffer-body"); }); it("sends Uint8Array body", async () => { const buf = new TextEncoder().encode("uint8-body"); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/echo`, { method: "POST", body: buf, }); expect(res.status).toBe(200); expect(await res.text()).toBe("uint8-body"); }); it("sends Blob body", async () => { const blob = new Blob(["blob-body"], { type: "text/plain" }); const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/echo`, { method: "POST", body: blob, }); expect(res.status).toBe(200); expect(await res.text()).toBe("blob-body"); }); }); describe("multi-value request headers", () => { it("preserves multiple cookie values", async () => { const headers = new Headers(); headers.append("x-multi", "val1"); headers.append("x-multi", "val2"); const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/headers`, { headers }, ); const body = (await res.json()) as { headers: Record }; // Node.js http server joins multi-value headers with ", " expect(body.headers["x-multi"]).toBe("val1, val2"); }); }); describe("followRedirects", () => { it("follows 302 redirect and returns final response", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/redirect`, undefined, { followRedirects: true }, ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); }); it("follows redirect chain", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/redirect-chain`, undefined, { followRedirects: true }, ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); }); it("preserves method and body on 307 redirect", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/redirect-307`, { method: "POST", body: "preserved" }, { followRedirects: true }, ); expect(res.status).toBe(200); expect(await res.text()).toBe("preserved"); }); it("respects custom max redirects", async () => { // redirect-chain → redirect → json (2 hops), limit to 1 const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/redirect-chain`, undefined, { followRedirects: 1 }, ); // After 1 hop we land on /redirect which is a 302 — returned as-is expect(res.status).toBe(302); }); it("returns raw 3xx when followRedirects is false", async () => { const res = await proxyFetch( { host: "127.0.0.1", port: tcpPort }, `http://localhost/redirect`, undefined, { followRedirects: false }, ); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/json"); }); }); describe("path merging", () => { it("prepends addr base path to request path", async () => { const res = await proxyFetch( `http://127.0.0.1:${tcpPort}/headers`, `http://localhost/?from=merge`, ); expect(res.status).toBe(200); const body = (await res.json()) as { url: string }; expect(body.url).toBe("/headers/?from=merge"); }); it("joins addr base path with request subpath", async () => { // addr has /headers, request has /sub → merged to /headers/sub // server matches startsWith("/headers") so this works const res = await proxyFetch(`http://127.0.0.1:${tcpPort}/headers`, `http://localhost/sub`); expect(res.status).toBe(200); const body = (await res.json()) as { url: string }; expect(body.url).toBe("/headers/sub"); }); it("uses request path when addr has no path", async () => { const res = await proxyFetch(`http://127.0.0.1:${tcpPort}`, `http://localhost/json`); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); }); }); describe("HTTPS upstream", () => { it("detects HTTPS from addr string", async () => { // We can't easily test real HTTPS without certs, but we can verify // that an https addr doesn't throw and properly rejects on connection // (which proves httpsRequest was selected, not httpRequest) await expect(proxyFetch(`https://127.0.0.1:1`, `http://localhost/json`)).rejects.toThrow(); }); it("uses HTTP by default for object addr", async () => { const res = await proxyFetch({ host: "127.0.0.1", port: tcpPort }, `http://localhost/json`); expect(res.status).toBe(200); }); }); }); ================================================ FILE: test/fixtures/agent2-cert.pem ================================================ -----BEGIN CERTIFICATE----- MIIEIDCCAggCCQChRDh/XiBF+zANBgkqhkiG9w0BAQsFADBUMQswCQYDVQQGEwJ1 czETMBEGA1UECAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEeMBwGA1UE AwwVRHVtbXkgSW50ZXJtZWRpYXRlIENBMB4XDTE4MDYyMjIwMzEwNFoXDTMyMDIy OTIwMzEwNFowUDELMAkGA1UEBhMCdXMxEzARBgNVBAgMCldhc2hpbmd0b24xEDAO BgNVBAcMB1NlYXR0bGUxGjAYBgNVBAMMEWR1bW15LmV4YW1wbGUuY29tMIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvSQq3d8AeZMTvtqZ13jWCckikyXJ SACvkGCQUCJqOceESbg6IHdRzQdoccE4P3sbvNsf9BlbdJKM+neCxabqKaU1PPje 4P0tHT57t6yJrMuUh9NxEz3Bgh1srNHVS7saKvwHmcKm79jc+wxlioPmEQvQagjn y7oTkyLt0sn4LGxBjrcv2JoHOC9f1pxX7l47MaiN0/ctRau7Nr3PFn+pkB4Yf6Z0 VyicVJbaUSz39Qo4HQWl1L2hiBP3CS1oKs2Yk0O1aOCMExWrhZQan+ZgHqL1rhgm kPpw2/zwwPt5Vf9CSakvHwg198EXuTTXtkzYduuIJAm8yp969iEIiG2xTwIDAQAB MA0GCSqGSIb3DQEBCwUAA4ICAQBnMSIo+kujkeXPh+iErFBmNtu/7EA+i/QnFPbN lSLngclYYBJAGQI+DhirJI8ghDi6vmlHB2THewDaOJXEKvC1czE8064wioIcA9HJ l3QJ3YYOFRctYdSHBU4TWdJbPgkLWDzYP5smjOfw8nDdr4WO/5jh9qRFcFpTFmQf DyU3xgWLsQnNK3qXLdJjWG75pEhHR+7TGo+Ob/RUho/1RX/P89Ux7/oVbzdKqqFu SErXAsjEIEFzWOM2uDOt6hrxDF6q+8/zudwQNEo422poEcTT9tDEFxMQ391CzZRi nozBm4igRn1f5S3YZzLI6VEUns0s76BNy2CzvFWn40DziTqNBExAMfFFj76wiMsX 6fTIdcvkaTBa0S9SZB0vN99qahBdcG17rt4RssMHVRH1Wn7NXMwe476L0yXZ6gO7 Z4uNAPxgaI3BRP75EPfslLutCLZ+BC4Zzu6MY0Salbpfl0Go462EhsKCxvYhE2Dg T477pICLfETZfA499Fd1tOaIsoLCrILAia/+Yd76uf94MuXUIqykDng/4H7xCc47 BZhNFJiPC6XHaXzN7NYSEUNX9VOwY8ncxKwtP6TXga96PdMUy/p98KIM8RZlDoxB Xy9dcZBFNn/zrqjW7R0CCWCUriDIFSmEP0wDZ91YYa6BVuJMb5uL/USkTLpjZS4/ HNGvug== -----END CERTIFICATE----- ================================================ FILE: test/fixtures/agent2-key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAvSQq3d8AeZMTvtqZ13jWCckikyXJSACvkGCQUCJqOceESbg6 IHdRzQdoccE4P3sbvNsf9BlbdJKM+neCxabqKaU1PPje4P0tHT57t6yJrMuUh9Nx Ez3Bgh1srNHVS7saKvwHmcKm79jc+wxlioPmEQvQagjny7oTkyLt0sn4LGxBjrcv 2JoHOC9f1pxX7l47MaiN0/ctRau7Nr3PFn+pkB4Yf6Z0VyicVJbaUSz39Qo4HQWl 1L2hiBP3CS1oKs2Yk0O1aOCMExWrhZQan+ZgHqL1rhgmkPpw2/zwwPt5Vf9CSakv Hwg198EXuTTXtkzYduuIJAm8yp969iEIiG2xTwIDAQABAoIBAGPIw/C/qJF7HYyv 6T+7GTiaa2o0IiehbP3/Y8NTFLWc49a8obXlHTvMr7Zr2I/tE+ojtIzkH9K1SjkN eelqsNj9tsOPDI6oIvftsflpxkxqLtclnt8m0oMhoObf4OaONDT/N8dP4SBiSdsM ZDmacnMFx5NZVWiup4sVf2CYexx7qks9FhyN2K5PArCQ4S9LHjFhSJVH4DSEpv7E Ykbp30rhpqV7wSwjgUsm8ZYvI2NOlmffzLSiPdt3vy2K5Q25S/MVEAicg83rfDgK 6EluHjeygRI1xU6DJ0hU7tnU7zE9KURoHPUycO3BKzZnzUH26AA36I58Pu4fXWw/ Cgmbv2ECgYEA+og9E4ziKCEi3p8gqjIfwTRgWZxDLjEzooB/K0UhEearn/xiX29A FiSzEHKfCB4uSrw5OENg2ckDs8uy08Qmxx7xFXL7AtufAl5fIYaWa0sNSqCaIk7p ebbUmPcaYhKiLzIEd1EYEL38sXVZ62wvSVMRSWvEMq44g1qnoRlDa/8CgYEAwUTt talYNwVmR9ZdkVEWm9ZxirdzoM6NaM6u4Tf34ygptpapdmIFSUhfq4iOiEnRGNg/ tuNqhNCIb3LNpJbhRPEzqN7E7qiF/mp7AcJgbuxLZBm12QuLuJdG3nrisKPFXcY1 lA4A7CFmNgH3E4THFfgwzyDXsBOxVLXleTqn+rECgYEA9up1P6J3dtOJuV2d5P/3 ugRz/X173LfTSxJXw36jZDAy8D/feG19/RT4gnplcKvGNhQiVOhbOOnbw0U8n2fQ TCmbs+cZqyxnH/+AxNsPvvk+RVHZ93xMsY/XIldP4l65B8jFDA+Zp06IESI2mEeM pzi+bd1Phh+dRSCA2865W2MCgYEAlxYsgmQ1WyX0dFpHYU+zzfXRYzDQyrhOYc2Z duVK+yCto1iad7pfCY/zgmRJkI+sT7DV9kJIRjXDQuTLkEyHJF8vFGe6KhxCS8aw DIsI2g4NTd6vg1J8UryoIUqNpqsQoqNNxUVBQVdG0ReuMGsPO8R/W50AIFz0txVP o/rP0LECgYEA7e/mOwCnR+ovmS/CAksmos3oIqvkRkXNKpKe513FVmp3TpTU38ex cBkFNU3hEO31FyrX1hGIKp3N5mHYSQ1lyODHM6teHW0OLWWTwIe8rIGvR2jfRLe0 bbkdj40atYVkfeFmpz9uHHG24CUYxJdPc360jbXTVp4i3q8zqgL5aMY= -----END RSA PRIVATE KEY----- ================================================ FILE: test/http-proxy.test.ts ================================================ import { describe, it, expect } from "vitest"; import * as httpProxy from "../src/index.ts"; 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"; // Source: https://github.com/http-party/node-http-proxy/blob/master/test/lib-http-proxy-test.js describe("http-proxy", () => { describe("#createProxyServer", () => { it.skip("should throw without options", () => { let error; try { httpProxy.createProxyServer(); } catch (error_) { error = error_; } expect(error).to.toBeInstanceOf(Error); }); it("should return an object otherwise", () => { const obj = httpProxy.createProxyServer({ target: "http://www.google.com:80", }); expect(obj.web).to.toBeInstanceOf(Function); expect(obj.ws).to.instanceOf(Function); expect(obj.listen).to.instanceOf(Function); }); }); describe("#createProxyServer with forward options and using web-incoming passes", () => { it("should pipe the request using web-incoming#stream method", async () => { const source = http.createServer(); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ forward: "http://127.0.0.1:" + sourcePort, }); const proxyPort = await proxyListen(proxy); const { promise, resolve } = Promise.withResolvers(); source.on("request", (req, res) => { expect(req.method).to.eql("GET"); expect(Number.parseInt(req.headers.host!.split(":")[1]!)).toBe(proxyPort); source.close(); proxy.close(resolve); }); http.request("http://127.0.0.1:" + proxyPort, () => {}).end(); await promise; }); }); describe("#createProxyServer using the web-incoming passes", () => { it("should proxy sse", async () => { const source = http.createServer(); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:" + sourcePort, }); 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, }; const { promise, resolve } = Promise.withResolvers(); const req = http .request(options, (res) => { let streamData = ""; res.on("data", (chunk) => { streamData += chunk.toString("utf8"); }); res.on("end", () => { expect(streamData).to.equal(":ok\n\ndata: Hello over SSE\n\n"); source.close(); proxy.close(resolve); }); }) .end(); await promise; }); it("should close downstream SSE stream when upstream aborts", async () => { const source = http.createServer((_, res) => { res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", }); res.write(":ok\n\n"); setTimeout(() => { res.socket?.destroy(); }, 20); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:" + sourcePort, }); const proxyPort = await proxyListen(proxy); const { promise, resolve } = Promise.withResolvers(); let gotFirstChunk = false; let requestError: Error | undefined; const finish = () => { source.close(); proxy.close(resolve); }; const timeout = setTimeout(() => { requestError = new Error("Timed out waiting for downstream SSE close"); finish(); }, 1000); http .request( { hostname: "127.0.0.1", port: proxyPort, method: "GET", }, (res) => { res.on("data", (chunk) => { if (chunk.toString("utf8").includes(":ok")) { gotFirstChunk = true; } }); res.once("close", () => { clearTimeout(timeout); finish(); }); }, ) .on("error", (error) => { clearTimeout(timeout); requestError = error; finish(); }) .end(); await promise; expect(requestError).toBeUndefined(); 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; 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"); req.socket.on("close", () => { upstreamReqDestroyed = true; }); }); const sourcePort = await listenOn(source); 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); // 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); await promise; expect(upstreamReqDestroyed).toBe(true); }); it("should make the request on pipe and finish it", async () => { const source = http.createServer(); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:" + sourcePort, }); const proxyPort = await proxyListen(proxy); const { promise, resolve } = Promise.withResolvers(); source.on("request", (req, res) => { expect(req.method).to.eql("POST"); expect(req.headers["x-forwarded-for"]).to.eql("127.0.0.1"); expect(Number.parseInt(req.headers.host!.split(":")[1]!)).to.eql(proxyPort); source.close(); proxy.close(() => {}); resolve(); }); http .request( { hostname: "127.0.0.1", port: proxyPort, method: "POST", headers: { "x-forwarded-for": "127.0.0.1", }, }, () => {}, ) .end(); await promise; }); }); 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); 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); } res.on("data", (data) => { expect(data.toString()).to.eql("Hello from " + sourcePort); }); res.on("end", () => { source.close(); proxy.close(resolve); }); }, ) .end(); await promise; }); }); describe("#createProxyServer() method with error response", () => { it("should make the request and emit the error event", async () => { const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:1", }); const { promise, resolve } = Promise.withResolvers(); proxy.on("error", (err) => { expect(err).toBeInstanceOf(Error); expect((err as any).code).toBe("ECONNREFUSED"); proxy.close(() => {}); resolve(); }); const proxyPort = await proxyListen(proxy); http .request( { hostname: "127.0.0.1", port: proxyPort, method: "GET", }, () => {}, ) .end(); await promise.catch(() => {}); }); }); describe("#createProxyServer setting the correct timeout value", () => { it("should hang up the socket at the timeout", async () => { const { promise, resolve } = Promise.withResolvers(); const source = http.createServer(function (_req, res) { setTimeout(() => { res.end("At this point the socket should be closed"); }, 5); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:" + sourcePort, timeout: 3, }); const proxyPort = await proxyListen(proxy); proxy.on("error", (err) => { expect(err).toBeInstanceOf(Error); expect((err as any).code).toBe("ECONNRESET"); }); const testReq = http.request( { hostname: "127.0.0.1", port: proxyPort, method: "GET", }, () => {}, ); testReq.on("error", (err) => { expect(err).toBeInstanceOf(Error); expect((err as any).code).toBe("ECONNRESET"); proxy.close(() => {}); source.close(); resolve(); }); testReq.end(); await promise; }); }); describe("#createProxyServer client disconnect", () => { it("should emit econnreset instead of error when client disconnects", async () => { const { promise, resolve, reject } = Promise.withResolvers(); // Target server that accepts connections but never responds const source = net.createServer(); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:" + sourcePort, }); // Intercept proxyReq to simulate the race condition where the client // has disconnected (socket is no longer writable) but req.socket.destroyed // is still false — reproducing the timing issue from upstream PR #1542. proxy.on("proxyReq", (proxyReq, req) => { setTimeout(() => { const socket = req.socket; Object.defineProperty(socket, "writable", { value: false, configurable: true }); Object.defineProperty(socket, "destroyed", { value: false, configurable: true }); proxyReq.emit( "error", Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }), ); }, 50); }); const proxyServer = http.createServer((req, res) => { proxy.web(req, res); }); const proxyPort = await listenOn(proxyServer); proxy.on("econnreset", (err) => { expect(err).toBeInstanceOf(Error); expect((err as any).code).toBe("ECONNRESET"); proxy.close(() => {}); proxyServer.close(); source.close(); resolve(); }); proxy.on("error", (err) => { proxy.close(() => {}); proxyServer.close(); source.close(); reject(new Error(`Unexpected error event: ${(err as any).code || err.message}`)); }); const testReq = http.request( { hostname: "127.0.0.1", port: proxyPort, method: "GET", }, () => {}, ); testReq.on("error", () => { // Expected }); testReq.end(); await promise; }); }); describe("#createProxyServer with xfwd option", () => { it("should not throw on empty http host header", async () => { const source = http.createServer(); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ forward: "http://127.0.0.1:" + sourcePort, xfwd: true, }); const proxyPort = await proxyListen(proxy); const { promise, resolve } = Promise.withResolvers(); source.on("request", function (req, _res) { expect(req.method).to.eql("GET"); // Host header is forwarded from the original request (not changed to source) expect(req.headers["x-forwarded-for"]).toBeDefined(); source.close(); proxy.close(resolve); }); const socket = net.connect({ port: proxyPort }, () => { socket.write("GET / HTTP/1.0\r\n\r\n"); }); socket.on("data", () => { socket.end(); }); // Ignore socket errors during teardown (server may close before socket drains) socket.on("error", () => {}); http.request("http://127.0.0.1:" + proxyPort, () => {}).end(); await promise; }); }); describe("#createProxyServer using the ws-incoming passes", () => { it("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; const proxy = httpProxy.createProxyServer({ target: "ws://127.0.0.1:" + sourcePort, ws: true, }); const proxyPort = await proxyListen(proxy); const proxyServer = proxy; const { promise, resolve } = Promise.withResolvers(); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("open", () => { client.send("hello there"); }); 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("hello there"); socket.send("Hello over websockets"); }); }); await promise; }); it("should emit error on proxy error", async () => { const { promise, resolve } = Promise.withResolvers(); const proxy = httpProxy.createProxyServer({ // Note: we don't ever listen on this port target: "ws://127.0.0.1:1", 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"); }); let count = 0; function maybe_done() { count += 1; if (count === 2) resolve(); } client.on("error", (err) => { expect(err).toBeInstanceOf(Error); expect((err as any).code).toBe("ECONNRESET"); maybe_done(); }); proxy.on("error", (err) => { expect(err).toBeInstanceOf(Error); expect((err as any).code).toBe("ECONNREFUSED"); proxyServer.close(() => {}); maybe_done(); }); 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"); }); client.on("error", (err) => { expect(err).toBeInstanceOf(Error); proxyServer.close(resolve); }); await promise; }); 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(); 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, }); proxy.on("error", () => { // Error handler - the fix ensures this is called instead of crashing }); 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); }); await promise; server.close(); }); it("should proxy a socket.io stream", async () => { const { resolve, promise } = Promise.withResolvers(); 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.emit("incoming", "hello there"); }); client.on("outgoing", (data: any) => { expect(data).toBe("Hello over websockets"); client.disconnect(); destiny.close(); server.close(); proxyServer.close(resolve); }); } startSocketIo(); destiny.on("connection", (socket) => { socket.on("incoming", (msg) => { expect(msg).toBe("hello there"); socket.emit("outgoing", "Hello over websockets"); }); }); await promise; }); it("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 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(); }); } let count = 0; proxyServer.on("open", () => { count += 1; }); proxyServer.on("close", () => { destiny.close(); server.close(); proxyServer.close(() => {}); expect(count).toBe(1); resolve(); }); startSocketIo(); await promise; }); it("should pass all set-cookie headers to client", async () => { const { resolve, promise } = Promise.withResolvers(); 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("upgrade", (res) => { expect(res.headers["set-cookie"]).toHaveLength(2); }); client.on("open", () => { client.close(); destiny.close(); proxyServer.close(resolve); }); destiny.on("headers", (headers) => { headers.push("Set-Cookie: test1=test1", "Set-Cookie: test2=test2"); }); await promise; }); it("should detect a proxyReq event and modify headers", async () => { const { promise, resolve } = Promise.withResolvers(); 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, }); proxy.on("proxyReqWs", function (proxyReq, req, socket, options, head) { proxyReq.setHeader("X-Special-Proxy-Header", "foobar"); }); 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"); }); client.on("message", (msg: any) => { expect(msg.toString("utf8")).toBe("Hello over websockets"); client.close(); destiny.close(); proxyServer.close(resolve); }); destiny.on("connection", function (socket, upgradeReq) { expect(upgradeReq.headers["x-special-proxy-header"]).to.eql("foobar"); socket.on("message", (msg: any) => { expect(msg.toString("utf8")).toBe("hello there"); socket.send("Hello over websockets"); }); }); 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); }); 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"); }); }); 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"); 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); }); 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"); }); }); await promise; }); it("should not crash when client socket errors before upstream upgrade (issue #79)", async () => { const { promise, resolve } = Promise.withResolvers(); // Backend that delays responding to the upgrade request const server = http.createServer(); server.on("upgrade", (_req, socket) => { // Never respond — simulate a slow/hanging backend socket.on("error", () => {}); setTimeout(() => socket.destroy(), 500); }); const sourcePort = await listenOn(server); const proxy = httpProxy.createProxyServer({ target: "ws://127.0.0.1:" + sourcePort, ws: true, }); // Intercept the ws stream pass to inject an error on the client socket // before the upstream upgrade response arrives proxy.before("ws", "", ((_req: any, socket: any) => { // After the proxy sets up the upstream request but before the // upgrade callback fires, simulate a client disconnect (ECONNRESET) setTimeout(() => { socket.destroy(new Error("read ECONNRESET")); }, 50); }) as any); const proxyPort = await proxyListen(proxy); proxy.on("error", () => { // The error should be caught here, not crash the process proxy.close(() => {}); server.close(); resolve(); }); // Use a raw TCP socket to send a WebSocket upgrade request const client = net.connect(proxyPort, "127.0.0.1", () => { client.write( "GET / HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n", ); }); client.on("error", () => {}); await promise; }); }); }); ================================================ FILE: test/http2-proxy.test.ts ================================================ import * as http from "node:http"; import * as https from "node:https"; import * as httpProxy from "../src/index.ts"; import * as path from "node:path"; import * as fs from "node:fs"; import { describe, it, expect, afterAll, beforeAll } from "vitest"; import { Agent, fetch } from "undici"; import { listenOn, proxyListen } from "./_utils.ts"; import { inspect } from "node:util"; const http1Agent = new Agent({ allowH2: false, connect: { // Allow to use SSL self signed rejectUnauthorized: false, }, }); const http2Agent = new Agent({ allowH2: true, connect: { // Allow to use SSL self signed rejectUnauthorized: false, }, }); describe("http/2 listener", () => { describe("http2 -> http", () => { let source: http.Server; let sourcePort: number; let proxy: httpProxy.ProxyServer; let proxyPort: number; beforeAll(async () => { source = http.createServer((_req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello from " + sourcePort); }); sourcePort = await listenOn(source); proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:" + sourcePort, ssl: { key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), }, http2: true, // Allow to use SSL self signed secure: false, }); proxyPort = await proxyListen(proxy); }); it("target http server should be working", async () => { try { const r = await ( await fetch(`http://127.0.0.1:${sourcePort}`, { dispatcher: http1Agent }) ).text(); expect(r).to.eql("Hello from " + sourcePort); } catch (err) { expect.fail("Failed to fetch target server: " + inspect(err)); } }); it("fetch proxy server over http1", async () => { try { const r = await ( await fetch(`https://127.0.0.1:${proxyPort}`, { dispatcher: http1Agent }) ).text(); expect(r).to.eql("Hello from " + sourcePort); } catch (err) { expect.fail("Failed to fetch target server: " + inspect(err)); } }); it("fetch proxy server over http2", async () => { try { const resp = await fetch(`https://127.0.0.1:${proxyPort}`, { dispatcher: http2Agent }); const r = await resp.text(); expect(r).to.eql("Hello from " + sourcePort); } catch (err) { expect.fail("Failed to fetch target server: " + inspect(err)); } }); afterAll(async () => { // cleans up await new Promise((resolve) => proxy.close(resolve)); source.close(); }); }); describe("http2 -> https", () => { let source: https.Server; let sourcePort: number; let proxy: httpProxy.ProxyServer; let proxyPort: number; beforeAll(async () => { source = https.createServer( { key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), ciphers: "AES128-GCM-SHA256", }, function (req, res) { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello from " + sourcePort); }, ); sourcePort = await listenOn(source); proxy = httpProxy.createProxyServer({ target: "https://127.0.0.1:" + sourcePort, ssl: { key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), }, http2: true, // Allow to use SSL self signed secure: false, }); proxyPort = await proxyListen(proxy); }); it("target https server should be working", async () => { try { const r = await ( await fetch(`https://127.0.0.1:${sourcePort}`, { dispatcher: http1Agent }) ).text(); expect(r).to.eql("Hello from " + sourcePort); } catch (err) { expect.fail("Failed to fetch target server: " + inspect(err)); } }); it("fetch proxy server over http1", async () => { try { const r = await ( await fetch(`https://127.0.0.1:${proxyPort}`, { dispatcher: http1Agent }) ).text(); expect(r).to.eql("Hello from " + sourcePort); } catch (err) { expect.fail("Failed to fetch target server: " + inspect(err)); } }); it("fetch proxy server over http2", async () => { try { const resp = await fetch(`https://127.0.0.1:${proxyPort}`, { dispatcher: http2Agent }); const r = await resp.text(); expect(r).to.eql("Hello from " + sourcePort); } catch (err) { expect.fail("Failed to fetch target server: " + inspect(err)); } }); afterAll(async () => { // cleans up await new Promise((resolve) => proxy.close(resolve)); source.close(); }); }); }); ================================================ FILE: test/https-proxy.test.ts ================================================ import { describe, it, expect } from "vitest"; import * as httpProxy from "../src/index.ts"; import http from "node:http"; import https from "node:https"; import path from "node:path"; import fs from "node:fs"; import { listenOn, proxyListen } from "./_utils.ts"; // Source: https://github.com/http-party/node-http-proxy/blob/master/test/lib-https-proxy-test.js describe("lib/http-proxy.js", () => { describe("HTTPS #createProxyServer", () => { describe("HTTPS to HTTP", () => { it("should proxy the request en send back the response", async () => { const { promise, resolve } = Promise.withResolvers(); const source = http.createServer(function (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 " + sourcePort); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:" + sourcePort, ssl: { key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), ciphers: "AES128-GCM-SHA256", }, }); const proxyPort = await proxyListen(proxy); https .request( { host: "127.0.0.1", port: proxyPort, path: "/", method: "GET", rejectUnauthorized: false, }, function (res) { expect(res.statusCode).to.eql(200); res.on("data", function (data) { expect(data.toString()).to.eql("Hello from " + sourcePort); }); res.on("end", () => { source.close(); proxy.close(resolve); }); }, ) .end(); await promise; }); }); describe("HTTP to HTTPS", () => { it("should proxy the request en send back the response", async () => { const { resolve, promise } = Promise.withResolvers(); const source = https.createServer( { key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), ciphers: "AES128-GCM-SHA256", }, function (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 " + sourcePort); }, ); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "https://127.0.0.1:" + sourcePort, // Allow to use SSL self signed secure: false, }); const proxyPort = await proxyListen(proxy); http .request( { hostname: "127.0.0.1", port: proxyPort, method: "GET", }, function (res) { expect(res.statusCode).to.eql(200); res.on("data", function (data) { expect(data.toString()).to.eql("Hello from " + sourcePort); }); res.on("end", () => { source.close(); proxy.close(resolve); }); }, ) .end(); await promise; }); }); describe("HTTPS to HTTPS", () => { it("should proxy the request en send back the response", async () => { const { resolve, promise } = Promise.withResolvers(); const source = https.createServer( { key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), ciphers: "AES128-GCM-SHA256", }, function (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 " + sourcePort); }, ); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "https://127.0.0.1:" + sourcePort, ssl: { key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), ciphers: "AES128-GCM-SHA256", }, secure: false, }); const proxyPort = await proxyListen(proxy); https .request( { host: "127.0.0.1", port: proxyPort, path: "/", method: "GET", rejectUnauthorized: false, }, function (res) { expect(res.statusCode).to.eql(200); res.on("data", function (data) { expect(data.toString()).to.eql("Hello from " + sourcePort); }); res.on("end", () => { source.close(); proxy.close(resolve); }); }, ) .end(); await promise; }); }); describe("HTTPS not allow SSL self signed", () => { it("should fail with error", async () => { const { resolve, promise } = Promise.withResolvers(); const source = https.createServer({ key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), ciphers: "AES128-GCM-SHA256", }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: "https://127.0.0.1:" + sourcePort, secure: true, }); const proxyPort = await proxyListen(proxy); proxy.on("error", function (err) { expect(err).toBeInstanceOf(Error); expect(err.toString()).toMatch( /unable to verify the first certificate|DEPTH_ZERO_SELF_SIGNED_CERT/, ); source.close(); proxy.close(); resolve(); }); http .request({ hostname: "127.0.0.1", port: proxyPort, method: "GET", }) .end(); await promise; }); }); describe("HTTPS to HTTP using own server", () => { it("should proxy the request en send back the response", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (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 " + sourcePort); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ agent: new http.Agent({ maxSockets: 2 }), }); const ownServer = https.createServer( { key: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-key.pem")), cert: fs.readFileSync(path.join(__dirname, "fixtures", "agent2-cert.pem")), ciphers: "AES128-GCM-SHA256", }, function (req, res) { proxy.web(req, res, { target: "http://127.0.0.1:" + sourcePort, }); }, ); const proxyPort = await listenOn(ownServer); https .request( { host: "127.0.0.1", port: proxyPort, path: "/", method: "GET", rejectUnauthorized: false, }, function (res) { expect(res.statusCode).to.eql(200); res.on("data", function (data) { expect(data.toString()).to.eql("Hello from " + sourcePort); }); res.on("end", () => { source.close(); ownServer.close(); resolve(); }); }, ) .end(); await promise; }); }); }); }); ================================================ FILE: test/index.test.ts ================================================ import { afterAll, describe, expect, it } from "vitest"; import http, { type IncomingMessage, type ServerResponse } from "node:http"; import { type AddressInfo } from "node:net"; import { $fetch } from "ofetch"; import { createProxyServer, ProxyServer, type ProxyServerOptions } from "../src/index.ts"; type Listener = { close: () => Promise; url: string; }; function listen(handler: (req: IncomingMessage, res: ServerResponse) => void | Promise) { return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { void handler(req, res); }); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const { port } = server.address() as AddressInfo; resolve({ close: () => new Promise((resolveClose, rejectClose) => { server.close((error) => { if (error) { rejectClose(error); return; } resolveClose(); }); }), url: `http://127.0.0.1:${port}/`, }); }); }); } describe("httpxy", () => { let mainListener: Listener; let proxyListener: Listener; let proxy: ProxyServer; let lastResolved: any; let lastRejected: any; const maskResponse = (obj: any) => ({ ...obj, headers: { ...obj.headers, connection: "<>", host: "<>" }, }); const makeProxy = async (options: ProxyServerOptions) => { mainListener = await listen((req, res) => { res.end( JSON.stringify({ method: req.method, path: req.url, headers: req.headers, }), ); }); proxy = createProxyServer(options); proxyListener = await listen(async (req, res) => { lastResolved = false; lastRejected = undefined; try { await proxy.web(req, res, { target: mainListener.url + "base" }); lastResolved = true; } catch (error) { lastRejected = error; res.statusCode = 500; res.end("Proxy error: " + (error as Error).toString()); } }); }; afterAll(async () => { await proxyListener?.close(); await mainListener?.close(); proxy?.close(); }); it("works", async () => { await makeProxy({}); const mainResponse = await $fetch(mainListener.url + "base/test?foo"); const proxyResponse = await $fetch(proxyListener.url + "test?foo"); expect(maskResponse(await mainResponse)).toMatchObject(maskResponse(proxyResponse)); expect(proxyResponse.path).toBe("/base/test?foo"); expect(lastResolved).toBe(true); expect(lastRejected).toBe(undefined); }); it("should avoid normalize url", async () => { const mainResponse = await $fetch(mainListener.url + "base/a/b//c"); const proxyResponse = await $fetch(proxyListener.url + "a/b//c"); expect(maskResponse(await mainResponse)).toMatchObject(maskResponse(proxyResponse)); expect(proxyResponse.path).toBe("/base/a/b//c"); expect(lastResolved).toBe(true); expect(lastRejected).toBe(undefined); }); }); describe("middleware pass exceptions", () => { it("should forward synchronous pass errors to error event", async () => { const target = await new Promise<{ close: () => Promise; url: string; }>((resolve, reject) => { const server = http.createServer((_req, res) => { res.end("ok"); }); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const { port } = server.address() as AddressInfo; resolve({ close: () => new Promise((r, j) => { server.close((e) => (e ? j(e) : r())); }), url: `http://127.0.0.1:${port}/`, }); }); }); const proxy = createProxyServer({ target: target.url }); // Inject a middleware pass that throws synchronously (simulates ERR_INVALID_HTTP_TOKEN) const testError = new TypeError("Invalid character in header"); proxy.before("web", "", () => { throw testError; }); const proxyServer = await new Promise<{ close: () => Promise; url: string; }>((resolve, reject) => { const server = http.createServer((req, res) => { void proxy.web(req, res); }); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const { port } = server.address() as AddressInfo; resolve({ close: () => new Promise((r, j) => { server.close((e) => (e ? j(e) : r())); }), url: `http://127.0.0.1:${port}/`, }); }); }); try { // With an error listener, the error should be emitted, not thrown const errorPromise = new Promise((resolve) => { proxy.on("error", (err, _req, res) => { resolve(err); // End the response so the request doesn't hang if (res && "writeHead" in res && !res.headersSent) { res.writeHead(502); res.end(); } }); }); // The request may fail since the proxy errored before sending a response await $fetch(proxyServer.url, { ignoreResponseError: true }).catch(() => {}); const emittedError = await errorPromise; expect(emittedError).toBe(testError); } finally { proxy.close(); await proxyServer.close(); await target.close(); } }); it("should reject promise when no error listener and pass throws", async () => { const target = await new Promise<{ close: () => Promise; url: string; }>((resolve, reject) => { const server = http.createServer((_req, res) => { res.end("ok"); }); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const { port } = server.address() as AddressInfo; resolve({ close: () => new Promise((r, j) => { server.close((e) => (e ? j(e) : r())); }), url: `http://127.0.0.1:${port}/`, }); }); }); const proxy = createProxyServer({ target: target.url }); // Inject a middleware pass that throws synchronously const testError = new TypeError("Invalid character in header"); proxy.before("web", "", () => { throw testError; }); const proxyServer = await new Promise<{ close: () => Promise; url: string; }>((resolve, reject) => { const server = http.createServer((req, res) => { void proxy.web(req, res).catch(() => { res.statusCode = 502; res.end("error"); }); }); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const { port } = server.address() as AddressInfo; resolve({ close: () => new Promise((r, j) => { server.close((e) => (e ? j(e) : r())); }), url: `http://127.0.0.1:${port}/`, }); }); }); try { // No error listener - the promise should reject with the thrown error const response = await $fetch.raw(proxyServer.url, { ignoreResponseError: true, }); expect(response.status).toBe(502); } finally { proxy.close(); await proxyServer.close(); await target.close(); } }); }); ================================================ FILE: test/middleware/web-incoming.test.ts ================================================ import { describe, it, expect } from "vitest"; import * as webPasses from "../../src/middleware/web-incoming.ts"; import * as httpProxy from "../../src/index.ts"; import concat from "concat-stream"; import http from "node:http"; import { stubIncomingMessage, stubServerResponse, stubMiddlewareOptions, stubProxyServer, } from "../_stubs.ts"; import { 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 describe("middleware:web-incoming", () => { describe("#deleteLength", () => { it("should change `content-length` for DELETE requests", () => { const stubRequest = stubIncomingMessage({ method: "DELETE", headers: {}, }); webPasses.deleteLength( stubRequest, stubServerResponse(), stubMiddlewareOptions(), stubProxyServer(), ); expect(stubRequest.headers["content-length"]).to.eql("0"); }); it("should change `content-length` for OPTIONS requests", () => { const stubRequest = stubIncomingMessage({ method: "OPTIONS", headers: {}, }); webPasses.deleteLength( stubRequest, stubServerResponse(), stubMiddlewareOptions(), stubProxyServer(), ); expect(stubRequest.headers["content-length"]).to.eql("0"); }); it("should remove `transfer-encoding` from empty DELETE requests", () => { const stubRequest = stubIncomingMessage({ method: "DELETE", headers: { "transfer-encoding": "chunked", }, }); webPasses.deleteLength( stubRequest, stubServerResponse(), stubMiddlewareOptions(), stubProxyServer(), ); expect(stubRequest.headers["content-length"]).to.eql("0"); expect(stubRequest.headers).to.not.have.key("transfer-encoding"); }); }); describe("#timeout", () => { it("should set timeout on the socket", () => { let done = false; const stubRequest = stubIncomingMessage({ socket: { setTimeout: function (value: any) { done = value; }, }, }); webPasses.timeout( stubRequest, stubServerResponse(), stubMiddlewareOptions({ timeout: 5000 }), stubProxyServer(), ); expect(done).to.eql(5000); }); }); describe("#XHeaders", () => { const req = stubIncomingMessage({ connection: { remoteAddress: "192.168.1.2", remotePort: "8080", }, headers: { host: "192.168.1.2:8080", }, }); it("set the correct x-forwarded-* headers", () => { webPasses.XHeaders( req, stubServerResponse(), stubMiddlewareOptions({ xfwd: true }), stubProxyServer(), ); expect(req.headers["x-forwarded-for"]).toBe("192.168.1.2"); expect(req.headers["x-forwarded-port"]).toBe("8080"); expect(req.headers["x-forwarded-proto"]).toBe("http"); }); it("should not overwrite existing x-forwarded-* headers", () => { const stubRequest = stubIncomingMessage({ connection: { remoteAddress: "192.168.1.2", remotePort: "8080", }, headers: { host: "192.168.1.2:8080", "x-forwarded-host": "192.168.1.3:8081", "x-forwarded-for": "192.168.1.3", "x-forwarded-port": "8081", "x-forwarded-proto": "https", }, }); webPasses.XHeaders( stubRequest, stubServerResponse(), stubMiddlewareOptions({ xfwd: true }), stubProxyServer(), ); expect(stubRequest.headers["x-forwarded-for"]).toBe("192.168.1.3"); expect(stubRequest.headers["x-forwarded-port"]).toBe("8081"); expect(stubRequest.headers["x-forwarded-proto"]).toBe("https"); expect(stubRequest.headers["x-forwarded-host"]).toBe("192.168.1.3:8081"); }); }); }); describe("#stream middleware direct tests", () => { it("should emit error on server when callback is not provided", async () => { const { resolve, promise } = Promise.withResolvers(); const EventEmitter = (await import("node:events")).EventEmitter; const server = Object.assign(new EventEmitter(), { _webPasses: [], _wsPasses: [], }) as any; server.on("error", (err: Error) => { expect(err).toBeInstanceOf(Error); resolve(); }); // Call stream directly without callback (6th arg) const stubReq = Object.assign(new (await import("node:stream")).PassThrough(), { method: "GET", url: "/", headers: { host: "127.0.0.1" }, connection: { remoteAddress: "127.0.0.1" }, socket: { remoteAddress: "127.0.0.1", destroyed: false }, }); const stubRes = Object.assign(new (await import("node:stream")).PassThrough(), { headersSent: false, finished: false, setHeader: () => {}, writeHead: () => {}, statusCode: 200, }); webPasses.stream( stubReq as any, stubRes as any, { target: new URL(`http://127.0.0.1:54322`), forward: undefined as any } as any, server as any, undefined, // No callback - this will trigger line 131 undefined, ); await promise; }); it("should emit end event when res.finished is true", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer((_req, res) => { res.end("done"); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, selfHandleResponse: true, }); const proxyServer = http.createServer((req, res) => { proxy.once("proxyRes", (_proxyRes, _pReq, pRes) => { // End the response before proxyRes piping would happen pRes.end("early-end"); }); proxy.once("end", () => { source.close(); proxyServer.close(); resolve(); }); proxy.web(req, res); }); const proxyPort = await listenOn(proxyServer); http.request(`http://127.0.0.1:${proxyPort}/`, () => {}).end(); await promise; }); }); describe("#stream POST body piping", () => { it("should deliver the full POST body to the target server", async () => { const { resolve, promise } = Promise.withResolvers(); const postBody = "x".repeat(8192); // large enough to test chunked piping const source = http.createServer((req, res) => { let body = ""; req.on("data", (chunk: Buffer) => (body += chunk)); req.on("end", () => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end(body); }); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, }); const proxyServer = http.createServer((req, res) => { proxy.web(req, res); }); const proxyPort = await listenOn(proxyServer); http .request(`http://127.0.0.1:${proxyPort}`, { method: "POST" }, function (res) { let body = ""; res.on("data", (chunk: Buffer) => (body += chunk)); res.on("end", () => { source.close(); proxyServer.close(); expect(body).to.eql(postBody); resolve(); }); }) .end(postBody); await promise; }); }); describe("#createProxyServer.web() using own http server", () => { it("should proxy the request using the web proxy handler", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { source.close(); proxyServer.close(); expect(req.method).to.eql("GET"); expect(Number.parseInt(req.headers.host!.split(":")[1])).to.eql(proxyPort); resolve(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, }); function requestHandler(req: any, res: any) { proxy.web(req, res); } const proxyServer = http.createServer(requestHandler); const proxyPort = await listenOn(proxyServer); http.request(`http://127.0.0.1:${proxyPort}`, () => {}).end(); await promise; }); it("should detect a proxyReq event and modify headers", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { source.close(); proxyServer.close(); expect(req.headers["x-special-proxy-header"]).to.eql("foobar"); resolve(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, }); proxy.on("proxyReq", function (proxyReq, req, res, options) { proxyReq.setHeader("X-Special-Proxy-Header", "foobar"); }); function requestHandler(req: any, res: any) { proxy.web(req, res); } const proxyServer = http.createServer(requestHandler); const proxyPort = await listenOn(proxyServer); http.request(`http://127.0.0.1:${proxyPort}`, () => {}).end(); await promise; }); it('should skip proxyReq event when handling a request with header "expect: 100-continue" [https://www.npmjs.com/advisories/1486]', async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { source.close(); proxyServer.close(); expect(req.headers["x-special-proxy-header"]).to.not.eql("foobar"); resolve(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, }); proxy.on("proxyReq", function (proxyReq, req, res, options) { proxyReq.setHeader("X-Special-Proxy-Header", "foobar"); }); function requestHandler(req: any, res: any) { proxy.web(req, res); } const proxyServer = http.createServer(requestHandler); const proxyPort = await listenOn(proxyServer); const postData = "".padStart(1025, "x"); const postOptions = { hostname: "127.0.0.1", port: proxyPort, path: "/", method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(postData), expect: "100-continue", }, }; const req = http.request(postOptions, () => {}); req.write(postData); req.end(); await promise; }); it("should proxy the request and handle error via callback", async () => { const { resolve, promise } = Promise.withResolvers(); const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:1", }); const proxyServer = http.createServer(requestHandler); async function requestHandler(req: any, res: any) { const proxyRes = await proxy.web(req, res).catch((error_) => error_); proxyServer.close(); resolve(); expect(proxyRes).toBeInstanceOf(Error); expect((proxyRes as any).code).toBe("ECONNREFUSED"); } const proxyPort = await listenOn(proxyServer); http .request( { hostname: "127.0.0.1", port: proxyPort, method: "GET", }, () => {}, ) .end(); await promise; }); it("should proxy the request and handle error via event listener", async () => { const { resolve, promise } = Promise.withResolvers(); const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:54320", }); const proxyServer = http.createServer(requestHandler); function requestHandler(req: any, res: any) { proxy.once("error", function (err, errReq, errRes) { proxyServer.close(); expect(err).toBeInstanceOf(Error); expect(errReq).toBe(req); expect(errRes).toBe(res); expect((err as any).code).toBe("ECONNREFUSED"); res.end(); resolve(); }); proxy.web(req, res); } const proxyPort = await listenOn(proxyServer); http.request({ hostname: "127.0.0.1", port: proxyPort, method: "GET" }, () => {}).end(); await promise; }); it("should forward the request and handle error via event listener", async () => { const { resolve, promise } = Promise.withResolvers(); const proxy = httpProxy.createProxyServer({ forward: "http://127.0.0.1:54321", }); const proxyServer = http.createServer(requestHandler); function requestHandler(req: any, res: any) { proxy.once("error", function (err, errReq, errRes) { proxyServer.close(); expect(err).toBeInstanceOf(Error); expect((err as any).code).toBe("ECONNREFUSED"); res.end(); resolve(); }); proxy.web(req, res); } const proxyPort = await listenOn(proxyServer); http.request({ hostname: "127.0.0.1", port: proxyPort, method: "GET" }, () => {}).end(); await promise; }); it("should proxy the request and handle timeout error (proxyTimeout)", async () => { const { resolve, promise } = Promise.withResolvers(); const net = await import("node:net"); // Create a TCP server that accepts but never responds const blackhole = net.createServer((_socket) => {}); await new Promise((r) => blackhole.listen(0, "127.0.0.1", r)); const blackholePort = (blackhole.address() as any).port; const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${blackholePort}`, proxyTimeout: 100, }); const proxyServer = http.createServer(requestHandler); const started = Date.now(); function requestHandler(req: any, res: any) { proxy.once("error", function (err, errReq, errRes) { proxyServer.close(); blackhole.close(); expect(err).toBeInstanceOf(Error); expect(errReq).toBe(req); expect(errRes).toBe(res); expect(Date.now() - started).toBeGreaterThan(99); expect((err as any).code).toBe("ECONNRESET"); res.end(); resolve(); }); proxy.web(req, res); } const proxyPort = await listenOn(proxyServer); http.request({ hostname: "127.0.0.1", port: proxyPort, method: "GET" }, () => {}).end(); await promise; }); // Note: req.on("aborted") no longer fires reliably on Node.js v18+ it.todo("should proxy the request and handle timeout error"); it("should proxy the request and provide a proxyRes event with the request and response parameters", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { res.end("Response"); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, }); function requestHandler(req: any, res: any) { proxy.once("proxyRes", function (proxyRes, pReq, pRes) { source.close(); proxyServer.close(); expect(pReq).toBe(req); expect(pRes).toBe(res); resolve(); }); proxy.web(req, res); } const proxyServer = http.createServer(requestHandler); const proxyPort = await listenOn(proxyServer); http.request(`http://127.0.0.1:${proxyPort}`, () => {}).end(); await promise; }); it("should proxy the request and provide and respond to manual user response when using modifyResponse", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { res.end("Response"); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, selfHandleResponse: true, }); function requestHandler(req: any, res: any) { proxy.once("proxyRes", function (proxyRes, pReq, pRes) { proxyRes.pipe( concat(function (body) { expect(body.toString("utf8")).eql("Response"); pRes.end(Buffer.from("my-custom-response")); }), ); }); proxy.web(req, res); } const proxyServer = http.createServer(requestHandler); const proxyPort = await listenOn(proxyServer); http .get(`http://127.0.0.1:${proxyPort}`, function (res) { res.pipe( concat(function (body) { expect(body.toString("utf8")).eql("my-custom-response"); source.close(); proxyServer.close(); resolve(); }), ); }) .once("error", resolve); await promise; }); it("should proxy the request and handle changeOrigin option", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { source.close(); proxyServer.close(); expect(req.method).to.eql("GET"); expect(Number.parseInt(req.headers.host!.split(":")[1])).to.eql(sourcePort); resolve(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, changeOrigin: true, }); function requestHandler(req: any, res: any) { proxy.web(req, res); } const proxyServer = http.createServer(requestHandler); const proxyPort = await listenOn(proxyServer); http.request(`http://127.0.0.1:${proxyPort}`, () => {}).end(); await promise; }); it("should proxy the request with the Authorization header set", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { source.close(); proxyServer.close(); const auth = Buffer.from(req.headers.authorization.split(" ")[1], "base64"); expect(req.method).to.eql("GET"); expect(auth.toString()).to.eql("user:pass"); resolve(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, auth: "user:pass", }); function requestHandler(req: any, res: any) { proxy.web(req, res); } const proxyServer = http.createServer(requestHandler); const proxyPort = await listenOn(proxyServer); http.request(`http://127.0.0.1:${proxyPort}`, () => {}).end(); await promise; }); it("should proxy requests to multiple servers with different options", async () => { const { resolve, promise } = Promise.withResolvers(); const source1 = http.createServer(function (req: any, res: any) { expect(req.method).to.eql("GET"); expect(Number.parseInt(req.headers.host!.split(":")[1])).to.eql(proxyPort); expect(req.url).to.eql("/test1"); }); const source2 = http.createServer(function (req: any, res: any) { source1.close(); source2.close(); proxyServer.close(); expect(req.method).to.eql("GET"); expect(Number.parseInt(req.headers.host!.split(":")[1])).to.eql(proxyPort); expect(req.url).to.eql("/test2"); resolve(); }); const [source1Port, source2Port] = await Promise.all([listenOn(source1), listenOn(source2)]); const proxy = httpProxy.createProxyServer(); // proxies to two servers depending on url, rewriting the url as well function requestHandler(req: any, res: any) { if (req.url.indexOf("/s1/") === 0) { proxy.web(req, res, { ignorePath: true, target: `http://127.0.0.1:${source1Port}` + req.url.slice(3), }); } else { proxy.web(req, res, { target: `http://127.0.0.1:${source2Port}`, }); } } const proxyServer = http.createServer(requestHandler); const proxyPort = await listenOn(proxyServer); http.request(`http://127.0.0.1:${proxyPort}/s1/test1`, () => {}).end(); http.request(`http://127.0.0.1:${proxyPort}/test2`, () => {}).end(); await promise; }); }); describe("#client abort propagation", () => { it("should abort proxy request when client disconnects", async () => { const { resolve, promise } = Promise.withResolvers(); // Target server that waits long enough for client to abort const source = http.createServer((req, _res) => { req.on("close", () => { source.close(); proxyServer.close(); resolve(); }); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, }); const proxyServer = http.createServer((req, res) => { proxy.web(req, res); }); const proxyPort = await listenOn(proxyServer); // Make a request and abort it immediately const req = http.request(`http://127.0.0.1:${proxyPort}`, () => {}); req.on("error", () => {}); // Ignore client-side errors from destroy req.end(); req.on("socket", () => { // Destroy after connection is established setTimeout(() => req.destroy(), 50); }); await promise; }); }); // Regression: upstream http-party/node-http-proxy#1634 // When req.socket is undefined, the error handler in stream middleware // would throw TypeError: Cannot read properties of undefined (reading 'destroyed') describe("#req.socket undefined", () => { it("should not crash when req.socket is undefined and error occurs", async () => { const { resolve, promise } = Promise.withResolvers(); // Use a port that refuses connections to trigger the error handler const proxy = httpProxy.createProxyServer({ target: "http://127.0.0.1:1", }); const proxyServer = http.createServer((req, res) => { // Set req.socket to undefined after proxyReq is emitted but before // the error handler fires (simulates HTTP/2 or edge-case teardown) proxy.once("proxyReq", () => { Object.defineProperty(req, "socket", { value: undefined, writable: true, configurable: true, }); }); proxy.web(req, res); }); const proxyPort = await listenOn(proxyServer); proxy.once("error", () => { // Should reach here without TypeError crash proxyServer.close(); resolve(); }); const req = http.request(`http://127.0.0.1:${proxyPort}`, () => {}); req.on("error", () => {}); // Ignore client-side errors req.end(); await promise; }); }); describe("#followRedirects", () => { it("should follow 301 redirect", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { if (new URL(req.url, "http://localhost").pathname === "/redirect") { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("ok"); return; } res.writeHead(301, { Location: "/redirect" }); res.end(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, followRedirects: true, }); const proxyServer = http.createServer((req, res) => proxy.web(req, res)); const proxyPort = await listenOn(proxyServer); http .request(`http://127.0.0.1:${proxyPort}`, function (res) { source.close(); proxyServer.close(); expect(res.statusCode).to.eql(200); resolve(); }) .end(); await promise; }); it("should follow 302 and change method to GET", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { if (new URL(req.url, "http://localhost").pathname === "/dest") { res.writeHead(200, { "Content-Type": "text/plain" }); res.end(req.method); return; } res.writeHead(302, { Location: "/dest" }); res.end(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, followRedirects: true, }); const proxyServer = http.createServer((req, res) => proxy.web(req, res)); const proxyPort = await listenOn(proxyServer); http .request(`http://127.0.0.1:${proxyPort}`, { method: "POST" }, function (res) { let body = ""; res.on("data", (chunk: Buffer) => (body += chunk)); res.on("end", () => { source.close(); proxyServer.close(); expect(res.statusCode).to.eql(200); expect(body).to.eql("GET"); resolve(); }); }) .end("post body"); await promise; }); it("should follow 307 preserving method and body", async () => { const { resolve, promise } = Promise.withResolvers(); const postBody = "test-body-content"; const source = http.createServer(function (req: any, res: any) { if (new URL(req.url, "http://localhost").pathname === "/dest") { let body = ""; req.on("data", (chunk: Buffer) => (body += chunk)); req.on("end", () => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end(`${req.method}:${body}`); }); return; } res.writeHead(307, { Location: "/dest" }); res.end(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, followRedirects: true, }); const proxyServer = http.createServer((req, res) => proxy.web(req, res)); const proxyPort = await listenOn(proxyServer); const proxyReq = http.request( `http://127.0.0.1:${proxyPort}`, { method: "POST" }, function (res) { let body = ""; res.on("data", (chunk: Buffer) => (body += chunk)); res.on("end", () => { source.close(); proxyServer.close(); expect(res.statusCode).to.eql(200); expect(body).to.eql(`POST:${postBody}`); resolve(); }); }, ); proxyReq.end(postBody); await promise; }); it("should respect numeric max redirects limit", async () => { const { resolve, promise } = Promise.withResolvers(); let redirectCount = 0; const source = http.createServer(function (_req: any, res: any) { redirectCount++; res.writeHead(301, { Location: `/hop-${redirectCount}` }); res.end(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, followRedirects: 2, }); const proxyServer = http.createServer((req, res) => proxy.web(req, res)); const proxyPort = await listenOn(proxyServer); http .request(`http://127.0.0.1:${proxyPort}`, function (res) { source.close(); proxyServer.close(); // After 2 redirects followed, the 3rd 301 is returned to client expect(res.statusCode).to.eql(301); expect(redirectCount).to.eql(3); resolve(); }) .end(); await promise; }); it("should handle relative Location headers", async () => { const { resolve, promise } = Promise.withResolvers(); const source = http.createServer(function (req: any, res: any) { if (new URL(req.url, "http://localhost").pathname === "/sub/dest") { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("ok"); return; } res.writeHead(302, { Location: "/sub/dest" }); res.end(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, followRedirects: true, }); const proxyServer = http.createServer((req, res) => proxy.web(req, res)); const proxyPort = await listenOn(proxyServer); http .request(`http://127.0.0.1:${proxyPort}/sub/start`, function (res) { source.close(); proxyServer.close(); expect(res.statusCode).to.eql(200); resolve(); }) .end(); await promise; }); it("should emit proxyRes only for the final response", async () => { const { resolve, promise } = Promise.withResolvers(); let proxyResCount = 0; const source = http.createServer(function (req: any, res: any) { if (new URL(req.url, "http://localhost").pathname === "/final") { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("done"); return; } res.writeHead(302, { Location: "/final" }); res.end(); }); const sourcePort = await listenOn(source); const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, followRedirects: true, }); proxy.on("proxyRes", () => proxyResCount++); const proxyServer = http.createServer((req, res) => proxy.web(req, res)); const proxyPort = await listenOn(proxyServer); http .request(`http://127.0.0.1:${proxyPort}`, function (res) { let body = ""; res.on("data", (chunk: Buffer) => (body += chunk)); res.on("end", () => { source.close(); proxyServer.close(); expect(res.statusCode).to.eql(200); expect(body).to.eql("done"); expect(proxyResCount).to.eql(1); resolve(); }); }) .end(); await promise; }); }); // Regression: upstream http-party/node-http-proxy#1559 // req.on('aborted') stopped firing on Node 15.5+ and was later removed, // causing a memory leak from accumulated listeners that never get cleaned up. describe("#req-aborted-memory-leak", () => { it("should not attach deprecated 'aborted' listener on req", async () => { const { promise, resolve } = Promise.withResolvers(); const source = http.createServer((_req, res) => { res.writeHead(200); res.end("ok"); }); const sourcePort = await listenOn(source); const proxyServer = httpProxy.createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, }); // Intercept the incoming request to inspect its listeners const server = http.createServer((req, res) => { const abortedBefore = req.listenerCount("aborted"); proxyServer.web(req, res).then(() => { const abortedAfter = req.listenerCount("aborted"); const addedAbortedListeners = abortedAfter - abortedBefore; expect(addedAbortedListeners).to.eql(0); source.close(); server.close(); proxyServer.close(); resolve(); }); }); const port = await listenOn(server); http.get(`http://127.0.0.1:${port}/test`); 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; }); }); const sourcePort = await listenOn(source); 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(); setTimeout(() => { expect(upstreamAborted).to.eql(true); source.close(); proxy.close(resolve); }, 100); }); }); await promise; }); }); ================================================ FILE: test/middleware/web-outgoing.test.ts ================================================ import { describe, it, expect, beforeEach } from "vitest"; import * as webOutgoing from "../../src/middleware/web-outgoing.ts"; import { stubIncomingMessage, stubServerResponse, stubMiddlewareOptions } from "../_stubs.ts"; // Source: https://github.com/http-party/node-http-proxy/blob/master/test/lib-http-proxy-passes-web-outgoing-test.js describe("middleware:web-outgoing", () => { const ctx: any = {}; describe("#setRedirectHostRewrite", () => { beforeEach(() => { ctx.req = { headers: { host: "ext-auto.com", }, }; ctx.proxyRes = { statusCode: 301, headers: { location: "http://backend.com/", }, }; ctx.options = { target: "http://backend.com", }; }); describe("rewrites location host with hostRewrite", () => { beforeEach(() => { ctx.options.hostRewrite = "ext-manual.com"; }); for (const code of [201, 301, 302, 303, 307, 308]) { it("on " + code, () => { ctx.proxyRes.statusCode = code; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://ext-manual.com/"); }); } it("not on 200", () => { ctx.proxyRes.statusCode = 200; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://backend.com/"); }); it("not when hostRewrite is unset", () => { delete ctx.options.hostRewrite; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://backend.com/"); }); it("takes precedence over autoRewrite", () => { ctx.options.autoRewrite = true; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://ext-manual.com/"); }); it("not when the redirected location does not match target host", () => { ctx.proxyRes.statusCode = 302; ctx.proxyRes.headers.location = "http://some-other/"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://some-other/"); }); it("not when the redirected location does not match target port", () => { ctx.proxyRes.statusCode = 302; ctx.proxyRes.headers.location = "http://backend.com:8080/"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://backend.com:8080/"); }); it("handles relative Location URLs (issue #20)", () => { ctx.proxyRes.statusCode = 201; ctx.proxyRes.headers.location = "/api/books/1"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://ext-manual.com/api/books/1"); }); it("handles relative Location URLs with path", () => { ctx.proxyRes.statusCode = 301; ctx.proxyRes.headers.location = "/redirect/here?foo=bar"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://ext-manual.com/redirect/here?foo=bar"); }); }); describe("rewrites location host with autoRewrite", () => { beforeEach(() => { ctx.options.autoRewrite = true; delete ctx.req.headers[":authority"]; }); for (const code of [201, 301, 302, 303, 307, 308]) { it("on " + code, () => { ctx.proxyRes.statusCode = code; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://ext-auto.com/"); }); } it("with HTTP/2 :authority", () => { ctx.req.headers[":authority"] = "ext-auto.com"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://ext-auto.com/"); }); it("not on 200", () => { ctx.proxyRes.statusCode = 200; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://backend.com/"); }); it("not when autoRewrite is unset", () => { delete ctx.options.autoRewrite; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://backend.com/"); }); it("not when the redirected location does not match target host", () => { ctx.proxyRes.statusCode = 302; ctx.proxyRes.headers.location = "http://some-other/"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://some-other/"); }); it("not when the redirected location does not match target port", () => { ctx.proxyRes.statusCode = 302; ctx.proxyRes.headers.location = "http://backend.com:8080/"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://backend.com:8080/"); }); }); describe("handles object target (ProxyTargetDetailed)", () => { it("rewrites location when target is an object with hostRewrite", () => { ctx.options.target = { protocol: "http:", host: "backend.com", hostname: "backend.com", }; ctx.options.hostRewrite = "ext-manual.com"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://ext-manual.com/"); }); it("rewrites location when target is a URL instance", () => { ctx.options.target = new URL("http://backend.com"); ctx.options.hostRewrite = "ext-manual.com"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://ext-manual.com/"); }); }); describe("rewrites location protocol with protocolRewrite", () => { beforeEach(() => { ctx.options.protocolRewrite = "https"; }); for (const code of [201, 301, 302, 303, 307, 308]) { it("on " + code, () => { ctx.proxyRes.statusCode = code; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("https://backend.com/"); }); } it("not on 200", () => { ctx.proxyRes.statusCode = 200; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://backend.com/"); }); it("not when protocolRewrite is unset", () => { delete ctx.options.protocolRewrite; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("http://backend.com/"); }); it("works together with hostRewrite", () => { ctx.options.hostRewrite = "ext-manual.com"; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("https://ext-manual.com/"); }); it("works together with autoRewrite", () => { ctx.options.autoRewrite = true; webOutgoing.setRedirectHostRewrite( ctx.req, stubServerResponse(), ctx.proxyRes, ctx.options, ); expect(ctx.proxyRes.headers.location).to.eql("https://ext-auto.com/"); }); }); }); describe("#setConnection", () => { it("set the right connection with 1.0 - `close`", () => { const proxyRes = stubIncomingMessage({ headers: {} }); webOutgoing.setConnection( stubIncomingMessage({ httpVersion: "1.0", headers: { connection: undefined }, }), stubServerResponse(), proxyRes, stubMiddlewareOptions(), ); expect(proxyRes.headers.connection).to.eql("close"); }); it("set the right connection with 1.0 - req.connection", () => { const proxyRes = stubIncomingMessage({ headers: {} }); webOutgoing.setConnection( stubIncomingMessage({ httpVersion: "1.0", headers: { connection: "hey" }, }), stubServerResponse(), proxyRes, stubMiddlewareOptions(), ); expect(proxyRes.headers.connection).to.eql("hey"); }); it("set the right connection - req.connection", () => { const proxyRes = stubIncomingMessage({ headers: {} }); webOutgoing.setConnection( stubIncomingMessage({ httpVersion: undefined, headers: { connection: "hola" }, }), stubServerResponse(), proxyRes, stubMiddlewareOptions(), ); expect(proxyRes.headers.connection).to.eql("hola"); }); it("set the right connection (HTTP/1.1) - req.connection", () => { const proxyRes = { headers: {} as any }; webOutgoing.setConnection( { httpVersion: "1.0", httpVersionMajor: 1, headers: { connection: "hola", }, } as any, {} as any, proxyRes as any, {} as any, ); expect(proxyRes.headers.connection).to.eql("hola"); }); it("set the right connection (HTTP/2) - req.connection", () => { const proxyRes = { headers: {} as any }; webOutgoing.setConnection( { httpVersion: "2.0", httpVersionMajor: 2, headers: { connection: "hola", }, } as any, {} as any, proxyRes as any, {} as any, ); expect(proxyRes.headers.connection).to.eql(undefined); }); it("set the right connection - `keep-alive`", () => { const proxyRes = stubIncomingMessage({ headers: {} }); webOutgoing.setConnection( stubIncomingMessage({ httpVersion: undefined, headers: { connection: undefined }, }), stubServerResponse(), proxyRes, stubMiddlewareOptions(), ); expect(proxyRes.headers.connection).to.eql("keep-alive"); }); it("don`t set connection with 2.0 if exist", () => { const proxyRes = stubIncomingMessage({ headers: {} }); webOutgoing.setConnection( stubIncomingMessage({ httpVersion: "2.0", httpVersionMajor: 2, headers: { connection: "namstey" }, }), stubServerResponse(), proxyRes, stubMiddlewareOptions(), ); expect(proxyRes.headers.connection).to.eql(undefined); }); it("don`t set connection with 2.0 if doesn`t exist", () => { const proxyRes = stubIncomingMessage({ headers: {} }); webOutgoing.setConnection( stubIncomingMessage({ httpVersion: "2.0", httpVersionMajor: 2, headers: {}, }), stubServerResponse(), proxyRes, stubMiddlewareOptions(), ); expect(proxyRes.headers.connection as any).to.eql(undefined); }); }); describe("#writeStatusCode", () => { it("should write status code", () => { const res = stubServerResponse({ writeHead: function (n: number) { expect(n).to.eql(200); }, }); webOutgoing.writeStatusCode( stubIncomingMessage(), res, stubIncomingMessage({ statusCode: 200 }), stubMiddlewareOptions(), ); }); it("should write status code with statusMessage", () => { const res = stubServerResponse(); webOutgoing.writeStatusCode( stubIncomingMessage(), res, stubIncomingMessage({ statusCode: 404, statusMessage: "Not Found" }), stubMiddlewareOptions(), ); expect(res.statusCode).to.eql(404); expect(res.statusMessage).to.eql("Not Found"); }); it("should write status code without statusMessage", () => { const res = stubServerResponse(); webOutgoing.writeStatusCode( stubIncomingMessage(), res, stubIncomingMessage({ statusCode: 200 }), stubMiddlewareOptions(), ); expect(res.statusCode).to.eql(200); expect(res.statusMessage).to.eql(undefined); }); }); describe("#writeHeaders", () => { beforeEach(() => { ctx.proxyRes = { headers: { hey: "hello", how: "are you?", "set-cookie": ["hello; domain=my.domain; path=/", "there; domain=my.domain; path=/"], }, }; ctx.rawProxyRes = { headers: { hey: "hello", how: "are you?", "set-cookie": ["hello; domain=my.domain; path=/", "there; domain=my.domain; path=/"], }, rawHeaders: [ "Hey", "hello", "How", "are you?", "Set-Cookie", "hello; domain=my.domain; path=/", "Set-Cookie", "there; domain=my.domain; path=/", ], }; ctx.res = { setHeader: function (k: string, v: string) { // https://nodejs.org/api/http.html#http_message_headers // Header names are lower-cased ctx.res.headers[k.toLowerCase()] = v; }, headers: {} as Record, }; }); it("writes headers", () => { const options = {}; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.proxyRes, options as any); expect(ctx.res.headers.hey).to.eql("hello"); expect(ctx.res.headers.how).to.eql("are you?"); expect(ctx.res.headers["set-cookie"]).toBeInstanceOf(Array); expect(ctx.res.headers["set-cookie"]).to.have.length(2); }); it("writes raw headers", () => { const options = {}; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.rawProxyRes, options as any); expect(ctx.res.headers.hey).to.eql("hello"); expect(ctx.res.headers.how).to.eql("are you?"); expect(ctx.res.headers["set-cookie"]).toBeInstanceOf(Array); expect(ctx.res.headers["set-cookie"]).to.have.length(2); }); it("rewrites path", () => { const options = { cookiePathRewrite: "/dummyPath", }; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.proxyRes, options as any); expect(ctx.res.headers["set-cookie"]).to.contain("hello; domain=my.domain; path=/dummyPath"); }); it("does not rewrite path", () => { const options = {}; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.proxyRes, options as any); expect(ctx.res.headers["set-cookie"]).to.contain("hello; domain=my.domain; path=/"); }); it("removes path", () => { const options = { cookiePathRewrite: "", }; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.proxyRes, options as any); expect(ctx.res.headers["set-cookie"]).to.contain("hello; domain=my.domain"); }); it("does not rewrite domain", () => { const options = {}; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.proxyRes, options as any); expect(ctx.res.headers["set-cookie"]).to.contain("hello; domain=my.domain; path=/"); }); it("rewrites domain", () => { const options = { cookieDomainRewrite: "my.new.domain", }; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.proxyRes, options as any); expect(ctx.res.headers["set-cookie"]).to.contain("hello; domain=my.new.domain; path=/"); }); it("removes domain", () => { const options = { cookieDomainRewrite: "", }; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.proxyRes, options as any); expect(ctx.res.headers["set-cookie"]).to.contain("hello; path=/"); }); it("rewrites headers with advanced configuration", () => { const options = { cookieDomainRewrite: { "*": "", "my.old.domain": "my.new.domain", "my.special.domain": "my.special.domain", }, }; ctx.proxyRes.headers["set-cookie"] = [ "hello-on-my.domain; domain=my.domain; path=/", "hello-on-my.old.domain; domain=my.old.domain; path=/", "hello-on-my.special.domain; domain=my.special.domain; path=/", ]; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.proxyRes, options as any); expect(ctx.res.headers["set-cookie"]).to.contain("hello-on-my.domain; path=/"); expect(ctx.res.headers["set-cookie"]).to.contain( "hello-on-my.old.domain; domain=my.new.domain; path=/", ); expect(ctx.res.headers["set-cookie"]).to.contain( "hello-on-my.special.domain; domain=my.special.domain; path=/", ); }); it("rewrites raw headers with advanced configuration", () => { const options = { cookieDomainRewrite: { "*": "", "my.old.domain": "my.new.domain", "my.special.domain": "my.special.domain", }, }; ctx.rawProxyRes.headers["set-cookie"] = [ "hello-on-my.domain; domain=my.domain; path=/", "hello-on-my.old.domain; domain=my.old.domain; path=/", "hello-on-my.special.domain; domain=my.special.domain; path=/", ]; ctx.rawProxyRes.rawHeaders = [ ...ctx.rawProxyRes.rawHeaders, "Set-Cookie", "hello-on-my.domain; domain=my.domain; path=/", "Set-Cookie", "hello-on-my.old.domain; domain=my.old.domain; path=/", "Set-Cookie", "hello-on-my.special.domain; domain=my.special.domain; path=/", ]; webOutgoing.writeHeaders(stubIncomingMessage(), ctx.res, ctx.rawProxyRes, options as any); expect(ctx.res.headers["set-cookie"]).to.include("hello-on-my.domain; path=/"); expect(ctx.res.headers["set-cookie"]).to.contain( "hello-on-my.old.domain; domain=my.new.domain; path=/", ); expect(ctx.res.headers["set-cookie"]).to.contain( "hello-on-my.special.domain; domain=my.special.domain; path=/", ); }); it("skips invalid headers instead of crashing", () => { const options = {}; const proxyRes = { headers: { hey: "hello", how: "are you?", "invalid-header": "value\u0001with\u0002invalid\u0003chars", "set-cookie": ["hello; domain=my.domain; path=/", "there; domain=my.domain; path=/"], }, }; // Use a real-ish setHeader that validates like Node.js does const headers: Record = {}; const res = { setHeader(k: string, v: string | string[]) { // Simulate Node.js ERR_INVALID_CHAR for control characters // eslint-disable-next-line no-control-regex) if (typeof v === "string" && /[\u0000-\u001F]/.test(v)) { throw new TypeError(`Invalid character in header content ["${k}"]`); } headers[k.toLowerCase()] = v; }, }; webOutgoing.writeHeaders(stubIncomingMessage(), res as any, proxyRes as any, options as any); // Valid headers should still be set expect(headers.hey).to.eql("hello"); expect(headers.how).to.eql("are you?"); expect(headers["set-cookie"]).toBeInstanceOf(Array); expect(headers["set-cookie"]).to.have.length(2); // Invalid header should be skipped (not set) expect(headers).to.not.have.key("invalid-header"); }); it("skips empty header names (upstream#1551)", () => { const proxyRes = { headers: { "": "empty-key-value", " ": "whitespace-key-value", hey: "hello", }, }; const headers: Record = {}; const res = { setHeader(k: string, v: string | string[]) { // Simulate Node.js ERR_INVALID_HTTP_TOKEN for empty/invalid header names if (!k || !/^[\w!#$%&'*+\-.^`|~]+$/.test(k)) { throw new TypeError(`Header name must be a valid HTTP token ["${k}"]`); } headers[k.toLowerCase()] = v; }, }; webOutgoing.writeHeaders( stubIncomingMessage(), res as any, proxyRes as any, stubMiddlewareOptions(), ); // Valid headers should still be set expect(headers.hey).to.eql("hello"); // Empty/whitespace-only header names should be skipped expect(headers).to.not.have.key(""); expect(headers).to.not.have.key(" "); }); it("skips undefined header values", () => { const proxyRes = { headers: { hey: "hello", undef: undefined, }, }; const headers: any = {}; const res = { setHeader: function (k: string, v: string) { headers[k.toLowerCase()] = v; }, }; webOutgoing.writeHeaders( stubIncomingMessage(), res as any, proxyRes as any, stubMiddlewareOptions(), ); expect(headers.hey).to.eql("hello"); expect(headers).to.not.have.key("undef"); }); }); describe("#removeChunked", () => { it("removes transfer-encoding on HTTP/1.0", () => { const proxyRes = { headers: { "transfer-encoding": "hello", }, }; webOutgoing.removeChunked( stubIncomingMessage({ httpVersion: "1.0" }), stubServerResponse(), proxyRes as any, stubMiddlewareOptions(), ); expect(proxyRes.headers["transfer-encoding"]).to.eql(undefined); }); it("removes transfer-encoding on 204 response", () => { const proxyRes = { statusCode: 204, headers: { "transfer-encoding": "chunked", }, }; webOutgoing.removeChunked( stubIncomingMessage({ httpVersion: "1.1" }), stubServerResponse(), proxyRes as any, stubMiddlewareOptions(), ); expect(proxyRes.headers["transfer-encoding"]).to.eql(undefined); }); it("removes transfer-encoding on 304 response", () => { const proxyRes = { statusCode: 304, headers: { "transfer-encoding": "chunked", }, }; webOutgoing.removeChunked( stubIncomingMessage({ httpVersion: "1.1" }), stubServerResponse(), proxyRes as any, stubMiddlewareOptions(), ); expect(proxyRes.headers["transfer-encoding"]).to.eql(undefined); }); it("preserves transfer-encoding on normal HTTP/1.1 responses", () => { const proxyRes = { statusCode: 200, headers: { "transfer-encoding": "chunked", }, }; webOutgoing.removeChunked( stubIncomingMessage({ httpVersion: "1.1" }), stubServerResponse(), proxyRes as any, stubMiddlewareOptions(), ); expect(proxyRes.headers["transfer-encoding"]).to.eql("chunked"); }); }); }); ================================================ FILE: test/middleware/ws-incoming.test.ts ================================================ import { describe, it, expect } from "vitest"; import * as wsIncoming from "../../src/middleware/ws-incoming.ts"; import { stubIncomingMessage, stubSocket, stubMiddlewareOptions, stubProxyServer, } from "../_stubs.ts"; // Source: https://github.com/http-party/node-http-proxy/blob/master/test/lib-http-proxy-passes-ws-incoming-test.js describe("middleware:ws-incoming", () => { describe("#checkMethodAndHeader", () => { it("should drop non-GET connections", () => { let destroyCalled = false; const returnValue = wsIncoming.checkMethodAndHeader( stubIncomingMessage({ method: "DELETE", headers: {} }), stubSocket({ destroy: () => { destroyCalled = true; }, }), stubMiddlewareOptions(), stubProxyServer(), ); expect(returnValue).toBe(true); expect(destroyCalled).toBe(true); }); it("should drop connections when no upgrade header", () => { let destroyCalled = false; const returnValue = wsIncoming.checkMethodAndHeader( stubIncomingMessage({ method: "GET", headers: {} }), stubSocket({ destroy: () => { destroyCalled = true; }, }), stubMiddlewareOptions(), stubProxyServer(), ); expect(returnValue).toBe(true); expect(destroyCalled).toBe(true); }); it("should drop connections when upgrade header is different of `websocket`", () => { let destroyCalled = false; const returnValue = wsIncoming.checkMethodAndHeader( stubIncomingMessage({ method: "GET", headers: { upgrade: "anotherprotocol" }, }), stubSocket({ destroy: () => { destroyCalled = true; }, }), stubMiddlewareOptions(), stubProxyServer(), ); expect(returnValue).toBe(true); expect(destroyCalled).toBe(true); }); it("should return nothing when all is ok", () => { let destroyCalled = false; const returnValue = wsIncoming.checkMethodAndHeader( stubIncomingMessage({ method: "GET", headers: { upgrade: "websocket" }, }), stubSocket({ destroy: () => { destroyCalled = true; }, }), stubMiddlewareOptions(), stubProxyServer(), ); expect(returnValue).toBe(undefined); expect(destroyCalled).toBe(false); }); }); describe("#XHeaders", () => { it("return if no forward request", () => { const returnValue = wsIncoming.XHeaders( stubIncomingMessage(), stubSocket(), stubMiddlewareOptions(), stubProxyServer(), ); expect(returnValue).toBe(undefined); }); it("set the correct x-forwarded-* headers from req.connection", () => { const req = stubIncomingMessage({ connection: { remoteAddress: "192.168.1.2", remotePort: "8080", }, headers: { host: "192.168.1.2:8080", }, }); wsIncoming.XHeaders( req, stubSocket(), stubMiddlewareOptions({ xfwd: true }), stubProxyServer(), ); expect(req.headers["x-forwarded-for"]).toBe("192.168.1.2"); expect(req.headers["x-forwarded-port"]).toBe("8080"); expect(req.headers["x-forwarded-proto"]).toBe("ws"); }); it("set the correct x-forwarded-* headers from req.socket", () => { const req = stubIncomingMessage({ socket: { remoteAddress: "192.168.1.3", remotePort: "8181", encrypted: true, }, connection: {}, headers: { host: "192.168.1.3:8181", }, }); wsIncoming.XHeaders( req, stubSocket(), stubMiddlewareOptions({ xfwd: true }), stubProxyServer(), ); expect(req.headers["x-forwarded-for"]).toBe("192.168.1.3"); expect(req.headers["x-forwarded-port"]).toBe("8181"); expect(req.headers["x-forwarded-proto"]).toBe("wss"); }); it("should not overwrite existing x-forwarded-* headers", () => { const req = stubIncomingMessage({ socket: { remoteAddress: "192.168.1.3", remotePort: "8181", }, connection: { pair: true, }, headers: { host: "192.168.1.3:8181", "x-forwarded-for": "192.168.1.2", "x-forwarded-port": "8182", "x-forwarded-proto": "ws", }, }); wsIncoming.XHeaders( req, stubSocket(), stubMiddlewareOptions({ xfwd: true }), stubProxyServer(), ); expect(req.headers["x-forwarded-for"]).toBe("192.168.1.2"); expect(req.headers["x-forwarded-port"]).toBe("8182"); expect(req.headers["x-forwarded-proto"]).toBe("ws"); }); }); }); ================================================ FILE: test/server.test.ts ================================================ import { describe, it, expect, afterEach, vi } from "vitest"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { createProxyServer, ProxyServer } from "../src/index.ts"; describe("ProxyServer", () => { let proxy: ProxyServer; let source: http.Server; afterEach(() => { if (proxy && (proxy as any)._server) { proxy.close(); } source?.close(); }); describe("#listen", () => { it("should create an HTTP server and listen", async () => { source = http.createServer((req, res) => { res.end("ok"); }); await new Promise((r) => source.listen(0, "127.0.0.1", r)); const sourcePort = (source.address() as AddressInfo).port; proxy = createProxyServer({ target: `http://127.0.0.1:${sourcePort}` }); const cb = vi.fn(); proxy.listen(0, "127.0.0.1", cb); // Wait for the server to be ready await new Promise((r) => setTimeout(r, 50)); expect(cb).toHaveBeenCalledOnce(); expect(cb).toHaveBeenCalledWith(); const proxyPort = ((proxy as any)._server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${proxyPort}/`); expect(res.status).toBe(200); expect(await res.text()).toBe("ok"); }); it("should create an HTTPS server when ssl option is set", async () => { const { readFileSync } = await import("node:fs"); const { join } = await import("node:path"); // Check if test certs exist let key: Buffer, cert: Buffer; try { key = readFileSync(join(import.meta.dirname, "fixtures/agent2-key.pem")); cert = readFileSync(join(import.meta.dirname, "fixtures/agent2-cert.pem")); } catch { // Skip if no test certs return; } source = http.createServer((req, res) => { res.end("ssl-ok"); }); await new Promise((r) => source.listen(0, "127.0.0.1", r)); const sourcePort = (source.address() as AddressInfo).port; proxy = createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, ssl: { key, cert }, }); proxy.listen(0, "127.0.0.1"); await new Promise((r) => setTimeout(r, 50)); const server = (proxy as any)._server; expect(server).toBeDefined(); // It should be an HTTPS server (has cert context) expect(server.constructor.name).toBe("Server"); }); it("should set up WebSocket upgrade listener when ws option is set", async () => { source = http.createServer((req, res) => { res.end("ws-ok"); }); await new Promise((r) => source.listen(0, "127.0.0.1", r)); const sourcePort = (source.address() as AddressInfo).port; proxy = createProxyServer({ target: `http://127.0.0.1:${sourcePort}`, ws: true, }); proxy.listen(0, "127.0.0.1"); await new Promise((r) => setTimeout(r, 50)); const server = (proxy as any)._server; expect(server.listeners("upgrade").length).toBeGreaterThan(0); }); }); describe("#close", () => { it("should close the server and call the callback", async () => { source = http.createServer((req, res) => { res.end("ok"); }); await new Promise((r) => source.listen(0, "127.0.0.1", r)); const sourcePort = (source.address() as AddressInfo).port; proxy = createProxyServer({ target: `http://127.0.0.1:${sourcePort}` }); proxy.listen(0, "127.0.0.1"); await new Promise((r) => setTimeout(r, 50)); const closed = await new Promise((resolve) => { proxy.close(() => resolve(true)); }); expect(closed).toBe(true); expect((proxy as any)._server).toBeUndefined(); }); it("should not throw when no server exists", () => { proxy = createProxyServer({}); // close without listen should be a no-op proxy.close(); expect((proxy as any)._server).toBeUndefined(); }); it("should close without callback", async () => { source = http.createServer((req, res) => { res.end("ok"); }); await new Promise((r) => source.listen(0, "127.0.0.1", r)); const sourcePort = (source.address() as AddressInfo).port; proxy = createProxyServer({ target: `http://127.0.0.1:${sourcePort}` }); proxy.listen(0, "127.0.0.1"); await new Promise((r) => setTimeout(r, 50)); // Should not throw proxy.close(); await new Promise((r) => setTimeout(r, 50)); }); }); describe("#before / #after", () => { it("should insert a middleware before a named pass (using empty name for arrow fns)", () => { proxy = createProxyServer({}); const initialLength = proxy._webPasses.length; // Arrow function passes have empty string names; before() finds the last match const customPass = function customMiddleware() {}; proxy.before("web", "", customPass as any); expect(proxy._webPasses.length).toBe(initialLength + 1); // Inserted before the last pass (last match of "") expect(proxy._webPasses[initialLength - 1]).toBe(customPass); }); it("should insert a middleware after a named pass", () => { proxy = createProxyServer({}); const initialLength = proxy._webPasses.length; const customPass = function customMiddleware() {}; proxy.after("web", "", customPass as any); expect(proxy._webPasses.length).toBe(initialLength + 1); }); it("should throw for invalid type in before", () => { proxy = createProxyServer({}); expect(() => { proxy.before("invalid" as any, "", (() => {}) as any); }).toThrow("type must be `web` or `ws`"); }); it("should throw for invalid type in after", () => { proxy = createProxyServer({}); expect(() => { proxy.after("invalid" as any, "", (() => {}) as any); }).toThrow("type must be `web` or `ws`"); }); it("should throw for non-existent pass name in before", () => { proxy = createProxyServer({}); expect(() => { proxy.before("web", "nonexistent", (() => {}) as any); }).toThrow("No such pass"); }); it("should throw for non-existent pass name in after", () => { proxy = createProxyServer({}); expect(() => { proxy.after("web", "nonexistent", (() => {}) as any); }).toThrow("No such pass"); }); it("should work with ws type", () => { proxy = createProxyServer({}); const initialLength = proxy._wsPasses.length; const customPass = function wsMiddleware() {}; proxy.before("ws", "", customPass as any); expect(proxy._wsPasses.length).toBe(initialLength + 1); // Inserted before the last pass (last match of "") expect(proxy._wsPasses[initialLength - 1]).toBe(customPass); }); }); describe("_createProxyFn error paths", () => { it("should emit error when no target and no forward", async () => { proxy = createProxyServer({}); const { resolve, promise } = Promise.withResolvers(); proxy.on("error", (err) => { resolve(err); }); const stubReq = { url: "/", headers: {} } as any; const stubRes = { on: () => {}, end: () => {}, } as any; proxy.web(stubReq, stubRes); const err = await promise; expect(err.message).toBe("Must provide a proper URL as target"); }); it("should convert string target to URL", async () => { source = http.createServer((req, res) => { res.end("converted"); }); await new Promise((r) => source.listen(0, "127.0.0.1", r)); const sourcePort = (source.address() as AddressInfo).port; proxy = createProxyServer({}); const proxyServer = http.createServer((req, res) => { proxy.web(req, res, { target: `http://127.0.0.1:${sourcePort}` }); }); await new Promise((r) => proxyServer.listen(0, "127.0.0.1", r)); const proxyPort = (proxyServer.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${proxyPort}/`); expect(await res.text()).toBe("converted"); proxyServer.close(); }); it("should resolve when a pass returns truthy (halt loop)", async () => { proxy = createProxyServer({ target: "http://127.0.0.1:1" }); const net = await import("node:net"); // WS checkMethodAndHeader returns true for non-GET → halts loop const stubReq = { method: "POST", headers: {}, url: "/" } as any; const socket = new net.Socket(); await proxy.ws(stubReq, socket, { target: "http://127.0.0.1:1" }); // Should resolve without error (the socket was destroyed by checkMethodAndHeader) socket.destroy(); }); it("should convert string forward to URL", async () => { source = http.createServer((req, res) => { res.end("forward-ok"); }); await new Promise((r) => source.listen(0, "127.0.0.1", r)); const sourcePort = (source.address() as AddressInfo).port; proxy = createProxyServer({}); const proxyServer = http.createServer((req, res) => { proxy.web(req, res, { forward: `http://127.0.0.1:${sourcePort}` }); }); await new Promise((r) => proxyServer.listen(0, "127.0.0.1", r)); const proxyPort = (proxyServer.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${proxyPort}/`); // Forward-only ends the response expect(res.status).toBe(200); proxyServer.close(); }); }); }); ================================================ FILE: test/types.test-d.ts ================================================ import { assertType, describe, expectTypeOf, it } from "vitest"; import { ProxyServer } from "../src/server.ts"; import type { ProxyUpgradeOptions } from "../src/ws.ts"; import type { Request as ExpressRequest, Response as ExpressResponse } from "express"; import type { IncomingMessage, ServerResponse } from "node:http"; describe("httpxy types", () => { it("ProxyServer generic types", () => { assertType(new ProxyServer()); assertType>(new ProxyServer()); assertType>( new ProxyServer(), ); const expressProxyServer = new ProxyServer(); expressProxyServer.on("start", (req, res) => { expectTypeOf(req).toEqualTypeOf(); expectTypeOf(req).toExtend(); expectTypeOf(res).toEqualTypeOf(); expectTypeOf(res).toExtend(); }); }); it("ProxyUpgradeOptions type", () => { assertType({ xfwd: true, changeOrigin: true, headers: { "x-test": "1" }, secure: true, localAddress: "127.0.0.1", auth: "user:pass", prependPath: false, ignorePath: true, toProxy: true, }); }); }); ================================================ FILE: test/ws-destroyed-socket.test.ts ================================================ import { describe, it } from "vitest"; import http from "node:http"; import net from "node:net"; import type { AddressInfo } from "node:net"; import { createProxyServer } from "../src/index.ts"; import { proxyUpgrade } from "../src/ws.ts"; /** * Regression test for writing to a destroyed socket during WS upgrade. * Upstream: https://github.com/http-party/node-http-proxy/pull/1433 * * When a WS upgrade request hits a target that responds with a plain HTTP * response (no upgrade), the proxy writes the response back to the client * socket. If the client socket is already destroyed by that point, calling * `socket.write()` throws and crashes the process. */ function listenOn(server: http.Server | net.Server): Promise { return new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { resolve((server.address() as AddressInfo).port); }); }); } describe("ws: write to destroyed socket", () => { it("ProxyServer.ws() should not crash when socket is destroyed before upstream responds", async () => { // Target server that responds with a normal HTTP 404 (no upgrade) const target = http.createServer((_req, res) => { // Delay so the client socket can be destroyed first setTimeout(() => { res.writeHead(404); res.end("Not Found"); }, 50); }); const targetPort = await listenOn(target); const proxy = createProxyServer({ target: `http://127.0.0.1:${targetPort}`, ws: true, }); // Suppress proxy error events (expected when socket is destroyed) proxy.on("error", () => {}); const proxyServer = http.createServer(); proxyServer.on("upgrade", (req, socket, head) => { // Destroy the socket before the target has a chance to respond setTimeout(() => { socket.destroy(); }, 10); proxy.ws(req, socket as net.Socket, {}, head); }); const proxyPort = await listenOn(proxyServer); // Open a raw TCP connection and send a WS upgrade request const socket = net.connect(proxyPort, "127.0.0.1"); const { promise, resolve } = Promise.withResolvers(); socket.on("connect", () => { socket.write( "GET / HTTP/1.1\r\n" + `Host: 127.0.0.1:${proxyPort}\r\n` + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "\r\n", ); }); socket.on("error", () => { // Expected — connection closed }); // Wait for the target response to arrive and verify no crash setTimeout(() => { target.close(); proxyServer.close(); proxy.close(); resolve(); }, 200); await promise; }); it("proxyUpgrade() should not crash when socket is destroyed before upstream responds", async () => { // Target server that responds with a normal HTTP 404 (no upgrade) const target = http.createServer((_req, res) => { setTimeout(() => { res.writeHead(404); res.end("Not Found"); }, 50); }); const targetPort = await listenOn(target); const server = http.createServer(); server.on("upgrade", (req, socket, head) => { // Destroy the socket before the upstream response arrives setTimeout(() => { socket.destroy(); }, 10); proxyUpgrade(`http://127.0.0.1:${targetPort}`, req, socket, head).catch(() => { // Expected rejection — upstream didn't upgrade }); }); const serverPort = await listenOn(server); const socket = net.connect(serverPort, "127.0.0.1"); const { promise, resolve } = Promise.withResolvers(); socket.on("connect", () => { socket.write( "GET / HTTP/1.1\r\n" + `Host: 127.0.0.1:${serverPort}\r\n` + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "\r\n", ); }); socket.on("error", () => { // Expected — connection closed }); setTimeout(() => { target.close(); server.close(); resolve(); }, 200); await promise; }); }); ================================================ FILE: test/ws.test.ts ================================================ import { createServer, type Server, type IncomingMessage } from "node:http"; import { createServer as createHTTPSServer } from "node:https"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { Duplex } from "node:stream"; import { connect, type AddressInfo } from "node:net"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import * as ws from "ws"; import type { ProxyAddr } from "../src/types.ts"; import { proxyUpgrade } from "../src/ws.ts"; // --- WebSocket echo server --- let wsServer: ws.WebSocketServer; let httpServer: Server; let wsPort: number; beforeAll(async () => { httpServer = createServer(); wsServer = new ws.WebSocketServer({ server: httpServer }); wsServer.on("connection", (socket) => { socket.on("message", (msg) => { socket.send("echo:" + msg.toString("utf8")); }); }); await new Promise((resolve) => { httpServer.listen(0, "127.0.0.1", resolve); }); wsPort = (httpServer.address() as AddressInfo).port; }); afterAll(() => { wsServer?.close(); httpServer?.close(); }); // --- Helper: create a local HTTP server that uses proxyUpgrade on upgrade --- function createProxyServer(addr: string | ProxyAddr, opts?: Parameters[4]) { const server = createServer((_req, res) => { res.writeHead(404); res.end(); }); server.on("upgrade", (req, socket, head) => { proxyUpgrade(addr, req, socket, head, opts).catch(() => {}); }); return server; } async function listenServer(server: Server): Promise { await new Promise((resolve) => { server.listen(0, "127.0.0.1", resolve); }); return (server.address() as AddressInfo).port; } function makeDummySocket() { return new Duplex({ read() {}, write(_c, _e, cb) { cb(); }, }); } function wsUpgradeRequest(port: number): string { return ( "GET / HTTP/1.1\r\n" + `Host: 127.0.0.1:${port}\r\n` + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" ); } async function createTargetServer( onUpgrade?: (req: IncomingMessage, socket: Duplex, head: Buffer) => void, ): Promise<{ server: Server; port: number }> { const server = createServer(); if (onUpgrade) server.on("upgrade", onUpgrade); const port = await listenServer(server); return { server, port }; } // --- Tests --- describe("proxyUpgrade", () => { describe("validate ws upgrade request", () => { it("rejects non-GET method", async () => { const socket = makeDummySocket(); const req = { method: "POST", headers: { upgrade: "websocket" }, socket: { remoteAddress: "127.0.0.1" }, } as any; await expect(proxyUpgrade({ host: "127.0.0.1", port: 1 }, req, socket)).rejects.toThrow( "Not a valid WebSocket upgrade request", ); expect(socket.destroyed).toBe(true); }); it("rejects missing upgrade header", async () => { const socket = makeDummySocket(); const req = { method: "GET", headers: {}, socket: { remoteAddress: "127.0.0.1" } } as any; await expect(proxyUpgrade({ host: "127.0.0.1", port: 1 }, req, socket)).rejects.toThrow( "Not a valid WebSocket upgrade request", ); expect(socket.destroyed).toBe(true); }); it("rejects non-websocket upgrade header", async () => { const socket = makeDummySocket(); const req = { method: "GET", headers: { upgrade: "h2c" }, socket: { remoteAddress: "127.0.0.1" }, } as any; await expect(proxyUpgrade({ host: "127.0.0.1", port: 1 }, req, socket)).rejects.toThrow( "Not a valid WebSocket upgrade request", ); expect(socket.destroyed).toBe(true); }); }); it("should proxy websocket messages", async () => { const proxy = createProxyServer({ host: "127.0.0.1", port: wsPort }); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("open", () => { client.send("hello"); }); client.on("message", (msg) => { expect(msg.toString("utf8")).toBe("echo:hello"); client.close(); proxy.close(() => resolve()); }); await promise; }); it("should accept URL string as addr", async () => { const proxy = createProxyServer(`ws://127.0.0.1:${wsPort}`); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("open", () => { client.send("url-addr"); }); client.on("message", (msg) => { expect(msg.toString("utf8")).toBe("echo:url-addr"); client.close(); proxy.close(() => resolve()); }); await promise; }); it("should reject on connection error", async () => { const { promise, resolve } = Promise.withResolvers(); const server = createServer(); server.on("upgrade", (req, socket, head) => { proxyUpgrade({ host: "127.0.0.1", port: 1 }, req, socket, head).catch((err) => { expect(err).toBeDefined(); resolve(); }); }); const port = await listenServer(server); const client = new ws.WebSocket("ws://127.0.0.1:" + port); client.on("error", () => { // Expected — upstream connection fails }); await promise; server.close(); }); it("should add x-forwarded headers when xfwd is set", async () => { const { server: targetServer, port: targetPort } = await createTargetServer(); const targetWs = new ws.WebSocketServer({ server: targetServer }); targetWs.on("connection", (socket, req) => { socket.send( JSON.stringify({ "x-forwarded-for": req.headers["x-forwarded-for"], "x-forwarded-port": req.headers["x-forwarded-port"], "x-forwarded-proto": req.headers["x-forwarded-proto"], }), ); }); const proxy = createProxyServer({ host: "127.0.0.1", port: targetPort }, { xfwd: true }); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("message", (msg) => { const headers = JSON.parse(msg.toString("utf8")); expect(headers["x-forwarded-for"]).toBeDefined(); expect(headers["x-forwarded-port"]).toBeDefined(); expect(headers["x-forwarded-proto"]).toBe("ws"); client.close(); targetWs.close(); targetServer.close(); proxy.close(() => resolve()); }); await promise; }); it("should reject when upstream responds without upgrading", async () => { // Target is a plain HTTP server that never upgrades — just returns 404 const targetServer = createServer((_req, res) => { res.writeHead(404); res.end("Not Found"); }); const targetPort = await listenServer(targetServer); const server = createServer(); const { promise, resolve } = Promise.withResolvers(); server.on("upgrade", (req, socket, head) => { proxyUpgrade({ host: "127.0.0.1", port: targetPort }, req, socket, head) .then(() => { expect.unreachable("should not resolve on non-upgrade response"); }) .catch((err) => { expect(err.message).toContain("did not upgrade the connection"); resolve(); }); }); const port = await listenServer(server); const client = new ws.WebSocket("ws://127.0.0.1:" + port); client.on("error", () => { // Expected — proxy relays the non-upgrade response }); await promise; targetServer.close(); server.close(); }, 5000); it("should not write to socket when it closes before non-upgrade response", async () => { // Regression: upstream PR http-party/node-http-proxy#1552 // If the client socket is not writable when the upstream non-upgrade // response arrives, socket.write() should be skipped to avoid // "This socket has been ended by the other party" errors. const { promise: targetReqReceived, resolve: onTargetReq } = Promise.withResolvers(); const { promise: canRespond, resolve: allowResponse } = Promise.withResolvers(); const targetServer = createServer(async (_req, res) => { onTargetReq(); await canRespond; res.writeHead(404); res.end("Not Found"); }); const targetPort = await listenServer(targetServer); const server = createServer(); const { promise, resolve } = Promise.withResolvers(); let socketWriteCalled = false; server.on("upgrade", (req, socket, head) => { const origWrite = socket.write.bind(socket) as typeof socket.write; socket.write = function (chunk: any, encodingOrCb?: any, cb?: any) { socketWriteCalled = true; return origWrite(chunk, encodingOrCb, cb); } as typeof socket.write; // Destroy the server-side socket before the upstream responds, // simulating a client that disconnects and the server detects it. targetReqReceived.then(() => { socket.destroy(); // Allow the target to respond after the socket is destroyed setTimeout(allowResponse, 10); }); proxyUpgrade({ host: "127.0.0.1", port: targetPort }, req, socket, head).catch(() => { resolve(); }); }); const port = await listenServer(server); const rawSocket = connect(port, "127.0.0.1", () => { rawSocket.write(wsUpgradeRequest(port)); }); rawSocket.on("error", () => {}); await promise; expect(socketWriteCalled).toBe(false); targetServer.close(); server.close(); }, 5000); it("should not crash when upstream response errors during non-upgrade pipe", async () => { // Regression: https://github.com/http-party/node-http-proxy/pull/1439 // When upstream returns a non-upgrade response and the response stream errors // (e.g., ECONNRESET), the error on `res` was unhandled and crashed the process. const targetServer = createServer((req, res) => { res.writeHead(502); res.write("partial"); setTimeout(() => req.socket.destroy(), 10); }); const targetPort = await listenServer(targetServer); const server = createServer(); const { promise, resolve } = Promise.withResolvers(); server.on("upgrade", (req, socket, head) => { proxyUpgrade({ host: "127.0.0.1", port: targetPort }, req, socket, head).catch(() => { resolve(); }); }); const port = await listenServer(server); const client = new ws.WebSocket("ws://127.0.0.1:" + port); client.on("error", () => {}); await promise; targetServer.close(); server.close(); }, 5000); it("should not emit undefined header values", async () => { const { server: targetServer, port: targetPort } = await createTargetServer((req, socket) => { socket.write( "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n" + "\r\n", ); req.socket.pipe(socket).pipe(req.socket); }); const proxy = createProxyServer({ host: "127.0.0.1", port: targetPort }); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const sock = connect(proxyPort, "127.0.0.1", () => { sock.write(wsUpgradeRequest(proxyPort)); }); sock.on("data", (data) => { const response = data.toString(); // The response headers should never contain literal "undefined" expect(response).not.toContain(": undefined"); sock.destroy(); targetServer.close(); proxy.close(() => resolve()); }); await promise; }); it("should preserve multiple set-cookie headers in upgrade response", async () => { const { server: targetServer, port: targetPort } = await createTargetServer((req, socket) => { socket.write( "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n" + "Set-Cookie: a=1\r\n" + "Set-Cookie: b=2\r\n" + "Set-Cookie: c=3\r\n" + "\r\n", ); req.socket.pipe(socket).pipe(req.socket); }); const proxy = createProxyServer({ host: "127.0.0.1", port: targetPort }); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const sock = connect(proxyPort, "127.0.0.1", () => { sock.write(wsUpgradeRequest(proxyPort)); }); sock.on("data", (data) => { const response = data.toString(); // All three Set-Cookie values must appear as separate headers const cookies = [...response.matchAll(/set-cookie: (.+)/gi)].map((m) => m[1]!.trim()); expect(cookies).toContain("a=1"); expect(cookies).toContain("b=2"); expect(cookies).toContain("c=3"); sock.destroy(); targetServer.close(); proxy.close(() => resolve()); }); await promise; }); it("should resolve with the proxy socket", async () => { const server = createServer(); const { promise, resolve } = Promise.withResolvers(); server.on("upgrade", async (req, socket, head) => { const proxySocket = await proxyUpgrade( { host: "127.0.0.1", port: wsPort }, req, socket, head, ); expect(proxySocket).toBeDefined(); expect(proxySocket.writable).toBe(true); resolve(); }); const port = await listenServer(server); const client = new ws.WebSocket("ws://127.0.0.1:" + port); client.on("open", () => { client.close(); }); await promise; server.close(); }); it("should forward non-empty head buffer to upstream", async () => { const received: Buffer[] = []; const { promise, resolve } = Promise.withResolvers(); const { server: targetServer, port: targetPort } = await createTargetServer((_req, socket) => { socket.write( "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "\r\n", ); socket.on("data", (chunk: Buffer) => { received.push(Buffer.from(chunk)); setTimeout(() => socket.destroy(), 20); }); socket.on("close", () => { const all = Buffer.concat(received); expect(all.toString()).toContain("head-payload-data"); resolve(); }); }); // Proxy server that injects a non-empty head buffer const server = createServer(); server.on("upgrade", (req, socket, _head) => { const syntheticHead = Buffer.from("head-payload-data"); proxyUpgrade({ host: "127.0.0.1", port: targetPort }, req, socket, syntheticHead).catch( () => {}, ); }); const port = await listenServer(server); const sock = connect(port, "127.0.0.1", () => { sock.end(wsUpgradeRequest(port)); }); sock.on("error", () => {}); await promise; targetServer.close(); server.close(); }); describe("disconnect scenarios", () => { it("client socket error before upgrade rejects and destroys proxy request", async () => { const { server: targetServer, port: targetPort } = await createTargetServer(() => { // Intentionally hang — never respond }); const { promise, resolve } = Promise.withResolvers(); const server = createServer(); server.on("upgrade", (req, socket, head) => { const result = proxyUpgrade({ host: "127.0.0.1", port: targetPort }, req, socket, head); // Emit an error on the client socket after a short delay // (upstream upgrade is still pending) setTimeout(() => { socket.destroy(new Error("client socket error")); }, 30); result .then(() => { expect.unreachable("should not resolve when client socket errors"); }) .catch((err) => { expect(err.message).toBe("client socket error"); resolve(); }); }); const port = await listenServer(server); const client = new ws.WebSocket("ws://127.0.0.1:" + port); client.on("error", () => { // Expected }); await promise; targetServer.close(); server.close(); }); it("client disconnect before upgrade rejects the promise", async () => { const targetWs = new ws.WebSocketServer({ noServer: true }); const { server: targetServer, port: targetPort } = await createTargetServer( (req, socket, head) => { setTimeout(() => { targetWs.handleUpgrade(req, socket as any, head, (client) => { targetWs.emit("connection", client, req); }); }, 200); }, ); const { promise, resolve } = Promise.withResolvers(); const server = createServer(); server.on("upgrade", (req, socket, head) => { proxyUpgrade({ host: "127.0.0.1", port: targetPort }, req, socket, head) .then(() => { expect.unreachable("should not resolve when client disconnects early"); }) .catch((err) => { expect(err).toBeDefined(); resolve(); }); }); const port = await listenServer(server); const sock = connect(port, "127.0.0.1", () => { sock.write(wsUpgradeRequest(port)); setTimeout(() => sock.destroy(), 50); }); await promise; targetWs.close(); targetServer.close(); server.close(); }); it("client disconnect after upgrade ends the upstream socket", async () => { const { server: targetServer, port: targetPort } = await createTargetServer(); const targetWs = new ws.WebSocketServer({ server: targetServer }); const { promise, resolve } = Promise.withResolvers(); targetWs.on("connection", (socket) => { socket.on("close", () => resolve()); }); const proxy = createProxyServer({ host: "127.0.0.1", port: targetPort }); const proxyPort = await listenServer(proxy); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("open", () => { // Forcefully terminate the client connection client.terminate(); }); await promise; targetWs.close(); targetServer.close(); proxy.close(); }); it("client socket error after upgrade ends the upstream proxy socket", async () => { const { server: targetServer, port: targetPort } = await createTargetServer(); const targetWs = new ws.WebSocketServer({ server: targetServer }); const { promise, resolve } = Promise.withResolvers(); const server = createServer(); server.on("upgrade", async (req, socket, head) => { const proxySocket = await proxyUpgrade( { host: "127.0.0.1", port: targetPort }, req, socket, head, ); proxySocket.on("close", () => { resolve(); }); // Emit an error on the client socket after upgrade is complete // This triggers the post-upgrade error handler: sock.once("error", () => { proxySocket.end() }) socket.destroy(new Error("post-upgrade client error")); }); const port = await listenServer(server); const client = new ws.WebSocket("ws://127.0.0.1:" + port); client.on("error", () => {}); await promise; targetWs.close(); targetServer.close(); server.close(); }); it("server disconnect during upgrade rejects the promise", async () => { const { server: targetServer, port: targetPort } = await createTargetServer( (_req, socket) => { socket.destroy(); }, ); const { promise, resolve } = Promise.withResolvers(); const server = createServer(); server.on("upgrade", (req, socket, head) => { proxyUpgrade({ host: "127.0.0.1", port: targetPort }, req, socket, head) .then(() => { expect.unreachable("should not resolve when server destroys during upgrade"); }) .catch((err) => { expect(err).toBeDefined(); resolve(); }); }); const port = await listenServer(server); const client = new ws.WebSocket("ws://127.0.0.1:" + port); client.on("error", () => { // Expected }); await promise; targetServer.close(); server.close(); }); it("server disconnect after upgrade ends the client socket", async () => { const { server: targetServer, port: targetPort } = await createTargetServer(); const targetWs = new ws.WebSocketServer({ server: targetServer }); targetWs.on("connection", (socket) => { setTimeout(() => socket.terminate(), 50); }); const proxy = createProxyServer({ host: "127.0.0.1", port: targetPort }); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("close", () => { // Client sees the close after server disconnects resolve(); }); client.on("error", () => { // May fire before close on some platforms }); await promise; targetWs.close(); targetServer.close(); proxy.close(); }); }); describe("wss:// (TLS upstream)", () => { const __dirname = new URL(".", import.meta.url).pathname; const sslOpts = { key: readFileSync(join(__dirname, "fixtures", "agent2-key.pem")), cert: readFileSync(join(__dirname, "fixtures", "agent2-cert.pem")), }; it("should use HTTPS request for wss:// addr", async () => { // Create an HTTPS server with WebSocket support const httpsServer = createHTTPSServer(sslOpts); const targetWs = new ws.WebSocketServer({ server: httpsServer }); targetWs.on("connection", (socket) => { socket.on("message", (msg) => { socket.send("secure-echo:" + msg.toString("utf8")); }); }); await new Promise((resolve) => { httpsServer.listen(0, "127.0.0.1", resolve); }); const targetPort = (httpsServer.address() as AddressInfo).port; const proxy = createProxyServer(`wss://127.0.0.1:${targetPort}`, { secure: false, }); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("open", () => { client.send("tls-test"); }); client.on("message", (msg) => { expect(msg.toString("utf8")).toBe("secure-echo:tls-test"); client.close(); targetWs.close(); httpsServer.close(); proxy.close(() => resolve()); }); client.on("error", (err) => { targetWs.close(); httpsServer.close(); proxy.close(); throw err; }); await promise; }); it("should use HTTPS request for https:// addr", async () => { const httpsServer = createHTTPSServer(sslOpts); const targetWs = new ws.WebSocketServer({ server: httpsServer }); targetWs.on("connection", (socket) => { socket.on("message", (msg) => { socket.send("https-echo:" + msg.toString("utf8")); }); }); await new Promise((resolve) => { httpsServer.listen(0, "127.0.0.1", resolve); }); const targetPort = (httpsServer.address() as AddressInfo).port; const proxy = createProxyServer(`https://127.0.0.1:${targetPort}`, { secure: false, }); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("open", () => { client.send("https-test"); }); client.on("message", (msg) => { expect(msg.toString("utf8")).toBe("https-echo:https-test"); client.close(); targetWs.close(); httpsServer.close(); proxy.close(() => resolve()); }); client.on("error", (err) => { targetWs.close(); httpsServer.close(); proxy.close(); throw err; }); await promise; }); it("should use plain HTTP request for ws:// addr (no TLS)", async () => { // Verify ws:// still uses plain HTTP (not HTTPS) const proxy = createProxyServer({ host: "127.0.0.1", port: wsPort }); const proxyPort = await listenServer(proxy); const { promise, resolve } = Promise.withResolvers(); const client = new ws.WebSocket("ws://127.0.0.1:" + proxyPort); client.on("open", () => { client.send("plain-test"); }); client.on("message", (msg) => { expect(msg.toString("utf8")).toBe("echo:plain-test"); client.close(); proxy.close(() => resolve()); }); await promise; }); }); describe("non-upgrade response with destroyed socket", () => { it("should consume response body when socket is already destroyed", async () => { // Regression: when the client socket is destroyed before the upstream // non-upgrade response arrives, the response stream must be consumed // (res.resume()) to avoid unhandled stream errors. const { promise: targetReqReceived, resolve: onTargetReq } = Promise.withResolvers(); const { promise: canRespond, resolve: allowResponse } = Promise.withResolvers(); const targetServer = createServer(async (_req, res) => { onTargetReq(); await canRespond; // Send a larger response body to make unconsumed stream errors more likely res.writeHead(503); res.end("Service Unavailable — " + "x".repeat(1024)); }); const targetPort = await listenServer(targetServer); const server = createServer(); const { promise, resolve } = Promise.withResolvers(); server.on("upgrade", (req, socket, head) => { // Destroy socket before upstream responds targetReqReceived.then(() => { socket.destroy(); setTimeout(allowResponse, 10); }); proxyUpgrade({ host: "127.0.0.1", port: targetPort }, req, socket, head).catch(() => { // Give time for any potential unhandled stream errors to surface setTimeout(resolve, 50); }); }); const port = await listenServer(server); const sock = connect(port, "127.0.0.1", () => { sock.write(wsUpgradeRequest(port)); }); sock.on("error", () => {}); await promise; targetServer.close(); server.close(); }); }); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "esnext", "module": "nodenext", "moduleResolution": "nodenext", "moduleDetection": "force", "esModuleInterop": true, "allowSyntheticDefaultImports": true, "allowJs": true, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, "isolatedModules": true, "verbatimModuleSyntax": true, "noUncheckedIndexedAccess": true, "forceConsistentCasingInFileNames": true, "allowImportingTsExtensions": true, "noImplicitOverride": true, "noEmit": true } } ================================================ FILE: vitest.config.mjs ================================================ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { typecheck: { enabled: true }, coverage: { include: ["src/**/*.ts"], }, }, });