Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/dev-server-client-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"webpack-dev-middleware": patch
---

The client keeps doing what webpack-dev-server's did:

- A page url with a malformed escape no longer stops every update.
- `apply: "reload"` reloads a reconnected page whose build is out of date.
- A build's warnings are logged and posted along with its errors.
- An entry written for that server's query (`hostname`, `port`, `pathname`, `live-reload`, `hot=only`) still connects, with no deprecation warning for that server's own spelling.
- The published client is ES5 down to the logger.
- The overlay sits at the highest `z-index` and closes on `Esc`.
- The building indicator works without Shadow DOM and is announced as a progress bar.
5 changes: 5 additions & 0 deletions .changeset/finish-dev-server-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-middleware": minor
---

`hot.client: false` adds no runtime to the page while still applying `HotModuleReplacementPlugin`, which now goes to every compilation the middleware serves, including server bundles that hot-reload through `webpack/hot/poll`. `hot.client.transport` also accepts a module exporting a client class of your own, and the new `hot.ws` option is passed to the `ws` server (compression, `verifyClient`, or a `port` or `server` of its own), with `hot.cors` and `hot.token` still checked. The connection the runtime holds is exported as `webpack-dev-middleware/client/socket` for tooling that listens alongside it.
61 changes: 32 additions & 29 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,52 @@
"version": "0.2",
"language": "en,en-gb",
"words": [
"memfs",
"GHSA",
"rxfj",
"noextension",
"fullhash",
"execa",
"deepmerge",
"fastify",
"contextify",
"middie",
"apos",
"cachable",
"cexoso",
"usdz",
"leadinghash",
"myhtml",
"configurated",
"mycustom",
"commitlint",
"nosniff",
"deoptimize",
"etag",
"cachable",
"finalhandler",
"hono",
"rspack",
"apos",
"malformed",
"configurated",
"Consolas",
"contextify",
"cspellcache",
"CSSOM",
"darkgrey",
"deepmerge",
"deoptimize",
"eslintcache",
"esmodules",
"etag",
"execa",
"expressjs",
"fastify",
"finalhandler",
"fullhash",
"GHSA",
"hono",
"jshttp",
"leadinghash",
"malformed",
"mbold",
"memfs",
"middie",
"mred",
"mycustom",
"myhtml",
"noextension",
"noopener",
"noreferrer",
"webworker",
"nosniff",
"nwjs",
"expressjs",
"wildcarded",
"jshttp",
"realpath",
"Rsbuild"
"Rsbuild",
"rspack",
"rxfj",
"usdz",
"valuemax",
"valuemin",
"valuenow",
"webworker",
"wildcarded"
],
"ignorePaths": [
"CHANGELOG.md",
Expand Down
80 changes: 71 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,25 @@ Default: `undefined`

HTTP server the [`'ws'`](#hottransport) transport answers upgrades on, when it already exists where the middleware is built. Otherwise hand it over later with the middleware's [`attach`](#attachserver) method. Ignored by `'sse'`, which is answered by the middleware itself.

#### `hot.ws`

Type: `Object`
Default: `undefined`

Options for the [`ws`](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback) server behind the [`'ws'`](#hottransport) transport — compression (`perMessageDeflate`), `maxPayload`, `handleProtocols`, `verifyClient`, and so on. Ignored by `'sse'`.

A `port` (with an optional `host`) or a `server` gives the endpoint a server of its own to listen on, rather than the upgrades it is handed through [`hot.server`](#hotserver), [`attach`](#attachserver) or [`handleUpgrade`](#handleupgradereq-socket-head) — which then answer nothing:

```js
app.use(
middleware(compiler, {
hot: { transport: "ws", ws: { port: 8081 } },
}),
);
```

[`hot.cors`](#hotcors) and [`hot.token`](#hottoken) are checked either way, before the handshake completes; a `verifyClient` of your own is asked only about a client they allow. `path`, `noServer` and `clientTracking` are the middleware's, and are ignored here — the endpoint's path is [`hot.path`](#hotpath).

#### `hot.progress`

Type: `Boolean`
Expand Down Expand Up @@ -637,7 +656,7 @@ app.get("/my-client-config.json", (_req, res) => {
Type: `Boolean`
Default: `true`

Add the client entry and `HotModuleReplacementPlugin` to the compilation. Set it to `false` to wire both yourself — see [Hot Module Replacement client](#hot-module-replacement-client).
Add the client entry and `HotModuleReplacementPlugin` to the compilation. Set it to `false` to wire both yourself — see [Hot Module Replacement client](#hot-module-replacement-client). To keep the plugin and wire only the client yourself, set [`hot.client`](#client-options) to `false` instead.

Turn it off when you have a client of your own that the middleware will not recognize as one (anything other than `webpack-dev-middleware/client`), or when you do not want the hot runtime in your bundle at all and are using the endpoint through [`subscribe`](#custom-events) instead.

Expand Down Expand Up @@ -688,22 +707,24 @@ The client is recognized as `webpack-dev-middleware/client` (with or without a q

#### Which compilations get the runtime

Only the ones a browser runs, decided by the compilation's [`target`](https://webpack.js.org/configuration/target/):
Only the ones a browser runs, decided by the compilation's [`target`](https://webpack.js.org/configuration/target/). `HotModuleReplacementPlugin` goes to every compilation the middleware serves, whatever its target: a server bundle hot-reloads itself through `module.hot` too, with [`webpack/hot/poll`](https://github.com/webpack/webpack/blob/main/hot/poll.js) or [`webpack/hot/signal`](https://github.com/webpack/webpack/blob/main/hot/signal.js). In a multi-compiler build, a compilation whose configuration says `devServer: false` gets neither.

| `target` | Gets the runtime |
| :------------------------------------------------------------- | :--------------- |
| unset (webpack's default), `web`, `browserslist: …` | yes |
| `webworker` | yes |
| `electron-renderer`, `electron-preload`, `nwjs`, `node-webkit` | yes |
| universal — `web` and `node` together, as in `["node", "web"]` | yes |
| universal — `"universal"`, or `web` and `node` together | yes |
| `node`, `node14`, `async-node`, `electron-main` | no |
| `deno` | no |
| `false`, or a version with no platform such as `es2020` | no |

So in a multi-compiler build the browser half gets a client and the server-rendering half does not, with nothing to configure.
So in a multi-compiler build the browser half gets a client and the server-rendering half does not, with nothing to configure — both get the plugin.

**Web workers are included.** A worker has no `window` and no document, but it has `EventSource`, `WebSocket` and webpack's runtime, which is all an update needs — so a worker compilation gets a client and applies updates in place, with the overlay and the building indicator left to the page. The one thing a worker cannot do is reload itself, since it has no `location.reload`; when an update cannot be applied the client says so and leaves the page that started the worker to reload it.

**A universal build runs it in Node too.** One bundle serves both, so the runtime is in what Node runs as well, and there it does nothing: it opens no connection, prints nothing and leaves nothing running.

`deno` is a context webpack also counts as `web`, and it stays out until it can be tested there — it has no `window` either, and whether the transports are available is not something this project's test suite can answer.

The last row names no platform for the middleware to go on; if it is a browser bundle, add the entry yourself as above.
Expand Down Expand Up @@ -795,6 +816,10 @@ app.use(
);
```

`hot.client: false` adds no runtime to the page and still applies
`HotModuleReplacementPlugin`, for a page that wires a client of its own and
still wants its updates applied.

`hot.client` is read only when the client is injected. With `hot.inject: false`,
or for a client the configuration already has as an entry, the query string on
the entry path is the only source — and it works either way:
Expand Down Expand Up @@ -830,7 +855,7 @@ narrow the mode in force.

| Name | Type | Default | Description |
| :-----------------: | :--------------------------------------: | :------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `string` | `"sse"` | How the events are carried: `"sse"` or `"ws"`. Defaults to the server [`hot.transport`](#hottransport); override it only together with `path`, since a client asking this endpoint for a protocol it does not serve never connects — the middleware warns when it sees that. |
| `transport` | `string` | `"sse"` | How the events are carried: `"sse"` or `"ws"`. Defaults to the server [`hot.transport`](#hottransport); override it only together with `path`, since a client asking this endpoint for a protocol it does not serve never connects — the middleware warns when it sees that. Any other string is a module exporting [a client of your own](#a-client-of-your-own). |
| `path` | `string\|Object` | `/__webpack_hmr` | Where the runtime connects. Defaults to the server [`hot.path`](#hotpath); set it to an absolute url (`wss://dev.example.com/__webpack_hmr`) for an endpoint on another origin or behind a proxy, or to an object saying only the parts that differ — see [a path in parts](#a-path-in-parts). |
| `apply` | `"hmr"\|"hmr-only"\|"reload"\|"nothing"` | `"hmr"` | What a build does to the page. `"hmr"` applies the update and loads the page again if it cannot be applied; `"hmr-only"` applies it and stops with a message if it cannot; `"reload"` skips HMR and loads the page again on any build that changed something; `"nothing"` leaves the page alone until you reload it. One option rather than three booleans, since only four of their eight combinations differed. |
| `connect` | `boolean\|{ retries, timeout }` | `true` | Whether to connect when the entry runs, and how the connection is held open. `false` does not connect — call `setOptionsAndConnect()` yourself. `retries` is how many times to reconnect before giving up, and `Infinity` never does; unset, `"sse"` keeps trying for as long as the page is open while `"ws"` gives up after `10`. `timeout` is how long silence is tolerated before reconnecting, in milliseconds, and the interval between reconnections — `"sse"` only, since a `"ws"` heartbeat is a protocol ping the browser answers without telling JavaScript. |
Expand Down Expand Up @@ -919,10 +944,47 @@ 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.
Name it in [`hot.client.transport`](#client-options) — a path, or a package
name resolved from the compilation's context — and the injected runtime uses it
in place of the built-in one:

```js
app.use(
middleware(compiler, {
hot: {
transport: "ws",
client: { transport: require.resolve("./my-client.js") },
},
}),
);
```

It reaches the runtime as `__webpack_dev_server_client__`, which is how
webpack-dev-server's `client.webSocketTransport` option has always worked; a
module exporting the class as `default` is unwrapped. The query carries the
endpoint's own transport, which the runtime still builds the url's scheme from.

#### Listening alongside the runtime

Tooling that wants the raw messages without replacing the runtime can read the
connection it holds:

```js
import { client } from "webpack-dev-middleware/client/socket";

// `client` is live: `null` before the runtime starts to connect and while it
// waits to reconnect. `client.client` is the `WebSocket` or `EventSource`
// underneath, a new one for each connection, so a listener added to it hears
// that connection only.
if (client && client.client) {
client.client.addEventListener("message", (event) => {
console.log(JSON.parse(event.data));
});
}
```
Comment thread
alexander-akait marked this conversation as resolved.

This is the shape webpack-dev-server's `client/socket` has always exported,
which is what `@pmmmwh/react-refresh-webpack-plugin` reads.

#### Client `overlay` options

Expand Down
43 changes: 43 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const path = require("node:path");

const MIN_BABEL_VERSION = 8;

// The middleware itself runs on the node.js version `engines` requires.
Expand All @@ -7,6 +9,46 @@ const NODE_TARGETS = { node: "20.9.0" };
// `modules: false` keeps the ESM syntax for webpack to tree-shake.
const CLIENT_TARGETS = { ie: "11" };

// The two webpack modules the client logs through, and the ES5 copies of them
// `scripts/build-client-logger.mjs` writes next to the built client.
const CLIENT_SRC = path.join(__dirname, "client-src");
const ES5_LOGGER = {
"webpack/lib/logging/Logger.js": "Logger.cjs",
"webpack/lib/logging/createConsoleLogger.js": "createConsoleLogger.cjs",
};

/**
* Point the built client at the ES5 copies of webpack's logger. Webpack's own
* are node-side source, which a bundle for an ES5 browser would carry as is.
* @returns {import("@babel/core").PluginObj} plugin
*/
function useES5Logger() {
return {
name: "use-es5-logger",
visitor: {
ImportDeclaration(declaration, state) {
const target = ES5_LOGGER[declaration.node.source.value];

if (!target) {
return;
}

const relative = path
.relative(
path.dirname(/** @type {string} */ (state.filename)),
path.join(CLIENT_SRC, "modules", "logger", target),
)
.split(path.sep)
.join("/");

declaration.node.source.value = relative.startsWith(".")
? relative
: `./${relative}`;
},
},
};
}

module.exports = (api) => {
api.assertVersion(MIN_BABEL_VERSION);

Expand All @@ -26,6 +68,7 @@ module.exports = (api) => {
presets: [
["@babel/preset-env", { modules: false, targets: CLIENT_TARGETS }],
],
plugins: [useES5Logger],
},
],
};
Expand Down
47 changes: 38 additions & 9 deletions client-src/clients/createSocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ import { log } from "../utils/log.js";
* @property {EXPECTED_ANY=} clientOptions passed to the client's constructor
*/

/**
* A connection as `client` hands it out. Its own `client` is the `WebSocket`
* or `EventSource` underneath, on the built-in transports.
* @typedef {CommunicationClient & { client?: WebSocket | EventSource }} LiveClient
*/

/**
* The connection the runtime holds right now, or `null` while there is none —
* for tooling that listens alongside the runtime rather than through it.
* `client.client` is the `WebSocket` or `EventSource` underneath, the shape
* webpack-dev-server's `client/socket` has always exported, which is what
* `@pmmmwh/react-refresh-webpack-plugin` reads its build messages from.
* @type {LiveClient | null}
*/
// eslint-disable-next-line import/no-mutable-exports
export let client = null;

/**
* 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
Expand All @@ -59,24 +76,31 @@ export default function createSocket(Client, url, options = {}) {
/** @type {((event: { data: string }) => void)[]} */
const listeners = [];
/** @type {CommunicationClient | null} */
let client = null;
let current = null;
/** @type {ReturnType<typeof setTimeout> | undefined} */
let timer;
let attempt = 0;
let closed = false;

const open = () => {
client = new Client(url, options.clientOptions);
current = new Client(url, options.clientOptions);
client = current;

client.onOpen(() => {
current.onOpen(() => {
// Said here rather than in a transport, or whichever one did not say it
// would leave the page with no sign it had connected at all.
log.info("connected");
attempt = 0;
});

client.onClose(() => {
client = null;
current.onClose(() => {
// Only if it is still the one exported: two endpoints on one page each
// hold a connection, and one dropping says nothing about the other.
if (client === current) {
client = null;
}

current = null;

// Once per outage rather than once per failed attempt: the retries that
// follow are this module reconnecting, not the connection going away
Expand Down Expand Up @@ -104,7 +128,7 @@ export default function createSocket(Client, url, options = {}) {
timer = setTimeout(open, delay);
});

client.onMessage((data) => {
current.onMessage((data) => {
for (const listener of listeners) {
listener({ data: /** @type {string} */ (data) });
}
Expand All @@ -123,9 +147,14 @@ export default function createSocket(Client, url, options = {}) {
closed = true;
clearTimeout(timer);

if (client) {
client.close();
client = null;
if (current) {
current.close();

if (client === current) {
client = null;
}

current = null;
}
},
};
Expand Down
Loading
Loading