Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6e3d337
refactor: simplify code
SukkaW Feb 10, 2026
3d54aaf
feat: handle connection header
SukkaW Feb 10, 2026
fe5c360
feat: ignore conflicting http/2 pseudo headers
SukkaW Feb 10, 2026
29bb3a0
feat: ignore statusMessage on HTTP/2
SukkaW Feb 12, 2026
4f85058
feat: implement HTTP/2 listener
SukkaW Feb 12, 2026
4e514df
refactor: proper determine connection is encrypted
SukkaW Feb 12, 2026
811a25d
refactor: handle `:authority` and `transfer-encoding`
SukkaW Feb 12, 2026
45d3a02
test: add http2 listener test
SukkaW Feb 12, 2026
9a92849
refactor: types and test
SukkaW Feb 20, 2026
9d401ac
test: update test to re-use util
SukkaW Feb 20, 2026
84d4c02
test: improve coverage
SukkaW Feb 20, 2026
68a2f02
chore: add comment
SukkaW Feb 20, 2026
ebfd52d
test: fix describe
SukkaW Feb 23, 2026
795359d
test: extract util
SukkaW Feb 23, 2026
50c9d9e
fix: ws listen upgrade
SukkaW Feb 23, 2026
91b63fb
test: fix coverage
SukkaW Mar 1, 2026
c280335
fix(proxy): restore proxyRes error/close handlers
pi0 Mar 25, 2026
4718a42
fix(server): validate ssl option when http2 is enabled
pi0 Mar 25, 2026
3efe159
chore: sync devDependencies with main
pi0 Mar 25, 2026
e01a2fe
fix(outgoing): update removeChunked comment for http/2
pi0 Mar 25, 2026
aeb299f
refactor(utils): simplify hasEncryptedConnection
pi0 Mar 25, 2026
c4d7673
refactor(server): remove as any casts in listen()
pi0 Mar 25, 2026
91f60bb
Merge branch 'main' into http2-support
pi0 Mar 25, 2026
dd69045
test(http2): remove unnecessary async from describe callbacks
pi0 Mar 25, 2026
2ed0dfd
fix(outgoing): use explicit version check in `removeChunked`
pi0 Mar 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"sse": "^0.0.8",
"typescript": "^6.0.2",
"unbuild": "^3.6.1",
"undici": "^7.24.6",
"vitest": "^4.1.1",
"ws": "^8.20.0"
},
Expand Down
1,354 changes: 692 additions & 662 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

41 changes: 32 additions & 9 deletions src/_utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import httpNative from "node:http";
import httpsNative from "node:https";
import net from "node:net";
import type tls from "node:tls";
import type { ProxyAddr, ProxyServerOptions, ProxyTarget, ProxyTargetDetailed } from "./types.ts";
import type { Http2ServerRequest } from "node:http2";

const upgradeHeader = /(^|,)\s*upgrade\s*($|,)/i;

Expand All @@ -11,6 +11,15 @@ const upgradeHeader = /(^|,)\s*upgrade\s*($|,)/i;
*/
export const isSSL = /^https|wss/;

/**
* Node.js HTTP/2 accepts pseudo headers and it may conflict
* with request options.
*
* Let's just blacklist those potential conflicting pseudo
* headers.
*/
const HTTP2_HEADER_BLACKLIST = [":method", ":path", ":scheme", ":authority"];

