Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
d9a0034f4b test: Address review feedback - remove unused variable and fix drain logic
- Remove unused connectionSocket variable
- Use write() return value to check for backpressure instead of
  registering drain handler then checking writableNeedDrain

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:34:55 +00:00
Claude Bot
0ecf9912ff test: Replace setTimeout with event-driven triggers
Use proper event-based flow control instead of fixed delays:
- Use 'drain' event and writableNeedDrain to know when writes complete
- Use Promise-based coordination for server data receipt confirmation
- Eliminates flaky timing-dependent test behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:24:10 +00:00
Claude Bot
a062da23d6 fix(http): Fix HTTP server socket events for CONNECT requests
Fixes #26553

This PR addresses multiple HTTP server socket issues affecting Node.js
compatibility:

1. **'connection' event not fired before 'connect' event for CONNECT requests**
   - The 'connection' event handler can now set custom properties on the
     socket that are available in the 'connect' handler
   - This fixes compatibility with proxy libraries like proxy-chain

2. **socket.bytesWritten and socket.bytesRead always return 0**
   - Added bytesRead tracking in the C++ streamBuffer struct
   - Fixed the bytesWritten getter to properly combine direct socket writes
     and HTTP response writes
   - Values are now captured before handle is nulled on close

3. **socket 'close' event never fires**
   - The socket now properly emits 'close' when the underlying connection
     ends

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:18:15 +00:00
6 changed files with 258 additions and 4 deletions

View File

