Skip to content

Commit e02aad4

Browse files
authored
Merge pull request #42 from Corvince/fetch-refinements
Fetch refinements
2 parents e0344dc + fb178e1 commit e02aad4

File tree

12 files changed

+232
-68
lines changed

12 files changed

+232
-68
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,6 @@ jobs:
4444
- run: pnpm build
4545
- name: Test native HTTP code path
4646
run: pnpm test-native
47+
- name: Test fetch HTTP code path
48+
run: pnpm test-fetch
4749

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ const proxy = createProxyServer({
641641
642642
- **ca**: Optionally override the trusted CA certificates. This is passed to https.request.
643643
644-
- **fetchOptions**: Enable fetch API for HTTP/2 support. Set to `true` for defaults, or provide custom configuration:
644+
- **fetchOptions**: Enable fetch API for HTTP/2 support. Provide an object of type `FetchOptions` for custom configuration:
645645
- `requestOptions`: Additional fetch request options (e.g., undici Agent with `allowH2: true` for HTTP/2 as dispatcher)
646646
- `onBeforeRequest`: Async callback called before making the fetch request
647647
- `onAfterResponse`: Async callback called after receiving the fetch response
@@ -665,7 +665,7 @@ The following table shows which configuration options are compatible with differ
665665
|--------|-------------------|---------------|--------|
666666
| `target` | ✅ | ✅ | Core option, works in both paths |
667667
| `forward` | ✅ | ✅ | Core option, works in both paths |
668-
| `agent` | ✅ | ❌ | Native agents only, use `fetch.dispatcher` instead |
668+
| `agent` | ✅ | ❌ | Native agents only |
669669
| `ssl` | ✅ | ✅ | HTTPS server configuration |
670670
| `ws` | ✅ | ❌ | WebSocket proxying uses native path only |
671671
| `xfwd` | ✅ | ✅ | X-Forwarded headers |
@@ -674,8 +674,8 @@ The following table shows which configuration options are compatible with differ
674674
| `prependPath` | ✅ | ✅ | Path manipulation |
675675
| `ignorePath` | ✅ | ✅ | Path manipulation |
676676
| `localAddress` | ✅ | ✅ | Local interface binding |
677-
| `changeOrigin` | ✅ | | Host header rewriting |
678-
| `preserveHeaderKeyCase` | ✅ | | Header case preservation |
677+
| `changeOrigin` | ✅ | | Host header rewriting |
678+
| `preserveHeaderKeyCase` | ✅ | | Header case preservation |
679679
| `auth` | ✅ | ✅ | Basic authentication |
680680
| `hostRewrite` | ✅ | ✅ | Redirect hostname rewriting |
681681
| `autoRewrite` | ✅ | ✅ | Automatic redirect rewriting |
@@ -693,7 +693,7 @@ The following table shows which configuration options are compatible with differ
693693
| `fetch` | ❌ | ✅ | Fetch-specific configuration |
694694
695695
**Notes:**
696-
- ¹ `secure` is not directly supported in the fetch path. Instead, use `fetch.dispatcher` with `{connect: {rejectUnauthorized: false}}` to disable SSL certificate verification (e.g., for self-signed certificates).
696+
- ¹ `secure` is not directly supported in the fetch path. Instead, use a custom dispatcher with `{rejectUnauthorized: false}` to disable SSL certificate verification (e.g., for self-signed certificates).
697697
698698
**Code Path Selection:**
699699
- **Native Path**: Used by default, supports HTTP/1.1 and WebSockets

lib/http-proxy/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,14 @@ export interface ServerOptions {
9797
* This is passed to https.request.
9898
*/
9999
ca?: string;
100-
/** Enable using fetch for proxy requests. Set to true for defaults, or provide custom configuration. */
101-
fetchOptions?: FetchOptions;
102-
fetch?: typeof fetch;
100+
/** Optional fetch implementation to use instead of global fetch, use this to activate fetch-based proxying,
101+
* for example to proxy HTTP/2 requests
102+
*/
103+
fetch?: typeof fetch;
104+
/** Optional configuration object for fetch-based proxy requests.
105+
* Use this to customize fetch request and response handling.
106+
* For custom fetch implementations, use the `fetch` property.*/
107+
fetchOptions?: FetchOptions;
103108
}
104109
export interface FetchOptions {
105110
/** Fetch request options */

lib/http-proxy/passes/web-incoming.ts

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ async function stream2(
208208
) {
209209
// Helper function to handle errors consistently throughout the fetch path
210210
const handleError = (err: Error, target?: ProxyTargetUrl) => {
211+
const e = err as any;
212+
// Copy code from cause if available and missing on err
213+
if (e.code === undefined && e.cause?.code) {
214+
e.code = e.cause.code;
215+
}
216+
211217
if (cb) {
212218
cb(err, req, res, target);
213219
} else {
@@ -227,26 +233,58 @@ async function stream2(
227233
});
228234

229235
const customFetch = options.fetch || fetch;
230-
231236
const fetchOptions = options.fetchOptions ?? {} as FetchOptions;
232237

233-
234-
if (options.forward) {
235-
const outgoingOptions = common.setupOutgoing(options.ssl || {}, options, req, "forward");
236-
238+
const prepareRequest = (outgoing: common.Outgoing) => {
237239
const requestOptions: RequestInit = {
238-
method: outgoingOptions.method,
240+
method: outgoing.method,
241+
...fetchOptions.requestOptions,
239242
};
240243

244+
const headers = new Headers(fetchOptions.requestOptions?.headers);
245+
246+
if (!fetchOptions.requestOptions?.headers && outgoing.headers) {
247+
for (const [key, value] of Object.entries(outgoing.headers)) {
248+
if (typeof key === "string") {
249+
if (Array.isArray(value)) {
250+
for (const v of value) {
251+
headers.append(key, v as string);
252+
}
253+
} else if (value != null) {
254+
headers.append(key, value as string);
255+
}
256+
}
257+
}
258+
}
259+
260+
if (options.auth) {
261+
headers.set("authorization", `Basic ${Buffer.from(options.auth).toString("base64")}`);
262+
}
263+
264+
if (options.proxyTimeout) {
265+
requestOptions.signal = AbortSignal.timeout(options.proxyTimeout);
266+
}
267+
268+
requestOptions.headers = headers;
241269

242-
// Handle request body
243270
if (options.buffer) {
244271
requestOptions.body = options.buffer as Stream.Readable;
245272
} else if (req.method !== "GET" && req.method !== "HEAD") {
246273
requestOptions.body = req;
247274
requestOptions.duplex = "half";
248275
}
249276

277+
return requestOptions;
278+
};
279+
280+
if (options.forward) {
281+
const outgoingOptions = common.setupOutgoing(options.ssl || {}, options, req, "forward");
282+
const requestOptions = prepareRequest(outgoingOptions);
283+
let targetUrl = new URL(outgoingOptions.url).origin + outgoingOptions.path;
284+
if (targetUrl.startsWith("ws")) {
285+
targetUrl = targetUrl.replace("ws", "http");
286+
}
287+
250288
// Call onBeforeRequest callback before making the forward request
251289
if (fetchOptions.onBeforeRequest) {
252290
try {
@@ -258,7 +296,7 @@ async function stream2(
258296
}
259297

260298
try {
261-
const result = await customFetch(new URL(outgoingOptions.url).origin + outgoingOptions.path, requestOptions);
299+
const result = await customFetch(targetUrl, requestOptions);
262300

263301
// Call onAfterResponse callback for forward requests (though they typically don't expect responses)
264302
if (fetchOptions.onAfterResponse) {
@@ -279,30 +317,10 @@ async function stream2(
279317
}
280318

281319
const outgoingOptions = common.setupOutgoing(options.ssl || {}, options, req);
282-
283-
// Remove symbols from headers
284-
const requestOptions: RequestInit = {
285-
method: outgoingOptions.method,
286-
headers: Object.fromEntries(
287-
Object.entries(outgoingOptions.headers || {}).filter(([key, _value]) => {
288-
return typeof key === "string";
289-
}),
290-
) as RequestInit["headers"],
291-
...fetchOptions.requestOptions,
292-
};
293-
294-
if (options.auth) {
295-
requestOptions.headers = {
296-
...requestOptions.headers,
297-
authorization: `Basic ${Buffer.from(options.auth).toString("base64")}`,
298-
};
299-
}
300-
301-
if (options.buffer) {
302-
requestOptions.body = options.buffer as Stream.Readable;
303-
} else if (req.method !== "GET" && req.method !== "HEAD") {
304-
requestOptions.body = req;
305-
requestOptions.duplex = "half";
320+
const requestOptions = prepareRequest(outgoingOptions);
321+
let targetUrl = new URL(outgoingOptions.url).origin + outgoingOptions.path;
322+
if (targetUrl.startsWith("ws")) {
323+
targetUrl = targetUrl.replace("ws", "http");
306324
}
307325

308326
// Call onBeforeRequest callback before making the request
@@ -316,7 +334,7 @@ async function stream2(
316334
}
317335

318336
try {
319-
const response = await customFetch(new URL(outgoingOptions.url).origin + outgoingOptions.path, requestOptions);
337+
const response = await customFetch(targetUrl, requestOptions);
320338

321339
// Call onAfterResponse callback after receiving the response
322340
if (fetchOptions.onAfterResponse) {

lib/test/http/customFetch.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
pnpm test proxy-http2-to-http2.test.ts
2+
pnpm test customFetch.test.ts
33
44
*/
55

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
2+
import * as http from "node:http";
3+
import * as httpProxy from "../..";
4+
import getPort from "../get-port";
5+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
6+
import { fetch } from "undici";
7+
8+
describe("Fetch Proxy Timeout", () => {
9+
let ports: Record<"http" | "proxy", number>;
10+
beforeAll(async () => {
11+
ports = { http: await getPort(), proxy: await getPort() };
12+
});
13+
14+
const servers: Record<string, any> = {};
15+
16+
it("Create the target HTTP server that hangs", async () => {
17+
servers.http = http
18+
.createServer((_req, _res) => {
19+
// Do nothing, let it hang
20+
})
21+
.listen(ports.http);
22+
});
23+
24+
it("Create the proxy server with fetch and timeout", async () => {
25+
servers.proxy = httpProxy
26+
.createServer({
27+
target: `http://localhost:${ports.http}`,
28+
fetch: fetch as any, // Enable fetch path
29+
proxyTimeout: 500, // 500ms timeout
30+
})
31+
.listen(ports.proxy);
32+
});
33+
34+
it("should timeout the request and emit error", async () => {
35+
return new Promise<void>((resolve, reject) => {
36+
const timeout = setTimeout(() => {
37+
reject(new Error("Test timed out"));
38+
}, 2000);
39+
40+
servers.proxy.once('error', (err: Error, _req: any, res: any) => {
41+
clearTimeout(timeout);
42+
try {
43+
expect(err).toBeTruthy();
44+
expect(err.message).toMatchInlineSnapshot(`"The operation was aborted due to timeout"`);
45+
res.statusCode = 504;
46+
res.end("Gateway Timeout");
47+
resolve();
48+
} catch (e) {
49+
reject(e);
50+
}
51+
});
52+
53+
fetch(`http://localhost:${ports.proxy}`).catch(() => {
54+
// Ignore client side fetch error, we care about server side error emission
55+
});
56+
});
57+
});
58+
59+
afterAll(async () => {
60+
Object.values(servers).map((x: any) => x?.close());
61+
});
62+
});

lib/test/http/xfwd-http2.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
2+
import * as http from "node:http";
3+
import * as httpProxy from "../..";
4+
import getPort from "../get-port";
5+
import { join } from "node:path";
6+
import { readFile } from "node:fs/promises";
7+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
8+
import { Agent, fetch } from "undici";
9+
10+
const TestAgent = new Agent({ allowH2: true, connect: { rejectUnauthorized: false } });
11+
12+
const fixturesDir = join(__dirname, "..", "fixtures");
13+
14+
describe("X-Forwarded-Host with HTTP/2", () => {
15+
let ports: Record<"http" | "proxy", number>;
16+
beforeAll(async () => {
17+
ports = { http: await getPort(), proxy: await getPort() };
18+
});
19+
20+
const servers: any = {};
21+
22+
it("Create the target HTTP server", async () => {
23+
servers.http = http
24+
.createServer((req, res) => {
25+
res.writeHead(200, { "Content-Type": "application/json" });
26+
res.write(JSON.stringify(req.headers));
27+
res.end();
28+
})
29+
.listen(ports.http);
30+
});
31+
32+
it("Create the HTTPS proxy server with xfwd", async () => {
33+
servers.proxy = httpProxy
34+
.createServer({
35+
target: {
36+
host: "localhost",
37+
port: ports.http,
38+
},
39+
ssl: {
40+
key: await readFile(join(fixturesDir, "agent2-key.pem"), "utf8"),
41+
cert: await readFile(join(fixturesDir, "agent2-cert.pem"), "utf8"),
42+
},
43+
xfwd: true,
44+
})
45+
.listen(ports.proxy);
46+
});
47+
48+
it("should pass x-forwarded-host when using HTTP/2", async () => {
49+
const res = await fetch(`https://localhost:${ports.proxy}`, { dispatcher: TestAgent });
50+
const headers = await res.json() as any;
51+
52+
// In HTTP/2, :authority is used instead of Host.
53+
// The proxy should map :authority to x-forwarded-host if Host is missing.
54+
expect(headers["x-forwarded-host"]).toBe(`localhost:${ports.proxy}`);
55+
});
56+
57+
afterAll(async () => {
58+
// cleans up
59+
Object.values(servers).map((x: any) => x?.close());
60+
});
61+
});

0 commit comments

Comments
 (0)