Skip to content

Commit e76b96b

Browse files
committed
feat(bun): support running under Bun (raw-socket CONNECT chaining)
Three changes that make proxy-chain work as a library under Bun 1.3, inspired by the approach in #649: 1. `chain.ts` no longer tunnels through `http.request().on('connect')`. Bun implements `http.request` on top of `fetch()`, which (a) rejects RFC 7230 authority-form CONNECT paths like `:443` with "fetch() URL is invalid" and (b) swallows 407/590-class upstream responses. Instead we open a raw `net`/`tls` socket, write the CONNECT request ourselves, parse the response headers, and `unshift` any remaining bytes back as tunnel payload. The synthetic response object keeps `rawHeaders` so `tunnelConnectResponded`/`tunnelConnectFailed` subscribers see the same shape as before. TLS options configured on `httpsAgent` (custom `ca`, client certs) are honored by re-using the agent's stored constructor options for the direct connection. 2. `Server.onRequest` routes `CONNECT` requests to `onConnect`: Bun delivers CONNECT through the generic 'request' event for some client shapes instead of the dedicated 'connect' event. 3. `Server.getConnectionStats` returns `undefined` under Bun. Bun does not populate `socket.bytesRead`/`bytesWritten` on http sockets, so the stats would silently read as zero — better no stats than wrong stats. (`connectionClosed` events consequently carry `stats: undefined` on Bun.) Node behavior is unchanged: full e2e suite produces an identical pass/fail set to master baseline (2012 passing, same 174 pre-existing environment failures) on Node 22.
1 parent 31ea815 commit e76b96b

2 files changed

Lines changed: 172 additions & 117 deletions

File tree

src/chain.ts

Lines changed: 156 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import type { Buffer } from 'node:buffer';
1+
import { Buffer } from 'node:buffer';
22
import type dns from 'node:dns';
33
import type { EventEmitter } from 'node:events';
4-
import http from 'node:http';
5-
import https from 'node:https';
4+
import type http from 'node:http';
5+
import type https from 'node:https';
6+
import net from 'node:net';
7+
import tls from 'node:tls';
68
import type { URL } from 'node:url';
79

810
import type { Socket } from './socket.js';
@@ -11,15 +13,6 @@ import type { SocketWithPreviousStats } from './utils/count_target_bytes.js';
1113
import { countTargetBytes } from './utils/count_target_bytes.js';
1214
import { getBasicAuthorizationHeader } from './utils/get_basic.js';
1315

