From 015966bd2351c70f06aba56190fc19121667c824 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:21:43 +0000 Subject: [PATCH 1/3] feat(client): speak WebSocket as well as Server-Sent Events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime built its own EventSource inline, so the server's new `hot.transport: "ws"` had nothing to talk to. The transport is a class behind `onOpen`/`onClose`/`onMessage`/`close` now, chosen by a `transport` query parameter, and a client injected as `__webpack_dev_server_client__` wins over both — the shape webpack-dev-server's `client.webSocketTransport` already has, so one written for it works here unchanged. Reconnecting moves out of the transport into one loop the two share, which keeps what each of them did: Server-Sent Events retry at a steady interval for as long as the page is open, a WebSocket backs off and gives up after `reconnect` attempts. --- .changeset/client-transports.md | 5 + README.md | 59 +++++++- client-src/clients/EventSourceClient.js | 109 ++++++++++++++ client-src/clients/WebSocketClient.js | 57 +++++++ client-src/clients/createSocket.js | 108 ++++++++++++++ client-src/globals.d.ts | 16 ++ client-src/index.js | 168 +++++++++------------ package.json | 2 + test/client-socket.test.js | 188 ++++++++++++++++++++++++ 9 files changed, 610 insertions(+), 102 deletions(-) create mode 100644 .changeset/client-transports.md create mode 100644 client-src/clients/EventSourceClient.js create mode 100644 client-src/clients/WebSocketClient.js create mode 100644 client-src/clients/createSocket.js create mode 100644 test/client-socket.test.js diff --git a/.changeset/client-transports.md b/.changeset/client-transports.md new file mode 100644 index 000000000..b35b662ae --- /dev/null +++ b/.changeset/client-transports.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": minor +--- + +Carry the browser runtime's events over a WebSocket with the client `transport=ws` option, or over a transport of your own, and reuse the built-in two from `webpack-dev-middleware/client/sse` and `webpack-dev-middleware/client/ws` diff --git a/README.md b/README.md index a8d5eb281..9d205fa80 100644 --- a/README.md +++ b/README.md @@ -475,7 +475,8 @@ entry: [ ``` The runtime ships as ES5 and uses no built-in newer than ES5, apart from -`EventSource` and `Promise` (which HMR itself needs), so it runs in old +`Promise`, the transport in use (`EventSource` or `WebSocket`) and what HMR +itself needs, so it runs in old browsers too — set [`target`](https://webpack.js.org/configuration/target/) to `["web", "es5"]` in your configuration so webpack emits its own runtime as ES5 as well. @@ -484,8 +485,10 @@ as well. | Name | Type | Default | Description | | :-----------------: | :---------------: | :--------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `path` | `string` | `/__webpack_hmr` | Path the SSE endpoint is served at. Must match the server `hot.path`. | -| `timeout` | `number` | `20000` | Reconnection / heartbeat watchdog timeout in milliseconds. | +| `transport` | `string` | `"sse"` | How the events are carried: `"sse"` or `"ws"`. Must match the server [`hot.transport`](#hottransport). | +| `path` | `string` | `/__webpack_hmr` | Path the endpoint is served at. Must match the server `hot.path`. | +| `timeout` | `number` | `20000` | Heartbeat watchdog timeout in milliseconds, and the interval between reconnections under `"sse"`. | +| `reconnect` | `number` | `10` | How many times `"ws"` reconnects before giving up. `"sse"` retries for as long as the page is open and ignores this. | | `overlay` | `boolean\|Object` | `true` | In-page overlay for problems: a boolean, or a JSON object — see [overlay options](#client-overlay-options). Same value shape as webpack-dev-server's [`client.overlay`](https://webpack.js.org/configuration/dev-server/#overlay), plus a few webpack-dev-middleware extensions. | | `reload` | `boolean` | `true` | Fall back to a full page reload when an update cannot be applied through HMR (e.g. recovering from a broken build). Enabled by default, unlike webpack-hot-middleware; set to `false` to keep HMR-only. | | `logging` | `string` | `"info"` | Logger level — one of `"none"`, `"error"`, `"warn"`, `"info"`, `"log"`, `"verbose"`. Uses webpack's runtime logger. | @@ -494,6 +497,56 @@ as well. | `progress` | `boolean` | `true` | Show a small badge in the page while a rebuild is in progress (with the compilation percentage when the server enables `hot.progress`). Set to `false` to disable. | | `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. The leading slash of `path` is stripped and no other normalization is applied, so the public path should end with `/`. | +#### A client of your own + +The transport the page speaks can be replaced. A client is a class constructed +with the url, the same shape webpack-dev-server's +[`client.webSocketTransport`](https://webpack.js.org/configuration/dev-server/#websockettransport) +has always taken, so one written for that works here unchanged: + +```js +// my-client.js +module.exports = class MyClient { + constructor(url) { + this.socket = new WebSocket(url); + } + + onOpen(fn) { + this.socket.onopen = fn; + } + + onClose(fn) { + this.socket.onclose = fn; + } + + onMessage(fn) { + // Called with the message as a string. + this.socket.onmessage = (event) => fn(event.data); + } + + close() { + // Close without reporting it, so the runtime does not reconnect. + this.socket.onclose = null; + this.socket.close(); + } +}; +``` + +Reconnecting and the backoff between attempts are the runtime's job, not the +client's — it only has to report `onOpen` and `onClose` honestly. Extend one of +the built-in two rather than starting over if you only want to change part of +it: + +```js +const EventSourceClient = require("webpack-dev-middleware/client/sse"); +const WebSocketClient = require("webpack-dev-middleware/client/ws"); +``` + +The runtime picks it up from `__webpack_dev_server_client__`, which +webpack-dev-server sets from its `client.webSocketTransport` option; a module +exporting the class as `default` is unwrapped. An injected client wins over +both built-ins, whatever `transport` says. + #### Client `overlay` options Passed as a JSON object, e.g. `?overlay={"warnings":false}`. The three problem diff --git a/client-src/clients/EventSourceClient.js b/client-src/clients/EventSourceClient.js new file mode 100644 index 000000000..3f2296075 --- /dev/null +++ b/client-src/clients/EventSourceClient.js @@ -0,0 +1,109 @@ +import { log } from "../utils/log.js"; + +/** @typedef {import("./createSocket.js").CommunicationClient} CommunicationClient */ +/** @typedef {import("./createSocket.js").ClientHandler} ClientHandler */ + +// Long enough that a slow build does not look like a dead connection. +const DEFAULT_TIMEOUT = 20 * 1000; + +/** + * Server-Sent Events. A connection can die without the browser firing `error` + * — a proxy that stops forwarding, a laptop that slept — so this one watches + * for silence as well, and reports that as a close for the caller to reconnect. + * @implements {CommunicationClient} + */ +export default class EventSourceClient { + /** + * @param {string} url url to connect to + * @param {{ timeout?: number }=} options how long silence is tolerated + */ + constructor(url, options = {}) { + this.timeout = options.timeout || DEFAULT_TIMEOUT; + /** @type {ClientHandler | undefined} */ + this.openHandler = undefined; + /** @type {ClientHandler | undefined} */ + this.closeHandler = undefined; + /** @type {ClientHandler | undefined} */ + this.messageHandler = undefined; + // Set once closed, so an `error` the EventSource had already queued cannot + // report a close after the caller asked for none. + this.closed = false; + this.lastActivity = Date.now(); + + this.client = new window.EventSource(url); + + this.client.addEventListener("open", () => { + this.lastActivity = Date.now(); + log.info("connected"); + + if (this.openHandler) { + this.openHandler(); + } + }); + + this.client.addEventListener("message", (event) => { + this.lastActivity = Date.now(); + + if (this.messageHandler) { + this.messageHandler(/** @type {{ data: string }} */ (event).data); + } + }); + + this.client.addEventListener("error", () => { + this.handleDisconnect(); + }); + + // Halved so silence is noticed within one `timeout` rather than two. + this.timer = setInterval(() => { + if (Date.now() - this.lastActivity > this.timeout) { + this.handleDisconnect(); + } + }, this.timeout / 2); + } + + /** + * End this connection and report it, once. + */ + handleDisconnect() { + /* istanbul ignore next -- @preserve reached only by an event queued before close() */ + if (this.closed) { + return; + } + + this.close(); + + if (this.closeHandler) { + this.closeHandler(); + } + } + + /** + * @param {ClientHandler} fn called once the connection is open + */ + onOpen(fn) { + this.openHandler = fn; + } + + /** + * @param {ClientHandler} fn called once the connection is gone + */ + onClose(fn) { + this.closeHandler = fn; + } + + /** + * @param {ClientHandler} fn called with each message, as a string + */ + onMessage(fn) { + this.messageHandler = fn; + } + + /** + * Stop the watchdog and the connection, without reporting a close. + */ + close() { + this.closed = true; + clearInterval(this.timer); + this.client.close(); + } +} diff --git a/client-src/clients/WebSocketClient.js b/client-src/clients/WebSocketClient.js new file mode 100644 index 000000000..867467a51 --- /dev/null +++ b/client-src/clients/WebSocketClient.js @@ -0,0 +1,57 @@ +import { log } from "../utils/log.js"; + +/** @typedef {import("./createSocket.js").CommunicationClient} CommunicationClient */ +/** @typedef {import("./createSocket.js").ClientHandler} ClientHandler */ + +/** + * A WebSocket. The browser reports a dropped connection itself, and the server + * pings to find a half-open one, so unlike Server-Sent Events this needs no + * watchdog of its own. + * @implements {CommunicationClient} + */ +export default class WebSocketClient { + /** + * @param {string} url url to connect to + */ + constructor(url) { + this.client = new WebSocket(url); + this.client.onerror = (error) => { + log.error(error); + }; + } + + /** + * @param {ClientHandler} fn called once the connection is open + */ + onOpen(fn) { + this.client.onopen = () => { + fn(); + }; + } + + /** + * @param {ClientHandler} fn called once the connection is gone + */ + onClose(fn) { + this.client.onclose = () => { + fn(); + }; + } + + /** + * @param {ClientHandler} fn called with each message, as a string + */ + onMessage(fn) { + this.client.onmessage = (event) => { + fn(event.data); + }; + } + + /** + * Close without reporting it, so the caller does not reconnect. + */ + close() { + this.client.onclose = null; + this.client.close(); + } +} diff --git a/client-src/clients/createSocket.js b/client-src/clients/createSocket.js new file mode 100644 index 000000000..e2a6b98d7 --- /dev/null +++ b/client-src/clients/createSocket.js @@ -0,0 +1,108 @@ +import { log } from "../utils/log.js"; + +/** + * Called with no argument for open and close, and with the message string for + * a message. + * @typedef {(data?: string) => void} ClientHandler + */ + +/** + * One transport, as the page speaks it. Constructed with the url, the same + * shape webpack-dev-server's `client.webSocketTransport` has always taken, so + * a client written for that works here unchanged. + * @typedef {object} CommunicationClient + * @property {(fn: ClientHandler) => void} onOpen called once the connection is open + * @property {(fn: ClientHandler) => void} onClose called once the connection is gone + * @property {(fn: ClientHandler) => void} onMessage called with each message, as a string + * @property {() => void} close close without reporting it + */ + +/** + * @typedef {new (url: string, options?: EXPECTED_ANY) => CommunicationClient} CommunicationClientConstructor + */ + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** + * @typedef {object} SocketOptions + * @property {number=} retries how many times to reconnect before giving up, `Infinity` to keep trying + * @property {((attempt: number) => number)=} retryDelay how long to wait before the attempt, in milliseconds + * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor + */ + +/** + * Hold a connection open, reconnecting when it drops, and fan each message out + * to everyone listening. What "reconnect" costs is the transport's to say: a + * dropped WebSocket backs off, whereas Server-Sent Events retries at a steady + * interval for as long as the page is open. + * @param {CommunicationClientConstructor} Client what speaks the transport + * @param {string} url url to connect to + * @param {SocketOptions=} options how it reconnects + * @returns {{ addMessageListener: (fn: (event: { data: string }) => void) => void, close: () => void }} the socket + */ +export default function createSocket(Client, url, options = {}) { + const retries = options.retries === undefined ? 10 : options.retries; + const retryDelay = + options.retryDelay || + // Respectfully copied from the package `got`. + ((attempt) => 1000 * 2 ** attempt + Math.random() * 100); + + /** @type {((event: { data: string }) => void)[]} */ + const listeners = []; + /** @type {CommunicationClient | null} */ + let client = null; + /** @type {ReturnType | undefined} */ + let timer; + let attempt = 0; + let closed = false; + + const open = () => { + client = new Client(url, options.clientOptions); + + client.onOpen(() => { + attempt = 0; + }); + + client.onClose(() => { + client = null; + + if (closed || attempt >= retries) { + return; + } + + const delay = retryDelay(attempt); + + attempt += 1; + + log.info("Trying to reconnect..."); + + timer = setTimeout(open, delay); + }); + + client.onMessage((data) => { + for (const listener of listeners) { + listener({ data: /** @type {string} */ (data) }); + } + }); + }; + + open(); + + return { + addMessageListener(fn) { + listeners.push(fn); + }, + close() { + // Set before closing, so a close event the transport had already queued + // cannot schedule a reconnection after this. + closed = true; + clearTimeout(timer); + + if (client) { + client.close(); + client = null; + } + }, + }; +} diff --git a/client-src/globals.d.ts b/client-src/globals.d.ts index 66aa2754e..f2442ac71 100644 --- a/client-src/globals.d.ts +++ b/client-src/globals.d.ts @@ -23,6 +23,22 @@ interface EventSourceWrapper { close(): void; } +interface CommunicationClient { + onOpen(fn: (data?: string) => void): void; + onClose(fn: (data?: string) => void): void; + onMessage(fn: (data?: string) => void): void; + close(): void; +} + +interface CommunicationClientConstructor { + new (url: string, options?: any): CommunicationClient; +} + +declare const __webpack_dev_server_client__: + | CommunicationClientConstructor + | { default: CommunicationClientConstructor } + | undefined; + interface OverlayTrustedTypesPolicy { createHTML(value: string): string; } diff --git a/client-src/index.js b/client-src/index.js index e8d594080..9f9a76290 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -1,4 +1,4 @@ -/* global __resourceQuery, __webpack_public_path__ */ +/* global __resourceQuery, __webpack_dev_server_client__, __webpack_public_path__ */ // This file is bundled by webpack into a browser bundle, so it is compiled to // ES5 (see `babel.config.js`) and sticks to ES5 runtime APIs — `EventSource` @@ -9,6 +9,9 @@ // Adding it now is a breaking change: it would hide every other path of the // package (e.g. `webpack-dev-middleware/dist/...`) from existing users. +import EventSourceClient from "./clients/EventSourceClient.js"; +import WebSocketClient from "./clients/WebSocketClient.js"; +import createSocket from "./clients/createSocket.js"; import * as indicator from "./indicator.js"; import configureOverlay from "./overlay.js"; import applyUpdate from "./process-update.js"; @@ -34,18 +37,21 @@ import stripAnsi from "./utils/strip-ansi.js"; /** * @typedef {object} ClientOptions - * @property {string} path SSE endpoint path + * @property {("sse" | "ws")} transport how the events are carried, matching the server's `hot.transport` + * @property {string} path endpoint path * @property {number} timeout reconnection timeout in milliseconds * @property {boolean | OverlayOptions} overlay enable the in-page error overlay (same value shape as webpack-dev-server's `client.overlay`) * @property {boolean} reload reload the page when HMR cannot apply the update * @property {LogLevel} logging logger level * @property {string} name limit updates to this compilation name * @property {boolean} autoConnect connect immediately when the entry runs + * @property {number=} reconnect how many times to reconnect before giving up, unset to use the transport's default * @property {boolean} progress show a small badge while a rebuild is in progress */ /** @type {ClientOptions} */ const options = { + transport: "sse", path: "/__webpack_hmr", timeout: 20 * 1000, overlay: true, @@ -123,6 +129,13 @@ function setOverrides(overrides) { if (overrides.autoConnect) { options.autoConnect = overrides.autoConnect === "true"; } + if (overrides.transport === "sse" || overrides.transport === "ws") { + options.transport = overrides.transport; + } + // webpack-dev-server spells the endpoint `webSocketURL`, and unlike `path` + // it carries the origin as well, which is what lets the page reach a server + // on another host. + if (overrides.webSocketURL) options.path = overrides.webSocketURL; if (overrides.path) options.path = overrides.path; if (overrides.timeout) { const timeout = Number(overrides.timeout); @@ -155,6 +168,16 @@ function setOverrides(overrides) { decodeOverlayOptions(options.overlay); } } + if (overrides.reconnect) { + const reconnect = Number(overrides.reconnect); + + if (reconnect >= 0) { + options.reconnect = reconnect; + } + } + if (overrides["live-reload"]) { + options.reload = overrides["live-reload"] !== "false"; + } if (overrides.reload) options.reload = overrides.reload !== "false"; if (overrides.logging) { options.logging = /** @type {LogLevel} */ (overrides.logging); @@ -181,107 +204,47 @@ function setOverrides(overrides) { */ /** - * @returns {{ addMessageListener: (fn: MessageListener) => void, close: () => void }} event source wrapper + * The transport the page speaks. A custom one injected by webpack-dev-server + * wins over both built-ins, which is what `client.webSocketTransport` has + * always done; a module exporting it as `default` is unwrapped. + * @returns {import("./clients/createSocket.js").CommunicationClientConstructor} client constructor */ -function createEventSourceWrapper() { - /** @type {EventSource} */ - let source; - let lastActivity = Date.now(); - /** @type {MessageListener[]} */ - const listeners = []; - /** @type {ReturnType} */ - let timer; - /** @type {ReturnType} */ - let reconnectTimer; - // Set once the wrapper is closed for good, so an `error` event the - // EventSource had already queued cannot schedule a reconnection after it. - let closed = false; - - const handleOnline = () => { - log.info("connected"); - lastActivity = Date.now(); - }; - - /** - * @param {{ data: string }} event event - */ - const handleMessage = (event) => { - lastActivity = Date.now(); - for (const listener of listeners) { - listener(event); - } - }; - - /** - * Close the connection and stop the activity timer without scheduling a - * reconnection. A reconnection that is already pending is cancelled too, so - * closing during the reconnect window really is final. - */ - const close = () => { - closed = true; - clearInterval(timer); - clearTimeout(reconnectTimer); - source.close(); - }; - - /** - * Open the EventSource connection and (re)start the inactivity watchdog — - * disconnecting stops the watchdog, so a reconnected source has to bring its - * own. The disconnect handler belongs to one connection, so it is created - * here rather than shared between reconnections. - */ - const init = () => { - closed = false; - - const handleDisconnect = () => { - // Reached only by an `error` event the EventSource had already queued - // when `close()` ran — a race the browser will not stage on demand. - /* istanbul ignore next -- @preserve */ - if (closed) { - return; - } - - close(); - reconnectTimer = setTimeout( - init, - /** @type {number} */ (options.timeout), - ); - }; - - source = new window.EventSource(/** @type {string} */ (options.path)); - source.addEventListener("open", handleOnline); - source.addEventListener("error", handleDisconnect); - source.addEventListener("message", handleMessage); - - lastActivity = Date.now(); - clearInterval(timer); - timer = setInterval( - () => { - if ( - Date.now() - lastActivity > - /** @type {number} */ (options.timeout) - ) { - handleDisconnect(); - } - }, - /** @type {number} */ (options.timeout) / 2, +function getClient() { + if (typeof __webpack_dev_server_client__ !== "undefined") { + const injected = /** @type {EXPECTED_ANY} */ ( + __webpack_dev_server_client__ ); - }; - init(); + return typeof injected.default === "undefined" + ? injected + : injected.default; + } - return { - addMessageListener(fn) { - listeners.push(fn); - }, - close, - }; + return options.transport === "ws" ? WebSocketClient : EventSourceClient; +} + +/** + * @returns {ReturnType} a socket on the current options + */ +function createClientSocket() { + const isEventSource = options.transport !== "ws"; + + return createSocket(getClient(), /** @type {string} */ (options.path), { + clientOptions: { timeout: options.timeout }, + // Server-Sent Events are retried for as long as the page is open, at the + // steady interval its watchdog already uses: a dev server is expected to + // come back, and a tab left open over a restart has to find it again. + retries: isEventSource ? Infinity : options.reconnect, + retryDelay: isEventSource + ? () => /** @type {number} */ (options.timeout) + : undefined, + }); } const WRAPPER_KEY = "__wdmEventSourceWrapper"; /** - * @returns {ReturnType} cached event source wrapper for this path + * @returns {ReturnType} cached socket for this path */ function getEventSourceWrapper() { const path = /** @type {string} */ (options.path); @@ -289,9 +252,9 @@ function getEventSourceWrapper() { window[WRAPPER_KEY] = {}; } if (!window[WRAPPER_KEY][path]) { - // Cache the wrapper so multiple entries on the same page sharing the same - // `options.path` reuse a single SSE connection. - window[WRAPPER_KEY][path] = createEventSourceWrapper(); + // Cache the socket so multiple entries on the same page sharing the same + // `options.path` reuse a single connection. + window[WRAPPER_KEY][path] = createClientSocket(); } return window[WRAPPER_KEY][path]; } @@ -611,9 +574,16 @@ if (typeof window !== "undefined") { } reporter = window[REPORTER_KEY]; - if (typeof window.EventSource === "undefined") { + // Only the transport actually in use has to exist: asking for a WebSocket on + // a browser without `EventSource` is fine, and so is the reverse. + const missing = + options.transport === "ws" + ? typeof WebSocket === "undefined" && "WebSocket" + : typeof window.EventSource === "undefined" && "EventSource"; + + if (missing) { log.warn( - "webpack-dev-middleware's hot client requires EventSource to work. " + + `webpack-dev-middleware's hot client requires ${missing} to work. ` + "Include a polyfill if you want to support this browser: " + "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events#Tools", ); diff --git a/package.json b/package.json index 488969c72..5582ffc8d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "default": "./dist/index.js" }, "./client": "./client/index.js", + "./client/sse": "./client/clients/EventSourceClient.js", + "./client/ws": "./client/clients/WebSocketClient.js", "./client/indicator": "./client/indicator.js", "./client/overlay": "./client/overlay.js", "./package.json": "./package.json" diff --git a/test/client-socket.test.js b/test/client-socket.test.js new file mode 100644 index 000000000..0867cdf95 --- /dev/null +++ b/test/client-socket.test.js @@ -0,0 +1,188 @@ +import createSocket from "../client-src/clients/createSocket"; + +jest.spyOn(globalThis.console, "log").mockImplementation(); + +/** + * A transport that is driven from the test rather than from a network. Every + * instance is recorded, so a reconnection can be told apart from the first + * connection. + * @returns {EXPECTED_OBJECT} the constructor and what it built + */ +function createFakeClient() { + /** @type {EXPECTED_OBJECT[]} */ + const instances = []; + + class FakeClient { + /** + * @param {string} url url + * @param {EXPECTED_OBJECT=} options client options + */ + constructor(url, options) { + this.url = url; + this.options = options; + this.closed = false; + instances.push(this); + } + + onOpen(fn) { + this.openHandler = fn; + } + + onClose(fn) { + this.closeHandler = fn; + } + + onMessage(fn) { + this.messageHandler = fn; + } + + close() { + this.closed = true; + } + } + + return { FakeClient, instances }; +} + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_OBJECT */ + +describe("createSocket", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("hands each message to every listener", () => { + const { FakeClient, instances } = createFakeClient(); + const socket = createSocket(FakeClient, "ws://localhost/hmr"); + /** @type {string[]} */ + const first = []; + /** @type {string[]} */ + const second = []; + + socket.addMessageListener((event) => first.push(event.data)); + socket.addMessageListener((event) => second.push(event.data)); + + instances[0].messageHandler('{"action":"built"}'); + + // Several entries on one page share a connection, so one message has to + // reach all of them. + expect(first).toEqual(['{"action":"built"}']); + expect(second).toEqual(['{"action":"built"}']); + }); + + it("passes the url and the client options to the transport", () => { + const { FakeClient, instances } = createFakeClient(); + + createSocket(FakeClient, "http://localhost/__webpack_hmr", { + clientOptions: { timeout: 5000 }, + }); + + expect(instances[0].url).toBe("http://localhost/__webpack_hmr"); + expect(instances[0].options).toEqual({ timeout: 5000 }); + }); + + it("reconnects after a drop, backing off between attempts", () => { + const { FakeClient, instances } = createFakeClient(); + + createSocket(FakeClient, "ws://localhost/hmr", { + retryDelay: (attempt) => (attempt + 1) * 1000, + }); + + instances[0].closeHandler(); + expect(instances).toHaveLength(1); + + // Nothing before the delay is up, then exactly one new connection. + jest.advanceTimersByTime(999); + expect(instances).toHaveLength(1); + jest.advanceTimersByTime(1); + expect(instances).toHaveLength(2); + + // The second attempt waits longer than the first. + instances[1].closeHandler(); + jest.advanceTimersByTime(1999); + expect(instances).toHaveLength(2); + jest.advanceTimersByTime(1); + expect(instances).toHaveLength(3); + }); + + it("starts the backoff over once a connection opens", () => { + const { FakeClient, instances } = createFakeClient(); + + createSocket(FakeClient, "ws://localhost/hmr", { + retryDelay: (attempt) => (attempt + 1) * 1000, + }); + + instances[0].closeHandler(); + jest.advanceTimersByTime(1000); + + // A reconnection that succeeded means the next drop is a fresh outage, + // not the continuation of the last one. + instances[1].openHandler(); + instances[1].closeHandler(); + + jest.advanceTimersByTime(1000); + expect(instances).toHaveLength(3); + }); + + it("gives up after the configured number of retries", () => { + const { FakeClient, instances } = createFakeClient(); + + createSocket(FakeClient, "ws://localhost/hmr", { + retries: 2, + retryDelay: () => 1000, + }); + + for (let i = 0; i < 5; i++) { + const last = instances[instances.length - 1]; + + if (!last.closed) { + last.closeHandler(); + } + + jest.advanceTimersByTime(1000); + } + + // The first connection plus two retries, and no more: a server that is + // not coming back must not fill the console forever. + expect(instances).toHaveLength(3); + }); + + it("keeps retrying when told to", () => { + const { FakeClient, instances } = createFakeClient(); + + createSocket(FakeClient, "http://localhost/__webpack_hmr", { + retries: Infinity, + retryDelay: () => 1000, + }); + + for (let i = 0; i < 20; i++) { + instances[instances.length - 1].closeHandler(); + jest.advanceTimersByTime(1000); + } + + expect(instances).toHaveLength(21); + }); + + it("stops reconnecting once closed", () => { + const { FakeClient, instances } = createFakeClient(); + const socket = createSocket(FakeClient, "ws://localhost/hmr", { + retryDelay: () => 1000, + }); + + socket.close(); + + expect(instances[0].closed).toBe(true); + + // A close the transport had already queued must not schedule a + // reconnection after the caller asked for none. + instances[0].closeHandler(); + jest.advanceTimersByTime(10000); + + expect(instances).toHaveLength(1); + }); +}); From f741817b15c5f211eae0a805633acf9a15c6b795 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:42:58 +0000 Subject: [PATCH 2/3] fix(client): resolve the endpoint before handing it to WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WebSocket` only learned to take a relative or `http(s):` url in 2024 — Chrome 125, Firefox 124, Safari 17.3 — and throws on one before that. The endpoint defaults to a path, so `transport=ws` threw outright on exactly the older browsers this ES5 runtime exists to support. It is resolved to an absolute `ws:`/`wss:` url first now. The startup check also asked the browser for `EventSource` even where an injected client was going to do the connecting, and the documented way to reuse a built-in transport used `require()`, which the ESM client build cannot answer. --- README.md | 6 ++- client-src/clients/WebSocketClient.js | 25 +++++++++- client-src/index.js | 19 +++++--- test/client-websocket.test.js | 66 +++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 test/client-websocket.test.js diff --git a/README.md b/README.md index 9d205fa80..0329fa8e0 100644 --- a/README.md +++ b/README.md @@ -538,8 +538,10 @@ the built-in two rather than starting over if you only want to change part of it: ```js -const EventSourceClient = require("webpack-dev-middleware/client/sse"); -const WebSocketClient = require("webpack-dev-middleware/client/ws"); +// The runtime ships as ES modules, so import it — a `require()` through a +// bundler hands back the namespace, whose class is on `.default`. +import EventSourceClient from "webpack-dev-middleware/client/sse"; +import WebSocketClient from "webpack-dev-middleware/client/ws"; ``` The runtime picks it up from `__webpack_dev_server_client__`, which diff --git a/client-src/clients/WebSocketClient.js b/client-src/clients/WebSocketClient.js index 867467a51..7f0efb0d6 100644 --- a/client-src/clients/WebSocketClient.js +++ b/client-src/clients/WebSocketClient.js @@ -3,6 +3,29 @@ import { log } from "../utils/log.js"; /** @typedef {import("./createSocket.js").CommunicationClient} CommunicationClient */ /** @typedef {import("./createSocket.js").ClientHandler} ClientHandler */ +/** + * `WebSocket` only learned to resolve a relative or `http(s):` url recently — + * Chrome 125, Firefox 124, Safari 17.3 — and throws a `SyntaxError` on one + * before that. The default endpoint is a path, so it has to be resolved here + * or this transport is unusable on every older browser, which are the ones + * this runtime goes out of its way to support. + * @param {string} url absolute or relative url + * @returns {string} an absolute `ws:` or `wss:` url + */ +function toWebSocketURL(url) { + if (/^wss?:\/\//i.test(url)) { + return url; + } + + const anchor = document.createElement("a"); + + anchor.href = url; + + // Read back, `href` is absolute, and its scheme maps one to one onto the + // WebSocket ones: http to ws, https to wss. + return anchor.href.replace(/^http/i, "ws"); +} + /** * A WebSocket. The browser reports a dropped connection itself, and the server * pings to find a half-open one, so unlike Server-Sent Events this needs no @@ -14,7 +37,7 @@ export default class WebSocketClient { * @param {string} url url to connect to */ constructor(url) { - this.client = new WebSocket(url); + this.client = new WebSocket(toWebSocketURL(url)); this.client.onerror = (error) => { log.error(error); }; diff --git a/client-src/index.js b/client-src/index.js index 9f9a76290..da9a0e48d 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -574,12 +574,19 @@ if (typeof window !== "undefined") { } reporter = window[REPORTER_KEY]; - // Only the transport actually in use has to exist: asking for a WebSocket on - // a browser without `EventSource` is fine, and so is the reverse. - const missing = - options.transport === "ws" - ? typeof WebSocket === "undefined" && "WebSocket" - : typeof window.EventSource === "undefined" && "EventSource"; + // Only what the transport in use needs has to exist: asking for a WebSocket + // on a browser without `EventSource` is fine, and so is the reverse. An + // injected client speaks for itself, so nothing is required of the browser + // on its behalf. + /** @type {string | false} */ + let missing = false; + + if (typeof __webpack_dev_server_client__ === "undefined") { + missing = + options.transport === "ws" + ? typeof WebSocket === "undefined" && "WebSocket" + : typeof window.EventSource === "undefined" && "EventSource"; + } if (missing) { log.warn( diff --git a/test/client-websocket.test.js b/test/client-websocket.test.js new file mode 100644 index 000000000..77486e331 --- /dev/null +++ b/test/client-websocket.test.js @@ -0,0 +1,66 @@ +import WebSocketClient from "../client-src/clients/WebSocketClient"; + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_OBJECT */ + +describe("WebSocketClient", () => { + /** @type {string[]} */ + let urls; + + beforeEach(() => { + urls = []; + + // The page this runtime would be running in. + globalThis.document = /** @type {EXPECTED_OBJECT} */ ({ + createElement: () => { + const anchor = { href: "" }; + + Object.defineProperty(anchor, "href", { + get: () => anchor._resolved, + set: (value) => { + // What a browser does with `a.href`: resolve against the page. + anchor._resolved = /^[a-z]+:\/\//i.test(value) + ? value + : `https://example.test${value.startsWith("/") ? "" : "/"}${value}`; + }, + }); + + return anchor; + }, + }); + + globalThis.WebSocket = /** @type {EXPECTED_OBJECT} */ ( + function WebSocket(url) { + urls.push(url); + } + ); + }); + + afterEach(() => { + delete globalThis.document; + delete globalThis.WebSocket; + }); + + it("resolves a path into an absolute wss: url", () => { + // `WebSocket` only learned to take a relative url in 2024, and the default + // endpoint is a path — so on any older browser this would throw outright. + const client = new WebSocketClient("/__webpack_hmr"); + + expect(urls).toEqual(["wss://example.test/__webpack_hmr"]); + expect(client).toBeDefined(); + }); + + it("maps an http: endpoint onto ws:", () => { + const client = new WebSocketClient("http://localhost:8080/__webpack_hmr"); + + expect(urls).toEqual(["ws://localhost:8080/__webpack_hmr"]); + expect(client).toBeDefined(); + }); + + it("leaves an endpoint which is already a WebSocket url alone", () => { + const client = new WebSocketClient("wss://other.test/hmr"); + + expect(urls).toEqual(["wss://other.test/hmr"]); + expect(client).toBeDefined(); + }); +}); From 6114e4498f9312e075d28d72ac53ed9ed19c0392 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:53:12 +0000 Subject: [PATCH 3/3] fix(client): keep Server-Sent Events quiet between reconnections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconnecting moved into the shared loop, which announces each attempt the way webpack-dev-server's always has. That is affordable for a WebSocket, which gives up after ten tries; Server-Sent Events retry for as long as the page is open, so it meant saying so every few seconds, all day — and they had never said it at all. Two e2e snapshots pin exactly that silence. Whether to announce an attempt is the caller's now, defaulting to whether the attempts are bounded. --- client-src/clients/createSocket.js | 11 +++++++- test/client-socket.test.js | 41 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/client-src/clients/createSocket.js b/client-src/clients/createSocket.js index e2a6b98d7..b7888227b 100644 --- a/client-src/clients/createSocket.js +++ b/client-src/clients/createSocket.js @@ -28,6 +28,7 @@ import { log } from "../utils/log.js"; * @typedef {object} SocketOptions * @property {number=} retries how many times to reconnect before giving up, `Infinity` to keep trying * @property {((attempt: number) => number)=} retryDelay how long to wait before the attempt, in milliseconds + * @property {boolean=} logRetries say so before each attempt, which only a bounded number of them can afford to do * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor */ @@ -43,6 +44,12 @@ import { log } from "../utils/log.js"; */ export default function createSocket(Client, url, options = {}) { const retries = options.retries === undefined ? 10 : options.retries; + // A transport that keeps trying for as long as the page is open would + // otherwise say so every few seconds, all day. + const logRetries = + options.logRetries === undefined + ? retries !== Infinity + : options.logRetries; const retryDelay = options.retryDelay || // Respectfully copied from the package `got`. @@ -75,7 +82,9 @@ export default function createSocket(Client, url, options = {}) { attempt += 1; - log.info("Trying to reconnect..."); + if (logRetries) { + log.info("Trying to reconnect..."); + } timer = setTimeout(open, delay); }); diff --git a/test/client-socket.test.js b/test/client-socket.test.js index 0867cdf95..da7b1b6b3 100644 --- a/test/client-socket.test.js +++ b/test/client-socket.test.js @@ -168,6 +168,47 @@ describe("createSocket", () => { expect(instances).toHaveLength(21); }); + it("says it is reconnecting while the attempts are bounded", () => { + const info = jest.spyOn(globalThis.console, "info").mockImplementation(); + const { FakeClient, instances } = createFakeClient(); + + createSocket(FakeClient, "ws://localhost/hmr", { + retries: 2, + retryDelay: () => 1000, + }); + + instances[0].closeHandler(); + + expect(info).toHaveBeenCalledWith( + expect.stringContaining("Trying to reconnect"), + ); + + info.mockRestore(); + }); + + it("stays quiet when it will keep retrying forever", () => { + const info = jest.spyOn(globalThis.console, "info").mockImplementation(); + const { FakeClient, instances } = createFakeClient(); + + createSocket(FakeClient, "http://localhost/__webpack_hmr", { + retries: Infinity, + retryDelay: () => 1000, + }); + + for (let i = 0; i < 5; i++) { + instances[instances.length - 1].closeHandler(); + jest.advanceTimersByTime(1000); + } + + // Saying it every few seconds for as long as the page is open is not + // information, it is noise — and Server-Sent Events never said it before. + expect(info).not.toHaveBeenCalledWith( + expect.stringContaining("Trying to reconnect"), + ); + + info.mockRestore(); + }); + it("stops reconnecting once closed", () => { const { FakeClient, instances } = createFakeClient(); const socket = createSocket(FakeClient, "ws://localhost/hmr", {