diff --git a/README.md b/README.md index 7a823e8..bab2631 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Contributors: August 21, 2025 STATUS compared to [http-proxy](https://www.npmjs.com/package/http-proxy) and [httpxy](https://www.npmjs.com/package/httpxy): - Library entirely rewritten in Typescript in a modern style, with many typings added internally and strict mode enabled. +- **HTTP/2 Support**: Full HTTP/2 support via [undici](https://github.com/nodejs/undici) with callback-based request/response lifecycle hooks. - All dependent packages updated to latest versions, addressing all security vulnerabilities according to `pnpm audit`. - Code rewritten to not use deprecated/insecure API's, e.g., using `URL` instead of `parse`. - Fixed socket leaks in the Websocket proxy code, going beyond [http-proxy-node16](https://www.npmjs.com/package/http-proxy-node16) to also instrument and logging socket counts. Also fixed an issue with uncatchable errors when using websockets. @@ -89,7 +90,9 @@ This is the original user's guide, but with various updates. - [Setup a stand-alone proxy server with latency](#setup-a-stand-alone-proxy-server-with-latency) - [Using HTTPS](#using-https) - [Proxying WebSockets](#proxying-websockets) + - [HTTP/2 Support with Undici](#http2-support-with-undici) - [Options](#options) +- [Configuration Compatibility](#configuration-compatibility) - [Listening for proxy events](#listening-for-proxy-events) - [Shutdown](#shutdown) - [Miscellaneous](#miscellaneous) @@ -115,6 +118,10 @@ import { createProxyServer } from "http-proxy-3"; const proxy = createProxyServer(options); // See below ``` +http-proxy-3 supports two request processing paths: +- **Native Path**: Uses Node.js native `http`/`https` modules (default) +- **Undici Path**: Uses [undici](https://github.com/nodejs/undici) for HTTP/2 support (when `undici` option is provided) + Unless listen(..) is invoked on the object, this does not create a webserver. See below. An object is returned with four methods: @@ -218,6 +225,8 @@ server.listen(5050); This example shows how you can proxy a request using your own HTTP server that modifies the outgoing proxy request by adding a special header. +##### Using Traditional Events (Native HTTP/HTTPS) + ```js import * as http from "node:http"; import { createProxyServer } from "http-proxy-3"; @@ -248,6 +257,38 @@ console.log("listening on port 5050"); server.listen(5050); ``` +##### Using Callbacks (Undici HTTP/2) + +```js +import * as http from "node:http"; +import { createProxyServer } from "http-proxy-3"; + +// Create a proxy server with undici and HTTP/2 support +const proxy = createProxyServer({ + target: "https://127.0.0.1:5050", + undici: { + agentOptions: { allowH2: true }, + // Modify the request before it's sent + onBeforeRequest: async (requestOptions, req, res, options) => { + requestOptions.headers['X-Special-Proxy-Header'] = 'foobar'; + requestOptions.headers['X-HTTP2-Enabled'] = 'true'; + }, + // Access the response after it's received + onAfterResponse: async (response, req, res, options) => { + console.log(`Proxied ${req.url} -> ${response.statusCode}`); + } + } +}); + +const server = http.createServer((req, res) => { + // The headers are modified via the onBeforeRequest callback + proxy.web(req, res); +}); + +console.log("listening on port 5050"); +server.listen(5050); +``` + **[Back to top](#table-of-contents)** #### Modify a response from a proxied server @@ -398,6 +439,103 @@ proxyServer.listen(8015); **[Back to top](#table-of-contents)** +#### HTTP/2 Support with Undici + +http-proxy-3 supports HTTP/2 through [undici](https://github.com/nodejs/undici), a modern HTTP client. When undici is enabled, the proxy can communicate with HTTP/2 servers and provides enhanced performance and features. + +##### Basic HTTP/2 Setup + +```js +import { createProxyServer } from "http-proxy-3"; +import { Agent, setGlobalDispatcher } from "undici"; + +// Enable HTTP/2 for all fetch operations +setGlobalDispatcher(new Agent({ allowH2: true })); + +// Create a proxy with HTTP/2 support +const proxy = createProxyServer({ + target: "https://http2-server.example.com", + undici: { + agentOptions: { allowH2: true } + } +}); +``` + +##### Simple HTTP/2 Enablement + +```js +// Shorthand to enable undici with defaults +const proxy = createProxyServer({ + target: "https://http2-server.example.com", + undici: true // Uses default configuration +}); +``` + +##### Advanced Configuration with Callbacks + +```js +const proxy = createProxyServer({ + target: "https://api.example.com", + undici: { + // Undici agent configuration + agentOptions: { + allowH2: true, + connect: { + rejectUnauthorized: false, // For self-signed certs + timeout: 10000 + } + }, + // Undici request options + requestOptions: { + headersTimeout: 30000, + bodyTimeout: 60000 + }, + // Called before making the undici request + onBeforeRequest: async (requestOptions, req, res, options) => { + // Modify outgoing request + requestOptions.headers['X-API-Key'] = 'your-api-key'; + requestOptions.headers['X-Request-ID'] = Math.random().toString(36); + }, + // Called after receiving the undici response + onAfterResponse: async (response, req, res, options) => { + // Access full response object + console.log(`Status: ${response.statusCode}`); + console.log('Headers:', response.headers); + // Note: response.body is a stream, not the actual body content + } + } +}); +``` + +##### HTTP/2 with HTTPS Proxy + +```js +import { readFileSync } from "node:fs"; + +const proxy = createProxyServer({ + target: "https://http2-target.example.com", + ssl: { + key: readFileSync("server-key.pem"), + cert: readFileSync("server-cert.pem") + }, + undici: { + agentOptions: { + allowH2: true, + connect: { rejectUnauthorized: false } + } + }, + secure: false // Skip SSL verification for self-signed certs +}).listen(8443); +``` + +**Important Notes:** +- When `undici` option is provided, the proxy uses undici's HTTP client instead of Node.js native `http`/`https` modules +- undici automatically handles HTTP/2 negotiation when `allowH2: true` is set +- The `onBeforeRequest` and `onAfterResponse` callbacks are only available in the undici code path +- Traditional `proxyReq` and `proxyRes` events are not emitted in the undici path - use the callbacks instead + +**[Back to top](#table-of-contents)** + ### Options `httpProxy.createProxyServer` supports the following options: @@ -491,6 +629,14 @@ proxyServer.listen(8015); }; ``` +- **ca**: Optionally override the trusted CA certificates. This is passed to https.request. + +- **undici**: Enable undici for HTTP/2 support. Set to `true` for defaults, or provide custom configuration: + - `agentOptions`: Configuration for undici Agent (see [undici Agent.Options](https://github.com/nodejs/undici/blob/main/docs/api/Agent.md)) + - `requestOptions`: Configuration for undici requests (see [undici Dispatcher.RequestOptions](https://github.com/nodejs/undici/blob/main/docs/api/Dispatcher.md#dispatcherrequestoptions)) + - `onBeforeRequest`: Async callback called before making the undici request + - `onAfterResponse`: Async callback called after receiving the undici response + **NOTE:** `options.ws` and `options.ssl` are optional. `options.target` and `options.forward` cannot both be missing @@ -502,6 +648,51 @@ If you are using the `proxyServer.listen` method, the following options are also **[Back to top](#table-of-contents)** +### Configuration Compatibility + +The following table shows which configuration options are compatible with different code paths: + +| Option | Native HTTP/HTTPS | Undici HTTP/2 | Notes | +|--------|-------------------|---------------|--------| +| `target` | ✅ | ✅ | Core option, works in both paths | +| `forward` | ✅ | ✅ | Core option, works in both paths | +| `agent` | ✅ | ❌ | Native agents only, use `undici.agentOptions` instead | +| `ssl` | ✅ | ✅ | HTTPS server configuration | +| `ws` | ✅ | ❌ | WebSocket proxying uses native path only | +| `xfwd` | ✅ | ✅ | X-Forwarded headers | +| `secure` | ✅ | ✅ | SSL certificate verification | +| `toProxy` | ✅ | ✅ | Proxy-to-proxy configuration | +| `prependPath` | ✅ | ✅ | Path manipulation | +| `ignorePath` | ✅ | ✅ | Path manipulation | +| `localAddress` | ✅ | ✅ | Local interface binding | +| `changeOrigin` | ✅ | ✅ | Host header rewriting | +| `preserveHeaderKeyCase` | ✅ | ✅ | Header case preservation | +| `auth` | ✅ | ✅ | Basic authentication | +| `hostRewrite` | ✅ | ✅ | Redirect hostname rewriting | +| `autoRewrite` | ✅ | ✅ | Automatic redirect rewriting | +| `protocolRewrite` | ✅ | ✅ | Protocol rewriting on redirects | +| `cookieDomainRewrite` | ✅ | ✅ | Cookie domain rewriting | +| `cookiePathRewrite` | ✅ | ✅ | Cookie path rewriting | +| `headers` | ✅ | ✅ | Extra headers to add | +| `proxyTimeout` | ✅ | ✅ | Outgoing request timeout | +| `timeout` | ✅ | ✅ | Incoming request timeout | +| `followRedirects` | ✅ | ✅ | Redirect following | +| `selfHandleResponse` | ✅ | ✅ | Manual response handling | +| `buffer` | ✅ | ✅ | Request body stream | +| `method` | ✅ | ✅ | HTTP method override | +| `ca` | ✅ | ✅ | Custom CA certificates | +| `undici` | ❌ | ✅ | Undici-specific configuration | + +**Code Path Selection:** +- **Native Path**: Used by default, supports HTTP/1.1 and WebSockets +- **Undici Path**: Activated when `undici` option is provided, supports HTTP/2 + +**Event Compatibility:** +- **Native Path**: Emits traditional events (`proxyReq`, `proxyRes`, `proxyReqWs`) +- **Undici Path**: Uses callback functions (`onBeforeRequest`, `onAfterResponse`) instead of events + +**[Back to top](#table-of-contents)** + ### Listening for proxy events - `error`: The error event is emitted if the request to the target fail. **We do not do any error handling of messages passed between client and proxy, and messages passed between proxy and target, so it is recommended that you listen on errors and handle them.** @@ -512,11 +703,13 @@ If you are using the `proxyServer.listen` method, the following options are also - `close`: This event is emitted once the proxy websocket was closed. - (DEPRECATED) `proxySocket`: Deprecated in favor of `open`. +**Note**: When using the undici code path (HTTP/2), the `proxyReq` and `proxyRes` events are **not** emitted. Instead, use the `onBeforeRequest` and `onAfterResponse` callback functions in the `undici` configuration. + +#### Traditional Events (Native HTTP/HTTPS path) + ```js import { createProxyServer } from "http-proxy-3"; -// Error example -// -// Http Proxy Server with bad target + const proxy = createProxyServer({ target: "http://localhost:9005", }); @@ -528,7 +721,6 @@ proxy.on("error", (err, req, res) => { res.writeHead(500, { "Content-Type": "text/plain", }); - res.end("Something went wrong. And we are reporting a custom error message."); }); @@ -545,6 +737,32 @@ proxy.on("open", (proxySocket) => { // listen for messages coming FROM the target here proxySocket.on("data", hybiParseAndLogMessage); }); +``` + +#### Callback Functions (Undici/HTTP2 path) + +```js +import { createProxyServer } from "http-proxy-3"; + +const proxy = createProxyServer({ + target: "https://api.example.com", + undici: { + agentOptions: { allowH2: true }, + // Called before making the undici request + onBeforeRequest: async (requestOptions, req, res, options) => { + // Modify the outgoing request + requestOptions.headers['X-Custom-Header'] = 'added-by-callback'; + console.log('Making request to:', requestOptions.origin + requestOptions.path); + }, + // Called after receiving the undici response + onAfterResponse: async (response, req, res, options) => { + // Access the full response object + console.log(`Response: ${response.statusCode}`, response.headers); + // Note: response.body is a stream that will be piped to res automatically + } + } +}); +``` // Listen for the `close` event on `proxy`. proxy.on("close", (res, socket, head) => { diff --git a/lib/http-proxy/common.ts b/lib/http-proxy/common.ts index 3b5abc9..0716092 100644 --- a/lib/http-proxy/common.ts +++ b/lib/http-proxy/common.ts @@ -1,4 +1,8 @@ -import type { NormalizedServerOptions, ProxyTargetDetailed, ServerOptions } from "./index"; +import type { + NormalizedServerOptions, + ProxyTargetDetailed, + ServerOptions, +} from "./index"; import { type IncomingMessage as Request } from "node:http"; import { TLSSocket } from "node:tls"; import type { Socket } from "node:net"; @@ -17,6 +21,7 @@ export interface Outgoing extends Outgoing0 { headers: { [header: string]: string | string[] | undefined } & { overwritten?: boolean; }; + url: string; } // If we allow this header and a user sends it with a request, @@ -28,11 +33,13 @@ export interface Outgoing extends Outgoing0 { const HEADER_BLACKLIST = "trailer"; const HTTP2_HEADER_BLACKLIST = [ - ':method', - ':path', - ':scheme', - ':authority', -] + ":method", + ":path", + ":scheme", + ":authority", + "connection", + "keep-alive", +]; // setupOutgoing -- Copies the right headers from `options` and `req` to // `outgoing` which is then used to fire the proxied request by calling @@ -51,8 +58,10 @@ export function setupOutgoing( // the final path is target path + relative path requested by user: const target = options[forward || "target"]!; - outgoing.port = - +(target.port ?? (target.protocol !== undefined && isSSL.test(target.protocol) ? 443 : 80)); + outgoing.port = +( + target.port ?? + (target.protocol !== undefined && isSSL.test(target.protocol) ? 443 : 80) + ); for (const e of [ "host", @@ -125,7 +134,9 @@ export function setupOutgoing( // target if defined is a URL object so has attribute "pathname", not "path". const targetPath = - target && options.prependPath !== false && 'pathname' in target ? getPath(`${target.pathname}${target.search ?? ""}`) : "/"; + target && options.prependPath !== false && "pathname" in target + ? getPath(`${target.pathname}${target.search ?? ""}`) + : "/"; let outgoingPath = options.toProxy ? req.url : getPath(req.url); @@ -139,11 +150,25 @@ export function setupOutgoing( if (options.changeOrigin) { outgoing.headers.host = target.protocol !== undefined && - required(outgoing.port, target.protocol) && - !hasPort(outgoing.host) + required(outgoing.port, target.protocol) && + !hasPort(outgoing.host) ? outgoing.host + ":" + outgoing.port : outgoing.host; } + + outgoing.url = ("href" in target && + target.href) || + (target.protocol === "https" ? "https" : "http") + + "://" + + outgoing.host + + (outgoing.port ? ":" + outgoing.port : ""); + + if (req.httpVersionMajor > 1) { + for (const header of HTTP2_HEADER_BLACKLIST) { + delete outgoing.headers[header]; + } + } + return outgoing; } @@ -281,17 +306,23 @@ function hasPort(host: string): boolean { } function getPath(url?: string): string { - if (url === '' || url?.startsWith('?')) { - return url + if (url === "" || url?.startsWith("?")) { + return url; } const u = toURL(url); return `${u.pathname ?? ""}${u.search ?? ""}`; } -export function toURL(url: URL | urllib.Url | ProxyTargetDetailed | string | undefined): URL { +export function toURL( + url: URL | urllib.Url | ProxyTargetDetailed | string | undefined, +): URL { if (url instanceof URL) { return url; - } else if (typeof url === "object" && 'href' in url && typeof url.href === "string") { + } else if ( + typeof url === "object" && + "href" in url && + typeof url.href === "string" + ) { url = url.href; } if (!url) { diff --git a/lib/http-proxy/index.ts b/lib/http-proxy/index.ts index 82faea8..0a97f5c 100644 --- a/lib/http-proxy/index.ts +++ b/lib/http-proxy/index.ts @@ -7,22 +7,24 @@ import { EventEmitter } from "node:events"; import type { Stream } from "node:stream"; import debug from "debug"; import { toURL } from "./common"; +import type { Agent, Dispatcher } from "undici"; +import { Agent as UndiciAgent, interceptors } from "undici"; const log = debug("http-proxy-3"); export interface ProxyTargetDetailed { - host: string; - port: number; - protocol?: string; - hostname?: string; - socketPath?: string; - key?: string; - passphrase?: string; - pfx?: Buffer | string; - cert?: string; - ca?: string; - ciphers?: string; - secureProtocol?: string; + host: string; + port: number; + protocol?: string; + hostname?: string; + socketPath?: string; + key?: string; + passphrase?: string; + pfx?: Buffer | string; + cert?: string; + ca?: string; + ciphers?: string; + secureProtocol?: string; } export type ProxyType = "ws" | "web"; export type ProxyTarget = ProxyTargetUrl | ProxyTargetDetailed; @@ -31,419 +33,462 @@ export type ProxyTargetUrl = URL | string | { port: number; host: string; protoc export type NormalizeProxyTarget = Exclude | URL; export interface ServerOptions { - // NOTE: `options.target and `options.forward` cannot be both missing when the - // actually proxying is called. However, they can be missing when creating the - // proxy server in the first place! E.g., you could make a proxy server P with - // no options, then use P.web(req,res, {target:...}). - /** URL string to be parsed with the url module. */ - target?: ProxyTarget; - /** URL string to be parsed with the url module or a URL object. */ - forward?: ProxyTargetUrl; - /** Object to be passed to http(s).request. */ - agent?: any; - /** Object to be passed to https.createServer(). */ - ssl?: any; - /** If you want to proxy websockets. */ - ws?: boolean; - /** Adds x- forward headers. */ - xfwd?: boolean; - /** Verify SSL certificate. */ - secure?: boolean; - /** Explicitly specify if we are proxying to another proxy. */ - toProxy?: boolean; - /** Specify whether you want to prepend the target's path to the proxy path. */ - prependPath?: boolean; - /** Specify whether you want to ignore the proxy path of the incoming request. */ - ignorePath?: boolean; - /** Local interface string to bind for outgoing connections. */ - localAddress?: string; - /** Changes the origin of the host header to the target URL. */ - changeOrigin?: boolean; - /** specify whether you want to keep letter case of response header key */ - preserveHeaderKeyCase?: boolean; - /** Basic authentication i.e. 'user:password' to compute an Authorization header. */ - auth?: string; - /** Rewrites the location hostname on (301 / 302 / 307 / 308) redirects, Default: null. */ - hostRewrite?: string; - /** Rewrites the location host/ port on (301 / 302 / 307 / 308) redirects based on requested host/ port.Default: false. */ - autoRewrite?: boolean; - /** Rewrites the location protocol on (301 / 302 / 307 / 308) redirects to 'http' or 'https'.Default: null. */ - protocolRewrite?: string; - /** rewrites domain of set-cookie headers. */ - cookieDomainRewrite?: false | string | { [oldDomain: string]: string }; - /** rewrites path of set-cookie headers. Default: false */ - cookiePathRewrite?: false | string | { [oldPath: string]: string }; - /** object with extra headers to be added to target requests. */ - headers?: { [header: string]: string | string[] | undefined }; - /** Timeout (in milliseconds) when proxy receives no response from target. Default: 120000 (2 minutes) */ - proxyTimeout?: number; - /** Timeout (in milliseconds) for incoming requests */ - timeout?: number; - /** Specify whether you want to follow redirects. Default: false */ - followRedirects?: boolean; - /** If set to true, none of the webOutgoing passes are called and it's your responsibility to appropriately return the response by listening and acting on the proxyRes event */ - selfHandleResponse?: boolean; - /** Buffer */ - buffer?: Stream; - /** Explicitly set the method type of the ProxyReq */ - method?: string; - /** - * Optionally override the trusted CA certificates. - * This is passed to https.request. - */ - ca?: string; + // NOTE: `options.target and `options.forward` cannot be both missing when the + // actually proxying is called. However, they can be missing when creating the + // proxy server in the first place! E.g., you could make a proxy server P with + // no options, then use P.web(req,res, {target:...}). + /** URL string to be parsed with the url module. */ + target?: ProxyTarget; + /** URL string to be parsed with the url module or a URL object. */ + forward?: ProxyTargetUrl; + /** Object to be passed to http(s).request. */ + agent?: any; + /** Object to be passed to https.createServer(). */ + ssl?: any; + /** If you want to proxy websockets. */ + ws?: boolean; + /** Adds x- forward headers. */ + xfwd?: boolean; + /** Verify SSL certificate. */ + secure?: boolean; + /** Explicitly specify if we are proxying to another proxy. */ + toProxy?: boolean; + /** Specify whether you want to prepend the target's path to the proxy path. */ + prependPath?: boolean; + /** Specify whether you want to ignore the proxy path of the incoming request. */ + ignorePath?: boolean; + /** Local interface string to bind for outgoing connections. */ + localAddress?: string; + /** Changes the origin of the host header to the target URL. */ + changeOrigin?: boolean; + /** specify whether you want to keep letter case of response header key */ + preserveHeaderKeyCase?: boolean; + /** Basic authentication i.e. 'user:password' to compute an Authorization header. */ + auth?: string; + /** Rewrites the location hostname on (301 / 302 / 307 / 308) redirects, Default: null. */ + hostRewrite?: string; + /** Rewrites the location host/ port on (301 / 302 / 307 / 308) redirects based on requested host/ port.Default: false. */ + autoRewrite?: boolean; + /** Rewrites the location protocol on (301 / 302 / 307 / 308) redirects to 'http' or 'https'.Default: null. */ + protocolRewrite?: string; + /** rewrites domain of set-cookie headers. */ + cookieDomainRewrite?: false | string | { [oldDomain: string]: string }; + /** rewrites path of set-cookie headers. Default: false */ + cookiePathRewrite?: false | string | { [oldPath: string]: string }; + /** object with extra headers to be added to target requests. */ + headers?: { [header: string]: string | string[] | undefined }; + /** Timeout (in milliseconds) when proxy receives no response from target. Default: 120000 (2 minutes) */ + proxyTimeout?: number; + /** Timeout (in milliseconds) for incoming requests */ + timeout?: number; + /** Specify whether you want to follow redirects. Default: false */ + followRedirects?: boolean; + /** If set to true, none of the webOutgoing passes are called and it's your responsibility to appropriately return the response by listening and acting on the proxyRes event */ + selfHandleResponse?: boolean; + /** Buffer */ + buffer?: Stream; + /** Explicitly set the method type of the ProxyReq */ + method?: string; + /** + * Optionally override the trusted CA certificates. + * This is passed to https.request. + */ + ca?: string; + /** Enable undici for HTTP/2 support. Set to true for defaults, or provide custom configuration. */ + undici?: boolean | UndiciOptions; +} + +export interface UndiciOptions { + /** Undici Agent configuration */ + agentOptions?: Agent.Options; + /** Undici request options */ + requestOptions?: Dispatcher.RequestOptions; + /** Called before making the undici request */ + onBeforeRequest?: (requestOptions: Dispatcher.RequestOptions, req: http.IncomingMessage, res: http.ServerResponse, options: NormalizedServerOptions) => void | Promise; + /** Called after receiving the undici response */ + onAfterResponse?: (response: Dispatcher.ResponseData, req: http.IncomingMessage, res: http.ServerResponse, options: NormalizedServerOptions) => void | Promise; } export interface NormalizedServerOptions extends ServerOptions { - target?: NormalizeProxyTarget; - forward?: NormalizeProxyTarget; + target?: NormalizeProxyTarget; + forward?: NormalizeProxyTarget; } export type ErrorCallback = - ( - err: TError, - req: InstanceType, - res: InstanceType | net.Socket, - target?: ProxyTargetUrl, - ) => void; + ( + err: TError, + req: InstanceType, + res: InstanceType | net.Socket, + target?: ProxyTargetUrl, + ) => void; type ProxyServerEventMap = { - error: Parameters>; - start: [ - req: InstanceType, - res: InstanceType, - target: ProxyTargetUrl, - ]; - open: [socket: net.Socket]; - proxyReq: [ - proxyReq: http.ClientRequest, - req: InstanceType, - res: InstanceType, - options: ServerOptions, - socket: net.Socket, - ]; - proxyRes: [ - proxyRes: InstanceType, - req: InstanceType, - res: InstanceType, - ]; - proxyReqWs: [ - proxyReq: http.ClientRequest, - req: InstanceType, - socket: net.Socket, - options: ServerOptions, - head: any, - ]; - econnreset: [ - err: Error, - req: InstanceType, - res: InstanceType, - target: ProxyTargetUrl, - ]; - end: [ - req: InstanceType, - res: InstanceType, - proxyRes: InstanceType, - ]; - close: [ - proxyRes: InstanceType, - proxySocket: net.Socket, - proxyHead: any, - ]; + error: Parameters>; + start: [ + req: InstanceType, + res: InstanceType, + target: ProxyTargetUrl, + ]; + open: [socket: net.Socket]; + proxyReq: [ + proxyReq: http.ClientRequest, + req: InstanceType, + res: InstanceType, + options: ServerOptions, + socket: net.Socket, + ]; + proxyRes: [ + proxyRes: InstanceType, + req: InstanceType, + res: InstanceType, + ]; + proxyReqWs: [ + proxyReq: http.ClientRequest, + req: InstanceType, + socket: net.Socket, + options: ServerOptions, + head: any, + ]; + econnreset: [ + err: Error, + req: InstanceType, + res: InstanceType, + target: ProxyTargetUrl, + ]; + end: [ + req: InstanceType, + res: InstanceType, + proxyRes: InstanceType, + ]; + close: [ + proxyRes: InstanceType, + proxySocket: net.Socket, + proxyHead: any, + ]; } type ProxyMethodArgs = { - ws: [ - req: InstanceType, - socket: any, - head: any, - ...args: - [ - options?: ServerOptions, - callback?: ErrorCallback, - ] - | [ - callback?: ErrorCallback, - ] - ] - web: [ - req: InstanceType, - res: InstanceType, - ...args: - [ - options: ServerOptions, - callback?: ErrorCallback, - ] - | [ - callback?: ErrorCallback - ] - ] + ws: [ + req: InstanceType, + socket: any, + head: any, + ...args: + [ + options?: ServerOptions, + callback?: ErrorCallback, + ] + | [ + callback?: ErrorCallback, + ] + ] + web: [ + req: InstanceType, + res: InstanceType, + ...args: + [ + options: ServerOptions, + callback?: ErrorCallback, + ] + | [ + callback?: ErrorCallback + ] + ] } type PassFunctions = { - ws: ( - req: InstanceType, - socket: net.Socket, - options: NormalizedServerOptions, - head: Buffer | undefined, - server: ProxyServer, - cb?: ErrorCallback - ) => unknown - web: ( - req: InstanceType, - res: InstanceType, - options: NormalizedServerOptions, - head: Buffer | undefined, - server: ProxyServer, - cb?: ErrorCallback - ) => unknown + ws: ( + req: InstanceType, + socket: net.Socket, + options: NormalizedServerOptions, + head: Buffer | undefined, + server: ProxyServer, + cb?: ErrorCallback + ) => unknown + web: ( + req: InstanceType, + res: InstanceType, + options: NormalizedServerOptions, + head: Buffer | undefined, + server: ProxyServer, + cb?: ErrorCallback + ) => unknown } export class ProxyServer extends EventEmitter> { - /** - * Used for proxying WS(S) requests - * @param req - Client request. - * @param socket - Client socket. - * @param head - Client head. - * @param options - Additional options. - */ - public readonly ws: (...args: ProxyMethodArgs["ws"]) => void; - - /** - * Used for proxying regular HTTP(S) requests - * @param req - Client request. - * @param res - Client response. - * @param options - Additional options. - */ - public readonly web: (...args: ProxyMethodArgs["web"]) => void; - - private options: ServerOptions; - private webPasses: Array['web']>; - private wsPasses: Array['ws']>; - private _server?: http.Server | http2.Http2SecureServer | null; - - /** - * Creates the proxy server with specified options. - * @param options - Config object passed to the proxy - */ - constructor(options: ServerOptions = {}) { - super(); - log("creating a ProxyServer", options); - options.prependPath = options.prependPath === false ? false : true; - this.options = options; - this.web = this.createRightProxy("web")(options); - this.ws = this.createRightProxy("ws")(options); - this.webPasses = Object.values(WEB_PASSES) as Array['web']>; - this.wsPasses = Object.values(WS_PASSES) as Array['ws']>; - this.on("error", this.onError); - } - - /** - * Creates the proxy server with specified options. - * @param options Config object passed to the proxy - * @returns Proxy object with handlers for `ws` and `web` requests - */ - static createProxyServer< - TIncomingMessage extends typeof http.IncomingMessage, - TServerResponse extends typeof http.ServerResponse, - TError = Error - >(options?: ServerOptions): ProxyServer { - return new ProxyServer(options); - } - - /** - * Creates the proxy server with specified options. - * @param options Config object passed to the proxy - * @returns Proxy object with handlers for `ws` and `web` requests - */ - static createServer< - TIncomingMessage extends typeof http.IncomingMessage, - TServerResponse extends typeof http.ServerResponse, - TError = Error - >(options?: ServerOptions): ProxyServer { - return new ProxyServer(options); - } - - /** - * Creates the proxy server with specified options. - * @param options Config object passed to the proxy - * @returns Proxy object with handlers for `ws` and `web` requests - */ - static createProxy< - TIncomingMessage extends typeof http.IncomingMessage, - TServerResponse extends typeof http.ServerResponse, - TError = Error - >(options?: ServerOptions): ProxyServer { - return new ProxyServer(options); - } - - // createRightProxy - Returns a function that when called creates the loader for - // either `ws` or `web`'s passes. - createRightProxy = (type: PT): Function => { - log("createRightProxy", { type }); - return (options: ServerOptions) => { - return (...args: ProxyMethodArgs[PT] /* req, res, [head], [opts] */) => { - const req = args[0]; - log("proxy: ", { type, path: (req as http.IncomingMessage).url }); - const res = args[1]; - const passes = type === "ws" ? this.wsPasses : this.webPasses; - if (type == "ws") { - // socket -- proxy websocket errors to our error handler; - // see https://github.com/sagemathinc/http-proxy-3/issues/5 - // NOTE: as mentioned below, res is the socket in this case. - // One of the passes does add an error handler, but there's no - // guarantee we even get to that pass before something bad happens, - // and there's no way for a user of http-proxy-3 to get ahold - // of this res object and attach their own error handler until - // after the passes. So we better attach one ASAP right here: - (res as net.Socket).on("error", (err: TError) => { - this.emit("error", err, req, res); - }); - } - let counter = args.length - 1; - let head: Buffer | undefined; - let cb: ErrorCallback | undefined; - - // optional args parse begin - if (typeof args[counter] === "function") { - cb = args[counter]; - counter--; - } - - let requestOptions: ServerOptions; - if (!(args[counter] instanceof Buffer) && args[counter] !== res) { - // Copy global options, and overwrite with request options - requestOptions = { ...options, ...args[counter] }; - counter--; - } else { - requestOptions = { ...options }; - } - - if (args[counter] instanceof Buffer) { - head = args[counter]; - } - - for (const e of ["target", "forward"] as const) { - if (typeof requestOptions[e] === "string") { - requestOptions[e] = toURL(requestOptions[e]); - } - } - - if (!requestOptions.target && !requestOptions.forward) { - this.emit("error", new Error("Must set target or forward") as TError, req, res); - return; - } - - for (const pass of passes) { - /** - * Call of passes functions - * pass(req, res, options, head) - * - * In WebSockets case, the `res` variable - * refer to the connection socket - * pass(req, socket, options, head) - */ - if (pass(req, res, requestOptions as NormalizedServerOptions, head, this, cb)) { - // passes can return a truthy value to halt the loop - break; - } - } - }; - }; - }; - - onError = (err: TError) => { - // Force people to handle their own errors - if (this.listeners("error").length === 1) { - throw err; - } - }; - - /** - * A function that wraps the object in a webserver, for your convenience - * @param port - Port to listen on - * @param hostname - The hostname to listen on - */ - listen = (port: number, hostname?: string) => { - log("listen", { port, hostname }); - - const requestListener = (req: InstanceType | http2.Http2ServerRequest, res: InstanceType |http2.Http2ServerResponse) => { - this.web(req as InstanceType, res as InstanceType); - }; - - this._server = this.options.ssl ? http2.createSecureServer( - { ...this.options.ssl, allowHTTP1: true }, - requestListener - ) : http.createServer(requestListener); - - if (this.options.ws) { - this._server.on("upgrade", (req: InstanceType, socket, head) => { - this.ws(req, socket, head); - }); - } - - this._server.listen(port, hostname); - - return this; - }; - - // if the proxy started its own http server, this is the address of that server. - address = () => { - return this._server?.address(); - }; - - /** - * A function that closes the inner webserver and stops listening on given port - */ - close = (cb?: Function) => { - if (this._server == null) { - cb?.(); - return; - } - // Wrap cb anb nullify server after all open connections are closed. - this._server.close((err?) => { - this._server = null; - cb?.(err); - }); - }; - - before = (type: PT, passName: string, cb: PassFunctions[PT]) => { - if (type !== "ws" && type !== "web") { - throw new Error("type must be `web` or `ws`"); - } - const passes = (type === "ws" ? this.wsPasses : this.webPasses) as PassFunctions[PT][]; - let i: false | number = false; - - passes.forEach((v, idx) => { - if (v.name === passName) { - i = idx; - } - }); - - if (i === false) { - throw new Error("No such pass"); - } - - passes.splice(i, 0, cb); - }; - - after = (type: PT, passName: string, cb: PassFunctions[PT]) => { - if (type !== "ws" && type !== "web") { - throw new Error("type must be `web` or `ws`"); - } - const passes = (type === "ws" ? this.wsPasses : this.webPasses) as PassFunctions[PT][]; - let i: false | number = false; - - passes.forEach((v, idx) => { - if (v.name === passName) { - i = idx; - } - }); - - if (i === false) { - throw new Error("No such pass"); - } - - passes.splice(i++, 0, cb); - }; + /** + * Used for proxying WS(S) requests + * @param req - Client request. + * @param socket - Client socket. + * @param head - Client head. + * @param options - Additional options. + */ + public readonly ws: (...args: ProxyMethodArgs["ws"]) => void; + + /** + * Used for proxying regular HTTP(S) requests + * @param req - Client request. + * @param res - Client response. + * @param options - Additional options. + */ + public readonly web: (...args: ProxyMethodArgs["web"]) => void; + + private options: ServerOptions; + private webPasses: Array['web']>; + private wsPasses: Array['ws']>; + private _server?: http.Server | http2.Http2SecureServer | null; + + // Undici agent for this proxy server + public undiciAgent?: Agent; + + /** + * Creates the proxy server with specified options. + * @param options - Config object passed to the proxy + */ + constructor(options: ServerOptions = {}) { + super(); + log("creating a ProxyServer", options); + options.prependPath = options.prependPath !== false; + this.options = options; + this.web = this.createRightProxy("web")(options); + this.ws = this.createRightProxy("ws")(options); + this.webPasses = Object.values(WEB_PASSES) as Array['web']>; + this.wsPasses = Object.values(WS_PASSES) as Array['ws']>; + this.on("error", this.onError); + + // Initialize undici agent if enabled + if (options.undici) { + this.initializeAgent(options.undici); + } + } + + /** + * Initialize the single undici agent based on server options + */ + private initializeAgent(undiciOptions: UndiciOptions | boolean): void { + const resolvedOptions = undiciOptions === true ? {} as UndiciOptions : undiciOptions as UndiciOptions; + const agentOptions: Agent.Options = { + allowH2: true, + connect: { + rejectUnauthorized: this.options.secure !== false, + }, + ...(resolvedOptions.agentOptions || {}), + }; + + this.undiciAgent = new UndiciAgent(agentOptions); + + if (this.options.followRedirects) { + this.undiciAgent = this.undiciAgent.compose( + interceptors.redirect({ maxRedirections: 5 }) + ) as Agent; + } + } + + /** + * Creates the proxy server with specified options. + * @param options Config object passed to the proxy + * @returns Proxy object with handlers for `ws` and `web` requests + */ + static createProxyServer< + TIncomingMessage extends typeof http.IncomingMessage, + TServerResponse extends typeof http.ServerResponse, + TError = Error + >(options?: ServerOptions): ProxyServer { + return new ProxyServer(options); + } + + /** + * Creates the proxy server with specified options. + * @param options Config object passed to the proxy + * @returns Proxy object with handlers for `ws` and `web` requests + */ + static createServer< + TIncomingMessage extends typeof http.IncomingMessage, + TServerResponse extends typeof http.ServerResponse, + TError = Error + >(options?: ServerOptions): ProxyServer { + return new ProxyServer(options); + } + + /** + * Creates the proxy server with specified options. + * @param options Config object passed to the proxy + * @returns Proxy object with handlers for `ws` and `web` requests + */ + static createProxy< + TIncomingMessage extends typeof http.IncomingMessage, + TServerResponse extends typeof http.ServerResponse, + TError = Error + >(options?: ServerOptions): ProxyServer { + return new ProxyServer(options); + } + + // createRightProxy - Returns a function that when called creates the loader for + // either `ws` or `web`'s passes. + createRightProxy = (type: PT): Function => { + log("createRightProxy", { type }); + return (options: ServerOptions) => { + return (...args: ProxyMethodArgs[PT] /* req, res, [head], [opts] */) => { + const req = args[0]; + log("proxy: ", { type, path: (req as http.IncomingMessage).url }); + const res = args[1]; + const passes = type === "ws" ? this.wsPasses : this.webPasses; + if (type == "ws") { + // socket -- proxy websocket errors to our error handler; + // see https://github.com/sagemathinc/http-proxy-3/issues/5 + // NOTE: as mentioned below, res is the socket in this case. + // One of the passes does add an error handler, but there's no + // guarantee we even get to that pass before something bad happens, + // and there's no way for a user of http-proxy-3 to get ahold + // of this res object and attach their own error handler until + // after the passes. So we better attach one ASAP right here: + (res as net.Socket).on("error", (err: TError) => { + this.emit("error", err, req, res); + }); + } + let counter = args.length - 1; + let head: Buffer | undefined; + let cb: ErrorCallback | undefined; + + // optional args parse begin + if (typeof args[counter] === "function") { + cb = args[counter]; + counter--; + } + + let requestOptions: ServerOptions; + if (!(args[counter] instanceof Buffer) && args[counter] !== res) { + // Copy global options, and overwrite with request options + requestOptions = { ...options, ...args[counter] }; + counter--; + } else { + requestOptions = { ...options }; + } + + if (args[counter] instanceof Buffer) { + head = args[counter]; + } + + for (const e of ["target", "forward"] as const) { + if (typeof requestOptions[e] === "string") { + requestOptions[e] = toURL(requestOptions[e]); + } + } + + if (!requestOptions.target && !requestOptions.forward) { + this.emit("error", new Error("Must set target or forward") as TError, req, res); + return; + } + + for (const pass of passes) { + /** + * Call of passes functions + * pass(req, res, options, head) + * + * In WebSockets case, the `res` variable + * refer to the connection socket + * pass(req, socket, options, head) + */ + if (pass(req, res, requestOptions as NormalizedServerOptions, head, this, cb)) { + // passes can return a truthy value to halt the loop + break; + } + } + }; + }; + }; + + onError = (err: TError) => { + // Force people to handle their own errors + if (this.listeners("error").length === 1) { + throw err; + } + }; + + /** + * A function that wraps the object in a webserver, for your convenience + * @param port - Port to listen on + * @param hostname - The hostname to listen on + */ + listen = (port: number, hostname?: string) => { + log("listen", { port, hostname }); + + const requestListener = (req: InstanceType | http2.Http2ServerRequest, res: InstanceType | http2.Http2ServerResponse) => { + this.web(req as InstanceType, res as InstanceType); + }; + + this._server = this.options.ssl ? http2.createSecureServer( + { ...this.options.ssl, allowHTTP1: true }, + requestListener + ) : http.createServer(requestListener); + + if (this.options.ws) { + this._server.on("upgrade", (req: InstanceType, socket, head) => { + this.ws(req, socket, head); + }); + } + + this._server.listen(port, hostname); + + return this; + }; + + // if the proxy started its own http server, this is the address of that server. + address = () => { + return this._server?.address(); + }; + + /** + * A function that closes the inner webserver and stops listening on given port + */ + close = (cb?: Function) => { + if (this._server == null) { + cb?.(); + return; + } + // Wrap cb anb nullify server after all open connections are closed. + this._server.close((err?) => { + this._server = null; + cb?.(err); + }); + }; + + before = (type: PT, passName: string, cb: PassFunctions[PT]) => { + if (type !== "ws" && type !== "web") { + throw new Error("type must be `web` or `ws`"); + } + const passes = (type === "ws" ? this.wsPasses : this.webPasses) as PassFunctions[PT][]; + let i: false | number = false; + + passes.forEach((v, idx) => { + if (v.name === passName) { + i = idx; + } + }); + + if (i === false) { + throw new Error("No such pass"); + } + + passes.splice(i, 0, cb); + }; + + after = (type: PT, passName: string, cb: PassFunctions[PT]) => { + if (type !== "ws" && type !== "web") { + throw new Error("type must be `web` or `ws`"); + } + const passes = (type === "ws" ? this.wsPasses : this.webPasses) as PassFunctions[PT][]; + let i: false | number = false; + + passes.forEach((v, idx) => { + if (v.name === passName) { + i = idx; + } + }); + + if (i === false) { + throw new Error("No such pass"); + } + + passes.splice(i++, 0, cb); + }; } diff --git a/lib/http-proxy/passes/web-incoming.ts b/lib/http-proxy/passes/web-incoming.ts index fcbedb6..11995c1 100644 --- a/lib/http-proxy/passes/web-incoming.ts +++ b/lib/http-proxy/passes/web-incoming.ts @@ -7,17 +7,19 @@ The names of passes are exported as WEB_PASSES from this module. */ +import type { + IncomingMessage as Request, + ServerResponse as Response, +} from "node:http"; import * as http from "node:http"; import * as https from "node:https"; -import { OUTGOING_PASSES, EditableResponse } from "./web-outgoing"; -import * as common from "../common"; +import type { Socket } from "node:net"; +import type Stream from "node:stream"; import * as followRedirects from "follow-redirects"; -import { - type IncomingMessage as Request, - type ServerResponse as Response, -} from "node:http"; -import { type Socket } from "node:net"; -import type { ErrorCallback, NormalizedServerOptions, NormalizeProxyTarget, ProxyServer, ProxyTarget, ProxyTargetUrl, ServerOptions } from ".."; +import type { Dispatcher } from "undici"; +import type { ErrorCallback, NormalizedServerOptions, NormalizeProxyTarget, ProxyServer, ProxyTarget, ProxyTargetUrl, ServerOptions, UndiciOptions } from ".."; +import * as common from "../common"; +import { type EditableResponse, OUTGOING_PASSES } from "./web-outgoing"; export type ProxyResponse = Request & { headers: { [key: string]: string | string[] }; @@ -77,6 +79,10 @@ export function stream(req: Request, res: Response, options: NormalizedServerOpt // And we begin! server.emit("start", req, res, options.target || options.forward!); + if (options.undici) { + return stream2(req, res, options, _, server, cb); + } + const agents = options.followRedirects ? followRedirects : nativeAgents; const http = agents.http as typeof import('http'); const https = agents.https as typeof import('https'); @@ -183,4 +189,187 @@ export function stream(req: Request, res: Response, options: NormalizedServerOpt }); } + +async function stream2( + req: Request, + res: Response, + options: NormalizedServerOptions, + _: Buffer | undefined, + server: ProxyServer, + cb?: ErrorCallback, +) { + + // Helper function to handle errors consistently throughout the undici path + // Centralizes the error handling logic to avoid repetition + const handleError = (err: Error, target?: ProxyTargetUrl) => { + if (cb) { + cb(err, req, res, target); + } else { + server.emit("error", err, req, res, target); + } + }; + + req.on("error", (err: Error) => { + if (req.socket.destroyed && (err as NodeJS.ErrnoException).code === "ECONNRESET") { + const target = options.target || options.forward; + if (target) { + server.emit("econnreset", err, req, res, target); + } + return; + } + handleError(err); + }); + + const undiciOptions = options.undici === true ? {} as UndiciOptions : options.undici; + if (!undiciOptions) { + throw new Error("stream2 called without undici options"); + } + + const agent = server.undiciAgent + + if (!agent) { + handleError(new Error("Undici agent not initialized")); + return; + } + + if (options.forward) { + const outgoingOptions = common.setupOutgoing( + options.ssl || {}, + options, + req, + "forward", + ); + + const requestOptions: Dispatcher.RequestOptions = { + origin: new URL(outgoingOptions.url).origin, + method: outgoingOptions.method as Dispatcher.HttpMethod, + headers: outgoingOptions.headers || {}, + path: outgoingOptions.path || "/", + }; + + // Handle request body + if (options.buffer) { + requestOptions.body = options.buffer as Stream.Readable; + } else if (req.method !== "GET" && req.method !== "HEAD") { + requestOptions.body = req; + } + + // Call onBeforeRequest callback before making the forward request + if (undiciOptions.onBeforeRequest) { + try { + await undiciOptions.onBeforeRequest(requestOptions, req, res, options); + } catch (err) { + handleError(err as Error, options.forward); + return; + } + } + + try { + const result = await agent.request(requestOptions); + + // Call onAfterResponse callback for forward requests (though they typically don't expect responses) + if (undiciOptions.onAfterResponse) { + try { + await undiciOptions.onAfterResponse(result, req, res, options); + } catch (err) { + handleError(err as Error, options.forward); + return; + } + } + } catch (err) { + handleError(err as Error, options.forward); + } + + if (!options.target) { + return res.end(); + } + } + + const outgoingOptions = common.setupOutgoing(options.ssl || {}, options, req); + + const requestOptions: Dispatcher.RequestOptions = { + origin: new URL(outgoingOptions.url).origin, + method: outgoingOptions.method as Dispatcher.HttpMethod, + headers: outgoingOptions.headers || {}, + path: outgoingOptions.path || "/", + headersTimeout: options.proxyTimeout, + ...undiciOptions.requestOptions + }; + + if (options.auth) { + requestOptions.headers = { ...requestOptions.headers, authorization: `Basic ${Buffer.from(options.auth).toString("base64")}` }; + } + + if (options.buffer) { + requestOptions.body = options.buffer as Stream.Readable; + } else if (req.method !== "GET" && req.method !== "HEAD") { + requestOptions.body = req; + } + + // Call onBeforeRequest callback before making the request + if (undiciOptions.onBeforeRequest) { + try { + await undiciOptions.onBeforeRequest(requestOptions, req, res, options); + } catch (err) { + handleError(err as Error, options.target); + return; + } + } + + try { + const response = await agent.request(requestOptions); + + // Call onAfterResponse callback after receiving the response + if (undiciOptions.onAfterResponse) { + try { + await undiciOptions.onAfterResponse(response, req, res, options); + } catch (err) { + handleError(err as Error, options.target); + return; + } + } + + + // ProxyRes is used in the outgoing passes + // But since only certain properties are used, we can fake it here + // to avoid having to refactor everything. + const fakeProxyRes = { + ...response, rawHeaders: Object.entries(response.headers).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.flatMap(v => (v != null ? [key, v] : [])); + } + return value != null ? [key, value] : []; + }) as string[] + } as unknown as ProxyResponse; + + if (!res.headersSent && !options.selfHandleResponse) { + for (const pass of web_o) { + // note: none of these return anything + pass(req, res as EditableResponse, fakeProxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget }); + } + } + + if (!res.writableEnded) { + // Allow us to listen for when the proxy has completed + response.body.on("end", () => { + server?.emit("end", req, res, fakeProxyRes); + }); + // We pipe to the response unless its expected to be handled by the user + if (!options.selfHandleResponse) { + response.body.pipe(res); + } + } else { + server?.emit("end", req, res, fakeProxyRes); + } + + + } catch (err) { + if (err) { + handleError(err as Error, options.target); + } + } + + +} + export const WEB_PASSES = { deleteLength, timeout, XHeaders, stream }; diff --git a/lib/http-proxy/passes/web-outgoing.ts b/lib/http-proxy/passes/web-outgoing.ts index fa39569..4abfcde 100644 --- a/lib/http-proxy/passes/web-outgoing.ts +++ b/lib/http-proxy/passes/web-outgoing.ts @@ -100,12 +100,12 @@ export function writeHeaders( const rewriteCookieDomainConfig = typeof options.cookieDomainRewrite === "string" ? // also test for '' - { "*": options.cookieDomainRewrite } + { "*": options.cookieDomainRewrite } : options.cookieDomainRewrite; const rewriteCookiePathConfig = typeof options.cookiePathRewrite === "string" ? // also test for '' - { "*": options.cookiePathRewrite } + { "*": options.cookiePathRewrite } : options.cookiePathRewrite; const preserveHeaderKeyCase = options.preserveHeaderKeyCase; @@ -143,7 +143,7 @@ export function writeHeaders( for (const key0 in proxyRes.headers) { let key = key0; - if (_req.httpVersionMajor > 1 && key === "connection") { + if (_req.httpVersionMajor > 1 && (key === "connection") || key === "keep-alive") { // don't send connection header to http2 client continue; } diff --git a/lib/test/http/proxy-callbacks.test.ts b/lib/test/http/proxy-callbacks.test.ts new file mode 100644 index 0000000..594e1ce --- /dev/null +++ b/lib/test/http/proxy-callbacks.test.ts @@ -0,0 +1,93 @@ +/* +Test the new onProxyReq and onProxyRes callbacks for undici code path + +pnpm test proxy-callbacks.test.ts +*/ + +import * as http from "node:http"; +import * as httpProxy from "../.."; +import getPort from "../get-port"; +import fetch from "node-fetch"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Agent, setGlobalDispatcher } from "undici"; + +setGlobalDispatcher(new Agent({ + allowH2: true +})); + +describe("Undici callback functions (onBeforeRequest and onAfterResponse)", () => { + let ports: Record<'target' | 'proxy', number>; + const servers: Record = {}; + + beforeAll(async () => { + ports = { target: await getPort(), proxy: await getPort() }; + }); + + afterAll(async () => { + Object.values(servers).map((x) => x?.close()); + }); + + it("Create the target HTTP server", async () => { + servers.target = http + .createServer((req, res) => { + res.writeHead(200, { + "Content-Type": "text/plain", + "X-Target-Header": "from-target" + }); + res.write(`Request received: ${req.method} ${req.url}\n`); + res.write(`Headers: ${JSON.stringify(req.headers, null, 2)}\n`); + res.end(); + }) + .listen(ports.target); + }); + + it("Test onBeforeRequest and onAfterResponse callbacks", async () => { + let onBeforeRequestCalled = false; + let onAfterResponseCalled = false; + let capturedResponse: unknown = {}; + + const proxy = httpProxy.createServer({ + target: `http://localhost:${ports.target}`, + undici: { + agentOptions: { allowH2: true }, // Enable undici code path + onBeforeRequest: async (requestOptions, _req, _res, _options) => { + onBeforeRequestCalled = true; + // Modify the outgoing request + requestOptions.headers = { + ...requestOptions.headers, + 'X-Proxy-Added': 'callback-added-header', + 'X-Original-Method': _req.method || 'unknown' + }; + }, + onAfterResponse: async (response, _req, _res, _options) => { + onAfterResponseCalled = true; + capturedResponse = response; + console.log(`Response received: ${response.statusCode}`); + } + } + }); servers.proxy = proxy.listen(ports.proxy); + + // Make a request through the proxy + const response = await fetch(`http://localhost:${ports.proxy}/test`); + const text = await response.text(); + + // Check that the response is successful + expect(response.status).toBe(200); + expect(text).toContain("Request received: GET /test"); + + // Check that our added header made it to the target + expect(text).toContain("x-proxy-added"); + expect(text).toContain("callback-added-header"); + + // Check that callbacks were called + expect(onBeforeRequestCalled).toBe(true); + expect(onAfterResponseCalled).toBe(true); + + // Check that we received the full response object + expect(capturedResponse).toHaveProperty('statusCode'); + expect((capturedResponse as { statusCode: number }).statusCode).toBe(200); + expect(capturedResponse).toHaveProperty('headers'); + expect((capturedResponse as { headers: Record }).headers).toHaveProperty('x-target-header'); + expect((capturedResponse as { headers: Record }).headers['x-target-header']).toBe('from-target'); + }); +}); \ No newline at end of file diff --git a/lib/test/http/proxy-http2-to-http2.test.ts b/lib/test/http/proxy-http2-to-http2.test.ts new file mode 100644 index 0000000..3dc0b3d --- /dev/null +++ b/lib/test/http/proxy-http2-to-http2.test.ts @@ -0,0 +1,70 @@ +/* +pnpm test proxy-https-to-https.test.ts + +*/ + +import * as http2 from "node:http2"; +import * as httpProxy from "../.."; +import getPort from "../get-port"; +import { join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Agent, setGlobalDispatcher } from "undici"; + +setGlobalDispatcher(new Agent({ + allowH2: true +})); + +const fixturesDir = join(__dirname, "..", "fixtures"); + +describe("Basic example of proxying over HTTP2 to a target HTTP2 server", () => { + let ports: Record<'http2' | 'proxy', number>; + beforeAll(async () => { + // Gets ports + ports = { http2: await getPort(), proxy: await getPort() }; + }); + + const servers: any = {}; + let ssl: { key: string; cert: string }; + + it("Create the target HTTP2 server", async () => { + ssl = { + key: await readFile(join(fixturesDir, "agent2-key.pem"), "utf8"), + cert: await readFile(join(fixturesDir, "agent2-cert.pem"), "utf8"), + }; + servers.https = http2 + .createSecureServer(ssl, (_req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.write("hello over http2\n"); + res.end(); + }) + .listen(ports.http2); + }); + + it("Create the HTTPS proxy server", async () => { + servers.proxy = httpProxy + .createServer({ + target: `https://localhost:${ports.http2}`, + ssl, + undici: { agentOptions: { allowH2: true } }, + // without secure false, clients will fail and this is broken: + secure: false, + }) + .listen(ports.proxy); + }); + + it("Use fetch to test direct non-proxied http2 server", async () => { + const r = await (await fetch(`https://localhost:${ports.http2}`)).text(); + expect(r).toContain("hello over http2"); + }); + + it("Use fetch to test the proxy server", async () => { + const r = await (await fetch(`https://localhost:${ports.proxy}`)).text(); + expect(r).toContain("hello over http2"); + }); + + afterAll(async () => { + // cleanup + Object.values(servers).map((x: any) => x?.close()); + }); +});