From b0c73f0df1446c66063cee30b1e7be34a00b34c3 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:08:45 +0000 Subject: [PATCH 1/8] feat(hot): take over the last of webpack-dev-server's compiler and socket wiring Everything webpack-dev-server still wired up for itself, and the regressions a page using its client would see with this one in its place. - hot.client: false adds no runtime and still applies the plugin; the plugin goes to every compilation served, server bundles included - hot.client.transport accepts a module exporting a client of your own, provided as __webpack_dev_server_client__ - hot.ws is handed to the ws server: compression, verifyClient, or a port or server of its own, with cors and token still checked - the live connection is exported as webpack-dev-middleware/client/socket - client: malformed escapes in the page url, reload mode catching up after a restart, warnings alongside errors, webpack-dev-server's query spelling, an ES5 logger, overlay z-index and Esc, indicator without Shadow DOM and with progressbar semantics Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .changeset/dev-server-client-parity.md | 13 + .changeset/finish-dev-server-migration.md | 5 + .cspell.json | 61 +-- README.md | 72 +++- babel.config.js | 43 ++ client-src/clients/createSocket.js | 41 +- client-src/index.js | 84 +++- client-src/indicator.js | 30 +- client-src/overlay.js | 18 +- client-src/socket.js | 8 + package.json | 6 +- scripts/build-client-logger.mjs | 43 ++ src/hot.js | 7 +- src/options.check.js | 2 +- src/options.json | 397 +++++++++--------- src/servers/WebSocketServer.js | 97 ++++- src/utils.js | 87 +++- .../validation-options.test.js.snap.webpack5 | 14 +- test/client-bundle.test.js | 25 +- test/client-socket.test.js | 65 +++ test/e2e/dev-server-parity.test.js | 307 ++++++++++++++ test/e2e/transport.test.js | 115 +++++ test/fixtures/custom-client-transport.js | 40 ++ test/hot.test.js | 246 +++++++++++ test/inject-client.test.js | 254 ++++++++++- types/client/clients/createSocket.d.ts | 41 +- types/client/socket.d.ts | 1 + types/hot.d.ts | 12 +- types/servers/WebSocketServer.d.ts | 5 + types/utils.d.ts | 4 +- 30 files changed, 1811 insertions(+), 332 deletions(-) create mode 100644 .changeset/dev-server-client-parity.md create mode 100644 .changeset/finish-dev-server-migration.md create mode 100644 client-src/socket.js create mode 100644 scripts/build-client-logger.mjs create mode 100644 test/e2e/dev-server-parity.test.js create mode 100644 test/fixtures/custom-client-transport.js create mode 100644 types/client/socket.d.ts diff --git a/.changeset/dev-server-client-parity.md b/.changeset/dev-server-client-parity.md new file mode 100644 index 000000000..8e23da3a9 --- /dev/null +++ b/.changeset/dev-server-client-parity.md @@ -0,0 +1,13 @@ +--- +"webpack-dev-middleware": patch +--- + +The client keeps doing what webpack-dev-server's did: + +- A page url with a malformed escape no longer stops every update. +- `apply: "reload"` reloads a reconnected page whose build is out of date. +- A build's warnings are logged and posted along with its errors. +- An entry written for that server's query (`hostname`, `port`, `pathname`, `live-reload`, `hot=only`) still connects. +- The published client is ES5 down to the logger. +- The overlay sits at the highest `z-index` and closes on `Esc`. +- The building indicator works without Shadow DOM and is announced as a progress bar. diff --git a/.changeset/finish-dev-server-migration.md b/.changeset/finish-dev-server-migration.md new file mode 100644 index 000000000..a107d00a7 --- /dev/null +++ b/.changeset/finish-dev-server-migration.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": minor +--- + +`hot.client: false` adds no runtime to the page while still applying `HotModuleReplacementPlugin`, which now goes to every compilation the middleware serves, including server bundles that hot-reload through `webpack/hot/poll`. `hot.client.transport` also accepts a module exporting a client class of your own, and the new `hot.ws` option is passed to the `ws` server (compression, `verifyClient`, or a `port` or `server` of its own), with `hot.cors` and `hot.token` still checked. The connection the runtime holds is exported as `webpack-dev-middleware/client/socket` for tooling that listens alongside it. diff --git a/.cspell.json b/.cspell.json index 098a14d77..a8fbd04e5 100644 --- a/.cspell.json +++ b/.cspell.json @@ -2,49 +2,52 @@ "version": "0.2", "language": "en,en-gb", "words": [ - "memfs", - "GHSA", - "rxfj", - "noextension", - "fullhash", - "execa", - "deepmerge", - "fastify", - "contextify", - "middie", + "apos", + "cachable", "cexoso", - "usdz", - "leadinghash", - "myhtml", - "configurated", - "mycustom", "commitlint", - "nosniff", - "deoptimize", - "etag", - "cachable", - "finalhandler", - "hono", - "rspack", - "apos", - "malformed", + "configurated", "Consolas", + "contextify", "cspellcache", "CSSOM", "darkgrey", + "deepmerge", + "deoptimize", "eslintcache", "esmodules", + "etag", + "execa", + "expressjs", + "fastify", + "finalhandler", + "fullhash", + "GHSA", + "hono", + "jshttp", + "leadinghash", + "malformed", "mbold", + "memfs", + "middie", "mred", + "mycustom", + "myhtml", + "noextension", "noopener", "noreferrer", - "webworker", + "nosniff", "nwjs", - "expressjs", - "wildcarded", - "jshttp", "realpath", - "Rsbuild" + "Rsbuild", + "rspack", + "rxfj", + "usdz", + "valuemax", + "valuemin", + "valuenow", + "webworker", + "wildcarded" ], "ignorePaths": [ "CHANGELOG.md", diff --git a/README.md b/README.md index 6b819941a..d2e693514 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,25 @@ Default: `undefined` HTTP server the [`'ws'`](#hottransport) transport answers upgrades on, when it already exists where the middleware is built. Otherwise hand it over later with the middleware's [`attach`](#attachserver) method. Ignored by `'sse'`, which is answered by the middleware itself. +#### `hot.ws` + +Type: `Object` +Default: `undefined` + +Options for the [`ws`](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback) server behind the [`'ws'`](#hottransport) transport — compression (`perMessageDeflate`), `maxPayload`, `handleProtocols`, `verifyClient`, and so on. Ignored by `'sse'`. + +A `port` (with an optional `host`) or a `server` gives the endpoint a server of its own to listen on, rather than the upgrades it is handed through [`hot.server`](#hotserver), [`attach`](#attachserver) or [`handleUpgrade`](#handleupgradereq-socket-head) — which then answer nothing: + +```js +app.use( + middleware(compiler, { + hot: { transport: "ws", ws: { port: 8081 } }, + }), +); +``` + +[`hot.cors`](#hotcors) and [`hot.token`](#hottoken) are checked either way, before the handshake completes; a `verifyClient` of your own is asked only about a client they allow. `path`, `noServer` and `clientTracking` are the middleware's, and are ignored here — the endpoint's path is [`hot.path`](#hotpath). + #### `hot.progress` Type: `Boolean` @@ -637,7 +656,7 @@ app.get("/my-client-config.json", (_req, res) => { Type: `Boolean` Default: `true` -Add the client entry and `HotModuleReplacementPlugin` to the compilation. Set it to `false` to wire both yourself — see [Hot Module Replacement client](#hot-module-replacement-client). +Add the client entry and `HotModuleReplacementPlugin` to the compilation. Set it to `false` to wire both yourself — see [Hot Module Replacement client](#hot-module-replacement-client). To keep the plugin and wire only the client yourself, set [`hot.client`](#client-options) to `false` instead. Turn it off when you have a client of your own that the middleware will not recognize as one (anything other than `webpack-dev-middleware/client`), or when you do not want the hot runtime in your bundle at all and are using the endpoint through [`subscribe`](#custom-events) instead. @@ -688,7 +707,7 @@ The client is recognized as `webpack-dev-middleware/client` (with or without a q #### Which compilations get the runtime -Only the ones a browser runs, decided by the compilation's [`target`](https://webpack.js.org/configuration/target/): +Only the ones a browser runs, decided by the compilation's [`target`](https://webpack.js.org/configuration/target/). `HotModuleReplacementPlugin` goes to every compilation the middleware serves, whatever its target: a server bundle hot-reloads itself through `module.hot` too, with [`webpack/hot/poll`](https://github.com/webpack/webpack/blob/main/hot/poll.js) or [`webpack/hot/signal`](https://github.com/webpack/webpack/blob/main/hot/signal.js). In a multi-compiler build, a compilation whose configuration says `devServer: false` gets neither. | `target` | Gets the runtime | | :------------------------------------------------------------- | :--------------- | @@ -700,7 +719,7 @@ Only the ones a browser runs, decided by the compilation's [`target`](https://we | `deno` | no | | `false`, or a version with no platform such as `es2020` | no | -So in a multi-compiler build the browser half gets a client and the server-rendering half does not, with nothing to configure. +So in a multi-compiler build the browser half gets a client and the server-rendering half does not, with nothing to configure — both get the plugin. **Web workers are included.** A worker has no `window` and no document, but it has `EventSource`, `WebSocket` and webpack's runtime, which is all an update needs — so a worker compilation gets a client and applies updates in place, with the overlay and the building indicator left to the page. The one thing a worker cannot do is reload itself, since it has no `location.reload`; when an update cannot be applied the client says so and leaves the page that started the worker to reload it. @@ -795,6 +814,10 @@ app.use( ); ``` +`hot.client: false` adds no runtime to the page and still applies +`HotModuleReplacementPlugin`, for a page that wires a client of its own and +still wants its updates applied. + `hot.client` is read only when the client is injected. With `hot.inject: false`, or for a client the configuration already has as an entry, the query string on the entry path is the only source — and it works either way: @@ -830,7 +853,7 @@ narrow the mode in force. | Name | Type | Default | Description | | :-----------------: | :--------------------------------------: | :------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `transport` | `string` | `"sse"` | How the events are carried: `"sse"` or `"ws"`. Defaults to the server [`hot.transport`](#hottransport); override it only together with `path`, since a client asking this endpoint for a protocol it does not serve never connects — the middleware warns when it sees that. | +| `transport` | `string` | `"sse"` | How the events are carried: `"sse"` or `"ws"`. Defaults to the server [`hot.transport`](#hottransport); override it only together with `path`, since a client asking this endpoint for a protocol it does not serve never connects — the middleware warns when it sees that. Any other string is a module exporting [a client of your own](#a-client-of-your-own). | | `path` | `string\|Object` | `/__webpack_hmr` | Where the runtime connects. Defaults to the server [`hot.path`](#hotpath); set it to an absolute url (`wss://dev.example.com/__webpack_hmr`) for an endpoint on another origin or behind a proxy, or to an object saying only the parts that differ — see [a path in parts](#a-path-in-parts). | | `apply` | `"hmr"\|"hmr-only"\|"reload"\|"nothing"` | `"hmr"` | What a build does to the page. `"hmr"` applies the update and loads the page again if it cannot be applied; `"hmr-only"` applies it and stops with a message if it cannot; `"reload"` skips HMR and loads the page again on any build that changed something; `"nothing"` leaves the page alone until you reload it. One option rather than three booleans, since only four of their eight combinations differed. | | `connect` | `boolean\|{ retries, timeout }` | `true` | Whether to connect when the entry runs, and how the connection is held open. `false` does not connect — call `setOptionsAndConnect()` yourself. `retries` is how many times to reconnect before giving up, and `Infinity` never does; unset, `"sse"` keeps trying for as long as the page is open while `"ws"` gives up after `10`. `timeout` is how long silence is tolerated before reconnecting, in milliseconds, and the interval between reconnections — `"sse"` only, since a `"ws"` heartbeat is a protocol ping the browser answers without telling JavaScript. | @@ -919,10 +942,43 @@ import EventSourceClient from "webpack-dev-middleware/client/sse"; import WebSocketClient from "webpack-dev-middleware/client/ws"; ``` -The runtime picks it up from `__webpack_dev_server_client__`, which -webpack-dev-server sets from its `client.webSocketTransport` option; a module -exporting the class as `default` is unwrapped. An injected client wins over -both built-ins, whatever `transport` says. +Name it in [`hot.client.transport`](#client-options) — a path, or a package +name resolved from the compilation's context — and the injected runtime uses it +in place of the built-in one: + +```js +app.use( + middleware(compiler, { + hot: { + transport: "ws", + client: { transport: require.resolve("./my-client.js") }, + }, + }), +); +``` + +It reaches the runtime as `__webpack_dev_server_client__`, which is how +webpack-dev-server's `client.webSocketTransport` option has always worked; a +module exporting the class as `default` is unwrapped. The query carries the +endpoint's own transport, which the runtime still builds the url's scheme from. + +#### Listening alongside the runtime + +Tooling that wants the raw messages without replacing the runtime can read the +connection it holds: + +```js +import { client } from "webpack-dev-middleware/client/socket"; + +// `client` is live: `null` until the runtime connects and while it reconnects. +// `client.client` is the `WebSocket` or `EventSource` underneath. +client.client.addEventListener("message", (event) => { + console.log(JSON.parse(event.data)); +}); +``` + +This is the shape webpack-dev-server's `client/socket` has always exported, +which is what `@pmmmwh/react-refresh-webpack-plugin` reads. #### Client `overlay` options diff --git a/babel.config.js b/babel.config.js index 5dd4610f6..df7b372cd 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,5 @@ +const path = require("node:path"); + const MIN_BABEL_VERSION = 8; // The middleware itself runs on the node.js version `engines` requires. @@ -7,6 +9,46 @@ const NODE_TARGETS = { node: "20.9.0" }; // `modules: false` keeps the ESM syntax for webpack to tree-shake. const CLIENT_TARGETS = { ie: "11" }; +// The two webpack modules the client logs through, and the ES5 copies of them +// `scripts/build-client-logger.mjs` writes next to the built client. +const CLIENT_SRC = path.join(__dirname, "client-src"); +const ES5_LOGGER = { + "webpack/lib/logging/Logger.js": "Logger.cjs", + "webpack/lib/logging/createConsoleLogger.js": "createConsoleLogger.cjs", +}; + +/** + * Point the built client at the ES5 copies of webpack's logger. Webpack's own + * are node-side source, which a bundle for an ES5 browser would carry as is. + * @returns {import("@babel/core").PluginObj} plugin + */ +function useES5Logger() { + return { + name: "use-es5-logger", + visitor: { + ImportDeclaration(declaration, state) { + const target = ES5_LOGGER[declaration.node.source.value]; + + if (!target) { + return; + } + + const relative = path + .relative( + path.dirname(/** @type {string} */ (state.filename)), + path.join(CLIENT_SRC, "modules", "logger", target), + ) + .split(path.sep) + .join("/"); + + declaration.node.source.value = relative.startsWith(".") + ? relative + : `./${relative}`; + }, + }, + }; +} + module.exports = (api) => { api.assertVersion(MIN_BABEL_VERSION); @@ -26,6 +68,7 @@ module.exports = (api) => { presets: [ ["@babel/preset-env", { modules: false, targets: CLIENT_TARGETS }], ], + plugins: [useES5Logger], }, ], }; diff --git a/client-src/clients/createSocket.js b/client-src/clients/createSocket.js index b5410db1f..750960342 100644 --- a/client-src/clients/createSocket.js +++ b/client-src/clients/createSocket.js @@ -33,6 +33,17 @@ import { log } from "../utils/log.js"; * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor */ +/** + * The connection the runtime holds right now, or `null` while there is none — + * for tooling that listens alongside the runtime rather than through it. + * `client.client` is the `WebSocket` or `EventSource` underneath, the shape + * webpack-dev-server's `client/socket` has always exported, which is what + * `@pmmmwh/react-refresh-webpack-plugin` reads its build messages from. + * @type {CommunicationClient | null} + */ +// eslint-disable-next-line import/no-mutable-exports +export let client = null; + /** * Hold a connection open, reconnecting when it drops, and fan each message out * to everyone listening. What "reconnect" costs is the transport's to say: a @@ -59,24 +70,31 @@ export default function createSocket(Client, url, options = {}) { /** @type {((event: { data: string }) => void)[]} */ const listeners = []; /** @type {CommunicationClient | null} */ - let client = null; + let current = null; /** @type {ReturnType | undefined} */ let timer; let attempt = 0; let closed = false; const open = () => { - client = new Client(url, options.clientOptions); + current = new Client(url, options.clientOptions); + client = current; - client.onOpen(() => { + current.onOpen(() => { // Said here rather than in a transport, or whichever one did not say it // would leave the page with no sign it had connected at all. log.info("connected"); attempt = 0; }); - client.onClose(() => { - client = null; + current.onClose(() => { + // Only if it is still the one exported: two endpoints on one page each + // hold a connection, and one dropping says nothing about the other. + if (client === current) { + client = null; + } + + current = null; // Once per outage rather than once per failed attempt: the retries that // follow are this module reconnecting, not the connection going away @@ -104,7 +122,7 @@ export default function createSocket(Client, url, options = {}) { timer = setTimeout(open, delay); }); - client.onMessage((data) => { + current.onMessage((data) => { for (const listener of listeners) { listener({ data: /** @type {string} */ (data) }); } @@ -123,9 +141,14 @@ export default function createSocket(Client, url, options = {}) { closed = true; clearTimeout(timer); - if (client) { - client.close(); - client = null; + if (current) { + current.close(); + + if (client === current) { + client = null; + } + + current = null; } }, }; diff --git a/client-src/index.js b/client-src/index.js index e4db63c22..3c619e9f6 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -1,4 +1,4 @@ -/* global __resourceQuery, __webpack_dev_server_client__, __webpack_public_path__ */ +/* global __resourceQuery, __webpack_dev_server_client__, __webpack_hash__, __webpack_public_path__ */ // This file is bundled by webpack into a browser bundle, so it is compiled to // ES5 (see `babel.config.js`) and sticks to ES5 runtime APIs — `EventSource` @@ -110,10 +110,22 @@ function parseQuery(query) { const parameters = {}; /** + * A malformed escape — a page url such as `?discount=50%`, which a browser + * leaves as it is — is kept as written rather than thrown: this parses the + * page's own url on every build, and one bad parameter must not stop every + * update from being applied. * @param {string} value raw value * @returns {string} decoded value */ - const decode = (value) => decodeURIComponent(value.replace(/\+/g, " ")); + const decode = (value) => { + const spaced = value.replace(/\+/g, " "); + + try { + return decodeURIComponent(spaced); + } catch { + return spaced; + } + }; for (const pair of query.slice(1).split("&")) { if (!pair) { @@ -190,6 +202,15 @@ function legacyBoolean(value, fallback) { * @returns {void} */ function foldLegacyOptions(overrides) { + // webpack-dev-server's query spelled it this way, and an entry written by + // hand for that server still does. + if ( + overrides["live-reload"] !== undefined && + overrides.liveReload === undefined + ) { + overrides.liveReload = overrides["live-reload"]; + } + const used = LEGACY_OPTIONS.filter((name) => overrides[name] !== undefined); if (used.length === 0) { @@ -207,8 +228,9 @@ function foldLegacyOptions(overrides) { // the four modes are. const hot = legacyBoolean(overrides.hot, true); + // `hot=only` is webpack-dev-server's own: apply in place, never reload. options.apply = hot - ? legacyBoolean(overrides.reload, true) + ? overrides.hot !== "only" && legacyBoolean(overrides.reload, true) ? "hmr" : "hmr-only" : legacyBoolean(overrides.liveReload, true) @@ -344,6 +366,32 @@ function setOverrides(overrides) { // differ and leaves the rest to be resolved against the page, which is the // only place the rest is known — behind a proxy, on another host, or on a // socket listening on a port of its own. + // The parts of the url as parameters of their own, the way + // webpack-dev-server's query has always carried them — so an entry written + // by hand for that server still connects where it says. Read as a path in + // parts, which resolves what they leave out against the page; a `path` given + // as well says it all and wins. + if (!overrides.path) { + /** @type {Record} */ + const parts = {}; + + for (const name of [ + "protocol", + "hostname", + "port", + "pathname", + "username", + "password", + ]) { + if (overrides[name]) { + parts[name] = overrides[name]; + } + } + + if (Object.keys(parts).length > 0) { + overrides.path = JSON.stringify(parts); + } + } if (overrides.path) { let parsed; @@ -842,17 +890,22 @@ function processMessage(obj) { } let shouldApply = true; + // Warnings are reported (and possibly shown in the overlay) but do not + // block the update, matching webpack-dev-server. A build with errors as + // well still reports its warnings, before the errors, as that client + // did: they are as true of a broken build as of a working one. + if (obj.warnings.length > 0) { + if (reporter) { + reporter.problems("warnings", obj); + } + sendMessage("Warnings", obj.warnings.map(stripAnsi)); + } if (obj.errors.length > 0) { if (reporter) reporter.problems("errors", obj); shouldApply = false; - sendMessage("Errors", obj.errors); + sendMessage("Errors", obj.errors.map(stripAnsi)); } else if (obj.warnings.length > 0) { - // Warnings are reported (and possibly shown in the overlay) but do - // not block the update, matching webpack-dev-server. - if (reporter) { - reporter.problems("warnings", obj); - } - sendMessage("Warnings", obj.warnings); + // Reported above. } else { if (reporter) { reporter.cleanProblemsCache(obj.name || ""); @@ -884,10 +937,13 @@ function processMessage(obj) { ); } else if ( // Without Hot Module Replacement the new code can only reach the - // page by loading it again. `sync` is left alone: it reports what - // the page is already running. - obj.action === "built" && - mode === "reload" + // page by loading it again — whenever what the server built is not + // what the page is running. A `sync` counts too: a page that + // reconnects after the server restarted is caught up with one, and + // left alone it would stay on the old code. One whose hash is the + // page's own changes nothing, whichever action carried it. + mode === "reload" && + obj.hash !== __webpack_hash__ ) { log.info("App updated. Reloading..."); reloadPage(); diff --git a/client-src/indicator.js b/client-src/indicator.js index 8c4a99a1a..73555d604 100644 --- a/client-src/indicator.js +++ b/client-src/indicator.js @@ -194,7 +194,7 @@ function applyStyle(element, style) { /** * Build the linear indicator: a thin bar across the top of the viewport, the * shape `progress: "linear"` selects in webpack-dev-server. - * @param {ShadowRoot} root the host's shadow root + * @param {ShadowRoot | HTMLElement} root the host's shadow root, or the host itself where there is no Shadow DOM */ function buildBar(root) { applyStyle(/** @type {HTMLElement} */ (state.host), { @@ -233,8 +233,21 @@ function ensureIndicator() { state.host = document.createElement("div"); state.host.id = INDICATOR_ID; - - const root = state.host.attachShadow({ mode: "open" }); + // Announced as what it is. `aria-valuenow` is set while there is a percent + // to report, and left out while the build cannot be measured — which is + // how an indeterminate progress bar is told apart. + state.host.setAttribute("role", "progressbar"); + state.host.setAttribute("aria-label", "Rebuilding"); + state.host.setAttribute("aria-valuemin", "0"); + state.host.setAttribute("aria-valuemax", "100"); + + // A browser without Shadow DOM gets the indicator in the page itself. Its + // styles are inline either way, so only the isolation from page styles is + // lost, rather than every rebuild throwing. + const root = + typeof state.host.attachShadow === "function" + ? state.host.attachShadow({ mode: "open" }) + : state.host; if (state.type === "linear") { buildBar(root); @@ -398,6 +411,17 @@ export function update(text, percent) { function render(text, percent) { ensureIndicator(); + if (state.host) { + if (typeof percent === "number") { + state.host.setAttribute( + "aria-valuenow", + String(Math.round(Math.min(100, Math.max(0, percent)))), + ); + } else { + state.host.removeAttribute("aria-valuenow"); + } + } + if (state.type === "linear") { showBar(percent); diff --git a/client-src/overlay.js b/client-src/overlay.js index 0dcf01140..32561229c 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -54,6 +54,16 @@ let overlayId = DEFAULT_OVERLAY_ID; // a strict `style-src` Content Security Policy allows, unlike inline `style` // attributes. +/** + * Whether a key press is Escape. `"Esc"` and the key code are what older + * browsers — the ES5 ones this runtime still supports — report for it. + * @param {KeyboardEvent} event the key press + * @returns {boolean} true for Escape + */ +function isEscape(event) { + return event.key === "Escape" || event.key === "Esc" || event.keyCode === 27; +} + /** * The iframe acts as the backdrop: it covers the viewport and dims the page. * @type {Record} @@ -67,7 +77,9 @@ const backdropStyles = { width: "100vw", height: "100vh", border: "none", - zIndex: 9999, + // The highest a browser allows, as webpack-dev-server's overlay always had: + // a page's own modal or toast must not cover the build's errors. + zIndex: 2147483647, background: theme.backdrop, }; @@ -547,7 +559,7 @@ function ensureOverlay() { if (!state.hostKeydownAttached) { state.hostKeydownAttached = true; document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { + if (isEscape(event)) { clear(); } }); @@ -610,7 +622,7 @@ function ensureOverlay() { // Dismiss the overlay when pressing Escape while the frame has focus; // navigate between problems with the arrow keys when paginating. frameDocument.addEventListener("keydown", (event) => { - if (event.key === "Escape") { + if (isEscape(event)) { clear(); } else if (paginate && event.key === "ArrowLeft") { goToPage(state.pageIndex - 1); diff --git a/client-src/socket.js b/client-src/socket.js new file mode 100644 index 000000000..ca01461c7 --- /dev/null +++ b/client-src/socket.js @@ -0,0 +1,8 @@ +// The connection the runtime holds, for tooling that listens alongside it. +// +// `client` is live: `null` until the runtime connects and while it is +// reconnecting, and the current transport instance otherwise, whose own +// `client` is the `WebSocket` or `EventSource` underneath. It is the same +// shape webpack-dev-server's `client/socket` has always exported, which is +// how `@pmmmwh/react-refresh-webpack-plugin` reads its build messages. +export { client } from "./clients/createSocket.js"; diff --git a/package.json b/package.json index 25e469b9d..1318a3b65 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,10 @@ "types": "./types/client/problem.d.ts", "default": "./client/problem.js" }, + "./client/socket": { + "types": "./types/client/socket.d.ts", + "default": "./client/socket.js" + }, "./package.json": "./package.json" }, "main": "dist/index.js", @@ -68,7 +72,7 @@ "prebuild": "npm run clean", "build:types": "tsc && tsc -p tsconfig.client.build.json && node ./scripts/mark-client-esm.mjs && prettier \"types/**/*.ts\" --write", "build:code": "babel src -d dist --copy-files", - "build:client": "babel client-src -d client --copy-files", + "build:client": "babel client-src -d client --copy-files && node ./scripts/build-client-logger.mjs", "build": "npm-run-all -p \"build:**\"", "test:only": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest", "test:e2e": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest test/e2e --runInBand", diff --git a/scripts/build-client-logger.mjs b/scripts/build-client-logger.mjs new file mode 100644 index 000000000..1fb8bd788 --- /dev/null +++ b/scripts/build-client-logger.mjs @@ -0,0 +1,43 @@ +// The client logs through webpack's own `Logger` and `createConsoleLogger`, +// which are node-side source: classes, `const`, spread. Bundled for a page as +// they are, an `["web", "es5"]` build carries that syntax to a browser that +// cannot parse it, and webpack does not transpile `node_modules`. So the built +// client gets ES5 copies of the two next to it, and `babel.config.js` points +// its import at them; the source checkout keeps importing webpack's directly. +import { mkdir, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { transformFileAsync } from "@babel/core"; + +// Not `import.meta.dirname`: this package supports node.js 20.9, which does not +// have it. +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const OUT = path.join(ROOT, "client", "modules", "logger"); +const require = createRequire(import.meta.url); + +await mkdir(OUT, { recursive: true }); + +for (const name of ["Logger", "createConsoleLogger"]) { + const source = require.resolve(`webpack/lib/logging/${name}.js`); + const result = await transformFileAsync(source, { + babelrc: false, + configFile: false, + // CommonJS as webpack wrote it; only the syntax is lowered. + sourceType: "script", + presets: [["@babel/preset-env", { modules: false, targets: { ie: "11" } }]], + }); + + // `.cjs`, because `client/` is marked as ES modules and these are not; the + // one require between them has to name the renamed file. + const code = /** @type {string} */ (result && result.code).replaceAll( + 'require("./Logger")', + 'require("./Logger.cjs")', + ); + + await writeFile( + path.join(OUT, `${name}.cjs`), + `// Generated by scripts/build-client-logger.mjs from webpack/lib/logging/${name}.js — do not edit.\n${code}\n`, + ); +} diff --git a/src/hot.js b/src/hot.js index 15041da8e..0b5829ec9 100644 --- a/src/hot.js +++ b/src/hot.js @@ -40,7 +40,7 @@ * and the compilation's name. Setting one here replaces that, which is what a * page reaching the endpoint through a proxy or another origin needs. * @typedef {object} HotClientOptions - * @property {("sse" | "ws")=} transport which transport the runtime speaks, `hot.transport` by default + * @property {("sse" | "ws" | string)=} transport which transport the runtime speaks, `hot.transport` by default; any other string is a module exporting a client class of your own, used in place of the built-in one * @property {(string | PathSpec)=} path where the runtime connects, `hot.path` by default; may be an absolute url for an endpoint on another origin, or the parts that differ with the rest resolved in the page * @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 @@ -65,12 +65,13 @@ * @property {string=} path the path the endpoint is served at * @property {number=} heartbeat heartbeat interval in milliseconds * @property {HttpServer=} server HTTP server the `"ws"` transport answers upgrades on, when it is already built + * @property {Record=} ws options for the `ws` server behind the `"ws"` transport — compression, payload limits, `verifyClient`, or a `port` or a `server` of its own to listen on; `path`, `noServer` and `clientTracking` are the middleware's * @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 + * @property {(HotClientOptions | false)=} client options handed to the browser runtime through its entry query; `false` adds no runtime to the page and still applies `HotModuleReplacementPlugin` */ /** @@ -524,7 +525,7 @@ function createHot(compiler, userOptions, statsOption) { transportName = "a custom transport"; } else if (transport === "ws") { eventStream = requireServer("WebSocketServer")( - { heartbeat, path, cors, token }, + { heartbeat, path, cors, token, ws: options.ws }, logger, ); transportName = "a WebSocket"; diff --git a/src/options.check.js b/src/options.check.js index a1fe388cd..41f62d9a6 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":{"cache":{"description":"How responses are cached.","link":"https://github.com/webpack/webpack-dev-middleware#cache","type":"object","additionalProperties":false,"properties":{"etag":{"description":"Enable or disable etag generation.","enum":["weak","strong"]},"lastModified":{"description":"Enable or disable `Last-Modified` header. Uses the file system's last modified value.","type":"boolean"},"control":{"description":"Enable or disable setting `Cache-Control` response header.","anyOf":[{"type":"boolean"},{"type":"number"},{"type":"string","minLength":1},{"type":"object","properties":{"maxAge":{"type":"number"},"immutable":{"type":"boolean"}},"additionalProperties":false}]},"immutable":{"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`).","type":"boolean"}}},"mime":{"description":"How a file's media type is decided.","link":"https://github.com/webpack/webpack-dev-middleware#mime","type":"object","additionalProperties":false,"properties":{"types":{"description":"Allows a user to register custom mime types or extension mappings.","type":"object"},"default":{"description":"Allows a user to register a default mime type when we can't determine the content type.","type":"string"}}},"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`; a string may be an absolute url for an endpoint reached on another origin or through a proxy, and an object says only the parts that differ and leaves the rest to be resolved in the page, which is the only place the rest is known.","anyOf":[{"type":"string","minLength":1},{"type":"object","additionalProperties":false,"properties":{"protocol":{"description":"The scheme the runtime connects over. Unset, or `auto`, follows the page — and a page served over TLS then gets a secure connection, because a browser refuses a plaintext one from it. Set explicitly, it is honoured as given; the one exception is a `hostname` naming every interface, which is resolved to the page's host and takes the page's scheme with it. Only whether the scheme is the secure one carries over, since the two transports do not share one.","anyOf":[{"enum":["auto"]},{"type":"string","minLength":1}]},"hostname":{"description":"The host the runtime connects to. An address a server listens on to mean every interface (`0.0.0.0`, `::`) is not somewhere a page can connect, so it resolves to the page's own host.","type":"string","minLength":1},"port":{"description":"The port the runtime connects to. `0` means a port the server picked, which resolves to the page's own.","anyOf":[{"type":"number"},{"type":"string","minLength":1}]},"pathname":{"description":"The path the runtime connects to. Defaults to the resolved `hot.path`.","type":"string"},"username":{"description":"The username the runtime authenticates with.","type":"string"},"password":{"description":"The password the runtime authenticates with. Sent only alongside a username, since basic authentication has no empty one.","type":"string"}}}]},"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"]}]},"apply":{"description":"What a build does to the page: `hmr` applies the update and reloads if it cannot be applied, `hmr-only` applies it and stops with a message if it cannot, `reload` loads the page again on a build that changed something, and `nothing` leaves the page alone until you reload it.","enum":["hmr","hmr-only","reload","nothing"]},"connect":{"description":"Whether to connect when the entry runs, and how the connection is held open. `false` does not connect; an object sets `retries` and `timeout`.","anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":false,"properties":{"retries":{"description":"How many times to reconnect before giving up. Server-Sent Events keep trying for as long as the page is open unless this is set, since a dev server is expected to come back.","type":"number","minimum":0},"timeout":{"description":"How long silence is tolerated before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript.","type":"number","minimum":1}}}]},"urlPrefix":{"description":"Prefix of the page-url parameter that overrides `apply` for a single page.","type":"string","minLength":1},"logging":{"description":"Logger level in the browser, or an object carrying the level and the name every message is labelled with in the console — a package embedding this runtime is the one a developer would report a problem to, so it can say its own name.","anyOf":[{"enum":["none","error","warn","info","log","verbose"]},{"type":"object","additionalProperties":false,"properties":{"level":{"enum":["none","error","warn","info","log","verbose"]},"name":{"description":"What every message is labelled with in the console. Defaults to this package's name.","type":"string","minLength":1}}}]},"dynamicPublicPath":{"description":"Prefix the endpoint path with the bundle's public path at runtime.","type":"boolean"},"hot":{"description":"Apply a build through Hot Module Replacement. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"liveReload":{"description":"Reload the page on a build that changed something, when `hot` is off. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"reload":{"description":"Reload the page when an update cannot be applied. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"autoConnect":{"description":"Connect as soon as the entry runs. Deprecated, and removed in the next major release: use `connect`.","type":"boolean"},"reconnect":{"description":"How many times to reconnect before giving up. Unset, Server-Sent Events keep trying for as long as the page is open — a dev server is expected to come back — while a WebSocket gives up after 10. Setting it applies to both. Deprecated, and removed in the next major release: use `connect`.","type":"number","minimum":0},"timeout":{"description":"How long the runtime tolerates silence before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript — a half-open socket there is terminated by the server instead. Deprecated, and removed in the next major release: use `connect`.","type":"number","exclusiveMinimum":0}}}}}]}},"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.cache !== undefined){let data0 = data.cache;const _errs2 = errors;if(errors === _errs2){if(data0 && typeof data0 == "object" && !Array.isArray(data0)){const _errs5 = errors;for(const key1 in data0){if(!((((key1 === "etag") || (key1 === "lastModified")) || (key1 === "control")) || (key1 === "immutable"))){validate10.errors = [{instancePath:instancePath+"/cache",schemaPath:"#/properties/cache/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}];return false;break;}}if(_errs5 === errors){if(data0.etag !== undefined){let data1 = data0.etag;const _errs6 = errors;if(!((data1 === "weak") || (data1 === "strong"))){validate10.errors = [{instancePath:instancePath+"/cache/etag",schemaPath:"#/properties/cache/properties/etag/enum",keyword:"enum",params:{allowedValues: schema11.properties.cache.properties.etag.enum},message:"must be equal to one of the allowed values"}];return false;}var valid1 = _errs6 === errors;}else {var valid1 = true;}if(valid1){if(data0.lastModified !== undefined){const _errs7 = errors;if(typeof data0.lastModified !== "boolean"){validate10.errors = [{instancePath:instancePath+"/cache/lastModified",schemaPath:"#/properties/cache/properties/lastModified/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data0.control !== undefined){let data3 = data0.control;const _errs9 = errors;const _errs10 = errors;let valid2 = false;const _errs11 = errors;if(typeof data3 !== "boolean"){const err0 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}var _valid0 = _errs11 === errors;valid2 = valid2 || _valid0;if(!valid2){const _errs13 = errors;if(!(typeof data3 == "number")){const err1 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/1/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}var _valid0 = _errs13 === errors;valid2 = valid2 || _valid0;if(!valid2){const _errs15 = errors;if(errors === _errs15){if(typeof data3 === "string"){if(data3.length < 1){const err2 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/2/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {const err3 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/2/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}var _valid0 = _errs15 === errors;valid2 = valid2 || _valid0;if(!valid2){const _errs17 = errors;if(errors === _errs17){if(data3 && typeof data3 == "object" && !Array.isArray(data3)){const _errs19 = errors;for(const key2 in data3){if(!((key2 === "maxAge") || (key2 === "immutable"))){const err4 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/3/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;break;}}if(_errs19 === errors){if(data3.maxAge !== undefined){const _errs20 = errors;if(!(typeof data3.maxAge == "number")){const err5 = {instancePath:instancePath+"/cache/control/maxAge",schemaPath:"#/properties/cache/properties/control/anyOf/3/properties/maxAge/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}var valid3 = _errs20 === errors;}else {var valid3 = true;}if(valid3){if(data3.immutable !== undefined){const _errs22 = errors;if(typeof data3.immutable !== "boolean"){const err6 = {instancePath:instancePath+"/cache/control/immutable",schemaPath:"#/properties/cache/properties/control/anyOf/3/properties/immutable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}var valid3 = _errs22 === errors;}else {var valid3 = true;}}}}else {const err7 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/3/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}var _valid0 = _errs17 === errors;valid2 = valid2 || _valid0;}}}if(!valid2){const err8 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs10;if(vErrors !== null){if(_errs10){vErrors.length = _errs10;}else {vErrors = null;}}}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data0.immutable !== undefined){const _errs24 = errors;if(typeof data0.immutable !== "boolean"){validate10.errors = [{instancePath:instancePath+"/cache/immutable",schemaPath:"#/properties/cache/properties/immutable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs24 === errors;}else {var valid1 = true;}}}}}}else {validate10.errors = [{instancePath:instancePath+"/cache",schemaPath:"#/properties/cache/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid0 = _errs2 === errors;}else {var valid0 = true;}if(valid0){if(data.mime !== undefined){let data7 = data.mime;const _errs26 = errors;if(errors === _errs26){if(data7 && typeof data7 == "object" && !Array.isArray(data7)){const _errs29 = errors;for(const key3 in data7){if(!((key3 === "types") || (key3 === "default"))){validate10.errors = [{instancePath:instancePath+"/mime",schemaPath:"#/properties/mime/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"}];return false;break;}}if(_errs29 === errors){if(data7.types !== undefined){let data8 = data7.types;const _errs30 = errors;if(!(data8 && typeof data8 == "object" && !Array.isArray(data8))){validate10.errors = [{instancePath:instancePath+"/mime/types",schemaPath:"#/properties/mime/properties/types/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid4 = _errs30 === errors;}else {var valid4 = true;}if(valid4){if(data7.default !== undefined){const _errs32 = errors;if(typeof data7.default !== "string"){validate10.errors = [{instancePath:instancePath+"/mime/default",schemaPath:"#/properties/mime/properties/default/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid4 = _errs32 === errors;}else {var valid4 = true;}}}}else {validate10.errors = [{instancePath:instancePath+"/mime",schemaPath:"#/properties/mime/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid0 = _errs26 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypes !== undefined){let data10 = data.mimeTypes;const _errs34 = errors;if(!(data10 && typeof data10 == "object" && !Array.isArray(data10))){validate10.errors = [{instancePath:instancePath+"/mimeTypes",schemaPath:"#/properties/mimeTypes/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid0 = _errs34 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypeDefault !== undefined){const _errs37 = 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 = _errs37 === errors;}else {var valid0 = true;}if(valid0){if(data.writeToDisk !== undefined){let data12 = data.writeToDisk;const _errs40 = errors;const _errs41 = errors;let valid5 = false;const _errs42 = errors;if(typeof data12 !== "boolean"){const err9 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}var _valid1 = _errs42 === errors;valid5 = valid5 || _valid1;if(!valid5){const _errs44 = errors;if(!(data12 instanceof Function)){const err10 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}var _valid1 = _errs44 === errors;valid5 = valid5 || _valid1;}if(!valid5){const err11 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs41;if(vErrors !== null){if(_errs41){vErrors.length = _errs41;}else {vErrors = null;}}}var valid0 = _errs40 === errors;}else {var valid0 = true;}if(valid0){if(data.methods !== undefined){let data13 = data.methods;const _errs46 = errors;if(errors === _errs46){if(Array.isArray(data13)){var valid6 = true;const len0 = data13.length;for(let i0=0; i0=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {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 = [err50];}else {vErrors.push(err50);}errors++;}}var valid16 = _errs137 === errors;}else {var valid16 = true;}if(valid16){if(data32.server !== undefined){let data36 = data32.server;const _errs139 = errors;if(errors === _errs139){if(data36 && typeof data36 == "object" && !Array.isArray(data36)){}else {const err51 = {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 = [err51];}else {vErrors.push(err51);}errors++;}}var valid16 = _errs139 === errors;}else {var valid16 = true;}if(valid16){if(data32.progress !== undefined){const _errs142 = errors;if(typeof data32.progress !== "boolean"){const err52 = {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 = [err52];}else {vErrors.push(err52);}errors++;}var valid16 = _errs142 === errors;}else {var valid16 = true;}if(valid16){if(data32.token !== undefined){let data38 = data32.token;const _errs144 = errors;const _errs145 = errors;let valid18 = false;const _errs146 = errors;if(typeof data38 !== "boolean"){const err53 = {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 = [err53];}else {vErrors.push(err53);}errors++;}var _valid9 = _errs146 === errors;valid18 = valid18 || _valid9;if(!valid18){const _errs148 = errors;if(errors === _errs148){if(typeof data38 === "string"){if(data38.length < 1){const err54 = {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 = [err54];}else {vErrors.push(err54);}errors++;}}else {const err55 = {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 = [err55];}else {vErrors.push(err55);}errors++;}}var _valid9 = _errs148 === errors;valid18 = valid18 || _valid9;}if(!valid18){const err56 = {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 = [err56];}else {vErrors.push(err56);}errors++;}else {errors = _errs145;if(vErrors !== null){if(_errs145){vErrors.length = _errs145;}else {vErrors = null;}}}var valid16 = _errs144 === errors;}else {var valid16 = true;}if(valid16){if(data32.cors !== undefined){let data39 = data32.cors;const _errs151 = errors;const _errs152 = errors;let valid19 = false;const _errs153 = errors;if(typeof data39 !== "boolean"){const err57 = {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 = [err57];}else {vErrors.push(err57);}errors++;}var _valid10 = _errs153 === errors;valid19 = valid19 || _valid10;if(!valid19){const _errs155 = errors;const _errs157 = errors;let valid21 = false;const _errs158 = errors;if(errors === _errs158){if(typeof data39 === "string"){if(data39.length < 1){const err58 = {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 = [err58];}else {vErrors.push(err58);}errors++;}}else {const err59 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}var _valid11 = _errs158 === errors;valid21 = valid21 || _valid11;if(!valid21){const _errs160 = errors;if(!(data39 instanceof RegExp)){const err60 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}var _valid11 = _errs160 === errors;valid21 = valid21 || _valid11;if(!valid21){const _errs161 = errors;if(errors === _errs161){if(Array.isArray(data39)){if(data39.length < 1){const err61 = {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 = [err61];}else {vErrors.push(err61);}errors++;}else {var valid22 = true;const len2 = data39.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}}else {const err119 = {instancePath:instancePath+"/hot/client/connect/retries",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/connect/anyOf/1/properties/retries/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}}var valid38 = _errs251 === errors;}else {var valid38 = true;}if(valid38){if(data59.timeout !== undefined){let data61 = data59.timeout;const _errs253 = errors;if(errors === _errs253){if(typeof data61 == "number"){if(data61 < 1 || isNaN(data61)){const err120 = {instancePath:instancePath+"/hot/client/connect/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/connect/anyOf/1/properties/timeout/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}}else {const err121 = {instancePath:instancePath+"/hot/client/connect/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/connect/anyOf/1/properties/timeout/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}var valid38 = _errs253 === errors;}else {var valid38 = true;}}}}else {const err122 = {instancePath:instancePath+"/hot/client/connect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/connect/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}}var _valid21 = _errs248 === errors;valid37 = valid37 || _valid21;}if(!valid37){const err123 = {instancePath:instancePath+"/hot/client/connect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/connect/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}else {errors = _errs245;if(vErrors !== null){if(_errs245){vErrors.length = _errs245;}else {vErrors = null;}}}var valid30 = _errs244 === errors;}else {var valid30 = true;}if(valid30){if(data45.urlPrefix !== undefined){let data62 = data45.urlPrefix;const _errs255 = errors;if(errors === _errs255){if(typeof data62 === "string"){if(data62.length < 1){const err124 = {instancePath:instancePath+"/hot/client/urlPrefix",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/urlPrefix/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}}else {const err125 = {instancePath:instancePath+"/hot/client/urlPrefix",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/urlPrefix/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}}var valid30 = _errs255 === errors;}else {var valid30 = true;}if(valid30){if(data45.logging !== undefined){let data63 = data45.logging;const _errs257 = errors;const _errs258 = errors;let valid39 = false;const _errs259 = errors;if(!((((((data63 === "none") || (data63 === "error")) || (data63 === "warn")) || (data63 === "info")) || (data63 === "log")) || (data63 === "verbose"))){const err126 = {instancePath:instancePath+"/hot/client/logging",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/logging/anyOf/0/enum",keyword:"enum",params:{allowedValues: schema11.properties.hot.anyOf[1].properties.client.properties.logging.anyOf[0].enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}var _valid22 = _errs259 === errors;valid39 = valid39 || _valid22;if(!valid39){const _errs260 = errors;if(errors === _errs260){if(data63 && typeof data63 == "object" && !Array.isArray(data63)){const _errs262 = errors;for(const key11 in data63){if(!((key11 === "level") || (key11 === "name"))){const err127 = {instancePath:instancePath+"/hot/client/logging",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/logging/anyOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key11},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;break;}}if(_errs262 === errors){if(data63.level !== undefined){let data64 = data63.level;const _errs263 = errors;if(!((((((data64 === "none") || (data64 === "error")) || (data64 === "warn")) || (data64 === "info")) || (data64 === "log")) || (data64 === "verbose"))){const err128 = {instancePath:instancePath+"/hot/client/logging/level",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/logging/anyOf/1/properties/level/enum",keyword:"enum",params:{allowedValues: schema11.properties.hot.anyOf[1].properties.client.properties.logging.anyOf[1].properties.level.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}var valid40 = _errs263 === errors;}else {var valid40 = true;}if(valid40){if(data63.name !== undefined){let data65 = data63.name;const _errs264 = errors;if(errors === _errs264){if(typeof data65 === "string"){if(data65.length < 1){const err129 = {instancePath:instancePath+"/hot/client/logging/name",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/logging/anyOf/1/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}}else {const err130 = {instancePath:instancePath+"/hot/client/logging/name",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/logging/anyOf/1/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}}var valid40 = _errs264 === errors;}else {var valid40 = true;}}}}else {const err131 = {instancePath:instancePath+"/hot/client/logging",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/logging/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}}var _valid22 = _errs260 === errors;valid39 = valid39 || _valid22;}if(!valid39){const err132 = {instancePath:instancePath+"/hot/client/logging",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/logging/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;}else {errors = _errs258;if(vErrors !== null){if(_errs258){vErrors.length = _errs258;}else {vErrors = null;}}}var valid30 = _errs257 === errors;}else {var valid30 = true;}if(valid30){if(data45.dynamicPublicPath !== undefined){const _errs266 = errors;if(typeof data45.dynamicPublicPath !== "boolean"){const err133 = {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 = [err133];}else {vErrors.push(err133);}errors++;}var valid30 = _errs266 === errors;}else {var valid30 = true;}if(valid30){if(data45.hot !== undefined){const _errs268 = errors;if(typeof data45.hot !== "boolean"){const err134 = {instancePath:instancePath+"/hot/client/hot",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/hot/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}var valid30 = _errs268 === errors;}else {var valid30 = true;}if(valid30){if(data45.liveReload !== undefined){const _errs270 = errors;if(typeof data45.liveReload !== "boolean"){const err135 = {instancePath:instancePath+"/hot/client/liveReload",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/liveReload/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}var valid30 = _errs270 === errors;}else {var valid30 = true;}if(valid30){if(data45.reload !== undefined){const _errs272 = errors;if(typeof data45.reload !== "boolean"){const err136 = {instancePath:instancePath+"/hot/client/reload",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/reload/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err136];}else {vErrors.push(err136);}errors++;}var valid30 = _errs272 === errors;}else {var valid30 = true;}if(valid30){if(data45.autoConnect !== undefined){const _errs274 = errors;if(typeof data45.autoConnect !== "boolean"){const err137 = {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 = [err137];}else {vErrors.push(err137);}errors++;}var valid30 = _errs274 === errors;}else {var valid30 = true;}if(valid30){if(data45.reconnect !== undefined){let data71 = data45.reconnect;const _errs276 = errors;if(errors === _errs276){if(typeof data71 == "number"){if(data71 < 0 || isNaN(data71)){const err138 = {instancePath:instancePath+"/hot/client/reconnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/reconnect/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}}else {const err139 = {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 = [err139];}else {vErrors.push(err139);}errors++;}}var valid30 = _errs276 === errors;}else {var valid30 = true;}if(valid30){if(data45.timeout !== undefined){let data72 = data45.timeout;const _errs278 = errors;if(errors === _errs278){if(typeof data72 == "number"){if(data72 <= 0 || isNaN(data72)){const err140 = {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 = [err140];}else {vErrors.push(err140);}errors++;}}else {const err141 = {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 = [err141];}else {vErrors.push(err141);}errors++;}}var valid30 = _errs278 === errors;}else {var valid30 = true;}}}}}}}}}}}}}}}}}}}else {const err142 = {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 = [err142];}else {vErrors.push(err142);}errors++;}}var valid16 = _errs197 === errors;}else {var valid16 = true;}}}}}}}}}}}}else {const err143 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}}var _valid7 = _errs128 === errors;valid15 = valid15 || _valid7;}if(!valid15){const err144 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs125;if(vErrors !== null){if(_errs125){vErrors.length = _errs125;}else {vErrors = null;}}}var valid0 = _errs124 === 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":{"HotClient":{"description":"Options handed to the browser runtime through its entry query.","type":"object","additionalProperties":false,"properties":{"transport":{"description":"Which transport the runtime speaks: `sse` or `ws`, or a module that exports a client class of your own, which is used in place of the built-in one. Defaults to the resolved `hot.transport`.","anyOf":[{"enum":["sse","ws"]},{"type":"string","minLength":1}]},"path":{"description":"Where the runtime connects. Defaults to the resolved `hot.path`; a string may be an absolute url for an endpoint reached on another origin or through a proxy, and an object says only the parts that differ and leaves the rest to be resolved in the page, which is the only place the rest is known.","anyOf":[{"type":"string","minLength":1},{"type":"object","additionalProperties":false,"properties":{"protocol":{"description":"The scheme the runtime connects over. Unset, or `auto`, follows the page — and a page served over TLS then gets a secure connection, because a browser refuses a plaintext one from it. Set explicitly, it is honoured as given; the one exception is a `hostname` naming every interface, which is resolved to the page's host and takes the page's scheme with it. Only whether the scheme is the secure one carries over, since the two transports do not share one.","anyOf":[{"enum":["auto"]},{"type":"string","minLength":1}]},"hostname":{"description":"The host the runtime connects to. An address a server listens on to mean every interface (`0.0.0.0`, `::`) is not somewhere a page can connect, so it resolves to the page's own host.","type":"string","minLength":1},"port":{"description":"The port the runtime connects to. `0` means a port the server picked, which resolves to the page's own.","anyOf":[{"type":"number"},{"type":"string","minLength":1}]},"pathname":{"description":"The path the runtime connects to. Defaults to the resolved `hot.path`.","type":"string"},"username":{"description":"The username the runtime authenticates with.","type":"string"},"password":{"description":"The password the runtime authenticates with. Sent only alongside a username, since basic authentication has no empty one.","type":"string"}}}]},"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"]}]},"apply":{"description":"What a build does to the page: `hmr` applies the update and reloads if it cannot be applied, `hmr-only` applies it and stops with a message if it cannot, `reload` loads the page again on a build that changed something, and `nothing` leaves the page alone until you reload it.","enum":["hmr","hmr-only","reload","nothing"]},"connect":{"description":"Whether to connect when the entry runs, and how the connection is held open. `false` does not connect; an object sets `retries` and `timeout`.","anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":false,"properties":{"retries":{"description":"How many times to reconnect before giving up. Server-Sent Events keep trying for as long as the page is open unless this is set, since a dev server is expected to come back.","type":"number","minimum":0},"timeout":{"description":"How long silence is tolerated before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript.","type":"number","minimum":1}}}]},"urlPrefix":{"description":"Prefix of the page-url parameter that overrides `apply` for a single page.","type":"string","minLength":1},"logging":{"description":"Logger level in the browser, or an object carrying the level and the name every message is labelled with in the console — a package embedding this runtime is the one a developer would report a problem to, so it can say its own name.","anyOf":[{"enum":["none","error","warn","info","log","verbose"]},{"type":"object","additionalProperties":false,"properties":{"level":{"enum":["none","error","warn","info","log","verbose"]},"name":{"description":"What every message is labelled with in the console. Defaults to this package's name.","type":"string","minLength":1}}}]},"dynamicPublicPath":{"description":"Prefix the endpoint path with the bundle's public path at runtime.","type":"boolean"},"hot":{"description":"Apply a build through Hot Module Replacement. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"liveReload":{"description":"Reload the page on a build that changed something, when `hot` is off. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"reload":{"description":"Reload the page when an update cannot be applied. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"autoConnect":{"description":"Connect as soon as the entry runs. Deprecated, and removed in the next major release: use `connect`.","type":"boolean"},"reconnect":{"description":"How many times to reconnect before giving up. Unset, Server-Sent Events keep trying for as long as the page is open — a dev server is expected to come back — while a WebSocket gives up after 10. Setting it applies to both. Deprecated, and removed in the next major release: use `connect`.","type":"number","minimum":0},"timeout":{"description":"How long the runtime tolerates silence before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript — a half-open socket there is terminated by the server instead. Deprecated, and removed in the next major release: use `connect`.","type":"number","exclusiveMinimum":0}}},"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":{"cache":{"description":"How responses are cached.","link":"https://github.com/webpack/webpack-dev-middleware#cache","type":"object","additionalProperties":false,"properties":{"etag":{"description":"Enable or disable etag generation.","enum":["weak","strong"]},"lastModified":{"description":"Enable or disable `Last-Modified` header. Uses the file system's last modified value.","type":"boolean"},"control":{"description":"Enable or disable setting `Cache-Control` response header.","anyOf":[{"type":"boolean"},{"type":"number"},{"type":"string","minLength":1},{"type":"object","properties":{"maxAge":{"type":"number"},"immutable":{"type":"boolean"}},"additionalProperties":false}]},"immutable":{"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`).","type":"boolean"}}},"mime":{"description":"How a file's media type is decided.","link":"https://github.com/webpack/webpack-dev-middleware#mime","type":"object","additionalProperties":false,"properties":{"types":{"description":"Allows a user to register custom mime types or extension mappings.","type":"object"},"default":{"description":"Allows a user to register a default mime type when we can't determine the content type.","type":"string"}}},"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},"ws":{"description":"Options for the `ws` server behind the `ws` transport: compression, payload limits, `verifyClient`, or a `port` or a `server` of its own to listen on instead of answering the upgrades it is handed. `path`, `noServer` and `clientTracking` are the middleware's and are ignored here.","link":"https://github.com/webpack/webpack-dev-middleware#hot","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. `false` adds no runtime to the page while still applying `HotModuleReplacementPlugin`, for a page that wires a client of its own.","link":"https://github.com/webpack/webpack-dev-middleware#hot","anyOf":[{"enum":[false]},{"$ref":"#/definitions/HotClient"}]}}}]}},"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 schema14 = {"description":"Options handed to the browser runtime through its entry query.","type":"object","additionalProperties":false,"properties":{"transport":{"description":"Which transport the runtime speaks: `sse` or `ws`, or a module that exports a client class of your own, which is used in place of the built-in one. Defaults to the resolved `hot.transport`.","anyOf":[{"enum":["sse","ws"]},{"type":"string","minLength":1}]},"path":{"description":"Where the runtime connects. Defaults to the resolved `hot.path`; a string may be an absolute url for an endpoint reached on another origin or through a proxy, and an object says only the parts that differ and leaves the rest to be resolved in the page, which is the only place the rest is known.","anyOf":[{"type":"string","minLength":1},{"type":"object","additionalProperties":false,"properties":{"protocol":{"description":"The scheme the runtime connects over. Unset, or `auto`, follows the page — and a page served over TLS then gets a secure connection, because a browser refuses a plaintext one from it. Set explicitly, it is honoured as given; the one exception is a `hostname` naming every interface, which is resolved to the page's host and takes the page's scheme with it. Only whether the scheme is the secure one carries over, since the two transports do not share one.","anyOf":[{"enum":["auto"]},{"type":"string","minLength":1}]},"hostname":{"description":"The host the runtime connects to. An address a server listens on to mean every interface (`0.0.0.0`, `::`) is not somewhere a page can connect, so it resolves to the page's own host.","type":"string","minLength":1},"port":{"description":"The port the runtime connects to. `0` means a port the server picked, which resolves to the page's own.","anyOf":[{"type":"number"},{"type":"string","minLength":1}]},"pathname":{"description":"The path the runtime connects to. Defaults to the resolved `hot.path`.","type":"string"},"username":{"description":"The username the runtime authenticates with.","type":"string"},"password":{"description":"The password the runtime authenticates with. Sent only alongside a username, since basic authentication has no empty one.","type":"string"}}}]},"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"]}]},"apply":{"description":"What a build does to the page: `hmr` applies the update and reloads if it cannot be applied, `hmr-only` applies it and stops with a message if it cannot, `reload` loads the page again on a build that changed something, and `nothing` leaves the page alone until you reload it.","enum":["hmr","hmr-only","reload","nothing"]},"connect":{"description":"Whether to connect when the entry runs, and how the connection is held open. `false` does not connect; an object sets `retries` and `timeout`.","anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":false,"properties":{"retries":{"description":"How many times to reconnect before giving up. Server-Sent Events keep trying for as long as the page is open unless this is set, since a dev server is expected to come back.","type":"number","minimum":0},"timeout":{"description":"How long silence is tolerated before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript.","type":"number","minimum":1}}}]},"urlPrefix":{"description":"Prefix of the page-url parameter that overrides `apply` for a single page.","type":"string","minLength":1},"logging":{"description":"Logger level in the browser, or an object carrying the level and the name every message is labelled with in the console — a package embedding this runtime is the one a developer would report a problem to, so it can say its own name.","anyOf":[{"enum":["none","error","warn","info","log","verbose"]},{"type":"object","additionalProperties":false,"properties":{"level":{"enum":["none","error","warn","info","log","verbose"]},"name":{"description":"What every message is labelled with in the console. Defaults to this package's name.","type":"string","minLength":1}}}]},"dynamicPublicPath":{"description":"Prefix the endpoint path with the bundle's public path at runtime.","type":"boolean"},"hot":{"description":"Apply a build through Hot Module Replacement. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"liveReload":{"description":"Reload the page on a build that changed something, when `hot` is off. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"reload":{"description":"Reload the page when an update cannot be applied. Deprecated, and removed in the next major release: use `apply`.","type":"boolean"},"autoConnect":{"description":"Connect as soon as the entry runs. Deprecated, and removed in the next major release: use `connect`.","type":"boolean"},"reconnect":{"description":"How many times to reconnect before giving up. Unset, Server-Sent Events keep trying for as long as the page is open — a dev server is expected to come back — while a WebSocket gives up after 10. Setting it applies to both. Deprecated, and removed in the next major release: use `connect`.","type":"number","minimum":0},"timeout":{"description":"How long the runtime tolerates silence before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript — a half-open socket there is terminated by the server instead. Deprecated, and removed in the next major release: use `connect`.","type":"number","exclusiveMinimum":0}}};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.cache !== undefined){let data0 = data.cache;const _errs2 = errors;if(errors === _errs2){if(data0 && typeof data0 == "object" && !Array.isArray(data0)){const _errs5 = errors;for(const key1 in data0){if(!((((key1 === "etag") || (key1 === "lastModified")) || (key1 === "control")) || (key1 === "immutable"))){validate10.errors = [{instancePath:instancePath+"/cache",schemaPath:"#/properties/cache/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}];return false;break;}}if(_errs5 === errors){if(data0.etag !== undefined){let data1 = data0.etag;const _errs6 = errors;if(!((data1 === "weak") || (data1 === "strong"))){validate10.errors = [{instancePath:instancePath+"/cache/etag",schemaPath:"#/properties/cache/properties/etag/enum",keyword:"enum",params:{allowedValues: schema11.properties.cache.properties.etag.enum},message:"must be equal to one of the allowed values"}];return false;}var valid1 = _errs6 === errors;}else {var valid1 = true;}if(valid1){if(data0.lastModified !== undefined){const _errs7 = errors;if(typeof data0.lastModified !== "boolean"){validate10.errors = [{instancePath:instancePath+"/cache/lastModified",schemaPath:"#/properties/cache/properties/lastModified/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data0.control !== undefined){let data3 = data0.control;const _errs9 = errors;const _errs10 = errors;let valid2 = false;const _errs11 = errors;if(typeof data3 !== "boolean"){const err0 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}var _valid0 = _errs11 === errors;valid2 = valid2 || _valid0;if(!valid2){const _errs13 = errors;if(!(typeof data3 == "number")){const err1 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/1/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}var _valid0 = _errs13 === errors;valid2 = valid2 || _valid0;if(!valid2){const _errs15 = errors;if(errors === _errs15){if(typeof data3 === "string"){if(data3.length < 1){const err2 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/2/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {const err3 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/2/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}var _valid0 = _errs15 === errors;valid2 = valid2 || _valid0;if(!valid2){const _errs17 = errors;if(errors === _errs17){if(data3 && typeof data3 == "object" && !Array.isArray(data3)){const _errs19 = errors;for(const key2 in data3){if(!((key2 === "maxAge") || (key2 === "immutable"))){const err4 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/3/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;break;}}if(_errs19 === errors){if(data3.maxAge !== undefined){const _errs20 = errors;if(!(typeof data3.maxAge == "number")){const err5 = {instancePath:instancePath+"/cache/control/maxAge",schemaPath:"#/properties/cache/properties/control/anyOf/3/properties/maxAge/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}var valid3 = _errs20 === errors;}else {var valid3 = true;}if(valid3){if(data3.immutable !== undefined){const _errs22 = errors;if(typeof data3.immutable !== "boolean"){const err6 = {instancePath:instancePath+"/cache/control/immutable",schemaPath:"#/properties/cache/properties/control/anyOf/3/properties/immutable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}var valid3 = _errs22 === errors;}else {var valid3 = true;}}}}else {const err7 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf/3/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}var _valid0 = _errs17 === errors;valid2 = valid2 || _valid0;}}}if(!valid2){const err8 = {instancePath:instancePath+"/cache/control",schemaPath:"#/properties/cache/properties/control/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs10;if(vErrors !== null){if(_errs10){vErrors.length = _errs10;}else {vErrors = null;}}}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data0.immutable !== undefined){const _errs24 = errors;if(typeof data0.immutable !== "boolean"){validate10.errors = [{instancePath:instancePath+"/cache/immutable",schemaPath:"#/properties/cache/properties/immutable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs24 === errors;}else {var valid1 = true;}}}}}}else {validate10.errors = [{instancePath:instancePath+"/cache",schemaPath:"#/properties/cache/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid0 = _errs2 === errors;}else {var valid0 = true;}if(valid0){if(data.mime !== undefined){let data7 = data.mime;const _errs26 = errors;if(errors === _errs26){if(data7 && typeof data7 == "object" && !Array.isArray(data7)){const _errs29 = errors;for(const key3 in data7){if(!((key3 === "types") || (key3 === "default"))){validate10.errors = [{instancePath:instancePath+"/mime",schemaPath:"#/properties/mime/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"}];return false;break;}}if(_errs29 === errors){if(data7.types !== undefined){let data8 = data7.types;const _errs30 = errors;if(!(data8 && typeof data8 == "object" && !Array.isArray(data8))){validate10.errors = [{instancePath:instancePath+"/mime/types",schemaPath:"#/properties/mime/properties/types/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid4 = _errs30 === errors;}else {var valid4 = true;}if(valid4){if(data7.default !== undefined){const _errs32 = errors;if(typeof data7.default !== "string"){validate10.errors = [{instancePath:instancePath+"/mime/default",schemaPath:"#/properties/mime/properties/default/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid4 = _errs32 === errors;}else {var valid4 = true;}}}}else {validate10.errors = [{instancePath:instancePath+"/mime",schemaPath:"#/properties/mime/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid0 = _errs26 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypes !== undefined){let data10 = data.mimeTypes;const _errs34 = errors;if(!(data10 && typeof data10 == "object" && !Array.isArray(data10))){validate10.errors = [{instancePath:instancePath+"/mimeTypes",schemaPath:"#/properties/mimeTypes/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid0 = _errs34 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypeDefault !== undefined){const _errs37 = 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 = _errs37 === errors;}else {var valid0 = true;}if(valid0){if(data.writeToDisk !== undefined){let data12 = data.writeToDisk;const _errs40 = errors;const _errs41 = errors;let valid5 = false;const _errs42 = errors;if(typeof data12 !== "boolean"){const err9 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}var _valid1 = _errs42 === errors;valid5 = valid5 || _valid1;if(!valid5){const _errs44 = errors;if(!(data12 instanceof Function)){const err10 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}var _valid1 = _errs44 === errors;valid5 = valid5 || _valid1;}if(!valid5){const err11 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs41;if(vErrors !== null){if(_errs41){vErrors.length = _errs41;}else {vErrors = null;}}}var valid0 = _errs40 === errors;}else {var valid0 = true;}if(valid0){if(data.methods !== undefined){let data13 = data.methods;const _errs46 = errors;if(errors === _errs46){if(Array.isArray(data13)){var valid6 = true;const len0 = data13.length;for(let i0=0; i0=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {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 = [err50];}else {vErrors.push(err50);}errors++;}}var valid16 = _errs137 === errors;}else {var valid16 = true;}if(valid16){if(data32.server !== undefined){let data36 = data32.server;const _errs139 = errors;if(errors === _errs139){if(data36 && typeof data36 == "object" && !Array.isArray(data36)){}else {const err51 = {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 = [err51];}else {vErrors.push(err51);}errors++;}}var valid16 = _errs139 === errors;}else {var valid16 = true;}if(valid16){if(data32.ws !== undefined){let data37 = data32.ws;const _errs142 = errors;if(errors === _errs142){if(data37 && typeof data37 == "object" && !Array.isArray(data37)){}else {const err52 = {instancePath:instancePath+"/hot/ws",schemaPath:"#/properties/hot/anyOf/1/properties/ws/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}var valid16 = _errs142 === errors;}else {var valid16 = true;}if(valid16){if(data32.progress !== undefined){const _errs146 = errors;if(typeof data32.progress !== "boolean"){const err53 = {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 = [err53];}else {vErrors.push(err53);}errors++;}var valid16 = _errs146 === errors;}else {var valid16 = true;}if(valid16){if(data32.token !== undefined){let data39 = data32.token;const _errs148 = errors;const _errs149 = errors;let valid18 = false;const _errs150 = errors;if(typeof data39 !== "boolean"){const err54 = {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 = [err54];}else {vErrors.push(err54);}errors++;}var _valid9 = _errs150 === errors;valid18 = valid18 || _valid9;if(!valid18){const _errs152 = errors;if(errors === _errs152){if(typeof data39 === "string"){if(data39.length < 1){const err55 = {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 = [err55];}else {vErrors.push(err55);}errors++;}}else {const err56 = {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 = [err56];}else {vErrors.push(err56);}errors++;}}var _valid9 = _errs152 === errors;valid18 = valid18 || _valid9;}if(!valid18){const err57 = {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 = [err57];}else {vErrors.push(err57);}errors++;}else {errors = _errs149;if(vErrors !== null){if(_errs149){vErrors.length = _errs149;}else {vErrors = null;}}}var valid16 = _errs148 === errors;}else {var valid16 = true;}if(valid16){if(data32.cors !== undefined){let data40 = data32.cors;const _errs155 = errors;const _errs156 = errors;let valid19 = false;const _errs157 = errors;if(typeof data40 !== "boolean"){const err58 = {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 = [err58];}else {vErrors.push(err58);}errors++;}var _valid10 = _errs157 === errors;valid19 = valid19 || _valid10;if(!valid19){const _errs159 = errors;const _errs161 = errors;let valid21 = false;const _errs162 = errors;if(errors === _errs162){if(typeof data40 === "string"){if(data40.length < 1){const err59 = {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 = [err59];}else {vErrors.push(err59);}errors++;}}else {const err60 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}var _valid11 = _errs162 === errors;valid21 = valid21 || _valid11;if(!valid21){const _errs164 = errors;if(!(data40 instanceof RegExp)){const err61 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}var _valid11 = _errs164 === errors;valid21 = valid21 || _valid11;if(!valid21){const _errs165 = errors;if(errors === _errs165){if(Array.isArray(data40)){if(data40.length < 1){const err62 = {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 = [err62];}else {vErrors.push(err62);}errors++;}else {var valid22 = true;const len2 = data40.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}else {const err124 = {instancePath:instancePath+"/hot/client/connect/retries",schemaPath:"#/definitions/HotClient/properties/connect/anyOf/1/properties/retries/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}}var valid41 = _errs262 === errors;}else {var valid41 = true;}if(valid41){if(data60.timeout !== undefined){let data62 = data60.timeout;const _errs264 = errors;if(errors === _errs264){if(typeof data62 == "number"){if(data62 < 1 || isNaN(data62)){const err125 = {instancePath:instancePath+"/hot/client/connect/timeout",schemaPath:"#/definitions/HotClient/properties/connect/anyOf/1/properties/timeout/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}}else {const err126 = {instancePath:instancePath+"/hot/client/connect/timeout",schemaPath:"#/definitions/HotClient/properties/connect/anyOf/1/properties/timeout/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}}var valid41 = _errs264 === errors;}else {var valid41 = true;}}}}else {const err127 = {instancePath:instancePath+"/hot/client/connect",schemaPath:"#/definitions/HotClient/properties/connect/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;}}var _valid23 = _errs259 === errors;valid40 = valid40 || _valid23;}if(!valid40){const err128 = {instancePath:instancePath+"/hot/client/connect",schemaPath:"#/definitions/HotClient/properties/connect/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}else {errors = _errs256;if(vErrors !== null){if(_errs256){vErrors.length = _errs256;}else {vErrors = null;}}}var valid32 = _errs255 === errors;}else {var valid32 = true;}if(valid32){if(data46.urlPrefix !== undefined){let data63 = data46.urlPrefix;const _errs266 = errors;if(errors === _errs266){if(typeof data63 === "string"){if(data63.length < 1){const err129 = {instancePath:instancePath+"/hot/client/urlPrefix",schemaPath:"#/definitions/HotClient/properties/urlPrefix/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}}else {const err130 = {instancePath:instancePath+"/hot/client/urlPrefix",schemaPath:"#/definitions/HotClient/properties/urlPrefix/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}}var valid32 = _errs266 === errors;}else {var valid32 = true;}if(valid32){if(data46.logging !== undefined){let data64 = data46.logging;const _errs268 = errors;const _errs269 = errors;let valid42 = false;const _errs270 = errors;if(!((((((data64 === "none") || (data64 === "error")) || (data64 === "warn")) || (data64 === "info")) || (data64 === "log")) || (data64 === "verbose"))){const err131 = {instancePath:instancePath+"/hot/client/logging",schemaPath:"#/definitions/HotClient/properties/logging/anyOf/0/enum",keyword:"enum",params:{allowedValues: schema14.properties.logging.anyOf[0].enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}var _valid24 = _errs270 === errors;valid42 = valid42 || _valid24;if(!valid42){const _errs271 = errors;if(errors === _errs271){if(data64 && typeof data64 == "object" && !Array.isArray(data64)){const _errs273 = errors;for(const key11 in data64){if(!((key11 === "level") || (key11 === "name"))){const err132 = {instancePath:instancePath+"/hot/client/logging",schemaPath:"#/definitions/HotClient/properties/logging/anyOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key11},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;break;}}if(_errs273 === errors){if(data64.level !== undefined){let data65 = data64.level;const _errs274 = errors;if(!((((((data65 === "none") || (data65 === "error")) || (data65 === "warn")) || (data65 === "info")) || (data65 === "log")) || (data65 === "verbose"))){const err133 = {instancePath:instancePath+"/hot/client/logging/level",schemaPath:"#/definitions/HotClient/properties/logging/anyOf/1/properties/level/enum",keyword:"enum",params:{allowedValues: schema14.properties.logging.anyOf[1].properties.level.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err133];}else {vErrors.push(err133);}errors++;}var valid43 = _errs274 === errors;}else {var valid43 = true;}if(valid43){if(data64.name !== undefined){let data66 = data64.name;const _errs275 = errors;if(errors === _errs275){if(typeof data66 === "string"){if(data66.length < 1){const err134 = {instancePath:instancePath+"/hot/client/logging/name",schemaPath:"#/definitions/HotClient/properties/logging/anyOf/1/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}}else {const err135 = {instancePath:instancePath+"/hot/client/logging/name",schemaPath:"#/definitions/HotClient/properties/logging/anyOf/1/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}}var valid43 = _errs275 === errors;}else {var valid43 = true;}}}}else {const err136 = {instancePath:instancePath+"/hot/client/logging",schemaPath:"#/definitions/HotClient/properties/logging/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err136];}else {vErrors.push(err136);}errors++;}}var _valid24 = _errs271 === errors;valid42 = valid42 || _valid24;}if(!valid42){const err137 = {instancePath:instancePath+"/hot/client/logging",schemaPath:"#/definitions/HotClient/properties/logging/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err137];}else {vErrors.push(err137);}errors++;}else {errors = _errs269;if(vErrors !== null){if(_errs269){vErrors.length = _errs269;}else {vErrors = null;}}}var valid32 = _errs268 === errors;}else {var valid32 = true;}if(valid32){if(data46.dynamicPublicPath !== undefined){const _errs277 = errors;if(typeof data46.dynamicPublicPath !== "boolean"){const err138 = {instancePath:instancePath+"/hot/client/dynamicPublicPath",schemaPath:"#/definitions/HotClient/properties/dynamicPublicPath/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}var valid32 = _errs277 === errors;}else {var valid32 = true;}if(valid32){if(data46.hot !== undefined){const _errs279 = errors;if(typeof data46.hot !== "boolean"){const err139 = {instancePath:instancePath+"/hot/client/hot",schemaPath:"#/definitions/HotClient/properties/hot/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}var valid32 = _errs279 === errors;}else {var valid32 = true;}if(valid32){if(data46.liveReload !== undefined){const _errs281 = errors;if(typeof data46.liveReload !== "boolean"){const err140 = {instancePath:instancePath+"/hot/client/liveReload",schemaPath:"#/definitions/HotClient/properties/liveReload/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}var valid32 = _errs281 === errors;}else {var valid32 = true;}if(valid32){if(data46.reload !== undefined){const _errs283 = errors;if(typeof data46.reload !== "boolean"){const err141 = {instancePath:instancePath+"/hot/client/reload",schemaPath:"#/definitions/HotClient/properties/reload/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}var valid32 = _errs283 === errors;}else {var valid32 = true;}if(valid32){if(data46.autoConnect !== undefined){const _errs285 = errors;if(typeof data46.autoConnect !== "boolean"){const err142 = {instancePath:instancePath+"/hot/client/autoConnect",schemaPath:"#/definitions/HotClient/properties/autoConnect/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err142];}else {vErrors.push(err142);}errors++;}var valid32 = _errs285 === errors;}else {var valid32 = true;}if(valid32){if(data46.reconnect !== undefined){let data72 = data46.reconnect;const _errs287 = errors;if(errors === _errs287){if(typeof data72 == "number"){if(data72 < 0 || isNaN(data72)){const err143 = {instancePath:instancePath+"/hot/client/reconnect",schemaPath:"#/definitions/HotClient/properties/reconnect/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}}else {const err144 = {instancePath:instancePath+"/hot/client/reconnect",schemaPath:"#/definitions/HotClient/properties/reconnect/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;}}var valid32 = _errs287 === errors;}else {var valid32 = true;}if(valid32){if(data46.timeout !== undefined){let data73 = data46.timeout;const _errs289 = errors;if(errors === _errs289){if(typeof data73 == "number"){if(data73 <= 0 || isNaN(data73)){const err145 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/definitions/HotClient/properties/timeout/exclusiveMinimum",keyword:"exclusiveMinimum",params:{comparison: ">", limit: 0},message:"must be > 0"};if(vErrors === null){vErrors = [err145];}else {vErrors.push(err145);}errors++;}}else {const err146 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/definitions/HotClient/properties/timeout/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err146];}else {vErrors.push(err146);}errors++;}}var valid32 = _errs289 === errors;}else {var valid32 = true;}}}}}}}}}}}}}}}}}}}else {const err147 = {instancePath:instancePath+"/hot/client",schemaPath:"#/definitions/HotClient/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err147];}else {vErrors.push(err147);}errors++;}}var _valid16 = _errs204 === errors;valid30 = valid30 || _valid16;}if(!valid30){const err148 = {instancePath:instancePath+"/hot/client",schemaPath:"#/properties/hot/anyOf/1/properties/client/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err148];}else {vErrors.push(err148);}errors++;}else {errors = _errs202;if(vErrors !== null){if(_errs202){vErrors.length = _errs202;}else {vErrors = null;}}}var valid16 = _errs201 === errors;}else {var valid16 = true;}}}}}}}}}}}}}else {const err149 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err149];}else {vErrors.push(err149);}errors++;}}var _valid7 = _errs128 === errors;valid15 = valid15 || _valid7;}if(!valid15){const err150 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err150];}else {vErrors.push(err150);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs125;if(vErrors !== null){if(_errs125){vErrors.length = _errs125;}else {vErrors = null;}}}var valid0 = _errs124 === 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 bb258ae61..889673290 100644 --- a/src/options.json +++ b/src/options.json @@ -1,5 +1,195 @@ { "definitions": { + "HotClient": { + "description": "Options handed to the browser runtime through its entry query.", + "type": "object", + "additionalProperties": false, + "properties": { + "transport": { + "description": "Which transport the runtime speaks: `sse` or `ws`, or a module that exports a client class of your own, which is used in place of the built-in one. Defaults to the resolved `hot.transport`.", + "anyOf": [ + { + "enum": ["sse", "ws"] + }, + { + "type": "string", + "minLength": 1 + } + ] + }, + "path": { + "description": "Where the runtime connects. Defaults to the resolved `hot.path`; a string may be an absolute url for an endpoint reached on another origin or through a proxy, and an object says only the parts that differ and leaves the rest to be resolved in the page, which is the only place the rest is known.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "protocol": { + "description": "The scheme the runtime connects over. Unset, or `auto`, follows the page — and a page served over TLS then gets a secure connection, because a browser refuses a plaintext one from it. Set explicitly, it is honoured as given; the one exception is a `hostname` naming every interface, which is resolved to the page's host and takes the page's scheme with it. Only whether the scheme is the secure one carries over, since the two transports do not share one.", + "anyOf": [ + { + "enum": ["auto"] + }, + { + "type": "string", + "minLength": 1 + } + ] + }, + "hostname": { + "description": "The host the runtime connects to. An address a server listens on to mean every interface (`0.0.0.0`, `::`) is not somewhere a page can connect, so it resolves to the page's own host.", + "type": "string", + "minLength": 1 + }, + "port": { + "description": "The port the runtime connects to. `0` means a port the server picked, which resolves to the page's own.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1 + } + ] + }, + "pathname": { + "description": "The path the runtime connects to. Defaults to the resolved `hot.path`.", + "type": "string" + }, + "username": { + "description": "The username the runtime authenticates with.", + "type": "string" + }, + "password": { + "description": "The password the runtime authenticates with. Sent only alongside a username, since basic authentication has no empty one.", + "type": "string" + } + } + } + ] + }, + "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"] + } + ] + }, + "apply": { + "description": "What a build does to the page: `hmr` applies the update and reloads if it cannot be applied, `hmr-only` applies it and stops with a message if it cannot, `reload` loads the page again on a build that changed something, and `nothing` leaves the page alone until you reload it.", + "enum": ["hmr", "hmr-only", "reload", "nothing"] + }, + "connect": { + "description": "Whether to connect when the entry runs, and how the connection is held open. `false` does not connect; an object sets `retries` and `timeout`.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "retries": { + "description": "How many times to reconnect before giving up. Server-Sent Events keep trying for as long as the page is open unless this is set, since a dev server is expected to come back.", + "type": "number", + "minimum": 0 + }, + "timeout": { + "description": "How long silence is tolerated before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript.", + "type": "number", + "minimum": 1 + } + } + } + ] + }, + "urlPrefix": { + "description": "Prefix of the page-url parameter that overrides `apply` for a single page.", + "type": "string", + "minLength": 1 + }, + "logging": { + "description": "Logger level in the browser, or an object carrying the level and the name every message is labelled with in the console — a package embedding this runtime is the one a developer would report a problem to, so it can say its own name.", + "anyOf": [ + { + "enum": ["none", "error", "warn", "info", "log", "verbose"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "enum": ["none", "error", "warn", "info", "log", "verbose"] + }, + "name": { + "description": "What every message is labelled with in the console. Defaults to this package's name.", + "type": "string", + "minLength": 1 + } + } + } + ] + }, + "dynamicPublicPath": { + "description": "Prefix the endpoint path with the bundle's public path at runtime.", + "type": "boolean" + }, + "hot": { + "description": "Apply a build through Hot Module Replacement. Deprecated, and removed in the next major release: use `apply`.", + "type": "boolean" + }, + "liveReload": { + "description": "Reload the page on a build that changed something, when `hot` is off. Deprecated, and removed in the next major release: use `apply`.", + "type": "boolean" + }, + "reload": { + "description": "Reload the page when an update cannot be applied. Deprecated, and removed in the next major release: use `apply`.", + "type": "boolean" + }, + "autoConnect": { + "description": "Connect as soon as the entry runs. Deprecated, and removed in the next major release: use `connect`.", + "type": "boolean" + }, + "reconnect": { + "description": "How many times to reconnect before giving up. Unset, Server-Sent Events keep trying for as long as the page is open — a dev server is expected to come back — while a WebSocket gives up after 10. Setting it applies to both. Deprecated, and removed in the next major release: use `connect`.", + "type": "number", + "minimum": 0 + }, + "timeout": { + "description": "How long the runtime tolerates silence before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript — a half-open socket there is terminated by the server instead. Deprecated, and removed in the next major release: use `connect`.", + "type": "number", + "exclusiveMinimum": 0 + } + } + }, "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": [ @@ -310,12 +500,18 @@ "type": "object", "additionalProperties": true }, + "ws": { + "description": "Options for the `ws` server behind the `ws` transport: compression, payload limits, `verifyClient`, or a `port` or a `server` of its own to listen on instead of answering the upgrades it is handed. `path`, `noServer` and `clientTracking` are the middleware's and are ignored here.", + "link": "https://github.com/webpack/webpack-dev-middleware#hot", + "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 \u2014 for a client of your own that has to build the url itself \u2014 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.", + "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": [ { @@ -367,201 +563,16 @@ "type": "boolean" }, "client": { - "description": "Options handed to the browser runtime through its entry query.", + "description": "Options handed to the browser runtime through its entry query. `false` adds no runtime to the page while still applying `HotModuleReplacementPlugin`, for a page that wires a client of its own.", "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`; a string may be an absolute url for an endpoint reached on another origin or through a proxy, and an object says only the parts that differ and leaves the rest to be resolved in the page, which is the only place the rest is known.", - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "protocol": { - "description": "The scheme the runtime connects over. Unset, or `auto`, follows the page \u2014 and a page served over TLS then gets a secure connection, because a browser refuses a plaintext one from it. Set explicitly, it is honoured as given; the one exception is a `hostname` naming every interface, which is resolved to the page's host and takes the page's scheme with it. Only whether the scheme is the secure one carries over, since the two transports do not share one.", - "anyOf": [ - { - "enum": ["auto"] - }, - { - "type": "string", - "minLength": 1 - } - ] - }, - "hostname": { - "description": "The host the runtime connects to. An address a server listens on to mean every interface (`0.0.0.0`, `::`) is not somewhere a page can connect, so it resolves to the page's own host.", - "type": "string", - "minLength": 1 - }, - "port": { - "description": "The port the runtime connects to. `0` means a port the server picked, which resolves to the page's own.", - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "minLength": 1 - } - ] - }, - "pathname": { - "description": "The path the runtime connects to. Defaults to the resolved `hot.path`.", - "type": "string" - }, - "username": { - "description": "The username the runtime authenticates with.", - "type": "string" - }, - "password": { - "description": "The password the runtime authenticates with. Sent only alongside a username, since basic authentication has no empty one.", - "type": "string" - } - } - } - ] - }, - "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"] - } - ] - }, - "apply": { - "description": "What a build does to the page: `hmr` applies the update and reloads if it cannot be applied, `hmr-only` applies it and stops with a message if it cannot, `reload` loads the page again on a build that changed something, and `nothing` leaves the page alone until you reload it.", - "enum": ["hmr", "hmr-only", "reload", "nothing"] - }, - "connect": { - "description": "Whether to connect when the entry runs, and how the connection is held open. `false` does not connect; an object sets `retries` and `timeout`.", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "retries": { - "description": "How many times to reconnect before giving up. Server-Sent Events keep trying for as long as the page is open unless this is set, since a dev server is expected to come back.", - "type": "number", - "minimum": 0 - }, - "timeout": { - "description": "How long silence is tolerated before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript.", - "type": "number", - "minimum": 1 - } - } - } - ] - }, - "urlPrefix": { - "description": "Prefix of the page-url parameter that overrides `apply` for a single page.", - "type": "string", - "minLength": 1 - }, - "logging": { - "description": "Logger level in the browser, or an object carrying the level and the name every message is labelled with in the console \u2014 a package embedding this runtime is the one a developer would report a problem to, so it can say its own name.", - "anyOf": [ - { - "enum": [ - "none", - "error", - "warn", - "info", - "log", - "verbose" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "level": { - "enum": [ - "none", - "error", - "warn", - "info", - "log", - "verbose" - ] - }, - "name": { - "description": "What every message is labelled with in the console. Defaults to this package's name.", - "type": "string", - "minLength": 1 - } - } - } - ] - }, - "dynamicPublicPath": { - "description": "Prefix the endpoint path with the bundle's public path at runtime.", - "type": "boolean" - }, - "hot": { - "description": "Apply a build through Hot Module Replacement. Deprecated, and removed in the next major release: use `apply`.", - "type": "boolean" - }, - "liveReload": { - "description": "Reload the page on a build that changed something, when `hot` is off. Deprecated, and removed in the next major release: use `apply`.", - "type": "boolean" - }, - "reload": { - "description": "Reload the page when an update cannot be applied. Deprecated, and removed in the next major release: use `apply`.", - "type": "boolean" - }, - "autoConnect": { - "description": "Connect as soon as the entry runs. Deprecated, and removed in the next major release: use `connect`.", - "type": "boolean" - }, - "reconnect": { - "description": "How many times to reconnect before giving up. Unset, Server-Sent Events keep trying for as long as the page is open \u2014 a dev server is expected to come back \u2014 while a WebSocket gives up after 10. Setting it applies to both. Deprecated, and removed in the next major release: use `connect`.", - "type": "number", - "minimum": 0 + "anyOf": [ + { + "enum": [false] }, - "timeout": { - "description": "How long the runtime tolerates silence before reconnecting, in milliseconds. Server-Sent Events only: their heartbeat arrives as data the client can see, whereas a WebSocket's is a protocol ping the browser answers without telling JavaScript \u2014 a half-open socket there is terminated by the server instead. Deprecated, and removed in the next major release: use `connect`.", - "type": "number", - "exclusiveMinimum": 0 + { + "$ref": "#/definitions/HotClient" } - } + ] } } } diff --git a/src/servers/WebSocketServer.js b/src/servers/WebSocketServer.js index 08567246f..f52056c09 100644 --- a/src/servers/WebSocketServer.js +++ b/src/servers/WebSocketServer.js @@ -47,15 +47,22 @@ function requireWsServer() { * @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 {Record=} options.ws options for the `ws` server; a `port` or a `server` gives it one of its own * @param {Logger} logger logger * @returns {ClientStream} client stream */ function createWebSocketStream( - { path, heartbeat, cors, token = false }, + { path, heartbeat, cors, token = false, ws = {} }, logger, ) { const WebSocketServerImplementation = requireWsServer(); const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_WS); + // A `port` or a `server` is `ws` listening for itself rather than answering + // the upgrades it is handed. The handshake is then never seen here, so the + // token and the origins are checked through `verifyClient` instead — before + // the handshake completes, as they are on the shared server. + const ownsServer = + typeof ws.port !== "undefined" || typeof ws.server !== "undefined"; /** @type {Set} */ const clients = new Set(); /** @type {((client: WebSocket, req: IncomingMessage) => void) | undefined} */ @@ -65,8 +72,61 @@ function createWebSocketStream( /** @type {((req: IncomingMessage, socket: Duplex, head: Buffer) => void) | undefined} */ let upgradeListener; + /** + * Whether a handshake may go ahead, said the same way on either server: a + * reason is logged for the developer, and the refusal is a `403`. + * @param {IncomingMessage} req the request being upgraded + * @returns {boolean} true when it may + */ + const isAllowed = (req) => { + 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.`, + ); + + return false; + } + + 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.`, + ); + + return false; + } + + return true; + }; + + /** + * The check above, ahead of any `verifyClient` of the developer's own. + * @param {{ origin: string, secure: boolean, req: IncomingMessage }} info the handshake + * @param {(result: boolean, code?: number, message?: string, headers?: Record) => void} done called with the decision + */ + const verifyClient = (info, done) => { + if (!isAllowed(info.req)) { + done(false, 403); + + return; + } + + const own = ws.verifyClient; + + if (typeof own !== "function") { + done(true); + } else if (own.length >= 2) { + own(info, done); + } else { + done(Boolean(own(info))); + } + }; + const implementation = new WebSocketServerImplementation({ - noServer: true, + // Everything else `ws` takes is the developer's to set: compression, + // payload limits, subprotocols, a port or a server of its own. + ...ws, + ...(ownsServer ? { verifyClient } : { noServer: true }), + // The endpoint's path is `hot.path`, so the two cannot disagree. path, // `clients` is tracked here so a client is dropped the moment it closes, // which is what `hasClients` reads. @@ -171,6 +231,12 @@ function createWebSocketStream( * @returns {boolean} true when this endpoint answered the upgrade */ const handleUpgrade = (req, socket, head) => { + // The endpoint listens on a server of its own, so nothing on this one is + // its to answer. + if (ownsServer) { + return false; + } + // Another WebSocket endpoint on the same server owns this path. Said // rather than assumed, so a caller holding the server can go on to its own // endpoints instead of leaving the socket hanging. @@ -182,24 +248,7 @@ function createWebSocketStream( // 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.`, - ); - + if (!isAllowed(req)) { // Answered rather than dropped: a socket destroyed without a response // reads to the client as the server going away, and reconnecting // forever. The upgrade is still this endpoint's, so `true`. @@ -220,8 +269,9 @@ function createWebSocketStream( return { attach(server) { - // Attaching twice would upgrade every request twice over. - if (attachedServer === server) { + // Attaching twice would upgrade every request twice over, and an + // endpoint on a server of its own answers nothing on this one. + if (attachedServer === server || ownsServer) { return; } @@ -289,3 +339,6 @@ function createWebSocketStream( module.exports = createWebSocketStream; module.exports.WS_DEFAULT_HEARTBEAT = WS_DEFAULT_HEARTBEAT; module.exports.createWebSocketStream = createWebSocketStream; + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ diff --git a/src/utils.js b/src/utils.js index b3e78fc2e..298d32696 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1546,6 +1546,35 @@ function clientQuery(client, resolvedPath) { return query; } +/** + * The two transports the bundled client speaks. Anything else given as + * `hot.client.transport` names a module of someone else's that speaks for it. + * @type {readonly string[]} + */ +const BUILT_IN_CLIENT_TRANSPORTS = ["sse", "ws"]; + +/** + * Where a client transport of someone else's lives. Looked up from the + * project first, where a bare package name is installed, and then from here, + * so an absolute path or a package this one can see works as well. + * @param {string} request what `hot.client.transport` named + * @param {string} context the compilation's context + * @returns {string} the resolved module + */ +function resolveClientTransport(request, context) { + try { + return require.resolve(request, { paths: [context] }); + } catch { + try { + return require.resolve(request); + } catch { + throw new Error( + `'hot.client.transport' must be 'sse', 'ws', or a module that exports a client class, but '${request}' could not be resolved from '${context}'.`, + ); + } + } +} + /** * Put the hot runtime into the compilation, so enabling `hot` is the whole of * what a developer has to do: no entry to add, no `HotModuleReplacementPlugin` @@ -1558,7 +1587,7 @@ function clientQuery(client, resolvedPath) { /** * @param {Compiler[]} compilers compilers to modify - * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions, token?: string | false }} options resolved hot options + * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions | false, token?: string | false }} options resolved hot options * @param {Logger} logger logger */ function injectHotClient(compilers, options, logger) { @@ -1566,9 +1595,16 @@ function injectHotClient(compilers, options, logger) { return; } + // `client: false` is no runtime in the page while the compilation still + // gets everything else — the plugin, for a page that wires a client of its + // own and still wants its updates. + const addsClient = options.client !== false; + /** @type {HotClientOptions | undefined} */ + const clientOptions = options.client || undefined; + // TODO in the next major release remove this warning and `LEGACY_CLIENT_OPTIONS` const deprecated = LEGACY_CLIENT_OPTIONS.filter((name) => - Object.hasOwn(options.client || {}, name), + Object.hasOwn(clientOptions || {}, name), ); if (deprecated.length > 0) { @@ -1585,7 +1621,23 @@ function injectHotClient(compilers, options, logger) { // 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 // runtime and the one written by hand is the one that counts. - const client = clientQuery(options.client, options.path); + const client = clientQuery(clientOptions, options.path); + + // A client transport of someone else's: a module exporting a class with the + // same shape as the built-in two, handed to the runtime through + // `__webpack_dev_server_client__`, which it uses in place of its own. What + // goes on the query is the wire the endpoint serves, since the runtime still + // works out the connection url and how to hold it open from that. + /** @type {string | undefined} */ + const customClientTransport = + client.transport && !BUILT_IN_CLIENT_TRANSPORTS.includes(client.transport) + ? client.transport + : undefined; + + if (customClientTransport) { + client.transport = + typeof options.transport === "string" ? options.transport : "ws"; + } // A transport of your own carries whatever protocol you wrote it to carry, // and the built-in client speaks two. When yours speaks one of them, @@ -1601,9 +1653,11 @@ function injectHotClient(compilers, options, logger) { // Overriding the transport is for a client that talks to something else, so // it comes with an endpoint of its own. Without one it is pointed straight // back at this middleware speaking the wrong protocol, which is a page that - // silently never connects. + // silently never connects. A client module of someone else's says for + // itself what it speaks. if ( typeof options.transport === "string" && + !customClientTransport && client.transport && client.transport !== options.transport && !client.path @@ -1614,13 +1668,24 @@ function injectHotClient(compilers, options, logger) { } for (const compiler of compilers) { - if (!isWebTarget(compiler)) { - continue; - } - const { webpack } = compiler; + // The client goes only where a browser will run it. The plugin goes + // wherever the middleware serves a compilation from, since `module.hot` + // is what a server bundle hot-reloads itself with too — through + // `webpack/hot/poll` or `webpack/hot/signal` — and a project relying on + // `hot` to apply it has no plugin of its own to fall back to. + const isWeb = addsClient && isWebTarget(compiler); + + if (isWeb && customClientTransport) { + new webpack.ProvidePlugin({ + __webpack_dev_server_client__: resolveClientTransport( + customClientTransport, + compiler.context, + ), + }).apply(compiler); + } - const missing = entriesMissingClient(compiler); + const missing = isWeb ? entriesMissingClient(compiler) : []; if (missing === null || missing.length > 0) { if (transport === undefined) { @@ -1675,8 +1740,8 @@ function injectHotClient(compilers, options, logger) { // when `hot.client.apply` says so; a plugin written into the configuration // is the developer's and stays. const needsHmr = - !options.client || - (options.client.apply !== "reload" && options.client.apply !== "nothing"); + !clientOptions || + (clientOptions.apply !== "reload" && clientOptions.apply !== "nothing"); const hmrPluginExists = compiler.options.plugins.some( (plugin) => diff --git a/test/__snapshots__/validation-options.test.js.snap.webpack5 b/test/__snapshots__/validation-options.test.js.snap.webpack5 index 57b7ba68a..8c1dc3b3f 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?, token?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, ws?, 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?, token?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, ws?, 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?, token?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, ws?, 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?, token?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, ws?, 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?, token?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, ws?, 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?, token?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, ws?, 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?, token?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, ws?, progress?, token?, cors?, statsOptions?, inject?, client? }" `; exports[`validation should throw an error on the "index" option with "{}" value 1`] = ` diff --git a/test/client-bundle.test.js b/test/client-bundle.test.js index c2922a4b0..b27eb5662 100644 --- a/test/client-bundle.test.js +++ b/test/client-bundle.test.js @@ -1,9 +1,14 @@ +import fs from "node:fs"; import path from "node:path"; +import * as acorn from "acorn"; import { Volume, createFsFromVolume } from "memfs"; import webpack from "webpack"; const CLIENT = path.resolve(__dirname, "../client-src/index.js"); +// What is published, which is what a project bundles — `npm run build` writes +// it, and installing the package's dependencies runs that. +const BUILT_CLIENT = path.resolve(__dirname, "../client/index.js"); // `"universal"` and the combined `["web", "node"]` target are webpack 5.108+. const [major, minor] = webpack.version.split(".").map(Number); @@ -11,14 +16,15 @@ const hasUniversalTarget = major > 5 || (major === 5 && minor >= 108); /** * @param {string | string[]} target webpack target + * @param {string=} entry which copy of the client to build * @returns {Promise<{ errors: string[], source: string }>} what building the client for it produced */ -function build(target) { +function build(target, entry = CLIENT) { const compiler = webpack({ mode: "development", devtool: false, target, - entry: CLIENT, + entry, output: { path: "/", filename: "client.js" }, }); const volume = new Volume(); @@ -75,3 +81,18 @@ describe("the client in a bundle for every kind of target", () => { }); } }); + +// The client is published as ES5, and a project targeting `["web", "es5"]` +// gets nothing newer from it — including from what it imports, which webpack +// does not transpile. Parsed as ES5 rather than looked over, so anything newer +// fails the test whatever it is. +describe("the published client in an ES5 bundle", () => { + it("parses as ES5, logger and all", async () => { + expect(fs.existsSync(BUILT_CLIENT)).toBe(true); + + const { errors, source } = await build(["web", "es5"], BUILT_CLIENT); + + expect(errors).toEqual([]); + expect(() => acorn.parse(source, { ecmaVersion: 5 })).not.toThrow(); + }); +}); diff --git a/test/client-socket.test.js b/test/client-socket.test.js index 7f3c696a6..e60ea8991 100644 --- a/test/client-socket.test.js +++ b/test/client-socket.test.js @@ -1,4 +1,5 @@ import createSocket from "../client-src/clients/createSocket"; +import * as socketModule from "../client-src/socket"; jest.spyOn(globalThis.console, "log").mockImplementation(); @@ -281,3 +282,67 @@ describe("createSocket", () => { expect(instances).toHaveLength(1); }); }); + +// Tooling that listens alongside the runtime reads the connection it holds: +// `@pmmmwh/react-refresh-webpack-plugin` takes `client.client` — the +// `WebSocket` or `EventSource` underneath — through webpack-dev-server's +// `client/socket`, which re-exports this. +describe("the connection the runtime holds", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("is the transport instance while it is connected", () => { + const { FakeClient, instances } = createFakeClient(); + const socket = createSocket(FakeClient, "/hmr"); + + expect(socketModule.client).toBe(instances[0]); + + socket.close(); + }); + + it("is nothing once the connection drops, and the new one once it is back", () => { + const { FakeClient, instances } = createFakeClient(); + const socket = createSocket(FakeClient, "/hmr", { + retryDelay: () => 100, + }); + + instances[0].openHandler(); + instances[0].closeHandler(); + + expect(socketModule.client).toBeNull(); + + jest.advanceTimersByTime(100); + + expect(socketModule.client).toBe(instances[1]); + + socket.close(); + }); + + it("is nothing once the runtime closes it", () => { + const { FakeClient } = createFakeClient(); + const socket = createSocket(FakeClient, "/hmr"); + + socket.close(); + + expect(socketModule.client).toBeNull(); + }); + + it("is not cleared by another endpoint's connection dropping", () => { + const first = createFakeClient(); + const second = createFakeClient(); + const one = createSocket(first.FakeClient, "/one", { retries: 0 }); + const two = createSocket(second.FakeClient, "/two"); + + first.instances[0].closeHandler(); + + expect(socketModule.client).toBe(second.instances[0]); + + one.close(); + two.close(); + }); +}); diff --git a/test/e2e/dev-server-parity.test.js b/test/e2e/dev-server-parity.test.js new file mode 100644 index 000000000..e935e62ea --- /dev/null +++ b/test/e2e/dev-server-parity.test.js @@ -0,0 +1,307 @@ +import collectConsole from "../helpers/console-collector"; +import { + INDICATOR_ID, + OVERLAY_ID, + acceptedApp, + closeE2e, + unacceptedApp, + waitForAppText, + waitForNoOverlay, + waitForOverlay, + waitForOverlayText, + warningApp, +} from "../helpers/e2e"; +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(400000); + +// What webpack-dev-server's own client did, which a page running this one in +// its place still expects: that server ships this runtime now, so each of +// these is a regression for its users if it goes. +describe("what webpack-dev-server's client did (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + it("keeps applying updates on a page whose url has a malformed escape", async () => { + // A browser leaves `50%` as it is, and the runtime reads the page's url on + // every build — one bad parameter must not stop every update. + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(`${hotApp.url}?discount=50%`); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + expect(console_.messages.join("\n")).not.toContain("Invalid HMR message"); + }); + + it("reloads a live-reload page that reconnects to a newer build", async () => { + // Rebuilt while the server is down, so the only thing that can bring the + // new code is the catch-up a reconnected page is sent — which a page + // without Hot Module Replacement has to load itself again for. + hotApp = await createHotApp({ + query: "?apply=reload&timeout=1000", + code: unacceptedApp("v1"), + hot: { heartbeat: 300 }, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + await hotApp.stopHttp(); + + const rebuilt = hotApp.nextBuild(); + + hotApp.edit(unacceptedApp("v2")); + await rebuilt; + await hotApp.startHttp(); + + await waitForAppText(page, "v2"); + + expect( + await page.evaluate(() => document.getElementById("app").textContent), + ).toBe("v2"); + }); + + it("does not reload a live-reload page that reconnects to the build it runs", async () => { + hotApp = await createHotApp({ + query: "?apply=reload&timeout=1000", + code: unacceptedApp("v1"), + hot: { heartbeat: 300 }, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + await page.evaluate(() => { + globalThis.notReloaded = true; + }); + + // `] connected` rather than `connected`, which "Disconnected!" contains. + const before = console_.messages.filter((text) => + text.includes("] connected"), + ).length; + + await hotApp.stopHttp(); + await hotApp.startHttp(); + + // Connected again, and caught up on a build the page already has. + await console_.waitForCount("] connected", before + 1); + + expect(await page.evaluate(() => globalThis.notReloaded)).toBe(true); + }); + + it("reports a build's warnings as well as its errors", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await page.evaluate(() => { + globalThis.posted = []; + window.addEventListener("message", (event) => { + if (event.data && typeof event.data.type === "string") { + globalThis.posted.push(event.data.type); + } + }); + }); + + // Webpack's "Critical dependency" warning, and a module that is not there. + hotApp.edit(`${warningApp("v2")}\nrequire("./not-there");`); + await page.waitForFunction( + () => globalThis.posted.includes("webpackErrors"), + { timeout: 30000 }, + ); + + const posted = await page.evaluate(() => globalThis.posted); + + // In the order webpack-dev-server's client posted them. + expect(posted.indexOf("webpackWarnings")).toBeGreaterThan(-1); + expect(posted.indexOf("webpackWarnings")).toBeLessThan( + posted.indexOf("webpackErrors"), + ); + expect(console_.messages.join("\n")).toContain("Critical dependency"); + }); + + it("connects where webpack-dev-server's query spelling says", async () => { + // A client entry written by hand for that server: the parts of the url as + // parameters of their own, and `live-reload` rather than `liveReload`. + const query = new URLSearchParams({ + protocol: "ws:", + hostname: "0.0.0.0", + port: "0", + pathname: "/custom-hmr", + hot: "true", + "live-reload": "true", + }); + + hotApp = await createHotApp({ + hot: { path: "/custom-hmr" }, + query: `?${query}`, + code: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + expect(console_.messages.join("\n")).not.toContain("Invalid HMR message"); + }); + + it("dismisses the overlay with Escape as an older browser names it", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + hotApp.edit("broken {{{"); + await waitForOverlay(page); + + // Enter is not a way out. + await page.evaluate(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + }); + + expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); + + await page.evaluate(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Esc" })); + }); + await waitForNoOverlay(page); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + + it("puts the overlay above everything a page can stack", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + hotApp.edit("broken {{{"); + await waitForOverlay(page); + + expect( + await page.evaluate( + (id) => getComputedStyle(document.getElementById(id)).zIndex, + OVERLAY_ID, + ), + ).toBe("2147483647"); + }); + + it("shows a thrown value that is not an Error, and a rejection with a string", async () => { + hotApp = await createHotApp({ + code: `${acceptedApp("v1")} + globalThis.throwNull = () => setTimeout(() => { throw null; }, 0); + globalThis.rejectWith = (reason) => Promise.reject(reason);`, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + await page.evaluate(() => { + globalThis.throwNull(); + }); + await waitForOverlayText(page, { includes: ["Uncaught runtime error"] }); + + await page.evaluate(() => { + globalThis.rejectWith("a string, not an Error"); + }); + + expect( + await waitForOverlayText(page, { includes: ["a string, not an Error"] }), + ).toContain("a string, not an Error"); + }); + + it("reloads when the server asks without naming a file", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await page.evaluate(() => { + globalThis.notReloaded = true; + }); + + hotApp.instance.publish({ action: "reload" }); + + await page.waitForFunction(() => globalThis.notReloaded === undefined, { + timeout: 30000, + }); + expect(console_.messages.join("\n")).toContain( + "Reloading, as the server asked", + ); + }); + + it("announces the building indicator as a progress bar", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.instance.publish({ action: "building" }); + hotApp.instance.publish({ action: "progress", percent: 42, message: "x" }); + + await page.waitForFunction( + (id) => + document.getElementById(id)?.getAttribute("aria-valuenow") === "42", + { timeout: 30000 }, + INDICATOR_ID, + ); + + expect( + await page.evaluate( + (id) => document.getElementById(id).getAttribute("role"), + INDICATOR_ID, + ), + ).toBe("progressbar"); + }); + + it("shows the building indicator in a browser without Shadow DOM", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.evaluateOnNewDocument(() => { + Element.prototype.attachShadow = undefined; + }); + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + expect(console_.messages.join("\n")).not.toContain("Invalid HMR message"); + }); +}); diff --git a/test/e2e/transport.test.js b/test/e2e/transport.test.js index f3c5f415c..7503078f4 100644 --- a/test/e2e/transport.test.js +++ b/test/e2e/transport.test.js @@ -1,3 +1,5 @@ +import path from "node:path"; + import collectConsole from "../helpers/console-collector"; import { OVERLAY_ID, @@ -186,3 +188,116 @@ for (const transport of ["sse", "ws"]) { }); }); } + +// Someone else's client transport, named by `hot.client.transport`: the +// runtime has to use it in place of its own, not next to it. +describe("a client transport of someone else's (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + it("is the one the page connects with", async () => { + hotApp = await createHotApp({ + transport: "ws", + bare: true, + hot: { + client: { + transport: path.resolve( + __dirname, + "../fixtures/custom-client-transport.js", + ), + }, + }, + code: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + expect( + await page.evaluate(() => globalThis.__customClientTransportUrl__), + ).toContain("/__webpack_hmr"); + + await page.evaluate(() => { + globalThis.notReloaded = true; + }); + + hotApp.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + expect(await page.evaluate(() => globalThis.notReloaded)).toBe(true); + }); +}); + +// What `@pmmmwh/react-refresh-webpack-plugin` does through webpack-dev-server's +// `client/socket`: take the connection the runtime holds and listen to the raw +// messages on it, alongside the runtime rather than through it. +for (const transport of ["sse", "ws"]) { + describe(`the connection the runtime holds, over ${transport} (browser)`, () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + it("can be listened to by tooling next to the runtime", async () => { + const socketModule = JSON.stringify( + path.resolve(__dirname, "../../client-src/socket.js"), + ); + const listener = ` + import { client } from ${socketModule}; + + globalThis.__rawMessages = []; + + const listen = () => { + if (!client) { + setTimeout(listen, 10); + return; + } + + client.client.addEventListener("message", (event) => { + if (event.data !== "\u{1F493}") { + globalThis.__rawMessages.push(JSON.parse(event.data).action); + } + }); + }; + + listen(); + `; + + // The client entry from source, which the listener imports from too — + // one copy of the module, as there is in a real bundle. + hotApp = await createHotApp({ + transport, + files: { "listener.js": listener }, + code: `require("./listener.js");\n${acceptedApp("v1")}`, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.edit(`require("./listener.js");\n${acceptedApp("v2")}`); + await waitForAppText(page, "v2"); + + await page.waitForFunction(() => + globalThis.__rawMessages.includes("built"), + ); + + expect(await page.evaluate(() => globalThis.__rawMessages)).toContain( + "building", + ); + }); + }); +} diff --git a/test/fixtures/custom-client-transport.js b/test/fixtures/custom-client-transport.js new file mode 100644 index 000000000..e9944def1 --- /dev/null +++ b/test/fixtures/custom-client-transport.js @@ -0,0 +1,40 @@ +// A client transport of someone else's, in the shape the runtime asks for: +// constructed with the url, and reporting through `onOpen`, `onClose` and +// `onMessage`. It wraps a WebSocket and marks the page, so a test can tell it +// was the one used. +export default class CustomClientTransport { + /** + * @param {string} url url to connect to + */ + constructor(url) { + self.__customClientTransportUrl__ = url; + this.client = new WebSocket( + new URL(url, self.location.href).href.replace(/^http/, "ws"), + ); + } + + /** + * @param {(data?: string) => void} fn called once open + */ + onOpen(fn) { + this.client.addEventListener("open", () => fn()); + } + + /** + * @param {(data?: string) => void} fn called once closed + */ + onClose(fn) { + this.client.addEventListener("close", () => fn()); + } + + /** + * @param {(data?: string) => void} fn called with each message + */ + onMessage(fn) { + this.client.addEventListener("message", (event) => fn(event.data)); + } + + close() { + this.client.close(); + } +} diff --git a/test/hot.test.js b/test/hot.test.js index 8277839fc..505e74c21 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -1926,6 +1926,252 @@ describe("createHot over a WebSocket", () => { expect(JSON.parse(messages[0]).action).toBe("built"); }); + + // `hot.ws` is handed to the `ws` server, which is how webpack-dev-server's + // `webSocketServer.options` keep working now the socket is this package's: a + // port or a server of its own, and anything else `ws` takes. + describe("with options for the ws server", () => { + /** + * A port nothing is listening on. + * @returns {Promise} the port + */ + async function freePort() { + const probe = http.createServer(); + + await new Promise((resolve) => { + probe.listen(0, "127.0.0.1", resolve); + }); + + const { port } = probe.address(); + + await new Promise((resolve) => { + probe.close(resolve); + }); + + return port; + } + + /** + * Stand up an endpoint that listens on a port of its own. + * @param {EXPECTED_OBJECT} compiler fake compiler + * @param {EXPECTED_OBJECT=} options extra hot options + * @returns {Promise<{ hot: EXPECTED_OBJECT, url: string, port: number }>} the endpoint + */ + async function serveOnOwnPort(compiler, options = {}) { + const port = await freePort(); + const hot = createHot(compiler, { + transport: "ws", + ...options, + ws: { host: "127.0.0.1", port, ...options.ws }, + }); + + cleanups.push(async () => { + hot.close(); + }); + + // `ws` binds as it is constructed, which is not synchronous. + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + return { hot, url: `ws://127.0.0.1:${port}${hot.path}`, port }; + } + + it("listens on a port of its own", async () => { + const compiler = makeFakeCompiler(); + const endpoint = await serveOnOwnPort(compiler); + + compiler.emitDone(makeFakeStats({ hash: "own-port" })); + + const { messages } = await connect(endpoint.url); + + await until(() => messages.length > 0); + + expect(JSON.parse(messages[0]).hash).toBe("own-port"); + }); + + it("answers nothing on the server it is handed when it has a port of its own", async () => { + const compiler = makeFakeCompiler(); + const endpoint = await serveOnOwnPort(compiler); + const fakeSocket = { + write: () => { + throw new Error("must not answer"); + }, + destroy: () => { + throw new Error("must not answer"); + }, + }; + + expect( + endpoint.hot.handleUpgrade( + { url: endpoint.hot.path, headers: { upgrade: "websocket" } }, + fakeSocket, + Buffer.alloc(0), + ), + ).toBe(false); + }); + + it("listens on a server of its own", async () => { + const compiler = makeFakeCompiler(); + const server = http.createServer(); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const hot = createHot(compiler, { transport: "ws", ws: { server } }); + + cleanups.push(async () => { + hot.close(); + await new Promise((resolve) => { + server.close(resolve); + }); + }); + + compiler.emitDone(makeFakeStats({ hash: "own-server" })); + + const { messages } = await connect( + `ws://127.0.0.1:${server.address().port}${hot.path}`, + ); + + await until(() => messages.length > 0); + + expect(JSON.parse(messages[0]).hash).toBe("own-server"); + }); + + it("hands everything else to ws", async () => { + const compiler = makeFakeCompiler(); + const endpoint = await serveOverWs(compiler, { + ws: { + handleProtocols: (protocols) => + protocols.has("second") ? "second" : false, + }, + }); + + const { WebSocket } = require("ws"); + + const socket = new WebSocket(endpoint.url, ["first", "second"]); + + cleanups.push(async () => { + socket.terminate(); + }); + + await new Promise((resolve, reject) => { + socket.on("open", resolve); + socket.on("error", reject); + }); + + expect(socket.protocol).toBe("second"); + }); + + it("keeps the endpoint's path whatever the ws options say", async () => { + const compiler = makeFakeCompiler(); + const endpoint = await serveOnOwnPort(compiler, { + path: "/__hmr", + ws: { path: "/elsewhere" }, + }); + + compiler.emitDone(makeFakeStats({ hash: "path-kept" })); + + const { messages } = await connect(endpoint.url); + + await until(() => messages.length > 0); + + expect(endpoint.url).toContain("/__hmr"); + expect(JSON.parse(messages[0]).hash).toBe("path-kept"); + }); + + it("refuses an origin the option does not allow on its own port too", async () => { + const compiler = makeFakeCompiler(); + const endpoint = await serveOnOwnPort(compiler); + + await expect( + connect(endpoint.url, { Origin: "https://evil.example" }), + ).rejects.toThrow(/Unexpected server response: 403/); + }); + + it("requires the token on its own port too", async () => { + const compiler = makeFakeCompiler(); + const endpoint = await serveOnOwnPort(compiler, { token: "secret" }); + + await expect(connect(endpoint.url)).rejects.toThrow( + /Unexpected server response: 403/, + ); + + const { socket } = await connect(`${endpoint.url}?token=secret`); + + expect(socket.readyState).toBe(socket.OPEN); + }); + + it("asks a verifyClient of the developer's own after its own checks", async () => { + const compiler = makeFakeCompiler(); + /** @type {string[]} */ + const asked = []; + const endpoint = await serveOnOwnPort(compiler, { + ws: { + verifyClient: (info) => { + asked.push(info.req.url); + + return !info.req.url.includes("deny"); + }, + }, + }); + + await expect(connect(`${endpoint.url}?deny=1`)).rejects.toThrow( + /Unexpected server response: 401/, + ); + + const { socket } = await connect(endpoint.url); + + expect(socket.readyState).toBe(socket.OPEN); + expect(asked).toHaveLength(2); + }); + + it("waits for a verifyClient that answers through its callback", async () => { + const compiler = makeFakeCompiler(); + const endpoint = await serveOnOwnPort(compiler, { + ws: { + verifyClient: (info, done) => { + setTimeout(() => done(false, 418), 10); + }, + }, + }); + + await expect(connect(endpoint.url)).rejects.toThrow( + /Unexpected server response: 418/, + ); + }); + + it("never reaches the developer's verifyClient for a refused origin", async () => { + const compiler = makeFakeCompiler(); + let asked = false; + const endpoint = await serveOnOwnPort(compiler, { + ws: { + verifyClient: () => { + asked = true; + + return true; + }, + }, + }); + + await expect( + connect(endpoint.url, { Origin: "https://evil.example" }), + ).rejects.toThrow(/Unexpected server response: 403/); + expect(asked).toBe(false); + }); + + it("stops listening on its own port once closed", async () => { + const compiler = makeFakeCompiler(); + const endpoint = await serveOnOwnPort(compiler); + + endpoint.hot.close(); + + await expect(connect(endpoint.url)).rejects.toThrow( + /timed out connecting|ECONNREFUSED|socket hang up/, + ); + }); + }); }); describe("createHot over a transport of your own", () => { diff --git a/test/inject-client.test.js b/test/inject-client.test.js index d979d04db..8031cf639 100644 --- a/test/inject-client.test.js +++ b/test/inject-client.test.js @@ -771,7 +771,11 @@ describe("what injectHotClient leaves alone", () => { expect(hasHmrPlugin(instance)).toBe(false); }); - it("skips a compilation a browser does not run", () => { + it("gives a compilation a browser does not run the plugin, not the client", () => { + // A server bundle hot-reloads itself through `module.hot` too — with + // `webpack/hot/poll` or `webpack/hot/signal` — and webpack-dev-server has + // always applied the plugin to every compilation it serves, so a project + // relying on that has none of its own. const instance = compiler({ target: "node" }); const before = entryCount(instance); @@ -782,9 +786,55 @@ describe("what injectHotClient leaves alone", () => { ); expect(entryCount(instance)).toBe(before); + expect(hasHmrPlugin(instance)).toBe(true); + }); + + it("gives that compilation no plugin either in a mode that never applies an update", () => { + const instance = compiler({ target: "node" }); + + injectHotClient( + [instance], + { + path: "/__webpack_hmr", + transport: "sse", + client: { apply: "reload" }, + }, + logger, + ); + expect(hasHmrPlugin(instance)).toBe(false); }); + it("adds no client but still the plugin when the client is off", () => { + // A page that wires a runtime of its own still wants its updates applied, + // which takes the plugin. + const instance = compiler(); + const before = entryCount(instance); + + injectHotClient( + [instance], + { path: "/__webpack_hmr", transport: "sse", client: false }, + logger, + ); + + expect(entryCount(instance)).toBe(before); + expect(hasHmrPlugin(instance)).toBe(true); + }); + + it("does not warn about a token it never had a client to hand to, when the client is off", () => { + /** @type {string[]} */ + const warned = []; + const instance = compiler(); + + injectHotClient( + [instance], + { path: "/__webpack_hmr", transport: "sse", client: false }, + { warn: (message) => warned.push(message), log: () => {} }, + ); + + expect(warned).toStrictEqual([]); + }); + it("treats an entry it cannot read as having no client", () => { // A function `entry` is computed per build, so there is nothing to look // at — the client goes in, which is the safe way to be wrong. @@ -990,6 +1040,204 @@ describe("the entry query the client is given", () => { }); }); +// `hot.client.transport` naming a module rather than a wire: a client class of +// someone else's, which the runtime uses in place of the built-in one through +// `__webpack_dev_server_client__`. webpack-dev-server's +// `client.webSocketTransport` has always worked this way. +describe("a client transport of someone else's", () => { + const CUSTOM = path.resolve(__dirname, "fixtures/custom-client-transport.js"); + + /** @type {EXPECTED_OBJECT[]} */ + let compilers = []; + /** @type {string[]} */ + let warnings = []; + /** @type {EXPECTED_OBJECT} */ + const logger = { + warn: (message) => warnings.push(message), + log: () => {}, + }; + + afterEach(() => { + for (const compiler of compilers) { + compiler.close(() => {}); + } + + compilers = []; + warnings = []; + }); + + /** + * Inject with an `EntryPlugin` and a `ProvidePlugin` that record instead of + * applying. + * @param {EXPECTED_OBJECT} options resolved hot options + * @param {EXPECTED_OBJECT=} config extra webpack configuration + * @returns {{ queries: Record[], provided: Record[] }} what was added + */ + function inject(options, config) { + const instance = makeCompiler(config); + + compilers.push(instance); + + /** @type {string[]} */ + const added = []; + /** @type {Record[]} */ + const provided = []; + const real = instance.webpack; + + class RecordingEntryPlugin { + /** + * @param {string} _context context + * @param {string} entry entry request + */ + constructor(_context, entry) { + added.push(entry); + } + + apply() {} + } + + class RecordingProvidePlugin { + /** + * @param {Record} definitions what is provided + */ + constructor(definitions) { + provided.push(definitions); + } + + apply() {} + } + + instance.webpack = { + ...real, + EntryPlugin: RecordingEntryPlugin, + ProvidePlugin: RecordingProvidePlugin, + }; + + try { + injectHotClient([instance], options, logger); + } finally { + instance.webpack = real; + } + + return { + queries: added.map((entry) => + Object.fromEntries(new URLSearchParams(entry.split("?")[1])), + ), + provided, + }; + } + + it("hands the runtime the module, resolved", () => { + const { provided } = inject({ + path: "/__webpack_hmr", + transport: "ws", + client: { transport: CUSTOM }, + }); + + expect(provided).toStrictEqual([{ __webpack_dev_server_client__: CUSTOM }]); + }); + + it("resolves a relative request from the compilation's context", () => { + const { provided } = inject( + { + path: "/__webpack_hmr", + transport: "ws", + client: { transport: "./custom-client-transport.js" }, + }, + { context: path.resolve(__dirname, "fixtures") }, + ); + + expect(provided).toStrictEqual([{ __webpack_dev_server_client__: CUSTOM }]); + }); + + it("puts the endpoint's wire on the query, not the module", () => { + // The runtime still works out the url's scheme and how to hold the + // connection open from the wire, and a path there would be neither. + const { queries } = inject({ + path: "/__webpack_hmr", + transport: "sse", + client: { transport: CUSTOM }, + }); + + expect(queries).toStrictEqual([ + { path: "/__webpack_hmr", transport: "sse" }, + ]); + }); + + it("does not call it a disagreement with the endpoint", () => { + inject({ + path: "/__webpack_hmr", + transport: "ws", + client: { transport: CUSTOM }, + }); + + expect(warnings).toStrictEqual([]); + }); + + it("adds a client to a transport of your own on the server as well", () => { + // Both ends are someone else's, so the runtime is told a WebSocket — the + // wire webpack-dev-server has always built a custom client's url for. + const { queries, provided } = inject({ + path: "/ws", + transport: () => ({ + close: () => {}, + onConnect: () => {}, + publish: () => {}, + publishTo: () => {}, + }), + client: { transport: CUSTOM }, + }); + + expect(queries).toStrictEqual([{ path: "/ws", transport: "ws" }]); + expect(provided).toHaveLength(1); + expect(warnings).toStrictEqual([]); + }); + + it("provides nothing for a built-in transport", () => { + const { provided } = inject({ + path: "/__webpack_hmr", + transport: "ws", + client: { transport: "ws" }, + }); + + expect(provided).toStrictEqual([]); + }); + + it("provides nothing to a compilation a browser does not run", () => { + const { provided } = inject( + { + path: "/__webpack_hmr", + transport: "ws", + client: { transport: CUSTOM }, + }, + { target: "node" }, + ); + + expect(provided).toStrictEqual([]); + }); + + it("says which module it could not find", () => { + expect(() => + inject({ + path: "/__webpack_hmr", + transport: "ws", + client: { transport: "./no-such-client.js" }, + }), + ).toThrow( + /'hot\.client\.transport'.*'\.\/no-such-client\.js' could not be resolved/, + ); + }); + + it("is accepted by the schema", () => { + const client = schema.definitions.HotClient; + + expect(client.properties.transport.anyOf).toStrictEqual([ + { enum: ["sse", "ws"] }, + { type: "string", minLength: 1 }, + ]); + }); +}); + // Overriding the transport points the client at a different server. Left // pointing at this one it would ask for a protocol the endpoint does not // serve, and a page that never connects says nothing about why. @@ -1086,9 +1334,7 @@ describe("node and the query take the same names", () => { ]; /** @type {string[]} every name `hot.client` accepts */ - const takenInNode = Object.keys( - schema.properties.hot.anyOf[1].properties.client.properties, - ); + const takenInNode = Object.keys(schema.definitions.HotClient.properties); it("reads something from the query at all", () => { // The extraction above is regex over source; if it ever stops matching it diff --git a/types/client/clients/createSocket.d.ts b/types/client/clients/createSocket.d.ts index 49e98ed0e..5dbafe752 100644 --- a/types/client/clients/createSocket.d.ts +++ b/types/client/clients/createSocket.d.ts @@ -1,3 +1,21 @@ +/** + * Hold a connection open, reconnecting when it drops, and fan each message out + * to everyone listening. What "reconnect" costs is the transport's to say: a + * dropped WebSocket backs off, whereas Server-Sent Events retries at a steady + * interval for as long as the page is open. + * @param {CommunicationClientConstructor} Client what speaks the transport + * @param {string} url url to connect to + * @param {SocketOptions=} options how it reconnects + * @returns {{ addMessageListener: (fn: (event: { data: string }) => void) => void, close: () => void }} the socket + */ +export default function createSocket( + Client: CommunicationClientConstructor, + url: string, + options?: SocketOptions | undefined, +): { + addMessageListener: (fn: (event: { data: string }) => void) => void; + close: () => void; +}; /** * Called with no argument for open and close, and with the message string for * a message. @@ -26,23 +44,14 @@ * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor */ /** - * Hold a connection open, reconnecting when it drops, and fan each message out - * to everyone listening. What "reconnect" costs is the transport's to say: a - * dropped WebSocket backs off, whereas Server-Sent Events retries at a steady - * interval for as long as the page is open. - * @param {CommunicationClientConstructor} Client what speaks the transport - * @param {string} url url to connect to - * @param {SocketOptions=} options how it reconnects - * @returns {{ addMessageListener: (fn: (event: { data: string }) => void) => void, close: () => void }} the socket + * The connection the runtime holds right now, or `null` while there is none — + * for tooling that listens alongside the runtime rather than through it. + * `client.client` is the `WebSocket` or `EventSource` underneath, the shape + * webpack-dev-server's `client/socket` has always exported, which is what + * `@pmmmwh/react-refresh-webpack-plugin` reads its build messages from. + * @type {CommunicationClient | null} */ -export default function createSocket( - Client: CommunicationClientConstructor, - url: string, - options?: SocketOptions | undefined, -): { - addMessageListener: (fn: (event: { data: string }) => void) => void; - close: () => void; -}; +export let client: CommunicationClient | null; /** * Called with no argument for open and close, and with the message string for * a message. diff --git a/types/client/socket.d.ts b/types/client/socket.d.ts new file mode 100644 index 000000000..a45727107 --- /dev/null +++ b/types/client/socket.d.ts @@ -0,0 +1 @@ +export { client } from "./clients/createSocket.js"; diff --git a/types/hot.d.ts b/types/hot.d.ts index ee50dc512..7316d4577 100644 --- a/types/hot.d.ts +++ b/types/hot.d.ts @@ -212,9 +212,9 @@ type PathSpec = { */ type HotClientOptions = { /** - * which transport the runtime speaks, `hot.transport` by default + * which transport the runtime speaks, `hot.transport` by default; any other string is a module exporting a client class of your own, used in place of the built-in one */ - transport?: ("sse" | "ws") | undefined; + transport?: ("sse" | "ws" | string) | undefined; /** * where the runtime connects, `hot.path` by default; may be an absolute url for an endpoint on another origin, or the parts that differ with the rest resolved in the page */ @@ -313,6 +313,10 @@ type HotOptions = { * HTTP server the `"ws"` transport answers upgrades on, when it is already built */ server?: HttpServer | undefined; + /** + * options for the `ws` server behind the `"ws"` transport — compression, payload limits, `verifyClient`, or a `port` or a `server` of its own to listen on; `path`, `noServer` and `clientTracking` are the middleware's + */ + ws?: Record | undefined; /** * deprecated, removed in the next major release — webpack stats options used when serializing compilation results */ @@ -334,9 +338,9 @@ type HotOptions = { */ inject?: boolean | undefined; /** - * options handed to the browser runtime through its entry query + * options handed to the browser runtime through its entry query; `false` adds no runtime to the page and still applies `HotModuleReplacementPlugin` */ - client?: HotClientOptions | undefined; + client?: (HotClientOptions | false) | undefined; }; /** * What an origin is matched against: one origin, several, a pattern, or a diff --git a/types/servers/WebSocketServer.d.ts b/types/servers/WebSocketServer.d.ts index d52598abb..82c63c495 100644 --- a/types/servers/WebSocketServer.d.ts +++ b/types/servers/WebSocketServer.d.ts @@ -8,6 +8,7 @@ export = createWebSocketStream; * @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 {Record=} options.ws options for the `ws` server; a `port` or a `server` gives it one of its own * @param {Logger} logger logger * @returns {ClientStream} client stream */ @@ -17,11 +18,13 @@ declare function createWebSocketStream( heartbeat, cors, token, + ws, }: { path: string; heartbeat: number; cors?: CorsOption | undefined; token?: (string | false) | undefined; + ws?: Record | undefined; }, logger: Logger, ): ClientStream; @@ -38,6 +41,7 @@ declare namespace createWebSocketStream { Payload, ClientStream, CorsOption, + EXPECTED_ANY, }; } declare const WS_DEFAULT_HEARTBEAT: number; @@ -50,3 +54,4 @@ type Logger = import("../hot.js").Logger; type Payload = import("../hot.js").Payload; type ClientStream = import("../hot.js").ClientStream; type CorsOption = import("../hot.js").CorsOption; +type EXPECTED_ANY = any; diff --git a/types/utils.d.ts b/types/utils.d.ts index 1bc6d62b4..f3436d7a1 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -346,7 +346,7 @@ export function initState< */ /** * @param {Compiler[]} compilers compilers to modify - * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions, token?: string | false }} options resolved hot options + * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions | false, token?: string | false }} options resolved hot options * @param {Logger} logger logger */ export function injectHotClient( @@ -355,7 +355,7 @@ export function injectHotClient( path: string; transport: NonNullable; inject?: boolean; - client?: HotClientOptions; + client?: HotClientOptions | false; token?: string | false; }, logger: Logger, From 7ba56f4f481f48d6555514464e2ab6da218b0de6 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:57:08 +0000 Subject: [PATCH 2/8] fix(client): read the logging options first, and live-reload the way webpack-dev-server's client did A deprecated name found while reading the query is reported under the label and at the level the query asks for, and ?live-reload=true on its own is live reload, since that client read hot as off unless told. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- client-src/index.js | 73 +++++++++++++++++------------- test/e2e/dev-server-parity.test.js | 23 ++++++++++ 2 files changed, 64 insertions(+), 32 deletions(-) diff --git a/client-src/index.js b/client-src/index.js index 3c619e9f6..6eed9bc22 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -204,10 +204,11 @@ function legacyBoolean(value, fallback) { function foldLegacyOptions(overrides) { // webpack-dev-server's query spelled it this way, and an entry written by // hand for that server still does. - if ( + const devServerSpelling = overrides["live-reload"] !== undefined && - overrides.liveReload === undefined - ) { + overrides.liveReload === undefined; + + if (devServerSpelling) { overrides.liveReload = overrides["live-reload"]; } @@ -226,7 +227,11 @@ function foldLegacyOptions(overrides) { // happened when it could not be; `liveReload` what happened instead when // `hot` was off. Four of their eight combinations differed, which is what // the four modes are. - const hot = legacyBoolean(overrides.hot, true); + // + // webpack-dev-server's client read `hot` as off unless the query said + // otherwise, so its spelling is read its way: `?live-reload=true` alone + // is live reload. + const hot = legacyBoolean(overrides.hot, !devServerSpelling); // `hot=only` is webpack-dev-server's own: apply in place, never reload. options.apply = hot @@ -356,6 +361,38 @@ function legacyUrlMode(mode) { * @param {Record} overrides parsed query-string overrides */ function setOverrides(overrides) { + if (overrides.logging) { + // A level, or a json object carrying the level and the name to label + // messages with — the same two shapes the other options take. + let logging = overrides.logging; + let parsed; + + try { + parsed = JSON.parse(logging); + } catch { + // Not json, so it is the level it looks like. + } + + // Only an object is the second shape. `JSON.parse` also accepts a bare + // number, boolean or quoted string, and a level is none of those — asking + // what came back rather than what the text started with also means + // leading whitespace does not hide it. + if (parsed && typeof parsed === "object") { + logging = parsed.level; + + if (parsed.name) { + options.loggerName = parsed.name; + } + } + + if (logging) { + options.logging = /** @type {LogLevel} */ (logging); + } + } + // Before anything else is read, so whatever reading the rest has to say — + // a deprecated name, say — is labelled and leveled as the entry asked. + setLogName(options.loggerName); + setLogLevel(options.logging); // TODO in the next major release remove this, and the six names it reads. foldLegacyOptions(overrides); if (overrides.transport === "sse" || overrides.transport === "ws") { @@ -464,34 +501,6 @@ function setOverrides(overrides) { } } if (overrides.urlPrefix) options.urlPrefix = overrides.urlPrefix; - if (overrides.logging) { - // A level, or a json object carrying the level and the name to label - // messages with — the same two shapes the other options take. - let logging = overrides.logging; - let parsed; - - try { - parsed = JSON.parse(logging); - } catch { - // Not json, so it is the level it looks like. - } - - // Only an object is the second shape. `JSON.parse` also accepts a bare - // number, boolean or quoted string, and a level is none of those — asking - // what came back rather than what the text started with also means - // leading whitespace does not hide it. - if (parsed && typeof parsed === "object") { - logging = parsed.level; - - if (parsed.name) { - options.loggerName = parsed.name; - } - } - - if (logging) { - options.logging = /** @type {LogLevel} */ (logging); - } - } if (overrides.name) { options.name = overrides.name; } diff --git a/test/e2e/dev-server-parity.test.js b/test/e2e/dev-server-parity.test.js index e935e62ea..1c64fe4ec 100644 --- a/test/e2e/dev-server-parity.test.js +++ b/test/e2e/dev-server-parity.test.js @@ -170,6 +170,29 @@ describe("what webpack-dev-server's client did (browser)", () => { expect(console_.messages.join("\n")).not.toContain("Invalid HMR message"); }); + it("reads webpack-dev-server's live-reload alone as live reload", async () => { + // That client read `hot` as off unless the query said otherwise. + hotApp = await createHotApp({ + query: "?live-reload=true", + code: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await page.evaluate(() => { + globalThis.notReloaded = true; + }); + + hotApp.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + // Loaded again rather than updated in place, though the module accepts. + expect(await page.evaluate(() => globalThis.notReloaded)).toBeUndefined(); + }); + it("dismisses the overlay with Escape as an older browser names it", async () => { hotApp = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); From a0687a19c6372a5c29873190f7abbd9e56121f71 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:03:14 +0000 Subject: [PATCH 3/8] fix(client): say nothing of webpack-dev-server's own query spelling hot, liveReload and reconnect were never this client's query names; they arrive from entries written for webpack-dev-server's client, which is what webpack's guide shows for wiring it by hand. Only reload, autoConnect and timeout, which this client's own query offered, warn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- client-src/index.js | 18 ++++++++++++++++-- test/e2e/live-reload.test.js | 34 +++++++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/client-src/index.js b/client-src/index.js index 6eed9bc22..1d3945f59 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -183,6 +183,10 @@ const LEGACY_OPTIONS = [ "timeout", ]; +// The three of them this package's own query offered before `apply` and +// `connect`, which are the ones a warning is owed for. +const OWN_LEGACY_OPTIONS = ["reload", "autoConnect", "timeout"]; + /** * Was it set at all, and if so is it anything but `"false"`? The reading every * boolean on this query has always had. @@ -218,10 +222,20 @@ function foldLegacyOptions(overrides) { return; } - log.warn( - `${used.join(", ")} ${used.length === 1 ? "is" : "are"} deprecated and will be removed in the next major release. Use 'apply' and 'connect' instead.`, + // Said only of the names this client's own query ever offered. `hot`, + // `liveReload` and `reconnect` arrive from entries written for + // webpack-dev-server's — what webpack's guide shows for wiring that server's + // client by hand — and are read as they always were, without a word. + const deprecated = used.filter( + (name) => OWN_LEGACY_OPTIONS.indexOf(name) !== -1, ); + if (deprecated.length > 0) { + log.warn( + `${deprecated.join(", ")} ${deprecated.length === 1 ? "is" : "are"} deprecated and will be removed in the next major release. Use 'apply' and 'connect' instead.`, + ); + } + if (overrides.apply === undefined) { // `hot` decided whether an update was applied in place; `reload` what // happened when it could not be; `liveReload` what happened instead when diff --git a/test/e2e/live-reload.test.js b/test/e2e/live-reload.test.js index be52044ea..d27ba7ea6 100644 --- a/test/e2e/live-reload.test.js +++ b/test/e2e/live-reload.test.js @@ -430,7 +430,9 @@ describe("the deprecated browser options (browser)", () => { // Gone, so the page was loaded again rather than updated in place. expect(await readReloadMarker(page)).toBeUndefined(); - expect(console_.messages.join("\n")).toContain("deprecated"); + // webpack-dev-server's spelling, which this client never offered under + // its own name, so there is nothing to warn about. + expect(console_.messages.join("\n")).not.toContain("deprecated"); }); it("takes `hot=false&liveReload=false` as asking for nothing", async () => { @@ -489,7 +491,7 @@ describe("the deprecated browser options (browser)", () => { it("says which deprecated names it found", async () => { app = await createHotApp({ - query: "?reconnect=3&timeout=5000", + query: "?reload=true&timeout=5000&reconnect=3", code: acceptedApp("v1"), }); ({ page, browser } = await runBrowser()); @@ -499,10 +501,32 @@ describe("the deprecated browser options (browser)", () => { await waitForAppText(page, "v1"); await console_.waitFor("deprecated"); - const said = console_.messages.join("\n"); + const said = console_.messages.find((text) => text.includes("deprecated")); - expect(said).toContain("reconnect"); - expect(said).toContain("timeout"); + expect(said).toContain("reload, timeout are deprecated"); + // webpack-dev-server's spelling, read as it always was. + expect(said).not.toContain("reconnect"); expect(said).toContain("'apply' and 'connect'"); }); + + it("reads webpack-dev-server's own spelling without a warning", async () => { + // What webpack's guide shows for wiring that server's client by hand. + app = await createHotApp({ + query: "?hot=true&live-reload=true&reconnect=10", + code: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await plantReloadMarker(page); + + app.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + expect(await readReloadMarker(page)).toBe(true); + expect(console_.messages.join("\n")).not.toContain("deprecated"); + }); }); From 27f01ae631352bd9b93833e5b5d5e3de71dd15e6 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:09:43 +0000 Subject: [PATCH 4/8] docs(changeset): webpack-dev-server's query spelling reads without a warning Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .changeset/dev-server-client-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/dev-server-client-parity.md b/.changeset/dev-server-client-parity.md index 8e23da3a9..cc85edff6 100644 --- a/.changeset/dev-server-client-parity.md +++ b/.changeset/dev-server-client-parity.md @@ -7,7 +7,7 @@ The client keeps doing what webpack-dev-server's did: - A page url with a malformed escape no longer stops every update. - `apply: "reload"` reloads a reconnected page whose build is out of date. - A build's warnings are logged and posted along with its errors. -- An entry written for that server's query (`hostname`, `port`, `pathname`, `live-reload`, `hot=only`) still connects. +- An entry written for that server's query (`hostname`, `port`, `pathname`, `live-reload`, `hot=only`) still connects, with no deprecation warning for that server's own spelling. - The published client is ES5 down to the logger. - The overlay sits at the highest `z-index` and closes on `Esc`. - The building indicator works without Shadow DOM and is announced as a progress bar. From 48f0b0091accb1ac4217aceaaee910b98804523f Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:49:08 +0000 Subject: [PATCH 5/8] fix(client): type `client.client` on `client/socket`, and guard the example `client/socket`'s `client` is now typed with its `client`, the `WebSocket` or `EventSource` underneath, which the documented usage reads. It is a type of that export's own, so the contract a custom transport implements is unchanged. The README's example reads it only when there is one, and both it and the module's comment now say when that is: from when the runtime starts to connect, with a new one for each connection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- README.md | 14 +++++++++----- client-src/clients/createSocket.js | 8 +++++++- client-src/socket.js | 11 ++++++----- types/client/clients/createSocket.d.ts | 16 ++++++++++++++-- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d2e693514..cb42154ab 100644 --- a/README.md +++ b/README.md @@ -970,11 +970,15 @@ connection it holds: ```js import { client } from "webpack-dev-middleware/client/socket"; -// `client` is live: `null` until the runtime connects and while it reconnects. -// `client.client` is the `WebSocket` or `EventSource` underneath. -client.client.addEventListener("message", (event) => { - console.log(JSON.parse(event.data)); -}); +// `client` is live: `null` before the runtime starts to connect and while it +// waits to reconnect. `client.client` is the `WebSocket` or `EventSource` +// underneath, a new one for each connection, so a listener added to it hears +// that connection only. +if (client && client.client) { + client.client.addEventListener("message", (event) => { + console.log(JSON.parse(event.data)); + }); +} ``` This is the shape webpack-dev-server's `client/socket` has always exported, diff --git a/client-src/clients/createSocket.js b/client-src/clients/createSocket.js index 750960342..748a0303b 100644 --- a/client-src/clients/createSocket.js +++ b/client-src/clients/createSocket.js @@ -33,13 +33,19 @@ import { log } from "../utils/log.js"; * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor */ +/** + * A connection as `client` hands it out. Its own `client` is the `WebSocket` + * or `EventSource` underneath, on the built-in transports. + * @typedef {CommunicationClient & { client?: WebSocket | EventSource }} LiveClient + */ + /** * The connection the runtime holds right now, or `null` while there is none — * for tooling that listens alongside the runtime rather than through it. * `client.client` is the `WebSocket` or `EventSource` underneath, the shape * webpack-dev-server's `client/socket` has always exported, which is what * `@pmmmwh/react-refresh-webpack-plugin` reads its build messages from. - * @type {CommunicationClient | null} + * @type {LiveClient | null} */ // eslint-disable-next-line import/no-mutable-exports export let client = null; diff --git a/client-src/socket.js b/client-src/socket.js index ca01461c7..f6bb286a1 100644 --- a/client-src/socket.js +++ b/client-src/socket.js @@ -1,8 +1,9 @@ // The connection the runtime holds, for tooling that listens alongside it. // -// `client` is live: `null` until the runtime connects and while it is -// reconnecting, and the current transport instance otherwise, whose own -// `client` is the `WebSocket` or `EventSource` underneath. It is the same -// shape webpack-dev-server's `client/socket` has always exported, which is -// how `@pmmmwh/react-refresh-webpack-plugin` reads its build messages. +// `client` is live: `null` before the runtime starts to connect and while it +// waits to reconnect, and the current transport instance otherwise, whose own +// `client` is the `WebSocket` or `EventSource` underneath — a new one for each +// connection. It is the same shape webpack-dev-server's `client/socket` has +// always exported, which is how `@pmmmwh/react-refresh-webpack-plugin` reads +// its build messages. export { client } from "./clients/createSocket.js"; diff --git a/types/client/clients/createSocket.d.ts b/types/client/clients/createSocket.d.ts index 5dbafe752..a75fd5c2f 100644 --- a/types/client/clients/createSocket.d.ts +++ b/types/client/clients/createSocket.d.ts @@ -43,15 +43,20 @@ export default function createSocket( * @property {(() => void)=} onDisconnect called once per outage — on the first drop, whether or not that connection ever opened * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor */ +/** + * A connection as `client` hands it out. Its own `client` is the `WebSocket` + * or `EventSource` underneath, on the built-in transports. + * @typedef {CommunicationClient & { client?: WebSocket | EventSource }} LiveClient + */ /** * The connection the runtime holds right now, or `null` while there is none — * for tooling that listens alongside the runtime rather than through it. * `client.client` is the `WebSocket` or `EventSource` underneath, the shape * webpack-dev-server's `client/socket` has always exported, which is what * `@pmmmwh/react-refresh-webpack-plugin` reads its build messages from. - * @type {CommunicationClient | null} + * @type {LiveClient | null} */ -export let client: CommunicationClient | null; +export let client: LiveClient | null; /** * Called with no argument for open and close, and with the message string for * a message. @@ -107,3 +112,10 @@ export type SocketOptions = { */ clientOptions?: EXPECTED_ANY | undefined; }; +/** + * A connection as `client` hands it out. Its own `client` is the `WebSocket` + * or `EventSource` underneath, on the built-in transports. + */ +export type LiveClient = CommunicationClient & { + client?: WebSocket | EventSource; +}; From 3c41aec43cdb7943e2e41b0cd0c20e87362f3b9b Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:07:30 +0000 Subject: [PATCH 6/8] test: run a universal build in Node and in a browser A universal build (`target: "universal"` or `["web", "node"]`) is one bundle for a page and for Node. Tests build both spellings with the client over each transport, and through a re-export that hands it its query: Node runs the bundle with no error, no output from the client and nothing left running, and a browser updates the page in place and shows and clears the overlay. The check that the client is in the bundle takes either path separator, as webpack writes Windows paths with backslashes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- README.md | 4 +- test/e2e/universal-target.test.js | 171 ++++++++++++++++++++++++++++++ test/universal-target.test.js | 143 +++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 test/e2e/universal-target.test.js create mode 100644 test/universal-target.test.js diff --git a/README.md b/README.md index cb42154ab..09658e6e1 100644 --- a/README.md +++ b/README.md @@ -714,7 +714,7 @@ Only the ones a browser runs, decided by the compilation's [`target`](https://we | unset (webpack's default), `web`, `browserslist: …` | yes | | `webworker` | yes | | `electron-renderer`, `electron-preload`, `nwjs`, `node-webkit` | yes | -| universal — `web` and `node` together, as in `["node", "web"]` | yes | +| universal — `"universal"`, or `web` and `node` together | yes | | `node`, `node14`, `async-node`, `electron-main` | no | | `deno` | no | | `false`, or a version with no platform such as `es2020` | no | @@ -723,6 +723,8 @@ So in a multi-compiler build the browser half gets a client and the server-rende **Web workers are included.** A worker has no `window` and no document, but it has `EventSource`, `WebSocket` and webpack's runtime, which is all an update needs — so a worker compilation gets a client and applies updates in place, with the overlay and the building indicator left to the page. The one thing a worker cannot do is reload itself, since it has no `location.reload`; when an update cannot be applied the client says so and leaves the page that started the worker to reload it. +**A universal build runs it in Node too.** One bundle serves both, so the runtime is in what Node runs as well, and there it does nothing: it opens no connection, prints nothing and leaves nothing running. + `deno` is a context webpack also counts as `web`, and it stays out until it can be tested there — it has no `window` either, and whether the transports are available is not something this project's test suite can answer. The last row names no platform for the middleware to go on; if it is a browser bundle, add the entry yourself as above. diff --git a/test/e2e/universal-target.test.js b/test/e2e/universal-target.test.js new file mode 100644 index 000000000..3b2b9360e --- /dev/null +++ b/test/e2e/universal-target.test.js @@ -0,0 +1,171 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import express from "express"; +import webpack from "webpack"; + +import middleware from "../../src"; +import { closeE2e, waitForAppText } from "../helpers/e2e"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(400000); + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** + * An app that runs anywhere: it renders only where there is a page. + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function universalApp(text) { + return `if (typeof document !== "undefined") { + document.getElementById("app").textContent = ${JSON.stringify(text)}; +} + +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); +} +`; +} + +/** + * A universal build is an ES module, so the page loads it as one. + * @param {string | string[]} target webpack target + * @param {EXPECTED_ANY} hot `hot` option + * @returns {Promise} the running app + */ +async function serve(target, hot) { + const dir = fs.mkdtempSync( + path.join(fs.realpathSync.native(os.tmpdir()), "wdm-universal-e2e-"), + ); + const entry = path.join(dir, "app.js"); + + fs.writeFileSync(entry, universalApp("v1")); + + const compiler = webpack({ + mode: "development", + devtool: false, + context: dir, + entry, + target, + output: { path: path.join(dir, "dist"), publicPath: "/" }, + infrastructureLogging: { level: "none" }, + stats: "none", + watchOptions: { aggregateTimeout: 50, poll: 100 }, + }); + const instance = middleware(compiler, { hot }); + const app = express(); + + app.get("/", (_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.end( + 'universal
', + ); + }); + app.use(instance); + + const server = await new Promise((resolve, reject) => { + const created = app.listen(0); + + created.once("listening", () => resolve(created)); + created.once("error", reject); + }); + + if (hot.transport === "ws") { + instance.attach(server); + } + + await new Promise((resolve) => { + instance.waitUntilValid(resolve); + }); + + return { + url: `http://127.0.0.1:${server.address().port}/`, + edit(source) { + fs.writeFileSync(entry, source); + }, + async close() { + await new Promise((resolve) => { + instance.close(resolve); + }); + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10 }); + }, + }; +} + +// The browser half of a universal build, which `../universal-target.test.js` +// runs in Node: the client the bundle carries for a page works there as it +// does in a `web` build. +describe("a universal build (browser)", () => { + /** @type {[string, EXPECTED_ANY, string][]} */ + const clients = [ + [ + "the client over Server-Sent Events", + {}, + "webpack-dev-middleware-hot-overlay", + ], + [ + "the client over a WebSocket", + { transport: "ws" }, + "webpack-dev-middleware-hot-overlay", + ], + ]; + // Both spellings of the target, for each transport. + const cases = ["universal", ["web", "node"]].flatMap((target) => + clients.map(([title, hot, overlayId]) => [title, target, hot, overlayId]), + ); + + let served; + let browser; + let page; + + afterEach(async () => { + ({ browser, app: served } = await closeE2e(browser, served)); + }); + + it.each(cases)( + "updates the page and shows the overlay with %s, built for %j", + async (_title, target, hot, overlayId) => { + served = await serve(target, hot); + ({ page, browser } = await runBrowser()); + + /** @type {string[]} */ + const pageErrors = []; + + page.on("pageerror", (error) => { + pageErrors.push(String(error)); + }); + + await page.goto(served.url); + await waitForAppText(page, "v1"); + await page.evaluate(() => { + globalThis.notReloaded = true; + }); + + // Applied in place. + served.edit(universalApp("v2")); + await waitForAppText(page, "v2"); + + expect(await page.evaluate(() => globalThis.notReloaded)).toBe(true); + + served.edit("export const broken = ;"); + await page.waitForSelector(`#${overlayId}`, { timeout: 30000 }); + + served.edit(universalApp("v3")); + await waitForAppText(page, "v3"); + await page.waitForFunction( + (id) => !document.getElementById(id), + { timeout: 30000 }, + overlayId, + ); + + expect(pageErrors).toEqual([]); + }, + ); +}); diff --git a/test/universal-target.test.js b/test/universal-target.test.js new file mode 100644 index 000000000..b51024761 --- /dev/null +++ b/test/universal-target.test.js @@ -0,0 +1,143 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import webpack from "webpack"; + +import middleware from "../src"; + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +const run = promisify(execFile); + +const APP = `console.log("Hey."); + +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); +} +`; + +const CLIENT = path.resolve(__dirname, "../client/index.js"); + +// A module of someone else's that re-exports the client and hands it the query +// written after its own name, as webpack-dev-server's `client/index.js` does. +const STAND_IN = `/* global __resourceQuery */ +var inBrowser = typeof self !== "undefined"; + +if (inBrowser) { + self.__webpack_dev_middleware_client_query__ = __resourceQuery; +} + +module.exports = require(${JSON.stringify(CLIENT)}); +`; + +/** + * Build the app the way the middleware serves it, and write it out. + * @param {string | string[]} target webpack target + * @param {(dir: string) => EXPECTED_ANY} hot the `hot` option, given the app's directory + * @param {(dir: string) => EXPECTED_ANY=} entry the entry, given the app's directory + * @returns {Promise<{ errors: string[], bundle: string, close: () => void }>} what was built, and where + */ +async function build(target, hot, entry) { + const dir = fs.mkdtempSync( + path.join(fs.realpathSync.native(os.tmpdir()), "wdm-universal-"), + ); + + fs.writeFileSync(path.join(dir, "app.js"), APP); + fs.writeFileSync(path.join(dir, "stand-in.js"), STAND_IN); + + const compiler = webpack({ + mode: "development", + devtool: false, + context: dir, + entry: entry ? entry(dir) : "./app.js", + target, + output: { path: path.join(dir, "dist") }, + infrastructureLogging: { level: "none" }, + stats: "none", + }); + const instance = middleware(compiler, { hot: hot(dir), writeToDisk: true }); + + await new Promise((resolve) => { + instance.waitUntilValid(resolve); + }); + + const errors = /** @type {EXPECTED_ANY} */ (instance.context.stats) + .toJson({ all: false, errors: true }) + .errors.map((/** @type {EXPECTED_ANY} */ item) => item.message); + + await new Promise((resolve) => { + instance.close(resolve); + }); + + return { + errors, + bundle: path.join(dir, "dist", "main.mjs"), + close: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +/** @type {[string, (dir: string) => EXPECTED_ANY, ((dir: string) => EXPECTED_ANY) | undefined, RegExp][]} */ +const CLIENTS = [ + [ + "the client over Server-Sent Events", + () => ({}), + undefined, + /[\\/]client[\\/]+index\.js\?/, + ], + [ + "the client over a WebSocket", + () => ({ transport: "ws" }), + undefined, + /[\\/]client[\\/]+index\.js\?/, + ], + [ + "the client through a re-export of it", + () => ({ inject: false }), + (dir) => [`${path.join(dir, "stand-in.js")}?path=/hmr`, "./app.js"], + /stand-in\.js\?/, + ], +]; + +// Both spellings of the target, for each way of adding the client. +const CASES = ["universal", ["web", "node"]].flatMap((target) => + CLIENTS.map(([title, hot, entry, client]) => [ + title, + target, + hot, + entry, + client, + ]), +); + +// A universal build is one bundle for a browser and for Node. The client is in +// it for the browser — see `e2e/universal-target.test.js` — and in Node it has +// to stay out of the way: no error, nothing said, nothing left running. +describe("a universal build, run in Node", () => { + it.each(CASES)( + "runs %s quietly, built for %j", + async (_title, target, hot, entry, client) => { + const { errors, bundle, close } = await build(target, hot, entry); + + try { + expect(errors).toEqual([]); + // Not vacuous: the client is in what runs. + expect(fs.readFileSync(bundle, "utf8")).toMatch(client); + + // Rejects on an exit code other than 0, and on a process still + // running — a connection, a timer — after the timeout. + const { stdout, stderr } = await run(process.execPath, [bundle], { + timeout: 20000, + }); + + expect(stderr).toBe(""); + expect(stdout).toBe("Hey.\n"); + } finally { + close(); + } + }, + ); +}); From c82c422d1f3ea9dd36e24cdfc90371fa9a76e016 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:34:17 +0000 Subject: [PATCH 7/8] test: cover React Refresh's hot scenarios with the client `@pmmmwh/react-refresh-webpack-plugin` applies Fast Refresh through this client, so a change to how it applies updates shows here, over each transport: - a component keeps its state across an edit, and across several; - an edit to another module updates it and keeps its parent's state; - a component whose hooks changed is started over, in place; - a build error shows the overlay, and the fix is applied in place; - an error thrown while rendering shows, and the root renders again; - an edit no component accepts reloads the page. The sources are written as `react-refresh/babel` leaves them, since babel-loader cannot load `@babel/core` in this test runner on every Node.js version CI uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- package-lock.json | 141 ++++++++++++++ package.json | 4 + test/e2e/react-refresh.test.js | 324 +++++++++++++++++++++++++++++++++ 3 files changed, 469 insertions(+) create mode 100644 test/e2e/react-refresh.test.js diff --git a/package-lock.json b/package-lock.json index 3f570f8df..d4cb7adda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@fastify/express": "^4.0.7", "@hapi/hapi": "^21.4.10", "@hono/node-server": "^2.1.1", + "@pmmmwh/react-refresh-webpack-plugin": "^0.6.3", "@types/connect": "^3.4.38", "@types/express": "^5.0.6", "@types/mime-db": "^1.43.6", @@ -58,6 +59,9 @@ "npm-run-all": "^4.1.5", "prettier": "^3.9.6", "puppeteer": "^25.11.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-refresh": "^0.19.0", "router": "^2.2.0", "supertest": "^7.2.2", "typescript": "^6.0.3", @@ -6641,6 +6645,64 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.3.tgz", + "integrity": "sha512-k1EEmFS9K7685tqJM55DCA3sYlTNZb7aFj1M3HNFwMqH+fGgMEvemIQ5kurIzKc9wYRSmfKpSZN3WUyY/GHlvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "anser": "^2.1.1", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "@types/webpack": "5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <6.0.0", + "webpack": "^5.0.0", + "webpack-dev-server": "^4.8.0 || 5.x || 6.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/@pnpm/deps.graph-sequencer": { "version": "1100.0.1", "resolved": "https://registry.npmjs.org/@pnpm/deps.graph-sequencer/-/deps.graph-sequencer-1100.0.1.tgz", @@ -8131,6 +8193,13 @@ ], "license": "BSD-3-Clause" }, + "node_modules/anser": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", + "integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -9396,6 +9465,21 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-js-pure": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz", + "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10164,6 +10248,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -18260,6 +18354,29 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -18267,6 +18384,16 @@ "dev": true, "license": "MIT" }, + "node_modules/react-refresh": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.19.0.tgz", + "integrity": "sha512-5HdstYgoJBbBofjapO5qCPcovECJCBfvjLb+iTcsmqDFVMRdqy0qs7iiInBJTUXmIFc2bjoc5b2HVLp2zRtYhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -18724,6 +18851,13 @@ "dev": true, "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, "node_modules/schema-utils": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", @@ -19223,6 +19357,13 @@ "node": ">=8" } }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 1318a3b65..81f1e598f 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@fastify/express": "^4.0.7", "@hapi/hapi": "^21.4.10", "@hono/node-server": "^2.1.1", + "@pmmmwh/react-refresh-webpack-plugin": "^0.6.3", "@types/connect": "^3.4.38", "@types/express": "^5.0.6", "@types/mime-db": "^1.43.6", @@ -137,6 +138,9 @@ "npm-run-all": "^4.1.5", "prettier": "^3.9.6", "puppeteer": "^25.11.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-refresh": "^0.19.0", "router": "^2.2.0", "supertest": "^7.2.2", "typescript": "^6.0.3", diff --git a/test/e2e/react-refresh.test.js b/test/e2e/react-refresh.test.js new file mode 100644 index 000000000..f96749650 --- /dev/null +++ b/test/e2e/react-refresh.test.js @@ -0,0 +1,324 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import ReactRefreshPlugin from "@pmmmwh/react-refresh-webpack-plugin"; +import express from "express"; +import webpack from "webpack"; + +import middleware from "../../src"; +import collectConsole from "../helpers/console-collector"; +import { closeE2e } from "../helpers/e2e"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(400000); + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; +const NODE_MODULES = path.resolve(__dirname, "../../node_modules"); + +// What `react-refresh/babel` makes of a module: each component registered +// with Fast Refresh, and a component's hooks summed up as a signature it +// compares across edits. Written out rather than produced by babel-loader, +// which cannot load `@babel/core` inside this test runner on every Node.js +// version CI uses; the plugin's own loader does the rest. + +/** + * A component with state of its own, which Fast Refresh keeps across an edit. + * @param {string} text what the button says before the count + * @param {{ hooks?: boolean, crash?: boolean }=} options a second hook, which changes the component's hook signature; or a render that throws + * @returns {string} the module's source + */ +function component(text, { hooks = false, crash = false } = {}) { + return `var _s = $RefreshSig$(); +import { createElement, useState } from "react"; +import Child from "./Child.js"; + +export default function App() { + _s(); + const [count, setCount] = useState(0); +${hooks ? ' const [other] = useState("other");\n' : ""}${crash ? ' throw new Error("Render failed on purpose");\n' : ""} + return createElement( + "div", + null, + createElement( + "button", + { id: "counter", onClick: () => setCount(count + 1) }, + ${JSON.stringify(text)} + " " + count, + ), + createElement(Child), + ); +} + +_s(App, ${JSON.stringify(hooks ? "useState{count} useState{other}" : "useState{count}")}); +var _c = App; +$RefreshReg$(_c, "App"); +`; +} + +/** + * @param {string} text what the child says + * @returns {string} the module's source + */ +function child(text) { + return `import { createElement } from "react"; + +export default function Child() { + return createElement("p", { id: "child" }, ${JSON.stringify(text)}); +} + +var _c = Child; +$RefreshReg$(_c, "Child"); +`; +} + +const INDEX = `import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.js"; +import { label } from "./label.js"; + +const container = document.getElementById("root"); + +container.dataset.label = label; +createRoot(container).render(createElement(App)); +`; + +/** + * A React app with Fast Refresh, served by the middleware with its own client. + * The plugin's overlay reads webpack-dev-server's or webpack-hot-middleware's + * messages, not this one's, so the middleware's overlay is the one shown. + * @param {EXPECTED_ANY} hot the `hot` option + * @returns {Promise} the running app + */ +async function serve(hot) { + const dir = fs.mkdtempSync( + path.join(fs.realpathSync.native(os.tmpdir()), "wdm-react-refresh-"), + ); + const files = { + app: path.join(dir, "App.js"), + child: path.join(dir, "Child.js"), + label: path.join(dir, "label.js"), + }; + + // The plugin's loader writes ES module code for a package that says it is + // one. + fs.writeFileSync(path.join(dir, "package.json"), '{ "type": "module" }\n'); + fs.writeFileSync(path.join(dir, "index.js"), INDEX); + fs.writeFileSync(files.app, component("Clicked")); + fs.writeFileSync(files.child, child("child v1")); + fs.writeFileSync(files.label, 'export const label = "first";\n'); + + const compiler = webpack({ + mode: "development", + devtool: false, + context: dir, + entry: "./index.js", + output: { path: path.join(dir, "dist"), publicPath: "/" }, + resolve: { modules: [NODE_MODULES, "node_modules"] }, + plugins: [new ReactRefreshPlugin({ overlay: false })], + infrastructureLogging: { level: "none" }, + stats: "none", + watchOptions: { aggregateTimeout: 50, poll: 100 }, + }); + const instance = middleware(compiler, { hot }); + const app = express(); + + app.get("/", (_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.end( + 'react refresh
', + ); + }); + app.use(instance); + + const server = await new Promise((resolve, reject) => { + const created = app.listen(0); + + created.once("listening", () => resolve(created)); + created.once("error", reject); + }); + + if (hot.transport === "ws") { + instance.attach(server); + } + + await new Promise((resolve) => { + instance.waitUntilValid(resolve); + }); + + return { + url: `http://127.0.0.1:${server.address().port}/`, + /** + * @param {"app" | "child" | "label"} name which module + * @param {string} source its new source + */ + edit(name, source) { + fs.writeFileSync(files[name], source); + }, + async close() { + await new Promise((resolve) => { + instance.close(resolve); + }); + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10 }); + }, + }; +} + +/** + * @param {import("puppeteer").Page} page page + * @param {string} selector what to read + * @param {string | RegExp} text what it should say + * @returns {Promise} resolved once it does + */ +function waitForText(page, selector, text) { + const pattern = typeof text === "string" ? `^${text}$` : text.source; + + return page + .waitForFunction( + (target, source) => + new RegExp(source).test( + document.querySelector(target)?.textContent || "", + ), + { timeout: 60000, polling: 100 }, + selector, + pattern, + ) + .then(() => {}); +} + +// `@pmmmwh/react-refresh-webpack-plugin` with the middleware's own client: +// Fast Refresh is applied by this client, so each hot scenario a React project +// relies on is checked here, where a change to the client would break it. +describe.each([ + ["Server-Sent Events", {}], + ["a WebSocket", { transport: "ws" }], +])("React Refresh over %s (browser)", (_title, hot) => { + let served; + let browser; + let page; + let console_; + /** @type {string[]} */ + let pageErrors; + + /** + * @returns {Promise} whether the page is still the one first loaded + */ + function notReloaded() { + return page.evaluate(() => globalThis.notReloaded === true); + } + + beforeEach(async () => { + served = await serve(hot); + ({ page, browser } = await runBrowser()); + console_ = collectConsole(page); + pageErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(String(error)); + }); + + await page.goto(served.url); + await waitForText(page, "#counter", "Clicked 0"); + await console_.waitFor("connected"); + await page.click("#counter"); + await page.click("#counter"); + await waitForText(page, "#counter", "Clicked 2"); + await page.evaluate(() => { + globalThis.notReloaded = true; + }); + }); + + afterEach(async () => { + ({ browser, app: served } = await closeE2e(browser, served)); + }); + + it("keeps a component's state across an edit", async () => { + served.edit("app", component("Pressed")); + + await waitForText(page, "#counter", "Pressed 2"); + + expect(await notReloaded()).toBe(true); + expect(pageErrors).toEqual([]); + }); + + it("keeps it across several edits in a row", async () => { + for (const text of ["One", "Two", "Three"]) { + served.edit("app", component(text)); + await waitForText(page, "#counter", `${text} 2`); + } + + await page.click("#counter"); + await waitForText(page, "#counter", "Three 3"); + + expect(await notReloaded()).toBe(true); + expect(pageErrors).toEqual([]); + }); + + it("updates a component in another module, and keeps its parent's state", async () => { + served.edit("child", child("child v2")); + + await waitForText(page, "#child", "child v2"); + + expect(await page.$eval("#counter", (node) => node.textContent)).toBe( + "Clicked 2", + ); + expect(await notReloaded()).toBe(true); + }); + + it("remounts a component whose hooks changed, without reloading the page", async () => { + // Fast Refresh cannot carry state across a different set of hooks, so it + // starts the component over — in place. + served.edit("app", component("Hooked", { hooks: true })); + + await waitForText(page, "#counter", "Hooked 0"); + + expect(await notReloaded()).toBe(true); + }); + + it("shows a build error, and applies the fix in place", async () => { + served.edit("app", `${component("Pressed")}\nexport const broken = ;\n`); + + await page.waitForSelector(`#${OVERLAY_ID}`, { timeout: 60000 }); + + served.edit("app", component("Fixed")); + + await waitForText(page, "#counter", "Fixed 2"); + await page.waitForFunction( + (id) => !document.getElementById(id), + { timeout: 60000 }, + OVERLAY_ID, + ); + + expect(await notReloaded()).toBe(true); + }); + + it("shows an error thrown while rendering, and renders again once fixed", async () => { + served.edit("app", component("Pressed", { crash: true })); + + await page.waitForSelector(`#${OVERLAY_ID}`, { timeout: 60000 }); + + served.edit("app", component("Fixed")); + + // The failed root is rendered again by Fast Refresh, not by a reload. + await waitForText(page, "#counter", /^Fixed \d+$/); + + expect(await notReloaded()).toBe(true); + }); + + it("reloads the page for an edit no component accepts", async () => { + served.edit("label", 'export const label = "second";\n'); + + await page.waitForFunction( + () => document.querySelector("#root")?.dataset.label === "second", + { timeout: 60000, polling: 100 }, + ); + await waitForText(page, "#counter", "Clicked 0"); + + expect(await page.evaluate(() => globalThis.notReloaded)).toBeUndefined(); + }); +}); From 0c2aa2acdbc5bbd120462e20352736dca8d0037d Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:49:53 +0000 Subject: [PATCH 8/8] test: clean up after a universal build that fails to set up The helper made its directory and started a watcher before anything was handed back, so an error on the way left both behind. The watcher is now closed either way, and the directory removed when nothing is returned to remove it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- test/universal-target.test.js | 82 +++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/test/universal-target.test.js b/test/universal-target.test.js index b51024761..b140697e9 100644 --- a/test/universal-target.test.js +++ b/test/universal-target.test.js @@ -46,38 +46,56 @@ async function build(target, hot, entry) { path.join(fs.realpathSync.native(os.tmpdir()), "wdm-universal-"), ); - fs.writeFileSync(path.join(dir, "app.js"), APP); - fs.writeFileSync(path.join(dir, "stand-in.js"), STAND_IN); - - const compiler = webpack({ - mode: "development", - devtool: false, - context: dir, - entry: entry ? entry(dir) : "./app.js", - target, - output: { path: path.join(dir, "dist") }, - infrastructureLogging: { level: "none" }, - stats: "none", - }); - const instance = middleware(compiler, { hot: hot(dir), writeToDisk: true }); - - await new Promise((resolve) => { - instance.waitUntilValid(resolve); - }); - - const errors = /** @type {EXPECTED_ANY} */ (instance.context.stats) - .toJson({ all: false, errors: true }) - .errors.map((/** @type {EXPECTED_ANY} */ item) => item.message); - - await new Promise((resolve) => { - instance.close(resolve); - }); - - return { - errors, - bundle: path.join(dir, "dist", "main.mjs"), - close: () => fs.rmSync(dir, { recursive: true, force: true }), - }; + const remove = () => fs.rmSync(dir, { recursive: true, force: true }); + /** @type {EXPECTED_ANY} */ + let instance; + // The watcher, closed before the directory it watches can be removed. + const closeWatcher = () => + instance + ? new Promise((resolve) => { + instance.close(resolve); + }) + : Promise.resolve(); + + try { + fs.writeFileSync(path.join(dir, "app.js"), APP); + fs.writeFileSync(path.join(dir, "stand-in.js"), STAND_IN); + + const compiler = webpack({ + mode: "development", + devtool: false, + context: dir, + entry: entry ? entry(dir) : "./app.js", + target, + output: { path: path.join(dir, "dist") }, + infrastructureLogging: { level: "none" }, + stats: "none", + }); + + instance = middleware(compiler, { hot: hot(dir), writeToDisk: true }); + + await new Promise((resolve) => { + instance.waitUntilValid(resolve); + }); + + const errors = /** @type {EXPECTED_ANY} */ (instance.context.stats) + .toJson({ all: false, errors: true }) + .errors.map((/** @type {EXPECTED_ANY} */ item) => item.message); + + await closeWatcher(); + + return { + errors, + bundle: path.join(dir, "dist", "main.mjs"), + close: remove, + }; + } catch (error) { + // Nothing is handed back to clean up after, so it is done here. + await closeWatcher(); + remove(); + + throw error; + } } /** @type {[string, (dir: string) => EXPECTED_ANY, ((dir: string) => EXPECTED_ANY) | undefined, RegExp][]} */