From f0f3ad138b91e2885f7a5dd6267da85722b8648b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 14 Sep 2026 06:18:33 -0700 Subject: [PATCH 01/12] fix(node): preserve undici timeout error metadata (cherry picked from commit cb4d3b9b8b7efcc015ce54f5b3240fcbbae6a8f5) --- src/js/thirdparty/undici.js | 35 ++++++++++++++++++++--- test/js/first_party/undici/undici.test.ts | 17 ++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/js/thirdparty/undici.js b/src/js/thirdparty/undici.js index cdd08e8f239c..6258a781d708 100644 --- a/src/js/thirdparty/undici.js +++ b/src/js/thirdparty/undici.js @@ -307,14 +307,41 @@ const interceptors = { }; // Error classes -class UndiciError extends Error {} +class UndiciError extends Error { + constructor(message, options) { + super(message, options); + this.name = "UndiciError"; + this.code = "UND_ERR"; + } +} class AbortError extends UndiciError {} class HTTPParserError extends Error {} -class HeadersTimeoutError extends UndiciError {} +class HeadersTimeoutError extends UndiciError { + constructor(message) { + super(message); + this.name = "HeadersTimeoutError"; + this.message = message || "Headers Timeout Error"; + this.code = "UND_ERR_HEADERS_TIMEOUT"; + } +} class HeadersOverflowError extends UndiciError {} -class BodyTimeoutError extends UndiciError {} +class BodyTimeoutError extends UndiciError { + constructor(message) { + super(message); + this.name = "BodyTimeoutError"; + this.message = message || "Body Timeout Error"; + this.code = "UND_ERR_BODY_TIMEOUT"; + } +} class RequestContentLengthMismatchError extends UndiciError {} -class ConnectTimeoutError extends UndiciError {} +class ConnectTimeoutError extends UndiciError { + constructor(message) { + super(message); + this.name = "ConnectTimeoutError"; + this.message = message || "Connect Timeout Error"; + this.code = "UND_ERR_CONNECT_TIMEOUT"; + } +} class ResponseStatusCodeError extends UndiciError {} class InvalidArgumentError extends UndiciError {} class InvalidReturnValueError extends UndiciError {} diff --git a/test/js/first_party/undici/undici.test.ts b/test/js/first_party/undici/undici.test.ts index 59087d28e7ab..26ef6a8c10e9 100644 --- a/test/js/first_party/undici/undici.test.ts +++ b/test/js/first_party/undici/undici.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { Readable } from "node:stream"; -import { request, fetch as undiciFetch } from "undici"; +import { errors, request, fetch as undiciFetch } from "undici"; import { createServer } from "../../../http-test-server"; @@ -344,6 +344,21 @@ describe("undici", () => { }); }); +describe("undici timeout errors", () => { + it.each([ + [errors.ConnectTimeoutError, "ConnectTimeoutError", "UND_ERR_CONNECT_TIMEOUT", "Connect Timeout Error"], + [errors.HeadersTimeoutError, "HeadersTimeoutError", "UND_ERR_HEADERS_TIMEOUT", "Headers Timeout Error"], + [errors.BodyTimeoutError, "BodyTimeoutError", "UND_ERR_BODY_TIMEOUT", "Body Timeout Error"], + ])("matches %s metadata", (ErrorClass, name, code, fallbackMessage) => { + const fallback = new ErrorClass(); + expect(fallback).toBeInstanceOf(errors.UndiciError); + expect(fallback).toMatchObject({ name, code, message: fallbackMessage }); + + const explicit = new ErrorClass("custom timeout"); + expect(explicit).toMatchObject({ name, code, message: "custom timeout" }); + }); +}); + describe("undici.request maxRedirections", () => { it("does not follow more redirects than maxRedirections allows", async () => { const hits: string[] = []; From 47e4efdc1101a90421553f687ce5dda77c07aa43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 16 Sep 2026 23:53:16 -0700 Subject: [PATCH 02/12] fix(undici): support WebSocket heartbeat APIs Ports oven-sh/bun#37111 into the OpenClaw compatibility build. (cherry picked from commit 278105f25fdf59e42dff3f99bae13d86714fa4c5) --- src/js/thirdparty/undici.js | 51 +++++++- test/js/first_party/undici/undici.test.ts | 140 +++++++++++++++++++++- 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/src/js/thirdparty/undici.js b/src/js/thirdparty/undici.js index 6258a781d708..66ce30a5ef7a 100644 --- a/src/js/thirdparty/undici.js +++ b/src/js/thirdparty/undici.js @@ -1,8 +1,10 @@ const EventEmitter = require("node:events"); +const diagnosticsChannel = require("node:diagnostics_channel"); const { _ReadableFromWeb: ReadableFromWeb } = require("internal/webstreams_adapters"); const ObjectCreate = Object.create; const kEmptyObject = ObjectCreate(null); +const NativeArrayBuffer = ArrayBuffer; var fetch = Bun.fetch; const bindings = $cpp("Undici.cpp", "createUndiciInternalBinding"); @@ -14,7 +16,7 @@ const File = bindings[4]; const URL = bindings[5]; const AbortSignal = bindings[6]; const URLSearchParams = bindings[7]; -const WebSocket = bindings[8]; +const BunWebSocket = bindings[8]; const CloseEvent = bindings[9]; const ErrorEvent = bindings[10]; const MessageEvent = bindings[11]; @@ -391,6 +393,52 @@ const util = { }, }; +const pingChannel = diagnosticsChannel.channel("undici:websocket:ping"); +const pongChannel = diagnosticsChannel.channel("undici:websocket:pong"); + +// undici publishes a Buffer regardless of the socket's binaryType +function controlFramePayload(data) { + if (Buffer.isBuffer(data)) return data; + if (data instanceof NativeArrayBuffer) return Buffer.from(data); + if ($isTypedArrayView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + return data; +} + +class WebSocket extends BunWebSocket { + constructor(...args) { + super(...args); + this.addEventListener("ping", ({ data }) => { + if (pingChannel.hasSubscribers) { + pingChannel.publish({ payload: controlFramePayload(data), websocket: this }); + } + }); + this.addEventListener("pong", ({ data }) => { + if (pongChannel.hasSubscribers) { + pongChannel.publish({ payload: controlFramePayload(data), websocket: this }); + } + }); + } +} + +function ping(ws, payload) { + if (!(ws instanceof BunWebSocket)) { + throw new TypeError("Expected a WebSocket instance"); + } + + if (Buffer.isBuffer(payload)) { + if (payload.length > 125) { + throw new TypeError("A PING frame cannot have a body larger than 125 bytes."); + } + } else if (payload !== undefined) { + throw new TypeError("Expected buffer payload"); + } + + if (ws.readyState === BunWebSocket.OPEN) { + if (payload === undefined) ws.ping(); + else ws.ping(payload); + } +} + class EventSource extends EventTarget { static CONNECTING = 0; static OPEN = 1; @@ -501,6 +549,7 @@ const moduleExports = { mockErrors, MockPool, parseMIMEType, + ping, pipeline, Pool, ProxyAgent, diff --git a/test/js/first_party/undici/undici.test.ts b/test/js/first_party/undici/undici.test.ts index 26ef6a8c10e9..cdc287ab3cc3 100644 --- a/test/js/first_party/undici/undici.test.ts +++ b/test/js/first_party/undici/undici.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import diagnosticsChannel from "node:diagnostics_channel"; import { Readable } from "node:stream"; -import { errors, request, fetch as undiciFetch } from "undici"; +import { errors, ping, request, fetch as undiciFetch, WebSocket as UndiciWebSocket } from "undici"; import { createServer } from "../../../http-test-server"; @@ -409,3 +410,140 @@ describe("undici.request maxRedirections", () => { } }); }); + +describe.concurrent("undici WebSocket ping", () => { + function serveWebSocket(handlers: Partial = {}) { + return Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response(null, { status: 400 }); + }, + websocket: { + message() {}, + ...handlers, + }, + }); + } + + it("exports ping as a function", () => { + expect(typeof ping).toBe("function"); + }); + + it("ping() sends a ping frame and undici:websocket:pong fires on the reply", async () => { + const serverReceived = Promise.withResolvers(); + await using server = serveWebSocket({ + ping(_ws, data) { + serverReceived.resolve(data); + }, + }); + + const ws = new UndiciWebSocket(`ws://localhost:${server.port}/`); + const pongMessage = Promise.withResolvers<{ payload: Buffer; websocket: unknown }>(); + const pongChannel = diagnosticsChannel.channel("undici:websocket:pong"); + // the channel is process-global, so only accept messages for this socket + const onPong = (message: any) => { + if (message.websocket === ws) pongMessage.resolve(message); + }; + pongChannel.subscribe(onPong); + try { + const opened = Promise.withResolvers(); + ws.addEventListener("open", () => opened.resolve()); + ws.addEventListener("error", (e: any) => opened.reject(e.error ?? new Error(e.message))); + await opened.promise; + + ping(ws, Buffer.from("hello slack")); + + expect((await serverReceived.promise).toString()).toBe("hello slack"); + + // the server answers the ping with a pong echoing the payload + const message = await pongMessage.promise; + expect(Buffer.isBuffer(message.payload)).toBe(true); + expect(message.payload.toString()).toBe("hello slack"); + expect(message.websocket).toBe(ws); + } finally { + pongChannel.unsubscribe(onPong); + ws.close(); + } + }); + + it("undici:websocket:ping fires when the server sends a ping", async () => { + await using server = serveWebSocket({ + open(ws) { + ws.ping(Buffer.from("from server")); + }, + }); + + const ws = new UndiciWebSocket(`ws://localhost:${server.port}/`); + const pingMessage = Promise.withResolvers<{ payload: Buffer; websocket: unknown }>(); + const pingChannel = diagnosticsChannel.channel("undici:websocket:ping"); + // the channel is process-global, so only accept messages for this socket + const onPing = (message: any) => { + if (message.websocket === ws) pingMessage.resolve(message); + }; + pingChannel.subscribe(onPing); + try { + ws.addEventListener("error", (e: any) => pingMessage.reject(e.error ?? new Error(e.message))); + ws.addEventListener("close", (e: any) => + pingMessage.reject(new Error(`socket closed before ping: ${e.code} ${e.reason}`)), + ); + const message = await pingMessage.promise; + expect(Buffer.isBuffer(message.payload)).toBe(true); + expect(message.payload.toString()).toBe("from server"); + expect(message.websocket).toBe(ws); + } finally { + pingChannel.unsubscribe(onPing); + ws.close(); + } + }); + + it("publishes a Buffer payload even when binaryType is 'arraybuffer'", async () => { + await using server = serveWebSocket({ + open(ws) { + ws.ping(Buffer.from("typed")); + }, + }); + + const ws = new UndiciWebSocket(`ws://localhost:${server.port}/`); + ws.binaryType = "arraybuffer"; + const pingMessage = Promise.withResolvers<{ payload: Buffer; websocket: unknown }>(); + const pingChannel = diagnosticsChannel.channel("undici:websocket:ping"); + const onPing = (message: any) => { + if (message.websocket === ws) pingMessage.resolve(message); + }; + pingChannel.subscribe(onPing); + try { + ws.addEventListener("error", (e: any) => pingMessage.reject(e.error ?? new Error(e.message))); + ws.addEventListener("close", (e: any) => + pingMessage.reject(new Error(`socket closed before ping: ${e.code} ${e.reason}`)), + ); + const message = await pingMessage.promise; + expect(Buffer.isBuffer(message.payload)).toBe(true); + expect(message.payload.toString()).toBe("typed"); + } finally { + pingChannel.unsubscribe(onPing); + ws.close(); + } + }); + + it("ping() validates its arguments like undici", async () => { + await using server = serveWebSocket(); + const ws = new UndiciWebSocket(`ws://localhost:${server.port}/`); + try { + expect(() => ping(undefined as any)).toThrow(TypeError); + expect(() => ping(null as any)).toThrow(TypeError); + expect(() => ping({} as any)).toThrow(TypeError); + expect(() => ping(ws, "not a buffer" as any)).toThrow("Expected buffer payload"); + expect(() => ping(ws, null as any)).toThrow("Expected buffer payload"); + expect(() => ping(ws, new Uint8Array(4) as any)).toThrow("Expected buffer payload"); + expect(() => ping(ws, Buffer.alloc(126))).toThrow("A PING frame cannot have a body larger than 125 bytes."); + // a ping on a socket that is not open is a no-op, not an error + // (matches undici 7.x: ping() only sends while OPEN and never throws on state) + expect(ws.readyState).toBe(UndiciWebSocket.CONNECTING); + expect(() => ping(ws, Buffer.alloc(125))).not.toThrow(); + expect(() => ping(ws)).not.toThrow(); + } finally { + ws.close(); + } + }); +}); From 4c686f8e968656691065e1ac54f85fea825c889f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:39:37 -0700 Subject: [PATCH 03/12] fix(fetch): honor custom Headers iterators Backport the complete HeadersInit guard and caller cutover from oven-sh/bun#43023 at b276ab81ddf7c38737cf9b2917af5ba2b9b2eb52 onto the fb7c integration line. The older source keeps FetchSession proxy parsing inline in fetch.rs, so map that caller there. Apply the same guarded conversion to server.fetch and clone its native header list for Request ownership. This preserves the caller's Headers snapshot and incorporates the matching ownership correction from oven-sh/bun#40888 without adopting the JS wrapper's borrowed reference. (cherry picked from commit f5234e318cbacc4468401c927f90b80a5e6aab87) --- src/jsc/FetchHeaders.rs | 10 +- src/jsc/bindings/headers.h | 1 + src/jsc/bindings/webcore/JSFetchHeaders.cpp | 35 ++- src/jsc/bindings/webcore/JSFetchHeaders.h | 2 + src/jsc/bindings/webcore/JSWebSocket.cpp | 4 +- src/runtime/server/server_body.rs | 60 ++-- src/runtime/webcore/Response.rs | 5 +- src/runtime/webcore/fetch.rs | 4 +- src/runtime/webcore/fetch/FetchSession.rs | 2 +- test/js/bun/http/bun-server.test.ts | 43 +++ test/js/web/fetch/headers.test.ts | 260 +++++++++++++++++ .../web/fetch/wpt/headers-basic-wpt.test.ts | 22 ++ test/js/web/fetch/wpt/headers-basic.any.js | 275 ++++++++++++++++++ 13 files changed, 674 insertions(+), 49 deletions(-) create mode 100644 test/js/web/fetch/wpt/headers-basic-wpt.test.ts create mode 100644 test/js/web/fetch/wpt/headers-basic.any.js diff --git a/src/jsc/FetchHeaders.rs b/src/jsc/FetchHeaders.rs index 5ff3234ded09..51d12f89623e 100644 --- a/src/jsc/FetchHeaders.rs +++ b/src/jsc/FetchHeaders.rs @@ -1,7 +1,6 @@ use core::ffi::c_void; use core::ptr::NonNull; -use crate::virtual_machine::VirtualMachine; use crate::{JSGlobalObject, JSValue, JsResult, VM, host_fn}; use bun_core::{EncodedSlice, String as BunString, StringPointer}; use bun_uws::ResponseKind; @@ -22,6 +21,7 @@ bun_opaque::opaque_ffi! { // (which may free) keep their `unsafe fn` body. unsafe extern "C" { safe fn WebCore__FetchHeaders__cast_(value0: JSValue, arg1: &VM) -> *mut FetchHeaders; + safe fn WebCore__FetchHeaders__castAsInit(value0: JSValue) -> *mut FetchHeaders; safe fn WebCore__FetchHeaders__cloneThis( arg0: &FetchHeaders, arg1: &JSGlobalObject, @@ -245,11 +245,9 @@ impl FetchHeaders { NonNull::new(WebCore__FetchHeaders__cast_(value, vm)) } - pub fn cast(value: JSValue) -> Option> { - // SAFETY: `VirtualMachine::get()` is only called from the JS thread, where - // `global` is a live non-null JSGlobalObject for the VM's lifetime. - let global = VirtualMachine::get().global(); - Self::cast_(value, global.vm()) + /// [`Self::cast_`] for a HeadersInit: `None` unless `Symbol.iterator` is still `Headers.prototype.entries`. + pub fn cast_as_init(value: JSValue) -> Option> { + NonNull::new(WebCore__FetchHeaders__castAsInit(value)) } pub fn to_js(&mut self, global_this: &JSGlobalObject) -> JSValue { diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 06c164b0a830..962c26aa96ee 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -66,6 +66,7 @@ CPP_DECL WebCore::DOMFormData* _fromJS(JSC::EncodedJSValue JSValue0); #pragma mark - WebCore::FetchHeaders CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__cast_(JSC::EncodedJSValue JSValue0, JSC::VM* arg1); +CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__castAsInit(JSC::EncodedJSValue JSValue0); CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__cloneThis(WebCore::FetchHeaders* arg0, JSC::JSGlobalObject* arg1); CPP_DECL void WebCore__FetchHeaders__copyTo(WebCore::FetchHeaders* arg0, StringPointer* arg1, StringPointer* arg2, unsigned char* arg3); CPP_DECL void WebCore__FetchHeaders__count(WebCore::FetchHeaders* arg0, uint32_t* arg1, uint32_t* arg2); diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.cpp b/src/jsc/bindings/webcore/JSFetchHeaders.cpp index d6e37d72a3bd..c7c8d4ba9fa4 100644 --- a/src/jsc/bindings/webcore/JSFetchHeaders.cpp +++ b/src/jsc/bindings/webcore/JSFetchHeaders.cpp @@ -133,8 +133,8 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSFetchHeadersDOMConstru auto init = std::optional>, IDLRecord>>::ReturnType>(); if (argument0.value() && !argument0.value().isUndefined()) { - if (auto* existingJsFetchHeaders = dynamicDowncast(argument0.value())) { - auto newHeaders = FetchHeaders::create(existingJsFetchHeaders->wrapped()); + if (auto* existingHeaders = JSFetchHeaders::toWrappedAsInit(argument0.value())) { + auto newHeaders = FetchHeaders::create(*existingHeaders); auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *castedThis->globalObject(), throwScope, WTF::move(newHeaders)); if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); @@ -697,4 +697,35 @@ FetchHeaders* JSFetchHeaders::toWrapped(JSC::VM& vm, JSC::JSValue value) return &wrapper->wrapped(); return nullptr; } + +FetchHeaders* JSFetchHeaders::toWrappedAsInit(JSC::JSValue value) +{ + auto* wrapper = dynamicDowncast(value); + if (!wrapper) + return nullptr; + + // A [[Get]] of Symbol.iterator that runs no user code. PropertySlot::VMInquiry measured twice as slow on this path. + auto& vm = wrapper->vm(); + for (JSObject* object = wrapper;;) { + // A Proxy, or any object that answers from outside its Structure: the generic conversion does the real [[Get]]. + if (TypeInfo::overridesGetOwnPropertySlot(object->inlineTypeFlags()) || TypeInfo::hasStaticPropertyTable(object->inlineTypeFlags()) || object->structure()->typeInfo().overridesGetPrototype()) + return nullptr; + if (JSValue method = object->getDirect(vm, vm.propertyNames->iteratorSymbol)) { + // An accessor is a GetterSetter cell here, so it fails the cast. + auto* function = dynamicDowncast(method); + if (!function || !function->isHostFunction() || function->nativeFunction() != TaggedNativeFunction(jsFetchHeadersPrototypeFunction_entries)) + return nullptr; + return &wrapper->wrapped(); + } + JSValue prototype = object->getPrototypeDirect(); + if (!prototype.isObject()) + return nullptr; + object = asObject(prototype); + } +} +} + +extern "C" WebCore::FetchHeaders* WebCore__FetchHeaders__castAsInit(JSC::EncodedJSValue value) +{ + return WebCore::JSFetchHeaders::toWrappedAsInit(JSC::JSValue::decode(value)); } diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.h b/src/jsc/bindings/webcore/JSFetchHeaders.h index 19cd9ffb179b..50638dcbf6ee 100644 --- a/src/jsc/bindings/webcore/JSFetchHeaders.h +++ b/src/jsc/bindings/webcore/JSFetchHeaders.h @@ -38,6 +38,8 @@ class JSFetchHeaders : public JSDOMWrapper { static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); static FetchHeaders* toWrapped(JSC::VM&, JSC::JSValue); + // toWrapped() for a HeadersInit: null unless Symbol.iterator is still Headers.prototype.entries, which Web IDL converts through. + static FetchHeaders* toWrappedAsInit(JSC::JSValue); static void destroy(JSC::JSCell*); DECLARE_INFO; diff --git a/src/jsc/bindings/webcore/JSWebSocket.cpp b/src/jsc/bindings/webcore/JSWebSocket.cpp index 60993d8be5ff..8799f294e4cd 100644 --- a/src/jsc/bindings/webcore/JSWebSocket.cpp +++ b/src/jsc/bindings/webcore/JSWebSocket.cpp @@ -313,9 +313,9 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG RETURN_IF_EXCEPTION(throwScope, {}); if (proxyHeadersValue && !proxyHeadersValue.isUndefinedOrNull()) { // Check if it's already a Headers instance (like fetch does) - if (auto* jsHeaders = dynamicDowncast(proxyHeadersValue)) { + if (auto* wrapped = JSFetchHeaders::toWrappedAsInit(proxyHeadersValue)) { // Convert FetchHeaders to the Init variant - auto& headers = jsHeaders->wrapped(); + auto& headers = *wrapped; Vector> pairs; auto iterator = headers.createIterator(false); while (auto value = iterator.next()) { diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 7cb46afbadfe..85210069c8c3 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -1195,8 +1195,8 @@ fn on_reject_impl(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult Option<*mut FetchHeaders> { - FetchHeaders::cast_(value, global.vm()).map(|p| p.as_ptr()) +fn fetch_headers_from_js(value: JSValue) -> Option<*mut FetchHeaders> { + FetchHeaders::cast_as_init(value).map(|p| p.as_ptr()) } /// Per-process latch for the dev-mode idle-timeout warning. The @@ -1766,17 +1766,18 @@ where } let fetch_headers_to_use: *mut FetchHeaders = - match fetch_headers_from_js(headers_value, global) { + match fetch_headers_from_js(headers_value) { Some(h) => h, None => 'brk: { if headers_value.is_object() { - if let Some(fetch_headers) = + // `None` is an empty HeadersInit: no header to add. + let Some(fetch_headers) = FetchHeaders::create_from_js(global, headers_value)? - { - fetch_headers_to_deref - .set(Some(fetch_headers.as_ptr())); - break 'brk fetch_headers.as_ptr(); - } + else { + break 'getter; + }; + fetch_headers_to_deref.set(Some(fetch_headers.as_ptr())); + break 'brk fetch_headers.as_ptr(); } return Err(global.throw_invalid_arguments(format_args!( "upgrade options.headers must be a Headers or an object" @@ -1980,16 +1981,18 @@ where break 'getter; } use jsc::HTTPHeaderName; - let fh: *mut FetchHeaders = match fetch_headers_from_js(headers_value, global) { + let fh: *mut FetchHeaders = match fetch_headers_from_js(headers_value) { Some(h) => h, None => 'brk: { if headers_value.is_object() { - if let Some(created) = + // `None` is an empty HeadersInit: no header to add. + let Some(created) = FetchHeaders::create_from_js(global, headers_value)? - { - *fetch_headers_to_deref = Some(created.as_ptr()); - break 'brk created.as_ptr(); - } + else { + break 'getter; + }; + *fetch_headers_to_deref = Some(created.as_ptr()); + break 'brk created.as_ptr(); } return Err(global.throw_invalid_arguments(format_args!( "upgrade options.headers must be a Headers or an object" @@ -2353,23 +2356,16 @@ where } if let Some(headers_) = opts.fast_get(ctx, jsc::BuiltinName::Headers)? { - if let Some(headers__) = FetchHeaders::cast_(headers_, ctx.vm()) { - // NOTE: `cast_` returns the `FetchHeaders*` held by the - // JS `Headers` wrapper (`JSFetchHeaders`'s internal - // `Ref`) without bumping the refcount — - // the FFI surface has `WebCore__FetchHeaders__deref` but - // no `ref()`, so a +1 cannot be taken here. Adopting - // hands that wrapper-held ref to the constructed - // `Request` (via `Request::init2` below): the eventual - // single deref happens when the Request's finalizer - // drops its `headers` field (`HeadersRef::Drop`, - // Response.rs), pairing with the wrapper's +1. - // SAFETY: `headers__` is live (rooted by `headers_`), - // and ownership of one ref transfers as described above. - headers = Some(unsafe { HeadersRef::adopt(headers__) }); - } else if let Some(headers__) = FetchHeaders::create_from_js(ctx, headers_)? { - // SAFETY: create_from_js returns a +1 ref. - headers = Some(unsafe { HeadersRef::adopt(headers__) }); + if let Some(headers__) = FetchHeaders::cast_as_init(headers_) { + // The JS `Headers` keeps its own reference; the Request + // gets a copy, as `new Request(url, { headers })` does. + // S008: `FetchHeaders` is an opaque ZST FFI handle — safe deref. + headers = bun_opaque::opaque_deref_mut(headers__.as_ptr()) + .clone_this(ctx)? + // SAFETY: `clone_this` returns a +1 ref. + .map(|p| unsafe { HeadersRef::adopt(p) }); + } else { + headers = HeadersRef::create_from_js(ctx, headers_)?; } } diff --git a/src/runtime/webcore/Response.rs b/src/runtime/webcore/Response.rs index 7608783c95a6..8a3019303330 100644 --- a/src/runtime/webcore/Response.rs +++ b/src/runtime/webcore/Response.rs @@ -1263,10 +1263,7 @@ impl Init { } if let Some(headers) = response_init.fast_get(global_this, BuiltinName::headers)? { - // `JSValue::as_::()` requires `JsClass`; - // FetchHeaders is a hand-bound opaque, so use its dedicated - // `cast()` (wraps `WebCore__FetchHeaders__cast_`). - if let Some(orig) = FetchHeaders::cast(headers) { + if let Some(orig) = FetchHeaders::cast_as_init(headers) { // `orig` is a live `WebCore::FetchHeaders*` borrowed from JS; // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. let orig = bun_opaque::opaque_deref_mut(orig.as_ptr()); diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 7eba2cdf3b3a..daedd55f3b61 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1175,7 +1175,7 @@ fn fetch_impl( options.fast_get(global_this, jsc::BuiltinName::Headers)? { if !headers_value.is_undefined() { - if let Some(headers__) = FetchHeaders::cast(headers_value) { + if let Some(headers__) = FetchHeaders::cast_as_init(headers_value) { // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. if bun_opaque::opaque_deref_mut(headers__.as_ptr()).is_empty() { break 'brk None; @@ -1204,7 +1204,7 @@ fn fetch_impl( options.fast_get(global_this, jsc::BuiltinName::Headers)? { if !headers_value.is_undefined() { - if let Some(headers__) = FetchHeaders::cast(headers_value) { + if let Some(headers__) = FetchHeaders::cast_as_init(headers_value) { // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. if bun_opaque::opaque_deref_mut(headers__.as_ptr()).is_empty() { break 'brk None; diff --git a/src/runtime/webcore/fetch/FetchSession.rs b/src/runtime/webcore/fetch/FetchSession.rs index d5319237838f..8e781670b252 100644 --- a/src/runtime/webcore/fetch/FetchSession.rs +++ b/src/runtime/webcore/fetch/FetchSession.rs @@ -138,7 +138,7 @@ pub(crate) fn parse_proxy(global: &JSGlobalObject, proxy_arg: JSValue) -> JsResu let mut headers = None; if let Some(headers_value) = proxy_arg.get(global, "headers")? { if !headers_value.is_undefined_or_null() { - if let Some(fetch_headers) = FetchHeaders::cast(headers_value) { + if let Some(fetch_headers) = FetchHeaders::cast_as_init(headers_value) { let fetch_headers = bun_ptr::BackRef::from(fetch_headers); headers = Some(from_fetch_headers(Some(&*fetch_headers), None)); } else if let Some(fetch_headers) = HeadersRef::create_from_js(global, headers_value)? { diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 949bec4432b9..43c639b20be9 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -352,6 +352,49 @@ describe.concurrent("Server", () => { } }); + test("server.fetch(url, { headers: Headers }) copies the headers", async () => { + let request: Request | undefined; + using server = Bun.serve({ + port: 0, + fetch(req) { + request = req; + return new Response(req.headers.get("x-value")); + }, + }); + const url = `http://${server.hostname}:${server.port}/`; + const headers = new Headers({ "x-value": "initial" }); + const response = await server.fetch(url, { headers }); + expect(await response.text()).toBe("initial"); + expect(request).toBeDefined(); + const requestHeaders = request!.headers; + + headers.set("x-value", "source"); + expect(requestHeaders.get("x-value")).toBe("initial"); + + requestHeaders.set("x-value", "request"); + expect(headers.get("x-value")).toBe("source"); + }); + + test("server.fetch converts a Headers object through its custom iterator", async () => { + using server = Bun.serve({ + port: 0, + fetch(req) { + return Response.json({ + fromIterator: req.headers.get("x-from-iterator"), + original: req.headers.get("x-original"), + }); + }, + }); + const headers = new Headers({ "x-original": "ignored" }); + Object.defineProperty(headers, Symbol.iterator, { + value: function* () { + yield ["x-from-iterator", "used"]; + }, + }); + const response = await server.fetch(`http://${server.hostname}:${server.port}/`, { headers }); + expect(await response.json()).toEqual({ fromIterator: "used", original: null }); + }); + test("server should return a body for a OPTIONS Request", async () => { using server = Bun.serve({ port: 0, diff --git a/test/js/web/fetch/headers.test.ts b/test/js/web/fetch/headers.test.ts index b514a241674c..e7abf4921e7a 100644 --- a/test/js/web/fetch/headers.test.ts +++ b/test/js/web/fetch/headers.test.ts @@ -233,6 +233,266 @@ describe("Headers", () => { ).toThrow(error); }); }); + // Web IDL converts a HeadersInit through the Symbol.iterator method of the value. A Headers + // object is copied from its header list only while that method is Headers.prototype.entries. + describe("a Headers object as HeadersInit", () => { + const entries = (headers: Headers) => [...Headers.prototype.entries.call(headers)]; + + class Overridden extends Headers { + *[Symbol.iterator](): Generator<[string, string], undefined, unknown> { + yield ["x-from-iterator", "1"]; + } + } + const overridden = () => new Overridden([["a", "b"]]); + + function withOwnIterator(method: unknown, enumerable = true) { + const headers = new Headers([["a", "b"]]); + Object.defineProperty(headers, Symbol.iterator, { value: method, enumerable, configurable: true }); + return headers; + } + + test("new Headers() calls a subclass iterator", () => { + expect(entries(new Headers(overridden()))).toEqual([["x-from-iterator", "1"]]); + }); + + test("new Headers() calls an own iterator", () => { + const headers = withOwnIterator(function* (this: Headers) { + yield ["x-own", this.get("a")]; + }); + expect(entries(new Headers(headers))).toEqual([["x-own", "b"]]); + }); + + test("new Headers() reads an accessor once", () => { + const headers = new Headers([["a", "b"]]); + let reads = 0; + Object.defineProperty(headers, Symbol.iterator, { + get() { + reads++; + return function* () { + yield ["x-getter", "1"]; + }; + }, + }); + expect(entries(new Headers(headers))).toEqual([["x-getter", "1"]]); + expect(reads).toBe(1); + }); + + test("new Headers() reads the iterator through a Proxy in the prototype chain", () => { + const headers = new Headers([["a", "b"]]); + const keys: PropertyKey[] = []; + const proxy = new Proxy(Headers.prototype, { + get(target, key, receiver) { + keys.push(key); + if (key !== Symbol.iterator) return Reflect.get(target, key, receiver); + return function* () { + yield ["x-proxy", "1"]; + }; + }, + }); + Object.setPrototypeOf(headers, proxy); + expect(entries(new Headers(headers))).toEqual([["x-proxy", "1"]]); + expect(keys).toEqual([Symbol.iterator]); + }); + + test("new Headers() reads a replaced and a deleted Headers.prototype[Symbol.iterator]", () => { + const descriptor = Object.getOwnPropertyDescriptor(Headers.prototype, Symbol.iterator)!; + const source = new Headers([["a", "b"]]); + const seen: Record = {}; + try { + Headers.prototype[Symbol.iterator] = function* () { + yield ["x-prototype", "1"]; + } as any; + seen.replaced = entries(new Headers(source)); + delete (Headers.prototype as any)[Symbol.iterator]; + // No iterator method: the value converts as a record, and it has no own keys. + seen.deleted = entries(new Headers(source)); + } finally { + Object.defineProperty(Headers.prototype, Symbol.iterator, descriptor); + } + seen.restored = entries(new Headers(source)); + expect(seen).toEqual({ + replaced: [["x-prototype", "1"]], + deleted: [], + restored: [["a", "b"]], + }); + }); + + test("new Headers() throws what the iterator throws", () => { + const error = new RangeError("Iterator failed."); + const headers = withOwnIterator(() => { + throw error; + }); + expect(() => new Headers(headers)).toThrow(error); + }); + + test("new Headers() rejects an iterator that does not yield pairs", () => { + expect(() => new Headers(withOwnIterator(Headers.prototype.keys))).toThrow(TypeError); + }); + + test("new Headers() converts as a record when the iterator is undefined", () => { + expect(entries(new Headers(withOwnIterator(undefined, false)))).toEqual([]); + // A record key cannot be a symbol. + expect(() => new Headers(withOwnIterator(undefined))).toThrow(TypeError); + }); + + test("new Headers() copies the header list when the iterator is the built-in one", () => { + class OnlyEntries extends Headers { + *entries(): Generator<[string, string], undefined, unknown> { + yield ["x-entries", "1"]; + } + } + expect(entries(new Headers(new OnlyEntries([["a", "b"]])))).toEqual([["a", "b"]]); + expect(entries(new Headers(withOwnIterator(Headers.prototype.entries)))).toEqual([["a", "b"]]); + }); + + test("Response and Request call the iterator", () => { + const request = new Request("http://localhost/", { headers: { "x-request": "1" } }); + const emptyWithOwnIterator = new Headers(); + emptyWithOwnIterator[Symbol.iterator] = (() => [["x-own", "1"]].values()) as any; + expect({ + response: entries(new Response(null, { headers: overridden() }).headers), + json: entries(Response.json(null, { headers: overridden() }).headers), + redirect: entries(Response.redirect("http://localhost/", { headers: overridden() }).headers), + request: entries(new Request("http://localhost/", { headers: overridden() }).headers), + requestFromRequest: entries(new Request(request, { headers: overridden() }).headers), + emptyWithOwnIterator: entries(new Response(null, { headers: emptyWithOwnIterator }).headers), + }).toEqual({ + response: [["x-from-iterator", "1"]], + json: [ + ["content-type", "application/json;charset=utf-8"], + ["x-from-iterator", "1"], + ], + redirect: [ + ["location", "http://localhost/"], + ["x-from-iterator", "1"], + ], + request: [["x-from-iterator", "1"]], + requestFromRequest: [["x-from-iterator", "1"]], + emptyWithOwnIterator: [["x-own", "1"]], + }); + }); + + // Bun.serve gives lowercase names. The raw request shows the names as sent. + async function sentHeaders(send: (url: string) => Promise) { + const { promise, resolve, reject } = Promise.withResolvers(); + let received = ""; + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket, chunk) { + received += chunk.toString("latin1"); + if (!received.includes("\r\n\r\n")) return; + // end() can call close() before it returns. + resolve(received); + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }, + close() { + reject(new Error("The connection closed before the end of the headers.")); + }, + error(_, error) { + reject(error); + }, + }, + }); + const [head] = await Promise.all([promise, send(`http://127.0.0.1:${server.port}/`)]); + return customHeaders(head); + } + const customHeaders = (head: string) => + head + .split("\r\n") + .filter(line => /^(a|x-[a-z-]+):/i.test(line)) + .sort(); + + test("fetch() sends what the iterator yields", async () => { + expect(await sentHeaders(url => fetch(url, { headers: overridden() }))).toEqual(["x-from-iterator: 1"]); + }); + + test("fetch({ url, headers }) sends what the iterator yields", async () => { + const sent = await sentHeaders(url => fetch({ url, headers: overridden() } as any)); + expect(sent).toEqual(["x-from-iterator: 1"]); + }); + + test("fetch() keeps the case of the names when a subclass has the built-in iterator", async () => { + class Plain extends Headers {} + const headers = new Plain([["X-Mixed-Case", "1"]]); + expect(await sentHeaders(url => fetch(url, { headers }))).toEqual(["X-Mixed-Case: 1"]); + }); + + test("fetch() sends what the iterator of proxy.headers yields", async () => { + const sent = await sentHeaders(url => + fetch("http://example.invalid/", { proxy: { url, headers: overridden() } }), + ); + expect(sent).toEqual(["x-from-iterator: 1"]); + }); + + test("WebSocket sends what the iterator of proxy.headers yields", async () => { + const sent = await sentHeaders(url => { + const { promise, resolve } = Promise.withResolvers(); + // The proxy in this test answers and closes, so the handshake fails. + const ws = new WebSocket("ws://example.invalid/", { proxy: { url, headers: overridden() } }); + ws.onclose = () => resolve(); + return promise; + }); + expect(sent).toEqual(["x-from-iterator: 1"]); + }); + + // The status line and the custom headers of the answer to a WebSocket handshake. + async function upgradeResponse(headers: HeadersInit) { + using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request, server) { + if (server.upgrade(request, { headers })) return; + return new Response("The upgrade failed.", { status: 500 }); + }, + websocket: { message() {} }, + }); + const { promise, resolve, reject } = Promise.withResolvers(); + let received = ""; + const socket = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(socket) { + socket.write( + "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + }, + data(_, chunk) { + received += chunk.toString("latin1"); + if (received.includes("\r\n\r\n")) resolve(received); + }, + close() { + reject(new Error("The connection closed before the end of the headers.")); + }, + error(_, error) { + reject(error); + }, + }, + }); + try { + const head = await promise; + return [head.split("\r\n")[0], ...customHeaders(head)]; + } finally { + socket.end(); + } + } + + test("server.upgrade() sends what the iterator yields", async () => { + expect(await upgradeResponse(overridden())).toEqual(["HTTP/1.1 101 Switching Protocols", "x-from-iterator: 1"]); + }); + + test("server.upgrade() adds no header when the HeadersInit is empty", async () => { + class YieldsNothing extends Headers { + *[Symbol.iterator](): Generator<[string, string], undefined, unknown> {} + } + for (const headers of [new YieldsNothing([["a", "b"]]), {}, []]) { + expect(await upgradeResponse(headers)).toEqual(["HTTP/1.1 101 Switching Protocols"]); + } + }); + }); describe("append()", () => { test("can append header", () => { const headers = new Headers(); diff --git a/test/js/web/fetch/wpt/headers-basic-wpt.test.ts b/test/js/web/fetch/wpt/headers-basic-wpt.test.ts new file mode 100644 index 000000000000..6bade2896bca --- /dev/null +++ b/test/js/web/fetch/wpt/headers-basic-wpt.test.ts @@ -0,0 +1,22 @@ +// Runs the vendored Web Platform Tests fetch/api/headers/headers-basic.any.js +// against Bun's Headers. The .any.js file is byte-identical to upstream; this +// driver follows textstream-wpt.test.ts. +// +// Vendored from web-platform-tests/wpt: +// fetch/api/headers/headers-basic.any.js + +import { test as bunTest } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { setRegistrar, wptTest } from "../../../third_party/wpt-testharness-shim"; + +setRegistrar((name, run) => { + bunTest(name, run); +}); + +// bun:test injects its own `test` binding into every imported module, which +// would shadow the WPT-style test(fn, name) global. Load the vendored file +// as text and run it inside a Function whose `test` parameter is the shim. +// All other testharness identifiers resolve via globalThis. +const src = readFileSync(join(import.meta.dir, "headers-basic.any.js"), "utf8"); +new Function("test", src)(wptTest); diff --git a/test/js/web/fetch/wpt/headers-basic.any.js b/test/js/web/fetch/wpt/headers-basic.any.js new file mode 100644 index 000000000000..ead1047645a1 --- /dev/null +++ b/test/js/web/fetch/wpt/headers-basic.any.js @@ -0,0 +1,275 @@ +// META: title=Headers structure +// META: global=window,worker + +"use strict"; + +test(function() { + new Headers(); +}, "Create headers from no parameter"); + +test(function() { + new Headers(undefined); +}, "Create headers from undefined parameter"); + +test(function() { + new Headers({}); +}, "Create headers from empty object"); + +var parameters = [null, 1]; +parameters.forEach(function(parameter) { + test(function() { + assert_throws_js(TypeError, function() { new Headers(parameter) }); + }, "Create headers with " + parameter + " should throw"); +}); + +var headerDict = {"name1": "value1", + "name2": "value2", + "name3": "value3", + "name4": null, + "name5": undefined, + "name6": 1, + "Content-Type": "value4" +}; + +var headerSeq = []; +for (var name in headerDict) + headerSeq.push([name, headerDict[name]]); + +test(function() { + var headers = new Headers(headerSeq); + for (name in headerDict) { + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } + assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary"); +}, "Create headers with sequence"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) { + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Create headers with record"); + +test(function() { + var headers = new Headers(headerDict); + var headers2 = new Headers(headers); + for (name in headerDict) { + assert_equals(headers2.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Create headers with existing headers"); + +test(function() { + var headers = new Headers() + headers[Symbol.iterator] = function *() { + yield ["test", "test"] + } + var headers2 = new Headers(headers) + assert_equals(headers2.get("test"), "test") +}, "Create headers with existing headers with custom iterator"); + +test(function() { + var headers = new Headers(); + for (name in headerDict) { + headers.append(name, headerDict[name]); + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Check append method"); + +test(function() { + var headers = new Headers(); + for (name in headerDict) { + headers.set(name, headerDict[name]); + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Check set method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) + assert_true(headers.has(name),"headers has name " + name); + + assert_false(headers.has("nameNotInHeaders"),"headers do not have header: nameNotInHeaders"); +}, "Check has method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) { + assert_true(headers.has(name),"headers have a header: " + name); + headers.delete(name) + assert_true(!headers.has(name),"headers do not have anymore a header: " + name); + } +}, "Check delete method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + + assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value"); +}, "Check get method"); + +var headerEntriesDict = {"name1": "value1", + "Name2": "value2", + "name": "value3", + "content-Type": "value4", + "Content-Typ": "value5", + "Content-Types": "value6" +}; +var sortedHeaderDict = {}; +var headerValues = []; +var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) { + sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]; + headerValues.push(headerEntriesDict[value]); + return value.toLowerCase(); +}).sort(); + +var iteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())); +function checkIteratorProperties(iterator) { + var prototype = Object.getPrototypeOf(iterator); + assert_equals(Object.getPrototypeOf(prototype), iteratorPrototype); + + var descriptor = Object.getOwnPropertyDescriptor(prototype, "next"); + assert_true(descriptor.configurable, "configurable"); + assert_true(descriptor.enumerable, "enumerable"); + assert_true(descriptor.writable, "writable"); +} + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.keys(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value, key); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const key of headers.keys()) + assert_true(sortedHeaderKeys.indexOf(key) != -1); +}, "Check keys method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.values(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value, sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const value of headers.values()) + assert_true(headerValues.indexOf(value) != -1); +}, "Check values method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.entries(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value[0], key); + assert_equals(entry.value[1], sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const entry of headers.entries()) + assert_equals(entry[1], sortedHeaderDict[entry[0]]); +}, "Check entries method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers[Symbol.iterator](); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value[0], key); + assert_equals(entry.value[1], sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); +}, "Check Symbol.iterator method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var reference = sortedHeaderKeys[Symbol.iterator](); + headers.forEach(function(value, key, container) { + assert_equals(headers, container); + const entry = reference.next(); + assert_false(entry.done); + assert_equals(key, entry.value); + assert_equals(value, sortedHeaderDict[entry.value]); + }); + assert_true(reference.next().done); +}, "Check forEach method"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + headers.delete("foo"); + } + assert_array_equals(actualKeys, ["bar", "baz"]); + assert_array_equals(actualValues, ["0", "1"]); +}, "Iteration skips elements removed while iterating"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.delete("bar"); + } + assert_array_equals(actualKeys, ["bar", "baz", "quux"]); + assert_array_equals(actualValues, ["0", "1", "3"]); +}, "Removing elements already iterated over causes an element to be skipped during iteration"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.append("X-yZ", "4"); + } + assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"]); + assert_array_equals(actualValues, ["0", "1", "2", "3", "4"]); +}, "Appending a value pair during iteration causes it to be reached during iteration"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.append("abc", "-1"); + } + assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"]); + assert_array_equals(actualValues, ["0", "1", "1", "2", "3"]); +}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time"); From e821a89291b6171fa04b7905f86cd36514324f6f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 14 Sep 2026 12:47:05 -0700 Subject: [PATCH 04/12] fix(node): format HTTP listen errors (cherry picked from commit abc9dd2cf11fc450cb077616ac4c381c8161e25e) --- src/js/node/_http_server.ts | 33 +++++++++++++++++++++++++++-- test/js/node/http/node-http.test.ts | 22 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index fadde5ed7942..87b5e81adc9d 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -272,6 +272,31 @@ function emitListenErrorNextTick(self, err) { self.emit("error", err); } +function formatListenError(err, port, host, socketPath) { + const description = + err?.code === "EADDRINUSE" + ? "address already in use" + : err?.code === "EACCES" + ? "permission denied" + : err?.code === "EADDRNOTAVAIL" + ? "address not available" + : err?.code === "EINVAL" + ? "invalid argument" + : undefined; + if (!description) { + return err; + } + const address = socketPath ?? host ?? "::"; + err.syscall = "listen"; + err.address = address; + if (port) { + err.port = port; + } + const location = port ? `${address}:${port}` : address; + err.message = `listen ${err.code}: ${description}${location ? ` ${location}` : ""}`; + return err; +} + // Node.js only requests a client certificate when `requestCert: true`. // The uSockets SSL context treats `ca` alone as "verify peer", so without // these two flags an `https.Server({ ca })` would reject every client that @@ -822,7 +847,11 @@ Server.prototype.listen = function () { serverNameHost, ); } catch (err) { - process.nextTick(emitListenErrorNextTick, server, err); + process.nextTick( + emitListenErrorNextTick, + server, + formatListenError(err, port, address, socketPath), + ); } }); return this; @@ -831,7 +860,7 @@ Server.prototype.listen = function () { try { startServerListen(server, tls, port, host, socketPath, serverNameHost); } catch (err) { - process.nextTick(emitListenErrorNextTick, server, err); + process.nextTick(emitListenErrorNextTick, server, formatListenError(err, port, host, socketPath)); } return this; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index b1fb62bf55ee..95de13bbe66b 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1573,6 +1573,28 @@ describe("node:http", () => { const err = await promise; expect(err.code).toBe("EADDRINUSE"); }); + + test("listen errors include Node-compatible address details", async () => { + const occupant = createServer(); + occupant.listen(0, "127.0.0.1"); + await once(occupant, "listening"); + const { port } = occupant.address() as AddressInfo; + + const server = createServer(); + server.listen(port, "127.0.0.1"); + const [error] = (await once(server, "error")) as [NodeJS.ErrnoException]; + + expect(error).toMatchObject({ + code: "EADDRINUSE", + syscall: "listen", + address: "127.0.0.1", + port, + }); + expect(error.message).toBe(`listen EADDRINUSE: address already in use 127.0.0.1:${port}`); + + occupant.close(); + await once(occupant, "close"); + }); }); describe("node https server", async () => { From 5ea3c1459c40fbe0167592fe574bf2f473ff1325 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 15 Sep 2026 19:34:00 -0700 Subject: [PATCH 05/12] fix(http): honor HTTPS server half-open policy (cherry picked from commit 7e480b3e656c85e14a832e2a968f7eb51ac9eb20) --- src/js/node/_http_server.ts | 13 +++-- test/js/node/http/node-http-connect.test.ts | 58 +++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 87b5e81adc9d..f0481bfdce29 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -518,6 +518,9 @@ function Server(options, callback): void { this[optionsSymbol] = options; storeHTTPOptions.$call(this, options); + // Plain HTTP keeps its parser socket half-open, while Node's HTTPS server + // inherits tls.Server's default and only allows half-open sockets on request. + this.allowHalfOpen = this[isTlsSymbol] ? options.allowHalfOpen === true : true; if (this[tlsSymbol]) { this.on("secureConnection", secureConnectionListener); @@ -1842,13 +1845,13 @@ function getNodeHTTPServerSocket() { declare _writableState: { emitClose: boolean; decodeStrings: boolean }; declare _readableState: { emitClose: boolean }; constructor(server: Server, handle, encrypted, listenerGeneration) { - // allowHalfOpen: node's connectionListener sockets never auto-end the - // writable side on the peer's FIN (CONNECT/Upgrade tunnels stay writable); - // net.Socket would otherwise default it to false. + // Plain HTTP parser sockets stay half-open for CONNECT/Upgrade tunnels. + // HTTPS sockets inherit tls.Server's half-open policy instead. + const allowHalfOpen = encrypted ? server.allowHalfOpen : true; super( server[kHighWaterMark] !== undefined - ? { highWaterMark: server[kHighWaterMark], allowHalfOpen: true } - : { allowHalfOpen: true }, + ? { highWaterMark: server[kHighWaterMark], allowHalfOpen } + : { allowHalfOpen }, ); // net.Socket's constructor wires net-handle machinery this class replaces: // its 'end' listener installs writeAfterFIN (breaks half-open tunnels), and diff --git a/test/js/node/http/node-http-connect.test.ts b/test/js/node/http/node-http-connect.test.ts index 283cc5011693..abf56592cf95 100644 --- a/test/js/node/http/node-http-connect.test.ts +++ b/test/js/node/http/node-http-connect.test.ts @@ -1231,6 +1231,64 @@ describe("HTTP server CONNECT", () => { }, ); + test.each([ + ["default", "false"], + ["explicit", "true"], + ])("https upgrade sockets honor the %s half-open policy", async (_label, expected) => { + const fixture = /* js */ ` + const https = require("node:https"); + const explicit = process.env.ALLOW_HALF_OPEN === "true"; + const deadline = setTimeout(() => { + console.error("upgrade socket did not close"); + process.exit(1); + }, 2000); + let request; + const server = https.createServer({ + cert: process.env.CERT, + key: process.env.KEY, + ...(explicit ? { allowHalfOpen: true } : {}), + }); + server.on("upgrade", (_request, socket) => { + console.log("allowHalfOpen:" + socket.allowHalfOpen); + socket.resume(); + socket.on("end", () => { + console.log("end:" + socket.writable + ":" + socket.writableEnded); + if (explicit) socket.destroy(); + }); + socket.on("close", () => { + console.log("socket:close"); + server.close(() => { + clearTimeout(deadline); + console.log("server:close"); + }); + }); + request.destroy(); + }); + server.listen(0, "127.0.0.1", () => { + request = https.request({ + hostname: "127.0.0.1", + port: server.address().port, + rejectUnauthorized: false, + headers: { Connection: "Upgrade", Upgrade: "websocket" }, + }); + request.on("error", () => {}); + request.end(); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { ...bunEnv, CERT: tlsCert.cert, KEY: tlsCert.key, ALLOW_HALF_OPEN: expected }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: `allowHalfOpen:${expected}\nend:true:false\nsocket:close\nserver:close\n`, + stderr: "", + exitCode: 0, + }); + }); + test.skipIf(isWindows)( "AF_UNIX CONNECT sockets whose peer closes first do not spin the loop on EPOLLHUP", async () => { From 19e9b6e9ee637738f55176020c78aa0d8481e3e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 19 Sep 2026 21:39:47 -0700 Subject: [PATCH 06/12] fix(http): include raw socket writes in byte accounting Keep response-body and raw transport byte counts together when reporting server socket bytesWritten. A retained zero response-body count must not hide writes made after a WebSocket upgrade. Extend the existing immediate-send fixture to check the byte delta on fresh and reused sockets. Source review and formatting passed; candidate runtime validation remains pending. (cherry picked from commit 097d680c241e82ceb98bfff137fcc2c646cc591d) --- src/js/node/_http_server.ts | 5 +++-- test/js/node/http/node-http-with-ws.test.ts | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index f0481bfdce29..85c4c43e47eb 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1885,8 +1885,9 @@ function getNodeHTTPServerSocket() { get bytesWritten() { const handle = this[kHandle]; + // HTTP response bodies and raw socket writes use separate counters. return handle - ? (handle.response?.getBytesWritten?.() ?? handle.bytesWritten ?? this[kBytesWritten] ?? 0) + ? (handle.response?.getBytesWritten?.() ?? 0) + (handle.bytesWritten ?? 0) : (this[kBytesWritten] ?? 0); } set bytesWritten(value) { @@ -1936,7 +1937,7 @@ function getNodeHTTPServerSocket() { } #onDrain() { const handle = this[kHandle]; - this[kBytesWritten] = handle ? (handle.response?.getBytesWritten?.() ?? handle.bytesWritten ?? 0) : 0; + this[kBytesWritten] = handle ? (handle.response?.getBytesWritten?.() ?? 0) + (handle.bytesWritten ?? 0) : 0; const callback = this.#pendingCallback; if (callback) { this.#pendingCallback = null; diff --git a/test/js/node/http/node-http-with-ws.test.ts b/test/js/node/http/node-http-with-ws.test.ts index 0e744bd9aa96..7b7c46b48cf0 100644 --- a/test/js/node/http/node-http-with-ws.test.ts +++ b/test/js/node/http/node-http-with-ws.test.ts @@ -232,6 +232,7 @@ describe.concurrent("npm ws on node:http upgrade sockets", () => { "delivers the 101 and an immediate callback write on a %s socket", async (_name, reuse, warmupRequests) => { let connection!: import("ws").WebSocket; + let bytesWrittenBeforeSend = 0; const message = Promise.withResolvers(); const fixture = await openWebSocketWithAgent({ ServerClass: NpmWebSocketServer, @@ -242,6 +243,7 @@ describe.concurrent("npm ws on node:http upgrade sockets", () => { }, onConnection(ws) { connection = ws; + bytesWrittenBeforeSend = (ws as WsWebSocket & { _socket: Socket })._socket.bytesWritten; ws.send("ready"); }, }); @@ -249,6 +251,9 @@ describe.concurrent("npm ws on node:http upgrade sockets", () => { expect(connection).toBe(fixture.accepted.ws); expect(fixture.accepted.head).toEqual(Buffer.alloc(0)); expect((await message.promise).toString()).toBe("ready"); + expect((fixture.accepted.socket as Socket).bytesWritten - bytesWrittenBeforeSend).toBeGreaterThan( + Buffer.byteLength("ready"), + ); } finally { await closeWebSocketFixture(fixture); } From 1398d15c0d846d2adac83e1ddf613e18f207751d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 16 Sep 2026 06:16:51 -0700 Subject: [PATCH 07/12] fix(node:https): match Server constructor and ALPN (cherry picked from commit 4bc81c1fd37eea72e9fd1ca33a7a171888d16f87) --- packages/bun-usockets/src/crypto/openssl.c | 55 ++++++++++++++++++ packages/bun-usockets/src/libusockets.h | 4 ++ packages/bun-uws/src/App.h | 24 +++++++- src/js/node/_http_server.ts | 2 + src/js/node/https.ts | 23 +++++--- src/runtime/server/mod.rs | 12 ++++ src/runtime/server/server_body.rs | 8 ++- src/uws_sys/App.rs | 34 +++++++++++ src/uws_sys/libuwsockets.cpp | 10 +++- test/js/node/http/node-http.test.ts | 67 +++++++++++++++++++++- 10 files changed, 223 insertions(+), 16 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 74bfc3ca76b2..47ccb466793b 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -176,6 +176,8 @@ static int us_ssl_bio_type = 0; * entry — see us_ssl_ctx_set_sni_policy. Absent on node:tls SecureContexts, * whose policy is server-level. */ static int us_ctx_sni_policy_ex_idx = -1; +/* (SSL_CTX) owned wire-format ALPN protocol list used by the server selector. */ +static int us_ctx_alpn_protocols_ex_idx = -1; /* Defined in Rust (src/uws_sys/SocketKind.rs) so the ordinal tracks the enum. */ extern const unsigned char BUN_SOCKET_KIND_BUN_SOCKET_TLS; extern const unsigned char BUN_SOCKET_KIND_UWS_HTTP_TLS; @@ -366,6 +368,17 @@ static void ssl_flush_pending_events(struct us_socket_t *s) { extern void bun_ssl_ctx_cache_on_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, int index, long argl, void *argp); +struct us_ssl_alpn_protocols_t { + unsigned int length; + unsigned char data[]; +}; + +static void us_ssl_alpn_protocols_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, + int index, long argl, void *argp) { + (void)parent; (void)ad; (void)index; (void)argl; (void)argp; + if (ptr) us_free(ptr); +} + static void us_ex_idx_init(void) { us_ctx_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, us_ctx_ex_free); us_sni_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); @@ -373,6 +386,8 @@ static void us_ex_idx_init(void) { us_ctx_user_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_use_system_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_sni_policy_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); + us_ctx_alpn_protocols_ex_idx = + SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_alpn_protocols_free); us_ssl_rare_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_rare_free); us_ssl_wrapper_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_bio_type = BIO_get_new_index() | BIO_TYPE_SOURCE_SINK; @@ -1634,6 +1649,46 @@ static int us_alpn_select_h2(SSL *ssl, const unsigned char **out, unsigned char return allow_http1 ? SSL_TLSEXT_ERR_NOACK : SSL_TLSEXT_ERR_ALERT_FATAL; } +static int us_alpn_select_protocols(SSL *ssl, const unsigned char **out, + unsigned char *outlen, const unsigned char *in, + unsigned int inlen, void *arg) { + (void)ssl; + struct us_ssl_alpn_protocols_t *protocols = arg; + if (!protocols) return SSL_TLSEXT_ERR_NOACK; + return SSL_select_next_proto((unsigned char **)out, outlen, protocols->data, + protocols->length, in, inlen) == OPENSSL_NPN_NEGOTIATED + ? SSL_TLSEXT_ERR_OK + : SSL_TLSEXT_ERR_ALERT_FATAL; +} + +int us_ssl_ctx_set_alpn_protocols(SSL_CTX *ctx, const unsigned char *protocols, + unsigned int protocols_len) { + if (!ctx || !protocols || !protocols_len) return 0; + for (unsigned int offset = 0; offset < protocols_len;) { + unsigned int length = protocols[offset]; + if (!length || length > protocols_len - offset - 1) return 0; + offset += length + 1; + } + + us_ex_idx_ensure(); + if (us_ctx_alpn_protocols_ex_idx < 0) return 0; + struct us_ssl_alpn_protocols_t *owned = + us_malloc(sizeof(struct us_ssl_alpn_protocols_t) + protocols_len); + if (!owned) return 0; + owned->length = protocols_len; + memcpy(owned->data, protocols, protocols_len); + + struct us_ssl_alpn_protocols_t *previous = + SSL_CTX_get_ex_data(ctx, us_ctx_alpn_protocols_ex_idx); + if (!SSL_CTX_set_ex_data(ctx, us_ctx_alpn_protocols_ex_idx, owned)) { + us_free(owned); + return 0; + } + SSL_CTX_set_alpn_select_cb(ctx, us_alpn_select_protocols, owned); + if (previous) us_free(previous); + return 1; +} + void us_ssl_ctx_enable_http2_alpn(SSL_CTX *ctx, int allow_http1) { SSL_CTX_set_alpn_select_cb(ctx, us_alpn_select_h2, allow_http1 ? (void *) 1 : NULL); } diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index b102f100b297..245c1431557d 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -565,6 +565,10 @@ void us_internal_ssl_ctx_unref(struct ssl_ctx_st *ssl_ctx); /* Install an ALPN selector that prefers "h2", then "http/1.1" (when * allow_http1). Used by uWS when an App has an HTTP/2 context attached. */ void us_ssl_ctx_enable_http2_alpn(struct ssl_ctx_st *ssl_ctx, int allow_http1); +/* Install a server ALPN selector from the TLS wire-format protocol list. The + * SSL_CTX owns its copy until its final reference is released. */ +int us_ssl_ctx_set_alpn_protocols(struct ssl_ctx_st *ssl_ctx, + const unsigned char *protocols, unsigned int protocols_len); /* 1 iff the completed handshake on `s` negotiated ALPN "h2". */ int us_socket_alpn_is_h2(us_socket_r s); long us_ssl_ctx_live_count(void); diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 424279f2673e..b3de120ac859 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -116,6 +116,7 @@ struct TemplatedApp { HttpRouter::RouterData> *router; }; std::vector pendingServerNames; + std::vector alpnProtocols; /* No raw us_listen_socket_t* cache here. src/runtime/server/mod.rs's non-abrupt stop calls * us_listen_socket_close(ls) directly; the listener is queued for free in * loop_post, so any vector we kept would dangle by the time the deferred @@ -154,6 +155,11 @@ struct TemplatedApp { if (applyClientCertPolicy) { us_ssl_ctx_set_sni_policy(domainCtx, options.request_cert, options.reject_unauthorized); } + if (!alpnProtocols.empty() && !us_ssl_ctx_set_alpn_protocols(domainCtx, alpnProtocols.data(), static_cast(alpnProtocols.size()))) { + us_internal_ssl_ctx_unref(domainCtx); + if (success) *success = false; + return std::move(*this); + } if (httpContext->getSocketContextData()->http2Context) { us_ssl_ctx_enable_http2_alpn(domainCtx, httpContext->getSocketContextData()->allowHttp1); } @@ -218,7 +224,19 @@ struct TemplatedApp { return sslCtx; } - bool setSecureContext(SocketContextOptions options, const char *const *additionalCa, unsigned int additionalCaCount) { + bool setALPNProtocols(const unsigned char *protocols, unsigned int protocolsLength) { + if constexpr (!SSL) { + return false; + } else { + if (!us_ssl_ctx_set_alpn_protocols(sslCtx, protocols, protocolsLength)) { + return false; + } + alpnProtocols.assign(protocols, protocols + protocolsLength); + return true; + } + } + + bool setSecureContext(SocketContextOptions options, const char *const *additionalCa, unsigned int additionalCaCount, const unsigned char *alpnProtocols, unsigned int alpnProtocolsLength) { if constexpr (!SSL) { return false; } else { @@ -232,6 +250,10 @@ struct TemplatedApp { return false; } } + if (alpnProtocolsLength && !us_ssl_ctx_set_alpn_protocols(next, alpnProtocols, alpnProtocolsLength)) { + us_internal_ssl_ctx_unref(next); + return false; + } if (httpContext->getSocketContextData()->http2Context) { us_ssl_ctx_enable_http2_alpn(next, httpContext->getSocketContextData()->allowHttp1); } diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 85c4c43e47eb..47e3d468b67a 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -503,6 +503,7 @@ function Server(options, callback): void { minVersion, maxVersion, ciphers: typeof options.ciphers === "string" && options.ciphers ? options.ciphers : undefined, + ALPNProtocols: options.ALPNProtocols, requestCert: options.requestCert, rejectUnauthorized: options.rejectUnauthorized, _pfxExtraCACerts: pfxExtraCAs, @@ -738,6 +739,7 @@ Server.prototype[setSecureContextSymbol] = function (options) { serverName: tlsOptions.servername, requestCert: current.requestCert, rejectUnauthorized: current.rejectUnauthorized, + ALPNProtocols: this.ALPNProtocols, }; this[serverSymbol]?._setNodeHTTPSSecureContext(next, getAdditionalCAOptions(next)); this[tlsSymbol] = normalizeServerTls(next); diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 765297c87932..5f3c3cebb5ff 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -521,7 +521,9 @@ const { shouldUseEnvProxy } = require("node:_http_agent"); // normalized protocol list / callback on the server instance the way // tls.Server does (test-https-argument-of-creating.js). // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L82-L97 -function createServer(options, requestListener) { +function Server(options, requestListener) { + if (!(this instanceof Server)) return new Server(options, requestListener); + if (typeof options === "function") { requestListener = options; options = {}; @@ -536,14 +538,21 @@ function createServer(options, requestListener) { // ALPN requests are always answered with http/1.1. options.ALPNProtocols = ["http/1.1"]; } - const server = http.createServer(options, requestListener); const optionsALPNProtocols = options.ALPNProtocols; if (optionsALPNProtocols) { - require("node:tls").convertALPNProtocols(optionsALPNProtocols, server); + require("node:tls").convertALPNProtocols(optionsALPNProtocols, options); } - server.ALPNCallback = options.ALPNCallback; - server.setSecureContext = server[setSecureContextSymbol]; - return server; + http.Server.$call(this, options, requestListener); + this.ALPNProtocols = options.ALPNProtocols; + this.ALPNCallback = options.ALPNCallback; +} +$toClass(Server, "Server", http.Server); +Server.prototype.setSecureContext = function setSecureContext(options) { + return this[setSecureContextSymbol](options); +}; + +function createServer(options, requestListener) { + return new Server(options, requestListener); } var https = { @@ -554,7 +563,7 @@ var https = { timeout: 5000, proxyEnv: shouldUseEnvProxy() ? process.env : undefined, }), - Server: http.Server, + Server, createServer, get, request, diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 112a2323f3ee..733b7abdf986 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -2927,6 +2927,18 @@ impl NewServer { // SAFETY: `this` is the live boxed server from `init()`; no other borrow is live. unsafe { (*this).app = Some(app) }; + if let Some(protocols) = this_ref + .config + .ssl_config + .as_ref() + .and_then(|config| config.protos_bytes()) + && !bun_opaque::opaque_deref_mut(app).set_alpn_protocols(protocols) + { + let _ = global.throw(format_args!("Failed to configure TLS ALPN protocols")); + Self::deinit(this); + return JSValue::ZERO; + } + if Self::HAS_H3 && this_ref.config.http3 { let idle_timeout = this_ref.config.idle_timeout as u32; let h3 = match uws_sys::h3::App::create(&ssl_options, idle_timeout) { diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 85210069c8c3..6fbf1280bb10 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -1539,9 +1539,11 @@ where ))); }; // SAFETY: app is the live SSL NewApp owned by this running server. - if !bun_opaque::opaque_deref_mut(app) - .set_secure_context(&native_options, &additional_ca_options) - { + if !bun_opaque::opaque_deref_mut(app).set_secure_context( + &native_options, + &additional_ca_options, + config.protos_bytes(), + ) { return Err( global.throw_invalid_arguments(format_args!("Failed to set the TLS context",)) ); diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 8d6c19bb187b..577eb5cb9f04 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -158,7 +158,17 @@ impl App { &mut self, opts: &BunSocketContextOptions, additional_ca: &BunSocketContextOptions, + alpn_protocols: Option<&[u8]>, ) -> bool { + let (alpn_protocols_ptr, alpn_protocols_len) = match alpn_protocols { + Some(value) => { + let Ok(length) = c_uint::try_from(value.len()) else { + return false; + }; + (value.as_ptr(), length) + } + None => (core::ptr::null(), 0), + }; // SAFETY: self is a live app and the CA pointers remain valid for the duration of the call. unsafe { c::uws_app_set_secure_context( @@ -167,6 +177,22 @@ impl App { *opts, additional_ca.ca, additional_ca.ca_count, + alpn_protocols_ptr, + alpn_protocols_len, + ) != 0 + } + } + + pub fn set_alpn_protocols(&mut self, protocols: &[u8]) -> bool { + let Ok(protocols_len) = c_uint::try_from(protocols.len()) else { + return false; + }; + unsafe { + c::uws_app_set_alpn_protocols( + Self::SSL_FLAG, + self.as_raw(), + protocols.as_ptr(), + protocols_len, ) != 0 } } @@ -687,6 +713,14 @@ pub mod c { options: BunSocketContextOptions, additional_ca: *const *const c_char, additional_ca_count: c_uint, + alpn_protocols: *const u8, + alpn_protocols_len: c_uint, + ) -> c_int; + pub(crate) fn uws_app_set_alpn_protocols( + ssl_flag: c_int, + app: &mut uws_app_t, + protocols: *const u8, + protocols_len: c_uint, ) -> c_int; } diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 26e08809c4a3..0bb35440b877 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -62,12 +62,18 @@ extern "C" } } - int uws_app_set_secure_context(int ssl, uws_app_t *app, struct us_bun_socket_context_options_t options, const char *const *additional_ca, unsigned int additional_ca_count) + int uws_app_set_secure_context(int ssl, uws_app_t *app, struct us_bun_socket_context_options_t options, const char *const *additional_ca, unsigned int additional_ca_count, const unsigned char *alpn_protocols, unsigned int alpn_protocols_len) { if (!ssl) return 0; uWS::SocketContextOptions socket_context_options; memcpy(&socket_context_options, &options, sizeof(uWS::SocketContextOptions)); - return ((uWS::SSLApp *)app)->setSecureContext(socket_context_options, additional_ca, additional_ca_count); + return ((uWS::SSLApp *)app)->setSecureContext(socket_context_options, additional_ca, additional_ca_count, alpn_protocols, alpn_protocols_len); + } + + int uws_app_set_alpn_protocols(int ssl, uws_app_t *app, const unsigned char *protocols, unsigned int protocols_len) + { + if (!ssl) return 0; + return ((uWS::SSLApp *)app)->setALPNProtocols(protocols, protocols_len); } void uws_app_get(int ssl, uws_app_t *app, const char *pattern_ptr, size_t pattern_len, uws_method_handler handler, void *user_data) diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 95de13bbe66b..e47fcbb5297e 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1617,6 +1617,16 @@ describe("node https server", async () => { }); }; + it("constructs HTTPS servers through both public entry points", () => { + for (const server of [new https.Server(httpsOptions), createHttpsServer(httpsOptions)]) { + expect(server).toBeInstanceOf(https.Server); + expect(server).toBeInstanceOf(http.Server); + expect(server.constructor).toBe(https.Server); + expect(typeof server.setSecureContext).toBe("function"); + expect(() => server.setSecureContext(httpsOptions)).not.toThrow(); + } + }); + it("setSecureContext updates future handshakes without closing existing connections", async () => { const replacement = { key: nodefs.readFileSync(path.join(import.meta.dir, "../tls/fixtures/agent1-key.pem")), @@ -1625,10 +1635,11 @@ describe("node https server", async () => { }; const server = createHttpsServer(httpsOptions, (_req, res) => res.end("ok")); const url = await listen(server, "https"); - const connect = async () => { + const connect = async (servername?: string) => { const socket = tlsConnect({ host: "127.0.0.1", port: Number(url.port), + servername, rejectUnauthorized: false, }); await once(socket, "secureConnect"); @@ -1641,7 +1652,7 @@ describe("node https server", async () => { const originalFingerprint = existing.getPeerCertificate().fingerprint256; server.setSecureContext(replacement); - renewed = await connect(); + renewed = await connect("localhost"); const replacementFingerprint = renewed.getPeerCertificate().fingerprint256; expect(replacementFingerprint).not.toBe(originalFingerprint); @@ -1653,7 +1664,7 @@ describe("node https server", async () => { for (let iteration = 0; iteration < 8; iteration++) { const useReplacement = iteration % 2 === 0; server.setSecureContext(useReplacement ? replacement : httpsOptions); - const probe = await connect(); + const probe = await connect(iteration % 2 === 0 ? undefined : "localhost"); try { expect(probe.getPeerCertificate().fingerprint256).toBe( useReplacement ? replacementFingerprint : originalFingerprint, @@ -1670,6 +1681,56 @@ describe("node https server", async () => { } }); + it("preserves static ALPN protocols after setSecureContext", async () => { + const replacement = { + key: nodefs.readFileSync(path.join(import.meta.dir, "../tls/fixtures/agent1-key.pem")), + cert: nodefs.readFileSync(path.join(import.meta.dir, "../tls/fixtures/agent1-cert.pem")), + }; + const protocol = "openclaw-test"; + const server = createHttpsServer({ ...httpsOptions, ALPNProtocols: [protocol] }); + const url = await listen(server, "https"); + const connect = async () => { + const socket = tlsConnect({ + host: "127.0.0.1", + port: Number(url.port), + servername: "localhost", + ALPNProtocols: [protocol], + rejectUnauthorized: false, + }); + await once(socket, "secureConnect"); + return socket; + }; + let socket; + let rejected; + try { + rejected = tlsConnect({ + host: "127.0.0.1", + port: Number(url.port), + servername: "localhost", + ALPNProtocols: ["unsupported"], + rejectUnauthorized: false, + }); + const rejectedOutcome = await new Promise(resolve => { + rejected.once("secureConnect", () => resolve("connected")); + rejected.once("error", error => resolve(error.code)); + }); + expect(rejectedOutcome).toBe("ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL"); + rejected.destroy(); + + socket = await connect(); + expect(socket.alpnProtocol).toBe(protocol); + socket.destroy(); + + server.setSecureContext(replacement); + socket = await connect(); + expect(socket.alpnProtocol).toBe(protocol); + } finally { + rejected?.destroy(); + socket?.destroy(); + server.close(); + } + }); + it("setSecureContext updates a host-based listen while DNS lookup is pending", async () => { const replacement = { key: nodefs.readFileSync(path.join(import.meta.dir, "../tls/fixtures/agent1-key.pem")), From 46dbcd1bca17450d92b820d4c757d9a2cdb70e1e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 16 Sep 2026 06:19:21 -0700 Subject: [PATCH 08/12] test(node:https): nest PFX runtime matrix (cherry picked from commit eafacd15088ba1f21385d3926ddcd679aaec1086) --- test/js/node/http/node-http.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index e47fcbb5297e..2987286397a7 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1794,9 +1794,8 @@ describe("node https server", async () => { const systemNode = nodeExe(); const pfxDefaultCARuntimes: Array<[string, string]> = [["Bun", bunExe()]]; if (systemNode) pfxDefaultCARuntimes.push(["Node", systemNode]); - it.each(pfxDefaultCARuntimes)( - "PFX CAs remain additive to default CAs across HTTPS server lifecycles in %s", - async (runtime, executable) => { + describe.each(pfxDefaultCARuntimes)("PFX CA handling across HTTPS server lifecycles in %s", (runtime, executable) => { + it("keeps embedded CAs additive to default CAs", async () => { const fixtures = path.join(import.meta.dir, "../test/fixtures/keys"); await using proc = Bun.spawn({ cmd: [executable, path.join(import.meta.dir, "node-http-set-secure-context-pfx.node.mjs")], @@ -1822,8 +1821,8 @@ describe("node https server", async () => { exitCode: 0, failureDetail: "", }); - }, - ); + }); + }); it("is marked encrypted (#5867)", async () => { const { server, url, done } = await createServer(async (req, res) => { expect(req.connection.encrypted).toBe(true); From cdcf03dedef96832a0cd9fb4a877a7bd5887a96c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 16 Sep 2026 06:39:26 -0700 Subject: [PATCH 09/12] chore(rust): document ALPN FFI safety (cherry picked from commit 05f2611fa020a21871a0e38338433c19db1142a5) --- CHANGELOG.md | 3 +++ src/uws_sys/App.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cee6c1660dbb..8989f47a00ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Preserve custom Headers iterators and copy server.fetch header ownership. Thanks @robobun! +- Match Undici timeout metadata and WebSocket heartbeat APIs, HTTP listen errors and byte counters, and HTTPS constructor, half-open, and ALPN behavior. + - Preserve Node-alias stdin, eval arguments, version queries, and lexical entry paths; close transferred MessagePorts when worker startup fails. - Honor dynamic plugin targets and import kinds, preserve literal POSIX paths and ESM fragments, and match Node loading for untyped dependencies and inline TypeScript type clauses. - Report referenced Timeout and Immediate resources from `process.getActiveResourcesInfo()`, isolated per worker. diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 577eb5cb9f04..b0dce67fb050 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -187,6 +187,7 @@ impl App { let Ok(protocols_len) = c_uint::try_from(protocols.len()) else { return false; }; + // SAFETY: self is a live app, and C++ copies protocols into SSL_CTX-owned storage. unsafe { c::uws_app_set_alpn_protocols( Self::SSL_FLAG, From b3171032edc02300b964c8fedc5ec2aac928d23d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 29 Sep 2026 22:58:52 -0700 Subject: [PATCH 10/12] style(http): format listen error forwarding --- src/js/node/_http_server.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 47e3d468b67a..5e8de9f98765 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -852,11 +852,7 @@ Server.prototype.listen = function () { serverNameHost, ); } catch (err) { - process.nextTick( - emitListenErrorNextTick, - server, - formatListenError(err, port, address, socketPath), - ); + process.nextTick(emitListenErrorNextTick, server, formatListenError(err, port, address, socketPath)); } }); return this; From 45d6b54e689995e5fa050718cbf3763e7325f4fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 29 Sep 2026 23:43:22 -0700 Subject: [PATCH 11/12] test(tls): wait for server handshake completion before closing --- test/js/node/tls/node-tls-server.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index e9e6c3c22d9c..f7a0687904bd 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -669,7 +669,7 @@ it("createServer registers the callback as a regular 'secureConnection' listener it("connectionListener should emit the right amount of times, and with alpnProtocol available", async () => { let count = 0; const promises = []; - const server: Server = createServer( + await using server: Server = createServer( { ...COMMON_CERT, ALPNProtocols: ["bun"], @@ -697,8 +697,8 @@ it("connectionListener should emit the right amount of times, and with alpnProto }, () => { socket.on("close", resolve); + // The client handshake can finish before the server dispatches secureConnection. socket.resume(); - socket.end(); }, ); } From 339edb7739b4aee57815db1f2078c20a5415a866 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 00:04:19 -0700 Subject: [PATCH 12/12] fix(http): declare server types for builtin module checks --- src/js/node/_http_server.ts | 1 + src/js/node/https.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 5e8de9f98765..dfd63a020138 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -395,6 +395,7 @@ interface Server extends NodeHTTPServer { httpValidation?: "strict" | "relaxed" | "insecure"; requireHostHeader: boolean; httpAllowHalfOpen: boolean; + allowHalfOpen: boolean; } function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 5f3c3cebb5ff..325cbc0f0875 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -521,7 +521,7 @@ const { shouldUseEnvProxy } = require("node:_http_agent"); // normalized protocol list / callback on the server instance the way // tls.Server does (test-https-argument-of-creating.js). // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L82-L97 -function Server(options, requestListener) { +function Server(options, requestListener): void { if (!(this instanceof Server)) return new Server(options, requestListener); if (typeof options === "function") {