diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index eae63b0a56c..6e2ce352ed4 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -58,10 +58,40 @@ const { globalAgent } = require("node:_http_agent"); const { IncomingMessage } = require("node:_http_incoming"); const { OutgoingMessage } = require("node:_http_outgoing"); +const { getLazy } = require("internal/shared"); +const net = getLazy(() => require("node:net")); +const tls = getLazy(() => require("node:tls")); +const { getMaxHTTPHeaderSize, statusCodeSymbol, statusMessageSymbol, noBodySymbol } = require("internal/http"); + const globalReportError = globalThis.reportError; const setTimeout = globalThis.setTimeout; const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/; const INVALID_HOST_CHAR_REGEX = /[/\\?#@\t\n\r]/; +const CONNECT_STATUS_LINE_REGEX = /^HTTP\/(\d)\.(\d) (\d{3})(?: (.*))?$/; +const kEmptyBuffer = Buffer.alloc(0); +// Headers Node's IncomingMessage._addHeaderLine treats as singletons: the first +// occurrence wins and later duplicates are discarded (set-cookie is handled +// separately as an array). Used when folding parsed CONNECT response headers. +const kConnectSingletonHeaders = new Set([ + "age", + "authorization", + "content-length", + "content-type", + "etag", + "expires", + "from", + "host", + "if-modified-since", + "if-unmodified-since", + "last-modified", + "location", + "max-forwards", + "proxy-authorization", + "referer", + "retry-after", + "server", + "user-agent", +]); const { URL } = globalThis; @@ -283,6 +313,16 @@ function ClientRequest(input, options, cb) { return false; } + // CONNECT tunnels (HTTP proxies) have no representation in fetch(): the + // request target is a `host:port` authority, not a URL, and the response + // is a raw socket rather than a message body. Dispatch it over a raw TCP + // socket instead and emit the 'connect' event, matching Node. + if (this[kMethod] === "CONNECT") { + fetching = true; + startConnect(); + return true; + } + fetching = true; // Every entry point that dispatches the request (send(), flushHeaders(), @@ -603,6 +643,286 @@ function ClientRequest(input, options, cb) { } }; + // Dispatch a CONNECT request over a raw TCP (or TLS) socket and emit the + // 'connect' event once the proxy's response status line + headers arrive. + // This mirrors Node's http.ClientRequest CONNECT handling so HTTP proxy + // clients (e.g. @grpc/grpc-js proxy support) work. + const startConnect = () => { + if (!this[kAbortController]) { + this[kAbortController] = new AbortController(); + this[kAbortController].signal.addEventListener("abort", onAbort, { once: true }); + } + + this[kUpgradeOrConnect] = true; + + let keepalive = true; + const agentKeepalive = this[kAgent]?.keepAlive; + if (agentKeepalive !== undefined) { + keepalive = agentKeepalive; + } + + const connectOptions: any = { + signal: this[kAbortController].signal, + }; + const socketPath = this[kSocketPath]; + if (socketPath) { + connectOptions.path = socketPath; + } else { + connectOptions.host = this[kHost]; + connectOptions.port = this[kPort]; + // Forward the socket-level options Node honors when connecting to the + // proxy authority, so a custom DNS resolver (split-horizon DNS, service + // discovery) and address selection work the same as the normal path. + // net.connect() implements the resolution itself, so no manual loop. + if (options.lookup !== undefined) connectOptions.lookup = options.lookup; + if (options.family !== undefined) connectOptions.family = options.family; + if (options.hints !== undefined) connectOptions.hints = options.hints; + if (options.localAddress !== undefined) connectOptions.localAddress = options.localAddress; + if (options.localPort !== undefined) connectOptions.localPort = options.localPort; + } + + const isTLS = this[kProtocol] === "https:"; + if (isTLS && this[kTls]) { + ObjectAssign(connectOptions, this[kTls]); + connectOptions.servername = this[kTls].servername; + } + + let socket; + try { + socket = isTLS ? tls().connect(connectOptions) : net().connect(connectOptions); + } catch (err) { + fetching = false; + process.nextTick((self, err) => self.emit("error", err), this, err); + // Keep this terminal path consistent with onError below: emit 'close' + // after 'error' so a req.on('close') cleanup listener still runs. + maybeEmitClose(); + return; + } + + this.socket = socket; + + // Default Host/Connection headers, matching Node. A CONNECT request with no + // Host header is rejected by many proxies (and by Bun's own server parser), + // so add one pointing at the proxy authority unless the caller set it. + if (!this.hasHeader("host") && !socketPath) { + let hostHeader = this[kHost]; + if (isIPv6(hostHeader)) { + hostHeader = `[${hostHeader}]`; + } + if (!this[kUseDefaultPort]) { + hostHeader += ":" + this[kPort]; + } + this.setHeader("Host", hostHeader); + } + if (!this.hasHeader("connection")) { + this.setHeader("Connection", keepalive ? "keep-alive" : "close"); + } + + // Write the CONNECT request line + headers. The request target is the + // `host:port` authority from options.path, not a URL path, so it must be + // written verbatim (no leading slash). Use the raw (original-case) header + // names so the wire bytes match what the caller set, like Node. + const headerLines = [`CONNECT ${this[kPath]} HTTP/1.1`]; + const rawNames = this.getRawHeaderNames(); + for (let i = 0; i < rawNames.length; i++) { + const name = rawNames[i]; + const value = this.getHeader(name); + if (value === undefined) continue; + if ($isJSArray(value)) { + for (let j = 0; j < value.length; j++) { + headerLines.push(`${name}: ${value[j]}`); + } + } else { + headerLines.push(`${name}: ${value}`); + } + } + const requestHead = headerLines.join("\r\n") + "\r\n\r\n"; + + let connected = false; + let buffer: Buffer | null = null; + const maxHeaderSize = this[kMaxHeaderSize] || getMaxHTTPHeaderSize(); + + const swallowTeardownError = () => {}; + + const onError = err => { + if (connected) return; + socket.removeListener("data", onData); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); + // Keep swallowTeardownError attached here: on a pre-tunnel failure/abort + // the AbortController can still emit an AbortError on the socket after + // this runs, and it must not surface as an unhandled 'error'. + this[kClearTimeout]?.(); + // Abort/destroy is handled by onAbort โ†’ socketCloseListener, which emits + // 'close' and also synthesizes a socket 'close' that lands here; don't + // surface a spurious 'error' for a user-initiated teardown (Node doesn't). + if (isAbortError(err) || this.destroyed || this[abortedSymbol]) return; + // net/tls already produce a Node-shaped error (code/syscall/address/port), + // so propagate it verbatim like Node rather than flattening it. + fetching = false; + try { + this.emit("error", err); + } catch {} + // The request is done: emit 'close' like Node does after a failed request. + maybeEmitClose(); + }; + + const onClose = () => { + if (connected) return; + onError(new ConnResetException("socket hang up")); + }; + + const onData = chunk => { + buffer = buffer ? Buffer.concat([buffer, chunk]) : chunk; + + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) { + if (buffer.length > maxHeaderSize) { + socket.destroy(); + onError($HPE_HEADER_OVERFLOW("Header overflow")); + } + return; + } + // Reject an oversized header block even when it arrives complete (with its + // terminator) in a single read, so maxHeaderSize is honored the way Node's + // llhttp counts header bytes regardless of where \r\n\r\n lands. + if (headerEnd > maxHeaderSize) { + socket.destroy(); + onError($HPE_HEADER_OVERFLOW("Header overflow")); + return; + } + + const headerText = buffer.toString("latin1", 0, headerEnd); + + const lines = headerText.split("\r\n"); + const statusLine = lines.shift() || ""; + // "HTTP/1.1 200 Connection established" + const statusMatch = RegExpPrototypeExec.$call(CONNECT_STATUS_LINE_REGEX, statusLine); + if (!statusMatch) { + // A proxy that answers with an unparseable status line isn't a tunnel; + // fail the request instead of emitting 'connect' with no statusCode. + // onError runs before `connected` flips, so it still fires. + socket.destroy(); + onError($HPE_INVALID_HEADER_TOKEN("Parse Error: Invalid header token encountered")); + return; + } + + connected = true; + socket.removeListener("data", onData); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); + // Hand the tunnel socket to the user with no internal listeners, like Node. + socket.removeListener("error", swallowTeardownError); + // Our internal 'data' listener put the socket into flowing mode; reset it + // to the neutral (neither flowing nor paused) state like Node does before + // emitting 'connect', so bytes after the headers stay buffered until the + // user attaches a 'data' listener / pipes / resumes (no data loss). + socket.readableFlowing = null; + this[kClearTimeout]?.(); + fetching = false; + + const head = headerEnd + 4 < buffer.length ? buffer.subarray(headerEnd + 4) : kEmptyBuffer; + buffer = null; + + const res = new IncomingMessage(null, kEmptyObject); + res.httpVersion = `${statusMatch[1]}.${statusMatch[2]}`; + res[statusCodeSymbol] = Number(statusMatch[3]); + // Deliver the reason phrase verbatim, "" when omitted, matching llhttp/Node. + res[statusMessageSymbol] = statusMatch[4] ?? ""; + + const rawHeaders: string[] = []; + // Null prototype so a proxy header literally named "constructor"/"__proto__" + // folds against an absent own property instead of an inherited one. + const parsedHeaders: Record = { __proto__: null } as any; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon); + // Strip OWS = *(SP / HTAB) on both sides of the value, matching llhttp + // (RFC 7230 ยง3.2.4), so padded proxy headers parse like they do in Node. + let start = colon + 1; + let end = line.length; + while (start < end && (line.charCodeAt(start) === 32 || line.charCodeAt(start) === 9)) start++; + while (end > start && (line.charCodeAt(end - 1) === 32 || line.charCodeAt(end - 1) === 9)) end--; + const val = line.slice(start, end); + $putByValDirect(rawHeaders, rawHeaders.length, key); + $putByValDirect(rawHeaders, rawHeaders.length, val); + // Fold into headers with Node's _addHeaderLine rules: set-cookie is + // always an array, singleton headers keep the first value, everything + // else is comma-joined. + const lowerKey = key.toLowerCase(); + const existing = parsedHeaders[lowerKey]; + if (lowerKey === "set-cookie") { + if (existing === undefined) parsedHeaders[lowerKey] = [val]; + else (existing as string[]).push(val); + } else if (existing === undefined) { + parsedHeaders[lowerKey] = val; + } else if (!kConnectSingletonHeaders.has(lowerKey)) { + parsedHeaders[lowerKey] = `${existing}, ${val}`; + } + } + res.headers = parsedHeaders; + res.rawHeaders = rawHeaders; + // The CONNECT response has no body; mark it complete so reads emit EOF + // instead of touching the (absent) fetch Response backing store. + res[noBodySymbol] = true; + res.complete = true; + res.push(null); + + // Point res.socket at the real tunnel socket and back-reference the + // response from the request, matching Node (res.socket === socket, + // req.res === res, res.upgrade === true). Node leaves res.req undefined + // for CONNECT, so we do too. + res.upgrade = true; + res.socket = socket; + this.res = res; + + // The request is finished from the writable side's perspective. + if (!this.finished) { + this.finished = true; + } + process.nextTick(emitFinishAndDeferredCloseNT); + + if (this.listenerCount("connect") > 0) { + this.emit("connect", res, socket, head); + } else { + // Node destroys the socket when nobody is listening for 'connect'. + socket.destroy(); + } + + // Attach this after the emit so the user's 'connect' handler sees the + // tunnel socket with no internal listeners, like Node. Socket 'close' is + // async, so a listener added here still fires even if the handler called + // socket.destroy() synchronously. Once the tunnel socket goes away, the + // request is done too: emit 'close' the way Node does on CONNECT close. + socket.once("close", () => { + maybeEmitClose(); + }); + }; + + // Swallow a late error that fires during pre-tunnel teardown (e.g. the + // AbortController's AbortError when the request is aborted/destroyed before + // the tunnel is established) so it doesn't surface as an unhandled 'error'. + // Removed once the tunnel is handed to the user so the socket is delivered + // with no internal listeners, like Node. + socket.on("error", swallowTeardownError); + socket.on("data", onData); + socket.on("error", onError); + socket.on("close", onClose); + + const writeHead = () => { + socket.write(requestHead); + }; + if (socket.connecting) { + socket.once(isTLS ? "secureConnect" : "connect", writeHead); + } else { + writeHead(); + } + + return true; + }; + let onEnd = () => {}; let handleResponse: (() => void) | undefined = () => {}; // Set once handleResponse()'s nextTick has run and found the writable side diff --git a/test/js/node/http/node-http-connect.test.ts b/test/js/node/http/node-http-connect.test.ts index 5e486e48889..71c1c9a4ba3 100644 --- a/test/js/node/http/node-http-connect.test.ts +++ b/test/js/node/http/node-http-connect.test.ts @@ -440,7 +440,434 @@ describe("HTTP server socket access via normal requests", () => { }); }); +describe("HTTP client CONNECT", () => { + test("http.request CONNECT tunnels through a proxy and emits 'connect'", async () => { + // A minimal CONNECT proxy that echoes tunneled bytes back. + const proxyServer = http.createServer(); + let target = ""; + let proxySocket: net.Socket | undefined; + proxyServer.on("connect", (req, clientSocket) => { + proxySocket = clientSocket; + target = req.url ?? ""; + clientSocket.on("error", () => {}); + clientSocket.write("HTTP/1.1 200 Connection established\r\nProxy-Agent: bun-test\r\n\r\n"); + clientSocket.on("data", d => clientSocket.write(d)); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve, reject } = Promise.withResolvers<{ + statusCode: number; + headers: Record; + echoed: string; + socketIsTunnel: boolean; + reqResIsRes: boolean; + upgrade: unknown; + closeListeners: number; + }>(); + + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "example.com:443" }); + req.on("connect", (res, socket, head) => { + // Node wires res.socket to the tunnel socket, req.res to the response, + // and marks res.upgrade === true. + const socketIsTunnel = res.socket === socket; + const reqResIsRes = req.res === res; + const upgrade = (res as any).upgrade; + // Node hands the tunnel socket to 'connect' with no internal listeners; + // capture the 'close' listener count before we attach our own. + const closeListeners = socket.listenerCount("close"); + socket.on("error", () => {}); + socket.on("data", d => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + echoed: d.toString(), + socketIsTunnel, + reqResIsRes, + upgrade, + closeListeners, + }); + socket.destroy(); + }); + socket.write("ping"); + }); + req.on("error", reject); + req.end(); + + const result = await promise; + // The tunnel target must be sent verbatim (no leading slash). + expect(target).toBe("example.com:443"); + expect(result.statusCode).toBe(200); + expect(result.headers["proxy-agent"]).toBe("bun-test"); + expect(result.echoed).toBe("ping"); + expect(result.socketIsTunnel).toBe(true); + expect(result.reqResIsRes).toBe(true); + expect(result.upgrade).toBe(true); + // The socket is handed over with no internal listeners, like Node. + expect(result.closeListeners).toBe(0); + } finally { + proxySocket?.destroy(); + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT emits 'connect' even on a non-200 status", async () => { + const proxyServer = net.createServer(socket => { + socket.on("error", () => {}); + socket.on("data", () => socket.write("HTTP/1.1 403 Forbidden\r\nX-Reason: denied\r\n\r\n")); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve, reject } = Promise.withResolvers<{ + statusCode: number; + statusMessage: string; + headers: Record; + }>(); + + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "example.com:443" }); + req.on("connect", (res, socket) => { + resolve({ statusCode: res.statusCode, statusMessage: res.statusMessage, headers: res.headers }); + socket.destroy(); + }); + req.on("error", reject); + req.end(); + + const result = await promise; + expect(result.statusCode).toBe(403); + expect(result.statusMessage).toBe("Forbidden"); + expect(result.headers["x-reason"]).toBe("denied"); + } finally { + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT trims optional whitespace around proxy header values", async () => { + const proxyServer = net.createServer(socket => { + socket.on("error", () => {}); + // Leading tab, two leading spaces, and a trailing space โ€” llhttp trims all. + socket.on("data", () => + socket.write("HTTP/1.1 200 OK\r\nX-Tab:\tt-val\r\nX-Two: two-val\r\nX-Trail: trail-val \r\n\r\n"), + ); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve, reject } = Promise.withResolvers>(); + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "h:1" }); + req.on("connect", (res, socket) => { + resolve(res.headers); + socket.destroy(); + }); + req.on("error", reject); + req.end(); + + const headers = await promise; + expect(headers["x-tab"]).toBe("t-val"); + expect(headers["x-two"]).toBe("two-val"); + expect(headers["x-trail"]).toBe("trail-val"); + } finally { + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT folds duplicate proxy response headers like Node", async () => { + const proxyServer = net.createServer(socket => { + socket.on("error", () => {}); + socket.on("data", () => + socket.write( + "HTTP/1.1 200 OK\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\nContent-Length: 10\r\nContent-Length: 20\r\nX-Multi: p\r\nX-Multi: q\r\nConstructor: own\r\n\r\n", + ), + ); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve, reject } = Promise.withResolvers>(); + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "h:1" }); + req.on("connect", (res, socket) => { + resolve(res.headers); + socket.destroy(); + }); + req.on("error", reject); + req.end(); + + const headers = await promise; + // set-cookie is always an array; singleton headers keep the first value; + // other duplicates are comma-joined. + expect(headers["set-cookie"]).toEqual(["a=1", "b=2"]); + expect(headers["content-length"]).toBe("10"); + expect(headers["x-multi"]).toBe("p, q"); + // A header whose name collides with Object.prototype must fold against an + // absent own property, not the inherited one. + expect(headers["constructor"]).toBe("own"); + } finally { + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT delivers bytes received after the headers as 'head'", async () => { + const proxyServer = net.createServer(socket => { + socket.on("error", () => {}); + socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\n\r\nEARLY-DATA")); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve, reject } = Promise.withResolvers(); + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "h:1" }); + req.on("connect", (res, socket, head) => { + expect(head).toBeInstanceOf(Buffer); + resolve(head.toString()); + socket.destroy(); + }); + req.on("error", reject); + req.end(); + + expect(await promise).toBe("EARLY-DATA"); + } finally { + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT buffers post-tunnel data until a listener is attached", async () => { + // Node resets the tunnel socket to the neutral (non-flowing) state before + // emitting 'connect', so bytes arriving after the headers are buffered and a + // 'data' listener attached later (e.g. after an await) still receives them. + const proxySockets: net.Socket[] = []; + const proxyServer = net.createServer(socket => { + proxySockets.push(socket); + socket.on("error", () => {}); + socket.on("data", () => { + socket.write("HTTP/1.1 200 Connection established\r\n\r\n"); + // Send the tunneled bytes in a separate write, a short moment after the + // headers, so they land in a TCP read distinct from the header block + // (not coalesced into it, which would deliver them as 'head' instead). + setTimeout(() => socket.write("LATE-DATA"), 20); + }); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve, reject } = Promise.withResolvers<{ flowing: unknown; data: string }>(); + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "h:1" }); + req.on("connect", async (res, socket) => { + const flowing = (socket as any).readableFlowing; + socket.on("error", () => {}); + // Let the event loop turn so the post-header bytes physically arrive at + // the socket before the listener is attached; they must be buffered (not + // dropped) until now. There is no passive "data buffered" signal to wait + // on here โ€” while flowing === null the bytes sit at the socket handle + // (readableLength stays 0) on both Node and Bun until a consumer resumes + // the stream, so a short yield is the condition that exercises this. + await Bun.sleep(50); + let data = ""; + socket.on("data", d => { + data += d.toString(); + if (data.includes("LATE-DATA")) { + resolve({ flowing, data }); + socket.destroy(); + } + }); + }); + req.on("error", reject); + req.end(); + + const result = await promise; + // Matches Node: socket handed over in the neutral state, data buffered. + expect(result.flowing).toBe(null); + expect(result.data).toBe("LATE-DATA"); + } finally { + for (const s of proxySockets) s.destroy(); + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT emits 'error' then 'close' when the proxy is unreachable", async () => { + // Bind then immediately close to obtain a port nothing listens on. + const tmp = net.createServer(); + await once(tmp.listen(0, "127.0.0.1"), "listening"); + const { port } = tmp.address() as AddressInfo; + await new Promise(r => tmp.close(() => r())); + + const { promise, resolve, reject } = Promise.withResolvers<{ + code: string; + syscall: string; + address: string; + port: number; + events: string[]; + }>(); + const events: string[] = []; + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "example.com:443" }); + req.on("connect", () => reject(new Error("unexpected connect"))); + let err: NodeJS.ErrnoException | undefined; + req.on("error", e => { + events.push("error"); + err = e as NodeJS.ErrnoException; + }); + // Node emits 'close' on the request after a failed connection. + req.on("close", () => { + events.push("close"); + resolve({ + code: err?.code ?? "", + syscall: err?.syscall ?? "", + address: err?.address ?? "", + port: err?.port ?? 0, + events, + }); + }); + req.end(); + + const result = await promise; + expect(result.code).toBe("ECONNREFUSED"); + expect(result.events).toEqual(["error", "close"]); + // The net error is propagated verbatim (Node-shaped), so the diagnostic + // fields survive rather than being flattened to a bare Error. + expect(result.syscall).toBe("connect"); + expect(result.address).toBe("127.0.0.1"); + expect(result.port).toBe(port); + }); + + test("http.request CONNECT rejects a malformed proxy status line with 'error'", async () => { + const proxyServer = net.createServer(socket => { + socket.on("error", () => {}); + // Not a valid HTTP status line, but terminated like a header block. + socket.on("data", () => socket.write("garbage not http\r\nX: y\r\n\r\n")); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve, reject } = Promise.withResolvers(); + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "example.com:443" }); + req.on("connect", () => reject(new Error("unexpected connect on malformed response"))); + req.on("error", err => resolve((err as NodeJS.ErrnoException).code ?? err.message)); + req.end(); + + // Node surfaces an llhttp parse error (HPE_*). We only require that it's an + // error rather than a bogus tunnel, so assert the code class loosely. + const code = await promise; + expect(code).toContain("HPE_"); + } finally { + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT rejects a header block larger than maxHeaderSize", async () => { + // Oversized headers delivered complete (with the terminator) in one write + // must still be rejected, matching Node's llhttp byte counting. + const proxyServer = net.createServer(socket => { + socket.on("error", () => {}); + socket.on("data", () => + socket.write("HTTP/1.1 200 OK\r\nX: " + Buffer.alloc(20000, "a").toString() + "\r\n\r\n"), + ); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve, reject } = Promise.withResolvers(); + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "h:1", maxHeaderSize: 16384 }); + req.on("connect", () => reject(new Error("unexpected connect on oversized headers"))); + req.on("error", err => resolve((err as NodeJS.ErrnoException).code ?? err.message)); + req.end(); + + expect(await promise).toBe("HPE_HEADER_OVERFLOW"); + } finally { + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT destroyed before the proxy responds emits 'close' without crashing", async () => { + // A proxy that accepts the TCP connection but never sends a response. + const proxySockets: net.Socket[] = []; + const proxyServer = net.createServer(socket => { + socket.on("error", () => {}); + proxySockets.push(socket); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + const { promise, resolve } = Promise.withResolvers(); + const events: string[] = []; + const req = http.request({ method: "CONNECT", host: "127.0.0.1", port, path: "h:1" }); + req.on("connect", () => events.push("connect")); + // A spurious 'error' after 'close' (or an unhandled AbortError) would be a bug. + req.on("error", e => events.push("error:" + ((e as NodeJS.ErrnoException).code ?? e.message))); + req.on("close", () => { + events.push("close"); + resolve(events); + }); + req.end(); + // Destroy before the proxy has written anything. + await once(req, "socket"); + req.destroy(); + + const result = await promise; + // The request must close; it must not emit a spurious post-close error. + expect(result).toContain("close"); + expect(result[result.length - 1]).toBe("close"); + } finally { + for (const s of proxySockets) s.destroy(); + await new Promise(r => proxyServer.close(() => r())); + } + }); + + test("http.request CONNECT resolves the proxy host with a custom lookup", async () => { + const proxyServer = http.createServer(); + let proxySocket: net.Socket | undefined; + proxyServer.on("connect", (req, clientSocket) => { + proxySocket = clientSocket; + clientSocket.on("error", () => {}); + clientSocket.write("HTTP/1.1 200 Connection established\r\n\r\n"); + }); + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const { port } = proxyServer.address() as AddressInfo; + + try { + let lookupCalledWith = ""; + const { promise, resolve, reject } = Promise.withResolvers(); + const req = http.request({ + method: "CONNECT", + // A name that only the custom lookup can resolve. + host: "proxy.invalid.test", + port, + path: "example.com:443", + lookup: (hostname: string, _opts: any, cb: any) => { + lookupCalledWith = hostname; + // net.connect() defaults autoSelectFamily on, so the all-addresses + // array form is what both Node and Bun expect here. + cb(null, [{ address: "127.0.0.1", family: 4 }]); + }, + }); + req.on("connect", (res, socket) => { + socket.destroy(); + resolve(res.statusCode); + }); + req.on("error", reject); + req.end(); + + const statusCode = await promise; + expect(lookupCalledWith).toBe("proxy.invalid.test"); + expect(statusCode).toBe(200); + } finally { + proxySocket?.destroy(); + await new Promise(r => proxyServer.close(() => r())); + } + }); +}); + describe("Should be compatible with node.js", () => { + // These spawn a full `node --test` / `bun test` run of the sibling file, which + // can take several seconds. Give them a generous timeout so they don't race the + // default 5s deadline on a loaded CI machine. test("tests should run on node.js", async () => { const process = Bun.spawn({ cmd: [nodeExe(), "--test", join(import.meta.dir, "node-http-connect.node.mts")], @@ -450,7 +877,7 @@ describe("Should be compatible with node.js", () => { env: bunEnv, }); expect(await process.exited).toBe(0); - }); + }, 30_000); test("tests should run on bun", async () => { const process = Bun.spawn({ cmd: [bunExe(), "test", join(import.meta.dir, "node-http-connect.node.mts")], @@ -460,5 +887,5 @@ describe("Should be compatible with node.js", () => { env: bunEnv, }); expect(await process.exited).toBe(0); - }); + }, 30_000); });