Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/client-transports.md
Original file line number Diff line number Diff line change
@@ -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`
61 changes: 58 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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. |
Expand All @@ -494,6 +497,58 @@ 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
// 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
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
Expand Down
109 changes: 109 additions & 0 deletions client-src/clients/EventSourceClient.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
80 changes: 80 additions & 0 deletions client-src/clients/WebSocketClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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
* watchdog of its own.
* @implements {CommunicationClient}
*/
export default class WebSocketClient {
/**
* @param {string} url url to connect to
*/
constructor(url) {
this.client = new WebSocket(toWebSocketURL(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();
}
}
117 changes: 117 additions & 0 deletions client-src/clients/createSocket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 {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
*/

/**
* 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;
// 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`.
((attempt) => 1000 * 2 ** attempt + Math.random() * 100);

/** @type {((event: { data: string }) => void)[]} */
const listeners = [];
/** @type {CommunicationClient | null} */
let client = null;
/** @type {ReturnType<typeof setTimeout> | 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;

if (logRetries) {
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;
}
},
};
}
Loading
Loading