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/deprecate-hot-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-middleware": patch
---

Deprecated the `hot.progress` option; it will be removed in the next major release and keeps working until then. It applied `ProgressPlugin` to your compiler, which leaves a server that applies one itself — webpack-dev-server does — with two of them on one compiler. Apply it yourself and hand the result to [`publish`](https://github.com/webpack/webpack-dev-middleware#publishpayload), rounding the percent and dropping a tick that repeats one as the option did for you. The browser end of this, `hot.client.progress`, is unaffected and stays.
37 changes: 37 additions & 0 deletions .changeset/hot-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"webpack-dev-middleware": minor
---

Added `hot.token`: a secret the injected client carries and the hot endpoint
requires, so reaching the stream takes something a page has to have been given
rather than a header the browser may or may not send.

`hot.cors` is answered by `Origin`, and that is its weakness. A browser omits
`Origin` and the whole `Sec-Fetch-*` family when the destination is not
potentially trustworthy — plain `http` to anything but `localhost`, which
`host: "0.0.0.0"` gives you. webpack-dev-server shipped two fixes built on
those headers and both were bypassed exactly that way, CVE-2026-6402 and then
CVE-2026-14620. A token asks the browser to volunteer nothing.

Off by default on both transports, and `true` in the next major release. A
token only reaches the browser on the entry the middleware adds, and `inject`
being on does not mean an entry was added: it is skipped when every entry point
already pulls the client in, when `hot.transport` is a function, and for a
non-web target. Requiring one by default would turn each of those into a `403`
on every client. Set `token: true` to turn it on, and if you do so where no
client was injected the middleware warns rather than leaving you with an
unexplained refusal.

With the client injected, that is all it takes: it is handed the token and puts
it on its connection url. `hot.inject: false` turns the requirement off — the
token travels in the entry the middleware adds, so with nothing injected there
is no way to hand one over. A configuration that lists the client entry itself
is built before the middleware exists and cannot carry a minted token, so give
it a fixed one both sides know, or read the minted one from `instance.token`.

What it does not protect: the client reads the token from its entry query, so
it is a string in the bundle. Anything that can already read the bundle
cross-origin reads the token with it, and over plain `http` to a non-localhost
address nothing stops that unless your server sends
`Cross-Origin-Resource-Policy`. This hardens every case where the bundle is not
readable, and is defence in depth in the case where it is.
5 changes: 5 additions & 0 deletions .changeset/instance-publish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-middleware": minor
---

Added `publish(payload)` to the instance, which puts a payload of your own on the hot stream. The middleware publishes what it knows about — a build starting, finishing, failing — and anything else a server measures is its own; `ProgressPlugin` is the example. The bundled client already renders `{ action: "progress" }`, so a server that applies the plugin itself now has somewhere to put what it reports. Nothing is sent when no client is connected, and it does nothing when `hot` is off.
5 changes: 5 additions & 0 deletions .changeset/readme-anchors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-middleware": patch
---

Fixed two broken links in the README: the `etag` row of the options table and the two references to the `attach` method pointed at headings that do not exist.
3 changes: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"expressjs",
"wildcarded",
"jshttp",
"realpath"
"realpath",
"Rsbuild"
],
"ignorePaths": [
"CHANGELOG.md",
Expand Down
126 changes: 122 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ See [below](#other-servers) for an example of use with fastify.
| **[`index`](#index)** | `boolean\|string` | `index.html` | If `false` (but not `undefined`), the server will not respond to requests to the root URL. |
| **[`mimeTypes`](#mimetypes)** | `Object` | `undefined` | Allows to register custom mime types or extension mappings. |
| **[`mimeTypeDefault`](#mimetypedefault)** | `string` | `undefined` | Allows to register a default mime type when we can't determine the content type. |
| **[`etag`](#tag)** | `boolean\| "weak"\| "strong"` | `undefined` | Enable or disable etag generation. |
| **[`etag`](#etag)** | `boolean\| "weak"\| "strong"` | `undefined` | Enable or disable etag generation. |
| **[`lastModified`](#lastmodified)** | `boolean` | `undefined` | Enable or disable `Last-Modified` header. Uses the file system's last modified value. |
| **[`cacheControl`](#cachecontrol)** | `boolean\|number\|string\|Object` | `undefined` | Enable or disable setting `Cache-Control` response header. |
| **[`cacheImmutable`](#cacheimmutable)** | `boolean` | `undefined` | Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets. |
Expand Down Expand Up @@ -337,6 +337,7 @@ The object form accepts these options:
| **[`server`](#hotserver)** | `object` | `undefined` | HTTP server the `'ws'` transport answers upgrades on. |
| **[`progress`](#hotprogress)** | `boolean` | `false` | Publish compilation progress events to the clients. |
| **[`cors`](#hotcors)** | `boolean \| string \| string[] \| RegExp \| function \| object` | see below | Which origins may reach the endpoint from a page on another one, over either transport. |
| **[`token`](#hottoken)** | `boolean \| string` | see below | A secret the injected client carries and the endpoint requires, over either transport. |
| **[`inject`](#hotinject)** | `boolean` | `true` | Add the client entry and `HotModuleReplacementPlugin`. |
| **[`statsOptions`](#hotstatsoptions)** | `object` | `undefined` | Deprecated — do not use; see [`stats`](#stats). |

Expand All @@ -349,7 +350,7 @@ How events reach the clients.

`'sse'` serves them as [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) from the middleware itself, which needs nothing else.

`'ws'` serves them over a WebSocket. It needs the optional [`ws`](https://www.npmjs.com/package/ws) package (`npm install ws`), and an HTTP server to answer upgrades on — a handshake is an upgrade the server answers, which the middleware never sees. Give it [`hot.server`](#hotserver), or hand the server over later with the middleware's [`attach`](#attach) method:
`'ws'` serves them over a WebSocket. It needs the optional [`ws`](https://www.npmjs.com/package/ws) package (`npm install ws`), and an HTTP server to answer upgrades on — a handshake is an upgrade the server answers, which the middleware never sees. Give it [`hot.server`](#hotserver), or hand the server over later with the middleware's [`attach`](#attachserver) method:

```js
const server = http.createServer(instance);
Expand Down Expand Up @@ -443,14 +444,20 @@ Heartbeat interval (in milliseconds) used to keep the connection alive when no c
Type: `Object`
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`](#attach) method. Ignored by `'sse'`, which is answered by the middleware itself.
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.progress`

Type: `Boolean`
Default: `false`

Publish compilation progress events (`{ action: "progress", percent, message }`) to the clients using webpack's `ProgressPlugin`. The bundled client shows the percentage in its building badge (see the client `progress` option).
> [!WARNING]
>
> Deprecated, and removed in the next major release. Use [`publish`](#publishpayload) instead — the example is there.

Applies webpack's `ProgressPlugin` and publishes what it reports (`{ action: "progress", percent, message }`). The bundled client shows the percentage in its building badge, which the client `progress` option configures.

Deciding to measure a build is the server's call rather than the middleware's, and a server that applies `ProgressPlugin` already — webpack-dev-server does — ends up with two of them on one compiler. The client still renders the payload; what changes is who sends it.

#### `hot.cors`

Expand Down Expand Up @@ -537,6 +544,62 @@ A **function** [transport](#hottransport) of your own is handed the option as it
>
> This is about who may reach the endpoint, and nothing else. It does not decide who may reach the **assets** the middleware serves, which is your server's to answer — with a `Cross-Origin-Resource-Policy` response header, or with whatever your framework's own CORS middleware does.

#### `hot.token`

Type: `Boolean | String`
Default: `false`

A secret the injected client carries and the endpoint requires. Reaching the stream then takes something a page has to have been **given**, rather than a header a browser may or may not send.

[`cors`](#hotcors) is answered by `Origin`, and that is the weakness: a browser omits `Origin` and the whole `Sec-Fetch-*` family when the destination is not [potentially trustworthy](https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy) — plain `http` to anything but `localhost`, which is what `host: '0.0.0.0'` gives you. webpack-dev-server shipped two fixes built on those headers and both were bypassed exactly that way ([CVE-2026-6402](https://github.com/advisories/GHSA-79cf-xcqc-c78w), then [CVE-2026-14620](https://github.com/advisories/GHSA-f5vj-f2hx-8m93)). A token asks the browser to volunteer nothing.

**Off by default, on both transports**, and `true` in the next major release. A token only reaches the browser on the entry this middleware adds, and `inject` being on does not mean an entry was added — it is skipped when every entry point already pulls the client in, when [`hot.transport`](#hottransport) is a function, and for a non-web target. Requiring one by default would turn each of those into a `403` on every client.

Turn it on, which is all the normal setup needs — the client is injected, so it is handed the token and uses it:

```js
app.use(middleware(compiler, { hot: { token: true } }));
```

If you turn it on where no client was injected, the middleware says so rather than leaving you with an unexplained `403`:

```
[webpack-dev-middleware] 'hot.token' requires a token on the endpoint, but no
client entry was added to hand one over, so every client will be refused.
```

> [!IMPORTANT]
>
> **What a token does not protect.** The client reads it from its entry query, so it is a string in the bundle. Anything that can already read your bundle cross-origin can read the token out of it — and over plain `http` to a non-`localhost` address, nothing stops that unless your server sends `Cross-Origin-Resource-Policy`. The token hardens every case where the bundle is not readable; where it is, your source has already gone and the stream is the smaller loss. Closing that needs the response header and a `Host` allowlist, which are [your server's](#security) to set.

**Wiring the client yourself.** The token travels in the entry this middleware adds, so `hot.inject: false` turns the requirement off — there would be no way to hand one over, and requiring it would refuse a client you wired correctly.

A configuration that already lists the client as an entry is the other half of that: it is built before the middleware exists, so it cannot carry a token minted per run. Give it one of your own instead, which both sides can know in advance:

```js
const token = "a-secret-of-my-own";

// webpack.config.js
entry: [`webpack-dev-middleware/client?token=${token}`, "./src/index.js"];

// and the middleware
app.use(middleware(compiler, { hot: { transport: "ws", token } }));
```

Or read the minted one off the instance, for a client you serve yourself:

```js
const instance = middleware(compiler, {
hot: { transport: "ws", token: true },
});

app.get("/my-client-config.json", (_req, res) => {
res.json({ token: instance.token });
});
```

`false` requires none, which is the default.

#### `hot.inject`

Type: `Boolean`
Expand Down Expand Up @@ -1086,6 +1149,61 @@ Required: `Yes`

Called once per client, in the order the subscribers were added.

### `publish(payload)`

Put a payload of your own on the hot stream.

The middleware publishes what it knows about — a build starting, finishing, failing. Anything else a server measures is its own, and `ProgressPlugin` is the example: deciding to instrument a build is the server's call, and the middleware is only what carries the result.

The bundled client already renders `{ action: "progress" }` in its building badge, so a server with its own plugin has somewhere to put it:

```js
const compiler = webpack(config);
const instance = middleware(compiler, { hot: true });

// A `ProgressPlugin` callback fires far more often than the whole number
// changes, so the percent is rounded and a tick that repeats one is dropped —
// without that, most of these would be a message on the wire saying what the
// last one said. This is what `hot.progress` did for you.
let lastPercent = -1;

new webpack.ProgressPlugin((percent, message) => {
const rounded = Math.round(percent * 100);

if (rounded === lastPercent) {
return;
}

lastPercent = rounded;
instance.publish({ action: "progress", percent: rounded, message });
}).apply(compiler);

app.use(instance);
```

That replaces [`hot.progress`](#hotprogress), which applied the plugin for you and is deprecated — a server that applies `ProgressPlugin` already would otherwise have two of them on one compiler. The indicator itself is unaffected: whether a `progress` payload is drawn is [`hot.client.progress`](#client-options), which is the browser's end of this and stays.

Rounding and de-duplicating are the two things `hot.progress` did that become yours, which is why the example above does both.

It is not only for progress. Any action the clients understand can be published, and `{ action: "reload" }` is the other useful one — every page loads itself again, whatever `hot` and `liveReload` are set to:

```js
chokidar.watch("content/**/*.md").on("change", () => {
instance.publish({ action: "reload" });
});
```

Nothing is sent when no client is connected, so a caller does not have to ask whether anyone is listening — but that is the only traffic it saves, and with a page open every call is a message. Keeping a chatty source down to what changed, as above, is the caller's. Does nothing when `hot` is disabled.

#### Parameters

##### `payload`

Type: `{ action: String, ...}`
Required: `Yes`

An `action` the clients understand, and whatever that action carries. The built-in actions are `building`, `progress`, `built`, `sync` and `reload`.

### `close(callback)`

Instructs `webpack-dev-middleware` instance to stop watching for file changes.
Expand Down
35 changes: 23 additions & 12 deletions client-src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { log, setLogLevel } from "./utils/log.js";
import reloadPage from "./utils/reload.js";
import sendMessage from "./utils/send-message.js";
import stripAnsi from "./utils/strip-ansi.js";
import withToken from "./utils/with-token.js";

/** @typedef {import("./utils/log.js").LogLevel} LogLevel */

Expand Down Expand Up @@ -45,6 +46,7 @@ import stripAnsi from "./utils/strip-ansi.js";
* @property {string} urlPrefix prefix of the page-url parameters that turn `hot` and `liveReload` off for one page
* @property {LogLevel} logging logger level
* @property {string} name limit updates to this compilation name
* @property {string} token the secret the endpoint requires, when it requires one, put on the connection url — empty when it requires none
* @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 | "circular" | "linear"} progress show an indicator while a rebuild is in progress — `true` and `"circular"` a small badge, `"linear"` a thin bar across the top of the viewport
Expand All @@ -62,6 +64,10 @@ const options = {
urlPrefix: "webpack-dev-middleware",
logging: "info",
name: "",
// The secret the endpoint requires, when it requires one. Put on the url
// rather than sent as a header: neither `EventSource` nor `WebSocket` lets a
// page set one.
token: "",
autoConnect: true,
progress: true,
};
Expand Down Expand Up @@ -172,6 +178,7 @@ function setOverrides(overrides) {
// Where the page connects, which may be an absolute url rather than a path
// when the endpoint is on another origin.
if (overrides.path) options.path = overrides.path;
if (overrides.token) options.token = overrides.token;
if (overrides.timeout) {
const timeout = Number(overrides.timeout);

Expand Down Expand Up @@ -274,19 +281,23 @@ function getClient() {
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,
onDisconnect: () => {
sendMessage("Close");
return createSocket(
getClient(),
withToken(/** @type {string} */ (options.path), options.token),
{
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,
onDisconnect: () => {
sendMessage("Close");
},
},
});
);
}

const WRAPPER_KEY = "__wdmEventSourceWrapper";
Expand Down
Loading
Loading