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/README.md b/README.md index b275e39..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 @@ -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 ๐Ÿ’› 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..d843dde --- /dev/null +++ b/bench/bench.ts @@ -0,0 +1,323 @@ +#!/usr/bin/env node + +import { execSync, execFileSync, execFile as _execFile } from "node:child_process"; +import { parseArgs } from "node:util"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(_execFile); + +// --- Config --- + +const { values: args } = parseArgs({ + options: { + duration: { type: "string", short: "d", default: "1s" }, + connections: { type: "string", short: "c", default: "128" }, + sequential: { type: "boolean", short: "s", default: true }, + }, +}); + +const IMAGE = "httpxy-bench"; +const DURATION = args.duration!; +const CONNECTIONS = Number(args.connections); +const SEQUENTIAL = args.sequential!; +const POST_BODY = JSON.stringify({ + message: "hello world".repeat(30), + ts: 1234567890, + padding: "x".repeat(1024 - 360), +}); // ~1KB +const TARGET_PORT = 3000; + +const PROXIES = [ + { name: "httpxy.server", script: "bench/src/httpxy-server.ts", port: 3001 }, + { name: "httpxy.proxyFetch", script: "bench/src/httpxy-fetch.ts", port: 3002 }, + { name: "fast-proxy", script: "bench/src/fast-proxy.ts", port: 3003 }, + { name: "@fastify/http-proxy", script: "bench/src/fastify.ts", port: 3004 }, + { name: "http-proxy-3", script: "bench/src/http-proxy-3.ts", port: 3005 }, + { name: "http-proxy", script: "bench/src/http-proxy.ts", port: 3006 }, +]; + +// --- Helpers --- + +const blue = (s: string) => `\x1B[1;34m${s}\x1B[0m`; +const green = (s: string) => `\x1B[1;32m${s}\x1B[0m`; +// const red = (s: string) => `\x1B[1;31m${s}\x1B[0m`; + +const info = (msg: string) => console.log(blue(`=> ${msg}`)); +const ok = (msg: string) => console.log(green(` ${msg}`)); +// const err = (msg: string) => console.log(red(` ${msg}`)); + +const containers: string[] = []; + +function cleanup() { + info("Cleaning up..."); + if (containers.length === 0) return; + try { + execSync(`docker rm -f ${containers.join(" ")}`, { stdio: "ignore" }); + } catch {} + containers.length = 0; +} + +function dockerRun(...args: string[]) { + return execFileSync("docker", ["run", "--rm", "--network", "host", ...args], { + encoding: "utf8", + }).trim(); +} + +function startContainer(name: string, script: string, port: number) { + const cid = dockerRun( + "-d", + "--name", + name, + "--cpus=1", + "--memory=256m", + "-e", + `PORT=${port}`, + "-e", + `TARGET=http://127.0.0.1:${TARGET_PORT}`, + IMAGE, + "node", + script, + ); + containers.push(cid); +} + +let bombCounter = 0; + +async function bombJson(args: string[]): Promise { + const name = `bench-bombardier-${process.pid}-${bombCounter++}`; + const { stdout } = await execFileAsync( + "docker", + [ + "run", + "--rm", + "--network", + "host", + "--name", + name, + IMAGE, + "bombardier", + "--format=json", + "--print=result", + "--latencies", + ...args, + ], + { encoding: "utf8" }, + ); + return stdout; +} + +function waitForReady(port: number, retries = 60) { + for (let i = 0; i < retries; i++) { + try { + execSync(`curl -sf -o /dev/null http://127.0.0.1:${port}/`, { stdio: "ignore" }); + return; + } catch { + execSync("sleep 0.3", { stdio: "ignore" }); + } + } + throw new Error(`Timed out waiting for port ${port}`); +} + +// --- Bombardier types --- + +interface BombardierResult { + bytesRead: number; + bytesWritten: number; + timeTakenSeconds: number; + req1xx: number; + req2xx: number; + req3xx: number; + req4xx: number; + req5xx: number; + others: number; + errors?: { description: string; count: number }[]; + latency: { + mean: number; // nanoseconds + stddev: number; + max: number; + percentiles: Record; // "50", "75", "90", "95", "99" + }; + rps: { + mean: number; + stddev: number; + max: number; + percentiles: Record; + }; +} + +interface BenchResult { + rps: number; + avgLatency: number; // ns + p50: number; // ns + p99: number; // ns + bytesPerSec: number; +} + +// --- Result parsing --- + +function formatNs(ns: number): string { + return ns < 1e6 ? `${(ns / 1e3).toFixed(0)}ยตs` : `${(ns / 1e6).toFixed(2)}ms`; +} + +function formatThroughput(bytesPerSec: number): string { + return bytesPerSec > 1e6 + ? `${(bytesPerSec / 1e6).toFixed(1)}MB/s` + : `${(bytesPerSec / 1e3).toFixed(0)}KB/s`; +} + +function formatResult(r: BenchResult): string { + return `${r.rps.toFixed(0)} req/s | ${formatNs(r.avgLatency)} avg | ${formatThroughput(r.bytesPerSec)}`; +} + +function parseResult(json: string): BenchResult { + const { result: r } = JSON.parse(json) as { result: BombardierResult }; + + const nonOk = r.req1xx + r.req3xx + r.req4xx + r.req5xx + r.others; + if (nonOk > 0) { + throw new Error( + `Non-2xx responses: 1xx=${r.req1xx} 3xx=${r.req3xx} 4xx=${r.req4xx} 5xx=${r.req5xx} other=${r.others}`, + ); + } + if (r.errors && r.errors.length > 0) { + const details = r.errors.map((e) => `${e.description}(${e.count})`).join(", "); + throw new Error(`Transport errors: ${details}`); + } + + return { + rps: r.rps.mean, + avgLatency: r.latency.mean, + p50: r.latency.percentiles["50"] ?? 0, + p99: r.latency.percentiles["99"] ?? 0, + bytesPerSec: r.bytesRead / r.timeTakenSeconds, + }; +} + +const HEADERS = ["Proxy", "Req/s", "Scale", "Avg", "P50", "P99", "Throughput"]; + +function printTable(title: string, results: [name: string, result: BenchResult][]) { + // Sort by req/s descending + results.sort((a, b) => b[1].rps - a[1].rps); + const bestRps = Math.max(...results.map(([, r]) => r.rps)); + + // Build rows + const rows = results.map(([name, r]) => { + const ratio = bestRps > 0 ? r.rps / bestRps : 0; + return [ + name, + r.rps.toFixed(0), + ratio >= 1 ? "1.00x" : `${ratio.toFixed(2)}x`, + formatNs(r.avgLatency), + formatNs(r.p50), + formatNs(r.p99), + formatThroughput(r.bytesPerSec), + ]; + }); + + // Compute column widths from headers + data + const colWidths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length))); + + const mdRow = (cells: string[]) => + `| ${cells.map((c, i) => (i === 0 ? c.padEnd(colWidths[i]!) : c.padStart(colWidths[i]!))).join(" | ")} |`; + + console.log(); + console.log(`### ${title}`); + console.log(); + console.log(mdRow(HEADERS)); + console.log( + `| ${colWidths.map((w, i) => (i === 0 ? `:${"-".repeat(w - 1)}` : `${"-".repeat(w - 1)}:`)).join(" | ")} |`, + ); + for (const row of rows) { + console.log(mdRow(row)); + } +} + +// --- Main --- + +process.on("exit", cleanup); +process.on("SIGINT", () => process.exit(1)); +process.on("SIGTERM", () => process.exit(1)); + +info("Running validation tests..."); +execSync("node bench/test.ts", { + stdio: "inherit", + cwd: `${import.meta.dirname}/..`, +}); +ok("All implementations valid"); + +info("Building image..."); +execSync(`docker build -t ${IMAGE} -f bench/Dockerfile .`, { + stdio: "inherit", + cwd: `${import.meta.dirname}/..`, +}); + +info("Starting target server..."); +startContainer("bench-target", "bench/src/target.ts", TARGET_PORT); +waitForReady(TARGET_PORT); +ok("target ready"); + +info("Starting proxy servers..."); +for (const { name, script, port } of PROXIES) { + const containerName = `bench-${name.replaceAll(" ", "-").replaceAll(/[@/]/g, "")}`; + startContainer(containerName, script, port); +} + +for (const { name, port } of PROXIES) { + waitForReady(port); + ok(`${name} ready`); +} +console.log(); + +async function runBench(label: string, extraArgs: string[] = []) { + info( + `Benchmarking ${label} (duration=${DURATION}, connections=${CONNECTIONS}${SEQUENTIAL ? ", sequential" : ""})`, + ); + const benchOne = async ({ name, port }: (typeof PROXIES)[number]) => { + const json = await bombJson([ + "-c", + String(CONNECTIONS), + "-d", + DURATION, + ...extraArgs, + `http://127.0.0.1:${port}/`, + ]); + const result = parseResult(json); + ok(`${name} โ€” ${formatResult(result)}`); + return [name, result] as [string, BenchResult]; + }; + let results: [string, BenchResult][]; + if (SEQUENTIAL) { + results = []; + for (const proxy of PROXIES) { + results.push(await benchOne(proxy)); + } + } else { + results = await Promise.all(PROXIES.map(benchOne)); + } + console.log(); + return results; +} + +const getResults = await runBench("GET"); +const postResults = await runBench("POST ~1KB JSON", [ + "-m", + "POST", + "-H", + "Content-Type: application/json", + "-b", + POST_BODY, +]); + +// --- Summary --- + +console.log(); +info("Summary"); +console.log(); +console.log( + `> Duration: **${DURATION}** | Connections: **${CONNECTIONS}** | Mode: **${SEQUENTIAL ? "sequential" : "parallel"}**`, +); + +printTable("GET (no body)", getResults); +console.log(); +printTable("POST (~1KB JSON)", postResults); +console.log(); +info("Done!"); 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..93bf614 --- /dev/null +++ b/bench/src/target.ts @@ -0,0 +1,25 @@ +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/test.ts b/bench/test.ts new file mode 100755 index 0000000..73aec86 --- /dev/null +++ b/bench/test.ts @@ -0,0 +1,285 @@ +#!/usr/bin/env node +import http from "node:http"; +import { createProxyServer, proxyFetch } from "../src/index.ts"; +import fastProxy from "fast-proxy"; +import { createProxyServer as createHttpProxy3 } from "http-proxy-3"; +import httpProxyLegacy from "http-proxy"; +import Fastify from "fastify"; +import httpProxy from "@fastify/http-proxy"; + +// --- Config --- + +const TARGET_PORT = 9_900; +const HTTPXY_SERVER_PORT = 9_901; +const HTTPXY_FETCH_PORT = 9_902; +const FAST_PROXY_PORT = 9_903; +const FASTIFY_PROXY_PORT = 9_904; +const HTTP_PROXY_3_PORT = 9_905; +const HTTP_PROXY_PORT = 9_906; + +const SMALL_BODY = JSON.stringify({ message: "hello world", ts: Date.now() }); +const LARGE_BODY = JSON.stringify({ + data: Array.from({ length: 1000 }, (_, i) => ({ + id: i, + name: `item-${i}`, + value: Math.random(), + tags: ["a", "b", "c"], + })), +}); + +// --- Target server (echo) --- + +function createTargetServer(): Promise { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + if (req.method === "GET") { + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"ok":true}'); + return; + } + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + res.writeHead(200, { + "content-type": req.headers["content-type"] || "application/octet-stream", + "content-length": String(Buffer.concat(chunks).length), + }); + res.end(Buffer.concat(chunks)); + }); + }); + server.listen(TARGET_PORT, () => resolve(server)); + }); +} + +// --- Proxy servers setup --- + +const TARGET = `http://127.0.0.1:${TARGET_PORT}`; + +async function setupHttpxyServer(): Promise { + const proxy = createProxyServer({ target: TARGET }); + const server = http.createServer((req, res) => { + proxy.web(req, res); + }); + return new Promise((resolve) => { + server.listen(HTTPXY_SERVER_PORT, () => resolve(server)); + }); +} + +function collectBody(req: http.IncomingMessage): Promise { + if (req.method === "GET" || req.method === "HEAD") { + return Promise.resolve(undefined); + } + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(chunks.length > 0 ? Buffer.concat(chunks) : undefined)); + }); +} + +async function setupHttpxyFetchServer(): Promise { + const server = http.createServer(async (req, res) => { + const body = await collectBody(req); + const response = await proxyFetch( + TARGET, + new URL(req.url!, `http://127.0.0.1:${HTTPXY_FETCH_PORT}`), + { + method: req.method, + headers: req.headers as HeadersInit, + body: body as any, + }, + ); + res.writeHead(response.status, Object.fromEntries(response.headers)); + if (response.body) { + for await (const chunk of response.body) { + res.write(chunk); + } + } + res.end(); + }); + return new Promise((resolve) => { + server.listen(HTTPXY_FETCH_PORT, () => resolve(server)); + }); +} + +async function setupFastProxy(): Promise<{ server: http.Server; close: () => void }> { + const { proxy, close } = fastProxy({ base: TARGET }); + const server = http.createServer((req, res) => { + proxy(req, res, req.url!, {}); + }); + return new Promise((resolve) => { + server.listen(FAST_PROXY_PORT, () => resolve({ server, close })); + }); +} + +async function setupFastifyProxy(): Promise> { + const app = Fastify(); + await app.register(httpProxy, { upstream: TARGET }); + await app.listen({ port: FASTIFY_PROXY_PORT }); + return app; +} + +async function setupHttpProxy3(): Promise { + const proxy = createHttpProxy3({ target: TARGET }); + const server = http.createServer((req, res) => { + proxy.web(req, res); + }); + return new Promise((resolve) => { + server.listen(HTTP_PROXY_3_PORT, () => resolve(server)); + }); +} + +async function setupHttpProxyLegacy(): Promise { + const proxy = httpProxyLegacy.createProxyServer({ target: TARGET }); + const server = http.createServer((req, res) => { + proxy.web(req, res); + }); + return new Promise((resolve) => { + server.listen(HTTP_PROXY_PORT, () => resolve(server)); + }); +} + +// --- HTTP helpers --- + +interface HttpResult { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function httpGet(port: number, path = "/"): Promise { + return new Promise((resolve, reject) => { + const req = http.get(`http://127.0.0.1:${port}${path}`, (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => + resolve({ + status: res.statusCode!, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }), + ); + }); + req.on("error", reject); + }); +} + +function httpPost(port: number, body: string, path = "/"): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port, + path, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => + resolve({ + status: res.statusCode!, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }), + ); + }, + ); + req.on("error", reject); + req.end(body); + }); +} + +// --- Main --- + +async function main() { + console.log("Starting servers..."); + + const targetServer = await createTargetServer(); + const httpxyServer = await setupHttpxyServer(); + const httpxyFetchServer = await setupHttpxyFetchServer(); + const fastProxySetup = await setupFastProxy(); + const fastifyApp = await setupFastifyProxy(); + const httpProxy3Server = await setupHttpProxy3(); + const httpProxyLegacyServer = await setupHttpProxyLegacy(); + + console.log("Validating proxy implementations..."); + + const proxies = [ + { name: "httpxy server", port: HTTPXY_SERVER_PORT }, + { name: "httpxy proxyFetch", port: HTTPXY_FETCH_PORT }, + { name: "fast-proxy", port: FAST_PROXY_PORT }, + { name: "@fastify/http-proxy", port: FASTIFY_PROXY_PORT }, + { name: "http-proxy-3", port: HTTP_PROXY_3_PORT }, + { name: "http-proxy", port: HTTP_PROXY_PORT }, + ]; + + let allValid = true; + + for (const { name, port } of proxies) { + const errors: string[] = []; + + const getRes = await httpGet(port); + if (getRes.status !== 200) { + errors.push(`GET status=${getRes.status}, expected 200`); + } + if (getRes.body !== '{"ok":true}') { + errors.push(`GET body=${JSON.stringify(getRes.body)}, expected '{"ok":true}'`); + } + + const postSmall = await httpPost(port, SMALL_BODY); + if (postSmall.status !== 200) { + errors.push(`POST(1KB) status=${postSmall.status}, expected 200`); + } + if (postSmall.body !== SMALL_BODY) { + errors.push( + `POST(1KB) body mismatch: got ${postSmall.body.length} bytes, expected ${SMALL_BODY.length}`, + ); + } + + const postLarge = await httpPost(port, LARGE_BODY); + if (postLarge.status !== 200) { + errors.push(`POST(100KB) status=${postLarge.status}, expected 200`); + } + if (postLarge.body !== LARGE_BODY) { + errors.push( + `POST(100KB) body mismatch: got ${postLarge.body.length} bytes, expected ${LARGE_BODY.length}`, + ); + } + + if (errors.length > 0) { + allValid = false; + console.log(` FAIL ${name}`); + for (const e of errors) { + console.log(` - ${e}`); + } + } else { + console.log(` OK ${name}`); + } + } + + // Cleanup + targetServer.close(); + httpxyServer.close(); + httpxyFetchServer.close(); + fastProxySetup.server.close(); + fastProxySetup.close(); + await fastifyApp.close(); + httpProxy3Server.close(); + httpProxyLegacyServer.close(); + + if (!allValid) { + console.error("\nValidation failed."); + process.exit(1); + } + + console.log("\nAll implementations valid."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6674de..f95eb5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,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': @@ -342,6 +366,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 +677,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} @@ -909,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==} @@ -1084,6 +1142,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 +1159,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 +1191,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 +1329,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 +1400,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==} @@ -1413,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'} @@ -1423,15 +1512,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 +1565,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 +1584,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 +1631,14 @@ 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'} + + 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'} @@ -1520,6 +1658,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 +1722,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 +1741,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 +1906,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 +1959,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 +2026,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 +2055,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 +2085,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 +2097,27 @@ 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'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + 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 +2160,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 +2208,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 +2236,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 +2264,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 +2291,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 +2329,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 +2454,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 +2684,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 +2879,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 @@ -2771,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': {} @@ -2987,6 +3283,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 +3296,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 +3307,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 +3332,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 +3470,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + core-js-compat@3.48.0: dependencies: browserslist: 4.28.1 @@ -3204,6 +3522,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 @@ -3379,18 +3701,72 @@ snapshots: esutils@2.0.3: {} + eventemitter3@4.0.7: {} + expect-type@1.3.0: {} expect.js@0.3.1: {} 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 +3779,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 +3799,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 +3835,21 @@ 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 + + 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: {} @@ -3459,6 +3860,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 +3909,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 +3928,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 +4283,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + mitata@1.0.34: {} + moment@2.30.1: {} mri@1.2.0: {} @@ -3929,6 +4346,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 +4438,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 +4476,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 +4507,26 @@ 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: {} + + requires-port@1.0.0: {} + 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 +4615,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 +4678,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 +4711,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 +4733,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 +4752,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 +4784,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 +4874,8 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + ws@8.18.3: {} ws@8.20.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 diff --git a/src/_utils.ts b/src/_utils.ts index b1f8ad8..4e67d5e 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -6,6 +6,14 @@ import type { Http2ServerRequest } from "node:http2"; const upgradeHeader = /(^|,)\s*upgrade\s*($|,)/i; +/** + * Default keep-alive agents for connection reuse. + */ +export const defaultAgents = { + http: new httpNative.Agent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 64 }), + https: new httpsNative.Agent({ keepAlive: true, maxSockets: 256, maxFreeSockets: 64 }), +}; + /** * Simple Regex for testing if protocol is https */ @@ -82,7 +90,9 @@ export function setupOutgoing( // host override must happen before composing/merging the final outgoing headers if (options.headers) { - outgoing.headers = { ...outgoing.headers, ...options.headers }; + for (const key of Object.keys(options.headers)) { + outgoing.headers[key] = options.headers[key]; + } } if (req.httpVersionMajor > 1) { @@ -104,7 +114,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; // diff --git a/src/fetch.ts b/src/fetch.ts index ba01385..9996029 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 { defaultAgents, isSSL, joinURL, parseAddr } from "./_utils.ts"; /** * Options for {@link proxyFetch}. @@ -101,17 +101,18 @@ 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) { + // 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]; - reqHeaders[key] = Array.isArray(existing) - ? [...existing, value] - : [existing as string, value]; - } else { - reqHeaders[key] = value; + if (existing === undefined) { + reqHeaders[key] = value; + } else { + reqHeaders[key] = Array.isArray(existing) ? [...existing, value] : [existing, value]; + } } } } @@ -152,16 +153,27 @@ export async function proxyFetch( ? 5 : 0; + // Buffer body only when redirects need replay; otherwise stream through + const body = maxRedirects > 0 ? await _bufferBody(init.body) : _toNodeStream(init.body); + + // Default to keep-alive agent for connection reuse + const agent = + opts?.agent !== undefined + ? opts.agent || false + : useHTTPS + ? defaultAgents.https + : defaultAgents.http; + const res = await _sendRequest( useHTTPS ? httpsRequest : httpRequest, init.method || "GET", path, reqHeaders, resolvedAddr, - await _bufferBody(init.body), + body, { signal: init.signal || undefined, - agent: opts?.agent, + agent, timeout: opts?.timeout, ssl: opts?.ssl, maxRedirects, @@ -170,26 +182,27 @@ 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 +223,37 @@ function toInit(init?: RequestInit | Request): RequestInit | undefined { return init; } -/** Normalize any body type to Buffer (or 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; + } + if (typeof body === "string") { + return Buffer.from(body); + } + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + return Buffer.from(body as ArrayBuffer); + } + if (body instanceof ReadableStream) { + return Readable.fromWeb(body as import("node:stream/web").ReadableStream); + } + if (body instanceof Blob) { + return Readable.fromWeb(body.stream() as import("node:stream/web").ReadableStream); + } + return Buffer.from(String(body)); +} + +/** Normalize any body type to Buffer (or undefined) for redirect replay. */ async function _bufferBody(body: BodyInit | null | undefined): Promise { if (!body) { return undefined; } + if (typeof body === "string") { + return Buffer.from(body); + } + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + return Buffer.from(body as ArrayBuffer); + } if (body instanceof ReadableStream) { const readable = Readable.fromWeb(body as import("node:stream/web").ReadableStream); const chunks: Buffer[] = []; @@ -223,13 +262,10 @@ 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) => { @@ -258,7 +294,7 @@ function _sendRequest( method, path, headers, - agent: opts.agent ?? false, + agent: opts.agent, }; if (addr.socketPath) { @@ -336,7 +372,13 @@ function _sendRequest( }); } - if (body) { + if (body instanceof Readable) { + body.on("error", (err) => { + req.destroy(err); + reject(err); + }); + body.pipe(req); + } else if (body) { req.end(body); } else { req.end(); 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); });