From 53cfd98400de3842a2054a74a57507a430653958 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:46:40 +0000 Subject: [PATCH 1/6] feat(hot): require a secret on the endpoint, with `hot.token` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. `createHot` mints one per run, `injectHotClient` hands it to the client as another entry-query option, and both wires check it with `timingSafeEqual` before anything else — so a caller without one is told nothing about which origins the endpoint would have allowed. A transport of your own is given it too. Driven end to end: SSE, token: true no token 403 wrong 403 right 200 WS, default no token 403 wrong 403 right CONNECTED WS, token: false no token CONNECTED The two transports default as they did for `cors`: `true` for the WebSocket, which is unreleased so nothing is connecting to it that would not be handed one, and `false` for Server-Sent Events, where requiring one would refuse every client already connecting. Two things the implementation had to account for, both found by the browser tests rather than by reasoning: `inject: false` turns the requirement off. The token reaches the browser through the entry this middleware adds, so with nothing injected there is no way to hand one over and requiring it would refuse a correctly wired client. The client needed the option after all. The first attempt folded the token into the `path` query on the assumption that the client uses that verbatim — true for an injected client, useless for a hand-wired entry, which has no `path` parameter and would not know what a bare `token=` meant. It is a client option like the others now, and `hot.client.token` accepts it in node for a client pointed at another endpoint, which keeps the two name sets identical — a test asserts that and caught the asymmetry. What it does not protect, said in the README rather than left implied: 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 the server sends `Cross-Origin-Resource-Policy`. This hardens every case where the bundle is not readable, and is defence in depth where it is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .changeset/hot-token.md | 37 ++++++ .cspell.json | 3 +- README.md | 55 ++++++++ client-src/index.js | 25 +++- src/hot.js | 43 +++++- src/index.js | 10 ++ src/options.check.js | 2 +- src/options.json | 17 +++ src/servers/WebSocketServer.js | 20 ++- src/utils.js | 122 +++++++++++++++++- .../validation-options.test.js.snap.webpack5 | 14 +- test/helpers/hot-app.js | 29 ++++- test/hot.test.js | 116 ++++++++++++++++- test/inject-client.test.js | 30 +++++ types/client/index.d.ts | 4 + types/hot.d.ts | 12 ++ types/index.d.ts | 4 + types/servers/WebSocketServer.d.ts | 3 + types/utils.d.ts | 29 ++++- 19 files changed, 546 insertions(+), 29 deletions(-) create mode 100644 .changeset/hot-token.md diff --git a/.changeset/hot-token.md b/.changeset/hot-token.md new file mode 100644 index 000000000..ac170a599 --- /dev/null +++ b/.changeset/hot-token.md @@ -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. diff --git a/.cspell.json b/.cspell.json index 3171632a4..098a14d77 100644 --- a/.cspell.json +++ b/.cspell.json @@ -43,7 +43,8 @@ "expressjs", "wildcarded", "jshttp", - "realpath" + "realpath", + "Rsbuild" ], "ignorePaths": [ "CHANGELOG.md", diff --git a/README.md b/README.md index b29686b71..d1fed3788 100644 --- a/README.md +++ b/README.md @@ -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). | @@ -537,6 +538,60 @@ 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" } }); + +app.get("/my-client-config.json", (_req, res) => { + res.json({ token: instance.token }); +}); +``` + +`false` requires none, which is what `'sse'` does today. + #### `hot.inject` Type: `Boolean` diff --git a/client-src/index.js b/client-src/index.js index 5ee4d2a04..d7de5403b 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -45,6 +45,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 @@ -62,6 +63,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, }; @@ -172,6 +177,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); @@ -268,13 +274,30 @@ function getClient() { return options.transport === "ws" ? WebSocketClient : EventSourceClient; } +/** + * The endpoint url, carrying the token when the endpoint requires one. + * + * On the url because neither `EventSource` nor `WebSocket` lets a page set a + * request header, so there is nowhere else to put it. + * @returns {string} the url to connect to + */ +function endpoint() { + const path = /** @type {string} */ (options.path); + + if (!options.token) { + return path; + } + + return `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(options.token)}`; +} + /** * @returns {ReturnType} a socket on the current options */ function createClientSocket() { const isEventSource = options.transport !== "ws"; - return createSocket(getClient(), /** @type {string} */ (options.path), { + return createSocket(getClient(), endpoint(), { 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 diff --git a/src/hot.js b/src/hot.js index 5b782340d..9a8d5d80e 100644 --- a/src/hot.js +++ b/src/hot.js @@ -51,6 +51,7 @@ * @property {StatsOptions=} statsOptions deprecated, removed in the next major release — webpack stats options used when serializing compilation results * @property {boolean=} progress publish compilation progress events to the clients * @property {CorsOption=} cors which origins may reach the endpoint from a page on another one; the local ones by default + * @property {(boolean | string)=} token a secret the injected client carries and the endpoint requires; `true` mints one per run, a string uses that one, `false` requires none. Defaults to `false` on both transports; `true` in the next major release * @property {boolean=} inject add the hot client entry and `HotModuleReplacementPlugin` to the compilation (default `true`); turn it off to wire them yourself * @property {HotClientOptions=} client options handed to the browser runtime through its entry query */ @@ -128,7 +129,7 @@ * built-in two are made of whatever this returns. * @template {EXPECTED_ANY} [TClient=StreamClient] * @callback ClientStreamFactory - * @param {{ path: string, heartbeat: number, cors: CorsOption | undefined }} options the endpoint's path and heartbeat interval, and the origins it is meant to allow + * @param {{ path: string, heartbeat: number, cors: CorsOption | undefined, token: string | false }} options the endpoint's path and heartbeat interval, the origins it is meant to allow, and the token it should require * @param {Logger} logger logger * @returns {ClientStream} client stream */ @@ -141,7 +142,13 @@ // module paths and source frames a failed build reports. Both transports // honour it now, each the only way it can be honoured on that wire: the event // stream withholds the grant, and an upgrade is refused. -const { HOT_DEFAULT_CORS_SSE, applyCors, resolveCors } = require("./utils.js"); +const { + HOT_DEFAULT_CORS_SSE, + applyCors, + isTokenValid, + resolveCors, + resolveToken, +} = require("./utils.js"); const HOT_DEFAULT_PATH = "/__webpack_hmr"; const HOT_DEFAULT_HEARTBEAT = 10 * 1000; @@ -241,9 +248,10 @@ function checkClientStream(stream) { * @param {number} heartbeat heartbeat interval in milliseconds * @param {Logger} logger logger * @param {CorsOption=} cors which origins may read the stream, the local ones by default + * @param {(string | false)=} token the token the endpoint requires, or false for none * @returns {EventStream} event stream */ -function createEventStream(heartbeat, logger, cors) { +function createEventStream(heartbeat, logger, cors, token = false) { const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_SSE); let clientId = 0; /** @type {Map} */ @@ -317,6 +325,17 @@ function createEventStream(heartbeat, logger, cors) { return; } + // Before the stream, and without the CORS grant: a caller that does not + // carry the token is told nothing about who may read this endpoint. + if (!isTokenValid(token, req)) { + logger.warn( + `A request to "${req.url}" was refused: it carried no valid 'token'. The injected client is given one; a client of your own has to pass it, or set 'hot.token' to a value it can use.`, + ); + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Forbidden"); + return; + } + /** @type {Record} */ const headers = { "Content-Type": "text/event-stream;charset=utf-8", @@ -601,6 +620,7 @@ function publishBundles(bundles, previousBundles, eventStream) { * @typedef {object} HotInstance * @property {string} path path the endpoint is served at * @property {("sse" | "ws" | ClientStreamFactory)} transport how events reach the clients + * @property {string | false} token the secret the endpoint requires, or false when it requires none; the injected client is given it * @property {(server: HttpServer) => void} attach answer WebSocket upgrades on this server, a no-op for Server-Sent Events * @property {(req: IncomingMessage, socket: Duplex, head: Buffer) => boolean} handleUpgrade answer one WebSocket upgrade, for a caller that owns the server's `upgrade` event and wants to decide each one; returns false when the request is not the endpoint's, or the transport does not answer upgrades * @property {(fn: (client: EXPECTED_ANY, req: IncomingMessage) => void) => void} onConnect called with each client once it has joined, and the request it joined with, before anything is published to it @@ -622,6 +642,13 @@ function createHot(compiler, userOptions, statsOption) { const transport = options.transport || HOT_DEFAULT_TRANSPORT; const { cors } = options; const { statsOptions } = options; + // `inject: false` turns it off: the token reaches the browser through the + // entry this middleware adds, so with nothing injected there is no way to + // hand one over, and requiring it would refuse a client the developer wired + // correctly. Off by default either way — see `HOT_DEFAULT_TOKEN`. + const token = resolveToken( + options.inject === false ? (options.token ?? false) : options.token, + ); const logger = compiler.getInfrastructureLogger("webpack-dev-middleware"); // TODO in the next major release remove `statsOptions` and this warning @@ -638,7 +665,7 @@ function createHot(compiler, userOptions, statsOption) { if (typeof transport === "function") { eventStream = checkClientStream( - transport({ heartbeat, path, cors }, logger), + transport({ heartbeat, path, cors, token }, logger), ); transportName = "a custom transport"; } else if (transport === "ws") { @@ -648,10 +675,13 @@ function createHot(compiler, userOptions, statsOption) { const createWebSocketStream = require("./servers/WebSocketServer.js"); - eventStream = createWebSocketStream({ heartbeat, path, cors }, logger); + eventStream = createWebSocketStream( + { heartbeat, path, cors, token }, + logger, + ); transportName = "a WebSocket"; } else { - eventStream = createEventStream(heartbeat, logger, cors); + eventStream = createEventStream(heartbeat, logger, cors, token); transportName = "Server-Sent Events"; } @@ -778,6 +808,7 @@ function createHot(compiler, userOptions, statsOption) { return { path, transport, + token, attach(server) { if (closed || !eventStream.attach) { return; diff --git a/src/index.js b/src/index.js index c274ec2ef..483ed9b4b 100644 --- a/src/index.js +++ b/src/index.js @@ -189,6 +189,7 @@ const noop = () => {}; * @property {Attach} attach answer WebSocket upgrades on this server * @property {HandleUpgrade} handleUpgrade answer one WebSocket upgrade, for a server that owns its own `upgrade` event * @property {OnConnect} onConnect called with each client that joins, and the request it joined with + * @property {(string | false | undefined)=} token the secret the hot endpoint requires, for a client of your own to put on the url; false when it requires none, undefined when `hot` is off * @property {Close} close close * @property {Context} context context */ @@ -577,6 +578,9 @@ function wdm(compiler, options = {}, isPlugin = false) { transport: hotOptions.transport || "sse", inject: hotOptions.inject, client: hotOptions.client, + // The one `createHot` minted just above, so the client it injects and + // the endpoint it serves agree. + token: context.hot?.token, }, /** @type {Logger} */ (context.logger), ); @@ -692,6 +696,12 @@ function wdm(compiler, options = {}, isPlugin = false) { } }; + // The secret the hot endpoint requires, for a client of your own: the + // injected one is handed it through its entry query, but anything you wrote + // yourself has to put it on the url. `false` when the endpoint requires + // none, `undefined` when `hot` is off. + instance.token = filledContext.hot ? filledContext.hot.token : undefined; + instance.waitUntilValid = (callback = noop) => { middleware.ready(filledContext, callback); }; diff --git a/src/options.check.js b/src/options.check.js index 06e98fb8d..cd6fa3a5f 100644 --- a/src/options.check.js +++ b/src/options.check.js @@ -2,4 +2,4 @@ // DO NOT MODIFY BY HAND. Run `npm run fix:schema-check` to update. /* eslint-disable */ // @ts-nocheck -"use strict";module.exports = validate10;module.exports.default = validate10;const schema11 = {"definitions":{"CorsOrigin":{"description":"An origin, written as a browser sends it (scheme, host and port, no trailing slash), several of them, a pattern, or a function asked about each.","anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"},{"type":"array","items":{"anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"}]},"minItems":1},{"instanceof":"Function"}]}},"type":"object","properties":{"mimeTypes":{"description":"Allows a user to register custom mime types or extension mappings.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypes","type":"object"},"mimeTypeDefault":{"description":"Allows a user to register a default mime type when we can't determine the content type.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypedefault","type":"string"},"writeToDisk":{"description":"Allows to write generated files on disk.","link":"https://github.com/webpack/webpack-dev-middleware#writetodisk","anyOf":[{"type":"boolean"},{"instanceof":"Function"}]},"methods":{"description":"Allows to pass the list of HTTP request methods accepted by the middleware.","link":"https://github.com/webpack/webpack-dev-middleware#methods","type":"array","items":{"type":"string","minLength":1}},"headers":{"anyOf":[{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"key":{"description":"key of header.","type":"string"},"value":{"description":"value of header.","type":"string"}}},"minItems":1},{"type":"object"},{"instanceof":"Function"}],"description":"Allows to pass custom HTTP headers on each request","link":"https://github.com/webpack/webpack-dev-middleware#headers"},"publicPath":{"description":"The `publicPath` specifies the public URL address of the output files when referenced in a browser.","link":"https://github.com/webpack/webpack-dev-middleware#publicpath","anyOf":[{"enum":["auto"]},{"type":"string"},{"instanceof":"Function"}]},"stats":{"description":"Stats options object or preset name.","link":"https://github.com/webpack/webpack-dev-middleware#stats","anyOf":[{"enum":["none","summary","errors-only","errors-warnings","minimal","normal","detailed","verbose"]},{"type":"boolean"},{"type":"object","additionalProperties":true}]},"serverSideRender":{"description":"Instructs the module to enable or disable the server-side rendering mode.","link":"https://github.com/webpack/webpack-dev-middleware#serversiderender","type":"boolean"},"outputFileSystem":{"description":"Set the default file system which will be used by webpack as primary destination of generated files.","link":"https://github.com/webpack/webpack-dev-middleware#outputfilesystem","type":"object"},"index":{"description":"Allows to serve an index of the directory.","link":"https://github.com/webpack/webpack-dev-middleware#index","anyOf":[{"type":"boolean"},{"type":"string","minLength":1}]},"modifyResponseData":{"description":"Allows to set up a callback to change the response data.","link":"https://github.com/webpack/webpack-dev-middleware#modifyresponsedata","instanceof":"Function"},"etag":{"description":"Enable or disable etag generation.","link":"https://github.com/webpack/webpack-dev-middleware#etag","enum":["weak","strong"]},"lastModified":{"description":"Enable or disable `Last-Modified` header. Uses the file system's last modified value.","link":"https://github.com/webpack/webpack-dev-middleware#lastmodified","type":"boolean"},"cacheControl":{"description":"Enable or disable setting `Cache-Control` response header.","link":"https://github.com/webpack/webpack-dev-middleware#cachecontrol","anyOf":[{"type":"boolean"},{"type":"number"},{"type":"string","minLength":1},{"type":"object","properties":{"maxAge":{"type":"number"},"immutable":{"type":"boolean"}},"additionalProperties":false}]},"cacheImmutable":{"description":"Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets (i.e. asset with a hash in file name like `image.a4c12bde.jpg`).","link":"https://github.com/webpack/webpack-dev-middleware#cacheimmutable","type":"boolean"},"forwardError":{"description":"Enable or disable forwarding errors to next middleware.","link":"https://github.com/webpack/webpack-dev-middleware#forwarderrors","type":"boolean"},"hot":{"description":"Enable hot module replacement over a Server-Sent Events or WebSocket endpoint.","link":"https://github.com/webpack/webpack-dev-middleware#hot","anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":false,"properties":{"transport":{"description":"How events reach the clients: `sse`, `ws` (needs the optional `ws` dependency and an HTTP server to answer upgrades on, given as `server` or through the middleware's `attach` method), or a function building a transport of your own.","anyOf":[{"enum":["sse","ws"]},{"instanceof":"Function"}]},"path":{"description":"The path the endpoint is served at. Must start with a slash and carry no query string or fragment.","type":"string","pattern":"^/[^?#]*$"},"heartbeat":{"description":"Heartbeat interval (in milliseconds) used to keep the connection alive.","type":"number","minimum":1},"server":{"description":"HTTP server the `ws` transport answers upgrades on, when it is already built. Otherwise hand it over later with the middleware's `attach` method.","type":"object","additionalProperties":true},"progress":{"description":"Publish compilation progress events to the clients.","type":"boolean"},"cors":{"description":"Which origins may read the Server-Sent Events endpoint from a page on another one. Local origins only by default. `true` grants every origin, which lets any site the developer has open read the build's errors, including the source frames webpack puts in them.","link":"https://github.com/webpack/webpack-dev-middleware#hotcors","anyOf":[{"type":"boolean"},{"$ref":"#/definitions/CorsOrigin"},{"type":"object","additionalProperties":false,"properties":{"origin":{"description":"Which origins may read it, as Vite and `expressjs/cors` are configured.","anyOf":[{"type":"boolean"},{"$ref":"#/definitions/CorsOrigin"}]}}}]},"statsOptions":{"description":"Deprecated, do not use, will be removed in the next major release. Use the `stats` option instead, which decides whether a payload carries errors and warnings.","type":"object","additionalProperties":true},"inject":{"description":"Add the hot client entry and HotModuleReplacementPlugin to the compilation. Turn it off to wire them yourself.","link":"https://github.com/webpack/webpack-dev-middleware#hot","type":"boolean"},"client":{"description":"Options handed to the browser runtime through its entry query.","link":"https://github.com/webpack/webpack-dev-middleware#hot","type":"object","additionalProperties":false,"properties":{"transport":{"description":"Which transport the runtime speaks: `sse` or `ws`. Defaults to the resolved `hot.transport`.","enum":["sse","ws"]},"path":{"description":"Where the runtime connects. Defaults to the resolved `hot.path`; may be an absolute url for an endpoint reached on another origin or through a proxy.","type":"string","minLength":1},"name":{"description":"Limit the runtime to one compilation's builds. Defaults to the compilation's own name.","type":"string"},"overlay":{"description":"Show build problems and uncaught runtime errors in an overlay.","anyOf":[{"type":"boolean"},{"type":"object"}]},"progress":{"description":"Show an indicator while a rebuild is in progress.","anyOf":[{"type":"boolean"},{"enum":["circular","linear"]}]},"hot":{"description":"Apply a build through Hot Module Replacement.","type":"boolean"},"liveReload":{"description":"Reload the page on a build that changed something, when `hot` is off.","type":"boolean"},"reload":{"description":"Reload the page when an update cannot be applied.","type":"boolean"},"urlPrefix":{"description":"Names the page-url parameters that turn `hot` and `liveReload` off for a single page.","type":"string","minLength":1},"logging":{"description":"How much the runtime logs to the browser console.","enum":["none","error","warn","info","log","verbose"]},"reconnect":{"description":"How many times to reconnect before giving up.","type":"number","minimum":0},"timeout":{"description":"How long the runtime tolerates silence before reconnecting, in milliseconds.","type":"number","exclusiveMinimum":0},"autoConnect":{"description":"Connect as soon as the entry runs.","type":"boolean"},"dynamicPublicPath":{"description":"Prefix the endpoint path with the bundle's public path at runtime.","type":"boolean"}}}}}]}},"additionalProperties":false};const schema12 = {"description":"An origin, written as a browser sends it (scheme, host and port, no trailing slash), several of them, a pattern, or a function asked about each.","anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"},{"type":"array","items":{"anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"}]},"minItems":1},{"instanceof":"Function"}]};const func2 = Object.prototype.hasOwnProperty;const pattern0 = new RegExp("^/[^?#]*$", "u");function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){const _errs1 = errors;for(const key0 in data){if(!(func2.call(schema11.properties, key0))){validate10.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs1 === errors){if(data.mimeTypes !== undefined){let data0 = data.mimeTypes;const _errs2 = errors;if(!(data0 && typeof data0 == "object" && !Array.isArray(data0))){validate10.errors = [{instancePath:instancePath+"/mimeTypes",schemaPath:"#/properties/mimeTypes/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid0 = _errs2 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypeDefault !== undefined){const _errs5 = errors;if(typeof data.mimeTypeDefault !== "string"){validate10.errors = [{instancePath:instancePath+"/mimeTypeDefault",schemaPath:"#/properties/mimeTypeDefault/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs5 === errors;}else {var valid0 = true;}if(valid0){if(data.writeToDisk !== undefined){let data2 = data.writeToDisk;const _errs8 = errors;const _errs9 = errors;let valid1 = false;const _errs10 = errors;if(typeof data2 !== "boolean"){const err0 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}var _valid0 = _errs10 === errors;valid1 = valid1 || _valid0;if(!valid1){const _errs12 = errors;if(!(data2 instanceof Function)){const err1 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}var _valid0 = _errs12 === errors;valid1 = valid1 || _valid0;}if(!valid1){const err2 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs9;if(vErrors !== null){if(_errs9){vErrors.length = _errs9;}else {vErrors = null;}}}var valid0 = _errs8 === errors;}else {var valid0 = true;}if(valid0){if(data.methods !== undefined){let data3 = data.methods;const _errs14 = errors;if(errors === _errs14){if(Array.isArray(data3)){var valid2 = true;const len0 = data3.length;for(let i0=0; i0=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}else {const err41 = {instancePath:instancePath+"/hot/heartbeat",schemaPath:"#/properties/hot/anyOf/1/properties/heartbeat/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}var valid12 = _errs105 === errors;}else {var valid12 = true;}if(valid12){if(data22.server !== undefined){let data26 = data22.server;const _errs107 = errors;if(errors === _errs107){if(data26 && typeof data26 == "object" && !Array.isArray(data26)){}else {const err42 = {instancePath:instancePath+"/hot/server",schemaPath:"#/properties/hot/anyOf/1/properties/server/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}var valid12 = _errs107 === errors;}else {var valid12 = true;}if(valid12){if(data22.progress !== undefined){const _errs110 = errors;if(typeof data22.progress !== "boolean"){const err43 = {instancePath:instancePath+"/hot/progress",schemaPath:"#/properties/hot/anyOf/1/properties/progress/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}var valid12 = _errs110 === errors;}else {var valid12 = true;}if(valid12){if(data22.cors !== undefined){let data28 = data22.cors;const _errs112 = errors;const _errs113 = errors;let valid14 = false;const _errs114 = errors;if(typeof data28 !== "boolean"){const err44 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/properties/hot/anyOf/1/properties/cors/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}var _valid8 = _errs114 === errors;valid14 = valid14 || _valid8;if(!valid14){const _errs116 = errors;const _errs118 = errors;let valid16 = false;const _errs119 = errors;if(errors === _errs119){if(typeof data28 === "string"){if(data28.length < 1){const err45 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}else {const err46 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}var _valid9 = _errs119 === errors;valid16 = valid16 || _valid9;if(!valid16){const _errs121 = errors;if(!(data28 instanceof RegExp)){const err47 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}var _valid9 = _errs121 === errors;valid16 = valid16 || _valid9;if(!valid16){const _errs122 = errors;if(errors === _errs122){if(Array.isArray(data28)){if(data28.length < 1){const err48 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/2/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}else {var valid17 = true;const len2 = data28.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}else {const err92 = {instancePath:instancePath+"/hot/client/reconnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/reconnect/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}}var valid25 = _errs187 === errors;}else {var valid25 = true;}if(valid25){if(data34.timeout !== undefined){let data46 = data34.timeout;const _errs189 = errors;if(errors === _errs189){if(typeof data46 == "number"){if(data46 <= 0 || isNaN(data46)){const err93 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/timeout/exclusiveMinimum",keyword:"exclusiveMinimum",params:{comparison: ">", limit: 0},message:"must be > 0"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}}else {const err94 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/timeout/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}}var valid25 = _errs189 === errors;}else {var valid25 = true;}if(valid25){if(data34.autoConnect !== undefined){const _errs191 = errors;if(typeof data34.autoConnect !== "boolean"){const err95 = {instancePath:instancePath+"/hot/client/autoConnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/autoConnect/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}var valid25 = _errs191 === errors;}else {var valid25 = true;}if(valid25){if(data34.dynamicPublicPath !== undefined){const _errs193 = errors;if(typeof data34.dynamicPublicPath !== "boolean"){const err96 = {instancePath:instancePath+"/hot/client/dynamicPublicPath",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/dynamicPublicPath/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}var valid25 = _errs193 === errors;}else {var valid25 = true;}}}}}}}}}}}}}}}}else {const err97 = {instancePath:instancePath+"/hot/client",schemaPath:"#/properties/hot/anyOf/1/properties/client/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}}var valid12 = _errs158 === errors;}else {var valid12 = true;}}}}}}}}}}}else {const err98 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}var _valid6 = _errs96 === errors;valid11 = valid11 || _valid6;}if(!valid11){const err99 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs93;if(vErrors !== null){if(_errs93){vErrors.length = _errs93;}else {vErrors = null;}}}var valid0 = _errs92 === errors;}else {var valid0 = true;}}}}}}}}}}}}}}}}}}}else {validate10.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate10.errors = vErrors;return errors === 0;} \ No newline at end of file +"use strict";module.exports = validate10;module.exports.default = validate10;const schema11 = {"definitions":{"CorsOrigin":{"description":"An origin, written as a browser sends it (scheme, host and port, no trailing slash), several of them, a pattern, or a function asked about each.","anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"},{"type":"array","items":{"anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"}]},"minItems":1},{"instanceof":"Function"}]}},"type":"object","properties":{"mimeTypes":{"description":"Allows a user to register custom mime types or extension mappings.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypes","type":"object"},"mimeTypeDefault":{"description":"Allows a user to register a default mime type when we can't determine the content type.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypedefault","type":"string"},"writeToDisk":{"description":"Allows to write generated files on disk.","link":"https://github.com/webpack/webpack-dev-middleware#writetodisk","anyOf":[{"type":"boolean"},{"instanceof":"Function"}]},"methods":{"description":"Allows to pass the list of HTTP request methods accepted by the middleware.","link":"https://github.com/webpack/webpack-dev-middleware#methods","type":"array","items":{"type":"string","minLength":1}},"headers":{"anyOf":[{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"key":{"description":"key of header.","type":"string"},"value":{"description":"value of header.","type":"string"}}},"minItems":1},{"type":"object"},{"instanceof":"Function"}],"description":"Allows to pass custom HTTP headers on each request","link":"https://github.com/webpack/webpack-dev-middleware#headers"},"publicPath":{"description":"The `publicPath` specifies the public URL address of the output files when referenced in a browser.","link":"https://github.com/webpack/webpack-dev-middleware#publicpath","anyOf":[{"enum":["auto"]},{"type":"string"},{"instanceof":"Function"}]},"stats":{"description":"Stats options object or preset name.","link":"https://github.com/webpack/webpack-dev-middleware#stats","anyOf":[{"enum":["none","summary","errors-only","errors-warnings","minimal","normal","detailed","verbose"]},{"type":"boolean"},{"type":"object","additionalProperties":true}]},"serverSideRender":{"description":"Instructs the module to enable or disable the server-side rendering mode.","link":"https://github.com/webpack/webpack-dev-middleware#serversiderender","type":"boolean"},"outputFileSystem":{"description":"Set the default file system which will be used by webpack as primary destination of generated files.","link":"https://github.com/webpack/webpack-dev-middleware#outputfilesystem","type":"object"},"index":{"description":"Allows to serve an index of the directory.","link":"https://github.com/webpack/webpack-dev-middleware#index","anyOf":[{"type":"boolean"},{"type":"string","minLength":1}]},"modifyResponseData":{"description":"Allows to set up a callback to change the response data.","link":"https://github.com/webpack/webpack-dev-middleware#modifyresponsedata","instanceof":"Function"},"etag":{"description":"Enable or disable etag generation.","link":"https://github.com/webpack/webpack-dev-middleware#etag","enum":["weak","strong"]},"lastModified":{"description":"Enable or disable `Last-Modified` header. Uses the file system's last modified value.","link":"https://github.com/webpack/webpack-dev-middleware#lastmodified","type":"boolean"},"cacheControl":{"description":"Enable or disable setting `Cache-Control` response header.","link":"https://github.com/webpack/webpack-dev-middleware#cachecontrol","anyOf":[{"type":"boolean"},{"type":"number"},{"type":"string","minLength":1},{"type":"object","properties":{"maxAge":{"type":"number"},"immutable":{"type":"boolean"}},"additionalProperties":false}]},"cacheImmutable":{"description":"Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets (i.e. asset with a hash in file name like `image.a4c12bde.jpg`).","link":"https://github.com/webpack/webpack-dev-middleware#cacheimmutable","type":"boolean"},"forwardError":{"description":"Enable or disable forwarding errors to next middleware.","link":"https://github.com/webpack/webpack-dev-middleware#forwarderrors","type":"boolean"},"hot":{"description":"Enable hot module replacement over a Server-Sent Events or WebSocket endpoint.","link":"https://github.com/webpack/webpack-dev-middleware#hot","anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":false,"properties":{"transport":{"description":"How events reach the clients: `sse`, `ws` (needs the optional `ws` dependency and an HTTP server to answer upgrades on, given as `server` or through the middleware's `attach` method), or a function building a transport of your own.","anyOf":[{"enum":["sse","ws"]},{"instanceof":"Function"}]},"path":{"description":"The path the endpoint is served at. Must start with a slash and carry no query string or fragment.","type":"string","pattern":"^/[^?#]*$"},"heartbeat":{"description":"Heartbeat interval (in milliseconds) used to keep the connection alive.","type":"number","minimum":1},"server":{"description":"HTTP server the `ws` transport answers upgrades on, when it is already built. Otherwise hand it over later with the middleware's `attach` method.","type":"object","additionalProperties":true},"progress":{"description":"Publish compilation progress events to the clients.","type":"boolean"},"token":{"description":"A secret the injected client carries and the endpoint requires, so reaching the stream takes something a page has to have been given rather than a header the browser may not send. `true` mints one per run, a string uses that one — for a client of your own that has to build the url itself — and `false` requires none. Defaults to `false`, because requiring one would refuse a client this middleware did not inject; `true` in the next major release.","link":"https://github.com/webpack/webpack-dev-middleware#hottoken","anyOf":[{"type":"boolean"},{"type":"string","minLength":1}]},"cors":{"description":"Which origins may read the Server-Sent Events endpoint from a page on another one. Local origins only by default. `true` grants every origin, which lets any site the developer has open read the build's errors, including the source frames webpack puts in them.","link":"https://github.com/webpack/webpack-dev-middleware#hotcors","anyOf":[{"type":"boolean"},{"$ref":"#/definitions/CorsOrigin"},{"type":"object","additionalProperties":false,"properties":{"origin":{"description":"Which origins may read it, as Vite and `expressjs/cors` are configured.","anyOf":[{"type":"boolean"},{"$ref":"#/definitions/CorsOrigin"}]}}}]},"statsOptions":{"description":"Deprecated, do not use, will be removed in the next major release. Use the `stats` option instead, which decides whether a payload carries errors and warnings.","type":"object","additionalProperties":true},"inject":{"description":"Add the hot client entry and HotModuleReplacementPlugin to the compilation. Turn it off to wire them yourself.","link":"https://github.com/webpack/webpack-dev-middleware#hot","type":"boolean"},"client":{"description":"Options handed to the browser runtime through its entry query.","link":"https://github.com/webpack/webpack-dev-middleware#hot","type":"object","additionalProperties":false,"properties":{"transport":{"description":"Which transport the runtime speaks: `sse` or `ws`. Defaults to the resolved `hot.transport`.","enum":["sse","ws"]},"path":{"description":"Where the runtime connects. Defaults to the resolved `hot.path`; may be an absolute url for an endpoint reached on another origin or through a proxy.","type":"string","minLength":1},"name":{"description":"Limit the runtime to one compilation's builds. Defaults to the compilation's own name.","type":"string"},"token":{"description":"The secret the runtime puts on its connection url. The middleware sets this to whatever 'hot.token' resolved to, so it only needs setting for a client pointed at another endpoint that requires a different one.","type":"string"},"overlay":{"description":"Show build problems and uncaught runtime errors in an overlay.","anyOf":[{"type":"boolean"},{"type":"object"}]},"progress":{"description":"Show an indicator while a rebuild is in progress.","anyOf":[{"type":"boolean"},{"enum":["circular","linear"]}]},"hot":{"description":"Apply a build through Hot Module Replacement.","type":"boolean"},"liveReload":{"description":"Reload the page on a build that changed something, when `hot` is off.","type":"boolean"},"reload":{"description":"Reload the page when an update cannot be applied.","type":"boolean"},"urlPrefix":{"description":"Names the page-url parameters that turn `hot` and `liveReload` off for a single page.","type":"string","minLength":1},"logging":{"description":"How much the runtime logs to the browser console.","enum":["none","error","warn","info","log","verbose"]},"reconnect":{"description":"How many times to reconnect before giving up.","type":"number","minimum":0},"timeout":{"description":"How long the runtime tolerates silence before reconnecting, in milliseconds.","type":"number","exclusiveMinimum":0},"autoConnect":{"description":"Connect as soon as the entry runs.","type":"boolean"},"dynamicPublicPath":{"description":"Prefix the endpoint path with the bundle's public path at runtime.","type":"boolean"}}}}}]}},"additionalProperties":false};const schema12 = {"description":"An origin, written as a browser sends it (scheme, host and port, no trailing slash), several of them, a pattern, or a function asked about each.","anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"},{"type":"array","items":{"anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"}]},"minItems":1},{"instanceof":"Function"}]};const func2 = Object.prototype.hasOwnProperty;const pattern0 = new RegExp("^/[^?#]*$", "u");function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){const _errs1 = errors;for(const key0 in data){if(!(func2.call(schema11.properties, key0))){validate10.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs1 === errors){if(data.mimeTypes !== undefined){let data0 = data.mimeTypes;const _errs2 = errors;if(!(data0 && typeof data0 == "object" && !Array.isArray(data0))){validate10.errors = [{instancePath:instancePath+"/mimeTypes",schemaPath:"#/properties/mimeTypes/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid0 = _errs2 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypeDefault !== undefined){const _errs5 = errors;if(typeof data.mimeTypeDefault !== "string"){validate10.errors = [{instancePath:instancePath+"/mimeTypeDefault",schemaPath:"#/properties/mimeTypeDefault/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs5 === errors;}else {var valid0 = true;}if(valid0){if(data.writeToDisk !== undefined){let data2 = data.writeToDisk;const _errs8 = errors;const _errs9 = errors;let valid1 = false;const _errs10 = errors;if(typeof data2 !== "boolean"){const err0 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}var _valid0 = _errs10 === errors;valid1 = valid1 || _valid0;if(!valid1){const _errs12 = errors;if(!(data2 instanceof Function)){const err1 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}var _valid0 = _errs12 === errors;valid1 = valid1 || _valid0;}if(!valid1){const err2 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs9;if(vErrors !== null){if(_errs9){vErrors.length = _errs9;}else {vErrors = null;}}}var valid0 = _errs8 === errors;}else {var valid0 = true;}if(valid0){if(data.methods !== undefined){let data3 = data.methods;const _errs14 = errors;if(errors === _errs14){if(Array.isArray(data3)){var valid2 = true;const len0 = data3.length;for(let i0=0; i0=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}else {const err41 = {instancePath:instancePath+"/hot/heartbeat",schemaPath:"#/properties/hot/anyOf/1/properties/heartbeat/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}var valid12 = _errs105 === errors;}else {var valid12 = true;}if(valid12){if(data22.server !== undefined){let data26 = data22.server;const _errs107 = errors;if(errors === _errs107){if(data26 && typeof data26 == "object" && !Array.isArray(data26)){}else {const err42 = {instancePath:instancePath+"/hot/server",schemaPath:"#/properties/hot/anyOf/1/properties/server/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}var valid12 = _errs107 === errors;}else {var valid12 = true;}if(valid12){if(data22.progress !== undefined){const _errs110 = errors;if(typeof data22.progress !== "boolean"){const err43 = {instancePath:instancePath+"/hot/progress",schemaPath:"#/properties/hot/anyOf/1/properties/progress/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}var valid12 = _errs110 === errors;}else {var valid12 = true;}if(valid12){if(data22.token !== undefined){let data28 = data22.token;const _errs112 = errors;const _errs113 = errors;let valid14 = false;const _errs114 = errors;if(typeof data28 !== "boolean"){const err44 = {instancePath:instancePath+"/hot/token",schemaPath:"#/properties/hot/anyOf/1/properties/token/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}var _valid8 = _errs114 === errors;valid14 = valid14 || _valid8;if(!valid14){const _errs116 = errors;if(errors === _errs116){if(typeof data28 === "string"){if(data28.length < 1){const err45 = {instancePath:instancePath+"/hot/token",schemaPath:"#/properties/hot/anyOf/1/properties/token/anyOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}else {const err46 = {instancePath:instancePath+"/hot/token",schemaPath:"#/properties/hot/anyOf/1/properties/token/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}var _valid8 = _errs116 === errors;valid14 = valid14 || _valid8;}if(!valid14){const err47 = {instancePath:instancePath+"/hot/token",schemaPath:"#/properties/hot/anyOf/1/properties/token/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}else {errors = _errs113;if(vErrors !== null){if(_errs113){vErrors.length = _errs113;}else {vErrors = null;}}}var valid12 = _errs112 === errors;}else {var valid12 = true;}if(valid12){if(data22.cors !== undefined){let data29 = data22.cors;const _errs119 = errors;const _errs120 = errors;let valid15 = false;const _errs121 = errors;if(typeof data29 !== "boolean"){const err48 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/properties/hot/anyOf/1/properties/cors/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}var _valid9 = _errs121 === errors;valid15 = valid15 || _valid9;if(!valid15){const _errs123 = errors;const _errs125 = errors;let valid17 = false;const _errs126 = errors;if(errors === _errs126){if(typeof data29 === "string"){if(data29.length < 1){const err49 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}var _valid10 = _errs126 === errors;valid17 = valid17 || _valid10;if(!valid17){const _errs128 = errors;if(!(data29 instanceof RegExp)){const err51 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}var _valid10 = _errs128 === errors;valid17 = valid17 || _valid10;if(!valid17){const _errs129 = errors;if(errors === _errs129){if(Array.isArray(data29)){if(data29.length < 1){const err52 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/2/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}else {var valid18 = true;const len2 = data29.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}}else {const err97 = {instancePath:instancePath+"/hot/client/reconnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/reconnect/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}}var valid26 = _errs196 === errors;}else {var valid26 = true;}if(valid26){if(data35.timeout !== undefined){let data48 = data35.timeout;const _errs198 = errors;if(errors === _errs198){if(typeof data48 == "number"){if(data48 <= 0 || isNaN(data48)){const err98 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/timeout/exclusiveMinimum",keyword:"exclusiveMinimum",params:{comparison: ">", limit: 0},message:"must be > 0"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}else {const err99 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/timeout/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}}var valid26 = _errs198 === errors;}else {var valid26 = true;}if(valid26){if(data35.autoConnect !== undefined){const _errs200 = errors;if(typeof data35.autoConnect !== "boolean"){const err100 = {instancePath:instancePath+"/hot/client/autoConnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/autoConnect/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}var valid26 = _errs200 === errors;}else {var valid26 = true;}if(valid26){if(data35.dynamicPublicPath !== undefined){const _errs202 = errors;if(typeof data35.dynamicPublicPath !== "boolean"){const err101 = {instancePath:instancePath+"/hot/client/dynamicPublicPath",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/dynamicPublicPath/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}var valid26 = _errs202 === errors;}else {var valid26 = true;}}}}}}}}}}}}}}}}}else {const err102 = {instancePath:instancePath+"/hot/client",schemaPath:"#/properties/hot/anyOf/1/properties/client/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}var valid12 = _errs165 === errors;}else {var valid12 = true;}}}}}}}}}}}}else {const err103 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}}var _valid6 = _errs96 === errors;valid11 = valid11 || _valid6;}if(!valid11){const err104 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs93;if(vErrors !== null){if(_errs93){vErrors.length = _errs93;}else {vErrors = null;}}}var valid0 = _errs92 === errors;}else {var valid0 = true;}}}}}}}}}}}}}}}}}}}else {validate10.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate10.errors = vErrors;return errors === 0;} \ No newline at end of file diff --git a/src/options.json b/src/options.json index 77c7d63c0..e8ec9a4ed 100644 --- a/src/options.json +++ b/src/options.json @@ -251,6 +251,19 @@ "description": "Publish compilation progress events to the clients.", "type": "boolean" }, + "token": { + "description": "A secret the injected client carries and the endpoint requires, so reaching the stream takes something a page has to have been given rather than a header the browser may not send. `true` mints one per run, a string uses that one — for a client of your own that has to build the url itself — and `false` requires none. Defaults to `false`, because requiring one would refuse a client this middleware did not inject; `true` in the next major release.", + "link": "https://github.com/webpack/webpack-dev-middleware#hottoken", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "minLength": 1 + } + ] + }, "cors": { "description": "Which origins may read the Server-Sent Events endpoint from a page on another one. Local origins only by default. `true` grants every origin, which lets any site the developer has open read the build's errors, including the source frames webpack puts in them.", "link": "https://github.com/webpack/webpack-dev-middleware#hotcors", @@ -309,6 +322,10 @@ "description": "Limit the runtime to one compilation's builds. Defaults to the compilation's own name.", "type": "string" }, + "token": { + "description": "The secret the runtime puts on its connection url. The middleware sets this to whatever 'hot.token' resolved to, so it only needs setting for a client pointed at another endpoint that requires a different one.", + "type": "string" + }, "overlay": { "description": "Show build problems and uncaught runtime errors in an overlay.", "anyOf": [ diff --git a/src/servers/WebSocketServer.js b/src/servers/WebSocketServer.js index 1d006dd27..0e09ccbac 100644 --- a/src/servers/WebSocketServer.js +++ b/src/servers/WebSocketServer.js @@ -10,6 +10,7 @@ const { HOT_DEFAULT_CORS_WS, + isTokenValid, isUpgradeAllowed, resolveCors, } = require("../utils.js"); @@ -41,10 +42,14 @@ function requireWsServer() { * @param {string} options.path the path the endpoint is served at * @param {number} options.heartbeat heartbeat interval in milliseconds * @param {CorsOption=} options.cors which origins may connect, the local ones by default + * @param {(string | false)=} options.token the token the endpoint requires, or false for none * @param {Logger} logger logger * @returns {ClientStream} client stream */ -function createWebSocketStream({ path, heartbeat, cors }, logger) { +function createWebSocketStream( + { path, heartbeat, cors, token = false }, + logger, +) { const WebSocketServerImplementation = requireWsServer(); const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_WS); /** @type {Set} */ @@ -173,6 +178,19 @@ function createWebSocketStream({ path, heartbeat, cors }, logger) { // attention to what comes back, so the `cors` option can only be honoured // on this wire by refusing the upgrade — before it completes, rather than // closing the client afterwards, so nothing is ever published to it. + if (!isTokenValid(token, req)) { + logger.warn( + `An upgrade to "${req.url}" was refused: it carried no valid 'token'. The injected client is given one; a client of your own has to pass it, or set 'hot.token' to a value it can use.`, + ); + + socket.write( + "HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + ); + socket.destroy(); + + return true; + } + if (!isUpgradeAllowed(corsGrant, req)) { logger.warn( `A client from the origin "${req.headers.origin}" was refused. Add it to the 'hot.cors' option to allow it.`, diff --git a/src/utils.js b/src/utils.js index 53de697e3..64100b658 100644 --- a/src/utils.js +++ b/src/utils.js @@ -748,6 +748,38 @@ const HOT_DEFAULT_CORS_SSE = true; // keep and it starts where the other one is going. const HOT_DEFAULT_CORS_WS = CORS_LOCAL_ORIGINS; +// A secret the injected client carries and the endpoint requires, so reaching +// the stream takes something a page has to have been given rather than a +// header a browser may or may not send. +// +// Why a secret and not a better header check: `cors` is answered by `Origin`, +// and the `Origin`/`Sec-Fetch-*` family is absent entirely when the +// destination is not potentially trustworthy — plain `http` to anything but +// `localhost`. webpack-dev-server shipped two fixes built on those headers and +// both were bypassed that way (CVE-2026-6402, then CVE-2026-14620). A token +// does not ask the browser to volunteer anything. +// +// What it does not do: the client reads it from its entry query, so it is a +// string in the bundle. An attacker who can already read the bundle +// cross-origin — the same plain-`http`-to-a-LAN-address case, where nothing +// sets `Cross-Origin-Resource-Policy` — reads the token with it. Closing that +// needs the response header and a host allowlist, which belong to whoever owns +// the server. This hardens every case where the bundle is not readable, and is +// defence in depth in the case where it is. +// +// Off by default on BOTH transports, because requiring one by default breaks +// a client the middleware did not inject — and `inject` being on does not mean +// a client was injected. An entry is skipped when every entry point already +// pulls the client in (the developer wired it themselves, which the README +// documents), when `hot.transport` is a function, and for a non-web target. +// In each of those the endpoint would demand a token nothing had been given, +// and every client would be refused with a `403`. +// +// TODO in the next major release default both to `true`, alongside the `cors` +// default above, and hand the token to a client the middleware did not inject +// some way that does not depend on the entry query. +const HOT_DEFAULT_TOKEN = false; + /** * The resolved answer to "may this origin read the stream": no origin may, any * origin may, or ask this. @@ -928,6 +960,69 @@ function isUpgradeAllowed(grant, req) { return grant === "*" || grant(origin); } +/** + * The token the endpoint will require, if any. + * @param {boolean | string | undefined} option the `hot.token` option + * @returns {string | false} the token, or false when the endpoint requires none + */ +function resolveToken(option) { + // A token of your own, for a consumer that has to be able to construct the + // url without being handed one — a script, or a client you wrote. + if (typeof option === "string") { + return option.length > 0 ? option : false; + } + + const wanted = option ?? HOT_DEFAULT_TOKEN; + + if (!wanted) { + return false; + } + + // 9 bytes rather than a round 8 or 16: `base64url` encodes it without + // padding, so the query carries 12 characters and no `=`. The same size Vite + // and Rsbuild use for theirs. + return crypto.randomBytes(9).toString("base64url"); +} + +/** + * Does the request carry the token the endpoint requires? + * + * Compared in constant time. The comparison is not a plausible oracle — a + * token lives for one run of one dev server — but a length-dependent early + * return would be the kind of thing a reader has to reason about, and + * `timingSafeEqual` costs nothing here. + * @param {string | false} expected the resolved token, or false when none is required + * @param {IncomingMessage} req the request + * @returns {boolean} true when the request may proceed + */ +function isTokenValid(expected, req) { + if (expected === false) { + return true; + } + + let given; + + try { + given = new URL( + /** @type {string} */ (req.url), + "http://localhost", + ).searchParams.get("token"); + } catch { + return false; + } + + if (typeof given !== "string") { + return false; + } + + const a = Buffer.from(given); + const b = Buffer.from(expected); + + // `timingSafeEqual` throws on a length mismatch rather than returning false, + // and the length of the expected token is not a secret. + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + // -------------------------------------------------------------------------- // Media types // @@ -1413,8 +1508,11 @@ function clientQuery(client) { * The client is given the endpoint, the transport and the browser options * through its resource query, so it agrees with the server by construction * rather than by the developer keeping two settings in step. + */ + +/** * @param {Compiler[]} compilers compilers to modify - * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions }} options resolved hot options + * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions, token?: string | false }} options resolved hot options * @param {Logger} logger logger */ function injectHotClient(compilers, options, logger) { @@ -1423,6 +1521,9 @@ function injectHotClient(compilers, options, logger) { } let warned = false; + // A token only reaches the browser on the entry added below, so a required + // one with nothing added would refuse every client. + let injected = false; // What the developer set in node, which wins over everything below it: these // are the same options the query carries, so either spelling reaches the @@ -1482,6 +1583,10 @@ function injectHotClient(compilers, options, logger) { /** @type {Record} */ const query = { path: options.path, transport }; + if (options.token) { + query.token = options.token; + } + if (compilation) { query.name = compilation; } @@ -1489,6 +1594,8 @@ function injectHotClient(compilers, options, logger) { const search = new URLSearchParams({ ...query, ...client }).toString(); const entry = `${clientEntry()}?${search}`; + injected = true; + if (missing === null) { // No entry point has one, so a single entry every one of them gets. new webpack.EntryPlugin(compiler.context, entry, { @@ -1519,12 +1626,23 @@ function injectHotClient(compilers, options, logger) { new webpack.HotModuleReplacementPlugin().apply(compiler); } } + + // Every path above can decline to add an entry — every entry point already + // pulls the client in, `hot.transport` is a function, the target is not the + // web — and the endpoint still requires whatever token it was given. Said + // here rather than left as a `403` with no explanation. + if (options.token && !injected) { + logger.warn( + `'hot.token' requires a token on the endpoint, but no client entry was added to hand one over, so every client will be refused. Put 'token=${options.token}' on the query of the client you added yourself, read it from the middleware's 'token' property, or set 'hot.token: false'.`, + ); + } } module.exports = { CORS_LOCAL_ORIGINS, HOT_DEFAULT_CORS_SSE, HOT_DEFAULT_CORS_WS, + HOT_DEFAULT_TOKEN, applyCors, clientQuery, createMimeTypes, @@ -1547,6 +1665,7 @@ module.exports = { initState, injectHotClient, isSameOrigin, + isTokenValid, isUpgradeAllowed, isWebTarget, matchOrigin, @@ -1558,6 +1677,7 @@ module.exports = { pipe, removeResponseHeader, resolveCors, + resolveToken, send, setResponseHeader, setState, diff --git a/test/__snapshots__/validation-options.test.js.snap.webpack5 b/test/__snapshots__/validation-options.test.js.snap.webpack5 index 2b25c2fff..57b7ba68a 100644 --- a/test/__snapshots__/validation-options.test.js.snap.webpack5 +++ b/test/__snapshots__/validation-options.test.js.snap.webpack5 @@ -128,7 +128,7 @@ exports[`validation should throw an error on the "hot" option with "{"statsOptio exports[`validation should throw an error on the "hot" option with "{"transport":"websocket"}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot should be one of these: - boolean | object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? } -> Enable hot module replacement over a Server-Sent Events or WebSocket endpoint. -> Read more at https://github.com/webpack/webpack-dev-middleware#hot Details: @@ -144,7 +144,7 @@ exports[`validation should throw an error on the "hot" option with "{"transport" exports[`validation should throw an error on the "hot" option with "{"transport":true}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot should be one of these: - boolean | object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? } -> Enable hot module replacement over a Server-Sent Events or WebSocket endpoint. -> Read more at https://github.com/webpack/webpack-dev-middleware#hot Details: @@ -160,31 +160,31 @@ exports[`validation should throw an error on the "hot" option with "{"transport" exports[`validation should throw an error on the "hot" option with "{"unknown":true}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot has an unknown property 'unknown'. These properties are valid: - object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? }" `; exports[`validation should throw an error on the "hot" option with "0" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot should be one of these: - boolean | object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? } -> Enable hot module replacement over a Server-Sent Events or WebSocket endpoint. -> Read more at https://github.com/webpack/webpack-dev-middleware#hot Details: * options.hot should be a boolean. * options.hot should be an object: - object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? }" `; exports[`validation should throw an error on the "hot" option with "foo" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot should be one of these: - boolean | object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? } -> Enable hot module replacement over a Server-Sent Events or WebSocket endpoint. -> Read more at https://github.com/webpack/webpack-dev-middleware#hot Details: * options.hot should be a boolean. * options.hot should be an object: - object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? }" `; exports[`validation should throw an error on the "index" option with "{}" value 1`] = ` diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js index 700d3d7e9..82a637163 100644 --- a/test/helpers/hot-app.js +++ b/test/helpers/hot-app.js @@ -8,6 +8,13 @@ const webpack = require("webpack"); const middleware = require("../../src"); const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); + +// These fixtures ask for a token, so the e2e runs cover an endpoint that +// requires one. They wire the client entry themselves, which happens before +// the middleware exists, so there is no minted token to put in the query — a +// fixed one is what a developer wiring their own entry has to use, so it is +// what the fixtures use. +const E2E_TOKEN = "e2e-fixed-token"; const CLIENT_SRC = path.join(__dirname, "..", "..", "client-src"); const COLLECT_COVERAGE = Boolean(process.env.E2E_COVERAGE); @@ -63,6 +70,7 @@ function pageHtml(scripts) { * @param {string=} publicPath output public path * @param {boolean=} hmrPlugin include HotModuleReplacementPlugin * @param {boolean=} bare omit the client entry and the plugin, leaving both to the middleware + * @param {string=} token the token a hand-wired client entry has to carry, "" when the middleware injects one * @returns {EXPECTED_ANY} webpack configuration */ function makeConfig( @@ -73,10 +81,14 @@ function makeConfig( publicPath = "/", hmrPlugin = true, bare = false, + token = "", ) { - const clientQuery = name - ? `?name=${name}${query ? `&${query.replace(/^\?/, "")}` : ""}` - : query; + const parts = [ + ...(name ? [`name=${name}`] : []), + ...(query ? [query.replace(/^\?/, "")] : []), + ...(token ? [`token=${token}`] : []), + ]; + const clientQuery = parts.length > 0 ? `?${parts.join("&")}` : ""; return { ...(name ? { name } : {}), @@ -188,6 +200,7 @@ async function createHotApp({ undefined, undefined, bare, + transport === "ws" && !bare ? E2E_TOKEN : "", ); }); scripts = apps.map((app) => `/${app.name}.js`); @@ -202,6 +215,7 @@ async function createHotApp({ publicPath, hmrPlugin, bare, + transport === "ws" && !bare ? E2E_TOKEN : "", ); scripts = [`${publicPath}main.js`]; } @@ -220,7 +234,14 @@ async function createHotApp({ instance = middleware(compiler, { hot: transport === "ws" && hot - ? { ...(hot === true ? {} : hot), transport: "ws" } + ? { + ...(hot === true ? {} : hot), + transport: "ws", + // Fixed, so the entry wired above can carry it. `bare` has the + // middleware inject the client, which would be handed a minted + // one, but one token for both paths keeps the fixtures alike. + token: E2E_TOKEN, + } : hot, stats, }); diff --git a/test/hot.test.js b/test/hot.test.js index 66fef958c..e7400da94 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -1458,7 +1458,12 @@ describe("createHot over a WebSocket", () => { cleanups.push(stop); - return { hot, url: `ws://127.0.0.1:${port}${hot.path}`, stop }; + // The injected client is handed the token through its entry query, so a + // test client carries it the same way whenever one was asked for. The + // tests that are about the token build their own url. + const token = hot.token ? `?token=${encodeURIComponent(hot.token)}` : ""; + + return { hot, url: `ws://127.0.0.1:${port}${hot.path}${token}`, stop }; } /** @@ -1603,10 +1608,10 @@ describe("createHot over a WebSocket", () => { await until(() => joined.length > 0); // Same as the Server-Sent Events stream: the request a client arrived - // with, headers and all, so a caller can judge it. - expect(joined[0].headers.host).toBe( - endpoint.url.replace("ws://", "").replace(endpoint.hot.path, ""), - ); + // with, headers and all, so a caller can judge it. Read off the url rather + // than stripped out of it, so the token query does not end up in the + // expected host. + expect(joined[0].headers.host).toBe(new URL(endpoint.url).host); }); it("publishes nothing to a client a subscriber closed", async () => { @@ -1685,6 +1690,98 @@ describe("createHot over a WebSocket", () => { // subject to CORS, so a browser sends `Origin` and pays no attention to what // comes back. Refused before the handshake completes, so nothing is ever // published to a client that should not have one. + describe("the token", () => { + // Off by default on both transports: a token only reaches the browser on + // the entry the middleware adds, and there are several ways for no entry + // to be added — so requiring one by default would refuse every client of + // a setup that wired itself. + it("is required of nobody by default", async () => { + const endpoint = await serveOverWs(makeFakeCompiler()); + + expect(endpoint.hot.token).toBe(false); + + const { socket } = await connect(endpoint.url); + + expect(socket.readyState).toBe(socket.OPEN); + }); + + it("is minted per run when it is turned on", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: true }); + + expect(typeof endpoint.hot.token).toBe("string"); + expect(/** @type {string} */ (endpoint.hot.token).length).toBeGreaterThan( + 8, + ); + + const second = await serveOverWs(makeFakeCompiler(), { token: true }); + + expect(second.hot.token).not.toBe(endpoint.hot.token); + }); + + it("refuses a handshake that carries none", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: true }); + const withoutToken = endpoint.url.replace(/\?token=.*$/, ""); + + await expect(connect(withoutToken)).rejects.toThrow( + "Unexpected server response: 403", + ); + }); + + it("refuses a handshake that carries the wrong one", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: true }); + const wrong = endpoint.url.replace(/\?token=.*$/, "?token=not-the-token"); + + await expect(connect(wrong)).rejects.toThrow( + "Unexpected server response: 403", + ); + }); + + it("refuses one that is right apart from its length", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: true }); + const truncated = endpoint.url.slice(0, -1); + + await expect(connect(truncated)).rejects.toThrow( + "Unexpected server response: 403", + ); + }); + + it("takes a token of your own, for a client you wrote", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { + token: "a-token-of-my-own", + }); + + expect(endpoint.hot.token).toBe("a-token-of-my-own"); + + const { socket } = await connect(endpoint.url); + + expect(socket.readyState).toBe(socket.OPEN); + }); + + it("requires none when it is turned off", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: false }); + + expect(endpoint.hot.token).toBe(false); + + const { socket } = await connect(endpoint.url); + + expect(socket.readyState).toBe(socket.OPEN); + }); + + // The token is checked before the origin, so a caller without one learns + // nothing about which origins the endpoint would have allowed. + it("is checked before the origin", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { + token: true, + cors: "https://allowed.example", + }); + const withoutToken = endpoint.url.replace(/\?token=.*$/, ""); + + await expect( + connect(withoutToken, { origin: "https://allowed.example" }), + ).rejects.toThrow("Unexpected server response: 403"); + }); + }); + describe("the cross-origin grant", () => { it("refuses an origin the default does not allow", async () => { const endpoint = await serveOverWs(makeFakeCompiler()); @@ -1873,7 +1970,14 @@ describe("createHot over a transport of your own", () => { }); // Resolved, not raw: a transport should not have to re-apply the defaults. - expect(received).toEqual({ heartbeat: 1234, path: "/__custom" }); + // `cors` and `token` come through too, so a transport of your own can + // enforce the same two rules the built-in ones do. + expect(received).toEqual({ + cors: undefined, + heartbeat: 1234, + path: "/__custom", + token: false, + }); hot.close(); }); diff --git a/test/inject-client.test.js b/test/inject-client.test.js index 77ee95569..fbd68ac0e 100644 --- a/test/inject-client.test.js +++ b/test/inject-client.test.js @@ -231,6 +231,36 @@ describe("injectHotClient", () => { ); }); + // The hazard this guards: a required token only reaches the browser on + // the entry added here, so asking for one where no entry is added would + // refuse every client with an unexplained 403. + it("says a token was required that no client was given", () => { + injectHotClient( + [documentedSetup()], + { + path: "/__webpack_hmr", + transport: "sse", + token: "a-token-nobody-gets", + }, + logger, + ); + + expect(warnings.join("\n")).toContain( + "no client entry was added to hand one over", + ); + expect(warnings.join("\n")).toContain("token=a-token-nobody-gets"); + }); + + it("says nothing about a token when the client was injected", () => { + injectHotClient( + [compiler({ entry: "./app.js" })], + { path: "/__webpack_hmr", transport: "sse", token: "handed-over" }, + logger, + ); + + expect(warnings.join("\n")).not.toContain("no client entry was added"); + }); + it("adds the plugin to a project that only had the client entry", () => { // This one was broken before: the client was there, nothing applied the // update, and the runtime said so on every build. diff --git a/types/client/index.d.ts b/types/client/index.d.ts index e508c2146..087763734 100644 --- a/types/client/index.d.ts +++ b/types/client/index.d.ts @@ -117,6 +117,10 @@ export type ClientOptions = { * limit updates to this compilation name */ name: string; + /** + * the secret the endpoint requires, when it requires one, put on the connection url — empty when it requires none + */ + token: string; /** * connect immediately when the entry runs */ diff --git a/types/hot.d.ts b/types/hot.d.ts index 46a00df36..d582831ac 100644 --- a/types/hot.d.ts +++ b/types/hot.d.ts @@ -3,6 +3,7 @@ export = createHot; * @typedef {object} HotInstance * @property {string} path path the endpoint is served at * @property {("sse" | "ws" | ClientStreamFactory)} transport how events reach the clients + * @property {string | false} token the secret the endpoint requires, or false when it requires none; the injected client is given it * @property {(server: HttpServer) => void} attach answer WebSocket upgrades on this server, a no-op for Server-Sent Events * @property {(req: IncomingMessage, socket: Duplex, head: Buffer) => boolean} handleUpgrade answer one WebSocket upgrade, for a caller that owns the server's `upgrade` event and wants to decide each one; returns false when the request is not the endpoint's, or the transport does not answer upgrades * @property {(fn: (client: EXPECTED_ANY, req: IncomingMessage) => void) => void} onConnect called with each client once it has joined, and the request it joined with, before anything is published to it @@ -76,12 +77,14 @@ declare function checkClientStream( * @param {number} heartbeat heartbeat interval in milliseconds * @param {Logger} logger logger * @param {CorsOption=} cors which origins may read the stream, the local ones by default + * @param {(string | false)=} token the token the endpoint requires, or false for none * @returns {EventStream} event stream */ declare function createEventStream( heartbeat: number, logger: Logger, cors?: CorsOption | undefined, + token?: (string | false) | undefined, ): EventStream; /** * @param {(string | StatsError)[]} errors errors or warnings @@ -127,6 +130,10 @@ type HotInstance = { * how events reach the clients */ transport: "sse" | "ws" | ClientStreamFactory; + /** + * the secret the endpoint requires, or false when it requires none; the injected client is given it + */ + token: string | false; /** * answer WebSocket upgrades on this server, a no-op for Server-Sent Events */ @@ -274,6 +281,10 @@ type HotOptions = { * which origins may reach the endpoint from a page on another one; the local ones by default */ cors?: CorsOption | undefined; + /** + * a secret the injected client carries and the endpoint requires; `true` mints one per run, a string uses that one, `false` requires none. Defaults to `false` on both transports; `true` in the next major release + */ + token?: (boolean | string) | undefined; /** * add the hot client entry and `HotModuleReplacementPlugin` to the compilation (default `true`); turn it off to wire them yourself */ @@ -436,6 +447,7 @@ type ClientStreamFactory = ( path: string; heartbeat: number; cors: CorsOption | undefined; + token: string | false; }, logger: Logger, ) => ClientStream; diff --git a/types/index.d.ts b/types/index.d.ts index 19fabb75e..505d11d92 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -388,6 +388,10 @@ type AdditionalMethods< * called with each client that joins, and the request it joined with */ onConnect: OnConnect; + /** + * the secret the hot endpoint requires, for a client of your own to put on the url; false when it requires none, undefined when `hot` is off + */ + token?: (string | false | undefined) | undefined; /** * close */ diff --git a/types/servers/WebSocketServer.d.ts b/types/servers/WebSocketServer.d.ts index 954622926..d52598abb 100644 --- a/types/servers/WebSocketServer.d.ts +++ b/types/servers/WebSocketServer.d.ts @@ -7,6 +7,7 @@ export = createWebSocketStream; * @param {string} options.path the path the endpoint is served at * @param {number} options.heartbeat heartbeat interval in milliseconds * @param {CorsOption=} options.cors which origins may connect, the local ones by default + * @param {(string | false)=} options.token the token the endpoint requires, or false for none * @param {Logger} logger logger * @returns {ClientStream} client stream */ @@ -15,10 +16,12 @@ declare function createWebSocketStream( path, heartbeat, cors, + token, }: { path: string; heartbeat: number; cors?: CorsOption | undefined; + token?: (string | false) | undefined; }, logger: Logger, ): ClientStream; diff --git a/types/utils.d.ts b/types/utils.d.ts index 547faa258..9bbf1f5b5 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -118,6 +118,7 @@ export type MimeDbEntry = { export const CORS_LOCAL_ORIGINS: RegExp; export const HOT_DEFAULT_CORS_SSE: true; export const HOT_DEFAULT_CORS_WS: RegExp; +export const HOT_DEFAULT_TOKEN: false; /** * Add the cross-origin grant the `cors` option asks for, if any. * @@ -340,8 +341,10 @@ export function initState< * The client is given the endpoint, the transport and the browser options * through its resource query, so it agrees with the server by construction * rather than by the developer keeping two settings in step. + */ +/** * @param {Compiler[]} compilers compilers to modify - * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions }} options resolved hot options + * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions, token?: string | false }} options resolved hot options * @param {Logger} logger logger */ export function injectHotClient( @@ -351,6 +354,7 @@ export function injectHotClient( transport: NonNullable; inject?: boolean; client?: HotClientOptions; + token?: string | false; }, logger: Logger, ): void; @@ -366,6 +370,21 @@ export function injectHotClient( * @returns {boolean} true when the two are the same origin */ export function isSameOrigin(req: IncomingMessage, origin: string): boolean; +/** + * Does the request carry the token the endpoint requires? + * + * Compared in constant time. The comparison is not a plausible oracle — a + * token lives for one run of one dev server — but a length-dependent early + * return would be the kind of thing a reader has to reason about, and + * `timingSafeEqual` costs nothing here. + * @param {string | false} expected the resolved token, or false when none is required + * @param {IncomingMessage} req the request + * @returns {boolean} true when the request may proceed + */ +export function isTokenValid( + expected: string | false, + req: IncomingMessage, +): boolean; /** * May this WebSocket handshake go ahead? * @@ -501,6 +520,14 @@ export function removeResponseHeader< * @returns {CorsGrant} the resolved answer */ export function resolveCors(cors: CorsOption): CorsGrant; +/** + * The token the endpoint will require, if any. + * @param {boolean | string | undefined} option the `hot.token` option + * @returns {string | false} the token, or false when the endpoint requires none + */ +export function resolveToken( + option: boolean | string | undefined, +): string | false; /** * @template {ServerResponse & ExpectedServerResponse} Response * @param {Response} res res From bf072ca19c98acdcb7c8cdbf704178f3405a2b7d Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:05:52 +0000 Subject: [PATCH 2/6] refactor(hot): load only the transport that was chosen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createEventStream` moves out of `hot.js` into `src/servers/EventSourceServer.js`, beside the WebSocket one, and both are required where the transport is picked rather than at the top of the module. Neither is reached until a `createHot` call asks for it: transport EventSourceServer WebSocketServer ws "sse" (default) loaded — — "ws" — loaded loaded your own — — — Previously the event stream was parsed by every consumer, including one on a WebSocket or on a transport of its own, because it lived in `hot.js`. `src/servers/` now holds both, which is the shape webpack-dev-server's `lib/servers/` has, and the CORS and token rules sit with the transport that enforces them — `hot.js` keeps only the default each one starts from and the mint that hands a token to both. `createEventStream` is still exported from `hot.js`, as a wrapper that loads the module on the first call, so an importer sees no change. `requireServer` spells each path out rather than building one from its argument: a bundler has to be able to see both statically. Also fixes two fixtures that hand-wire a client and so have to carry a token of their own, which is what a developer wiring their own entry has to do: the worker app, and the cross-origin test that builds its own WebSocket url — that one reads `instance.token` rather than hardcoding one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- src/hot.js | 231 +++++---------------------- src/servers/EventSourceServer.js | 194 ++++++++++++++++++++++ test/e2e/cors.test.js | 9 +- test/e2e/worker.test.js | 14 +- types/hot.d.ts | 7 - types/servers/EventSourceServer.d.ts | 33 ++++ 6 files changed, 285 insertions(+), 203 deletions(-) create mode 100644 src/servers/EventSourceServer.js create mode 100644 types/servers/EventSourceServer.d.ts diff --git a/src/hot.js b/src/hot.js index 9a8d5d80e..3bf591fdc 100644 --- a/src/hot.js +++ b/src/hot.js @@ -142,13 +142,10 @@ // module paths and source frames a failed build reports. Both transports // honour it now, each the only way it can be honoured on that wire: the event // stream withholds the grant, and an upgrade is refused. -const { - HOT_DEFAULT_CORS_SSE, - applyCors, - isTokenValid, - resolveCors, - resolveToken, -} = require("./utils.js"); +// The CORS and token rules themselves live with the transports that enforce +// them, in `./servers`. What is left here is the default each one starts from +// and the mint that hands a token to both. +const { HOT_DEFAULT_CORS_SSE, resolveToken } = require("./utils.js"); const HOT_DEFAULT_PATH = "/__webpack_hmr"; const HOT_DEFAULT_HEARTBEAT = 10 * 1000; @@ -173,6 +170,23 @@ function pathMatch(url, expected) { // What a transport has to do for itself. Missing one of these would throw from // wherever the stream is first published to, which is a long way from the // option that built it. +/** + * Load the module for the transport that was chosen, and only that one. + * + * Neither is reached until a `createHot` call picks it: a project on the + * default Server-Sent Events never parses the WebSocket server or `ws`, one on + * a WebSocket never parses the event stream, and a transport of its own parses + * neither. Spelled out per name rather than built from a variable so the path + * stays statically analysable — a bundler has to be able to see both. + * @param {"EventSourceServer" | "WebSocketServer"} name which server + * @returns {EXPECTED_ANY} its factory + */ +function requireServer(name) { + return name === "WebSocketServer" + ? require("./servers/WebSocketServer.js") + : require("./servers/EventSourceServer.js"); +} + const CLIENT_STREAM_METHODS = ["close", "onConnect", "publish", "publishTo"]; // What it may also do. Absent is fine; present and not a function is not — that @@ -244,183 +258,6 @@ function checkClientStream(stream) { return stream; } -/** - * @param {number} heartbeat heartbeat interval in milliseconds - * @param {Logger} logger logger - * @param {CorsOption=} cors which origins may read the stream, the local ones by default - * @param {(string | false)=} token the token the endpoint requires, or false for none - * @returns {EventStream} event stream - */ -function createEventStream(heartbeat, logger, cors, token = false) { - const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_SSE); - let clientId = 0; - /** @type {Map} */ - let clients = new Map(); - /** @type {((client: StreamClient, req: IncomingMessage) => void) | undefined} */ - let onConnectFn; - - /** - * Run the callback for every client that can still be written to — a - * response ended between two `close` events would throw on write. - * @param {(client: ServerResponse) => void} fn each client callback - */ - const everyClient = (fn) => { - for (const client of clients.values()) { - if (!client.writableEnded) { - fn(client); - } - } - }; - - // Runs only while clients are connected: started with the first client, - // stopped with the last one. - /** @type {ReturnType | null} */ - let interval = null; - - const startHeartbeat = () => { - if (interval !== null) { - return; - } - - interval = setInterval(() => { - everyClient((client) => { - client.write("data: 💓\n\n"); - }); - }, heartbeat); - - // Don't block process exit on the heartbeat timer. - if (typeof interval.unref === "function") { - interval.unref(); - } - }; - - const stopHeartbeat = () => { - if (interval !== null) { - clearInterval(interval); - interval = null; - } - }; - - return { - close() { - stopHeartbeat(); - everyClient((client) => { - client.end(); - }); - clients = new Map(); - }, - hasClients() { - return clients.size > 0; - }, - onConnect(fn) { - onConnectFn = fn; - }, - handler(req, res) { - // A response another middleware already started can no longer become an - // SSE stream — end it instead of crashing on writeHead. - if (res.headersSent) { - if (!res.writableEnded) { - res.end(); - } - return; - } - - // Before the stream, and without the CORS grant: a caller that does not - // carry the token is told nothing about who may read this endpoint. - if (!isTokenValid(token, req)) { - logger.warn( - `A request to "${req.url}" was refused: it carried no valid 'token'. The injected client is given one; a client of your own has to pass it, or set 'hot.token' to a value it can use.`, - ); - res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Forbidden"); - return; - } - - /** @type {Record} */ - const headers = { - "Content-Type": "text/event-stream;charset=utf-8", - "Cache-Control": "no-cache, no-transform", - // While behind nginx, the event stream should not be buffered: - // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering - "X-Accel-Buffering": "no", - }; - - applyCors(corsGrant, req, headers); - - const { httpVersion, socket } = req; - const isHttp1 = !(Number.parseInt(httpVersion, 10) >= 2); - - if (isHttp1) { - if (socket && typeof socket.setKeepAlive === "function") { - socket.setKeepAlive(true); - } - headers.Connection = "keep-alive"; - } - - res.writeHead(200, headers); - res.write("\n"); - - const id = clientId++; - clients.set(id, res); - startHeartbeat(); - logger.log(`Client connected (${clients.size} active)`); - - const disconnect = () => { - if (!clients.has(id)) { - return; - } - - if (!res.writableEnded) { - res.end(); - } - - clients.delete(id); - - if (clients.size === 0) { - stopHeartbeat(); - } - - logger.log(`Client disconnected (${clients.size} active)`); - }; - - req.on("close", disconnect); - - // A request that died before the handshake finished never emits `close` - // again, so it would stay in `clients` forever. - if (req.destroyed) { - disconnect(); - - return; - } - - if (onConnectFn) { - onConnectFn(res, req); - } - }, - publish(payload) { - // With no clients connected there is nothing to serialize for. - if (clients.size === 0) { - return; - } - - const frame = `data: ${JSON.stringify(payload)}\n\n`; - - everyClient((client) => { - client.write(frame); - }); - }, - publishTo(client, payload) { - const res = /** @type {ServerResponse} */ (client); - - if (res.writableEnded) { - return; - } - - res.write(`data: ${JSON.stringify(payload)}\n\n`); - }, - }; -} - /** * @param {(string | StatsError)[]} errors errors or warnings * @returns {string[]} flat strings @@ -669,19 +506,18 @@ function createHot(compiler, userOptions, statsOption) { ); transportName = "a custom transport"; } else if (transport === "ws") { - // Required here rather than at the top: it pulls in `ws`, and the default - // transport is Server-Sent Events, so a project that never asks for a - // WebSocket should not pay to load either. - - const createWebSocketStream = require("./servers/WebSocketServer.js"); - - eventStream = createWebSocketStream( + eventStream = requireServer("WebSocketServer")( { heartbeat, path, cors, token }, logger, ); transportName = "a WebSocket"; } else { - eventStream = createEventStream(heartbeat, logger, cors, token); + eventStream = requireServer("EventSourceServer")( + heartbeat, + logger, + cors, + token, + ); transportName = "Server-Sent Events"; } @@ -870,7 +706,16 @@ module.exports.HOT_DEFAULT_HEARTBEAT = HOT_DEFAULT_HEARTBEAT; module.exports.HOT_DEFAULT_PATH = HOT_DEFAULT_PATH; module.exports.HOT_DEFAULT_TRANSPORT = HOT_DEFAULT_TRANSPORT; module.exports.checkClientStream = checkClientStream; -module.exports.createEventStream = createEventStream; +/** + * Kept as an export, loaded on the first call rather than with this module. + * @param {number} heartbeat heartbeat interval in milliseconds + * @param {Logger} logger logger + * @param {CorsOption=} cors which origins may read the stream + * @param {(string | false)=} token the token the endpoint requires, or false for none + * @returns {EventStream} event stream + */ +module.exports.createEventStream = (heartbeat, logger, cors, token) => + requireServer("EventSourceServer")(heartbeat, logger, cors, token); module.exports.createHot = createHot; module.exports.formatErrors = formatErrors; module.exports.pathMatch = pathMatch; diff --git a/src/servers/EventSourceServer.js b/src/servers/EventSourceServer.js new file mode 100644 index 000000000..1c7a46cbd --- /dev/null +++ b/src/servers/EventSourceServer.js @@ -0,0 +1,194 @@ +/** @typedef {import("node:http").IncomingMessage} IncomingMessage */ +/** @typedef {import("../index.js").ServerResponse} ServerResponse */ +/** @typedef {import("../hot.js").Logger} Logger */ +/** @typedef {import("../hot.js").Payload} Payload */ +/** @typedef {import("../hot.js").EventStream} EventStream */ +/** @typedef {import("../hot.js").StreamClient} StreamClient */ +/** @typedef {import("../hot.js").CorsOption} CorsOption */ + +const { + HOT_DEFAULT_CORS_SSE, + applyCors, + isTokenValid, + resolveCors, +} = require("../utils.js"); + +/** + * @param {number} heartbeat heartbeat interval in milliseconds + * @param {Logger} logger logger + * @param {CorsOption=} cors which origins may read the stream, the local ones by default + * @param {(string | false)=} token the token the endpoint requires, or false for none + * @returns {EventStream} event stream + */ +function createEventStream(heartbeat, logger, cors, token = false) { + const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_SSE); + let clientId = 0; + /** @type {Map} */ + let clients = new Map(); + /** @type {((client: StreamClient, req: IncomingMessage) => void) | undefined} */ + let onConnectFn; + + /** + * Run the callback for every client that can still be written to — a + * response ended between two `close` events would throw on write. + * @param {(client: ServerResponse) => void} fn each client callback + */ + const everyClient = (fn) => { + for (const client of clients.values()) { + if (!client.writableEnded) { + fn(client); + } + } + }; + + // Runs only while clients are connected: started with the first client, + // stopped with the last one. + /** @type {ReturnType | null} */ + let interval = null; + + const startHeartbeat = () => { + if (interval !== null) { + return; + } + + interval = setInterval(() => { + everyClient((client) => { + client.write("data: 💓\n\n"); + }); + }, heartbeat); + + // Don't block process exit on the heartbeat timer. + if (typeof interval.unref === "function") { + interval.unref(); + } + }; + + const stopHeartbeat = () => { + if (interval !== null) { + clearInterval(interval); + interval = null; + } + }; + + return { + close() { + stopHeartbeat(); + everyClient((client) => { + client.end(); + }); + clients = new Map(); + }, + hasClients() { + return clients.size > 0; + }, + onConnect(fn) { + onConnectFn = fn; + }, + handler(req, res) { + // A response another middleware already started can no longer become an + // SSE stream — end it instead of crashing on writeHead. + if (res.headersSent) { + if (!res.writableEnded) { + res.end(); + } + return; + } + + // Before the stream, and without the CORS grant: a caller that does not + // carry the token is told nothing about who may read this endpoint. + if (!isTokenValid(token, req)) { + logger.warn( + `A request to "${req.url}" was refused: it carried no valid 'token'. The injected client is given one; a client of your own has to pass it, or set 'hot.token' to a value it can use.`, + ); + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Forbidden"); + return; + } + + /** @type {Record} */ + const headers = { + "Content-Type": "text/event-stream;charset=utf-8", + "Cache-Control": "no-cache, no-transform", + // While behind nginx, the event stream should not be buffered: + // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering + "X-Accel-Buffering": "no", + }; + + applyCors(corsGrant, req, headers); + + const { httpVersion, socket } = req; + const isHttp1 = !(Number.parseInt(httpVersion, 10) >= 2); + + if (isHttp1) { + if (socket && typeof socket.setKeepAlive === "function") { + socket.setKeepAlive(true); + } + headers.Connection = "keep-alive"; + } + + res.writeHead(200, headers); + res.write("\n"); + + const id = clientId++; + clients.set(id, res); + startHeartbeat(); + logger.log(`Client connected (${clients.size} active)`); + + const disconnect = () => { + if (!clients.has(id)) { + return; + } + + if (!res.writableEnded) { + res.end(); + } + + clients.delete(id); + + if (clients.size === 0) { + stopHeartbeat(); + } + + logger.log(`Client disconnected (${clients.size} active)`); + }; + + req.on("close", disconnect); + + // A request that died before the handshake finished never emits `close` + // again, so it would stay in `clients` forever. + if (req.destroyed) { + disconnect(); + + return; + } + + if (onConnectFn) { + onConnectFn(res, req); + } + }, + publish(payload) { + // With no clients connected there is nothing to serialize for. + if (clients.size === 0) { + return; + } + + const frame = `data: ${JSON.stringify(payload)}\n\n`; + + everyClient((client) => { + client.write(frame); + }); + }, + publishTo(client, payload) { + const res = /** @type {ServerResponse} */ (client); + + if (res.writableEnded) { + return; + } + + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }, + }; +} + +module.exports = createEventStream; +module.exports.createEventStream = createEventStream; diff --git a/test/e2e/cors.test.js b/test/e2e/cors.test.js index 85c537fb8..bb101e1c8 100644 --- a/test/e2e/cors.test.js +++ b/test/e2e/cors.test.js @@ -147,7 +147,14 @@ describe("reading the event stream from another origin (browser)", () => { await page.goto(`http://${host}:${port}/`); - return connectFromPage(`ws://127.0.0.1:${port}/__webpack_hmr`); + // The fixture asks for a token, and this test builds its own url rather + // than using the injected client's — so it carries the one the + // middleware resolved. + const token = encodeURIComponent(hotApp.instance.token); + + return connectFromPage( + `ws://127.0.0.1:${port}/__webpack_hmr?token=${token}`, + ); }; it("refuses an origin the default does not allow", async () => { diff --git a/test/e2e/worker.test.js b/test/e2e/worker.test.js index 0489e7527..821e5d4ab 100644 --- a/test/e2e/worker.test.js +++ b/test/e2e/worker.test.js @@ -12,6 +12,11 @@ jest.setTimeout(400000); const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); +// This fixture asks for a token and wires the client entry itself — before the +// middleware exists, so a minted one could not be in the query. A fixed one is +// what hand-wiring requires. +const WORKER_TOKEN = "worker-fixed-token"; + /** * A worker that reports what it is running and takes updates in place. * @param {string} text what this version reports @@ -49,7 +54,10 @@ async function createWorkerApp({ transport = "sse", bare = false } = {}) { // exactly as injection would carry it. entry: bare ? [entryFile] - : [`${CLIENT_ENTRY}?transport=${transport}`, entryFile], + : [ + `${CLIENT_ENTRY}?transport=${transport}&token=${WORKER_TOKEN}`, + entryFile, + ], output: { path: path.join(dir, "dist"), filename: "worker.js" }, plugins: bare ? [] : [new webpack.HotModuleReplacementPlugin()], infrastructureLogging: { level: "none" }, @@ -58,7 +66,9 @@ async function createWorkerApp({ transport = "sse", bare = false } = {}) { watchOptions: { aggregateTimeout: 50, poll: 100 }, }); - const instance = middleware(compiler, { hot: { transport } }); + const instance = middleware(compiler, { + hot: { transport, token: WORKER_TOKEN }, + }); /** @type {((stats: EXPECTED_ANY) => void)[]} */ const buildWaiters = []; diff --git a/types/hot.d.ts b/types/hot.d.ts index d582831ac..76098bfc1 100644 --- a/types/hot.d.ts +++ b/types/hot.d.ts @@ -73,13 +73,6 @@ declare const HOT_DEFAULT_TRANSPORT: "sse"; declare function checkClientStream( stream: ClientStream, ): ClientStream; -/** - * @param {number} heartbeat heartbeat interval in milliseconds - * @param {Logger} logger logger - * @param {CorsOption=} cors which origins may read the stream, the local ones by default - * @param {(string | false)=} token the token the endpoint requires, or false for none - * @returns {EventStream} event stream - */ declare function createEventStream( heartbeat: number, logger: Logger, diff --git a/types/servers/EventSourceServer.d.ts b/types/servers/EventSourceServer.d.ts new file mode 100644 index 000000000..bd54b47e6 --- /dev/null +++ b/types/servers/EventSourceServer.d.ts @@ -0,0 +1,33 @@ +export = createEventStream; +/** + * @param {number} heartbeat heartbeat interval in milliseconds + * @param {Logger} logger logger + * @param {CorsOption=} cors which origins may read the stream, the local ones by default + * @param {(string | false)=} token the token the endpoint requires, or false for none + * @returns {EventStream} event stream + */ +declare function createEventStream( + heartbeat: number, + logger: Logger, + cors?: CorsOption | undefined, + token?: (string | false) | undefined, +): EventStream; +declare namespace createEventStream { + export { + createEventStream, + IncomingMessage, + ServerResponse, + Logger, + Payload, + EventStream, + StreamClient, + CorsOption, + }; +} +type IncomingMessage = import("node:http").IncomingMessage; +type ServerResponse = import("../index.js").ServerResponse; +type Logger = import("../hot.js").Logger; +type Payload = import("../hot.js").Payload; +type EventStream = import("../hot.js").EventStream; +type StreamClient = import("../hot.js").StreamClient; +type CorsOption = import("../hot.js").CorsOption; From 1972ffc7b9fc0fc97409ea50c65377f8fd1050ec Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:18:56 +0000 Subject: [PATCH 3/6] refactor(hot): put each piece where it belongs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to extracting the event stream: the module boundaries were right but three things were still on the wrong side of them. Each transport's CORS default moves to the transport that applies it. `HOT_DEFAULT_CORS_SSE` and `HOT_DEFAULT_CORS_WS` lived in `utils.js`, which `hot.js` then imported the SSE one from purely to re-export it — it never used it. `resolveCors` is called inside each server, so the default each one starts from belongs there too, with the comment explaining why they differ. `pathMatch` moves to `utils.js`. `middleware.js` was reaching through `hot.js` for a url helper, which is the wrong direction: the middleware does not otherwise depend on the hot module, and every other request helper it uses is already in `utils.js`. `hot.js` did not use `pathMatch` itself either — that import was another re-export. And a comment that the lazy-loading change had stranded: "what a transport has to do for itself" describes `CLIENT_STREAM_METHODS`, and `requireServer` had been inserted between the two. What is left in `hot.js` is one thing: the hot lifecycle and the payloads it publishes — its own defaults, the contract a custom transport has to meet, and `createHot`. Transport mechanics are in `./servers`, request helpers in `./utils`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- src/hot.js | 31 ++++++------------------- src/middleware.js | 3 +-- src/servers/EventSourceServer.js | 21 ++++++++++++----- src/servers/WebSocketServer.js | 6 ++++- src/utils.js | 40 ++++++++++++++++---------------- test/hot.test.js | 5 ++-- types/hot.d.ts | 9 ------- types/utils.d.ts | 12 ++++++++-- 8 files changed, 61 insertions(+), 66 deletions(-) diff --git a/src/hot.js b/src/hot.js index 3bf591fdc..5f444b870 100644 --- a/src/hot.js +++ b/src/hot.js @@ -142,34 +142,16 @@ // module paths and source frames a failed build reports. Both transports // honour it now, each the only way it can be honoured on that wire: the event // stream withholds the grant, and an upgrade is refused. -// The CORS and token rules themselves live with the transports that enforce -// them, in `./servers`. What is left here is the default each one starts from -// and the mint that hands a token to both. -const { HOT_DEFAULT_CORS_SSE, resolveToken } = require("./utils.js"); +// The CORS rules, and the default each transport starts from, live with the +// transport that applies them in `./servers`. What is left here is the mint +// that hands one token to whichever of them is built. +const { resolveToken } = require("./utils.js"); const HOT_DEFAULT_PATH = "/__webpack_hmr"; const HOT_DEFAULT_HEARTBEAT = 10 * 1000; const HOT_DEFAULT_TRANSPORT = "sse"; const PLUGIN_NAME = "DevMiddleware"; -/** - * @param {string | undefined} url url - * @param {string} expected expected pathname - * @returns {boolean} true when the url pathname matches the expected path - */ -function pathMatch(url, expected) { - if (!url) return false; - - try { - return new URL(url, "http://localhost").pathname === expected; - } catch { - return false; - } -} - -// What a transport has to do for itself. Missing one of these would throw from -// wherever the stream is first published to, which is a long way from the -// option that built it. /** * Load the module for the transport that was chosen, and only that one. * @@ -187,6 +169,9 @@ function requireServer(name) { : require("./servers/EventSourceServer.js"); } +// What a transport has to do for itself. Missing one of these would throw from +// wherever the stream is first published to, which is a long way from the +// option that built it. const CLIENT_STREAM_METHODS = ["close", "onConnect", "publish", "publishTo"]; // What it may also do. Absent is fine; present and not a function is not — that @@ -701,7 +686,6 @@ function createHot(compiler, userOptions, statsOption) { } module.exports = createHot; -module.exports.HOT_DEFAULT_CORS_SSE = HOT_DEFAULT_CORS_SSE; module.exports.HOT_DEFAULT_HEARTBEAT = HOT_DEFAULT_HEARTBEAT; module.exports.HOT_DEFAULT_PATH = HOT_DEFAULT_PATH; module.exports.HOT_DEFAULT_TRANSPORT = HOT_DEFAULT_TRANSPORT; @@ -718,6 +702,5 @@ module.exports.createEventStream = (heartbeat, logger, cors, token) => requireServer("EventSourceServer")(heartbeat, logger, cors, token); module.exports.createHot = createHot; module.exports.formatErrors = formatErrors; -module.exports.pathMatch = pathMatch; module.exports.publishBundles = publishBundles; module.exports.toBundles = toBundles; diff --git a/src/middleware.js b/src/middleware.js index ab5606443..c92156e6e 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -2,8 +2,6 @@ const path = require("node:path"); const querystring = require("node:querystring"); const { finished } = require("node:stream"); -const { pathMatch: hotPathMatch } = require("./hot"); - const { createReadStreamOrReadFile, destroyStream, @@ -23,6 +21,7 @@ const { memorize, parseHttpDate, parseTokenList, + pathMatch: hotPathMatch, pipe, removeResponseHeader, send, diff --git a/src/servers/EventSourceServer.js b/src/servers/EventSourceServer.js index 1c7a46cbd..146f86530 100644 --- a/src/servers/EventSourceServer.js +++ b/src/servers/EventSourceServer.js @@ -6,12 +6,21 @@ /** @typedef {import("../hot.js").StreamClient} StreamClient */ /** @typedef {import("../hot.js").CorsOption} CorsOption */ -const { - HOT_DEFAULT_CORS_SSE, - applyCors, - isTokenValid, - resolveCors, -} = require("../utils.js"); +const { applyCors, isTokenValid, resolveCors } = require("../utils.js"); + +// TODO in the next major release default the Server-Sent Events endpoint to +// `CORS_LOCAL_ORIGINS` too, so one default covers both transports, and say so +// in the changelog as a breaking change. +// +// Until 8.4 the endpoint answered every request with +// `Access-Control-Allow-Origin: *`, inherited from `webpack-hot-middleware`, +// and no option could turn it off. That grant let any site a developer had +// open read the stream — and with it the module paths and source frames a +// failed build reports — so the local-origins set is what it should be. But +// narrowing it would stop a page served from anywhere else reading its own +// build, which is a break, and this release is a minor. So it stays as it +// shipped, and `cors` is how you narrow it today. +const HOT_DEFAULT_CORS_SSE = true; /** * @param {number} heartbeat heartbeat interval in milliseconds diff --git a/src/servers/WebSocketServer.js b/src/servers/WebSocketServer.js index 0e09ccbac..08567246f 100644 --- a/src/servers/WebSocketServer.js +++ b/src/servers/WebSocketServer.js @@ -9,12 +9,16 @@ /** @typedef {import("../hot.js").CorsOption} CorsOption */ const { - HOT_DEFAULT_CORS_WS, + CORS_LOCAL_ORIGINS, isTokenValid, isUpgradeAllowed, resolveCors, } = require("../utils.js"); +// The WebSocket transport is new in this release, so there is no behavior to +// keep and it starts where the other one is going. +const HOT_DEFAULT_CORS_WS = CORS_LOCAL_ORIGINS; + // How often a client is pinged to find out whether it is still there. A client // that has not answered the previous ping is dropped rather than pinged again. const WS_DEFAULT_HEARTBEAT = 10 * 1000; diff --git a/src/utils.js b/src/utils.js index 64100b658..206f56600 100644 --- a/src/utils.js +++ b/src/utils.js @@ -730,24 +730,6 @@ function nodeReadableToWebStream(stream) { const CORS_LOCAL_ORIGINS = /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/; -// TODO in the next major release default the Server-Sent Events endpoint to -// `CORS_LOCAL_ORIGINS` too, so one default covers both transports, and say so -// in the changelog as a breaking change. -// -// Until 8.4 the endpoint answered every request with -// `Access-Control-Allow-Origin: *`, inherited from `webpack-hot-middleware`, -// and no option could turn it off. That grant let any site a developer had -// open read the stream — and with it the module paths and source frames a -// failed build reports — so the local-origins set is what it should be. But -// narrowing it would stop a page served from anywhere else reading its own -// build, which is a break, and this release is a minor. So it stays as it -// shipped, and `cors` is how you narrow it today. -const HOT_DEFAULT_CORS_SSE = true; - -// The WebSocket transport is new in this release, so there is no behavior to -// keep and it starts where the other one is going. -const HOT_DEFAULT_CORS_WS = CORS_LOCAL_ORIGINS; - // A secret the injected client carries and the endpoint requires, so reaching // the stream takes something a page has to have been given rather than a // header a browser may or may not send. @@ -984,6 +966,25 @@ function resolveToken(option) { return crypto.randomBytes(9).toString("base64url"); } +/** + * Does a url's pathname match an expected path exactly? + * + * Pathname only: the hot endpoint is reached with a query on it — the client's + * options, and the token — and with a fragment from a page that has one. + * @param {string | undefined} url url + * @param {string} expected expected pathname + * @returns {boolean} true when the url pathname matches the expected path + */ +function pathMatch(url, expected) { + if (!url) return false; + + try { + return new URL(url, "http://localhost").pathname === expected; + } catch { + return false; + } +} + /** * Does the request carry the token the endpoint requires? * @@ -1640,8 +1641,6 @@ function injectHotClient(compilers, options, logger) { module.exports = { CORS_LOCAL_ORIGINS, - HOT_DEFAULT_CORS_SSE, - HOT_DEFAULT_CORS_WS, HOT_DEFAULT_TOKEN, applyCors, clientQuery, @@ -1674,6 +1673,7 @@ module.exports = { nodeReadableToWebStream, parseHttpDate, parseTokenList, + pathMatch, pipe, removeResponseHeader, resolveCors, diff --git a/test/hot.test.js b/test/hot.test.js index e7400da94..8683970b2 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -4,10 +4,11 @@ import { problemLine } from "../client-src/problem"; import createHot, { createEventStream, formatErrors, - pathMatch, toBundles, } from "../src/hot"; -import { CORS_LOCAL_ORIGINS } from "../src/utils"; +// `pathMatch` lives with the other request helpers now; the endpoint it routes +// to is still what these cases are about, so they stay here. +import { CORS_LOCAL_ORIGINS, pathMatch } from "../src/utils"; jest.spyOn(globalThis.console, "log").mockImplementation(); diff --git a/types/hot.d.ts b/types/hot.d.ts index 76098bfc1..c593cc585 100644 --- a/types/hot.d.ts +++ b/types/hot.d.ts @@ -24,7 +24,6 @@ declare function createHot( ): HotInstance; declare namespace createHot { export { - HOT_DEFAULT_CORS_SSE, HOT_DEFAULT_HEARTBEAT, HOT_DEFAULT_PATH, HOT_DEFAULT_TRANSPORT, @@ -32,7 +31,6 @@ declare namespace createHot { createEventStream, createHot, formatErrors, - pathMatch, publishBundles, toBundles, HotInstance, @@ -62,7 +60,6 @@ declare namespace createHot { EventStream, }; } -import { HOT_DEFAULT_CORS_SSE } from "./utils.js"; declare const HOT_DEFAULT_HEARTBEAT: number; declare const HOT_DEFAULT_PATH: "/__webpack_hmr"; declare const HOT_DEFAULT_TRANSPORT: "sse"; @@ -84,12 +81,6 @@ declare function createEventStream( * @returns {string[]} flat strings */ declare function formatErrors(errors: (string | StatsError)[]): string[]; -/** - * @param {string | undefined} url url - * @param {string} expected expected pathname - * @returns {boolean} true when the url pathname matches the expected path - */ -declare function pathMatch(url: string | undefined, expected: string): boolean; /** * Publish one event per bundle. Bundles whose hash did not change are * published as `sync`, so their clients do not fetch a hot-update manifest diff --git a/types/utils.d.ts b/types/utils.d.ts index 9bbf1f5b5..47103e639 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -116,8 +116,6 @@ export type MimeDbEntry = { /** @typedef {import("./hot.js").CorsOption} CorsOption */ /** @typedef {import("./hot.js").CorsOrigin} CorsOrigin */ export const CORS_LOCAL_ORIGINS: RegExp; -export const HOT_DEFAULT_CORS_SSE: true; -export const HOT_DEFAULT_CORS_WS: RegExp; export const HOT_DEFAULT_TOKEN: false; /** * Add the cross-origin grant the `cors` option asks for, if any. @@ -495,6 +493,16 @@ export function parseHttpDate(date: string): number; * @returns {string[]} tokens */ export function parseTokenList(str: string): string[]; +/** + * Does a url's pathname match an expected path exactly? + * + * Pathname only: the hot endpoint is reached with a query on it — the client's + * options, and the token — and with a fragment from a page that has one. + * @param {string | undefined} url url + * @param {string} expected expected pathname + * @returns {boolean} true when the url pathname matches the expected path + */ +export function pathMatch(url: string | undefined, expected: string): boolean; /** * @template {ServerResponse & ExpectedServerResponse} Response * @param {Response} res res From 9959f95c1ba4c53e7de0e48a462563b9bd701248 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:15:39 +0300 Subject: [PATCH 4/6] feat(hot): add `publish`, and deprecate `hot.progress` (#2453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measuring a build is the server's call rather than the middleware's, and `hot.progress` made it the middleware's: it applied `ProgressPlugin` to your compiler, so a server that applies one itself — webpack-dev-server does — ends up with two of them on one compiler. What only the middleware can do is carry the result, and the bundled client already renders `{ action: "progress" }`. So the option becomes one public method: `instance.publish(payload)` puts a payload of your own on the stream, and a server hands over what its own plugin reports. It is a no-op when `hot` is off, so a caller does not have to ask first, and nothing is sent when no client is connected — a `ProgressPlugin` tick fires far more often than anyone is listening. `hot.progress` keeps working until the next major release. Its browser end, `hot.client.progress`, is unaffected and stays: a server publishing its own progress payload gets it drawn with no further configuration. Two things the option did for you become the caller's: rounding the percent, and dropping a tick that rounds to the same whole number as the last one. Also fixes two README links that pointed at headings which do not exist, for `etag` and for the `attach` method. Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA Co-authored-by: Claude Opus 5 --- .changeset/deprecate-hot-progress.md | 5 +++ .changeset/instance-publish.md | 5 +++ .changeset/readme-anchors.md | 5 +++ README.md | 60 ++++++++++++++++++++++++++-- src/hot.js | 11 +++++ src/index.js | 22 ++++++++++ test/e2e/live-reload.test.js | 4 +- test/hot.test.js | 21 ++++++++++ test/instance-hot-api.test.js | 18 +++++++++ types/index.d.ts | 12 ++++++ 10 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 .changeset/deprecate-hot-progress.md create mode 100644 .changeset/instance-publish.md create mode 100644 .changeset/readme-anchors.md diff --git a/.changeset/deprecate-hot-progress.md b/.changeset/deprecate-hot-progress.md new file mode 100644 index 000000000..856f710f8 --- /dev/null +++ b/.changeset/deprecate-hot-progress.md @@ -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). The browser end of this, `hot.client.progress`, is unaffected and stays. diff --git a/.changeset/instance-publish.md b/.changeset/instance-publish.md new file mode 100644 index 000000000..3dfb2499a --- /dev/null +++ b/.changeset/instance-publish.md @@ -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. diff --git a/.changeset/readme-anchors.md b/.changeset/readme-anchors.md new file mode 100644 index 000000000..741f6676d --- /dev/null +++ b/.changeset/readme-anchors.md @@ -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. diff --git a/README.md b/README.md index d1fed3788..408ec5a9a 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -350,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); @@ -444,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` @@ -1141,6 +1147,52 @@ 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 }); + +new webpack.ProgressPlugin((percent, message) => { + instance.publish({ + action: "progress", + percent: Math.round(percent * 100), + 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. + +Two things `hot.progress` did for you become yours. It rounded the percent, and it dropped a tick that rounded to the same whole number as the last one — a `ProgressPlugin` callback fires far more often than the number changes, and every one of those was a message on the wire. + +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 on a hot path — a `ProgressPlugin` tick fires often — does not have to check first. 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. diff --git a/src/hot.js b/src/hot.js index 5f444b870..146203d5f 100644 --- a/src/hot.js +++ b/src/hot.js @@ -551,7 +551,12 @@ function createHot(compiler, userOptions, statsOption) { eventStream.attach(options.server); } + // TODO in the next major release remove `progress` and this warning if (options.progress) { + logger.warn( + "The 'hot.progress' option is deprecated and will be removed in the next major release. Measuring a build is the server's call, not the middleware's: a server that applies 'ProgressPlugin' itself — webpack-dev-server does — ends up with two of them on one compiler. Apply it yourself and publish what it reports: 'new webpack.ProgressPlugin((percent, message) => instance.publish({ action: \"progress\", percent: Math.round(percent * 100), message })).apply(compiler)'. See https://github.com/webpack/webpack-dev-middleware#publishpayload. Until then this keeps working.", + ); + const { webpack } = "compilers" in compiler ? compiler.compilers[0] : compiler; @@ -669,6 +674,12 @@ function createHot(compiler, userOptions, statsOption) { publish(payload) { if (closed) return; + // No `hasClients` means the transport did not offer to answer, so the + // payload goes to it and it decides. This is a public entry point — + // something outside publishing on every ProgressPlugin tick should not + // pay for a stream nobody is reading. + if (eventStream.hasClients && !eventStream.hasClients()) return; + eventStream.publish(payload); }, close() { diff --git a/src/index.js b/src/index.js index 483ed9b4b..a1d31c24a 100644 --- a/src/index.js +++ b/src/index.js @@ -169,6 +169,11 @@ const noop = () => {}; * @returns {boolean} true when the endpoint answered the upgrade, false when the request was not its own — or `hot` is off, or the transport answers no upgrades */ +/** + * @callback Publish + * @param {import("./hot").Payload | { action: string }} payload the payload to publish to every client + */ + /** * @callback OnConnect * @param {(client: EXPECTED_ANY, req: IncomingMessage) => void} fn called with each client once it has joined, and the request it joined with, before anything is published to it @@ -189,6 +194,7 @@ const noop = () => {}; * @property {Attach} attach answer WebSocket upgrades on this server * @property {HandleUpgrade} handleUpgrade answer one WebSocket upgrade, for a server that owns its own `upgrade` event * @property {OnConnect} onConnect called with each client that joins, and the request it joined with + * @property {Publish} publish put a payload of your own on the hot stream, for what a server measures itself — a no-op when `hot` is off * @property {(string | false | undefined)=} token the secret the hot endpoint requires, for a client of your own to put on the url; false when it requires none, undefined when `hot` is off * @property {Close} close close * @property {Context} context context @@ -696,6 +702,22 @@ function wdm(compiler, options = {}, isPlugin = false) { } }; + // 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: `ProgressPlugin` is + // the example, and it belongs to whoever decided to measure rather than to + // the middleware that carries the result. The client already renders + // `{ action: "progress" }`, so a server with its own plugin has everywhere to + // put it and needed only this. + // + // A no-op when `hot` is off, so a caller does not have to ask first. + instance.publish = (payload) => { + if (filledContext.hot) { + filledContext.hot.publish(payload); + } + }; + // The secret the hot endpoint requires, for a client of your own: the // injected one is handed it through its entry query, but anything you wrote // yourself has to put it on the url. `false` when the endpoint requires diff --git a/test/e2e/live-reload.test.js b/test/e2e/live-reload.test.js index 7258cac44..f73f24798 100644 --- a/test/e2e/live-reload.test.js +++ b/test/e2e/live-reload.test.js @@ -258,8 +258,8 @@ describe("live reload (browser)", () => { await plantReloadMarker(page); // What a server watching files of its own would publish — nothing here - // came from a compilation. - app.instance.context.hot.publish({ + // came from a compilation — through the public method it has for it. + app.instance.publish({ action: "reload", file: "static/index.html", }); diff --git a/test/hot.test.js b/test/hot.test.js index 8683970b2..8277839fc 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -1266,6 +1266,27 @@ describe("createHot", () => { hot.close(); }); + it("warns that progress is deprecated", () => { + const warnings = []; + const compiler = makeFakeCompiler({ + log() {}, + warn: (m) => warnings.push(m), + }); + + compiler.webpack = { + ProgressPlugin: class { + apply() {} + }, + }; + + const hot = createHot(compiler, { progress: true }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'hot.progress' option is deprecated"); + + hot.close(); + }); + it("forwards custom statsOptions to stats.toJson", () => { const compiler = makeFakeCompiler(); const hot = createHot(compiler, { diff --git a/test/instance-hot-api.test.js b/test/instance-hot-api.test.js index b405130d6..515de5afb 100644 --- a/test/instance-hot-api.test.js +++ b/test/instance-hot-api.test.js @@ -74,6 +74,18 @@ describe("the hot API on the middleware instance", () => { expect(instance.handleUpgrade({}, {}, Buffer.alloc(0))).toBe(false); }); + + // What a server measures itself goes on the stream through here — + // `ProgressPlugin` being the one the middleware used to apply for you. + it("passes a payload of your own through to the endpoint", () => { + const instance = build({ hot: true }); + const publish = jest.spyOn(instance.context.hot, "publish"); + const payload = { action: "progress", percent: 42, message: "building" }; + + instance.publish(payload); + + expect(publish).toHaveBeenCalledWith(payload); + }); }); describe("with hot disabled", () => { @@ -88,5 +100,11 @@ describe("the hot API on the middleware instance", () => { expect(() => instance.onConnect(() => {})).not.toThrow(); }); + + it("takes a payload and drops it, rather than making the caller ask", () => { + const instance = build(); + + expect(() => instance.publish({ action: "progress" })).not.toThrow(); + }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 505d11d92..8e3b8a3ee 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -56,6 +56,7 @@ declare namespace wdm { Invalidate, Attach, HandleUpgrade, + Publish, OnConnect, Close, AdditionalMethods, @@ -356,6 +357,13 @@ type HandleUpgrade = ( socket: import("node:stream").Duplex, head: Buffer, ) => boolean; +type Publish = ( + payload: + | import("./hot").Payload + | { + action: string; + }, +) => any; type OnConnect = ( fn: (client: EXPECTED_ANY, req: IncomingMessage) => void, ) => any; @@ -388,6 +396,10 @@ type AdditionalMethods< * called with each client that joins, and the request it joined with */ onConnect: OnConnect; + /** + * put a payload of your own on the hot stream, for what a server measures itself — a no-op when `hot` is off + */ + publish: Publish; /** * the secret the hot endpoint requires, for a client of your own to put on the url; false when it requires none, undefined when `hot` is off */ From 95294b9efcfbab2d5f63e693dd2d287be61194e8 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:46:36 +0000 Subject: [PATCH 5/6] fix(hot): address the review on `hot.token` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all consequences of the token work rather than of the refactors on top of it. **A `path` that already carried a `token` ended up with two.** The client appended `&token=`, and the endpoint reads the first one — so the token configured here was the one ignored. A fragment was mishandled the same way: `path#frag` became `path#frag?token=…`, putting the query inside the fragment. Now in `client-src/utils/with-token.js`, where it can be tested: the url is taken apart on `#` and then `?` rather than parsed with `URL`, which would need a base and would turn a relative path into an absolute one. Every shape it can arrive in survives — absolute url, protocol-relative, rooted path, relative path, with or without a query or fragment. **The "no client was injected" warning printed the token.** Infrastructure warnings travel into CI output, and a minted token is different every run, so printing it also invited the wrong fix — pasting a value that is already stale. It names `token=` and points at the `token` property instead. **A README example read `instance.token` from a configuration that does not ask for one**, so it served `{ token: false }`. It asks for one now. The sentence beside it still described the per-transport defaults this option no longer has. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- README.md | 6 ++- client-src/index.js | 46 +++++++++-------------- client-src/utils/with-token.js | 35 ++++++++++++++++++ src/utils.js | 7 +++- test/client-with-token.test.js | 59 ++++++++++++++++++++++++++++++ test/e2e/cors.test.js | 7 ++-- test/inject-client.test.js | 5 ++- types/client/utils/with-token.d.ts | 19 ++++++++++ 8 files changed, 148 insertions(+), 36 deletions(-) create mode 100644 client-src/utils/with-token.js create mode 100644 test/client-with-token.test.js create mode 100644 types/client/utils/with-token.d.ts diff --git a/README.md b/README.md index 408ec5a9a..4d719c6f0 100644 --- a/README.md +++ b/README.md @@ -589,14 +589,16 @@ 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" } }); +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 what `'sse'` does today. +`false` requires none, which is the default. #### `hot.inject` diff --git a/client-src/index.js b/client-src/index.js index d7de5403b..77df4f2aa 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -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 */ @@ -274,42 +275,29 @@ function getClient() { return options.transport === "ws" ? WebSocketClient : EventSourceClient; } -/** - * The endpoint url, carrying the token when the endpoint requires one. - * - * On the url because neither `EventSource` nor `WebSocket` lets a page set a - * request header, so there is nowhere else to put it. - * @returns {string} the url to connect to - */ -function endpoint() { - const path = /** @type {string} */ (options.path); - - if (!options.token) { - return path; - } - - return `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(options.token)}`; -} - /** * @returns {ReturnType} a socket on the current options */ function createClientSocket() { const isEventSource = options.transport !== "ws"; - return createSocket(getClient(), endpoint(), { - 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"; diff --git a/client-src/utils/with-token.js b/client-src/utils/with-token.js new file mode 100644 index 000000000..ffb8f926f --- /dev/null +++ b/client-src/utils/with-token.js @@ -0,0 +1,35 @@ +/** + * Put the token on an endpoint url. + * + * On the url because neither `EventSource` nor `WebSocket` lets a page set a + * request header, so there is nowhere else to put it. + * + * Set rather than appended: a path that already carries a `token` would end up + * with two, and the endpoint reads the first — so the one passed here would be + * the one ignored. Taken apart by hand rather than through `URL`, which would + * need a base and would turn a relative path into an absolute one; this keeps + * whatever shape it was given, absolute url, rooted path or relative. + * @param {string} path the endpoint, which may already carry a query, a fragment, or both + * @param {string | undefined} token the token, when the endpoint requires one + * @returns {string} the endpoint to connect to + */ +export default function withToken(path, token) { + if (!token) { + return path; + } + + const hashAt = path.indexOf("#"); + const hash = hashAt === -1 ? "" : path.slice(hashAt); + const withoutHash = hashAt === -1 ? path : path.slice(0, hashAt); + const queryAt = withoutHash.indexOf("?"); + const before = queryAt === -1 ? withoutHash : withoutHash.slice(0, queryAt); + const query = new URLSearchParams( + queryAt === -1 ? "" : withoutHash.slice(queryAt + 1), + ); + + query.set("token", token); + + // Before the fragment, where a query belongs — appending would have put the + // token inside it. + return `${before}?${query}${hash}`; +} diff --git a/src/utils.js b/src/utils.js index 206f56600..db61d8570 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1632,9 +1632,14 @@ function injectHotClient(compilers, options, logger) { // pulls the client in, `hot.transport` is a function, the target is not the // web — and the endpoint still requires whatever token it was given. Said // here rather than left as a `403` with no explanation. + // + // Without the token itself in it. A minted one is different every run, so + // printing it would invite exactly the wrong fix — pasting a value that is + // already stale — and infrastructure warnings travel into CI output, where + // a secret has no business being. if (options.token && !injected) { logger.warn( - `'hot.token' requires a token on the endpoint, but no client entry was added to hand one over, so every client will be refused. Put 'token=${options.token}' on the query of the client you added yourself, read it from the middleware's 'token' property, or set 'hot.token: false'.`, + "'hot.token' requires a token on the endpoint, but no client entry was added to hand one over, so every client will be refused. Put 'token=' on the query of the client you added yourself, reading it from the middleware's 'token' property, or set a fixed 'hot.token' both sides know — or 'hot.token: false' to require none.", ); } } diff --git a/test/client-with-token.test.js b/test/client-with-token.test.js new file mode 100644 index 000000000..9cce5da54 --- /dev/null +++ b/test/client-with-token.test.js @@ -0,0 +1,59 @@ +import withToken from "../client-src/utils/with-token"; + +describe("putting the token on the endpoint", () => { + it("leaves the path alone when there is no token", () => { + expect(withToken("/__webpack_hmr", undefined)).toBe("/__webpack_hmr"); + expect(withToken("/__webpack_hmr", "")).toBe("/__webpack_hmr"); + }); + + it("adds a query to a path that has none", () => { + expect(withToken("/__webpack_hmr", "abc")).toBe("/__webpack_hmr?token=abc"); + }); + + it("joins a query the path already carries", () => { + expect(withToken("/__webpack_hmr?name=main", "abc")).toBe( + "/__webpack_hmr?name=main&token=abc", + ); + }); + + // The one that made this a function rather than a concatenation: appending + // would leave two `token` parameters, and the endpoint reads the first — so + // the token configured here would be the one ignored. + it("replaces a token the path already carries", () => { + expect(withToken("/__webpack_hmr?token=stale", "fresh")).toBe( + "/__webpack_hmr?token=fresh", + ); + expect(withToken("/__webpack_hmr?token=a&token=b", "fresh")).toBe( + "/__webpack_hmr?token=fresh", + ); + }); + + it("keeps the query ahead of a fragment, rather than inside it", () => { + expect(withToken("/__webpack_hmr#frag", "abc")).toBe( + "/__webpack_hmr?token=abc#frag", + ); + expect(withToken("/__webpack_hmr?name=main#frag", "abc")).toBe( + "/__webpack_hmr?name=main&token=abc#frag", + ); + }); + + it("keeps an absolute url absolute", () => { + expect(withToken("ws://127.0.0.1:8080/__webpack_hmr", "abc")).toBe( + "ws://127.0.0.1:8080/__webpack_hmr?token=abc", + ); + expect(withToken("//127.0.0.1:8080/__webpack_hmr", "abc")).toBe( + "//127.0.0.1:8080/__webpack_hmr?token=abc", + ); + }); + + it("keeps a relative path relative", () => { + // `URL` would have needed a base and returned an absolute url for this. + expect(withToken("__webpack_hmr", "abc")).toBe("__webpack_hmr?token=abc"); + }); + + it("escapes a token that needs it", () => { + expect(withToken("/__webpack_hmr", "a b&c=d")).toBe( + "/__webpack_hmr?token=a+b%26c%3Dd", + ); + }); +}); diff --git a/test/e2e/cors.test.js b/test/e2e/cors.test.js index bb101e1c8..2295a3a03 100644 --- a/test/e2e/cors.test.js +++ b/test/e2e/cors.test.js @@ -147,9 +147,10 @@ describe("reading the event stream from another origin (browser)", () => { await page.goto(`http://${host}:${port}/`); - // The fixture asks for a token, and this test builds its own url rather - // than using the injected client's — so it carries the one the - // middleware resolved. + // The fixture asks for a token, and this socket is built here rather + // than taken from the injected client — so it carries the one the + // middleware resolved. Without it the endpoint refuses before it ever + // looks at the origin, which is what this test is about. const token = encodeURIComponent(hotApp.instance.token); return connectFromPage( diff --git a/test/inject-client.test.js b/test/inject-client.test.js index fbd68ac0e..63dfab4cb 100644 --- a/test/inject-client.test.js +++ b/test/inject-client.test.js @@ -248,7 +248,10 @@ describe("injectHotClient", () => { expect(warnings.join("\n")).toContain( "no client entry was added to hand one over", ); - expect(warnings.join("\n")).toContain("token=a-token-nobody-gets"); + // Not the token itself: a minted one is stale by the time anyone reads + // the warning, and infrastructure warnings travel into CI output. + expect(warnings.join("\n")).not.toContain("a-token-nobody-gets"); + expect(warnings.join("\n")).toContain("token="); }); it("says nothing about a token when the client was injected", () => { diff --git a/types/client/utils/with-token.d.ts b/types/client/utils/with-token.d.ts new file mode 100644 index 000000000..1f40e69c8 --- /dev/null +++ b/types/client/utils/with-token.d.ts @@ -0,0 +1,19 @@ +/** + * Put the token on an endpoint url. + * + * On the url because neither `EventSource` nor `WebSocket` lets a page set a + * request header, so there is nowhere else to put it. + * + * Set rather than appended: a path that already carries a `token` would end up + * with two, and the endpoint reads the first — so the one passed here would be + * the one ignored. Taken apart by hand rather than through `URL`, which would + * need a base and would turn a relative path into an absolute one; this keeps + * whatever shape it was given, absolute url, rooted path or relative. + * @param {string} path the endpoint, which may already carry a query, a fragment, or both + * @param {string | undefined} token the token, when the endpoint requires one + * @returns {string} the endpoint to connect to + */ +export default function withToken( + path: string, + token: string | undefined, +): string; From b937c07cefc15f8c6452a37330c5340c80fcb39b Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:21:11 +0000 Subject: [PATCH 6/6] fix(hot): declare `hot.client.token`, and dedupe in the progress example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hot.client.token` was in the schema and serialized into the browser query, but missing from the `HotClientOptions` typedef, so TypeScript rejected it as an excess property: error TS2353: Object literal may only specify known properties, and 'token' does not exist in type 'HotClientOptions'. The test that keeps the schema and the client source to one set of names now covers the typedef too. That was the third place holding this list and the easiest to forget, which is how the omission shipped; removing the line again fails the test by name. The `publish` example published on every `ProgressPlugin` tick. The prose beside it said the caller now owns both rounding the percent and dropping a tick that repeats one, and then the example only rounded — so anyone copying it sent a message per callback, most of them repeating what the last one said. The example, the deprecation warning and the changeset all show both now. Also stopped overselling the no-clients short-circuit: it spares a caller from asking whether anyone is listening, but with a page open every call is still a message, so keeping a chatty source down to what changed stays the caller's job. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .changeset/deprecate-hot-progress.md | 2 +- README.md | 23 ++++++++++++++++------- src/hot.js | 3 ++- test/inject-client.test.js | 19 +++++++++++++++++++ types/hot.d.ts | 4 ++++ 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.changeset/deprecate-hot-progress.md b/.changeset/deprecate-hot-progress.md index 856f710f8..932bd297a 100644 --- a/.changeset/deprecate-hot-progress.md +++ b/.changeset/deprecate-hot-progress.md @@ -2,4 +2,4 @@ "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). The browser end of this, `hot.client.progress`, is unaffected and stays. +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. diff --git a/README.md b/README.md index 4d719c6f0..9cd5fb22a 100644 --- a/README.md +++ b/README.md @@ -1161,12 +1161,21 @@ The bundled client already renders `{ action: "progress" }` in its building badg 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) => { - instance.publish({ - action: "progress", - percent: Math.round(percent * 100), - 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); @@ -1174,7 +1183,7 @@ 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. -Two things `hot.progress` did for you become yours. It rounded the percent, and it dropped a tick that rounded to the same whole number as the last one — a `ProgressPlugin` callback fires far more often than the number changes, and every one of those was a message on the wire. +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: @@ -1184,7 +1193,7 @@ chokidar.watch("content/**/*.md").on("change", () => { }); ``` -Nothing is sent when no client is connected, so a caller on a hot path — a `ProgressPlugin` tick fires often — does not have to check first. Does nothing when `hot` is disabled. +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 diff --git a/src/hot.js b/src/hot.js index 146203d5f..dff06e583 100644 --- a/src/hot.js +++ b/src/hot.js @@ -29,6 +29,7 @@ * @property {("sse" | "ws")=} transport which transport the runtime speaks, `hot.transport` by default * @property {string=} path where the runtime connects, `hot.path` by default; may be an absolute url for an endpoint on another origin * @property {string=} name limit the runtime to one compilation's builds, the compilation's own name by default + * @property {string=} token the secret the runtime puts on its connection url, `hot.token` by default * @property {(boolean | Record)=} overlay show build problems and uncaught runtime errors in an overlay * @property {(boolean | "circular" | "linear")=} progress show an indicator while a rebuild is in progress * @property {boolean=} hot apply a build through Hot Module Replacement @@ -554,7 +555,7 @@ function createHot(compiler, userOptions, statsOption) { // TODO in the next major release remove `progress` and this warning if (options.progress) { logger.warn( - "The 'hot.progress' option is deprecated and will be removed in the next major release. Measuring a build is the server's call, not the middleware's: a server that applies 'ProgressPlugin' itself — webpack-dev-server does — ends up with two of them on one compiler. Apply it yourself and publish what it reports: 'new webpack.ProgressPlugin((percent, message) => instance.publish({ action: \"progress\", percent: Math.round(percent * 100), message })).apply(compiler)'. See https://github.com/webpack/webpack-dev-middleware#publishpayload. Until then this keeps working.", + "The 'hot.progress' option is deprecated and will be removed in the next major release. Measuring a build is the server's call, not the middleware's: a server that applies 'ProgressPlugin' itself — webpack-dev-server does — ends up with two of them on one compiler. Apply it yourself and hand what it reports to the middleware's 'publish' method, rounding the percent and dropping a tick that repeats one as this option did for you — the example is at https://github.com/webpack/webpack-dev-middleware#publishpayload. Until then this keeps working.", ); const { webpack } = diff --git a/test/inject-client.test.js b/test/inject-client.test.js index 63dfab4cb..7b04d9b6f 100644 --- a/test/inject-client.test.js +++ b/test/inject-client.test.js @@ -939,4 +939,23 @@ describe("node and the query take the same names", () => { it("is one set of names, with nothing on one side only", () => { expect(readByClient.toSorted()).toStrictEqual(takenInNode.toSorted()); }); + + // The third place, and the one that is easiest to forget: the typedef the + // published declarations are generated from. A name the schema takes that + // it omits is accepted at runtime and rejected by TypeScript, which is how + // `token` first shipped. + it("is in the typedef the declarations come from, as well", () => { + const hotSource = fs.readFileSync( + path.join(__dirname, "..", "src", "hot.js"), + "utf8", + ); + const [typedef] = /** @type {RegExpMatchArray} */ ( + hotSource.match(/@typedef \{object\} HotClientOptions[\s\S]*?\n \*\//) + ); + const declared = [ + ...typedef.matchAll(/@property \{[^}]+\} ([A-Za-z]+)/g), + ].map((found) => found[1]); + + expect(declared.toSorted()).toStrictEqual(takenInNode.toSorted()); + }); }); diff --git a/types/hot.d.ts b/types/hot.d.ts index c593cc585..ec1d37799 100644 --- a/types/hot.d.ts +++ b/types/hot.d.ts @@ -190,6 +190,10 @@ type HotClientOptions = { * limit the runtime to one compilation's builds, the compilation's own name by default */ name?: string | undefined; + /** + * the secret the runtime puts on its connection url, `hot.token` by default + */ + token?: string | undefined; /** * show build problems and uncaught runtime errors in an overlay */