Skip to content

Commit 908ab9c

Browse files
robobunClaude Botclaude
authored
feat(fetch): add proxy object format with headers support (#25090)
## Summary - Extends `fetch()` proxy option to accept an object format: `proxy: { url: string, headers?: Headers }` - Allows sending custom headers to the proxy server (useful for proxy authentication, custom routing headers, etc.) - Headers are sent in CONNECT requests (for HTTPS targets) and direct proxy requests (for HTTP targets) - User-provided `Proxy-Authorization` header overrides auto-generated credentials from URL ## Usage ```typescript // Old format (still works) fetch(url, { proxy: "http://proxy.example.com:8080" }); // New object format with headers fetch(url, { proxy: { url: "http://proxy.example.com:8080", headers: { "Proxy-Authorization": "Bearer token", "X-Custom-Proxy-Header": "value" } } }); ``` ## Test plan - [x] Test proxy object with url string works same as string proxy - [x] Test proxy object with headers sends headers to proxy (HTTP target) - [x] Test proxy object with headers sends headers in CONNECT request (HTTPS target) - [x] Test proxy object with Headers instance - [x] Test proxy object with empty headers - [x] Test proxy object with undefined headers - [x] Test user-provided Proxy-Authorization overrides URL credentials - [x] All existing proxy tests pass (25 total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 43c46b1 commit 908ab9c

File tree

9 files changed

+626
-13
lines changed

9 files changed

+626
-13
lines changed

docs/guides/http/proxy.mdx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,42 @@ In Bun, `fetch` supports sending requests through an HTTP or HTTPS proxy. This i
99
```ts proxy.ts icon="/icons/typescript.svg"
1010
await fetch("https://example.com", {
1111
// The URL of the proxy server
12-
proxy: "https://usertitle:[email protected]:8080",
12+
proxy: "https://username:[email protected]:8080",
1313
});
1414
```
1515

1616
---
1717

18-
The `proxy` option is a URL string that specifies the proxy server. It can include the username and password if the proxy requires authentication. It can be `http://` or `https://`.
18+
The `proxy` option can be a URL string or an object with `url` and optional `headers`. The URL can include the username and password if the proxy requires authentication. It can be `http://` or `https://`.
1919

2020
---
2121

22+
## Custom proxy headers
23+
24+
To send custom headers to the proxy server (useful for proxy authentication tokens, custom routing, etc.), use the object format:
25+
26+
```ts proxy-headers.ts icon="/icons/typescript.svg"
27+
await fetch("https://example.com", {
28+
proxy: {
29+
url: "https://proxy.example.com:8080",
30+
headers: {
31+
"Proxy-Authorization": "Bearer my-token",
32+
"X-Proxy-Region": "us-east-1",
33+
},
34+
},
35+
});
36+
```
37+
38+
The `headers` property accepts a plain object or a `Headers` instance. These headers are sent directly to the proxy server in `CONNECT` requests (for HTTPS targets) or in the proxy request (for HTTP targets).
39+
40+
If you provide a `Proxy-Authorization` header, it will override any credentials specified in the proxy URL.
41+
42+
---
43+
44+
## Environment variables
45+
2246
You can also set the `$HTTP_PROXY` or `$HTTPS_PROXY` environment variable to the proxy URL. This is useful when you want to use the same proxy for all requests.
2347

2448
```sh terminal icon="terminal"
25-
HTTPS_PROXY=https://usertitle:[email protected]:8080 bun run index.ts
49+
HTTPS_PROXY=https://username:[email protected]:8080 bun run index.ts
2650
```

docs/runtime/networking/fetch.mdx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,30 @@ const response = await fetch("http://example.com", {
5151

5252
### Proxying requests
5353

54-
To proxy a request, pass an object with the `proxy` property set to a URL.
54+
To proxy a request, pass an object with the `proxy` property set to a URL string:
5555

5656
```ts
5757
const response = await fetch("http://example.com", {
5858
proxy: "http://proxy.com",
5959
});
6060
```
6161

62+
You can also use an object format to send custom headers to the proxy server:
63+
64+
```ts
65+
const response = await fetch("http://example.com", {
66+
proxy: {
67+
url: "http://proxy.com",
68+
headers: {
69+
"Proxy-Authorization": "Bearer my-token",
70+
"X-Custom-Proxy-Header": "value",
71+
},
72+
},
73+
});
74+
```
75+
76+
The `headers` are sent directly to the proxy in `CONNECT` requests (for HTTPS targets) or in the proxy request (for HTTP targets). If you provide a `Proxy-Authorization` header, it overrides any credentials in the proxy URL.
77+
6278
### Custom headers
6379

6480
To set custom headers, pass an object with the `headers` property set to an object.

packages/bun-types/globals.d.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,14 +1920,44 @@ interface BunFetchRequestInit extends RequestInit {
19201920
* Override http_proxy or HTTPS_PROXY
19211921
* This is a custom property that is not part of the Fetch API specification.
19221922
*
1923+
* Can be a string URL or an object with `url` and optional `headers`.
1924+
*
19231925
* @example
19241926
* ```js
1927+
* // String format
19251928
* const response = await fetch("http://example.com", {
19261929
* proxy: "https://username:[email protected]:8080"
19271930
* });
1931+
*
1932+
* // Object format with custom headers sent to the proxy
1933+
* const response = await fetch("http://example.com", {
1934+
* proxy: {
1935+
* url: "https://127.0.0.1:8080",
1936+
* headers: {
1937+
* "Proxy-Authorization": "Bearer token",
1938+
* "X-Custom-Proxy-Header": "value"
1939+
* }
1940+
* }
1941+
* });
19281942
* ```
1929-
*/
1930-
proxy?: string;
1943+
*
1944+
* If a `Proxy-Authorization` header is provided in `proxy.headers`, it takes
1945+
* precedence over credentials parsed from the proxy URL.
1946+
*/
1947+
proxy?:
1948+
| string
1949+
| {
1950+
/**
1951+
* The proxy URL
1952+
*/
1953+
url: string;
1954+
/**
1955+
* Custom headers to send to the proxy server.
1956+
* These headers are sent in the CONNECT request (for HTTPS targets)
1957+
* or in the proxy request (for HTTP targets).
1958+
*/
1959+
headers?: Bun.HeadersInit;
1960+
};
19311961

19321962
/**
19331963
* Override the default S3 options

src/bun.js/webcore/fetch.zig

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,11 @@ pub fn Bun__fetch_(
632632
break :extract_verbose verbose;
633633
};
634634

635-
// proxy: string | undefined;
635+
// proxy: string | { url: string, headers?: Headers } | undefined;
636+
var proxy_headers: ?Headers = null;
637+
defer if (proxy_headers) |*hdrs| {
638+
hdrs.deinit();
639+
};
636640
url_proxy_buffer = extract_proxy: {
637641
const objects_to_try = [_]jsc.JSValue{
638642
options_object orelse .zero,
@@ -641,6 +645,7 @@ pub fn Bun__fetch_(
641645
inline for (0..2) |i| {
642646
if (objects_to_try[i] != .zero) {
643647
if (try objects_to_try[i].get(globalThis, "proxy")) |proxy_arg| {
648+
// Handle string format: proxy: "http://proxy.example.com:8080"
644649
if (proxy_arg.isString() and try proxy_arg.getLength(ctx) > 0) {
645650
var href = try jsc.URL.hrefFromJS(proxy_arg, globalThis);
646651
if (href.tag == .Dead) {
@@ -661,6 +666,54 @@ pub fn Bun__fetch_(
661666
allocator.free(url_proxy_buffer);
662667
break :extract_proxy buffer;
663668
}
669+
// Handle object format: proxy: { url: "http://proxy.example.com:8080", headers?: Headers }
670+
if (proxy_arg.isObject()) {
671+
// Get the URL from the proxy object
672+
const proxy_url_arg = try proxy_arg.get(globalThis, "url");
673+
if (proxy_url_arg == null or proxy_url_arg.?.isUndefinedOrNull()) {
674+
const err = ctx.toTypeError(.INVALID_ARG_VALUE, "fetch() proxy object requires a 'url' property", .{});
675+
is_error = true;
676+
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, err);
677+
}
678+
if (proxy_url_arg.?.isString() and try proxy_url_arg.?.getLength(ctx) > 0) {
679+
var href = try jsc.URL.hrefFromJS(proxy_url_arg.?, globalThis);
680+
if (href.tag == .Dead) {
681+
const err = ctx.toTypeError(.INVALID_ARG_VALUE, "fetch() proxy URL is invalid", .{});
682+
is_error = true;
683+
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, err);
684+
}
685+
defer href.deref();
686+
const buffer = try std.fmt.allocPrint(allocator, "{s}{f}", .{ url_proxy_buffer, href });
687+
url = ZigURL.parse(buffer[0..url.href.len]);
688+
if (url.isFile()) {
689+
url_type = URLType.file;
690+
} else if (url.isBlob()) {
691+
url_type = URLType.blob;
692+
}
693+
694+
proxy = ZigURL.parse(buffer[url.href.len..]);
695+
allocator.free(url_proxy_buffer);
696+
url_proxy_buffer = buffer;
697+
698+
// Get the headers from the proxy object (optional)
699+
if (try proxy_arg.get(globalThis, "headers")) |headers_value| {
700+
if (!headers_value.isUndefinedOrNull()) {
701+
if (headers_value.as(FetchHeaders)) |fetch_hdrs| {
702+
proxy_headers = Headers.from(fetch_hdrs, allocator, .{}) catch |err| bun.handleOom(err);
703+
} else if (try FetchHeaders.createFromJS(ctx, headers_value)) |fetch_hdrs| {
704+
defer fetch_hdrs.deref();
705+
proxy_headers = Headers.from(fetch_hdrs, allocator, .{}) catch |err| bun.handleOom(err);
706+
}
707+
}
708+
}
709+
710+
break :extract_proxy url_proxy_buffer;
711+
} else {
712+
const err = ctx.toTypeError(.INVALID_ARG_VALUE, "fetch() proxy.url must be a non-empty string", .{});
713+
is_error = true;
714+
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, err);
715+
}
716+
}
664717
}
665718

666719
if (globalThis.hasException()) {
@@ -1338,6 +1391,7 @@ pub fn Bun__fetch_(
13381391
.redirect_type = redirect_type,
13391392
.verbose = verbose,
13401393
.proxy = proxy,
1394+
.proxy_headers = proxy_headers,
13411395
.url_proxy_buffer = url_proxy_buffer,
13421396
.signal = signal,
13431397
.globalThis = globalThis,
@@ -1372,6 +1426,7 @@ pub fn Bun__fetch_(
13721426
body = FetchTasklet.HTTPRequestBody.Empty;
13731427
}
13741428
proxy = null;
1429+
proxy_headers = null;
13751430
url_proxy_buffer = "";
13761431
signal = null;
13771432
ssl_config = null;

src/bun.js/webcore/fetch/FetchTasklet.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,7 @@ pub const FetchTasklet = struct {
10491049
fetch_options.redirect_type,
10501050
.{
10511051
.http_proxy = proxy,
1052+
.proxy_headers = fetch_options.proxy_headers,
10521053
.hostname = fetch_options.hostname,
10531054
.signals = fetch_tasklet.signals,
10541055
.unix_socket_path = fetch_options.unix_socket_path,
@@ -1222,6 +1223,7 @@ pub const FetchTasklet = struct {
12221223
verbose: http.HTTPVerboseLevel = .none,
12231224
redirect_type: FetchRedirect = FetchRedirect.follow,
12241225
proxy: ?ZigURL = null,
1226+
proxy_headers: ?Headers = null,
12251227
url_proxy_buffer: []const u8 = "",
12261228
signal: ?*jsc.WebCore.AbortSignal = null,
12271229
globalThis: ?*JSGlobalObject,

src/http.zig

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,29 @@ fn writeProxyConnect(
328328

329329
_ = writer.write("\r\nProxy-Connection: Keep-Alive\r\n") catch 0;
330330

331+
// Check if user provided Proxy-Authorization in custom headers
332+
const user_provided_proxy_auth = if (client.proxy_headers) |hdrs| hdrs.get("proxy-authorization") != null else false;
333+
334+
// Only write auto-generated proxy_authorization if user didn't provide one
331335
if (client.proxy_authorization) |auth| {
332-
_ = writer.write("Proxy-Authorization: ") catch 0;
333-
_ = writer.write(auth) catch 0;
334-
_ = writer.write("\r\n") catch 0;
336+
if (!user_provided_proxy_auth) {
337+
_ = writer.write("Proxy-Authorization: ") catch 0;
338+
_ = writer.write(auth) catch 0;
339+
_ = writer.write("\r\n") catch 0;
340+
}
341+
}
342+
343+
// Write custom proxy headers
344+
if (client.proxy_headers) |hdrs| {
345+
const slice = hdrs.entries.slice();
346+
const names = slice.items(.name);
347+
const values = slice.items(.value);
348+
for (names, 0..) |name_ptr, idx| {
349+
_ = writer.write(hdrs.asStr(name_ptr)) catch 0;
350+
_ = writer.write(": ") catch 0;
351+
_ = writer.write(hdrs.asStr(values[idx])) catch 0;
352+
_ = writer.write("\r\n") catch 0;
353+
}
335354
}
336355

337356
_ = writer.write("\r\n") catch 0;
@@ -359,11 +378,31 @@ fn writeProxyRequest(
359378
_ = writer.write(request.path) catch 0;
360379
_ = writer.write(" HTTP/1.1\r\nProxy-Connection: Keep-Alive\r\n") catch 0;
361380

381+
// Check if user provided Proxy-Authorization in custom headers
382+
const user_provided_proxy_auth = if (client.proxy_headers) |hdrs| hdrs.get("proxy-authorization") != null else false;
383+
384+
// Only write auto-generated proxy_authorization if user didn't provide one
362385
if (client.proxy_authorization) |auth| {
363-
_ = writer.write("Proxy-Authorization: ") catch 0;
364-
_ = writer.write(auth) catch 0;
365-
_ = writer.write("\r\n") catch 0;
386+
if (!user_provided_proxy_auth) {
387+
_ = writer.write("Proxy-Authorization: ") catch 0;
388+
_ = writer.write(auth) catch 0;
389+
_ = writer.write("\r\n") catch 0;
390+
}
366391
}
392+
393+
// Write custom proxy headers
394+
if (client.proxy_headers) |hdrs| {
395+
const slice = hdrs.entries.slice();
396+
const names = slice.items(.name);
397+
const values = slice.items(.value);
398+
for (names, 0..) |name_ptr, idx| {
399+
_ = writer.write(hdrs.asStr(name_ptr)) catch 0;
400+
_ = writer.write(": ") catch 0;
401+
_ = writer.write(hdrs.asStr(values[idx])) catch 0;
402+
_ = writer.write("\r\n") catch 0;
403+
}
404+
}
405+
367406
for (request.headers) |header| {
368407
_ = writer.write(header.name) catch 0;
369408
_ = writer.write(": ") catch 0;
@@ -450,6 +489,7 @@ if_modified_since: string = "",
450489
request_content_len_buf: ["-4294967295".len]u8 = undefined,
451490

452491
http_proxy: ?URL = null,
492+
proxy_headers: ?Headers = null,
453493
proxy_authorization: ?[]u8 = null,
454494
proxy_tunnel: ?*ProxyTunnel = null,
455495
signals: Signals = .{},
@@ -466,6 +506,10 @@ pub fn deinit(this: *HTTPClient) void {
466506
this.allocator.free(auth);
467507
this.proxy_authorization = null;
468508
}
509+
if (this.proxy_headers) |*hdrs| {
510+
hdrs.deinit();
511+
this.proxy_headers = null;
512+
}
469513
if (this.proxy_tunnel) |tunnel| {
470514
this.proxy_tunnel = null;
471515
tunnel.detachAndDeref();

src/http/AsyncHTTP.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ const AtomicState = std.atomic.Value(State);
9393

9494
pub const Options = struct {
9595
http_proxy: ?URL = null,
96+
proxy_headers: ?Headers = null,
9697
hostname: ?[]u8 = null,
9798
signals: ?Signals = null,
9899
unix_socket_path: ?jsc.ZigString.Slice = null,
@@ -185,6 +186,7 @@ pub fn init(
185186
.signals = options.signals orelse this.signals,
186187
.async_http_id = this.async_http_id,
187188
.http_proxy = this.http_proxy,
189+
.proxy_headers = options.proxy_headers,
188190
.redirect_type = redirect_type,
189191
};
190192
if (options.unix_socket_path) |val| {

0 commit comments

Comments
 (0)