Repository: denoland/deno
Pull request: feat(unstable): WebSocket headers field

This changes the second argument in the WebSocket constructor to be able to take an object, which can contain a headers field with which the headers for the connection can be set.

Changed files:
filename: cli/tsc/dts/lib.deno_websocket.d.ts
patch: @@ -267,20 +267,48 @@ interface WebSocket extends EventTarget {
  * // Using URL object instead of string
  * const url = new URL("ws://localhost:8080/path");
  * const wsWithUrl = new WebSocket(url);
+ *
+ * // WebSocket with headers
+ * const wsWithProtocols = new WebSocket("ws://localhost:8080", {
+ *   headers: {
+ *     "Authorization": "Bearer foo",
+ *   },
+ * });
  * ```
  *
  * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket
  * @category WebSockets
  */
 declare var WebSocket: {
   readonly prototype: WebSocket;
-  new (url: string | URL, protocols?: string | string[]): WebSocket;
+  new (
+    url: string | URL,
+    protocolsOrOptions?: string | string[] | WebSocketOptions,
+  ): WebSocket;
   readonly CLOSED: number;
   readonly CLOSING: number;
   readonly CONNECTING: number;
   readonly OPEN: number;
 };
 
+/**
+ * Options for a WebSocket instance.
+ * This feature is non-standard.
+ *
+ * @category WebSockets
+ */
+interface WebSocketOptions {
+  /**
+   * The sub-protocol(s) that the client would like to use, in order of preference.
+   */
+  protocols?: string | string[];
+  /**
+   * A Headers object, an object literal, or an array of two-item arrays to set handshake's headers.
+   * This feature is non-standard.
+   */
+  headers?: HeadersInit;
+}
+
 /**
  * Specifies the type of binary data being received over a `WebSocket` connection.
  *

filename: ext/fetch/27_eventsource.js
patch: @@ -6,8 +6,6 @@ import { primordials } from "ext:core/mod.js";
 import { op_utf8_to_byte_string } from "ext:core/ops";
 const {
   ArrayPrototypeFind,
-  ArrayPrototypeSlice,
-  ArrayPrototypeSplice,
   Number,
   NumberIsFinite,
   NumberIsNaN,
@@ -200,16 +198,7 @@ class EventSource extends EventTarget {
     );
 
     if (this.#headers) {
-      const headerList = headerListFromHeaders(initialHeaders);
-      const headers = this.#headers ?? ArrayPrototypeSlice(
-        headerList,
-        0,
-        headerList.length,
-      );
-      if (headerList.length !== 0) {
-        ArrayPrototypeSplice(headerList, 0, headerList.length);
-      }
-      fillHeaders(initialHeaders, headers);
+      fillHeaders(initialHeaders, this.#headers);
     }
 
     const req = newInnerRequest(

filename: ext/websocket/01_websocket.js
patch: @@ -23,6 +23,7 @@ import {
 } from "ext:core/ops";
 const {
   ArrayBufferIsView,
+  ArrayIsArray,
   ArrayPrototypeJoin,
   ArrayPrototypeMap,
   ArrayPrototypePush,
@@ -64,18 +65,41 @@ import {
 } from "ext:deno_web/02_event.js";
 import { Blob, BlobPrototype } from "ext:deno_web/09_file.js";
 import { getLocationHref } from "ext:deno_web/12_location.js";
-
-webidl.converters["sequence<DOMString> or DOMString"] = (
+import {
+  fillHeaders,
+  headerListFromHeaders,
+  headersFromHeaderList,
+} from "ext:deno_fetch/20_headers.js";
+
+webidl.converters["WebSocketInit"] = webidl.createDictionaryConverter(
+  "WebSocketInit",
+  [
+    {
+      key: "headers",
+      converter: webidl.converters["HeadersInit"],
+    },
+    {
+      key: "protocols",
+      converter: webidl.converters["sequence<DOMString>"],
+    },
+  ],
+);
+
+webidl.converters["WebSocketInit or sequence<DOMString> or DOMString"] = (
   V,
   prefix,
   context,
   opts,
 ) => {
-  // Union for (sequence<DOMString> or DOMString)
+  // Union for (WebSocketInit or sequence<DOMString> or DOMString)
+  if (V === null || V === undefined) {
+    return webidl.converters["WebSocketInit"](V, prefix, context, opts);
+  }
   if (webidl.type(V) === "Object" && V !== null) {
     if (V[SymbolIterator] !== undefined) {
       return webidl.converters["sequence<DOMString>"](V, prefix, context, opts);
     }
+    return webidl.converters["WebSocketInit"](V, prefix, context, opts);
   }
   return webidl.converters.DOMString(V, prefix, context, opts);
 };
@@ -124,7 +148,7 @@ const _idleTimeoutTimeout = Symbol("[[idleTimeoutTimeout]]");
 const _serverHandleIdleTimeout = Symbol("[[serverHandleIdleTimeout]]");
 
 class WebSocket extends EventTarget {
-  constructor(url, protocols = []) {
+  constructor(url, initOrProtocols) {
     super();
     this[webidl.brand] = webidl.brand;
     this[_rid] = undefined;
@@ -142,11 +166,12 @@ class WebSocket extends EventTarget {
     const prefix = "Failed to construct 'WebSocket'";
     webidl.requiredArguments(arguments.length, 1, prefix);
     url = webidl.converters.USVString(url, prefix, "Argument 1");
-    protocols = webidl.converters["sequence<DOMString> or DOMString"](
-      protocols,
-      prefix,
-      "Argument 2",
-    );
+    initOrProtocols = webidl.converters
+      ["WebSocketInit or sequence<DOMString> or DOMString"](
+        initOrProtocols,
+        prefix,
+        "Argument 2",
+      );
 
     let wsURL;
 
@@ -179,8 +204,20 @@ class WebSocket extends EventTarget {
     this[_url] = wsURL.href;
     this[_role] = CLIENT;
 
-    if (typeof protocols === "string") {
-      protocols = [protocols];
+    let protocols;
+    let headers = null;
+
+    if (typeof initOrProtocols === "string") {
+      protocols = [initOrProtocols];
+    } else if (ArrayIsArray(initOrProtocols)) {
+      protocols = initOrProtocols;
+    } else {
+      protocols = initOrProtocols.protocols || [];
+
+      if (initOrProtocols.headers !== undefined) {
+        headers = headersFromHeaderList([], "request");
+        fillHeaders(headers, initOrProtocols.headers);
+      }
     }
 
     if (
@@ -224,6 +261,7 @@ class WebSocket extends EventTarget {
         wsURL.href,
         ArrayPrototypeJoin(protocols, ", "),
         cancelRid,
+        headers ? headerListFromHeaders(headers) : null,
       ),
       (create) => {
         this[_rid] = create.rid;

filename: tests/unit/websocket_test.ts
patch: @@ -1,4 +1,7 @@
 // Copyright 2018-2025 the Deno authors. MIT license.
+
+// deno-lint-ignore-file no-console
+
 import {
   assert,
   assertEquals,
@@ -867,3 +870,68 @@ Deno.test("websocket close ongoing handshake", async () => {
     assert(gotError2);
   }
 });
+
+function createOnErrorCb(ac: AbortController): (err: unknown) => Response {
+  return (err) => {
+    console.error(err);
+    ac.abort();
+    return new Response("Internal server error", { status: 500 });
+  };
+}
+
+function onListen(
+  resolve: (value: void | PromiseLike<void>) => void,
+): ({ hostname, port }: { hostname: string; port: number }) => void {
+  return () => {
+    resolve();
+  };
+}
+
+Deno.test("WebSocket headers", async () => {
+  const ac = new AbortController();
+  const listeningDeferred = Promise.withResolvers<void>();
+  const doneDeferred = Promise.withResolvers<void>();
+  await using server = Deno.serve({
+    handler: (request) => {
+      assertEquals(request.headers.get("Authorization"), "Bearer foo");
+      const {
+        response,
+        socket,
+      } = Deno.upgradeWebSocket(request);
+      socket.onerror = (e) => {
+        console.error(e);
+        fail();
+      };
+      socket.onmessage = (m) => {
+        socket.send(m.data);
+        socket.close(1001);
+      };
+      socket.onclose = () => doneDeferred.resolve();
+      return response;
+    },
+    port: servePort,
+    signal: ac.signal,
+    onListen: onListen(listeningDeferred.resolve),
+    onError: createOnErrorCb(ac),
+  });
+
+  await listeningDeferred.promise;
+  const def = Promise.withResolvers<void>();
+  const ws = new WebSocket(`ws://localhost:${servePort}`, {
+    headers: {
+      "Authorization": "Bearer foo",
+    },
+  });
+  ws.onmessage = (m) => assertEquals(m.data, "foo");
+  ws.onerror = (e) => {
+    console.error(e);
+    fail();
+  };
+  ws.onclose = () => def.resolve();
+  ws.onopen = () => ws.send("foo");
+
+  await def.promise;
+  await doneDeferred.promise;
+  ac.abort();
+  await server.finished;
+});

filename: tests/wpt/runner/expectation.json
patch: @@ -14554,6 +14554,7 @@
     "eventhandlers.any.worker.html?wpt_flags=h2": true,
     "eventhandlers.any.worker.html?wss": true,
     "idlharness.any.html": [
+      "WebSocket interface object length",
       "WebSocket interface: constant CONNECTING on interface object",
       "WebSocket interface: constant CONNECTING on interface prototype object",
       "WebSocket interface: constant OPEN on interface object",
@@ -14572,6 +14573,7 @@
       "Stringification of new CloseEvent(\"close\")"
     ],
     "idlharness.any.worker.html": [
+      "WebSocket interface object length",
       "WebSocket interface: constant CONNECTING on interface object",
       "WebSocket interface: constant CONNECTING on interface prototype object",
       "WebSocket interface: constant OPEN on interface object",