From b610e80ee0fda03c360cb72cd7c6e77e0892f61b Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Fri, 9 Jan 2026 19:08:02 -0800 Subject: [PATCH] fix(http): properly handle pipelined data in CONNECT requests (#25938) Fixes #25862 ### What does this PR do? When a client sends pipelined data immediately after CONNECT request headers in the same TCP segment, Bun now properly delivers this data to the `head` parameter of the 'connect' event handler, matching Node.js behavior. This enables compatibility with Cap'n Proto's KJ HTTP library used by Cloudflare's workerd runtime, which pipelines RPC data after CONNECT. ### How did you verify your code works? CleanShot 2026-01-09 at 15 30 22@2x Tests --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- packages/bun-uws/src/HttpParser.h | 18 +++++++++-- src/bun.js/bindings/NodeHTTP.cpp | 9 ++++++ src/js/node/_http_server.ts | 5 ++- test/regression/issue/25862.test.ts | 49 +++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 test/regression/issue/25862.test.ts diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 27664740d4..8f4bf5e27a 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -30,6 +30,7 @@ #include #include #include +#include #include #include "MoveOnlyFunction.h" #include "ChunkedEncoding.h" @@ -160,6 +161,13 @@ namespace uWS std::map> *currentParameterOffsets = nullptr; public: + /* Any data pipelined after the HTTP headers (before response). + * Used for Node.js compatibility: 'connect' and 'upgrade' events + * pass this as the 'head' Buffer parameter. + * WARNING: This points to data in the receive buffer and may be stack-allocated. + * Must be cloned before the request handler returns. */ + std::span head; + bool isAncient() { return ancientHttp; @@ -883,6 +891,8 @@ namespace uWS /* If returned socket is not what we put in we need * to break here as we either have upgraded to * WebSockets or otherwise closed the socket. */ + /* Store any remaining data as head for Node.js compat (connect/upgrade events) */ + req->head = std::span(data, length); void *returnedUser = requestHandler(user, req); if (returnedUser != user) { /* We are upgraded to WebSocket or otherwise broken */ @@ -928,9 +938,13 @@ namespace uWS consumedTotal += emittable; } } else if(isConnectRequest) { - // This only server to mark that the connect request read all headers - // and can starting emitting data + // This only serves to mark that the connect request read all headers + // and can start emitting data. Don't try to parse remaining data as HTTP - + // it's pipelined data that we've already captured in req->head. remainingStreamingBytes = STATE_IS_CHUNKED; + // Mark remaining data as consumed and break - it's not HTTP + consumedTotal += length; + break; } else { /* If we came here without a body; emit an empty data chunk to signal no data */ dataHandler(user, {}, true); diff --git a/src/bun.js/bindings/NodeHTTP.cpp b/src/bun.js/bindings/NodeHTTP.cpp index d315e75249..a13cba6591 100644 --- a/src/bun.js/bindings/NodeHTTP.cpp +++ b/src/bun.js/bindings/NodeHTTP.cpp @@ -474,6 +474,15 @@ static EncodedJSValue NodeHTTPServer__onRequest( } args.append(jsBoolean(request->isAncient())); + // Pass pipelined data (head buffer) for Node.js compat (connect/upgrade events) + if (!request->head.empty()) { + JSC::JSUint8Array* headBuffer = WebCore::createBuffer(globalObject, std::span(reinterpret_cast(request->head.data()), request->head.size())); + RETURN_IF_EXCEPTION(scope, {}); + args.append(headBuffer); + } else { + args.append(jsUndefined()); + } + JSValue returnValue = AsyncContextFrame::profiledCall(globalObject, callbackObject, jsUndefined(), args); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index e40bb70bb5..ae646dcd41 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -517,6 +517,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort isSocketNew, socket, isAncientHTTP: boolean, + connectHead?: Buffer, ) { const prevIsNextIncomingMessageHTTPS = getIsNextIncomingMessageHTTPS(); setIsNextIncomingMessageHTTPS(isHTTPS); @@ -537,7 +538,9 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort socket[kEnableStreaming](true); const { promise, resolve } = $newPromiseCapability(Promise); socket.once("close", resolve); - server.emit("connect", http_req, socket, kEmptyBuffer); + // Pass the pipelined data (head buffer) if any was received with the CONNECT request + const head = connectHead ? connectHead : kEmptyBuffer; + server.emit("connect", http_req, socket, head); return promise; } else { // Node.js will close the socket and will NOT respond with 400 Bad Request diff --git a/test/regression/issue/25862.test.ts b/test/regression/issue/25862.test.ts new file mode 100644 index 0000000000..a4f89b9af4 --- /dev/null +++ b/test/regression/issue/25862.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { once } from "node:events"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import net from "node:net"; + +// Test for https://github.com/oven-sh/bun/issues/25862 +// Pipelined data sent immediately after CONNECT request headers should be +// delivered to the `head` parameter of the 'connect' event handler. + +test("CONNECT request should receive pipelined data in head parameter", async () => { + const PIPELINED_DATA = "PIPELINED_DATA"; + const { promise: headReceived, resolve: resolveHead } = Promise.withResolvers(); + + await using server = http.createServer(); + + server.on("connect", (req, socket, head) => { + resolveHead(head); + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + socket.end(); + }); + + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port, address } = server.address() as AddressInfo; + + const { promise: clientDone, resolve: resolveClient } = Promise.withResolvers(); + + const client = net.connect({ port, host: address }, () => { + // Send CONNECT request with pipelined data in the same write + // This simulates what Cap'n Proto's KJ HTTP library does + client.write(`CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n${PIPELINED_DATA}`); + }); + + client.on("data", () => { + // We got the response, we can close + client.end(); + }); + + client.on("close", () => { + resolveClient(); + }); + + const head = await headReceived; + await clientDone; + + expect(head).toBeInstanceOf(Buffer); + expect(head.length).toBe(PIPELINED_DATA.length); + expect(head.toString()).toBe(PIPELINED_DATA); +});