14-
interface Options {
15-
method: string;
16-
headers: Record<string, string>;
17-
path?: string;
18-
localAddress?: string;
19-
family?: number;
20-
lookup?: typeof dns['lookup'];
21-
}
22-
2316
export interface HandlerOpts {
2417
upstreamProxyUrlParsed: URL;
2518
ignoreUpstreamProxyCertificate: boolean;
@@ -44,6 +37,13 @@ interface ChainOpts {
4437
* Passes the traffic to upstream HTTP proxy server.
4538
* Client -> Apify -> Upstream -> Web
4639
* Client <- Apify <- Upstream <- Web
40+
*
41+
* Uses raw TCP/TLS sockets to establish the upstream CONNECT tunnel rather
42+
* than `http.request().on('connect', ...)`. The latter is implemented on top
43+
* of `fetch()` in Bun 1.3 and (a) rejects non-URL CONNECT paths like `:443`
44+
* with "fetch() URL is invalid", (b) silently swallows the 407/590-class
45+
* upstream responses tests rely on. Speaking CONNECT directly over a socket
46+
* sidesteps both quirks without changing the behaviour on Node.
4747
*/
4848
export const chain = (
4949
{
@@ -67,151 +67,190 @@ export const chain = (
6767
}
6868

6969
const { proxyChainId } = sourceSocket;
70-
7170
const { upstreamProxyUrlParsed: proxy, customTag } = handlerOpts;
7271

73-
const options: Options = {
74-
method: 'CONNECT',
75-
path: request.url,
76-
headers: {
77-
host: request.url!,
78-
},
72+
const isHttps = proxy.protocol === 'https:';
73+
const proxyHost = proxy.hostname;
74+
const proxyPort = Number(proxy.port) || (isHttps ? 443 : 80);
75+
76+
let connectRequest = `CONNECT ${request.url} HTTP/1.1\r\nHost: ${request.url}\r\n`;
77+
if (proxy.username || proxy.password) {
78+
connectRequest += `Proxy-Authorization: ${getBasicAuthorizationHeader(proxy)}\r\n`;
79+
}
80+
connectRequest += '\r\n';
81+
82+
const socketOptions: net.TcpNetConnectOpts = {
83+
host: proxyHost,
84+
port: proxyPort,
7985
localAddress: handlerOpts.localAddress,
80-
family: handlerOpts.ipFamily,
86+
family: handlerOpts.ipFamily as 4 | 6 | undefined,
8187
lookup: handlerOpts.dnsLookup,
8288
};
8389

84-
if (proxy.username || proxy.password) {
85-
options.headers['proxy-authorization'] = getBasicAuthorizationHeader(proxy);
86-
}
90+
let targetSocket: net.Socket;
8791

88-
const client = proxy.protocol === 'https:'
89-
? https.request(proxy.origin, {
90-
...options,
91-
rejectUnauthorized: !handlerOpts.ignoreUpstreamProxyCertificate,
92-
agent: handlerOpts.httpsAgent,
93-
})
94-
: http.request(proxy.origin, {
95-
...options,
96-
agent: handlerOpts.httpAgent,
97-
});
98-
99-
client.once('socket', (targetSocket: SocketWithPreviousStats) => {
100-
// Socket can be re-used by multiple requests.
101-
// That's why we need to track the previous stats.
102-
targetSocket.previousBytesRead = targetSocket.bytesRead;
103-
targetSocket.previousBytesWritten = targetSocket.bytesWritten;
104-
countTargetBytes(sourceSocket, targetSocket);
105-
});
92+
const onPreConnectError = (error: NodeJS.ErrnoException): void => {
93+
server.log(proxyChainId, `Failed to connect to upstream proxy: ${error.stack}`);
10694

107-
client.on('connect', (response, targetSocket, clientHead) => {
108-
if (sourceSocket.readyState !== 'open') {
109-
// Sanity check, should never reach.
110-
targetSocket.destroy();
111-
return;
95+
if (sourceSocket.readyState === 'open') {
96+
if (isPlain) {
97+
sourceSocket.end();
98+
} else {
99+
const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR;
100+
const response = createCustomStatusHttpResponse(statusCode, error.code ?? 'Upstream Closed Early');
101+
sourceSocket.end(response);
102+
}
112103
}
104+
};
113105

114-
targetSocket.on('error', (error) => {
115-
server.log(proxyChainId, `Chain Destination Socket Error: ${error.stack}`);
106+
const onProxyConnected = (): void => {
107+
targetSocket.write(connectRequest);
116108

117-
sourceSocket.destroy();
118-
});
109+
let responseBuffer = Buffer.alloc(0);
119110

120-
sourceSocket.on('error', (error) => {
121-
server.log(proxyChainId, `Chain Source Socket Error: ${error.stack}`);
111+
const onData = (chunk: Buffer): void => {
112+
responseBuffer = Buffer.concat([responseBuffer, chunk]);
122113

123-
targetSocket.destroy();
124-
});
114+
const headerEnd = responseBuffer.indexOf('\r\n\r\n');
115+
if (headerEnd === -1) return;
125116

126-
if (response.statusCode !== 200) {
127-
server.log(proxyChainId, `Failed to authenticate upstream proxy: ${response.statusCode}`);
117+
targetSocket.removeListener('data', onData);
128118

129-
if (isPlain) {
130-
sourceSocket.end();
131-
} else {
132-
const { statusCode } = response;
133-
const status = statusCode === 401 || statusCode === 407
134-
? badGatewayStatusCodes.AUTH_FAILED
135-
: badGatewayStatusCodes.NON_200;
119+
const headerStr = responseBuffer.subarray(0, headerEnd).toString();
120+
const remaining = responseBuffer.subarray(headerEnd + 4);
121+
122+
const statusMatch = headerStr.match(/^HTTP\/\d+(?:\.\d+)? (\d+)(?: (.*))?/);
123+
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 0;
124+
const statusMessage = statusMatch ? (statusMatch[2] || '') : '';
136125

137-
sourceSocket.end(createCustomStatusHttpResponse(status, `UPSTREAM${statusCode}`));
126+
const headers: Record<string, string> = {};
127+
const rawHeaders: string[] = [];
128+
for (const line of headerStr.split('\r\n').slice(1)) {
129+
if (!line) continue;
130+
const colonIdx = line.indexOf(':');
131+
if (colonIdx > 0) {
132+
const name = line.slice(0, colonIdx).trim();
133+
const value = line.slice(colonIdx + 1).trim();
134+
headers[name.toLowerCase()] = value;
135+
rawHeaders.push(name, value);
136+
}
138137
}
139138

140-
targetSocket.end();
139+
const response = { statusCode, statusMessage, headers, rawHeaders } as unknown as http.IncomingMessage;
140+
141+
if (sourceSocket.readyState !== 'open') {
142+
targetSocket.destroy();
143+
return;
144+
}
145+
146+
targetSocket.removeListener('error', onPreConnectError);
147+
148+
targetSocket.on('error', (error) => {
149+
server.log(proxyChainId, `Chain Destination Socket Error: ${error.stack}`);
150+
sourceSocket.destroy();
151+
});
152+
153+
sourceSocket.on('error', (error) => {
154+
server.log(proxyChainId, `Chain Source Socket Error: ${error.stack}`);
155+
targetSocket.destroy();
156+
});
157+
158+
if (statusCode !== 200) {
159+
server.log(proxyChainId, `Failed to authenticate upstream proxy: ${statusCode}`);
141160

142-
server.emit('tunnelConnectFailed', {
161+
if (isPlain) {
162+
sourceSocket.end();
163+
} else {
164+
const status = statusCode === 401 || statusCode === 407
165+
? badGatewayStatusCodes.AUTH_FAILED
166+
: badGatewayStatusCodes.NON_200;
167+
168+
sourceSocket.end(createCustomStatusHttpResponse(status, `UPSTREAM${statusCode}`));
169+
}
170+
171+
targetSocket.end();
172+
173+
server.emit('tunnelConnectFailed', {
174+
proxyChainId,
175+
response,
176+
customTag,
177+
socket: targetSocket,
178+
head: remaining,
179+
});
180+
181+
return;
182+
}
183+
184+
if (remaining.length > 0) {
185+
// See comment above re: pre-response CONNECT payload
186+
targetSocket.unshift(remaining);
187+
}
188+
189+
server.emit('tunnelConnectResponded', {
143190
proxyChainId,
144191
response,
145192
customTag,
146193
socket: targetSocket,
147-
head: clientHead,
194+
head: remaining,
148195
});
149196

150-
return;
151-
}
197+
sourceSocket.write(isPlain ? '' : `HTTP/1.1 200 Connection Established\r\n\r\n`);
152198

153-
if (clientHead.length > 0) {
154-
// See comment above
155-
targetSocket.unshift(clientHead);
156-
}
199+
sourceSocket.pipe(targetSocket);
200+
targetSocket.pipe(sourceSocket);
157201

158-
server.emit('tunnelConnectResponded', {
159-
proxyChainId,
160-
response,
161-
customTag,
162-
socket: targetSocket,
163-
head: clientHead,
164-
});
202+
// Once target socket closes forcibly, the source socket gets paused.
203+
// We need to enable flowing, otherwise the socket would remain open indefinitely.
204+
// Nothing would consume the data, we just want to close the socket.
205+
targetSocket.on('close', () => {
206+
sourceSocket.resume();
165207

166-
sourceSocket.write(isPlain ? '' : `HTTP/1.1 200 Connection Established\r\n\r\n`);
208+
if (sourceSocket.writable) {
209+
sourceSocket.end();
210+
}
211+
});
167212

168-
sourceSocket.pipe(targetSocket);
169-
targetSocket.pipe(sourceSocket);
213+
// Same here.
214+
sourceSocket.on('close', () => {
215+
targetSocket.resume();
170216

171-
// Once target socket closes forcibly, the source socket gets paused.
172-
// We need to enable flowing, otherwise the socket would remain open indefinitely.
173-
// Nothing would consume the data, we just want to close the socket.
174-
targetSocket.on('close', () => {
175-
sourceSocket.resume();
217+
if (targetSocket.writable) {
218+
targetSocket.end();
219+
}
220+
});
221+
};
176222

177-
if (sourceSocket.writable) {
178-
sourceSocket.end();
179-
}
180-
});
223+
targetSocket.on('data', onData);
224+
};
181225

182-
// Same here.
183-
sourceSocket.on('close', () => {
184-
targetSocket.resume();
226+
if (isHttps) {
227+
// We connect directly instead of going through `https.request` with an
228+
// agent, but users may have configured TLS settings (custom `ca`,
229+
// client certs, ...) on `httpsAgent`. Honor those by re-using the
230+
// agent's stored constructor options for this connection.
231+
const httpsAgentOptions = handlerOpts.httpsAgent?.options as tls.ConnectionOptions | undefined;
185232

186-
if (targetSocket.writable) {
187-
targetSocket.end();
188-
}
189-
});
190-
});
233+
targetSocket = tls.connect({
234+
...httpsAgentOptions,
235+
...socketOptions,
236+
rejectUnauthorized: !handlerOpts.ignoreUpstreamProxyCertificate,
237+
}, onProxyConnected);
238+
} else {
239+
targetSocket = net.createConnection(socketOptions, onProxyConnected);
240+
}
191241

192-
client.on('error', (error: NodeJS.ErrnoException) => {
193-
server.log(proxyChainId, `Failed to connect to upstream proxy: ${error.stack}`);
242+
(targetSocket as SocketWithPreviousStats).previousBytesRead = 0;
243+
(targetSocket as SocketWithPreviousStats).previousBytesWritten = 0;
244+
countTargetBytes(sourceSocket, targetSocket);
194245

195-
// The end socket may get connected after the client to proxy one gets disconnected.
196-
if (sourceSocket.readyState === 'open') {
197-
if (isPlain) {
198-
sourceSocket.end();
199-
} else {
200-
const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR;
201-
const response = createCustomStatusHttpResponse(statusCode, error.code ?? 'Upstream Closed Early');
202-
sourceSocket.end(response);
203-
}
204-
}
205-
});
246+
targetSocket.on('error', onPreConnectError);
206247

207248
sourceSocket.on('error', () => {
208-
client.destroy();
249+
targetSocket.destroy();
209250
});
210251

211252
// In case the client ends the socket too early
212253
sourceSocket.on('close', () => {
213-
client.destroy();
254+
targetSocket.destroy();
214255
});
215-
216-
client.end();
217256
};

src/server.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,15 @@ export class Server extends EventEmitter {
361361
* Handles normal HTTP request by forwarding it to target host or the upstream proxy.
362362
*/
363363
async onRequest(request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
364+
// Some runtimes (Bun 1.3) deliver HTTP CONNECT requests through the
365+
// generic 'request' event rather than the dedicated 'connect' event.
366+
// Route them through onConnect explicitly so CONNECT tunnelling keeps
367+
// working on those runtimes.
368+
if (request.method === 'CONNECT') {
369+
await this.onConnect(request, request.socket as Socket, Buffer.alloc(0));
370+
return;
371+
}
372+
364373
try {
365374
const handlerOpts = await this.prepareRequestHandling(request);
366375
handlerOpts.srcResponse = response;
@@ -698,8 +707,15 @@ export class Server extends EventEmitter {
698707

699708
/**
700709
* Gets data transfer statistics of a specific proxy connection.
710+
*
711+
* Returns `undefined` when the connection does not exist, and also when
712+
* running under Bun: Bun (as of 1.3) does not populate
713+
* `socket.bytesRead` / `socket.bytesWritten` on http sockets, so the
714+
* numbers would silently read as zero. Better no stats than wrong stats.
701715
*/
702716
getConnectionStats(connectionId: number): ConnectionStats | undefined {
717+
if (process.versions.bun) return undefined;
718+
703719
const socket = this.connections.get(connectionId);
704720
if (!socket) return undefined;
705721

0 commit comments

Comments
 (0)