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/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 fadde5ed7942..dfd63a020138 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 @@ -370,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); @@ -478,6 +504,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, @@ -493,6 +520,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); @@ -710,6 +740,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); @@ -822,7 +853,7 @@ 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 +862,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; @@ -1813,13 +1844,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 @@ -1853,8 +1884,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) { @@ -1904,7 +1936,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/src/js/node/https.ts b/src/js/node/https.ts index 765297c87932..325cbc0f0875 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): void { + 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/js/thirdparty/undici.js b/src/js/thirdparty/undici.js index cdd08e8f239c..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]; @@ -307,14 +309,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 {} @@ -364,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; @@ -474,6 +549,7 @@ const moduleExports = { mockErrors, MockPool, parseMIMEType, + ping, pipeline, Pool, ProxyAgent, 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/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 7cb46afbadfe..6fbf1280bb10 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 @@ -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",)) ); @@ -1766,17 +1768,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 +1983,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 +2358,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/src/uws_sys/App.rs b/src/uws_sys/App.rs index 8d6c19bb187b..b0dce67fb050 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,23 @@ 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; + }; + // 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, + self.as_raw(), + protocols.as_ptr(), + protocols_len, ) != 0 } } @@ -687,6 +714,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/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/first_party/undici/undici.test.ts b/test/js/first_party/undici/undici.test.ts index 59087d28e7ab..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 { request, fetch as undiciFetch } from "undici"; +import { errors, ping, request, fetch as undiciFetch, WebSocket as UndiciWebSocket } from "undici"; import { createServer } from "../../../http-test-server"; @@ -344,6 +345,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[] = []; @@ -394,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(); + } + }); +}); 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 () => { 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); } diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index b1fb62bf55ee..2987286397a7 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 () => { @@ -1595,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")), @@ -1603,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"); @@ -1619,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); @@ -1631,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, @@ -1648,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")), @@ -1711,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")], @@ -1739,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); 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(); }, ); } 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");