From 58acf57ab7b3b8120901ffd6c7baa92b1070b209 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 25 Mar 2026 22:48:16 +0100 Subject: [PATCH 01/16] chore(bench): add benchmark suite comparing proxy implementations mitata-based benchmarks for httpxy server, proxyFetch, fast-proxy, @fastify/http-proxy, and http-proxy-3 with pre-bench validation. --- PLAN.md | 67 ++++++++ bench/index.ts | 362 ++++++++++++++++++++++++++++++++++++++ package.json | 5 + pnpm-lock.yaml | 458 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 892 insertions(+) create mode 100644 PLAN.md create mode 100644 bench/index.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..367a566 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,67 @@ +# httpxy Optimization Plan + +## Optimization Opportunities (from fast-proxy analysis) + +### 1. Connection Pooling / Agent Reuse (HIGH impact) +- fast-proxy creates a persistent `http.Agent` with keepAlive:true, 2048 maxSockets at factory time +- httpxy creates no agent by default — every request opens a new TCP connection +- **Action**: Add default keep-alive agent option to `ProxyServer` constructor and `proxyFetch` + +### 2. URL Parse Caching (HIGH impact) +- fast-proxy caches parsed URLs in LRU (`tiny-lru`, 100 entries) +- httpxy calls `new URL()` on every request in `_createProxyFn` (server.ts:200-202) +- **Action**: Cache URL objects for string targets (same string -> same URL) + +### 3. Avoid Double Header Copying (MEDIUM-HIGH impact) +- `setupOutgoing()` spreads `req.headers` then spreads again with `options.headers` +- 2 full header object allocations per request +- **Action**: Single `Object.assign()` or mutate-in-place + +### 4. Compile Cookie Rewrite Regex (MEDIUM impact) +- `rewriteCookieProperty()` creates `new RegExp()` for every Set-Cookie header +- Pattern for "domain"/"path" is static +- **Action**: Pre-compile and cache regex per property name + +### 5. Replace Regex in `getPort()` (LOW-MEDIUM impact) +- `hostHeader.match(/:(\d+)/)` allocates match array on every `xfwd` request +- **Action**: Use `indexOf(':')` + `substring()` — zero allocation + +### 6. Lazy Redirect Body Buffering (MEDIUM impact) +- web-incoming stream pass sets up `chunks: Buffer[]` + tee pattern when `maxRedirects > 0` +- Buffering happens even for non-redirect responses +- **Action**: Defer buffering until redirect actually occurs + +### 7. `setImmediate` Yielding (LOW-MEDIUM impact) +- fast-proxy wraps response callbacks in `setImmediate()` for event loop fairness +- httpxy processes responses synchronously +- **Action**: Consider yielding before outgoing passes under high concurrency + +## What NOT to adopt from fast-proxy +- Dropping WebSocket support (httpxy's key differentiator) +- Removing the event system (essential for observability) +- Always-on changeOrigin (httpxy's opt-in is more correct) +- External deps (undici, pump, tiny-lru) — zero-dep policy is a strength + +## Benchmark Suite + +Compare using `mitata`: +- **httpxy** `ProxyServer.web()` (event-driven server) +- **httpxy** `proxyFetch()` (web-standard fetch) +- **fast-proxy** (fastify/fast-proxy) +- **fastify-http-proxy** (@fastify/http-proxy) +- **http-proxy-3** (sagemathinc/http-proxy-3) + +### Scenarios +1. Simple GET proxy (no body) +2. POST proxy with JSON body (~1KB) +3. Large body proxy (~100KB) + +Benchmark source: `bench/index.ts` + +## Key Takeaways (from benchmark results) + +- **fast-proxy / @fastify/http-proxy are 3-4x faster** on GET and small POST — almost entirely due to connection pooling (`keepAlive` agent with 2048 sockets created once) +- **httpxy and http-proxy-3 show similar performance** — both lack default connection reuse +- The gap **narrows on large bodies (~100KB)** — network I/O dominates, so the overhead difference becomes proportionally smaller +- **proxyFetch** is competitive with server mode on GET but slightly slower on large POST due to body buffering + `Readable.toWeb()` conversion +- This confirms **agent reuse / connection pooling (optimization #1) is the dominant factor** — it alone would likely close 60-70% of the gap diff --git a/bench/index.ts b/bench/index.ts new file mode 100644 index 0000000..a9a0a0d --- /dev/null +++ b/bench/index.ts @@ -0,0 +1,362 @@ +import { bench, group, compact, summary, run } from "mitata"; +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 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 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; + } + // Echo POST body + 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)); + }); +} + +// --- 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); + }); +} + +// Lean versions for benchmarks (drain body, minimal allocations) +function benchGet(port: number): Promise { + return new Promise((resolve, reject) => { + const req = http.get(`http://127.0.0.1:${port}/`, (res) => { + res.resume(); + res.on("end", resolve); + }); + req.on("error", reject); + }); +} + +function benchPost(port: number, body: string): 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) => { + res.resume(); + res.on("end", resolve); + }, + ); + 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(); + + // --- Validation --- + 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 }, + ]; + + let allValid = true; + + for (const { name, port } of proxies) { + const errors: string[] = []; + + // GET: should return 200 with {"ok":true} + 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}'`); + } + + // POST small: should echo back the body + 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}`, + ); + } + + // POST large: should echo back the body + 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}`); + } + } + + if (!allValid) { + console.error("\nValidation failed — aborting benchmarks."); + process.exit(1); + } + console.log(""); + + // Warmup — ensure all connections are established + console.log("Warming up..."); + for (const { port } of proxies) { + for (let i = 0; i < 50; i++) { + await benchGet(port); + } + } + console.log("Running benchmarks...\n"); + + // --- GET (no body) --- + + compact(() => { + summary(() => { + group("GET proxy (no body)", () => { + bench("httpxy server", () => benchGet(HTTPXY_SERVER_PORT)); + bench("httpxy proxyFetch", () => benchGet(HTTPXY_FETCH_PORT)); + bench("fast-proxy", () => benchGet(FAST_PROXY_PORT)); + bench("@fastify/http-proxy", () => benchGet(FASTIFY_PROXY_PORT)); + bench("http-proxy-3", () => benchGet(HTTP_PROXY_3_PORT)); + }); + }); + }); + + // --- POST small body (~1KB) --- + + compact(() => { + summary(() => { + group("POST proxy (~1KB JSON body)", () => { + bench("httpxy server", () => benchPost(HTTPXY_SERVER_PORT, SMALL_BODY)); + bench("httpxy proxyFetch", () => benchPost(HTTPXY_FETCH_PORT, SMALL_BODY)); + bench("fast-proxy", () => benchPost(FAST_PROXY_PORT, SMALL_BODY)); + bench("@fastify/http-proxy", () => benchPost(FASTIFY_PROXY_PORT, SMALL_BODY)); + bench("http-proxy-3", () => benchPost(HTTP_PROXY_3_PORT, SMALL_BODY)); + }); + }); + }); + + // --- POST large body (~100KB) --- + + compact(() => { + summary(() => { + group("POST proxy (~100KB JSON body)", () => { + bench("httpxy server", () => benchPost(HTTPXY_SERVER_PORT, LARGE_BODY)); + bench("httpxy proxyFetch", () => benchPost(HTTPXY_FETCH_PORT, LARGE_BODY)); + bench("fast-proxy", () => benchPost(FAST_PROXY_PORT, LARGE_BODY)); + bench("@fastify/http-proxy", () => benchPost(FASTIFY_PROXY_PORT, LARGE_BODY)); + bench("http-proxy-3", () => benchPost(HTTP_PROXY_3_PORT, LARGE_BODY)); + }); + }); + }); + + await run(); + + // Cleanup + console.log("\nShutting down servers..."); + targetServer.close(); + httpxyServer.close(); + httpxyFetchServer.close(); + fastProxySetup.server.close(); + fastProxySetup.close(); + await fastifyApp.close(); + httpProxy3Server.close(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/package.json b/package.json index ac1a77e..3d0e1ce 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "typecheck": "tsgo --noEmit" }, "devDependencies": { + "@fastify/http-proxy": "^11.4.2", "@types/async": "^3.2.25", "@types/concat-stream": "^2.0.3", "@types/express": "^5.0.6", @@ -38,6 +39,10 @@ "concat-stream": "^2.0.0", "eslint-config-unjs": "^0.6.2", "expect.js": "^0.3.1", + "fast-proxy": "^2.2.0", + "fastify": "^5.8.4", + "http-proxy-3": "^1.23.2", + "mitata": "^1.0.34", "obuild": "^0.4.32", "ofetch": "^1.5.1", "oxfmt": "^0.42.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6674de..d7c8065 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@fastify/http-proxy': + specifier: ^11.4.2 + version: 11.4.2 '@types/async': specifier: ^3.2.25 version: 3.2.25 @@ -50,6 +53,18 @@ importers: expect.js: specifier: ^0.3.1 version: 0.3.1 + fast-proxy: + specifier: ^2.2.0 + version: 2.2.0 + fastify: + specifier: ^5.8.4 + version: 5.8.4 + http-proxy-3: + specifier: ^1.23.2 + version: 1.23.2 + mitata: + specifier: ^1.0.34 + version: 1.0.34 obuild: specifier: ^0.4.32 version: 0.4.32(@typescript/native-preview@7.0.0-dev.20260325.1)(chokidar@5.0.0)(dotenv@17.3.1)(giget@2.0.0)(jiti@2.6.1)(magicast@0.5.2)(picomatch@4.0.3)(rollup@4.57.1)(typescript@6.0.2) @@ -342,6 +357,34 @@ packages: resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/http-proxy@11.4.2': + resolution: {integrity: sha512-J52YphwK9XMw85Sy0Dx5ExKPntMbG9YQeKcwqfjaYpTUqaCvqGwuySLU9ltIFFyslDZUdiX5JrxeZRxyfD4gaw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/reply-from@12.6.1': + resolution: {integrity: sha512-J95lfqd6wWhAF5Lx1Tt2Htaite/Bmk9bEAsGFR8+YakjCdgOPK+WX9IUeRXUX4e7bdf23rP/+xPPf/vICAs73Q==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -625,6 +668,9 @@ packages: cpu: [x64] os: [win32] + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rolldown/binding-android-arm64@1.0.0-rc.12': resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1084,6 +1130,9 @@ packages: '@vitest/utils@4.1.1': resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1098,9 +1147,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + array-find-index@1.0.2: resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} engines: {node: '>=0.10.0'} @@ -1119,6 +1179,13 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1250,6 +1317,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-js-compat@3.48.0: resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} @@ -1317,6 +1388,9 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-client@6.6.4: resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} @@ -1423,15 +1497,43 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-proxy@2.2.0: + resolution: {integrity: sha512-jSrXdYKxPg+DcP8qoKKPbZ1sLvRKGN8sSbmCn+xrSAMfErI+tMJAwUbqMBDJi2tzq7c/m4Nv4ewosD+SIA4vhg==} + deprecated: Use @fastify/http-proxy instead + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.4: + resolution: {integrity: sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} @@ -1448,6 +1550,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + engines: {node: '>=20'} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -1463,6 +1569,15 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -1501,6 +1616,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-3@1.23.2: + resolution: {integrity: sha512-vZks1dLliM0w7aQDT9eFYLO8PUuQ9Cm67y7kn+kgkLtvKP0HZ6Thb3+MCGFFNCnKMCkLXY6rvIH1d7jQITryxA==} + engines: {node: '>=18'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1520,6 +1639,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + is-builtin-module@5.0.0: resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} engines: {node: '>=18.20'} @@ -1580,9 +1703,15 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1593,6 +1722,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1755,6 +1887,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + mitata@1.0.34: + resolution: {integrity: sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA==} + moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} @@ -1805,6 +1940,13 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -1865,6 +2007,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -1884,10 +2036,22 @@ packages: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -1902,6 +2066,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -1910,9 +2078,24 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown-plugin-dts@0.22.5: resolution: {integrity: sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==} engines: {node: '>=20.19.0'} @@ -1955,14 +2138,28 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.0: + resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1989,6 +2186,9 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2014,6 +2214,10 @@ packages: spdx-satisfies@5.0.1: resolution: {integrity: sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sse@0.0.8: resolution: {integrity: sha512-cviG7JH31TUhZeaEVhac3zTzA+2FwA7qvHziAHpb7mC7RNVJ/RbHN+6LIGsS2ugP4o2H15DWmrSMK+91CboIcg==} engines: {node: '>=0.4.0'} @@ -2038,6 +2242,14 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2057,6 +2269,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -2091,6 +2307,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@7.24.6: resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} engines: {node: '>=20.18.1'} @@ -2212,6 +2432,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -2439,6 +2662,51 @@ snapshots: '@eslint/core': 1.1.0 levn: 0.4.1 + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + + '@fastify/busboy@2.1.1': {} + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/http-proxy@11.4.2': + dependencies: + '@fastify/reply-from': 12.6.1 + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/reply-from@12.6.1': + dependencies: + '@fastify/error': 4.2.0 + end-of-stream: 1.4.5 + fast-content-type-parse: 3.0.0 + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + undici: 7.24.6 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2589,6 +2857,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.57.0': optional: true + '@pinojs/redact@0.4.0': {} + '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true @@ -2987,6 +3257,8 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.0.3 + abstract-logging@2.0.1: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -2998,6 +3270,10 @@ snapshots: acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3005,6 +3281,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + array-find-index@1.0.2: {} assertion-error@2.0.1: {} @@ -3023,6 +3306,13 @@ snapshots: async@3.2.6: {} + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + balanced-match@1.0.2: {} balanced-match@4.0.2: @@ -3154,6 +3444,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + core-js-compat@3.48.0: dependencies: browserslist: 4.28.1 @@ -3204,6 +3496,10 @@ snapshots: electron-to-chromium@1.5.286: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + engine.io-client@6.6.4: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -3385,12 +3681,64 @@ snapshots: exsolve@1.0.8: {} + fast-content-type-parse@3.0.0: {} + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-proxy@2.2.0: + dependencies: + end-of-stream: 1.4.5 + fast-querystring: 1.1.2 + pump: 3.0.4 + semver: 7.7.4 + tiny-lru: 11.4.7 + undici: 5.29.0 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fastify-plugin@5.1.0: {} + + fastify@5.8.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fault@2.0.1: dependencies: format: 0.2.2 @@ -3403,6 +3751,12 @@ snapshots: dependencies: flat-cache: 4.0.1 + find-my-way@9.5.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.0 + find-up-simple@1.0.1: {} find-up@5.0.0: @@ -3417,6 +3771,10 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + format@0.2.2: {} fsevents@2.3.3: @@ -3449,6 +3807,13 @@ snapshots: html-escaper@2.0.2: {} + http-proxy-3@1.23.2: + dependencies: + debug: 4.4.3 + follow-redirects: 1.15.11(debug@4.4.3) + transitivePeerDependencies: + - supports-color + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3459,6 +3824,8 @@ snapshots: inherits@2.0.4: {} + ipaddr.js@2.3.0: {} + is-builtin-module@5.0.0: dependencies: builtin-modules: 5.0.0 @@ -3506,8 +3873,14 @@ snapshots: json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} keyv@4.5.4: @@ -3519,6 +3892,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3868,6 +4247,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + mitata@1.0.34: {} + moment@2.30.1: {} mri@1.2.0: {} @@ -3929,6 +4310,12 @@ snapshots: ohash@2.0.11: {} + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -4015,6 +4402,26 @@ snapshots: picomatch@4.0.3: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + pkg-types@2.3.0: dependencies: confbox: 0.2.4 @@ -4033,8 +4440,19 @@ snapshots: pretty-bytes@7.1.0: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} + quick-format-unescaped@4.0.4: {} + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -4053,14 +4471,24 @@ snapshots: readdirp@5.0.0: {} + real-require@0.2.0: {} + regexp-tree@0.1.27: {} regjsparser@0.13.0: dependencies: jsesc: 3.1.0 + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + rolldown-plugin-dts@0.22.5(@typescript/native-preview@7.0.0-dev.20260325.1)(rolldown@1.0.0-rc.12)(typescript@6.0.2): dependencies: '@babel/generator': 8.0.0-rc.2 @@ -4149,10 +4577,20 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex2@5.1.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + scule@1.3.0: {} + secure-json-parse@4.1.0: {} + semver@7.7.4: {} + set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4202,6 +4640,10 @@ snapshots: - supports-color - utf-8-validate + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} spdx-compare@1.0.0: @@ -4231,6 +4673,8 @@ snapshots: spdx-expression-parse: 3.0.1 spdx-ranges: 2.1.1 + split2@4.2.0: {} + sse@0.0.8: dependencies: options: 0.0.6 @@ -4251,6 +4695,12 @@ snapshots: dependencies: has-flag: 4.0.0 + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + tiny-lru@11.4.7: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -4264,6 +4714,8 @@ snapshots: tinyrainbow@3.0.3: {} + toad-cache@3.7.0: {} + ts-api-utils@2.4.0(typescript@6.0.2): dependencies: typescript: 6.0.2 @@ -4294,6 +4746,10 @@ snapshots: undici-types@7.18.2: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@7.24.6: {} unist-util-is@6.0.1: @@ -4380,6 +4836,8 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + ws@8.18.3: {} ws@8.20.0: {} From dd562f88a5b714a006909c5d119dc39f5490e116 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 25 Mar 2026 22:51:55 +0100 Subject: [PATCH 02/16] perf: default keep-alive agent, URL parse cache, single header copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Default keep-alive agents (http/https) with 256 maxSockets for connection reuse — skipped for HTTP/2 incoming requests. 2. LRU URL parse cache (256 entries) avoids repeated `new URL()` for the same string targets in server.ts, fetch.ts, and parseAddr. 3. Single header copy in `setupOutgoing` — merge req.headers and options.headers in one pass instead of two spread operations. httpxy server is now on par with fast-proxy (~50µs/req vs ~43µs GET). --- src/_utils.ts | 59 +++++++++++++++++++++++++++++++++++++++------ src/fetch.ts | 4 +-- src/server.ts | 3 ++- test/_utils.test.ts | 18 +++++++++++--- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/src/_utils.ts b/src/_utils.ts index b1f8ad8..33d936e 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -6,6 +6,35 @@ 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 }), +}; + +/** + * URL parse cache to avoid repeated `new URL()` for the same string targets. + */ +const _urlCache = new Map(); +const _urlCacheMaxSize = 256; + +export function parseURL(str: string): URL { + let cached = _urlCache.get(str); + if (cached) { + return cached; + } + cached = new URL(str); + if (_urlCache.size >= _urlCacheMaxSize) { + // Evict oldest entry + const firstKey = _urlCache.keys().next().value!; + _urlCache.delete(firstKey); + } + _urlCache.set(str, cached); + return cached; +} + /** * Simple Regex for testing if protocol is https */ @@ -73,17 +102,22 @@ export function setupOutgoing( } outgoing.method = options.method || req.method; - outgoing.headers = { ...req.headers }; + + // Single copy: merge req.headers + options.headers in one pass + if (options.headers) { + const headers: Record = { ...req.headers }; + for (const key of Object.keys(options.headers)) { + headers[key] = options.headers[key]; + } + outgoing.headers = headers; + } else { + 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) { - outgoing.headers = { ...outgoing.headers, ...options.headers }; - } if (req.httpVersionMajor > 1) { // ignore potential conflicting HTTP/2 pseudo headers @@ -104,7 +138,16 @@ export function setupOutgoing( outgoing.rejectUnauthorized = options.secure === undefined ? true : options.secure; } - outgoing.agent = options.agent || false; + if (options.agent !== undefined) { + outgoing.agent = options.agent || false; + } else if (req.httpVersionMajor > 1) { + // HTTP/2 incoming requests: keep-alive agents can conflict with stream lifecycle + 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; // @@ -298,7 +341,7 @@ export function parseAddr(addr: string | ProxyAddr): ProxyAddr { if (addr.startsWith("unix:")) { return { socketPath: addr.slice(5) }; } - const url = new URL(addr); + const url = parseURL(addr); return { host: url.hostname, port: Number(url.port) || (isSSL.test(url.protocol) ? 443 : 80), diff --git a/src/fetch.ts b/src/fetch.ts index ba01385..c188db7 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -3,7 +3,7 @@ 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 { isSSL, joinURL, parseAddr } from "./_utils.ts"; +import { isSSL, joinURL, parseAddr, parseURL } from "./_utils.ts"; /** * Options for {@link proxyFetch}. @@ -67,7 +67,7 @@ export async function proxyFetch( let useHTTPS = false; let addrBasePath = ""; if (typeof addr === "string" && !addr.startsWith("unix:")) { - const addrURL = new URL(addr); + const addrURL = parseURL(addr); useHTTPS = isSSL.test(addrURL.protocol); if (addrURL.pathname && addrURL.pathname !== "/") { addrBasePath = addrURL.pathname; diff --git a/src/server.ts b/src/server.ts index aed7cef..6a89ea3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ 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"; +import { parseURL } from "./_utils.ts"; export interface ProxyServerEventMap< Req extends http.IncomingMessage | http2.Http2ServerRequest = http.IncomingMessage, @@ -198,7 +199,7 @@ function _createProxyFn< for (const key of ["target", "forward"] as const) { if (typeof requestOptions[key] === "string") { - requestOptions[key] = new URL(requestOptions[key]); + requestOptions[key] = parseURL(requestOptions[key]); } } diff --git a/test/_utils.test.ts b/test/_utils.test.ts index eb70b63..447e09d 100644 --- a/test/_utils.test.ts +++ b/test/_utils.test.ts @@ -131,7 +131,7 @@ describe("lib/http-proxy/common.js", () => { common.setupOutgoing( outgoing, { - agent: undefined, + agent: false, target: { host: "hey", hostname: "how", @@ -154,7 +154,7 @@ describe("lib/http-proxy/common.js", () => { common.setupOutgoing( outgoing, { - agent: undefined, + agent: false, target: { host: "hey", hostname: "how", @@ -172,7 +172,7 @@ describe("lib/http-proxy/common.js", () => { expect(outgoing.headers!.connection).to.eql("close"); }); - it("should set the agent to false if none is given", () => { + it("should use default keep-alive agent if none is given", () => { const outgoing = createOutgoing(); common.setupOutgoing( outgoing, @@ -181,6 +181,18 @@ describe("lib/http-proxy/common.js", () => { 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); }); From 37c597ad81963cc7d8ed0538bca61edf1a772686 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:52:21 +0000 Subject: [PATCH 03/16] chore: apply automated updates --- CHANGELOG.md | 6 +++--- PLAN.md | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b7904..4188336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,14 +27,14 @@ - **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)) +- **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)) +- ⚠️ Esm-only ([d65b3f7](https://github.com/unjs/httpxy/commit/d65b3f7)) ### 🏡 Chore @@ -42,7 +42,7 @@ #### ⚠️ Breaking Changes -- ⚠️ Esm-only ([d65b3f7](https://github.com/unjs/httpxy/commit/d65b3f7)) +- ⚠️ Esm-only ([d65b3f7](https://github.com/unjs/httpxy/commit/d65b3f7)) ### ❤️ Contributors diff --git a/PLAN.md b/PLAN.md index 367a566..fa57bf2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -3,40 +3,48 @@ ## Optimization Opportunities (from fast-proxy analysis) ### 1. Connection Pooling / Agent Reuse (HIGH impact) + - fast-proxy creates a persistent `http.Agent` with keepAlive:true, 2048 maxSockets at factory time - httpxy creates no agent by default — every request opens a new TCP connection - **Action**: Add default keep-alive agent option to `ProxyServer` constructor and `proxyFetch` ### 2. URL Parse Caching (HIGH impact) + - fast-proxy caches parsed URLs in LRU (`tiny-lru`, 100 entries) - httpxy calls `new URL()` on every request in `_createProxyFn` (server.ts:200-202) - **Action**: Cache URL objects for string targets (same string -> same URL) ### 3. Avoid Double Header Copying (MEDIUM-HIGH impact) + - `setupOutgoing()` spreads `req.headers` then spreads again with `options.headers` - 2 full header object allocations per request - **Action**: Single `Object.assign()` or mutate-in-place ### 4. Compile Cookie Rewrite Regex (MEDIUM impact) + - `rewriteCookieProperty()` creates `new RegExp()` for every Set-Cookie header - Pattern for "domain"/"path" is static - **Action**: Pre-compile and cache regex per property name ### 5. Replace Regex in `getPort()` (LOW-MEDIUM impact) + - `hostHeader.match(/:(\d+)/)` allocates match array on every `xfwd` request - **Action**: Use `indexOf(':')` + `substring()` — zero allocation ### 6. Lazy Redirect Body Buffering (MEDIUM impact) + - web-incoming stream pass sets up `chunks: Buffer[]` + tee pattern when `maxRedirects > 0` - Buffering happens even for non-redirect responses - **Action**: Defer buffering until redirect actually occurs ### 7. `setImmediate` Yielding (LOW-MEDIUM impact) + - fast-proxy wraps response callbacks in `setImmediate()` for event loop fairness - httpxy processes responses synchronously - **Action**: Consider yielding before outgoing passes under high concurrency ## What NOT to adopt from fast-proxy + - Dropping WebSocket support (httpxy's key differentiator) - Removing the event system (essential for observability) - Always-on changeOrigin (httpxy's opt-in is more correct) @@ -45,6 +53,7 @@ ## Benchmark Suite Compare using `mitata`: + - **httpxy** `ProxyServer.web()` (event-driven server) - **httpxy** `proxyFetch()` (web-standard fetch) - **fast-proxy** (fastify/fast-proxy) @@ -52,6 +61,7 @@ Compare using `mitata`: - **http-proxy-3** (sagemathinc/http-proxy-3) ### Scenarios + 1. Simple GET proxy (no body) 2. POST proxy with JSON body (~1KB) 3. Large body proxy (~100KB) From 64bb3864eba7ed2e4da6c0e1a4ef2d02e73b038b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 25 Mar 2026 22:59:08 +0100 Subject: [PATCH 04/16] perf(fetch): default keep-alive agent, sync body path, raw header pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default keep-alive agents (http/https) for connection reuse in proxyFetch - Sync _toBuffer fast path avoids async overhead for string/ArrayBuffer bodies - Response headers built from rawHeaders array pairs instead of Headers object - Request headers: fast path for plain object (Object.assign) skipping Headers API proxyFetch: ~170µs -> ~57µs GET, ~165µs -> ~55µs POST 1KB (3x faster) --- src/fetch.ts | 101 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 69 insertions(+), 32 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index c188db7..ff1097d 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -3,7 +3,7 @@ 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 { isSSL, joinURL, parseAddr, parseURL } from "./_utils.ts"; +import { defaultAgents, isSSL, joinURL, parseAddr, parseURL } from "./_utils.ts"; /** * Options for {@link proxyFetch}. @@ -101,18 +101,32 @@ export async function proxyFetch( const reqHeaders: Record = {}; if (init.headers) { - const h = - init.headers instanceof Headers ? init.headers : new Headers(init.headers as HeadersInit); - for (const [key, value] of h) { - // Preserve multi-value headers (e.g. set-cookie) as arrays - if (key in reqHeaders) { - const existing = reqHeaders[key]; - reqHeaders[key] = Array.isArray(existing) - ? [...existing, value] - : [existing as string, value]; - } else { - reqHeaders[key] = value; + // Fast path: plain object headers (most common from programmatic use) + if (init.headers instanceof Headers) { + for (const [key, value] of init.headers) { + if (key in reqHeaders) { + const existing = reqHeaders[key]; + reqHeaders[key] = Array.isArray(existing) + ? [...existing, value] + : [existing as string, value]; + } else { + reqHeaders[key] = value; + } + } + } else if (Array.isArray(init.headers)) { + for (const [key, value] of init.headers) { + if (key in reqHeaders) { + const existing = reqHeaders[key]; + reqHeaders[key] = Array.isArray(existing) + ? [...existing, value] + : [existing as string, value]; + } else { + reqHeaders[key] = value; + } } + } else { + // Record — direct assign, no iteration needed + Object.assign(reqHeaders, init.headers); } } @@ -152,16 +166,28 @@ export async function proxyFetch( ? 5 : 0; + // Fast path: sync conversion for string/ArrayBuffer/TypedArray bodies + // Falls back to async _bufferBody for ReadableStream/Blob + const body = _toBuffer(init.body) ?? (await _bufferBody(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, - await _bufferBody(init.body), + body, { signal: init.signal || undefined, - agent: opts?.agent, + agent, timeout: opts?.timeout, ssl: opts?.ssl, maxRedirects, @@ -170,26 +196,23 @@ export async function proxyFetch( }, ); - // Build Response - const headers = new Headers(); - for (const [key, value] of Object.entries(res.headers)) { - if (key === "transfer-encoding" || key === "keep-alive" || key === "connection") { + // 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; } - if (Array.isArray(value)) { - for (const v of value) { - headers.append(key, v); - } - } else if (value) { - headers.set(key, value); - } + 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, + headers: resHeaders, }); } @@ -210,11 +233,28 @@ function toInit(init?: RequestInit | Request): RequestInit | undefined { return init; } -/** Normalize any body type to Buffer (or undefined). */ -async function _bufferBody(body: BodyInit | null | undefined): Promise { +/** Synchronous body conversion for non-stream types. Returns undefined for streams. */ +function _toBuffer(body: BodyInit | null | undefined): 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); + } + // ReadableStream / Blob: fall through — caller should use _bufferBody for these + return undefined; +} + +/** Normalize any body type to Buffer (or undefined), including async types. */ +async function _bufferBody(body: BodyInit | null | undefined): Promise { + // Try sync first + const buf = _toBuffer(body); + if (buf || !body) { + return buf; + } if (body instanceof ReadableStream) { const readable = Readable.fromWeb(body as import("node:stream/web").ReadableStream); const chunks: Buffer[] = []; @@ -223,9 +263,6 @@ async function _bufferBody(body: BodyInit | null | undefined): Promise Date: Wed, 25 Mar 2026 21:59:38 +0000 Subject: [PATCH 05/16] chore: apply automated updates --- src/fetch.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/fetch.ts b/src/fetch.ts index ff1097d..b5904ab 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -202,7 +202,11 @@ export async function proxyFetch( 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") { + if ( + keyLower === "transfer-encoding" || + keyLower === "keep-alive" || + keyLower === "connection" + ) { continue; } resHeaders.push([key, rawHeaders[i + 1]!]); From 29f1bdca7ad422da65aaea325b065614d7b4d177 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 25 Mar 2026 22:59:20 +0100 Subject: [PATCH 06/16] remove plan.md --- PLAN.md | 77 --------------------------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index fa57bf2..0000000 --- a/PLAN.md +++ /dev/null @@ -1,77 +0,0 @@ -# httpxy Optimization Plan - -## Optimization Opportunities (from fast-proxy analysis) - -### 1. Connection Pooling / Agent Reuse (HIGH impact) - -- fast-proxy creates a persistent `http.Agent` with keepAlive:true, 2048 maxSockets at factory time -- httpxy creates no agent by default — every request opens a new TCP connection -- **Action**: Add default keep-alive agent option to `ProxyServer` constructor and `proxyFetch` - -### 2. URL Parse Caching (HIGH impact) - -- fast-proxy caches parsed URLs in LRU (`tiny-lru`, 100 entries) -- httpxy calls `new URL()` on every request in `_createProxyFn` (server.ts:200-202) -- **Action**: Cache URL objects for string targets (same string -> same URL) - -### 3. Avoid Double Header Copying (MEDIUM-HIGH impact) - -- `setupOutgoing()` spreads `req.headers` then spreads again with `options.headers` -- 2 full header object allocations per request -- **Action**: Single `Object.assign()` or mutate-in-place - -### 4. Compile Cookie Rewrite Regex (MEDIUM impact) - -- `rewriteCookieProperty()` creates `new RegExp()` for every Set-Cookie header -- Pattern for "domain"/"path" is static -- **Action**: Pre-compile and cache regex per property name - -### 5. Replace Regex in `getPort()` (LOW-MEDIUM impact) - -- `hostHeader.match(/:(\d+)/)` allocates match array on every `xfwd` request -- **Action**: Use `indexOf(':')` + `substring()` — zero allocation - -### 6. Lazy Redirect Body Buffering (MEDIUM impact) - -- web-incoming stream pass sets up `chunks: Buffer[]` + tee pattern when `maxRedirects > 0` -- Buffering happens even for non-redirect responses -- **Action**: Defer buffering until redirect actually occurs - -### 7. `setImmediate` Yielding (LOW-MEDIUM impact) - -- fast-proxy wraps response callbacks in `setImmediate()` for event loop fairness -- httpxy processes responses synchronously -- **Action**: Consider yielding before outgoing passes under high concurrency - -## What NOT to adopt from fast-proxy - -- Dropping WebSocket support (httpxy's key differentiator) -- Removing the event system (essential for observability) -- Always-on changeOrigin (httpxy's opt-in is more correct) -- External deps (undici, pump, tiny-lru) — zero-dep policy is a strength - -## Benchmark Suite - -Compare using `mitata`: - -- **httpxy** `ProxyServer.web()` (event-driven server) -- **httpxy** `proxyFetch()` (web-standard fetch) -- **fast-proxy** (fastify/fast-proxy) -- **fastify-http-proxy** (@fastify/http-proxy) -- **http-proxy-3** (sagemathinc/http-proxy-3) - -### Scenarios - -1. Simple GET proxy (no body) -2. POST proxy with JSON body (~1KB) -3. Large body proxy (~100KB) - -Benchmark source: `bench/index.ts` - -## Key Takeaways (from benchmark results) - -- **fast-proxy / @fastify/http-proxy are 3-4x faster** on GET and small POST — almost entirely due to connection pooling (`keepAlive` agent with 2048 sockets created once) -- **httpxy and http-proxy-3 show similar performance** — both lack default connection reuse -- The gap **narrows on large bodies (~100KB)** — network I/O dominates, so the overhead difference becomes proportionally smaller -- **proxyFetch** is competitive with server mode on GET but slightly slower on large POST due to body buffering + `Readable.toWeb()` conversion -- This confirms **agent reuse / connection pooling (optimization #1) is the dominant factor** — it alone would likely close 60-70% of the gap From df70dd3c52aad9df9e44c1d3fdd7033d5e6583f6 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 25 Mar 2026 23:01:14 +0100 Subject: [PATCH 07/16] docs: acknowledge fast-proxy and @fastify/http-proxy --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b275e39..3185bbe 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,10 @@ proxy.listen(3000); - 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 💛 From 4fd96dca38dc1b88272a2825e94a53470af49317 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 26 Mar 2026 09:47:24 +0100 Subject: [PATCH 08/16] update benchs --- bench/Dockerfile | 13 ++ bench/bench.ts | 277 ++++++++++++++++++++++++++++++++++++ bench/package.json | 14 ++ bench/src/fast-proxy.ts | 14 ++ bench/src/fastify.ts | 10 ++ bench/src/http-proxy-3.ts | 14 ++ bench/src/http-proxy.ts | 14 ++ bench/src/httpxy-fetch.ts | 36 +++++ bench/src/httpxy-server.ts | 14 ++ bench/src/target.ts | 26 ++++ bench/{index.ts => test.ts} | 125 ++++------------ package.json | 5 - pnpm-lock.yaml | 68 +++++++-- pnpm-workspace.yaml | 2 + 14 files changed, 511 insertions(+), 121 deletions(-) create mode 100644 bench/Dockerfile create mode 100755 bench/bench.ts create mode 100644 bench/package.json create mode 100644 bench/src/fast-proxy.ts create mode 100644 bench/src/fastify.ts create mode 100644 bench/src/http-proxy-3.ts create mode 100644 bench/src/http-proxy.ts create mode 100644 bench/src/httpxy-fetch.ts create mode 100644 bench/src/httpxy-server.ts create mode 100644 bench/src/target.ts rename bench/{index.ts => test.ts} (71%) mode change 100644 => 100755 create mode 100644 pnpm-workspace.yaml diff --git a/bench/Dockerfile b/bench/Dockerfile new file mode 100644 index 0000000..95fefd8 --- /dev/null +++ b/bench/Dockerfile @@ -0,0 +1,13 @@ +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 ./ diff --git a/bench/bench.ts b/bench/bench.ts new file mode 100755 index 0000000..816e0cc --- /dev/null +++ b/bench/bench.ts @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +import { execSync, execFileSync } from "node:child_process"; + +// --- Config --- + +const IMAGE = "httpxy-bench"; +const DURATION = process.env.DURATION || "1s"; +const CONNECTIONS = process.env.CONNECTIONS || "128"; +const POST_BODY = '{"message":"hello world","ts":1234567890}'; +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 bold = (s: string) => `\x1B[1m${s}\x1B[0m`; +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 yellow = (s: string) => `\x1B[1;33m${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..."); + try { + const ids = execSync('docker ps -q --filter "name=bench-"', { encoding: "utf8" }).trim(); + if (ids) { + execSync(`docker rm -f ${ids.split("\n").join(" ")}`, { stdio: "ignore" }); + } + } catch {} +} + +function startContainer(name: string, script: string, port: number) { + const cid = execFileSync( + "docker", + [ + "run", + "-d", + "--rm", + "--name", + name, + "--network", + "host", + "--cpus=1", + "--memory=256m", + "-e", + `PORT=${port}`, + "-e", + `TARGET=http://127.0.0.1:${TARGET_PORT}`, + IMAGE, + "node", + script, + ], + { encoding: "utf8" }, + ).trim(); + containers.push(cid); +} + +function bomb(args: string[]) { + execFileSync( + "docker", + [ + "run", + "--rm", + "--name", + `bench-bombardier-${process.pid}`, + "--network", + "host", + IMAGE, + "bombardier", + ...args, + ], + { stdio: "inherit" }, + ); +} + +function bombJson(args: string[]): string { + return execFileSync( + "docker", + [ + "run", + "--rm", + "--name", + `bench-bombardier-${process.pid}`, + "--network", + "host", + IMAGE, + "bombardier", + "--format=json", + "--print=result", + ...args, + ], + { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }, + ); +} + +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}`); +} + +// --- Result parsing --- + +function formatNs(ns: number): string { + return ns < 1e6 ? `${(ns / 1e3).toFixed(0)}µs` : `${(ns / 1e6).toFixed(2)}ms`; +} + +function parseResult(json: string): string { + const j = JSON.parse(json); + const r = j.result; + const rps = r.rps.mean.toFixed(0); + const avgLatency = formatNs(r.latency.mean); + const p50 = formatNs(r.latency?.percentiles?.["0.5"] ?? r.latency?.["50"] ?? 0); + const p99 = formatNs(r.latency?.percentiles?.["0.99"] ?? r.latency?.["99"] ?? 0); + const bytesPerSec = r.bytesRead / r.timeTakenSeconds; + const throughput = + bytesPerSec > 1e6 + ? `${(bytesPerSec / 1e6).toFixed(1)}MB/s` + : `${(bytesPerSec / 1e3).toFixed(0)}KB/s`; + return `${rps}|${avgLatency}|${p50}|${p99}|${throughput}|${bytesPerSec}`; +} + +function printTable(title: string, results: [name: string, result: string][]) { + console.log(); + console.log(bold(title)); + console.log( + `${"Proxy".padEnd(22)} ${"Req/s".padStart(10)} ${"Scale".padStart(7)} ${"Avg".padStart(10)} ${"P50".padStart(10)} ${"P99".padStart(10)} ${"Throughput".padStart(12)}`, + ); + console.log( + `${"─".repeat(22)} ${"─".repeat(10)} ${"─".repeat(7)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(12)}`, + ); + + // Sort by throughput (bytes/sec) descending + results.sort((a, b) => { + const aTP = Number.parseFloat(a[1].split("|")[5]!); + const bTP = Number.parseFloat(b[1].split("|")[5]!); + return bTP - aTP; + }); + + let bestRps = 0; + for (const [, result] of results) { + const rps = Number.parseInt(result.split("|")[0]); + if (rps > bestRps) bestRps = rps; + } + + for (const [name, result] of results) { + const parts = result.split("|"); + const [rps, avg, p50, p99, tp] = [parts[0]!, parts[1]!, parts[2]!, parts[3]!, parts[4]!]; + const ratio = bestRps > 0 ? Number.parseInt(rps) / bestRps : 0; + const x = ratio >= 1 ? "1.00x" : `${ratio.toFixed(2)}x`; + console.log( + `${name.padEnd(22)} ${rps.padStart(10)} ${x.padStart(7)} ${avg.padStart(10)} ${p50.padStart(10)} ${p99.padStart(10)} ${tp.padStart(12)}`, + ); + } +} + +// --- 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(); + +// --- GET benchmark --- + +const getResults: [string, string][] = []; + +info(`Benchmarking GET (duration=${DURATION}, connections=${CONNECTIONS})`); +console.log("━".repeat(63)); +for (const { name, port } of PROXIES) { + console.log(`\n${yellow(`▸ ${name}`)}`); + bomb(["-c", CONNECTIONS, "-d", DURATION, "--latencies", `http://127.0.0.1:${port}/`]); + const json = bombJson(["-c", CONNECTIONS, "-d", DURATION, `http://127.0.0.1:${port}/`]); + getResults.push([name, parseResult(json)]); +} + +console.log(); + +// --- POST benchmark --- + +const postResults: [string, string][] = []; + +info(`Benchmarking POST ~1KB JSON (duration=${DURATION}, connections=${CONNECTIONS})`); +console.log("━".repeat(63)); +for (const { name, port } of PROXIES) { + console.log(`\n${yellow(`▸ ${name}`)}`); + bomb([ + "-c", + CONNECTIONS, + "-d", + DURATION, + "-m", + "POST", + "-H", + "Content-Type: application/json", + "-b", + POST_BODY, + "--latencies", + `http://127.0.0.1:${port}/`, + ]); + const json = bombJson([ + "-c", + CONNECTIONS, + "-d", + DURATION, + "-m", + "POST", + "-H", + "Content-Type: application/json", + "-b", + POST_BODY, + `http://127.0.0.1:${port}/`, + ]); + postResults.push([name, parseResult(json)]); +} + +// --- Summary --- + +console.log(); +console.log("━".repeat(63)); +info("Summary"); +console.log("━".repeat(63)); + +printTable("GET (no body)", getResults); +console.log(); +printTable("POST (~1KB JSON)", postResults); +console.log(); +info("Done!"); diff --git a/bench/package.json b/bench/package.json new file mode 100644 index 0000000..d41f65d --- /dev/null +++ b/bench/package.json @@ -0,0 +1,14 @@ +{ + "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" + } +} diff --git a/bench/src/fast-proxy.ts b/bench/src/fast-proxy.ts new file mode 100644 index 0000000..8d75954 --- /dev/null +++ b/bench/src/fast-proxy.ts @@ -0,0 +1,14 @@ +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}`); +}); diff --git a/bench/src/fastify.ts b/bench/src/fastify.ts new file mode 100644 index 0000000..34d633f --- /dev/null +++ b/bench/src/fastify.ts @@ -0,0 +1,10 @@ +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}`); diff --git a/bench/src/http-proxy-3.ts b/bench/src/http-proxy-3.ts new file mode 100644 index 0000000..e567fa3 --- /dev/null +++ b/bench/src/http-proxy-3.ts @@ -0,0 +1,14 @@ +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}`); +}); diff --git a/bench/src/http-proxy.ts b/bench/src/http-proxy.ts new file mode 100644 index 0000000..3e67117 --- /dev/null +++ b/bench/src/http-proxy.ts @@ -0,0 +1,14 @@ +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}`); +}); diff --git a/bench/src/httpxy-fetch.ts b/bench/src/httpxy-fetch.ts new file mode 100644 index 0000000..11daa4e --- /dev/null +++ b/bench/src/httpxy-fetch.ts @@ -0,0 +1,36 @@ +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}`); +}); diff --git a/bench/src/httpxy-server.ts b/bench/src/httpxy-server.ts new file mode 100644 index 0000000..51c6a09 --- /dev/null +++ b/bench/src/httpxy-server.ts @@ -0,0 +1,14 @@ +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}`); +}); diff --git a/bench/src/target.ts b/bench/src/target.ts new file mode 100644 index 0000000..e0f0e37 --- /dev/null +++ b/bench/src/target.ts @@ -0,0 +1,26 @@ +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}`); +}); diff --git a/bench/index.ts b/bench/test.ts old mode 100644 new mode 100755 similarity index 71% rename from bench/index.ts rename to bench/test.ts index a9a0a0d..73aec86 --- a/bench/index.ts +++ b/bench/test.ts @@ -1,8 +1,9 @@ -import { bench, group, compact, summary, run } from "mitata"; +#!/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"; @@ -14,6 +15,7 @@ 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({ @@ -35,7 +37,6 @@ function createTargetServer(): Promise { res.end('{"ok":true}'); return; } - // Echo POST body const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { @@ -127,7 +128,17 @@ async function setupHttpProxy3(): Promise { }); } -// --- Helpers --- +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; @@ -182,40 +193,6 @@ function httpPost(port: number, body: string, path = "/"): Promise { }); } -// Lean versions for benchmarks (drain body, minimal allocations) -function benchGet(port: number): Promise { - return new Promise((resolve, reject) => { - const req = http.get(`http://127.0.0.1:${port}/`, (res) => { - res.resume(); - res.on("end", resolve); - }); - req.on("error", reject); - }); -} - -function benchPost(port: number, body: string): 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) => { - res.resume(); - res.on("end", resolve); - }, - ); - req.on("error", reject); - req.end(body); - }); -} - // --- Main --- async function main() { @@ -227,8 +204,8 @@ async function main() { const fastProxySetup = await setupFastProxy(); const fastifyApp = await setupFastifyProxy(); const httpProxy3Server = await setupHttpProxy3(); + const httpProxyLegacyServer = await setupHttpProxyLegacy(); - // --- Validation --- console.log("Validating proxy implementations..."); const proxies = [ @@ -237,6 +214,7 @@ async function main() { { 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; @@ -244,7 +222,6 @@ async function main() { for (const { name, port } of proxies) { const errors: string[] = []; - // GET: should return 200 with {"ok":true} const getRes = await httpGet(port); if (getRes.status !== 200) { errors.push(`GET status=${getRes.status}, expected 200`); @@ -253,7 +230,6 @@ async function main() { errors.push(`GET body=${JSON.stringify(getRes.body)}, expected '{"ok":true}'`); } - // POST small: should echo back the body const postSmall = await httpPost(port, SMALL_BODY); if (postSmall.status !== 200) { errors.push(`POST(1KB) status=${postSmall.status}, expected 200`); @@ -264,7 +240,6 @@ async function main() { ); } - // POST large: should echo back the body const postLarge = await httpPost(port, LARGE_BODY); if (postLarge.status !== 200) { errors.push(`POST(100KB) status=${postLarge.status}, expected 200`); @@ -286,67 +261,7 @@ async function main() { } } - if (!allValid) { - console.error("\nValidation failed — aborting benchmarks."); - process.exit(1); - } - console.log(""); - - // Warmup — ensure all connections are established - console.log("Warming up..."); - for (const { port } of proxies) { - for (let i = 0; i < 50; i++) { - await benchGet(port); - } - } - console.log("Running benchmarks...\n"); - - // --- GET (no body) --- - - compact(() => { - summary(() => { - group("GET proxy (no body)", () => { - bench("httpxy server", () => benchGet(HTTPXY_SERVER_PORT)); - bench("httpxy proxyFetch", () => benchGet(HTTPXY_FETCH_PORT)); - bench("fast-proxy", () => benchGet(FAST_PROXY_PORT)); - bench("@fastify/http-proxy", () => benchGet(FASTIFY_PROXY_PORT)); - bench("http-proxy-3", () => benchGet(HTTP_PROXY_3_PORT)); - }); - }); - }); - - // --- POST small body (~1KB) --- - - compact(() => { - summary(() => { - group("POST proxy (~1KB JSON body)", () => { - bench("httpxy server", () => benchPost(HTTPXY_SERVER_PORT, SMALL_BODY)); - bench("httpxy proxyFetch", () => benchPost(HTTPXY_FETCH_PORT, SMALL_BODY)); - bench("fast-proxy", () => benchPost(FAST_PROXY_PORT, SMALL_BODY)); - bench("@fastify/http-proxy", () => benchPost(FASTIFY_PROXY_PORT, SMALL_BODY)); - bench("http-proxy-3", () => benchPost(HTTP_PROXY_3_PORT, SMALL_BODY)); - }); - }); - }); - - // --- POST large body (~100KB) --- - - compact(() => { - summary(() => { - group("POST proxy (~100KB JSON body)", () => { - bench("httpxy server", () => benchPost(HTTPXY_SERVER_PORT, LARGE_BODY)); - bench("httpxy proxyFetch", () => benchPost(HTTPXY_FETCH_PORT, LARGE_BODY)); - bench("fast-proxy", () => benchPost(FAST_PROXY_PORT, LARGE_BODY)); - bench("@fastify/http-proxy", () => benchPost(FASTIFY_PROXY_PORT, LARGE_BODY)); - bench("http-proxy-3", () => benchPost(HTTP_PROXY_3_PORT, LARGE_BODY)); - }); - }); - }); - - await run(); - // Cleanup - console.log("\nShutting down servers..."); targetServer.close(); httpxyServer.close(); httpxyFetchServer.close(); @@ -354,6 +269,14 @@ async function main() { 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) => { diff --git a/package.json b/package.json index 3d0e1ce..ac1a77e 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "typecheck": "tsgo --noEmit" }, "devDependencies": { - "@fastify/http-proxy": "^11.4.2", "@types/async": "^3.2.25", "@types/concat-stream": "^2.0.3", "@types/express": "^5.0.6", @@ -39,10 +38,6 @@ "concat-stream": "^2.0.0", "eslint-config-unjs": "^0.6.2", "expect.js": "^0.3.1", - "fast-proxy": "^2.2.0", - "fastify": "^5.8.4", - "http-proxy-3": "^1.23.2", - "mitata": "^1.0.34", "obuild": "^0.4.32", "ofetch": "^1.5.1", "oxfmt": "^0.42.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7c8065..f95eb5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: devDependencies: - '@fastify/http-proxy': - specifier: ^11.4.2 - version: 11.4.2 '@types/async': specifier: ^3.2.25 version: 3.2.25 @@ -53,18 +50,6 @@ importers: expect.js: specifier: ^0.3.1 version: 0.3.1 - fast-proxy: - specifier: ^2.2.0 - version: 2.2.0 - fastify: - specifier: ^5.8.4 - version: 5.8.4 - http-proxy-3: - specifier: ^1.23.2 - version: 1.23.2 - mitata: - specifier: ^1.0.34 - version: 1.0.34 obuild: specifier: ^0.4.32 version: 0.4.32(@typescript/native-preview@7.0.0-dev.20260325.1)(chokidar@5.0.0)(dotenv@17.3.1)(giget@2.0.0)(jiti@2.6.1)(magicast@0.5.2)(picomatch@4.0.3)(rollup@4.57.1)(typescript@6.0.2) @@ -102,6 +87,30 @@ importers: specifier: ^8.20.0 version: 8.20.0 + bench: + devDependencies: + '@fastify/http-proxy': + specifier: ^11.4.2 + version: 11.4.2 + '@types/http-proxy': + specifier: ^1.17.17 + version: 1.17.17 + fast-proxy: + specifier: ^2.2.0 + version: 2.2.0 + fastify: + specifier: ^5.8.4 + version: 5.8.4 + http-proxy: + specifier: ^1.18.1 + version: 1.18.1 + http-proxy-3: + specifier: ^1.23.2 + version: 1.23.2 + mitata: + specifier: ^1.0.34 + version: 1.0.34 + packages: '@babel/generator@8.0.0-rc.2': @@ -955,6 +964,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/http-proxy@1.17.17': + resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} + '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} @@ -1487,6 +1499,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1620,6 +1635,10 @@ packages: resolution: {integrity: sha512-vZks1dLliM0w7aQDT9eFYLO8PUuQ9Cm67y7kn+kgkLtvKP0HZ6Thb3+MCGFFNCnKMCkLXY6rvIH1d7jQITryxA==} engines: {node: '>=18'} + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2082,6 +2101,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3041,6 +3063,10 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/http-proxy@1.17.17': + dependencies: + '@types/node': 25.5.0 + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} @@ -3675,6 +3701,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@4.0.7: {} + expect-type@1.3.0: {} expect.js@0.3.1: {} @@ -3814,6 +3842,14 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.11(debug@4.4.3) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4481,6 +4517,8 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resolve-pkg-maps@1.0.0: {} ret@0.5.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..49250f3 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - bench From 2e697688b57f8b547774435d29df6a3ba634d7eb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 26 Mar 2026 10:12:36 +0100 Subject: [PATCH 09/16] update bench script --- bench/bench.ts | 294 ++++++++++++++++++++++++++----------------------- 1 file changed, 156 insertions(+), 138 deletions(-) diff --git a/bench/bench.ts b/bench/bench.ts index 816e0cc..391d1f3 100755 --- a/bench/bench.ts +++ b/bench/bench.ts @@ -1,12 +1,25 @@ #!/usr/bin/env node -import { execSync, execFileSync } from "node:child_process"; +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: false }, + }, +}); + const IMAGE = "httpxy-bench"; -const DURATION = process.env.DURATION || "1s"; -const CONNECTIONS = process.env.CONNECTIONS || "128"; +const DURATION = args.duration!; +const CONNECTIONS = Number(args.connections); +const SEQUENTIAL = args.sequential!; const POST_BODY = '{"message":"hello world","ts":1234567890}'; const TARGET_PORT = 3000; @@ -24,12 +37,11 @@ const PROXIES = [ const bold = (s: string) => `\x1B[1m${s}\x1B[0m`; 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 yellow = (s: string) => `\x1B[1;33m${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 err = (msg: string) => console.log(red(` ${msg}`)); const containers: string[] = []; @@ -43,68 +55,53 @@ function cleanup() { } catch {} } -function startContainer(name: string, script: string, port: number) { - const cid = execFileSync( - "docker", - [ - "run", - "-d", - "--rm", - "--name", - name, - "--network", - "host", - "--cpus=1", - "--memory=256m", - "-e", - `PORT=${port}`, - "-e", - `TARGET=http://127.0.0.1:${TARGET_PORT}`, - IMAGE, - "node", - script, - ], - { encoding: "utf8" }, - ).trim(); - containers.push(cid); +function dockerRun(...args: string[]) { + return execFileSync("docker", ["run", "--rm", "--network", "host", ...args], { + encoding: "utf8", + }).trim(); } -function bomb(args: string[]) { - execFileSync( - "docker", - [ - "run", - "--rm", - "--name", - `bench-bombardier-${process.pid}`, - "--network", - "host", - IMAGE, - "bombardier", - ...args, - ], - { stdio: "inherit" }, +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); } -function bombJson(args: string[]): string { - return execFileSync( +let bombCounter = 0; + +async function bombJson(args: string[]): Promise { + const name = `bench-bombardier-${process.pid}-${bombCounter++}`; + const { stdout } = await execFileAsync( "docker", [ "run", "--rm", - "--name", - `bench-bombardier-${process.pid}`, "--network", "host", + "--name", + name, IMAGE, "bombardier", "--format=json", "--print=result", + "--latencies", ...args, ], - { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }, + { encoding: "utf8" }, ); + return stdout; } function waitForReady(port: number, retries = 60) { @@ -119,57 +116,90 @@ function waitForReady(port: number, retries = 60) { 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 parseResult(json: string): string { - const j = JSON.parse(json); - const r = j.result; - const rps = r.rps.mean.toFixed(0); - const avgLatency = formatNs(r.latency.mean); - const p50 = formatNs(r.latency?.percentiles?.["0.5"] ?? r.latency?.["50"] ?? 0); - const p99 = formatNs(r.latency?.percentiles?.["0.99"] ?? r.latency?.["99"] ?? 0); - const bytesPerSec = r.bytesRead / r.timeTakenSeconds; - const throughput = - bytesPerSec > 1e6 - ? `${(bytesPerSec / 1e6).toFixed(1)}MB/s` - : `${(bytesPerSec / 1e3).toFixed(0)}KB/s`; - return `${rps}|${avgLatency}|${p50}|${p99}|${throughput}|${bytesPerSec}`; +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 }; + 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, + }; } -function printTable(title: string, results: [name: string, result: string][]) { +const TABLE_COLS = [22, 10, 7, 10, 10, 10, 12] as const; +const TABLE_WIDTH = TABLE_COLS.reduce((sum, w) => sum + w, 0) + TABLE_COLS.length - 1; + +function printTable(title: string, results: [name: string, result: BenchResult][]) { console.log(); console.log(bold(title)); console.log( - `${"Proxy".padEnd(22)} ${"Req/s".padStart(10)} ${"Scale".padStart(7)} ${"Avg".padStart(10)} ${"P50".padStart(10)} ${"P99".padStart(10)} ${"Throughput".padStart(12)}`, - ); - console.log( - `${"─".repeat(22)} ${"─".repeat(10)} ${"─".repeat(7)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(12)}`, + `${"Proxy".padEnd(TABLE_COLS[0])} ${"Req/s".padStart(TABLE_COLS[1])} ${"Scale".padStart(TABLE_COLS[2])} ${"Avg".padStart(TABLE_COLS[3])} ${"P50".padStart(TABLE_COLS[4])} ${"P99".padStart(TABLE_COLS[5])} ${"Throughput".padStart(TABLE_COLS[6])}`, ); + console.log(TABLE_COLS.map((w) => "─".repeat(w)).join(" ")); - // Sort by throughput (bytes/sec) descending - results.sort((a, b) => { - const aTP = Number.parseFloat(a[1].split("|")[5]!); - const bTP = Number.parseFloat(b[1].split("|")[5]!); - return bTP - aTP; - }); - - let bestRps = 0; - for (const [, result] of results) { - const rps = Number.parseInt(result.split("|")[0]); - if (rps > bestRps) bestRps = rps; - } + // Sort by req/s descending (matches Scale column) + results.sort((a, b) => b[1].rps - a[1].rps); + + const bestRps = Math.max(...results.map(([, r]) => r.rps)); - for (const [name, result] of results) { - const parts = result.split("|"); - const [rps, avg, p50, p99, tp] = [parts[0]!, parts[1]!, parts[2]!, parts[3]!, parts[4]!]; - const ratio = bestRps > 0 ? Number.parseInt(rps) / bestRps : 0; - const x = ratio >= 1 ? "1.00x" : `${ratio.toFixed(2)}x`; + for (const [name, r] of results) { + const rps = r.rps.toFixed(0); + const ratio = bestRps > 0 ? r.rps / bestRps : 0; + const scale = ratio >= 1 ? "1.00x" : `${ratio.toFixed(2)}x`; console.log( - `${name.padEnd(22)} ${rps.padStart(10)} ${x.padStart(7)} ${avg.padStart(10)} ${p50.padStart(10)} ${p99.padStart(10)} ${tp.padStart(12)}`, + `${name.padEnd(TABLE_COLS[0])} ${rps.padStart(TABLE_COLS[1])} ${scale.padStart(TABLE_COLS[2])} ${formatNs(r.avgLatency).padStart(TABLE_COLS[3])} ${formatNs(r.p50).padStart(TABLE_COLS[4])} ${formatNs(r.p99).padStart(TABLE_COLS[5])} ${formatThroughput(r.bytesPerSec).padStart(TABLE_COLS[6])}`, ); } } @@ -210,65 +240,53 @@ for (const { name, port } of PROXIES) { } console.log(); -// --- GET benchmark --- - -const getResults: [string, string][] = []; - -info(`Benchmarking GET (duration=${DURATION}, connections=${CONNECTIONS})`); -console.log("━".repeat(63)); -for (const { name, port } of PROXIES) { - console.log(`\n${yellow(`▸ ${name}`)}`); - bomb(["-c", CONNECTIONS, "-d", DURATION, "--latencies", `http://127.0.0.1:${port}/`]); - const json = bombJson(["-c", CONNECTIONS, "-d", DURATION, `http://127.0.0.1:${port}/`]); - getResults.push([name, parseResult(json)]); +async function runBench(label: string, extraArgs: string[] = []) { + info( + `Benchmarking ${label} (duration=${DURATION}, connections=${CONNECTIONS}${SEQUENTIAL ? ", sequential" : ""})`, + ); + console.log("━".repeat(TABLE_WIDTH)); + 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; } -console.log(); - -// --- POST benchmark --- - -const postResults: [string, string][] = []; - -info(`Benchmarking POST ~1KB JSON (duration=${DURATION}, connections=${CONNECTIONS})`); -console.log("━".repeat(63)); -for (const { name, port } of PROXIES) { - console.log(`\n${yellow(`▸ ${name}`)}`); - bomb([ - "-c", - CONNECTIONS, - "-d", - DURATION, - "-m", - "POST", - "-H", - "Content-Type: application/json", - "-b", - POST_BODY, - "--latencies", - `http://127.0.0.1:${port}/`, - ]); - const json = bombJson([ - "-c", - CONNECTIONS, - "-d", - DURATION, - "-m", - "POST", - "-H", - "Content-Type: application/json", - "-b", - POST_BODY, - `http://127.0.0.1:${port}/`, - ]); - postResults.push([name, parseResult(json)]); -} +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(); -console.log("━".repeat(63)); +console.log("━".repeat(TABLE_WIDTH)); info("Summary"); -console.log("━".repeat(63)); +console.log("━".repeat(TABLE_WIDTH)); printTable("GET (no body)", getResults); console.log(); From 79890124edfc86d8beb3648936b4522bb70e60bf Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 26 Mar 2026 10:18:06 +0100 Subject: [PATCH 10/16] update bench --- bench/bench.ts | 55 ++++++++++++++++++++++++++++----------------- bench/src/target.ts | 3 +-- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/bench/bench.ts b/bench/bench.ts index 391d1f3..d7c3669 100755 --- a/bench/bench.ts +++ b/bench/bench.ts @@ -34,7 +34,6 @@ const PROXIES = [ // --- Helpers --- -const bold = (s: string) => `\x1B[1m${s}\x1B[0m`; 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`; @@ -178,29 +177,44 @@ function parseResult(json: string): BenchResult { }; } -const TABLE_COLS = [22, 10, 7, 10, 10, 10, 12] as const; -const TABLE_WIDTH = TABLE_COLS.reduce((sum, w) => sum + w, 0) + TABLE_COLS.length - 1; +const HEADERS = ["Proxy", "Req/s", "Scale", "Avg", "P50", "P99", "Throughput"]; function printTable(title: string, results: [name: string, result: BenchResult][]) { - console.log(); - console.log(bold(title)); - console.log( - `${"Proxy".padEnd(TABLE_COLS[0])} ${"Req/s".padStart(TABLE_COLS[1])} ${"Scale".padStart(TABLE_COLS[2])} ${"Avg".padStart(TABLE_COLS[3])} ${"P50".padStart(TABLE_COLS[4])} ${"P99".padStart(TABLE_COLS[5])} ${"Throughput".padStart(TABLE_COLS[6])}`, - ); - console.log(TABLE_COLS.map((w) => "─".repeat(w)).join(" ")); - - // Sort by req/s descending (matches Scale column) + // Sort by req/s descending results.sort((a, b) => b[1].rps - a[1].rps); - const bestRps = Math.max(...results.map(([, r]) => r.rps)); - for (const [name, r] of results) { - const rps = r.rps.toFixed(0); + // Build rows + const rows = results.map(([name, r]) => { const ratio = bestRps > 0 ? r.rps / bestRps : 0; - const scale = ratio >= 1 ? "1.00x" : `${ratio.toFixed(2)}x`; - console.log( - `${name.padEnd(TABLE_COLS[0])} ${rps.padStart(TABLE_COLS[1])} ${scale.padStart(TABLE_COLS[2])} ${formatNs(r.avgLatency).padStart(TABLE_COLS[3])} ${formatNs(r.p50).padStart(TABLE_COLS[4])} ${formatNs(r.p99).padStart(TABLE_COLS[5])} ${formatThroughput(r.bytesPerSec).padStart(TABLE_COLS[6])}`, - ); + 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)); } } @@ -244,7 +258,6 @@ async function runBench(label: string, extraArgs: string[] = []) { info( `Benchmarking ${label} (duration=${DURATION}, connections=${CONNECTIONS}${SEQUENTIAL ? ", sequential" : ""})`, ); - console.log("━".repeat(TABLE_WIDTH)); const benchOne = async ({ name, port }: (typeof PROXIES)[number]) => { const json = await bombJson([ "-c", @@ -284,9 +297,9 @@ const postResults = await runBench("POST ~1KB JSON", [ // --- Summary --- console.log(); -console.log("━".repeat(TABLE_WIDTH)); info("Summary"); -console.log("━".repeat(TABLE_WIDTH)); +console.log(); +console.log(`> Duration: **${DURATION}** | Connections: **${CONNECTIONS}** | Mode: **${SEQUENTIAL ? "sequential" : "parallel"}**`); printTable("GET (no body)", getResults); console.log(); diff --git a/bench/src/target.ts b/bench/src/target.ts index e0f0e37..93bf614 100644 --- a/bench/src/target.ts +++ b/bench/src/target.ts @@ -13,8 +13,7 @@ const server = http.createServer((req, res) => { req.on("end", () => { const body = Buffer.concat(chunks); res.writeHead(200, { - "content-type": - req.headers["content-type"] || "application/octet-stream", + "content-type": req.headers["content-type"] || "application/octet-stream", "content-length": String(body.length), }); res.end(body); From 0019748658ccf4de8bd5cdc684dcfbfd56ab243b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 26 Mar 2026 10:18:28 +0100 Subject: [PATCH 11/16] fmt --- bench/bench.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bench/bench.ts b/bench/bench.ts index d7c3669..45e024e 100755 --- a/bench/bench.ts +++ b/bench/bench.ts @@ -199,9 +199,7 @@ function printTable(title: string, results: [name: string, result: BenchResult][ }); // Compute column widths from headers + data - const colWidths = HEADERS.map((h, i) => - Math.max(h.length, ...rows.map((r) => r[i]!.length)), - ); + 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(" | ")} |`; @@ -299,7 +297,9 @@ const postResults = await runBench("POST ~1KB JSON", [ console.log(); info("Summary"); console.log(); -console.log(`> Duration: **${DURATION}** | Connections: **${CONNECTIONS}** | Mode: **${SEQUENTIAL ? "sequential" : "parallel"}**`); +console.log( + `> Duration: **${DURATION}** | Connections: **${CONNECTIONS}** | Mode: **${SEQUENTIAL ? "sequential" : "parallel"}**`, +); printTable("GET (no body)", getResults); console.log(); From 5332bef8a9773a8b9cef866c6a31241f86798b1b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 26 Mar 2026 10:46:11 +0100 Subject: [PATCH 12/16] perf: fix header merge regression, stream request bodies, remove URL cache - Restore original header merge order in setupOutgoing so options.headers still overrides :authority host for HTTP/2 requests - Stream request bodies in proxyFetch instead of buffering (buffer only when followRedirects is enabled for 307/308 replay) - Remove URL parse cache (cloning is slower than parsing, mutable cache is fragile) - Document default keep-alive agent and agent: false opt-out in README --- README.md | 54 +++++++++++++++++++++++++-------------------------- src/_utils.ts | 42 +++++++++------------------------------ src/fetch.ts | 45 ++++++++++++++++++++++++++---------------- src/server.ts | 3 +-- 4 files changed, 65 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 3185bbe..d8987a7 100644 --- a/README.md +++ b/README.md @@ -106,33 +106,33 @@ server.listen(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` | — | Object passed to `http(s).request` for connection pooling | -| `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 | +| 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 diff --git a/src/_utils.ts b/src/_utils.ts index 33d936e..4e67d5e 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -14,27 +14,6 @@ export const defaultAgents = { https: new httpsNative.Agent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 64 }), }; -/** - * URL parse cache to avoid repeated `new URL()` for the same string targets. - */ -const _urlCache = new Map(); -const _urlCacheMaxSize = 256; - -export function parseURL(str: string): URL { - let cached = _urlCache.get(str); - if (cached) { - return cached; - } - cached = new URL(str); - if (_urlCache.size >= _urlCacheMaxSize) { - // Evict oldest entry - const firstKey = _urlCache.keys().next().value!; - _urlCache.delete(firstKey); - } - _urlCache.set(str, cached); - return cached; -} - /** * Simple Regex for testing if protocol is https */ @@ -102,22 +81,19 @@ export function setupOutgoing( } outgoing.method = options.method || req.method; - - // Single copy: merge req.headers + options.headers in one pass - if (options.headers) { - const headers: Record = { ...req.headers }; - for (const key of Object.keys(options.headers)) { - headers[key] = options.headers[key]; - } - outgoing.headers = headers; - } else { - outgoing.headers = { ...req.headers }; - } + 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 @@ -341,7 +317,7 @@ export function parseAddr(addr: string | ProxyAddr): ProxyAddr { if (addr.startsWith("unix:")) { return { socketPath: addr.slice(5) }; } - const url = parseURL(addr); + const url = new URL(addr); return { host: url.hostname, port: Number(url.port) || (isSSL.test(url.protocol) ? 443 : 80), diff --git a/src/fetch.ts b/src/fetch.ts index b5904ab..5b1470e 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -3,7 +3,7 @@ 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, parseURL } from "./_utils.ts"; +import { defaultAgents, isSSL, joinURL, parseAddr } from "./_utils.ts"; /** * Options for {@link proxyFetch}. @@ -67,7 +67,7 @@ export async function proxyFetch( let useHTTPS = false; let addrBasePath = ""; if (typeof addr === "string" && !addr.startsWith("unix:")) { - const addrURL = parseURL(addr); + const addrURL = new URL(addr); useHTTPS = isSSL.test(addrURL.protocol); if (addrURL.pathname && addrURL.pathname !== "/") { addrBasePath = addrURL.pathname; @@ -166,9 +166,8 @@ export async function proxyFetch( ? 5 : 0; - // Fast path: sync conversion for string/ArrayBuffer/TypedArray bodies - // Falls back to async _bufferBody for ReadableStream/Blob - const body = _toBuffer(init.body) ?? (await _bufferBody(init.body)); + // 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 = @@ -237,8 +236,8 @@ function toInit(init?: RequestInit | Request): RequestInit | undefined { return init; } -/** Synchronous body conversion for non-stream types. Returns undefined for streams. */ -function _toBuffer(body: BodyInit | null | undefined): Buffer | undefined { +/** 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; } @@ -248,16 +247,25 @@ function _toBuffer(body: BodyInit | null | undefined): Buffer | undefined { if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { return Buffer.from(body as ArrayBuffer); } - // ReadableStream / Blob: fall through — caller should use _bufferBody for these - return undefined; + 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), including async types. */ +/** Normalize any body type to Buffer (or undefined) for redirect replay. */ async function _bufferBody(body: BodyInit | null | undefined): Promise { - // Try sync first - const buf = _toBuffer(body); - if (buf || !body) { - return buf; + 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); @@ -270,7 +278,7 @@ async function _bufferBody(body: BodyInit | null | undefined): Promise, addr: ProxyAddr, - body: Buffer | undefined, + body: Buffer | Readable | undefined, opts: _RequestOpts, ): Promise { return new Promise((resolve, reject) => { @@ -377,7 +385,10 @@ function _sendRequest( }); } - if (body) { + if (body instanceof Readable) { + body.on("error", reject); + body.pipe(req); + } else if (body) { req.end(body); } else { req.end(); diff --git a/src/server.ts b/src/server.ts index 6a89ea3..aed7cef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,7 +7,6 @@ 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"; -import { parseURL } from "./_utils.ts"; export interface ProxyServerEventMap< Req extends http.IncomingMessage | http2.Http2ServerRequest = http.IncomingMessage, @@ -199,7 +198,7 @@ function _createProxyFn< for (const key of ["target", "forward"] as const) { if (typeof requestOptions[key] === "string") { - requestOptions[key] = parseURL(requestOptions[key]); + requestOptions[key] = new URL(requestOptions[key]); } } From 9016c6c0da60d1dc32697afd0badc2fc94f275ea Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 26 Mar 2026 10:58:17 +0100 Subject: [PATCH 13/16] refactor(fetch): deduplicate header parsing fast paths Unify Headers and Array branches into single iterable loop. --- src/fetch.ts | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 5b1470e..2935e18 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -101,32 +101,21 @@ export async function proxyFetch( const reqHeaders: Record = {}; if (init.headers) { - // Fast path: plain object headers (most common from programmatic use) - if (init.headers instanceof Headers) { - for (const [key, value] of init.headers) { - if (key in reqHeaders) { - const existing = reqHeaders[key]; - reqHeaders[key] = Array.isArray(existing) - ? [...existing, value] - : [existing as string, value]; - } else { + // 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 if (Array.isArray(init.headers)) { - for (const [key, value] of init.headers) { - if (key in reqHeaders) { - const existing = reqHeaders[key]; + } else { reqHeaders[key] = Array.isArray(existing) ? [...existing, value] - : [existing as string, value]; - } else { - reqHeaders[key] = value; + : [existing, value]; } } - } else { - // Record — direct assign, no iteration needed - Object.assign(reqHeaders, init.headers); } } From b0ad03c13fe1c7ef79208472dfd707d8da4a9c05 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:58:46 +0000 Subject: [PATCH 14/16] chore: apply automated updates --- src/fetch.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 2935e18..df843b9 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -111,9 +111,7 @@ export async function proxyFetch( if (existing === undefined) { reqHeaders[key] = value; } else { - reqHeaders[key] = Array.isArray(existing) - ? [...existing, value] - : [existing, value]; + reqHeaders[key] = Array.isArray(existing) ? [...existing, value] : [existing, value]; } } } From d980476839e0db9255c1f045bbcd3bf83f1b8fb7 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 26 Mar 2026 11:12:26 +0100 Subject: [PATCH 15/16] fix(fetch): destroy outgoing request on readable body stream error Ensures the socket is properly closed when the request body stream errors. --- src/fetch.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/fetch.ts b/src/fetch.ts index df843b9..9996029 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -373,7 +373,10 @@ function _sendRequest( } if (body instanceof Readable) { - body.on("error", reject); + body.on("error", (err) => { + req.destroy(err); + reject(err); + }); body.pipe(req); } else if (body) { req.end(body); From d9d81c5dc088d3d2cca479f671113a1de799c1b9 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 26 Mar 2026 11:12:29 +0100 Subject: [PATCH 16/16] fix(bench): harden bench script defaults, payload, cleanup, and error detection - Default to sequential mode to avoid shared-target contention - Generate actual ~1KB JSON POST body matching the label - Scope cleanup to only tracked container IDs - Fail on non-2xx responses and transport errors in parseResult --- bench/bench.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/bench/bench.ts b/bench/bench.ts index 45e024e..d843dde 100755 --- a/bench/bench.ts +++ b/bench/bench.ts @@ -12,7 +12,7 @@ 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: false }, + sequential: { type: "boolean", short: "s", default: true }, }, }); @@ -20,7 +20,11 @@ const IMAGE = "httpxy-bench"; const DURATION = args.duration!; const CONNECTIONS = Number(args.connections); const SEQUENTIAL = args.sequential!; -const POST_BODY = '{"message":"hello world","ts":1234567890}'; +const POST_BODY = JSON.stringify({ + message: "hello world".repeat(30), + ts: 1234567890, + padding: "x".repeat(1024 - 360), +}); // ~1KB const TARGET_PORT = 3000; const PROXIES = [ @@ -46,12 +50,11 @@ const containers: string[] = []; function cleanup() { info("Cleaning up..."); + if (containers.length === 0) return; try { - const ids = execSync('docker ps -q --filter "name=bench-"', { encoding: "utf8" }).trim(); - if (ids) { - execSync(`docker rm -f ${ids.split("\n").join(" ")}`, { stdio: "ignore" }); - } + execSync(`docker rm -f ${containers.join(" ")}`, { stdio: "ignore" }); } catch {} + containers.length = 0; } function dockerRun(...args: string[]) { @@ -168,6 +171,18 @@ function formatResult(r: BenchResult): string { 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,