@@ -232,6 +232,9 @@ void JSNodeHTTPServerSocket::onDrain()
void JSNodeHTTPServerSocket::onData(const char* data, int length, bool last)
{
// Track total bytes read
this->streamBuffer.total_bytes_read += length;
// This function can be called during GC!
Zig::GlobalObject* globalObject = static_cast<Zig::GlobalObject*>(this->globalObject());
if (!functionToCallOnData) {

View File

@@ -11,6 +11,7 @@ struct us_socket_stream_buffer_t {
size_t list_cap = 0;
size_t listLen = 0;
size_t total_bytes_written = 0;
size_t total_bytes_read = 0;
size_t cursor = 0;
size_t bufferedSize() const
@@ -21,6 +22,10 @@ struct us_socket_stream_buffer_t {
{
return total_bytes_written;
}
size_t totalBytesRead() const
{
return total_bytes_read;
}
};
struct us_socket_t;

View File

@@ -25,6 +25,7 @@ JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnDrain);
JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnData);
JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnData);
JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesWritten);
JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesRead);
JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketClose);
JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketWrite);
JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketEnd);
@@ -48,6 +49,7 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] =
{ "ondrain"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterOnDrain, jsNodeHttpServerSocketSetterOnDrain } },
{ "ondata"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterOnData, jsNodeHttpServerSocketSetterOnData } },
{ "bytesWritten"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterBytesWritten, noOpSetter } },
{ "bytesRead"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterBytesRead, noOpSetter } },
{ "closed"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterClosed, noOpSetter } },
{ "response"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterResponse, noOpSetter } },
{ "duplex"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterDuplex, jsNodeHttpServerSocketSetterDuplex } },
@@ -317,6 +319,12 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesWritten, (JSC::JSGloba
return JSValue::encode(JSC::jsNumber(thisObject->streamBuffer.totalBytesWritten()));
}
JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesRead, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName propertyName))
{
auto* thisObject = jsCast<JSNodeHTTPServerSocket*>(JSC::JSValue::decode(thisValue));
return JSValue::encode(JSC::jsNumber(thisObject->streamBuffer.totalBytesRead()));
}
JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterResponse, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName propertyName))
{
auto* thisObject = jsCast<JSNodeHTTPServerSocket*>(JSC::JSValue::decode(thisValue));

View File

@@ -232,6 +232,7 @@ pub const c = struct {
list_cap: usize = 0,
list_len: usize = 0,
total_bytes_written: usize = 0,
total_bytes_read: usize = 0,
cursor: usize = 0,
pub fn update(this: *us_socket_stream_buffer_t, stream_buffer: bun.io.StreamBuffer) void {

View File

@@ -532,6 +532,11 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort
if (method === "CONNECT") {
// Handle CONNECT method for HTTP tunneling/proxy
if (server.listenerCount("connect") > 0) {
// Emit 'connection' event first (Node.js compatibility)
// This allows users to set custom properties on the socket before 'connect' fires
if (isSocketNew) {
server.emit("connection", socket);
}
// For CONNECT, emit the event and let the handler respond
// Don't assign the socket to a response for CONNECT
// The handler should write the raw response
@@ -809,12 +814,13 @@ function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, r
}
const kBytesWritten = Symbol("kBytesWritten");
const kBytesRead = Symbol("kBytesRead");
const kEnableStreaming = Symbol("kEnableStreaming");
const NodeHTTPServerSocket = class Socket extends Duplex {
bytesRead = 0;
connecting = false;
timeout = 0;
[kBytesWritten] = 0;
[kBytesRead] = 0;
[kHandle];
server: Server;
_httpMessage;
@@ -834,14 +840,27 @@ const NodeHTTPServerSocket = class Socket extends Duplex {
get bytesWritten() {
const handle = this[kHandle];
return handle
? (handle.response?.getBytesWritten?.() ?? handle.bytesWritten ?? this[kBytesWritten] ?? 0)
: (this[kBytesWritten] ?? 0);
if (handle) {
// For direct socket writes (e.g., CONNECT tunnels), use handle.bytesWritten
// For HTTP response writes, use handle.response.getBytesWritten()
const handleBytes = handle.bytesWritten ?? 0;
const responseBytes = handle.response?.getBytesWritten?.() ?? 0;
return Math.max(handleBytes, responseBytes, this[kBytesWritten] ?? 0);
}
return this[kBytesWritten] ?? 0;
}
set bytesWritten(value) {
this[kBytesWritten] = value;
}
get bytesRead() {
const handle = this[kHandle];
return handle ? (handle.bytesRead ?? this[kBytesRead] ?? 0) : (this[kBytesRead] ?? 0);
}
set bytesRead(value) {
this[kBytesRead] = value;
}
[kEnableStreaming](enable: boolean) {
const handle = this[kHandle];
if (handle) {
@@ -890,6 +909,14 @@ const NodeHTTPServerSocket = class Socket extends Duplex {
}
}
#onClose() {
const handle = this[kHandle];
// Save bytesWritten and bytesRead before nulling the handle (Node.js compatibility)
if (handle) {
const handleBytes = handle.bytesWritten ?? 0;
const responseBytes = handle.response?.getBytesWritten?.() ?? 0;
this[kBytesWritten] = Math.max(handleBytes, responseBytes);
this[kBytesRead] = handle.bytesRead ?? 0;
}
this[kHandle] = null;
const message = this._httpMessage;
@@ -904,6 +931,9 @@ const NodeHTTPServerSocket = class Socket extends Duplex {
req.destroy();
}
}
// Emit the 'close' event on the socket (Node.js compatibility)
this.emit("close");
}
#onCloseForDestroy(closeCallback) {
this.#onClose();

View File

@@ -0,0 +1,207 @@
import { describe, expect, test } from "bun:test";
import http from "node:http";
import net from "node:net";
describe("Issue #26553 - HTTP server socket events and properties", () => {
test("connection event fires before connect event for CONNECT requests", async () => {
const events: string[] = [];
const server = http.createServer();
server.on("connection", socket => {
events.push("connection");
// Set a custom property on the socket that should be available in the 'connect' handler
socket.myCustomId = 12345;
});
server.on("connect", (req, socket, head) => {
events.push("connect");
// The socket from the 'connect' event should be the same as from 'connection'
expect(req.socket.myCustomId).toBe(12345);
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
socket.end();
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as any).port;
try {
// Make a CONNECT request using node:net
const client = net.createConnection({ host: "127.0.0.1", port }, () => {
client.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n");
});
await new Promise<void>((resolve, reject) => {
client.on("data", data => {
const response = data.toString();
if (response.includes("200 Connection Established")) {
client.end();
}
});
client.on("close", resolve);
client.on("error", reject);
});
// Verify the order of events
expect(events).toEqual(["connection", "connect"]);
} finally {
server.close();
}
});
test("socket close event fires when CONNECT connection closes", async () => {
const { promise: closePromise, resolve: closeResolve } = Promise.withResolvers<void>();
const server = http.createServer();
server.on("connection", socket => {
socket.on("close", () => {
closeResolve();
});
});
server.on("connect", (req, socket, head) => {
const flushed = socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (flushed) {
socket.end();
} else {
socket.once("drain", () => socket.end());
}
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as any).port;
try {
// Make a CONNECT request using node:net
const client = net.createConnection({ host: "127.0.0.1", port }, () => {
client.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n");
});
await new Promise<void>((resolve, reject) => {
client.on("close", resolve);
client.on("error", reject);
});
// Wait for the close event with a timeout
await Promise.race([
closePromise,
Bun.sleep(1000).then(() => {
throw new Error("Timeout waiting for close event");
}),
]);
} finally {
server.close();
}
});
test("socket bytesWritten tracks data sent for CONNECT requests", async () => {
let bytesWrittenValue = 0;
const { promise: closePromise, resolve: closeResolve } = Promise.withResolvers<void>();
const server = http.createServer();
server.on("connection", socket => {
socket.on("close", () => {
bytesWrittenValue = socket.bytesWritten;
closeResolve();
});
});
server.on("connect", (req, socket, head) => {
const flushed = socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (flushed) {
socket.end();
} else {
socket.once("drain", () => socket.end());
}
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as any).port;
try {
const client = net.createConnection({ host: "127.0.0.1", port }, () => {
client.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n");
});
await new Promise<void>((resolve, reject) => {
client.on("close", resolve);
client.on("error", reject);
});
await Promise.race([
closePromise,
Bun.sleep(1000).then(() => {
throw new Error("Timeout waiting for close event");
}),
]);
// bytesWritten should include the "HTTP/1.1 200 Connection Established\r\n\r\n" response
// which is 39 bytes
expect(bytesWrittenValue).toBeGreaterThanOrEqual(39);
} finally {
server.close();
}
});
test("socket bytesRead tracks data received for CONNECT tunnel", async () => {
let bytesReadValue = 0;
const { promise: closePromise, resolve: closeResolve } = Promise.withResolvers<void>();
const { promise: dataReceivedPromise, resolve: dataReceivedResolve } = Promise.withResolvers<void>();
const testData = "Hello from client!";
const server = http.createServer();
server.on("connection", socket => {
socket.on("close", () => {
bytesReadValue = socket.bytesRead;
closeResolve();
});
});
server.on("connect", (req, socket, head) => {
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
// Read data from the tunnel and signal when received
socket.once("data", () => {
dataReceivedResolve();
});
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as any).port;
try {
const client = net.createConnection({ host: "127.0.0.1", port }, () => {
client.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n");
});
// Wait for the server to receive the tunnel data before closing
client.on("data", data => {
if (data.toString().includes("200 Connection Established")) {
// Send data through the tunnel
client.write(testData);
// Wait for server to receive the data, then close client
dataReceivedPromise.then(() => client.end());
}
});
await new Promise<void>((resolve, reject) => {
client.on("close", resolve);
client.on("error", reject);
});
await Promise.race([
closePromise,
Bun.sleep(1000).then(() => {
throw new Error("Timeout waiting for close event");
}),
]);
// bytesRead should include the data sent after tunnel establishment
expect(bytesReadValue).toBeGreaterThanOrEqual(testData.length);
} finally {
server.close();
}
});
});