/**
* Copies the right headers from `options` and `req` to
* `outgoing` which is then used to fire the proxied
Expand Down Expand Up @@ -38,7 +47,7 @@ export function setupOutgoing(
ca?: string;
method?: string;
},
req: httpNative.IncomingMessage,
req: httpNative.IncomingMessage | Http2ServerRequest,
forward?: "forward" | "target",
): httpNative.RequestOptions | httpsNative.RequestOptions {
outgoing.port =
Expand All @@ -64,10 +73,23 @@ export function setupOutgoing(
outgoing.method = options.method || req.method;
outgoing.headers = { ...req.headers };

// before clean up HTTP/2 blacklist header, we might wanna override host first
if (req.headers?.[":authority"]) {
outgoing.headers.host = req.headers[":authority"] as string;
}
// host override must happen before composing/merging the final outgoing headers

if (options.headers) {
outgoing.headers = { ...outgoing.headers, ...options.headers };
}

if (req.httpVersionMajor > 1) {
// ignore potential conflicting HTTP/2 pseudo headers
for (const header of HTTP2_HEADER_BLACKLIST) {
delete outgoing.headers[header];
}
}

if (options.auth) {
outgoing.auth = options.auth;
}
Expand Down Expand Up @@ -181,8 +203,9 @@ export function setupSocket(socket: net.Socket): net.Socket {
*
* @api private
*/
export function getPort(req: httpNative.IncomingMessage): string {
const res = req.headers.host ? req.headers.host.match(/:(\d+)/) : "";
export function getPort(req: httpNative.IncomingMessage | Http2ServerRequest): string {
const hostHeader = (req.headers[":authority"] as string | undefined) || req.headers.host;
const res = hostHeader ? hostHeader.match(/:(\d+)/) : "";
if (res) {
return res[1]!;
}
Expand All @@ -198,11 +221,11 @@ export function getPort(req: httpNative.IncomingMessage): string {
*
* @api private
*/
export function hasEncryptedConnection(req: httpNative.IncomingMessage): boolean {
return Boolean(
// req.connection.pair probably does not exist anymore
(req.connection as tls.TLSSocket).encrypted || (req.connection as any).pair,
);
export function hasEncryptedConnection(
req: httpNative.IncomingMessage | Http2ServerRequest,
): boolean {
const socket = req.socket;
return !!socket && "encrypted" in socket && socket.encrypted;
}

/**
Expand Down
17 changes: 9 additions & 8 deletions src/middleware/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,26 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { Socket } from "node:net";
import type { ProxyServer } from "../server.ts";
import type { ProxyServerOptions, ProxyTargetDetailed } from "../types.ts";
import type { Http2ServerRequest, Http2ServerResponse } from "node:http2";

export type ResOfType<T extends "web" | "ws"> = T extends "ws"
? T extends "web"
? ServerResponse | Socket
? ServerResponse | Http2ServerResponse | Socket
: Socket
: T extends "web"
? ServerResponse
? ServerResponse | Http2ServerResponse
: never;

export type ProxyMiddleware<T extends ServerResponse | Socket> = (
req: IncomingMessage,
export type ProxyMiddleware<T extends ServerResponse | Http2ServerResponse | Socket> = (
req: IncomingMessage | Http2ServerRequest,
res: T,
opts: ProxyServerOptions & {
target: URL | ProxyTargetDetailed;
forward: URL;
},
server: ProxyServer,
server: ProxyServer<IncomingMessage | Http2ServerRequest, ServerResponse | Http2ServerResponse>,
head?: Buffer,
callback?: (err: any, req: IncomingMessage, socket: T, url?: any) => void,
callback?: (err: any, req: IncomingMessage | Http2ServerRequest, socket: T, url?: any) => void,
) => void | true;

export function defineProxyMiddleware<T extends ServerResponse | Socket = ServerResponse>(
Expand All @@ -30,8 +31,8 @@ export function defineProxyMiddleware<T extends ServerResponse | Socket = Server
}

export type ProxyOutgoingMiddleware = (
req: IncomingMessage,
res: ServerResponse,
req: IncomingMessage | Http2ServerRequest,
res: ServerResponse | Http2ServerResponse,
proxyRes: IncomingMessage,
opts: ProxyServerOptions & {
target: URL | ProxyTargetDetailed;
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/web-incoming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ export const XHeaders = defineProxyMiddleware((req, res, options) => {
values[header];
}

req.headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || req.headers.host || "";
req.headers["x-forwarded-host"] =
req.headers["x-forwarded-host"] || req.headers[":authority"] || req.headers.host || "";
});

/**
Expand Down
35 changes: 24 additions & 11 deletions src/middleware/web-outgoing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,29 @@ import { type ProxyOutgoingMiddleware, defineProxyOutgoingMiddleware } from "./_
const redirectRegex = /^201|30([1278])$/;

/**
* If is a HTTP 1.0 request, remove chunk headers
* Remove chunked transfer-encoding for HTTP/1.0 and HTTP/2 requests
*/
export const removeChunked = defineProxyOutgoingMiddleware((req, res, proxyRes) => {
if (req.httpVersion === "1.0") {
// HTTP/1.0 and HTTP/2 do not have transfer-encoding: chunked
if (req.httpVersion === "1.0" || req.httpVersionMajor >= 2) {
delete proxyRes.headers["transfer-encoding"];
}
});

/**
* If is a HTTP 1.0 request, set the correct connection header
* or if connection header not present, then use `keep-alive`
*
* If is a HTTP/2 request, remove connection header no matter what,
* this avoids sending connection header to the underlying http2 client
*/
export const setConnection = defineProxyOutgoingMiddleware((req, res, proxyRes) => {
if (req.httpVersion === "1.0") {
proxyRes.headers.connection = req.headers.connection || "close";
} else if (req.httpVersion !== "2.0" && !proxyRes.headers.connection) {
} else if (req.httpVersionMajor < 2 && !proxyRes.headers.connection) {
proxyRes.headers.connection = req.headers.connection || "keep-alive";
} else if (req.httpVersionMajor >= 2) {
delete proxyRes.headers.connection;
}
});

Expand All @@ -42,8 +48,12 @@ export const setRedirectHostRewrite = defineProxyOutgoingMiddleware(

if (options.hostRewrite) {
u.host = options.hostRewrite;
} else if (options.autoRewrite && req.headers.host) {
u.host = req.headers.host;
} else if (options.autoRewrite) {
if (req.headers[":authority"]) {
u.host = req.headers[":authority"] as string;
} else if (req.headers.host) {
u.host = req.headers.host;
}
}
if (options.protocolRewrite) {
u.protocol = options.protocolRewrite;
Expand Down Expand Up @@ -116,13 +126,16 @@ export const writeHeaders = defineProxyOutgoingMiddleware((req, res, proxyRes, o
*/
export const writeStatusCode = defineProxyOutgoingMiddleware((req, res, proxyRes) => {
// From Node.js docs: response.writeHead(statusCode[, statusMessage][, headers])
if (proxyRes.statusMessage) {
// @ts-expect-error
res.statusCode = proxyRes.statusCode;

// @ts-expect-error
res.statusCode = proxyRes.statusCode;

if (
proxyRes.statusMessage &&
// Only HTTP/1.0 and HTTP/1.1 support statusMessage
req.httpVersionMajor < 2
) {
res.statusMessage = proxyRes.statusMessage;
} else {
// @ts-expect-error
res.statusCode = proxyRes.statusCode;
}
});

Expand Down
69 changes: 38 additions & 31 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import http from "node:http";
import https from "node:https";
import http2 from "node:http2";
import { EventEmitter } from "node:events";
import { webIncomingMiddleware } from "./middleware/web-incoming.ts";
import { websocketIncomingMiddleware } from "./middleware/ws-incoming.ts";
Expand All @@ -8,8 +9,8 @@ import type { ProxyMiddleware, ResOfType } from "./middleware/_utils.ts";
import type net from "node:net";

export interface ProxyServerEventMap<
Req extends http.IncomingMessage = http.IncomingMessage,
Res extends http.ServerResponse = http.ServerResponse,
Req extends http.IncomingMessage | http2.Http2ServerRequest = http.IncomingMessage,
Res extends http.ServerResponse | http2.Http2ServerResponse = http.ServerResponse,
> {
error: [err: Error, req?: Req, res?: Res | net.Socket, target?: URL | ProxyTarget];
start: [req: Req, res: Res, target: URL | ProxyTarget];
Expand All @@ -32,29 +33,20 @@ export interface ProxyServerEventMap<

// eslint-disable-next-line unicorn/prefer-event-target
export class ProxyServer<
Req extends http.IncomingMessage = http.IncomingMessage,
Res extends http.ServerResponse = http.ServerResponse,
Req extends http.IncomingMessage | http2.Http2ServerRequest = http.IncomingMessage,
Res extends http.ServerResponse | http2.Http2ServerResponse = http.ServerResponse,
> extends EventEmitter<ProxyServerEventMap<Req, Res>> {
private _server?: http.Server | https.Server;
// we use http2.Http2Server to handle HTTP/1.1 HTTPS as well (with allowHTTP1 enabled)
private _server?: http.Server | https.Server | http2.Http2SecureServer;

_webPasses: ProxyMiddleware<http.ServerResponse>[] = [...webIncomingMiddleware];
_wsPasses: ProxyMiddleware<net.Socket>[] = [...websocketIncomingMiddleware];

options: ProxyServerOptions;

web: (
req: http.IncomingMessage,
res: http.ServerResponse,
opts?: ProxyServerOptions,
head?: any,
) => Promise<void>;
web: (req: Req, res: Res, opts?: ProxyServerOptions, head?: any) => Promise<void>;

ws: (
req: http.IncomingMessage,
socket: net.Socket,
opts: ProxyServerOptions,
head?: any,
) => Promise<void>;
ws: (req: Req, socket: net.Socket, opts: ProxyServerOptions, head?: any) => Promise<void>;

/**
* Creates the proxy server with specified options.
Expand All @@ -76,18 +68,27 @@ export class ProxyServer<
* @param hostname - The hostname to listen on
*/
listen(port: number, hostname?: string) {
const closure = (req: http.IncomingMessage, res: http.ServerResponse) => {
this.web(req, res);
const closure = (
req: http.IncomingMessage | http2.Http2ServerRequest,
res: http.ServerResponse | http2.Http2ServerResponse,
) => {
return this.web(req as Req, res as Res);
};

this._server = this.options.ssl
? https.createServer(this.options.ssl, closure)
: http.createServer(closure);
if (this.options.http2) {
if (!this.options.ssl) {
throw new Error("HTTP/2 requires ssl option");
}
this._server = http2.createSecureServer({ ...this.options.ssl, allowHTTP1: true }, closure);
} else if (this.options.ssl) {
this._server = https.createServer(this.options.ssl, closure);
} else {
this._server = http.createServer(closure);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (this.options.ws) {
this._server.on("upgrade", (req, socket, head) => {
// @ts-expect-error
this.ws(req, socket, head).catch(() => {});
this.ws(req, socket, this.options, head).catch(() => {});
});
}

Expand Down Expand Up @@ -181,12 +182,15 @@ export function createProxyServer(options: ProxyServerOptions = {}) {

// --- Internal ---

function _createProxyFn<Type extends "web" | "ws">(type: Type, server: ProxyServer) {
type Res = ResOfType<Type>;
function _createProxyFn<
Type extends "web" | "ws",
ProxyServerReq extends http.IncomingMessage | http2.Http2ServerRequest,
ProxyServerRes extends http.ServerResponse | http2.Http2ServerResponse,
>(type: Type, server: ProxyServer<ProxyServerReq, ProxyServerRes>) {
return function (
this: ProxyServer,
req: http.IncomingMessage,
res: Res,
this: ProxyServer<ProxyServerReq, ProxyServerRes>,
req: ProxyServerReq,
res: ResOfType<Type>,
opts?: ProxyServerOptions,
head?: any,
): Promise<void> {
Expand Down Expand Up @@ -222,11 +226,14 @@ function _createProxyFn<Type extends "web" | "ws">(type: Type, server: ProxyServ
req,
res,
requestOptions as ProxyServerOptions & { target: URL; forward: URL },
server,
server as ProxyServer<
http.IncomingMessage | http2.Http2ServerRequest,
http.ServerResponse | http2.Http2ServerResponse
>,
head,
(error) => {
if (server.listenerCount("error") > 0) {
server.emit("error", error, req, res);
server.emit("error", error, req, res as ProxyServerRes | net.Socket);
_resolve();
} else {
_reject(error);
Expand Down
6 changes: 5 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export interface ProxyServerOptions {
forward?: ProxyTarget;
/** Object to be passed to http(s).request. */
agent?: any;
/** Object to be passed to https.createServer(). */
/** Enable HTTP/2 listener, default is `false` */
http2?: boolean;
/** Object to be passed to https.createServer()
* or http2.createSecureServer() if the `http2` option is enabled
*/
ssl?: any;
/** If you want to proxy websockets. */
ws?: boolean;
Expand Down
11 changes: 9 additions & 2 deletions test/_stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export function createOutgoing(): OutgoingOptions {
// --- IncomingMessage stubs ---

export function stubIncomingMessage(overrides: Record<string, unknown> = {}): IncomingMessage {
return { method: "GET", url: "/", headers: {}, ...overrides } as unknown as IncomingMessage;
return {
method: "GET",
url: "/",
headers: {},
httpVersion: "1.1",
httpVersionMajor: 1,
...overrides,
} as unknown as IncomingMessage;
}

// --- ServerResponse stub ---
Expand Down Expand Up @@ -50,6 +57,6 @@ export function stubMiddlewareOptions(overrides: Record<string, unknown> = {}):

// --- ProxyServer stub ---

export function stubProxyServer(overrides: Record<string, unknown> = {}): ProxyServer {
export function stubProxyServer(overrides: Record<string, unknown> = {}): ProxyServer<any, any> {
return overrides as unknown as ProxyServer;
}
Loading
Loading