Skip to content

Commit efa9711

Browse files
committed
feat(fetch): add proxyFetch options for timeout, xfwd, changeOrigin, agent, followRedirects, HTTPS, and path merging
- Adds ProxyFetchOptions as optional 4th argument with: timeout, xfwd, changeOrigin, agent - followRedirects (with 307/308 body replay), ssl/secure for HTTPS upstream, and addr base - path merging via joinURL(). Also fixes multi-value request header flattening and adds - ArrayBuffer/TypedArray/Blob body support. Wires AbortSignal via standard RequestInit.signal
1 parent f8d5a0a commit efa9711

4 files changed

Lines changed: 586 additions & 31 deletions

File tree

AGENTS.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,24 @@ Returns Promise<Socket> (the upstream proxy socket)
106106

107107
### `proxyFetch` semantics
108108

109-
- `proxyFetch` is HTTP-only upstream (`node:http` request); HTTPS upstream targets are not supported.
110-
- `addr` accepts `http://host:port`, `unix:/path.sock`, or object form `{ host, port }` / `{ socketPath }`.
109+
- `addr` accepts `http://host:port`, `https://host:port`, `unix:/path.sock`, or object form `{ host, port }` / `{ socketPath }`.
110+
- Both HTTP and HTTPS upstream targets are supported. HTTPS is auto-detected from the `addr` string protocol.
111+
- When `addr` is a URL string with a path (e.g. `http://host:port/api`), the path is prepended to the request path via `joinURL()`.
111112
- Redirect mode defaults to `manual`.
112113
- Streaming request bodies are supported (`ReadableStream`) and set `duplex: "half"`.
113114
- Hop-by-hop response headers `transfer-encoding`, `keep-alive`, `connection` are stripped.
114115
- Response body is `null` for `204` and `304`.
115116
- Network and request-body-stream errors reject the Promise.
117+
- Accepts optional `ProxyFetchOptions` as 4th argument with `timeout`, `xfwd`, `changeOrigin`, `agent`, `followRedirects`, and `ssl`.
118+
- `timeout` sets a deadline on the upstream request; rejects with `"Proxy request timed out"` on expiry.
119+
- `xfwd` adds `x-forwarded-for`, `x-forwarded-port`, `x-forwarded-proto`, `x-forwarded-host` derived from the input URL (not from a socket, since there is no incoming connection). Existing headers are not overwritten.
120+
- `changeOrigin` rewrites the `Host` header to match the resolved target address (host:port for TCP, `localhost` for Unix sockets). Accounts for default ports (80 for HTTP, 443 for HTTPS).
121+
- `agent` enables connection pooling/reuse via a custom `http.Agent`. Defaults to `false` (no agent).
122+
- `followRedirects` enables automatic redirect following. `true` = max 5 hops; number = custom max. On 301/302/303 method changes to GET and body is dropped. On 307/308 method and body are preserved (body is buffered). Sensitive headers (`authorization`, `cookie`) are stripped on cross-origin redirects.
123+
- `ssl` passes TLS options to `https.request` (e.g. `{ rejectUnauthorized: false }`).
124+
- `AbortSignal` support is wired through `init.signal` (standard `RequestInit`), aborting the underlying `http.request`.
125+
- Multi-value request headers are preserved as arrays (not flattened by the `Headers` API).
126+
- Body types `ArrayBuffer`, `TypedArray`, and `Blob` are properly converted to `Buffer` before sending.
116127

117128
### `proxyUpgrade` semantics
118129

@@ -130,7 +141,7 @@ Returns Promise<Socket> (the upstream proxy socket)
130141
```
131142
test/
132143
├── index.test.ts — Main proxy: paths, headers, changeOrigin, xfwd, WebSocket, errors
133-
├── fetch.test.ts — proxyFetch: TCP/Unix, GET/POST, redirects, cookies, 204/304
144+
├── fetch.test.ts — proxyFetch: TCP/Unix, GET/POST, redirects, cookies, 204/304, signal, timeout, xfwd, changeOrigin
134145
├── upgrade.test.ts — proxyUpgrade: WS proxy, addr formats, xfwd, error handling
135146
├── http-proxy.test.ts — Forward, target, WebSocket, socket.io, SSE, timeouts, error events
136147
├── https-proxy.test.ts — HTTPS targets, SSL certs, certificate validation

src/fetch.ts

Lines changed: 254 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,79 @@
11
import type { IncomingMessage, RequestOptions } from "node:http";
22
import { request as httpRequest } from "node:http";
3+
import { request as httpsRequest } from "node:https";
34
import { Readable } from "node:stream";
45
import type { ProxyAddr } from "./types.ts";
5-
import { parseAddr } from "./_utils.ts";
6+
import { isSSL, joinURL, parseAddr } from "./_utils.ts";
7+
8+
/**
9+
* Options for {@link proxyFetch}.
10+
*/
11+
export interface ProxyFetchOptions {
12+
/**
13+
* Timeout in milliseconds for the upstream request.
14+
* Rejects with an error if the upstream does not respond within this time.
15+
*/
16+
timeout?: number;
17+
/**
18+
* Add `x-forwarded-for`, `x-forwarded-port`, `x-forwarded-proto`, and
19+
* `x-forwarded-host` headers derived from the input URL.
20+
* Default: `false`.
21+
*/
22+
xfwd?: boolean;
23+
/**
24+
* Rewrite the `Host` header to match the target address.
25+
* Default: `false` (original host from the input URL is kept).
26+
*/
27+
changeOrigin?: boolean;
28+
/**
29+
* HTTP agent for connection pooling / reuse.
30+
* Default: `false` (no agent, no keep-alive).
31+
*/
32+
agent?: any;
33+
/**
34+
* Follow HTTP redirects from the upstream.
35+
* `true` = max 5 hops; number = custom max.
36+
* Default: `false` (manual redirect, raw 3xx responses are returned).
37+
*/
38+
followRedirects?: boolean | number;
39+
/**
40+
* TLS options forwarded to `https.request` (e.g. `{ rejectUnauthorized: false }`).
41+
* Also controls certificate verification — set `rejectUnauthorized: false` to skip.
42+
* Default: none.
43+
*/
44+
ssl?: Record<string, unknown>;
45+
}
646

747
/**
848
* Proxy a request to a specific server address (TCP host/port or Unix socket)
949
* using web standard {@link Request}/{@link Response} interfaces.
1050
*
11-
* Note: Only plain HTTP is supported. HTTPS targets are not supported.
51+
* Supports both HTTP and HTTPS upstream targets.
1252
*
13-
* @param addr - The target server address. Can be a URL string (`http://host:port`, `unix:/path`), or an object with `host`/`port` for TCP or `socketPath` for Unix sockets.
53+
* @param addr - The target server address. Can be a URL string (`http://host:port`, `https://host:port`, `unix:/path`), or an object with `host`/`port` for TCP or `socketPath` for Unix sockets.
1454
* @param input - The request URL (string or URL) or a {@link Request} object.
1555
* @param inputInit - Optional {@link RequestInit} or {@link Request} to override method, headers, and body.
56+
* @param opts - Optional proxy options.
1657
*/
1758
export async function proxyFetch(
1859
addr: string | ProxyAddr,
1960
input: string | URL | Request,
2061
inputInit?: RequestInit | Request,
62+
opts?: ProxyFetchOptions,
2163
) {
2264
const resolvedAddr = parseAddr(addr);
2365

66+
// Detect protocol and base path from addr string
67+
let useHTTPS = false;
68+
let addrBasePath = "";
69+
if (typeof addr === "string" && !addr.startsWith("unix:")) {
70+
const addrURL = new URL(addr);
71+
useHTTPS = isSSL.test(addrURL.protocol);
72+
if (addrURL.pathname && addrURL.pathname !== "/") {
73+
addrBasePath = addrURL.pathname;
74+
}
75+
}
76+
2477
let url: URL;
2578
let init: RequestInit | undefined;
2679

@@ -42,44 +95,82 @@ export async function proxyFetch(
4295
(init as RequestInit & { duplex: string }).duplex = "half";
4396
}
4497

45-
const path = url.pathname + url.search;
46-
const reqHeaders: Record<string, string> = {};
98+
// Merge addr base path with request path
99+
const requestPath = url.pathname + url.search;
100+
const path = addrBasePath ? joinURL(addrBasePath, requestPath) : requestPath;
101+
102+
const reqHeaders: Record<string, string | string[]> = {};
47103
if (init.headers) {
48104
const h =
49105
init.headers instanceof Headers ? init.headers : new Headers(init.headers as HeadersInit);
50106
for (const [key, value] of h) {
51-
reqHeaders[key] = value;
107+
// Preserve multi-value headers (e.g. set-cookie) as arrays
108+
if (key in reqHeaders) {
109+
const existing = reqHeaders[key];
110+
reqHeaders[key] = Array.isArray(existing)
111+
? [...existing, value]
112+
: [existing as string, value];
113+
} else {
114+
reqHeaders[key] = value;
115+
}
52116
}
53117
}
54118

55-
const res = await new Promise<IncomingMessage>((resolve, reject) => {
56-
const reqOpts: RequestOptions = {
57-
method: init!.method || "GET",
58-
path,
59-
headers: reqHeaders,
60-
};
119+
// Add x-forwarded-* headers derived from the input URL
120+
if (opts?.xfwd) {
121+
if (!reqHeaders["x-forwarded-for"]) {
122+
reqHeaders["x-forwarded-for"] = url.hostname;
123+
}
124+
if (!reqHeaders["x-forwarded-port"]) {
125+
reqHeaders["x-forwarded-port"] = url.port || (url.protocol === "https:" ? "443" : "80");
126+
}
127+
if (!reqHeaders["x-forwarded-proto"]) {
128+
reqHeaders["x-forwarded-proto"] = url.protocol.replace(":", "");
129+
}
130+
if (!reqHeaders["x-forwarded-host"]) {
131+
reqHeaders["x-forwarded-host"] = url.host;
132+
}
133+
}
61134

135+
// Rewrite Host header to match the target address
136+
if (opts?.changeOrigin) {
62137
if (resolvedAddr.socketPath) {
63-
reqOpts.socketPath = resolvedAddr.socketPath;
138+
reqHeaders.host = "localhost";
64139
} else {
65-
reqOpts.hostname = resolvedAddr.host || "localhost";
66-
reqOpts.port = resolvedAddr.port;
140+
const targetHost = resolvedAddr.host || "localhost";
141+
const targetPort = resolvedAddr.port;
142+
const defaultPort = useHTTPS ? 443 : 80;
143+
reqHeaders.host =
144+
targetPort && targetPort !== defaultPort ? `${targetHost}:${targetPort}` : targetHost;
67145
}
146+
}
68147

69-
const req = httpRequest(reqOpts, resolve);
70-
req.on("error", reject);
148+
const maxRedirects =
149+
typeof opts?.followRedirects === "number"
150+
? opts.followRedirects
151+
: opts?.followRedirects
152+
? 5
153+
: 0;
71154

72-
if (init!.body instanceof ReadableStream) {
73-
const readable = Readable.fromWeb(init!.body as import("node:stream/web").ReadableStream);
74-
readable.on("error", reject);
75-
readable.pipe(req);
76-
} else if (init!.body) {
77-
req.end(init!.body);
78-
} else {
79-
req.end();
80-
}
81-
});
155+
const res = await _sendRequest(
156+
useHTTPS ? httpsRequest : httpRequest,
157+
init.method || "GET",
158+
path,
159+
reqHeaders,
160+
resolvedAddr,
161+
await _bufferBody(init.body),
162+
{
163+
signal: init.signal || undefined,
164+
agent: opts?.agent,
165+
timeout: opts?.timeout,
166+
ssl: opts?.ssl,
167+
maxRedirects,
168+
redirectCount: 0,
169+
originalHeaders: reqHeaders,
170+
},
171+
);
82172

173+
// Build Response
83174
const headers = new Headers();
84175
for (const [key, value] of Object.entries(res.headers)) {
85176
if (key === "transfer-encoding" || key === "keep-alive" || key === "connection") {
@@ -102,6 +193,8 @@ export async function proxyFetch(
102193
});
103194
}
104195

196+
// --- Internal ---
197+
105198
function toInit(init?: RequestInit | Request): RequestInit | undefined {
106199
if (!init) {
107200
return undefined;
@@ -116,3 +209,137 @@ function toInit(init?: RequestInit | Request): RequestInit | undefined {
116209
}
117210
return init;
118211
}
212+
213+
/** Normalize any body type to Buffer (or undefined). */
214+
async function _bufferBody(body: BodyInit | null | undefined): Promise<Buffer | undefined> {
215+
if (!body) {
216+
return undefined;
217+
}
218+
if (body instanceof ReadableStream) {
219+
const readable = Readable.fromWeb(body as import("node:stream/web").ReadableStream);
220+
const chunks: Buffer[] = [];
221+
for await (const chunk of readable) {
222+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
223+
}
224+
return Buffer.concat(chunks);
225+
}
226+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
227+
return Buffer.from(body as ArrayBuffer);
228+
}
229+
if (body instanceof Blob) {
230+
return Buffer.from(await body.arrayBuffer());
231+
}
232+
return Buffer.from(body as string);
233+
}
234+
235+
const _redirectStatuses = new Set([301, 302, 303, 307, 308]);
236+
237+
interface _RequestOpts {
238+
signal?: AbortSignal;
239+
agent?: any;
240+
timeout?: number;
241+
ssl?: Record<string, unknown>;
242+
maxRedirects: number;
243+
redirectCount: number;
244+
originalHeaders: Record<string, string | string[]>;
245+
}
246+
247+
function _sendRequest(
248+
doRequest: typeof httpRequest,
249+
method: string,
250+
path: string,
251+
headers: Record<string, string | string[]>,
252+
addr: ProxyAddr,
253+
body: Buffer | undefined,
254+
opts: _RequestOpts,
255+
): Promise<IncomingMessage> {
256+
return new Promise<IncomingMessage>((resolve, reject) => {
257+
const reqOpts: RequestOptions = {
258+
method,
259+
path,
260+
headers,
261+
agent: opts.agent ?? false,
262+
};
263+
264+
if (addr.socketPath) {
265+
reqOpts.socketPath = addr.socketPath;
266+
} else {
267+
reqOpts.hostname = addr.host || "localhost";
268+
reqOpts.port = addr.port;
269+
}
270+
271+
if (opts.signal) {
272+
reqOpts.signal = opts.signal;
273+
}
274+
275+
if (opts.ssl) {
276+
Object.assign(reqOpts, opts.ssl);
277+
}
278+
279+
const req = doRequest(reqOpts, (res) => {
280+
const statusCode = res.statusCode!;
281+
282+
if (
283+
opts.maxRedirects > 0 &&
284+
_redirectStatuses.has(statusCode) &&
285+
opts.redirectCount < opts.maxRedirects &&
286+
res.headers.location
287+
) {
288+
res.resume();
289+
290+
const currentURL = new URL(path, `http://${addr.host || "localhost"}:${addr.port || 80}`);
291+
const location = new URL(res.headers.location, currentURL);
292+
const redirectHTTPS = isSSL.test(location.protocol);
293+
294+
const preserveMethod = statusCode === 307 || statusCode === 308;
295+
const redirectMethod = preserveMethod ? method : "GET";
296+
297+
const redirectHeaders: Record<string, string | string[]> = {
298+
...opts.originalHeaders,
299+
};
300+
redirectHeaders.host = location.host;
301+
302+
if (location.host !== currentURL.host) {
303+
delete redirectHeaders.authorization;
304+
delete redirectHeaders.cookie;
305+
}
306+
307+
if (!preserveMethod) {
308+
delete redirectHeaders["content-length"];
309+
delete redirectHeaders["content-type"];
310+
delete redirectHeaders["transfer-encoding"];
311+
}
312+
313+
_sendRequest(
314+
redirectHTTPS ? httpsRequest : httpRequest,
315+
redirectMethod,
316+
location.pathname + location.search,
317+
redirectHeaders,
318+
{
319+
host: location.hostname,
320+
port: Number(location.port) || (redirectHTTPS ? 443 : 80),
321+
},
322+
preserveMethod ? body : undefined,
323+
{ ...opts, redirectCount: opts.redirectCount + 1 },
324+
).then(resolve, reject);
325+
return;
326+
}
327+
328+
resolve(res);
329+
});
330+
331+
req.on("error", reject);
332+
333+
if (opts.timeout) {
334+
req.setTimeout(opts.timeout, () => {
335+
req.destroy(new Error("Proxy request timed out"));
336+
});
337+
}
338+
339+
if (body) {
340+
req.end(body);
341+
} else {
342+
req.end();
343+
}
344+
});
345+
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export type { ProxyAddr, ProxyServerOptions, ProxyTarget, ProxyTargetDetailed } from "./types.ts";
22
export { ProxyServer, createProxyServer, type ProxyServerEventMap } from "./server.ts";
3-
export { proxyFetch } from "./fetch.ts";
3+
export { proxyFetch, type ProxyFetchOptions } from "./fetch.ts";
44
export { proxyUpgrade, type ProxyUpgradeOptions } from "./ws.ts";

0 commit comments

Comments
 (0)