From 0d5ccc7093966967598b19afd194b892a929a8af Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 16:58:53 -0500 Subject: [PATCH 01/13] feat: add browser client runtime for HMR Ports the browser client into `client-src/`, mirroring the layout used in `webpack-dev-server` (source in `client-src/`, built to `client/`). The client connects to the SSE endpoint via `EventSource`, parses query-string options from `__resourceQuery`, dispatches `building`, `built` and `sync` payloads, applies HMR through `process-update.js` and renders compile-time errors and warnings through an in-page overlay (`overlay.js`). Exposed via the `./client` subpath export so users can wire it as a webpack entry: `require('webpack-dev-middleware/client')`. The source is transpiled with a browser-targeted babel override and the resulting files are shipped under `/client`. --- .gitignore | 1 + babel.config.js | 13 + client-src/index.js | 358 +++++++++++++++++++ client-src/overlay.js | 121 +++++++ client-src/process-update.js | 191 ++++++++++ cspell.config.json | 13 + eslint.config.mjs | 33 ++ lint-staged.config.js | 2 +- package-lock.json | 653 ++++++++++++++++++++++++++++++++++- package.json | 20 +- 10 files changed, 1392 insertions(+), 13 deletions(-) create mode 100644 client-src/index.js create mode 100644 client-src/overlay.js create mode 100644 client-src/process-update.js create mode 100644 cspell.config.json diff --git a/.gitignore b/.gitignore index 13bb32b4c..106a4173f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ logs npm-debug.log* .eslintcache .cspellcache +/client /dist /local /reports diff --git a/babel.config.js b/babel.config.js index 700d9fd7a..6138b4a21 100644 --- a/babel.config.js +++ b/babel.config.js @@ -15,5 +15,18 @@ module.exports = (api) => { }, ], ], + overrides: [ + { + test: /client-src[\\/]/, + presets: [ + [ + "@babel/preset-env", + { + targets: "defaults", + }, + ], + ], + }, + ], }; }; diff --git a/client-src/index.js b/client-src/index.js new file mode 100644 index 000000000..a294dd700 --- /dev/null +++ b/client-src/index.js @@ -0,0 +1,358 @@ +/* global __resourceQuery, __webpack_public_path__ */ + +const stripAnsi = require("strip-ansi"); + +const processUpdate = require("./process-update"); + +/** @typedef {Record} ClientOptions */ + +/** @type {ClientOptions} */ +const options = { + path: "/__webpack_hmr", + timeout: 20 * 1000, + overlay: true, + reload: false, + log: true, + warn: true, + name: "", + autoConnect: true, + overlayStyles: {}, + overlayWarnings: false, + ansiColors: {}, +}; + +/** + * @param {Record} overrides parsed query-string overrides + */ +function setOverrides(overrides) { + if (overrides.autoConnect) { + options.autoConnect = overrides.autoConnect === "true"; + } + if (overrides.path) options.path = overrides.path; + if (overrides.timeout) options.timeout = Number(overrides.timeout); + if (overrides.overlay) options.overlay = overrides.overlay !== "false"; + if (overrides.reload) options.reload = overrides.reload !== "false"; + if (overrides.noInfo && overrides.noInfo !== "false") { + options.log = false; + } + if (overrides.name) { + options.name = overrides.name; + } + if (overrides.quiet && overrides.quiet !== "false") { + options.log = false; + options.warn = false; + } + + if (overrides.dynamicPublicPath) { + options.path = __webpack_public_path__ + options.path; + } + + if (overrides.ansiColors) { + options.ansiColors = JSON.parse(overrides.ansiColors); + } + if (overrides.overlayStyles) { + options.overlayStyles = JSON.parse(overrides.overlayStyles); + } + + if (overrides.overlayWarnings) { + options.overlayWarnings = overrides.overlayWarnings === "true"; + } +} + +/** + * @typedef {(event: { data: string }) => void} MessageListener + */ + +/** + * @returns {{ addMessageListener: (fn: MessageListener) => void }} event source wrapper + */ +function EventSourceWrapper() { + /** @type {EventSource} */ + let source; + let lastActivity = Date.now(); + /** @type {MessageListener[]} */ + const listeners = []; + /** @type {ReturnType} */ + let timer; + + const handleOnline = () => { + if (options.log) console.log("[HMR] connected"); + lastActivity = Date.now(); + }; + + /** + * @param {{ data: string }} event event + */ + const handleMessage = (event) => { + lastActivity = Date.now(); + for (const listener of listeners) { + listener(event); + } + }; + + const handleDisconnect = () => { + clearInterval(timer); + source.close(); + setTimeout(init, /** @type {number} */ (options.timeout)); + }; + + /** + * + */ + function init() { + source = new window.EventSource(/** @type {string} */ (options.path)); + source.addEventListener("open", handleOnline); + source.addEventListener("error", handleDisconnect); + source.addEventListener("message", handleMessage); + } + + init(); + timer = setInterval( + () => { + if (Date.now() - lastActivity > /** @type {number} */ (options.timeout)) { + handleDisconnect(); + } + }, + /** @type {number} */ (options.timeout) / 2, + ); + + return { + addMessageListener(fn) { + listeners.push(fn); + }, + }; +} + +const WRAPPER_KEY = "__wdmEventSourceWrapper"; + +/** + * @returns {ReturnType} cached event source wrapper for this path + */ +function getEventSourceWrapper() { + const path = /** @type {string} */ (options.path); + if (!window[WRAPPER_KEY]) { + window[WRAPPER_KEY] = {}; + } + if (!window[WRAPPER_KEY][path]) { + // Cache the wrapper so multiple entries on the same page sharing the same + // `options.path` reuse a single SSE connection. + window[WRAPPER_KEY][path] = EventSourceWrapper(); + } + return window[WRAPPER_KEY][path]; +} + +/** + * + */ +function connect() { + getEventSourceWrapper().addMessageListener((event) => { + if (event.data === "💓") { + return; + } + try { + processMessage(JSON.parse(event.data)); + } catch (err) { + if (options.warn) { + console.warn(`Invalid HMR message: ${event.data}\n${err}`); + } + } + }); +} + +/** + * @param {Record} overrides overrides + */ +function setOptionsAndConnect(overrides) { + setOverrides(overrides); + connect(); +} + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** @typedef {{ name?: string, errors: string[], warnings: string[], hash: string, time?: number, modules?: Record, action?: string }} HMRPayload */ + +/** + * @returns {{ + * cleanProblemsCache: () => void, + * problems: (type: "errors" | "warnings", obj: HMRPayload) => boolean, + * success: () => void, + * useCustomOverlay: (customOverlay: EXPECTED_ANY) => void, + * }} reporter + */ +function createReporter() { + /** @type {EXPECTED_ANY} */ + let overlay; + if (typeof document !== "undefined" && options.overlay) { + overlay = require("./overlay")({ + ansiColors: options.ansiColors, + overlayStyles: options.overlayStyles, + }); + } + + /** @type {Record} */ + const styles = { + errors: "color: #ff0000;", + warnings: "color: #999933;", + }; + /** @type {string | null} */ + let previousProblems = null; + + /** + * @param {"errors" | "warnings"} type problem type + * @param {HMRPayload} obj payload + */ + const log = (type, obj) => { + const newProblems = obj[type].map(stripAnsi).join("\n"); + if (previousProblems === newProblems) { + return; + } + previousProblems = newProblems; + + const style = styles[type]; + const name = obj.name ? `'${obj.name}' ` : ""; + const title = `[HMR] bundle ${name}has ${obj[type].length} ${type}`; + // NOTE: console.warn / console.error print the stack trace which is noise + // for us; using console.log to keep the message clean. + if (console.group && console.groupEnd) { + console.group(`%c${title}`, style); + console.log(`%c${newProblems}`, style); + console.groupEnd(); + } else { + console.log( + `%c${title}\n\t%c${newProblems.replaceAll("\n", "\n\t")}`, + `${style}font-weight: bold;`, + `${style}font-weight: normal;`, + ); + } + }; + + return { + cleanProblemsCache() { + previousProblems = null; + }, + problems(type, obj) { + if (options.warn) { + log(type, obj); + } + if (overlay) { + if (options.overlayWarnings || type === "errors") { + overlay.showProblems(type, obj[type]); + return false; + } + overlay.clear(); + } + return true; + }, + success() { + if (overlay) overlay.clear(); + }, + useCustomOverlay(customOverlay) { + overlay = customOverlay; + }, + }; +} + +// The reporter is a singleton on the page so that, when multiple bundles +// include the client, errors are reported once but all clients receive them. +const REPORTER_KEY = "__webpack_dev_middleware_hot_reporter__"; +/** @type {ReturnType | undefined} */ +let reporter; + +/** @type {((obj: HMRPayload) => void) | undefined} */ +let customHandler; +/** @type {((obj: HMRPayload) => void) | undefined} */ +let subscribeAllHandler; + +/** + * @param {HMRPayload} obj payload + */ +function processMessage(obj) { + switch (obj.action) { + case "building": { + if (options.log) { + console.log( + `[HMR] bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilding`, + ); + } + break; + } + case "built": + case "sync": { + if (obj.action === "built" && options.log) { + console.log( + `[HMR] bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilt in ${obj.time}ms`, + ); + } + if (obj.name && options.name && obj.name !== options.name) { + return; + } + let applyUpdate = true; + if (obj.errors.length > 0) { + if (reporter) reporter.problems("errors", obj); + applyUpdate = false; + } else if (obj.warnings.length > 0) { + if (reporter) { + applyUpdate = reporter.problems("warnings", obj); + } + } else if (reporter) { + reporter.cleanProblemsCache(); + reporter.success(); + } + if (applyUpdate) { + processUpdate(obj.hash, obj.modules, options); + } + break; + } + default: { + if (customHandler) { + customHandler(obj); + } + } + } + + if (subscribeAllHandler) { + subscribeAllHandler(obj); + } +} + +// Bootstrap: parse query string overrides, then connect (if enabled). +if (typeof __resourceQuery === "string" && __resourceQuery.length > 0) { + const params = [...new URLSearchParams(__resourceQuery.slice(1))]; + /** @type {Record} */ + const overrides = {}; + for (const [key, value] of params) { + overrides[key] = value; + } + setOverrides(overrides); +} + +if (typeof window !== "undefined") { + if (!window[REPORTER_KEY]) { + window[REPORTER_KEY] = createReporter(); + } + reporter = window[REPORTER_KEY]; + + if (typeof window.EventSource === "undefined") { + console.warn( + "webpack-dev-middleware's hot client requires EventSource to work. " + + "Include a polyfill if you want to support this browser: " + + "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events#Tools", + ); + } else if (options.autoConnect) { + connect(); + } +} + +module.exports = { + subscribeAll(handler) { + subscribeAllHandler = handler; + }, + subscribe(handler) { + customHandler = handler; + }, + useCustomOverlay(customOverlay) { + if (reporter) reporter.useCustomOverlay(customOverlay); + }, + setOptionsAndConnect, +}; diff --git a/client-src/overlay.js b/client-src/overlay.js new file mode 100644 index 000000000..e6a332f8a --- /dev/null +++ b/client-src/overlay.js @@ -0,0 +1,121 @@ +const ansiHTML = require("ansi-html-community"); +const htmlEntities = require("html-entities"); + +const clientOverlay = document.createElement("div"); +clientOverlay.id = "webpack-dev-middleware-hot-overlay"; + +/** @type {Record} */ +const styles = { + background: "rgba(0,0,0,0.85)", + color: "#e8e8e8", + lineHeight: "1.6", + whiteSpace: "pre", + fontFamily: "Menlo, Consolas, monospace", + fontSize: "13px", + position: "fixed", + zIndex: 9999, + padding: "10px", + left: 0, + right: 0, + top: 0, + bottom: 0, + overflow: "auto", + dir: "ltr", + textAlign: "left", +}; + +/** @type {Record} */ +const colors = { + reset: ["transparent", "transparent"], + black: "181818", + red: "ff3348", + green: "3fff4f", + yellow: "ffd30e", + blue: "169be0", + magenta: "f840b7", + cyan: "0ad8e9", + lightgrey: "ebe7e3", + darkgrey: "6d7891", +}; + +/** + * @param {"errors" | "warnings"} type problem type + * @returns {string} HTML span with a colored badge + */ +function problemType(type) { + /** @type {Record} */ + const problemColors = { + errors: colors.red, + warnings: colors.yellow, + }; + const color = problemColors[type] || colors.red; + return ( + `' + + `${type.slice(0, -1).toUpperCase()}` + ); +} + +/** + * @param {"errors" | "warnings"} type problem type + * @param {string[]} lines messages to render + */ +function showProblems(type, lines) { + clientOverlay.innerHTML = ""; + for (const line of lines) { + const msg = ansiHTML(htmlEntities.encode(line)); + const div = document.createElement("div"); + div.style.marginBottom = "26px"; + div.innerHTML = `${problemType(type)} in ${msg}`; + clientOverlay.appendChild(div); + } + if (document.body) { + document.body.appendChild(clientOverlay); + } +} + +/** + * + */ +function clear() { + if (document.body && clientOverlay.parentNode) { + document.body.removeChild(clientOverlay); + } +} + +/** + * @param {{ ansiColors?: Record, overlayStyles?: Record }} options options + * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api + */ +module.exports = function (options) { + if (options.ansiColors) { + for (const color of Object.keys(options.ansiColors)) { + if (color in colors) { + colors[color] = options.ansiColors[color]; + } + } + ansiHTML.setColors(colors); + } + + if (options.overlayStyles) { + for (const style of Object.keys(options.overlayStyles)) { + styles[style] = options.overlayStyles[style]; + } + } + + for (const key of Object.keys(styles)) { + /** @type {EXPECTED_ANY} */ + (clientOverlay.style)[key] = styles[key]; + } + + return { + showProblems, + clear, + }; +}; + +module.exports.clear = clear; +module.exports.showProblems = showProblems; + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ diff --git a/client-src/process-update.js b/client-src/process-update.js new file mode 100644 index 000000000..2dc301348 --- /dev/null +++ b/client-src/process-update.js @@ -0,0 +1,191 @@ +/* global __webpack_hash__ */ + +if (!module.hot) { + throw new Error("[HMR] Hot Module Replacement is disabled."); +} + +const HMR_DOCS_URL = "https://webpack.js.org/concepts/hot-module-replacement/"; + +/** @type {string | undefined} */ +let lastHash; +/** @type {Record} */ +const failureStatuses = { abort: 1, fail: 1 }; + +const applyOptions = { + ignoreUnaccepted: true, + ignoreDeclined: true, + ignoreErrored: true, + /** + * @param {{ chain: string[] }} data data + */ + onUnaccepted(data) { + console.warn( + `Ignored an update to unaccepted module ${data.chain.join(" -> ")}`, + ); + }, + /** + * @param {{ chain: string[] }} data data + */ + onDeclined(data) { + console.warn( + `Ignored an update to declined module ${data.chain.join(" -> ")}`, + ); + }, + /** + * @param {{ error: Error, moduleId: string, type: string }} data data + */ + onErrored(data) { + console.error(data.error); + console.warn( + `Ignored an error while updating module ${data.moduleId} (${data.type})`, + ); + }, +}; + +/** + * @param {string=} hash latest webpack compilation hash + * @returns {boolean} true when the current bundle matches the latest hash + */ +function upToDate(hash) { + if (hash) lastHash = hash; + return lastHash === __webpack_hash__; +} + +/** + * @param {string} hash latest hash from the SSE payload + * @param {Record | undefined} moduleMap module id → name map + * @param {{ reload?: boolean, log?: boolean, warn?: boolean }} options client options + */ +module.exports = function (hash, moduleMap, options) { + const { reload } = options; + + /** + * @param {Error} err error + */ + function handleError(err) { + if (module.hot.status() in failureStatuses) { + if (options.warn) { + console.warn("[HMR] Cannot check for update (Full reload needed)"); + console.warn(`[HMR] ${err.stack || err.message}`); + } + performReload(); + return; + } + if (options.warn) { + console.warn(`[HMR] Update check failed: ${err.stack || err.message}`); + } + } + + /** + * + */ + function performReload() { + if (reload) { + if (options.warn) console.warn("[HMR] Reloading page"); + window.location.reload(); + } + } + + /** + * @param {string[]} updatedModules ids of modules that were attempted to update + * @param {string[] | undefined} renewedModules ids of modules that were successfully renewed + */ + function logUpdates(updatedModules, renewedModules) { + const unacceptedModules = updatedModules.filter( + (moduleId) => !renewedModules || !renewedModules.includes(moduleId), + ); + + if (unacceptedModules.length > 0) { + if (options.warn) { + console.warn( + "[HMR] The following modules couldn't be hot updated: " + + "(Full reload needed)\n" + + "This is usually because the modules which have changed " + + "(and their parents) do not know how to hot reload themselves. " + + `See ${HMR_DOCS_URL} for more details.`, + ); + for (const moduleId of unacceptedModules) { + console.warn( + `[HMR] - ${(moduleMap && moduleMap[moduleId]) || moduleId}`, + ); + } + } + performReload(); + return; + } + + if (options.log) { + if (!renewedModules || renewedModules.length === 0) { + console.log("[HMR] Nothing hot updated."); + } else { + console.log("[HMR] Updated modules:"); + for (const moduleId of renewedModules) { + console.log( + `[HMR] - ${(moduleMap && moduleMap[moduleId]) || moduleId}`, + ); + } + } + + if (upToDate()) { + console.log("[HMR] App is up to date."); + } + } + } + + /** + * + */ + function check() { + /** + * @param {Error | null} err err + * @param {string[] | null=} updatedModules updated module ids + */ + const cb = (err, updatedModules) => { + if (err) return handleError(err); + + if (!updatedModules) { + if (options.warn) { + console.warn("[HMR] Cannot find update (Full reload needed)"); + console.warn("[HMR] (Probably because of restarting the server)"); + } + performReload(); + return; + } + + /** + * @param {Error | null} applyErr apply error + * @param {string[] | undefined} renewedModules renewed module ids + */ + const applyCallback = (applyErr, renewedModules) => { + if (applyErr) return handleError(applyErr); + + if (!upToDate()) check(); + + logUpdates(updatedModules, renewedModules); + }; + + const applyResult = module.hot.apply(applyOptions, applyCallback); + // webpack 2 promise + if (applyResult && applyResult.then) { + applyResult.then((outdatedModules) => { + applyCallback(null, outdatedModules); + }); + applyResult.catch(applyCallback); + } + }; + + const result = module.hot.check(false, cb); + // webpack 2 promise + if (result && result.then) { + result.then((updatedModules) => { + cb(null, updatedModules); + }); + result.catch(cb); + } + } + + if (!upToDate(hash) && module.hot.status() === "idle") { + if (options.log) console.log("[HMR] Checking for updates on the server..."); + check(); + } +}; diff --git a/cspell.config.json b/cspell.config.json new file mode 100644 index 000000000..affcb0b57 --- /dev/null +++ b/cspell.config.json @@ -0,0 +1,13 @@ +{ + "ignorePaths": [ + "/client/**", + "/dist/**", + "/node_modules/**", + "/coverage/**", + "/test/outputs/**", + "/test/fixtures/**", + "CHANGELOG.md", + "cspell.config.json" + ], + "words": ["Consolas", "cspellcache", "darkgrey", "eslintcache"] +} diff --git a/eslint.config.mjs b/eslint.config.mjs index c2e588884..08bcd4342 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,9 @@ import { defineConfig } from "eslint/config"; import configs from "eslint-config-webpack/configs.js"; export default defineConfig([ + { + ignores: ["client/**"], + }, { extends: [configs["recommended-dirty"]], }, @@ -11,4 +14,34 @@ export default defineConfig([ "n/hashbang": "off", }, }, + { + files: ["client-src/**/*.js"], + languageOptions: { + globals: { + window: "readonly", + document: "readonly", + console: "readonly", + URLSearchParams: "readonly", + EventSource: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + setTimeout: "readonly", + module: "readonly", + }, + }, + rules: { + "no-console": "off", + "no-use-before-define": "off", + "unicorn/prefer-global-this": "off", + "n/no-unsupported-features/node-builtins": "off", + "func-names": "off", + "new-cap": "off", + "jsdoc/require-jsdoc": "off", + "jsdoc/no-blank-blocks": "off", + "jsdoc/require-returns": "off", + "jsdoc/escape-inline-tags": "off", + "jsdoc/no-restricted-syntax": "off", + "prefer-destructuring": "off", + }, + }, ]); diff --git a/lint-staged.config.js b/lint-staged.config.js index 301084338..e99f27917 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -1,7 +1,7 @@ module.exports = { "*": [ "prettier --cache --write --ignore-unknown", - "cspell --cache --no-must-find-files", + "cspell --cache --no-must-find-files --config cspell.config.json", ], "*.js": ["eslint --cache --fix"], }; diff --git a/package-lock.json b/package-lock.json index f10967036..881c0ab53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,14 @@ "version": "8.0.3", "license": "MIT", "dependencies": { + "ansi-html-community": "^0.0.8", + "html-entities": "^2.6.0", "memfs": "^4.56.10", "mime-types": "^3.0.2", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "schema-utils": "^4.3.3" + "schema-utils": "^4.3.3", + "strip-ansi": "^6.0.1" }, "devDependencies": { "@babel/cli": "^7.16.7", @@ -45,6 +48,7 @@ "hono": "^4.12.12", "husky": "^9.1.3", "jest": "^30.1.3", + "jest-environment-jsdom": "^30.4.1", "koa": "^3.0.0", "lint-staged": "^17.0.2", "npm-run-all": "^4.1.5", @@ -70,6 +74,27 @@ } } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/cli": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz", @@ -131,6 +156,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2342,6 +2368,7 @@ "integrity": "sha512-IQA++Idqb8fZzkCbHq3+T+9yG9WpeaBxomOrG2KcR/Pj0CgnovzuApYKL2cc35UWLePboKinMeqEPiweFpHVug==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=22.18.0" } @@ -2423,7 +2450,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.1.tgz", "integrity": "sha512-y/Vgo6qY08e1t9OqR56qjoFLBCpi4QfWMf2qzD1l9omRZwvSMQGRPz4x0bxkkkU4oocMAeztjzCsmLew//c/8w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-dart": { "version": "2.3.2", @@ -2563,14 +2591,16 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -2768,7 +2798,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -2838,6 +2869,123 @@ "node": ">=22.18.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -3943,6 +4091,34 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/@jest/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", @@ -5119,6 +5295,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5211,6 +5388,18 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5261,6 +5450,7 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5317,6 +5507,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5347,6 +5544,7 @@ "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.0", @@ -5386,6 +5584,7 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -6114,6 +6313,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6144,12 +6344,23 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6239,6 +6450,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -6831,6 +7054,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7775,6 +7999,71 @@ "node": ">=10" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -7854,6 +8143,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -8279,6 +8575,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", @@ -8539,6 +8848,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8628,6 +8938,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10837,6 +11148,7 @@ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -10848,11 +11160,23 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", - "dev": true, "funding": [ { "type": "github", @@ -10944,6 +11268,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-id": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", @@ -11513,6 +11865,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -11894,6 +12253,7 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -12266,6 +12626,29 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-environment-jsdom": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jest-environment-node": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", @@ -12802,6 +13185,84 @@ "node": ">=20.0.0" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -15023,6 +15484,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -15401,6 +15869,19 @@ "dev": true, "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -15644,6 +16125,7 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -16284,6 +16766,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -16424,6 +16913,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", @@ -16448,6 +16950,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17234,7 +17737,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -17271,7 +17773,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17388,6 +17889,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -17628,6 +18136,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17635,6 +18144,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -17692,6 +18221,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -17804,7 +18346,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsscmp": { "version": "1.0.6", @@ -17951,6 +18494,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18307,6 +18851,19 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -18344,6 +18901,7 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -18420,6 +18978,43 @@ "node": ">=4.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -18658,6 +19253,28 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", @@ -18671,6 +19288,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -18790,6 +19424,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b87a0ce09..08b1d61ed 100644 --- a/package.json +++ b/package.json @@ -16,25 +16,35 @@ }, "license": "MIT", "author": "Tobias Koppers @sokra", + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + }, + "./client": "./client/index.js", + "./package.json": "./package.json" + }, "main": "dist/index.js", "types": "types/index.d.ts", "files": [ + "client", "dist", "types" ], "scripts": { "lint:prettier": "prettier --cache --list-different .", "lint:code": "eslint --cache .", - "lint:spelling": "cspell --cache --no-must-find-files --quiet \"**/*.*\"", + "lint:spelling": "cspell --cache --no-must-find-files --quiet --config cspell.config.json \"**/*.*\"", "lint:types": "tsc --pretty --noEmit", "lint": "npm-run-all -l -p \"lint:**\"", "fix:js": "npm run lint:code -- --fix", "fix:prettier": "npm run lint:prettier -- --write", "fix": "npm-run-all -l fix:js fix:prettier", - "clean": "del-cli dist types", + "clean": "del-cli client dist types", "prebuild": "npm run clean", "build:types": "tsc && prettier \"types/**/*.ts\" --write", "build:code": "babel src -d dist --copy-files", + "build:client": "babel client-src -d client --copy-files", "build": "npm-run-all -p \"build:**\"", "test:only": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest", "test:watch": "npm run test:only -- --watch", @@ -46,11 +56,14 @@ "release": "npm run build && changeset publish" }, "dependencies": { + "ansi-html-community": "^0.0.8", + "html-entities": "^2.6.0", "memfs": "^4.56.10", "mime-types": "^3.0.2", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "schema-utils": "^4.3.3" + "schema-utils": "^4.3.3", + "strip-ansi": "^6.0.1" }, "devDependencies": { "@babel/cli": "^7.16.7", @@ -82,6 +95,7 @@ "hono": "^4.12.12", "husky": "^9.1.3", "jest": "^30.1.3", + "jest-environment-jsdom": "^30.4.1", "koa": "^3.0.0", "lint-staged": "^17.0.2", "npm-run-all": "^4.1.5", From 6cfc56745f30cdffae75c7874f61dcb86133ecd6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 17:02:38 -0500 Subject: [PATCH 02/13] test: add browser client runtime tests Covers the public client API and key SSE handling paths in jsdom: EventSource connection on default and custom paths, ignored heartbeat messages, dispatch of building/built/sync to subscribers, custom handler for unknown actions, warnings on invalid JSON, EventSource wrapper caching across multiple entries, and timeout-driven reconnect. --- test/client.test.js | 180 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 test/client.test.js diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 000000000..0791a78ca --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,180 @@ +/** + * @jest-environment jsdom + */ + +// `process-update.js` throws at require-time when `module.hot` is missing +// (which is always true outside a webpack runtime). Replace it with a no-op so +// the client itself can be exercised in jsdom. +jest.mock("../client-src/process-update", () => jest.fn(), { virtual: false }); + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** + * @returns {{ + * EventSourceMock: EXPECTED_ANY, + * instances: EXPECTED_ANY[], + * reset: () => void, + * }} mock EventSource constructor + */ +function setupEventSource() { + const instances = []; + + class EventSourceMock { + constructor(url) { + this.url = url; + this.listeners = { open: [], error: [], message: [] }; + this.closed = false; + instances.push(this); + } + + addEventListener(type, fn) { + if (this.listeners[type]) this.listeners[type].push(fn); + } + + dispatch(type, event) { + for (const fn of this.listeners[type] || []) { + fn(event); + } + } + + close() { + this.closed = true; + } + } + + return { + EventSourceMock, + instances, + reset() { + instances.length = 0; + }, + }; +} + +describe("client runtime", () => { + let mock; + + beforeEach(() => { + jest.resetModules(); + mock = setupEventSource(); + globalThis.EventSource = mock.EventSourceMock; + // Each test simulates the client being loaded for the first time on the + // page, so any per-page singletons need to be cleared from `window`. + delete globalThis.__wdmEventSourceWrapper; + delete globalThis.__webpack_dev_middleware_hot_reporter__; + }); + + afterEach(() => { + delete globalThis.EventSource; + delete globalThis.__wdmEventSourceWrapper; + delete globalThis.__webpack_dev_middleware_hot_reporter__; + }); + + it("opens an EventSource on the default path when autoConnect is on", () => { + require("../client-src"); + + expect(mock.instances).toHaveLength(1); + expect(mock.instances[0].url).toBe("/__webpack_hmr"); + }); + + it("does not open an EventSource when window is not present yet", () => { + // Simulate a non-browser environment by removing EventSource. + delete globalThis.EventSource; + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + require("../client-src"); + + expect(warn).toHaveBeenCalled(); + expect(mock.instances).toHaveLength(0); + + warn.mockRestore(); + }); + + it("ignores heartbeat messages", () => { + const client = require("../client-src"); + + const handler = jest.fn(); + client.subscribeAll(handler); + + mock.instances[0].dispatch("message", { data: "💓" }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("dispatches building messages to subscribers", () => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + + const client = require("../client-src"); + + const seen = []; + client.subscribeAll((obj) => seen.push(obj)); + + mock.instances[0].dispatch("message", { + data: JSON.stringify({ action: "building" }), + }); + + expect(seen).toEqual([{ action: "building" }]); + expect(log.mock.calls.some(([msg]) => /rebuilding/.test(msg))).toBe(true); + + log.mockRestore(); + }); + + it("invokes the custom handler for unknown actions", () => { + const client = require("../client-src"); + + const customs = []; + client.subscribe((obj) => customs.push(obj)); + + mock.instances[0].dispatch("message", { + data: JSON.stringify({ action: "custom-thing", payload: 1 }), + }); + + expect(customs).toEqual([{ action: "custom-thing", payload: 1 }]); + }); + + it("warns on invalid JSON", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + require("../client-src"); + + mock.instances[0].dispatch("message", { data: "not-json{" }); + + expect( + warn.mock.calls.some(([msg]) => /Invalid HMR message/.test(msg)), + ).toBe(true); + + warn.mockRestore(); + }); + + it("reuses the same EventSource for multiple entries on the same path", () => { + require("../client-src"); + // Re-load the module — the wrapper should be reused via `window.__wdmEventSourceWrapper`. + jest.resetModules(); + require("../client-src"); + + expect(mock.instances).toHaveLength(1); + }); + + it("closes and re-opens the connection on timeout", () => { + jest.useFakeTimers({ doNotFake: ["nextTick"] }); + try { + require("../client-src"); + + const [first] = mock.instances; + expect(first.closed).toBe(false); + + // Advance past 2x the timeout window so the heartbeat watchdog ticks at + // least once with `Date.now() - lastActivity > timeout`. + jest.advanceTimersByTime(30 * 1000); + + expect(first.closed).toBe(true); + + // Reconnect timer scheduled with options.timeout (20s). + jest.advanceTimersByTime(20 * 1000); + + expect(mock.instances).toHaveLength(2); + } finally { + jest.useRealTimers(); + } + }); +}); From 5e615bdf3507826a64e805012fc4b31f2a628eb3 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 17:13:46 -0500 Subject: [PATCH 03/13] test: expand browser client coverage to mirror webpack-hot-middleware Adds tests covering the original webpack-hot-middleware client suite (processUpdate invocations on built/sync, errored/warning behavior, overlay show/hide transitions, the overlayWarnings option, the name filter), while keeping the new coverage for heartbeat handling, invalid JSON warnings, EventSource wrapper caching across entries and timeout-driven reconnects. --- test/client.test.js | 616 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 498 insertions(+), 118 deletions(-) diff --git a/test/client.test.js b/test/client.test.js index 0791a78ca..9a4f4a377 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -2,179 +2,559 @@ * @jest-environment jsdom */ -// `process-update.js` throws at require-time when `module.hot` is missing -// (which is always true outside a webpack runtime). Replace it with a no-op so -// the client itself can be exercised in jsdom. -jest.mock("../client-src/process-update", () => jest.fn(), { virtual: false }); - // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ -/** - * @returns {{ - * EventSourceMock: EXPECTED_ANY, - * instances: EXPECTED_ANY[], - * reset: () => void, - * }} mock EventSource constructor - */ -function setupEventSource() { - const instances = []; +/** @type {EXPECTED_ANY} */ +let processUpdate; +/** @type {{ showProblems: jest.Mock, clear: jest.Mock }} */ +let clientOverlay; - class EventSourceMock { - constructor(url) { - this.url = url; - this.listeners = { open: [], error: [], message: [] }; - this.closed = false; - instances.push(this); - } +jest.mock("../client-src/process-update", () => { + const fn = jest.fn(); + return fn; +}); - addEventListener(type, fn) { - if (this.listeners[type]) this.listeners[type].push(fn); - } +jest.mock("../client-src/overlay", () => { + const overlay = { showProblems: jest.fn(), clear: jest.fn() }; + const factory = jest.fn(() => overlay); + factory.__getOverlay = () => overlay; + return factory; +}); - dispatch(type, event) { - for (const fn of this.listeners[type] || []) { - fn(event); - } - } +/** + * @param {EXPECTED_ANY} obj message payload + * @returns {{ data: string }} fake SSE event + */ +function makeMessage(obj) { + return { data: typeof obj === "string" ? obj : JSON.stringify(obj) }; +} - close() { +/** + * Stub `EventSource` so each test can drive `message`/`error`/`open` events. + * @returns {EXPECTED_ANY} fake constructor + last instance accessor + */ +function makeEventSourceStub() { + /** @type {EXPECTED_ANY[]} */ + const instances = []; + function EventSourceStub(url) { + this.url = url; + this.listeners = { open: [], error: [], message: [] }; + this.closed = false; + this.addEventListener = (type, fn) => { + if (this.listeners[type]) this.listeners[type].push(fn); + }; + this.dispatch = (type, event) => { + for (const fn of this.listeners[type] || []) fn(event); + }; + this.onmessage = (event) => this.dispatch("message", event); + // eslint-disable-next-line jest/prefer-spy-on + this.close = jest.fn(() => { this.closed = true; - } + }); + instances.push(this); } - - return { - EventSourceMock, - instances, - reset() { - instances.length = 0; - }, - }; + EventSourceStub.instances = instances; + EventSourceStub.lastInstance = () => instances[instances.length - 1]; + return EventSourceStub; } -describe("client runtime", () => { - let mock; +/** + * Reset module state so each test loads a fresh client. The per-page + * singletons on `window` are NOT cleared here — the outer `afterEach` handles + * that, so tests that re-require the client on the same "page" can observe + * the wrapper being reused. + * @param {string=} resourceQuery `__resourceQuery` value injected by webpack + * @returns {EXPECTED_ANY} client module + */ +function loadClient(resourceQuery = "") { + jest.resetModules(); + globalThis.__resourceQuery = resourceQuery; + processUpdate = require("../client-src/process-update"); + processUpdate.mockReset(); - beforeEach(() => { - jest.resetModules(); - mock = setupEventSource(); - globalThis.EventSource = mock.EventSourceMock; - // Each test simulates the client being loaded for the first time on the - // page, so any per-page singletons need to be cleared from `window`. - delete globalThis.__wdmEventSourceWrapper; - delete globalThis.__webpack_dev_middleware_hot_reporter__; - }); + const overlayFactory = require("../client-src/overlay"); + + clientOverlay = overlayFactory.__getOverlay(); + clientOverlay.showProblems.mockReset(); + clientOverlay.clear.mockReset(); + + return require("../client-src"); +} +describe("client", () => { afterEach(() => { + delete globalThis.__resourceQuery; delete globalThis.EventSource; delete globalThis.__wdmEventSourceWrapper; delete globalThis.__webpack_dev_middleware_hot_reporter__; + jest.useRealTimers(); }); - it("opens an EventSource on the default path when autoConnect is on", () => { - require("../client-src"); - - expect(mock.instances).toHaveLength(1); - expect(mock.instances[0].url).toBe("/__webpack_hmr"); - }); + describe("with default options", () => { + let EventSourceStub; + let client; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "group").mockImplementation(() => {}); + jest.spyOn(console, "groupEnd").mockImplementation(() => {}); + client = loadClient(); + }); - it("does not open an EventSource when window is not present yet", () => { - // Simulate a non-browser environment by removing EventSource. - delete globalThis.EventSource; - const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + afterEach(() => { + jest.restoreAllMocks(); + }); - require("../client-src"); + it("connects to /__webpack_hmr", () => { + expect(EventSourceStub.instances).toHaveLength(1); + expect(EventSourceStub.instances[0].url).toBe("/__webpack_hmr"); + }); - expect(warn).toHaveBeenCalled(); - expect(mock.instances).toHaveLength(0); + it("triggers webpack on successful builds", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).toHaveBeenCalledTimes(1); + }); - warn.mockRestore(); - }); + it("triggers webpack on successful syncs", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "sync", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).toHaveBeenCalledTimes(1); + }); - it("ignores heartbeat messages", () => { - const client = require("../client-src"); + it("calls subscribeAll handler on default messages", () => { + const spy = jest.fn(); + client.subscribeAll(spy); + const message = { + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }; + EventSourceStub.lastInstance().onmessage(makeMessage(message)); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(message); + }); - const handler = jest.fn(); - client.subscribeAll(handler); + it("calls subscribeAll handler on custom messages", () => { + const spy = jest.fn(); + client.subscribeAll(spy); + EventSourceStub.lastInstance().onmessage( + makeMessage({ action: "thingy" }), + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ action: "thingy" }); + }); - mock.instances[0].dispatch("message", { data: "💓" }); + it("calls only the custom handler for custom messages", () => { + const spy = jest.fn(); + client.subscribe(spy); + EventSourceStub.lastInstance().onmessage( + makeMessage({ custom: "thingy" }), + ); + EventSourceStub.lastInstance().onmessage( + makeMessage({ action: "built" }), + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ custom: "thingy" }); + expect(processUpdate).not.toHaveBeenCalled(); + }); - expect(handler).not.toHaveBeenCalled(); - }); + it("does not trigger webpack on errored builds", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: ["Something broke"], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).not.toHaveBeenCalled(); + }); - it("dispatches building messages to subscribers", () => { - const log = jest.spyOn(console, "log").mockImplementation(() => {}); + it("shows overlay on errored builds", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: ["Something broke", "Actually, 2 things broke"], + warnings: [], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); + expect(clientOverlay.showProblems).toHaveBeenCalledWith("errors", [ + "Something broke", + "Actually, 2 things broke", + ]); + }); - const client = require("../client-src"); + it("hides overlay after errored build is fixed", () => { + const es = EventSourceStub.lastInstance(); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: ["Something broke", "Actually, 2 things broke"], + warnings: [], + modules: [], + }), + ); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef2", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); + expect(clientOverlay.clear).toHaveBeenCalledTimes(1); + }); - const seen = []; - client.subscribeAll((obj) => seen.push(obj)); + it("hides overlay after errored build becomes a warning", () => { + const es = EventSourceStub.lastInstance(); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: ["Something broke", "Actually, 2 things broke"], + warnings: [], + modules: [], + }), + ); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef2", + errors: [], + warnings: ["This isn't great, but it's not terrible"], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); + expect(clientOverlay.clear).toHaveBeenCalledTimes(1); + }); - mock.instances[0].dispatch("message", { - data: JSON.stringify({ action: "building" }), + it("triggers webpack on warning builds", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: ["This isn't great, but it's not terrible"], + modules: [], + }), + ); + expect(processUpdate).toHaveBeenCalledTimes(1); }); - expect(seen).toEqual([{ action: "building" }]); - expect(log.mock.calls.some(([msg]) => /rebuilding/.test(msg))).toBe(true); + it("does not show overlay on warning builds by default", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: ["This isn't great, but it's not terrible"], + modules: [], + }), + ); + expect(clientOverlay.showProblems).not.toHaveBeenCalled(); + }); - log.mockRestore(); + it("shows overlay after warning build becomes an error", () => { + const es = EventSourceStub.lastInstance(); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: ["This isn't great, but it's not terrible"], + modules: [], + }), + ); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef2", + errors: ["Something broke", "Actually, 2 things broke"], + warnings: [], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); + }); }); - it("invokes the custom handler for unknown actions", () => { - const client = require("../client-src"); + describe("with overlayWarnings: true", () => { + let EventSourceStub; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "group").mockImplementation(() => {}); + jest.spyOn(console, "groupEnd").mockImplementation(() => {}); + loadClient("?overlayWarnings=true"); + }); - const customs = []; - client.subscribe((obj) => customs.push(obj)); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("shows overlay on errored builds", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: ["Something broke", "Actually, 2 things broke"], + warnings: [], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); + expect(clientOverlay.showProblems).toHaveBeenCalledWith("errors", [ + "Something broke", + "Actually, 2 things broke", + ]); + }); - mock.instances[0].dispatch("message", { - data: JSON.stringify({ action: "custom-thing", payload: 1 }), + it("shows overlay on warning builds", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: ["This isn't great, but it's not terrible"], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); + expect(clientOverlay.showProblems).toHaveBeenCalledWith("warnings", [ + "This isn't great, but it's not terrible", + ]); }); - expect(customs).toEqual([{ action: "custom-thing", payload: 1 }]); + it("hides overlay after warning build is fixed", () => { + const es = EventSourceStub.lastInstance(); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: ["This isn't great, but it's not terrible"], + modules: [], + }), + ); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef2", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); + expect(clientOverlay.clear).toHaveBeenCalledTimes(1); + }); + + it("updates overlay after errored build becomes a warning", () => { + const es = EventSourceStub.lastInstance(); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: ["Something broke"], + warnings: [], + modules: [], + }), + ); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef2", + errors: [], + warnings: ["This isn't great, but it's not terrible"], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(2); + expect(clientOverlay.showProblems).toHaveBeenNthCalledWith(1, "errors", [ + "Something broke", + ]); + expect(clientOverlay.showProblems).toHaveBeenNthCalledWith( + 2, + "warnings", + ["This isn't great, but it's not terrible"], + ); + }); }); - it("warns on invalid JSON", () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); - require("../client-src"); + describe("with name option", () => { + let EventSourceStub; - mock.instances[0].dispatch("message", { data: "not-json{" }); + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "log").mockImplementation(() => {}); + loadClient("?name=test"); + }); - expect( - warn.mock.calls.some(([msg]) => /Invalid HMR message/.test(msg)), - ).toBe(true); + afterEach(() => { + jest.restoreAllMocks(); + }); - warn.mockRestore(); + it("does not trigger webpack when event name differs", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + name: "foo", + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).not.toHaveBeenCalled(); + }); + + it("does not trigger webpack on sync when event name differs", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + name: "bar", + action: "sync", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).not.toHaveBeenCalled(); + }); }); - it("reuses the same EventSource for multiple entries on the same path", () => { - require("../client-src"); - // Re-load the module — the wrapper should be reused via `window.__wdmEventSourceWrapper`. - jest.resetModules(); - require("../client-src"); + describe("connection lifecycle", () => { + let EventSourceStub; + let client; - expect(mock.instances).toHaveLength(1); - }); + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + client = loadClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("ignores heartbeat messages", () => { + const handler = jest.fn(); + client.subscribeAll(handler); + EventSourceStub.lastInstance().dispatch("message", { data: "💓" }); + expect(handler).not.toHaveBeenCalled(); + expect(processUpdate).not.toHaveBeenCalled(); + }); - it("closes and re-opens the connection on timeout", () => { - jest.useFakeTimers({ doNotFake: ["nextTick"] }); - try { + it("warns on invalid JSON", () => { + EventSourceStub.lastInstance().dispatch("message", { data: "not-json{" }); + expect( + console.warn.mock.calls.some(([msg]) => + /Invalid HMR message/.test(msg), + ), + ).toBe(true); + }); + + it("reuses the EventSource wrapper across reloads on the same path", () => { + // Re-loading the entry on the same page should reuse the cached SSE + // connection rather than opening a new one. + jest.resetModules(); require("../client-src"); + expect(EventSourceStub.instances).toHaveLength(1); + }); - const [first] = mock.instances; + it("closes and re-opens the connection on timeout", () => { + // The watchdog interval is created during the client's first load. Fake + // timers must be enabled before that load so jest can drive it. + jest.useFakeTimers({ doNotFake: ["nextTick"] }); + // Drop the wrapper opened by the outer beforeEach so we get a fresh + // EventSource scheduled under fake timers. + delete globalThis.__wdmEventSourceWrapper; + EventSourceStub.instances.length = 0; + loadClient(); + + const [first] = EventSourceStub.instances; expect(first.closed).toBe(false); - - // Advance past 2x the timeout window so the heartbeat watchdog ticks at - // least once with `Date.now() - lastActivity > timeout`. + // The watchdog ticks at `timeout/2` and disconnects when + // `Date.now() - lastActivity > timeout`. 30s is enough to cross that + // boundary regardless of which tick reports it first. jest.advanceTimersByTime(30 * 1000); - expect(first.closed).toBe(true); - - // Reconnect timer scheduled with options.timeout (20s). + // Reconnect is scheduled after `options.timeout` (20s). jest.advanceTimersByTime(20 * 1000); + expect(EventSourceStub.instances).toHaveLength(2); + }); + }); - expect(mock.instances).toHaveLength(2); - } finally { - jest.useRealTimers(); - } + describe("with no EventSource", () => { + beforeEach(() => { + delete globalThis.EventSource; + jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("emits a warning and does not connect", () => { + loadClient(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn.mock.calls[0][0]).toMatch(/EventSource/); + }); }); }); From 4b2cf102ed76c39c4fe2bb37a6cc91becef7b4c7 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 17:23:52 -0500 Subject: [PATCH 04/13] ci: run on push and PRs against the hot-middleware umbrella branch --- .github/workflows/nodejs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index b5008e777..fa10c0b64 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -5,10 +5,12 @@ on: branches: - main - next + - hot-middleware pull_request: branches: - main - next + - hot-middleware permissions: contents: read From 471521d67021a3a2b2892c6c8a99329f51dddccb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 17:32:14 -0500 Subject: [PATCH 05/13] refactor(client): switch process-update to promise-only HMR API The ported logic called `module.hot.check`/`apply` with both a callback and a Promise-handling branch to support webpack < 2. In webpack 5 both paths fire, so the callback ran twice, triggering a redundant `module.hot.apply` on every update. Drop the legacy callback path and use the Promise API exclusively, which is the canonical webpack 5 contract and matches our peer dependency. --- client-src/process-update.js | 58 ++++++++++-------------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/client-src/process-update.js b/client-src/process-update.js index 2dc301348..e77f0a1da 100644 --- a/client-src/process-update.js +++ b/client-src/process-update.js @@ -136,52 +136,24 @@ module.exports = function (hash, moduleMap, options) { * */ function check() { - /** - * @param {Error | null} err err - * @param {string[] | null=} updatedModules updated module ids - */ - const cb = (err, updatedModules) => { - if (err) return handleError(err); - - if (!updatedModules) { - if (options.warn) { - console.warn("[HMR] Cannot find update (Full reload needed)"); - console.warn("[HMR] (Probably because of restarting the server)"); + module.hot + .check(false) + .then((updatedModules) => { + if (!updatedModules) { + if (options.warn) { + console.warn("[HMR] Cannot find update (Full reload needed)"); + console.warn("[HMR] (Probably because of restarting the server)"); + } + performReload(); + return undefined; } - performReload(); - return; - } - - /** - * @param {Error | null} applyErr apply error - * @param {string[] | undefined} renewedModules renewed module ids - */ - const applyCallback = (applyErr, renewedModules) => { - if (applyErr) return handleError(applyErr); - - if (!upToDate()) check(); - logUpdates(updatedModules, renewedModules); - }; - - const applyResult = module.hot.apply(applyOptions, applyCallback); - // webpack 2 promise - if (applyResult && applyResult.then) { - applyResult.then((outdatedModules) => { - applyCallback(null, outdatedModules); + return module.hot.apply(applyOptions).then((renewedModules) => { + if (!upToDate()) check(); + logUpdates(updatedModules, renewedModules); }); - applyResult.catch(applyCallback); - } - }; - - const result = module.hot.check(false, cb); - // webpack 2 promise - if (result && result.then) { - result.then((updatedModules) => { - cb(null, updatedModules); - }); - result.catch(cb); - } + }) + .catch(handleError); } if (!upToDate(hash) && module.hot.status() === "idle") { From 368f6435a16eb0f4c49e64c90dfab5f2ac8e8b47 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 17:44:08 -0500 Subject: [PATCH 06/13] chore(client): type-check client-src with a dedicated tsconfig Mirrors the layout webpack-dev-server uses: a separate `tsconfig.client.json` (`noEmit`, browser-targeted libs, `webpack/module` augmentation) runs over `client-src/` via a new `lint:types-client` script, with a small `client-src/globals.d.ts` declaring `ansi-html-community` and the per-page singletons the client stores on `window`. Refines the JSDoc annotations in `client-src/index.js` and `client-src/process-update.js` so `module.hot`, `window` extensions and the HMR `ApplyOptions` type-check cleanly. --- client-src/globals.d.ts | 28 +++++++++++++++++++++ client-src/index.js | 24 +++++++++++++++++- client-src/process-update.js | 47 ++++++++++++++++++------------------ package.json | 1 + tsconfig.client.json | 16 ++++++++++++ 5 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 client-src/globals.d.ts create mode 100644 tsconfig.client.json diff --git a/client-src/globals.d.ts b/client-src/globals.d.ts new file mode 100644 index 000000000..7f66bdd08 --- /dev/null +++ b/client-src/globals.d.ts @@ -0,0 +1,28 @@ +/* eslint-disable */ + +declare module "ansi-html-community" { + function ansiHtmlCommunity(str: string): string; + namespace ansiHtmlCommunity { + function setColors(colors: Record): void; + } + export = ansiHtmlCommunity; +} + +interface ClientReporter { + cleanProblemsCache(): void; + problems( + type: "errors" | "warnings", + obj: { errors: string[]; warnings: string[]; name?: string }, + ): boolean; + success(): void; + useCustomOverlay(customOverlay: unknown): void; +} + +interface EventSourceWrapper { + addMessageListener(fn: (event: { data: string }) => void): void; +} + +interface Window { + __wdmEventSourceWrapper?: Record; + __webpack_dev_middleware_hot_reporter__?: ClientReporter; +} diff --git a/client-src/index.js b/client-src/index.js index a294dd700..cd18ecfac 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -4,7 +4,20 @@ const stripAnsi = require("strip-ansi"); const processUpdate = require("./process-update"); -/** @typedef {Record} ClientOptions */ +/** + * @typedef {object} ClientOptions + * @property {string} path SSE endpoint path + * @property {number} timeout reconnection timeout in milliseconds + * @property {boolean} overlay enable the in-page error overlay + * @property {boolean} reload reload the page when HMR cannot apply the update + * @property {boolean} log emit informational logs to the console + * @property {boolean} warn emit warnings to the console + * @property {string} name limit updates to this compilation name + * @property {boolean} autoConnect connect immediately when the entry runs + * @property {Record} overlayStyles overrides for the overlay container CSS + * @property {boolean} overlayWarnings show warnings in the overlay too + * @property {Record} ansiColors overrides for ANSI → HTML color mapping + */ /** @type {ClientOptions} */ const options = { @@ -345,12 +358,21 @@ if (typeof window !== "undefined") { } module.exports = { + /** + * @param {(obj: HMRPayload) => void} handler called for every incoming HMR message + */ subscribeAll(handler) { subscribeAllHandler = handler; }, + /** + * @param {(obj: HMRPayload) => void} handler called for messages whose `action` is not recognized + */ subscribe(handler) { customHandler = handler; }, + /** + * @param {EXPECTED_ANY} customOverlay replacement for the default error overlay + */ useCustomOverlay(customOverlay) { if (reporter) reporter.useCustomOverlay(customOverlay); }, diff --git a/client-src/process-update.js b/client-src/process-update.js index e77f0a1da..9aa5f9386 100644 --- a/client-src/process-update.js +++ b/client-src/process-update.js @@ -1,9 +1,18 @@ /* global __webpack_hash__ */ -if (!module.hot) { +// `module.exports = function (...)` below narrows TS's view of `module` to +// `{ exports: ... }`, hiding the webpack-augmented `hot` property. Alias it +// through `NodeJS.Module` so type-checking still finds `hot`. +/** @type {NodeJS.Module} */ +const $module = /** @type {EXPECTED_ANY} */ (module); + +if (!$module.hot) { throw new Error("[HMR] Hot Module Replacement is disabled."); } +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + const HMR_DOCS_URL = "https://webpack.js.org/concepts/hot-module-replacement/"; /** @type {string | undefined} */ @@ -11,33 +20,25 @@ let lastHash; /** @type {Record} */ const failureStatuses = { abort: 1, fail: 1 }; +/** @type {webpack.ApplyOptions} */ const applyOptions = { ignoreUnaccepted: true, ignoreDeclined: true, ignoreErrored: true, - /** - * @param {{ chain: string[] }} data data - */ - onUnaccepted(data) { + onUnaccepted(event) { console.warn( - `Ignored an update to unaccepted module ${data.chain.join(" -> ")}`, + `Ignored an update to unaccepted module ${event.chain.join(" -> ")}`, ); }, - /** - * @param {{ chain: string[] }} data data - */ - onDeclined(data) { + onDeclined(event) { console.warn( - `Ignored an update to declined module ${data.chain.join(" -> ")}`, + `Ignored an update to declined module ${event.chain.join(" -> ")}`, ); }, - /** - * @param {{ error: Error, moduleId: string, type: string }} data data - */ - onErrored(data) { - console.error(data.error); + onErrored(event) { + console.error(event.error); console.warn( - `Ignored an error while updating module ${data.moduleId} (${data.type})`, + `Ignored an error while updating module ${event.moduleId} (${event.type})`, ); }, }; @@ -63,7 +64,7 @@ module.exports = function (hash, moduleMap, options) { * @param {Error} err error */ function handleError(err) { - if (module.hot.status() in failureStatuses) { + if ($module.hot.status() in failureStatuses) { if (options.warn) { console.warn("[HMR] Cannot check for update (Full reload needed)"); console.warn(`[HMR] ${err.stack || err.message}`); @@ -87,8 +88,8 @@ module.exports = function (hash, moduleMap, options) { } /** - * @param {string[]} updatedModules ids of modules that were attempted to update - * @param {string[] | undefined} renewedModules ids of modules that were successfully renewed + * @param {(string | number)[]} updatedModules ids of modules that were attempted to update + * @param {(string | number)[] | null | undefined} renewedModules ids of modules that were successfully renewed */ function logUpdates(updatedModules, renewedModules) { const unacceptedModules = updatedModules.filter( @@ -136,7 +137,7 @@ module.exports = function (hash, moduleMap, options) { * */ function check() { - module.hot + $module.hot .check(false) .then((updatedModules) => { if (!updatedModules) { @@ -148,7 +149,7 @@ module.exports = function (hash, moduleMap, options) { return undefined; } - return module.hot.apply(applyOptions).then((renewedModules) => { + return $module.hot.apply(applyOptions).then((renewedModules) => { if (!upToDate()) check(); logUpdates(updatedModules, renewedModules); }); @@ -156,7 +157,7 @@ module.exports = function (hash, moduleMap, options) { .catch(handleError); } - if (!upToDate(hash) && module.hot.status() === "idle") { + if (!upToDate(hash) && $module.hot.status() === "idle") { if (options.log) console.log("[HMR] Checking for updates on the server..."); check(); } diff --git a/package.json b/package.json index 08b1d61ed..66547f8b2 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "lint:code": "eslint --cache .", "lint:spelling": "cspell --cache --no-must-find-files --quiet --config cspell.config.json \"**/*.*\"", "lint:types": "tsc --pretty --noEmit", + "lint:types-client": "tsc -p tsconfig.client.json --pretty", "lint": "npm-run-all -l -p \"lint:**\"", "fix:js": "npm run lint:code -- --fix", "fix:prettier": "npm run lint:prettier -- --write", diff --git a/tsconfig.client.json b/tsconfig.client.json new file mode 100644 index 000000000..012c20eb0 --- /dev/null +++ b/tsconfig.client.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2022", "dom", "webworker"], + "module": "nodenext", + "moduleResolution": "nodenext", + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "types": ["node", "webpack/module"], + "skipDefaultLibCheck": true, + "esModuleInterop": true + }, + "include": ["./client-src/**/*"] +} From 3607b25f5f0208de99cfc583a68b6c238947d680 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 18:04:08 -0500 Subject: [PATCH 07/13] docs: document the browser client runtime in README Adds a 'Hot Module Replacement client' section explaining how to wire `webpack-dev-middleware/client` as a webpack entry, the query-string options that the runtime understands, and the programmatic `subscribe` / `subscribeAll` / `useCustomOverlay` / `setOptionsAndConnect` exports. --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/README.md b/README.md index 5b67deb5a..c5f8f99e0 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,79 @@ middleware(compiler, { }); ``` +## Hot Module Replacement client + +When the server is configured to serve the hot module replacement endpoint, the bundled application needs a small runtime that subscribes to that stream and applies the updates. `webpack-dev-middleware` ships that runtime under the `./client` subpath. Add it as a webpack entry next to your application code and enable `HotModuleReplacementPlugin`: + +```js +const webpack = require("webpack"); + +module.exports = { + entry: ["webpack-dev-middleware/client", "./src/app.js"], + plugins: [new webpack.HotModuleReplacementPlugin()], +}; +``` + +The runtime connects to `/__webpack_hmr` by default. Any of the options below can be set by adding a query string to the entry path: + +```js +entry: [ + "webpack-dev-middleware/client?reload=true&overlay=false", + "./src/app.js", +]; +``` + +### Client options + +| Name | Type | Default | Description | +| :-----------------: | :-------: | :--------------: | :------------------------------------------------------------------------------------------------ | +| `path` | `string` | `/__webpack_hmr` | Path the SSE endpoint is served at. Must match the server `hot.path`. | +| `timeout` | `number` | `20000` | Reconnection / heartbeat watchdog timeout in milliseconds. | +| `overlay` | `boolean` | `true` | Show compile-time errors in an in-page overlay. | +| `overlayWarnings` | `boolean` | `false` | Also show compile-time warnings in the overlay. | +| `overlayStyles` | `Object` | `{}` | JSON object of CSS overrides for the overlay container. Pass JSON-encoded value via query string. | +| `ansiColors` | `Object` | `{}` | JSON object overriding the ANSI → HTML color map used by the overlay. | +| `reload` | `boolean` | `false` | Reload the page when an update cannot be applied through HMR. | +| `log` | `boolean` | `true` | Emit informational logs to the console. | +| `noInfo` | `boolean` | `false` | Disable informational logs (alias for `log=false`). | +| `quiet` | `boolean` | `false` | Disable both logs and warnings. | +| `name` | `string` | `""` | Restrict updates to a specific compilation name (useful with multi-compiler). | +| `autoConnect` | `boolean` | `true` | Connect on load; set to `false` and call `setOptionsAndConnect()` manually. | +| `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. | + +### Programmatic API + +`webpack-dev-middleware/client` also exports a few functions for advanced cases: + +```js +const hotClient = require("webpack-dev-middleware/client"); + +// Receive every HMR payload (building / built / sync / custom). +hotClient.subscribeAll((payload) => { + console.log("hot event", payload); +}); + +// Receive payloads whose `action` is not recognised by the client (i.e. custom +// payloads published via the server's `instance.context.hot.publish(...)`). +hotClient.subscribe((payload) => { + // do something +}); + +// Replace the default error overlay with your own implementation. +hotClient.useCustomOverlay({ + showProblems(type, lines) { + /* ... */ + }, + clear() { + /* ... */ + }, +}); + +// Connect manually when `autoConnect=false`. Accepts the same option keys as +// the query-string API above. +hotClient.setOptionsAndConnect({ path: "/__hmr" }); +``` + ## API `webpack-dev-middleware` also provides convenience methods that can be use to From bc40fee350db084c58f8524104d62fd18803ccc4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 18:27:14 -0500 Subject: [PATCH 08/13] chore(client): adopt browser-outdated-recommended-commonjs eslint preset Switches the client-src lint config to the dedicated preset `eslint-config-webpack` ships for browser-targeted CommonJS code, the same family of preset webpack-dev-server uses for its own client. Brings the per-directory rule overrides down to two: `no-console` (legitimately used for HMR status messages) and `no-use-before-define` (relaxed for hoisted function declarations). Adjusts the source to satisfy the rest of the preset directly: adds `use strict` headers, fills in JSDoc for every function, renames `EventSourceWrapper` to `createEventSourceWrapper` (per `new-cap`), names the anonymous module exports, and reorders `performReload` before `handleError` so it is declared before use. --- client-src/index.js | 12 +++++++----- client-src/overlay.js | 6 ++++-- client-src/process-update.js | 26 +++++++++++++------------ eslint.config.mjs | 37 ++++++++++-------------------------- 4 files changed, 35 insertions(+), 46 deletions(-) diff --git a/client-src/index.js b/client-src/index.js index cd18ecfac..22314e302 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -1,3 +1,5 @@ +"use strict"; + /* global __resourceQuery, __webpack_public_path__ */ const stripAnsi = require("strip-ansi"); @@ -79,7 +81,7 @@ function setOverrides(overrides) { /** * @returns {{ addMessageListener: (fn: MessageListener) => void }} event source wrapper */ -function EventSourceWrapper() { +function createEventSourceWrapper() { /** @type {EventSource} */ let source; let lastActivity = Date.now(); @@ -110,7 +112,7 @@ function EventSourceWrapper() { }; /** - * + * Open the EventSource connection. */ function init() { source = new window.EventSource(/** @type {string} */ (options.path)); @@ -139,7 +141,7 @@ function EventSourceWrapper() { const WRAPPER_KEY = "__wdmEventSourceWrapper"; /** - * @returns {ReturnType} cached event source wrapper for this path + * @returns {ReturnType} cached event source wrapper for this path */ function getEventSourceWrapper() { const path = /** @type {string} */ (options.path); @@ -149,13 +151,13 @@ function getEventSourceWrapper() { if (!window[WRAPPER_KEY][path]) { // Cache the wrapper so multiple entries on the same page sharing the same // `options.path` reuse a single SSE connection. - window[WRAPPER_KEY][path] = EventSourceWrapper(); + window[WRAPPER_KEY][path] = createEventSourceWrapper(); } return window[WRAPPER_KEY][path]; } /** - * + * Subscribe the message handler to the shared event source wrapper. */ function connect() { getEventSourceWrapper().addMessageListener((event) => { diff --git a/client-src/overlay.js b/client-src/overlay.js index e6a332f8a..02fc266a3 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -1,3 +1,5 @@ +"use strict"; + const ansiHTML = require("ansi-html-community"); const htmlEntities = require("html-entities"); @@ -75,7 +77,7 @@ function showProblems(type, lines) { } /** - * + * Remove the overlay container from the DOM. */ function clear() { if (document.body && clientOverlay.parentNode) { @@ -87,7 +89,7 @@ function clear() { * @param {{ ansiColors?: Record, overlayStyles?: Record }} options options * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api */ -module.exports = function (options) { +module.exports = function configureOverlay(options) { if (options.ansiColors) { for (const color of Object.keys(options.ansiColors)) { if (color in colors) { diff --git a/client-src/process-update.js b/client-src/process-update.js index 9aa5f9386..15ccb33a9 100644 --- a/client-src/process-update.js +++ b/client-src/process-update.js @@ -1,3 +1,5 @@ +"use strict"; + /* global __webpack_hash__ */ // `module.exports = function (...)` below narrows TS's view of `module` to @@ -57,9 +59,19 @@ function upToDate(hash) { * @param {Record | undefined} moduleMap module id → name map * @param {{ reload?: boolean, log?: boolean, warn?: boolean }} options client options */ -module.exports = function (hash, moduleMap, options) { +module.exports = function applyUpdate(hash, moduleMap, options) { const { reload } = options; + /** + * Trigger a full page reload when HMR cannot apply the update. + */ + function performReload() { + if (reload) { + if (options.warn) console.warn("[HMR] Reloading page"); + window.location.reload(); + } + } + /** * @param {Error} err error */ @@ -77,16 +89,6 @@ module.exports = function (hash, moduleMap, options) { } } - /** - * - */ - function performReload() { - if (reload) { - if (options.warn) console.warn("[HMR] Reloading page"); - window.location.reload(); - } - } - /** * @param {(string | number)[]} updatedModules ids of modules that were attempted to update * @param {(string | number)[] | null | undefined} renewedModules ids of modules that were successfully renewed @@ -134,7 +136,7 @@ module.exports = function (hash, moduleMap, options) { } /** - * + * Ask webpack for the next chunk of HMR updates and apply them. */ function check() { $module.hot diff --git a/eslint.config.mjs b/eslint.config.mjs index 08bcd4342..1cb301c2c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,12 +1,11 @@ -import { defineConfig } from "eslint/config"; +import { defineConfig, globalIgnores } from "eslint/config"; import configs from "eslint-config-webpack/configs.js"; export default defineConfig([ - { - ignores: ["client/**"], - }, + globalIgnores(["client/**/*"]), { extends: [configs["recommended-dirty"]], + ignores: ["client-src/**/*"], }, { files: ["test/helpers/runner.js"], @@ -15,33 +14,17 @@ export default defineConfig([ }, }, { - files: ["client-src/**/*.js"], + files: ["client-src/**/*"], + extends: [configs["browser-outdated-recommended-commonjs"]], languageOptions: { - globals: { - window: "readonly", - document: "readonly", - console: "readonly", - URLSearchParams: "readonly", - EventSource: "readonly", - setInterval: "readonly", - clearInterval: "readonly", - setTimeout: "readonly", - module: "readonly", - }, + ecmaVersion: "latest", }, rules: { + // The HMR client legitimately reports build status to the browser console. "no-console": "off", - "no-use-before-define": "off", - "unicorn/prefer-global-this": "off", - "n/no-unsupported-features/node-builtins": "off", - "func-names": "off", - "new-cap": "off", - "jsdoc/require-jsdoc": "off", - "jsdoc/no-blank-blocks": "off", - "jsdoc/require-returns": "off", - "jsdoc/escape-inline-tags": "off", - "jsdoc/no-restricted-syntax": "off", - "prefer-destructuring": "off", + // Function declarations are hoisted; allow referencing them ahead of + // their definition for readability. + "no-use-before-define": ["error", { functions: false }], }, }, ]); From bd8de9710e315c066d3c85292263e8201b293de1 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 18:45:17 -0500 Subject: [PATCH 09/13] refactor(client): route logging through webpack's runtime logger Wraps `webpack/lib/logging/runtime` in a small `utils/log.js` module that exposes a level-based logger registered under the `webpack-dev-middleware` name (matching the infrastructure logger the server side already uses). Replaces every `console.log`/ `console.warn` call in the client and HMR update path with the equivalent `log.info`/`log.warn`/`log.error` calls so output is prefixed and gated by a single `logging` level. User-facing API: - `logging` query-string option accepts `none|error|warn|info|log|verbose` - The previous `log`, `warn`, `noInfo` and `quiet` flags are dropped in favour of `logging` Other cleanups enabled by this: - Drop the `no-console: off` exception from the client-src ESLint config - Update README's client option table accordingly - Add tests covering the new `logging` levels and the logger prefix --- README.md | 28 +++++---- client-src/index.js | 71 +++++++++-------------- client-src/process-update.js | 76 ++++++++++-------------- client-src/utils/log.js | 22 +++++++ eslint.config.mjs | 2 - test/client.test.js | 109 +++++++++++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 106 deletions(-) create mode 100644 client-src/utils/log.js diff --git a/README.md b/README.md index c5f8f99e0..11eb8c9bc 100644 --- a/README.md +++ b/README.md @@ -336,21 +336,19 @@ entry: [ ### Client options -| Name | Type | Default | Description | -| :-----------------: | :-------: | :--------------: | :------------------------------------------------------------------------------------------------ | -| `path` | `string` | `/__webpack_hmr` | Path the SSE endpoint is served at. Must match the server `hot.path`. | -| `timeout` | `number` | `20000` | Reconnection / heartbeat watchdog timeout in milliseconds. | -| `overlay` | `boolean` | `true` | Show compile-time errors in an in-page overlay. | -| `overlayWarnings` | `boolean` | `false` | Also show compile-time warnings in the overlay. | -| `overlayStyles` | `Object` | `{}` | JSON object of CSS overrides for the overlay container. Pass JSON-encoded value via query string. | -| `ansiColors` | `Object` | `{}` | JSON object overriding the ANSI → HTML color map used by the overlay. | -| `reload` | `boolean` | `false` | Reload the page when an update cannot be applied through HMR. | -| `log` | `boolean` | `true` | Emit informational logs to the console. | -| `noInfo` | `boolean` | `false` | Disable informational logs (alias for `log=false`). | -| `quiet` | `boolean` | `false` | Disable both logs and warnings. | -| `name` | `string` | `""` | Restrict updates to a specific compilation name (useful with multi-compiler). | -| `autoConnect` | `boolean` | `true` | Connect on load; set to `false` and call `setOptionsAndConnect()` manually. | -| `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. | +| Name | Type | Default | Description | +| :-----------------: | :-------: | :--------------: | :------------------------------------------------------------------------------------------------------------------ | +| `path` | `string` | `/__webpack_hmr` | Path the SSE endpoint is served at. Must match the server `hot.path`. | +| `timeout` | `number` | `20000` | Reconnection / heartbeat watchdog timeout in milliseconds. | +| `overlay` | `boolean` | `true` | Show compile-time errors in an in-page overlay. | +| `overlayWarnings` | `boolean` | `false` | Also show compile-time warnings in the overlay. | +| `overlayStyles` | `Object` | `{}` | JSON object of CSS overrides for the overlay container. Pass JSON-encoded value via query string. | +| `ansiColors` | `Object` | `{}` | JSON object overriding the ANSI → HTML color map used by the overlay. | +| `reload` | `boolean` | `false` | Reload the page when an update cannot be applied through HMR. | +| `logging` | `string` | `"info"` | Logger level — one of `"none"`, `"error"`, `"warn"`, `"info"`, `"log"`, `"verbose"`. Uses webpack's runtime logger. | +| `name` | `string` | `""` | Restrict updates to a specific compilation name (useful with multi-compiler). | +| `autoConnect` | `boolean` | `true` | Connect on load; set to `false` and call `setOptionsAndConnect()` manually. | +| `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. | ### Programmatic API diff --git a/client-src/index.js b/client-src/index.js index 22314e302..8ff502a72 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -5,6 +5,9 @@ const stripAnsi = require("strip-ansi"); const processUpdate = require("./process-update"); +const { log, setLogLevel } = require("./utils/log"); + +/** @typedef {import("./utils/log").LogLevel} LogLevel */ /** * @typedef {object} ClientOptions @@ -12,8 +15,7 @@ const processUpdate = require("./process-update"); * @property {number} timeout reconnection timeout in milliseconds * @property {boolean} overlay enable the in-page error overlay * @property {boolean} reload reload the page when HMR cannot apply the update - * @property {boolean} log emit informational logs to the console - * @property {boolean} warn emit warnings to the console + * @property {LogLevel} logging logger level * @property {string} name limit updates to this compilation name * @property {boolean} autoConnect connect immediately when the entry runs * @property {Record} overlayStyles overrides for the overlay container CSS @@ -27,8 +29,7 @@ const options = { timeout: 20 * 1000, overlay: true, reload: false, - log: true, - warn: true, + logging: "info", name: "", autoConnect: true, overlayStyles: {}, @@ -36,6 +37,8 @@ const options = { ansiColors: {}, }; +setLogLevel(options.logging); + /** * @param {Record} overrides parsed query-string overrides */ @@ -47,16 +50,12 @@ function setOverrides(overrides) { if (overrides.timeout) options.timeout = Number(overrides.timeout); if (overrides.overlay) options.overlay = overrides.overlay !== "false"; if (overrides.reload) options.reload = overrides.reload !== "false"; - if (overrides.noInfo && overrides.noInfo !== "false") { - options.log = false; + if (overrides.logging) { + options.logging = /** @type {LogLevel} */ (overrides.logging); } if (overrides.name) { options.name = overrides.name; } - if (overrides.quiet && overrides.quiet !== "false") { - options.log = false; - options.warn = false; - } if (overrides.dynamicPublicPath) { options.path = __webpack_public_path__ + options.path; @@ -72,6 +71,8 @@ function setOverrides(overrides) { if (overrides.overlayWarnings) { options.overlayWarnings = overrides.overlayWarnings === "true"; } + + setLogLevel(options.logging); } /** @@ -91,7 +92,7 @@ function createEventSourceWrapper() { let timer; const handleOnline = () => { - if (options.log) console.log("[HMR] connected"); + log.info("connected"); lastActivity = Date.now(); }; @@ -167,9 +168,7 @@ function connect() { try { processMessage(JSON.parse(event.data)); } catch (err) { - if (options.warn) { - console.warn(`Invalid HMR message: ${event.data}\n${err}`); - } + log.warn(`Invalid HMR message: ${event.data}\n${err}`); } }); } @@ -205,11 +204,6 @@ function createReporter() { }); } - /** @type {Record} */ - const styles = { - errors: "color: #ff0000;", - warnings: "color: #999933;", - }; /** @type {string | null} */ let previousProblems = null; @@ -217,28 +211,21 @@ function createReporter() { * @param {"errors" | "warnings"} type problem type * @param {HMRPayload} obj payload */ - const log = (type, obj) => { + const logProblems = (type, obj) => { const newProblems = obj[type].map(stripAnsi).join("\n"); if (previousProblems === newProblems) { return; } previousProblems = newProblems; - const style = styles[type]; const name = obj.name ? `'${obj.name}' ` : ""; - const title = `[HMR] bundle ${name}has ${obj[type].length} ${type}`; - // NOTE: console.warn / console.error print the stack trace which is noise - // for us; using console.log to keep the message clean. - if (console.group && console.groupEnd) { - console.group(`%c${title}`, style); - console.log(`%c${newProblems}`, style); - console.groupEnd(); + const title = `bundle ${name}has ${obj[type].length} ${type}`; + if (type === "errors") { + log.error(title); + log.error(newProblems); } else { - console.log( - `%c${title}\n\t%c${newProblems.replaceAll("\n", "\n\t")}`, - `${style}font-weight: bold;`, - `${style}font-weight: normal;`, - ); + log.warn(title); + log.warn(newProblems); } }; @@ -247,9 +234,7 @@ function createReporter() { previousProblems = null; }, problems(type, obj) { - if (options.warn) { - log(type, obj); - } + logProblems(type, obj); if (overlay) { if (options.overlayWarnings || type === "errors") { overlay.showProblems(type, obj[type]); @@ -285,18 +270,14 @@ let subscribeAllHandler; function processMessage(obj) { switch (obj.action) { case "building": { - if (options.log) { - console.log( - `[HMR] bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilding`, - ); - } + log.info(`bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilding`); break; } case "built": case "sync": { - if (obj.action === "built" && options.log) { - console.log( - `[HMR] bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilt in ${obj.time}ms`, + if (obj.action === "built") { + log.info( + `bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilt in ${obj.time}ms`, ); } if (obj.name && options.name && obj.name !== options.name) { @@ -349,7 +330,7 @@ if (typeof window !== "undefined") { reporter = window[REPORTER_KEY]; if (typeof window.EventSource === "undefined") { - console.warn( + log.warn( "webpack-dev-middleware's hot client requires EventSource to work. " + "Include a polyfill if you want to support this browser: " + "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events#Tools", diff --git a/client-src/process-update.js b/client-src/process-update.js index 15ccb33a9..75fb19541 100644 --- a/client-src/process-update.js +++ b/client-src/process-update.js @@ -2,6 +2,8 @@ /* global __webpack_hash__ */ +const { log } = require("./utils/log"); + // `module.exports = function (...)` below narrows TS's view of `module` to // `{ exports: ... }`, hiding the webpack-augmented `hot` property. Alias it // through `NodeJS.Module` so type-checking still finds `hot`. @@ -28,18 +30,18 @@ const applyOptions = { ignoreDeclined: true, ignoreErrored: true, onUnaccepted(event) { - console.warn( + log.warn( `Ignored an update to unaccepted module ${event.chain.join(" -> ")}`, ); }, onDeclined(event) { - console.warn( + log.warn( `Ignored an update to declined module ${event.chain.join(" -> ")}`, ); }, onErrored(event) { - console.error(event.error); - console.warn( + log.error(event.error); + log.warn( `Ignored an error while updating module ${event.moduleId} (${event.type})`, ); }, @@ -57,7 +59,7 @@ function upToDate(hash) { /** * @param {string} hash latest hash from the SSE payload * @param {Record | undefined} moduleMap module id → name map - * @param {{ reload?: boolean, log?: boolean, warn?: boolean }} options client options + * @param {{ reload?: boolean }} options client options */ module.exports = function applyUpdate(hash, moduleMap, options) { const { reload } = options; @@ -67,7 +69,7 @@ module.exports = function applyUpdate(hash, moduleMap, options) { */ function performReload() { if (reload) { - if (options.warn) console.warn("[HMR] Reloading page"); + log.warn("Reloading page"); window.location.reload(); } } @@ -77,16 +79,12 @@ module.exports = function applyUpdate(hash, moduleMap, options) { */ function handleError(err) { if ($module.hot.status() in failureStatuses) { - if (options.warn) { - console.warn("[HMR] Cannot check for update (Full reload needed)"); - console.warn(`[HMR] ${err.stack || err.message}`); - } + log.warn("Cannot check for update (Full reload needed)"); + log.warn(err.stack || err.message); performReload(); return; } - if (options.warn) { - console.warn(`[HMR] Update check failed: ${err.stack || err.message}`); - } + log.warn(`Update check failed: ${err.stack || err.message}`); } /** @@ -99,39 +97,31 @@ module.exports = function applyUpdate(hash, moduleMap, options) { ); if (unacceptedModules.length > 0) { - if (options.warn) { - console.warn( - "[HMR] The following modules couldn't be hot updated: " + - "(Full reload needed)\n" + - "This is usually because the modules which have changed " + - "(and their parents) do not know how to hot reload themselves. " + - `See ${HMR_DOCS_URL} for more details.`, - ); - for (const moduleId of unacceptedModules) { - console.warn( - `[HMR] - ${(moduleMap && moduleMap[moduleId]) || moduleId}`, - ); - } + log.warn( + "The following modules couldn't be hot updated: " + + "(Full reload needed)\n" + + "This is usually because the modules which have changed " + + "(and their parents) do not know how to hot reload themselves. " + + `See ${HMR_DOCS_URL} for more details.`, + ); + for (const moduleId of unacceptedModules) { + log.warn(` - ${(moduleMap && moduleMap[moduleId]) || moduleId}`); } performReload(); return; } - if (options.log) { - if (!renewedModules || renewedModules.length === 0) { - console.log("[HMR] Nothing hot updated."); - } else { - console.log("[HMR] Updated modules:"); - for (const moduleId of renewedModules) { - console.log( - `[HMR] - ${(moduleMap && moduleMap[moduleId]) || moduleId}`, - ); - } + if (!renewedModules || renewedModules.length === 0) { + log.info("Nothing hot updated."); + } else { + log.info("Updated modules:"); + for (const moduleId of renewedModules) { + log.info(` - ${(moduleMap && moduleMap[moduleId]) || moduleId}`); } + } - if (upToDate()) { - console.log("[HMR] App is up to date."); - } + if (upToDate()) { + log.info("App is up to date."); } } @@ -143,10 +133,8 @@ module.exports = function applyUpdate(hash, moduleMap, options) { .check(false) .then((updatedModules) => { if (!updatedModules) { - if (options.warn) { - console.warn("[HMR] Cannot find update (Full reload needed)"); - console.warn("[HMR] (Probably because of restarting the server)"); - } + log.warn("Cannot find update (Full reload needed)"); + log.warn("(Probably because of restarting the server)"); performReload(); return undefined; } @@ -160,7 +148,7 @@ module.exports = function applyUpdate(hash, moduleMap, options) { } if (!upToDate(hash) && $module.hot.status() === "idle") { - if (options.log) console.log("[HMR] Checking for updates on the server..."); + log.info("Checking for updates on the server..."); check(); } }; diff --git a/client-src/utils/log.js b/client-src/utils/log.js new file mode 100644 index 000000000..6a50bc130 --- /dev/null +++ b/client-src/utils/log.js @@ -0,0 +1,22 @@ +"use strict"; + +// @ts-expect-error -- no published types for this entry point +const logger = require("webpack/lib/logging/runtime"); + +const LOGGER_NAME = "webpack-dev-middleware"; +const DEFAULT_LEVEL = "info"; + +/** @typedef {false | true | "none" | "error" | "warn" | "info" | "log" | "verbose"} LogLevel */ + +/** + * @param {LogLevel} level log level (or `false` for off, `true` for default) + */ +function setLogLevel(level) { + logger.configureDefaultLogger({ level }); +} + +setLogLevel(DEFAULT_LEVEL); + +const log = logger.getLogger(LOGGER_NAME); + +module.exports = { log, setLogLevel }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 1cb301c2c..62f7021f2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,8 +20,6 @@ export default defineConfig([ ecmaVersion: "latest", }, rules: { - // The HMR client legitimately reports build status to the browser console. - "no-console": "off", // Function declarations are hoisted; allow referencing them ahead of // their definition for readability. "no-use-before-define": ["error", { functions: false }], diff --git a/test/client.test.js b/test/client.test.js index 9a4f4a377..dd4cda06e 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -541,6 +541,115 @@ describe("client", () => { }); }); + describe("with logging option", () => { + let EventSourceStub; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("emits info-level logs by default", () => { + loadClient(); + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect( + console.info.mock.calls.some(([msg]) => /rebuilt/.test(String(msg))), + ).toBe(true); + }); + + it("prefixes log output with [webpack-dev-middleware]", () => { + loadClient(); + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect( + console.info.mock.calls.some(([msg]) => + /\[webpack-dev-middleware\]/.test(String(msg)), + ), + ).toBe(true); + }); + + it("logging=none silences every level", () => { + loadClient("?logging=none"); + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: ["boom"], + warnings: [], + modules: [], + }), + ); + expect(console.info).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("logging=warn silences info but keeps warn and error", () => { + loadClient("?logging=warn&overlayWarnings=true"); + const es = EventSourceStub.lastInstance(); + es.onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: ["something"], + modules: [], + }), + ); + expect( + console.info.mock.calls.some(([msg]) => /rebuilt/.test(String(msg))), + ).toBe(false); + expect( + console.warn.mock.calls.some(([msg]) => /something/.test(String(msg))), + ).toBe(true); + }); + + it("logging=error silences info and warn", () => { + loadClient("?logging=error"); + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: ["boom"], + warnings: [], + modules: [], + }), + ); + expect(console.info).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + expect( + console.error.mock.calls.some(([msg]) => /boom/.test(String(msg))), + ).toBe(true); + }); + }); + describe("with no EventSource", () => { beforeEach(() => { delete globalThis.EventSource; From 19db43c4c0056333066615b969077ae0d29a04bb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 18:54:36 -0500 Subject: [PATCH 10/13] test(client): use snapshots for logger output assertions Replaces regex-based `some(([msg]) => /.../).toBe(true)` checks on the mocked console with `toMatchSnapshot()` over `mock.calls`. The snapshots capture the exact log lines including the `[webpack-dev-middleware]` prefix and per-call argument count, so any change to the log format surfaces in the test output instead of silently passing. Adds explicit assertions to the existing error / warning flow tests so `console.error` / `console.warn` mocks are not only silenced but also verified to be called with the expected output. --- .../client.test.js.snap.webpack5 | 66 +++++++++++++++++++ test/client.test.js | 59 ++++++----------- 2 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 test/__snapshots__/client.test.js.snap.webpack5 diff --git a/test/__snapshots__/client.test.js.snap.webpack5 b/test/__snapshots__/client.test.js.snap.webpack5 new file mode 100644 index 000000000..e03872bc6 --- /dev/null +++ b/test/__snapshots__/client.test.js.snap.webpack5 @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`client with default options does not show overlay on warning builds by default 1`] = ` +[ + [ + "[webpack-dev-middleware] bundle has 1 warnings", + ], + [ + "[webpack-dev-middleware] This isn't great, but it's not terrible", + ], +] +`; + +exports[`client with default options shows overlay on errored builds 1`] = ` +[ + [ + "[webpack-dev-middleware] bundle has 2 errors", + ], + [ + "[webpack-dev-middleware] Something broke +Actually, 2 things broke", + ], +] +`; + +exports[`client with logging option emits info-level logs (including the [webpack-dev-middleware] prefix) by default 1`] = ` +[ + [ + "[webpack-dev-middleware] bundle rebuilt in 100ms", + ], +] +`; + +exports[`client with logging option logging=error silences info and warn but keeps error 1`] = ` +[ + [ + "[webpack-dev-middleware] bundle has 1 errors", + ], + [ + "[webpack-dev-middleware] boom", + ], +] +`; + +exports[`client with logging option logging=warn silences info but keeps warn 1`] = ` +[ + [ + "[webpack-dev-middleware] bundle has 1 warnings", + ], + [ + "[webpack-dev-middleware] something", + ], +] +`; + +exports[`client with overlayWarnings: true shows overlay on errored builds 1`] = ` +[ + [ + "[webpack-dev-middleware] bundle has 2 errors", + ], + [ + "[webpack-dev-middleware] Something broke +Actually, 2 things broke", + ], +] +`; diff --git a/test/client.test.js b/test/client.test.js index dd4cda06e..10e1bfe91 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -98,10 +98,10 @@ describe("client", () => { beforeEach(() => { EventSourceStub = makeEventSourceStub(); globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "group").mockImplementation(() => {}); - jest.spyOn(console, "groupEnd").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); client = loadClient(); }); @@ -212,6 +212,7 @@ describe("client", () => { "Something broke", "Actually, 2 things broke", ]); + expect(console.error.mock.calls).toMatchSnapshot(); }); it("hides overlay after errored build is fixed", () => { @@ -292,6 +293,8 @@ describe("client", () => { }), ); expect(clientOverlay.showProblems).not.toHaveBeenCalled(); + // Warnings still surface through the logger even when the overlay stays hidden. + expect(console.warn.mock.calls).toMatchSnapshot(); }); it("shows overlay after warning build becomes an error", () => { @@ -326,10 +329,10 @@ describe("client", () => { beforeEach(() => { EventSourceStub = makeEventSourceStub(); globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "group").mockImplementation(() => {}); - jest.spyOn(console, "groupEnd").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); loadClient("?overlayWarnings=true"); }); @@ -353,6 +356,7 @@ describe("client", () => { "Something broke", "Actually, 2 things broke", ]); + expect(console.error.mock.calls).toMatchSnapshot(); }); it("shows overlay on warning builds", () => { @@ -438,7 +442,9 @@ describe("client", () => { beforeEach(() => { EventSourceStub = makeEventSourceStub(); globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); loadClient("?name=test"); }); @@ -484,6 +490,7 @@ describe("client", () => { beforeEach(() => { EventSourceStub = makeEventSourceStub(); globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); client = loadClient(); @@ -556,24 +563,7 @@ describe("client", () => { jest.restoreAllMocks(); }); - it("emits info-level logs by default", () => { - loadClient(); - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }), - ); - expect( - console.info.mock.calls.some(([msg]) => /rebuilt/.test(String(msg))), - ).toBe(true); - }); - - it("prefixes log output with [webpack-dev-middleware]", () => { + it("emits info-level logs (including the [webpack-dev-middleware] prefix) by default", () => { loadClient(); EventSourceStub.lastInstance().onmessage( makeMessage({ @@ -585,11 +575,7 @@ describe("client", () => { modules: [], }), ); - expect( - console.info.mock.calls.some(([msg]) => - /\[webpack-dev-middleware\]/.test(String(msg)), - ), - ).toBe(true); + expect(console.info.mock.calls).toMatchSnapshot(); }); it("logging=none silences every level", () => { @@ -609,10 +595,9 @@ describe("client", () => { expect(console.error).not.toHaveBeenCalled(); }); - it("logging=warn silences info but keeps warn and error", () => { + it("logging=warn silences info but keeps warn", () => { loadClient("?logging=warn&overlayWarnings=true"); - const es = EventSourceStub.lastInstance(); - es.onmessage( + EventSourceStub.lastInstance().onmessage( makeMessage({ action: "built", time: 100, @@ -622,15 +607,11 @@ describe("client", () => { modules: [], }), ); - expect( - console.info.mock.calls.some(([msg]) => /rebuilt/.test(String(msg))), - ).toBe(false); - expect( - console.warn.mock.calls.some(([msg]) => /something/.test(String(msg))), - ).toBe(true); + expect(console.info).not.toHaveBeenCalled(); + expect(console.warn.mock.calls).toMatchSnapshot(); }); - it("logging=error silences info and warn", () => { + it("logging=error silences info and warn but keeps error", () => { loadClient("?logging=error"); EventSourceStub.lastInstance().onmessage( makeMessage({ @@ -644,9 +625,7 @@ describe("client", () => { ); expect(console.info).not.toHaveBeenCalled(); expect(console.warn).not.toHaveBeenCalled(); - expect( - console.error.mock.calls.some(([msg]) => /boom/.test(String(msg))), - ).toBe(true); + expect(console.error.mock.calls).toMatchSnapshot(); }); }); From 144013b426369b905118ba1895d86ebd64b3aff8 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 19:06:29 -0500 Subject: [PATCH 11/13] chore: document why client-src needs the ecmaVersion override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eslint-config-webpack@4.9.6` still ships `browser-outdated-recommended-commonjs` with `configs["javascript/es5"]` and no parser override, so `const` is rejected. The module variant of the same preset patches this upstream — we replicate the patch locally until the commonjs variant does the same. --- eslint.config.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 62f7021f2..918d74e35 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,10 @@ export default defineConfig([ files: ["client-src/**/*"], extends: [configs["browser-outdated-recommended-commonjs"]], languageOptions: { + // The preset bundles `javascript/es5` (parser locked to ES5) which + // rejects `const`. The module variant of the same preset overrides + // this to "latest" upstream; we replicate that for the commonjs one + // until eslint-config-webpack does it itself. ecmaVersion: "latest", }, rules: { From 95fe35c0262ab0beed55df5644b84faee1440fe6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 19:36:55 -0500 Subject: [PATCH 12/13] refactor(client): migrate to ES modules and update Babel configuration --- babel.config.js | 19 +-- client-src/index.js | 66 ++++---- client-src/overlay.js | 27 ++-- client-src/process-update.js | 27 ++-- client-src/utils/log.js | 10 +- cspell.config.json | 2 +- eslint.config.mjs | 9 +- package-lock.json | 299 ++++++++++++++++++----------------- package.json | 3 +- tsconfig.client.json | 2 +- 10 files changed, 224 insertions(+), 240 deletions(-) diff --git a/babel.config.js b/babel.config.js index 6138b4a21..dc7d001f7 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,4 @@ -const MIN_BABEL_VERSION = 7; - module.exports = (api) => { - api.assertVersion(MIN_BABEL_VERSION); api.cache(true); return { @@ -9,24 +6,28 @@ module.exports = (api) => { [ "@babel/preset-env", { + modules: false, targets: { - node: "20.9.0", + esmodules: true, + node: "0.12", }, }, ], ], - overrides: [ - { - test: /client-src[\\/]/, + env: { + test: { presets: [ [ "@babel/preset-env", { - targets: "defaults", + targets: { + node: "18.12.0", + }, }, ], ], + plugins: ["@babel/plugin-transform-runtime"], }, - ], + }, }; }; diff --git a/client-src/index.js b/client-src/index.js index 8ff502a72..e66ccf651 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -1,13 +1,12 @@ -"use strict"; - /* global __resourceQuery, __webpack_public_path__ */ -const stripAnsi = require("strip-ansi"); +import stripAnsi from "strip-ansi"; -const processUpdate = require("./process-update"); -const { log, setLogLevel } = require("./utils/log"); +import configureOverlay from "./overlay.js"; +import applyUpdate from "./process-update.js"; +import { log, setLogLevel } from "./utils/log.js"; -/** @typedef {import("./utils/log").LogLevel} LogLevel */ +/** @typedef {import("./utils/log.js").LogLevel} LogLevel */ /** * @typedef {object} ClientOptions @@ -176,7 +175,7 @@ function connect() { /** * @param {Record} overrides overrides */ -function setOptionsAndConnect(overrides) { +export function setOptionsAndConnect(overrides) { setOverrides(overrides); connect(); } @@ -198,7 +197,7 @@ function createReporter() { /** @type {EXPECTED_ANY} */ let overlay; if (typeof document !== "undefined" && options.overlay) { - overlay = require("./overlay")({ + overlay = configureOverlay({ ansiColors: options.ansiColors, overlayStyles: options.overlayStyles, }); @@ -283,20 +282,20 @@ function processMessage(obj) { if (obj.name && options.name && obj.name !== options.name) { return; } - let applyUpdate = true; + let shouldApply = true; if (obj.errors.length > 0) { if (reporter) reporter.problems("errors", obj); - applyUpdate = false; + shouldApply = false; } else if (obj.warnings.length > 0) { if (reporter) { - applyUpdate = reporter.problems("warnings", obj); + shouldApply = reporter.problems("warnings", obj); } } else if (reporter) { reporter.cleanProblemsCache(); reporter.success(); } - if (applyUpdate) { - processUpdate(obj.hash, obj.modules, options); + if (shouldApply) { + applyUpdate(obj.hash, obj.modules, options); } break; } @@ -340,24 +339,23 @@ if (typeof window !== "undefined") { } } -module.exports = { - /** - * @param {(obj: HMRPayload) => void} handler called for every incoming HMR message - */ - subscribeAll(handler) { - subscribeAllHandler = handler; - }, - /** - * @param {(obj: HMRPayload) => void} handler called for messages whose `action` is not recognized - */ - subscribe(handler) { - customHandler = handler; - }, - /** - * @param {EXPECTED_ANY} customOverlay replacement for the default error overlay - */ - useCustomOverlay(customOverlay) { - if (reporter) reporter.useCustomOverlay(customOverlay); - }, - setOptionsAndConnect, -}; +/** + * @param {(obj: HMRPayload) => void} handler called for every incoming HMR message + */ +export function subscribeAll(handler) { + subscribeAllHandler = handler; +} + +/** + * @param {(obj: HMRPayload) => void} handler called for messages whose `action` is not recognized + */ +export function subscribe(handler) { + customHandler = handler; +} + +/** + * @param {EXPECTED_ANY} customOverlay replacement for the default error overlay + */ +export function useCustomOverlay(customOverlay) { + if (reporter) reporter.useCustomOverlay(customOverlay); +} diff --git a/client-src/overlay.js b/client-src/overlay.js index 02fc266a3..a63596853 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -1,7 +1,5 @@ -"use strict"; - -const ansiHTML = require("ansi-html-community"); -const htmlEntities = require("html-entities"); +import ansiHTML from "ansi-html-community"; +import { encode as encodeHtmlEntity } from "html-entities"; const clientOverlay = document.createElement("div"); clientOverlay.id = "webpack-dev-middleware-hot-overlay"; @@ -62,26 +60,26 @@ function problemType(type) { * @param {"errors" | "warnings"} type problem type * @param {string[]} lines messages to render */ -function showProblems(type, lines) { +export function showProblems(type, lines) { clientOverlay.innerHTML = ""; for (const line of lines) { - const msg = ansiHTML(htmlEntities.encode(line)); + const msg = ansiHTML(encodeHtmlEntity(line)); const div = document.createElement("div"); div.style.marginBottom = "26px"; div.innerHTML = `${problemType(type)} in ${msg}`; - clientOverlay.appendChild(div); + clientOverlay.append(div); } if (document.body) { - document.body.appendChild(clientOverlay); + document.body.append(clientOverlay); } } /** * Remove the overlay container from the DOM. */ -function clear() { - if (document.body && clientOverlay.parentNode) { - document.body.removeChild(clientOverlay); +export function clear() { + if (clientOverlay.parentNode) { + clientOverlay.remove(); } } @@ -89,7 +87,7 @@ function clear() { * @param {{ ansiColors?: Record, overlayStyles?: Record }} options options * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api */ -module.exports = function configureOverlay(options) { +export default function configureOverlay(options) { if (options.ansiColors) { for (const color of Object.keys(options.ansiColors)) { if (color in colors) { @@ -114,10 +112,7 @@ module.exports = function configureOverlay(options) { showProblems, clear, }; -}; - -module.exports.clear = clear; -module.exports.showProblems = showProblems; +} // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ diff --git a/client-src/process-update.js b/client-src/process-update.js index 75fb19541..1d00d7c97 100644 --- a/client-src/process-update.js +++ b/client-src/process-update.js @@ -1,22 +1,13 @@ -"use strict"; - /* global __webpack_hash__ */ -const { log } = require("./utils/log"); +import { log } from "./utils/log.js"; -// `module.exports = function (...)` below narrows TS's view of `module` to -// `{ exports: ... }`, hiding the webpack-augmented `hot` property. Alias it -// through `NodeJS.Module` so type-checking still finds `hot`. -/** @type {NodeJS.Module} */ -const $module = /** @type {EXPECTED_ANY} */ (module); +const hot = import.meta.webpackHot; -if (!$module.hot) { +if (!hot) { throw new Error("[HMR] Hot Module Replacement is disabled."); } -// eslint-disable-next-line jsdoc/reject-any-type -/** @typedef {any} EXPECTED_ANY */ - const HMR_DOCS_URL = "https://webpack.js.org/concepts/hot-module-replacement/"; /** @type {string | undefined} */ @@ -61,7 +52,7 @@ function upToDate(hash) { * @param {Record | undefined} moduleMap module id → name map * @param {{ reload?: boolean }} options client options */ -module.exports = function applyUpdate(hash, moduleMap, options) { +export default function applyUpdate(hash, moduleMap, options) { const { reload } = options; /** @@ -78,7 +69,7 @@ module.exports = function applyUpdate(hash, moduleMap, options) { * @param {Error} err error */ function handleError(err) { - if ($module.hot.status() in failureStatuses) { + if (hot.status() in failureStatuses) { log.warn("Cannot check for update (Full reload needed)"); log.warn(err.stack || err.message); performReload(); @@ -129,7 +120,7 @@ module.exports = function applyUpdate(hash, moduleMap, options) { * Ask webpack for the next chunk of HMR updates and apply them. */ function check() { - $module.hot + hot .check(false) .then((updatedModules) => { if (!updatedModules) { @@ -139,7 +130,7 @@ module.exports = function applyUpdate(hash, moduleMap, options) { return undefined; } - return $module.hot.apply(applyOptions).then((renewedModules) => { + return hot.apply(applyOptions).then((renewedModules) => { if (!upToDate()) check(); logUpdates(updatedModules, renewedModules); }); @@ -147,8 +138,8 @@ module.exports = function applyUpdate(hash, moduleMap, options) { .catch(handleError); } - if (!upToDate(hash) && $module.hot.status() === "idle") { + if (!upToDate(hash) && hot.status() === "idle") { log.info("Checking for updates on the server..."); check(); } -}; +} diff --git a/client-src/utils/log.js b/client-src/utils/log.js index 6a50bc130..56f1d82d4 100644 --- a/client-src/utils/log.js +++ b/client-src/utils/log.js @@ -1,7 +1,5 @@ -"use strict"; - // @ts-expect-error -- no published types for this entry point -const logger = require("webpack/lib/logging/runtime"); +import logger from "webpack/lib/logging/runtime.js"; const LOGGER_NAME = "webpack-dev-middleware"; const DEFAULT_LEVEL = "info"; @@ -11,12 +9,10 @@ const DEFAULT_LEVEL = "info"; /** * @param {LogLevel} level log level (or `false` for off, `true` for default) */ -function setLogLevel(level) { +export function setLogLevel(level) { logger.configureDefaultLogger({ level }); } setLogLevel(DEFAULT_LEVEL); -const log = logger.getLogger(LOGGER_NAME); - -module.exports = { log, setLogLevel }; +export const log = logger.getLogger(LOGGER_NAME); diff --git a/cspell.config.json b/cspell.config.json index affcb0b57..bf5c179ed 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -9,5 +9,5 @@ "CHANGELOG.md", "cspell.config.json" ], - "words": ["Consolas", "cspellcache", "darkgrey", "eslintcache"] + "words": ["Consolas", "cspellcache", "darkgrey", "eslintcache", "esmodules"] } diff --git a/eslint.config.mjs b/eslint.config.mjs index 918d74e35..9f658762a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,14 +15,7 @@ export default defineConfig([ }, { files: ["client-src/**/*"], - extends: [configs["browser-outdated-recommended-commonjs"]], - languageOptions: { - // The preset bundles `javascript/es5` (parser locked to ES5) which - // rejects `const`. The module variant of the same preset overrides - // this to "latest" upstream; we replicate that for the commonjs one - // until eslint-config-webpack does it itself. - ecmaVersion: "latest", - }, + extends: [configs["browser-outdated-recommended-module"]], rules: { // Function declarations are hoisted; allow referencing them ahead of // their definition for readability. diff --git a/package-lock.json b/package-lock.json index 881c0ab53..2d238881c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "devDependencies": { "@babel/cli": "^7.16.7", "@babel/core": "^7.16.7", + "@babel/plugin-transform-runtime": "^7.29.0", "@babel/preset-env": "^7.16.7", "@changesets/cli": "^2.30.0", "@changesets/get-github-info": "^0.8.0", @@ -38,7 +39,7 @@ "deepmerge": "^4.2.2", "del-cli": "^7.0.0", "eslint": "^9.28.0", - "eslint-config-webpack": "^4.9.5", + "eslint-config-webpack": "^4.9.6", "execa": "^9.6.1", "express": "^5.1.0", "express-4": "npm:express@^4", @@ -1584,6 +1585,41 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", + "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", @@ -5539,18 +5575,18 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", - "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/type-utils": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5563,7 +5599,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.0", + "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -5579,17 +5615,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", - "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "engines": { @@ -5605,14 +5641,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", - "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.0", - "@typescript-eslint/types": "^8.58.0", + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "engines": { @@ -5627,14 +5663,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", - "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5645,9 +5681,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", - "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", "dev": true, "license": "MIT", "engines": { @@ -5662,15 +5698,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", - "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5687,9 +5723,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", - "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", "dev": true, "license": "MIT", "engines": { @@ -5701,16 +5737,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", - "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.0", - "@typescript-eslint/tsconfig-utils": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5739,9 +5775,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -5768,9 +5804,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -5781,16 +5817,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", - "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5805,13 +5841,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", - "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8920,9 +8956,9 @@ } }, "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -8950,9 +8986,9 @@ } }, "node_modules/eslint-config-webpack": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/eslint-config-webpack/-/eslint-config-webpack-4.9.5.tgz", - "integrity": "sha512-A4FtsxyBZfLF69zX0+18EolUMNSkua3vKAz9GV0XovQIbK/TqE8qenXziq+sMvzIWFwTJXhhtd5gFTUjccuZmQ==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/eslint-config-webpack/-/eslint-config-webpack-4.9.6.tgz", + "integrity": "sha512-4g1VqqOVgPrO/2bh17qNRKsQK26Aw1WF9mVTnvF+rNTDIUUTx+IaukXqXlumzwApQ1GfJlOsdLvT6WER1SPePg==", "dev": true, "license": "MIT", "dependencies": { @@ -8962,18 +8998,18 @@ "detect-indent": "^7.0.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jest": "^29.15.1", + "eslint-plugin-jest": "^29.15.2", "eslint-plugin-jsdoc": "^62.9.0", - "eslint-plugin-n": "^17.24.0", + "eslint-plugin-n": "^18.0.1", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-unicorn": "^64.0.0", - "globals": "^17.4.0", + "globals": "^17.6.0", "jsonc-eslint-parser": "^3.1.0", - "semver": "^7.7.4", + "semver": "^7.8.0", "sort-package-json": "^3.6.0", - "typescript-eslint": "^8.58.0" + "typescript-eslint": "^8.59.3" }, "engines": { "node": ">= 20.9.0" @@ -9002,9 +9038,9 @@ } }, "node_modules/eslint-config-webpack/node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -9015,9 +9051,9 @@ } }, "node_modules/eslint-config-webpack/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -9144,9 +9180,9 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "29.15.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.15.1.tgz", - "integrity": "sha512-6BjyErCQauz3zfJvzLw/kAez2lf4LEpbHLvWBfEcG4EI0ZiRSwjoH2uZulMouU8kRkBH+S0rhqn11IhTvxKgKw==", + "version": "29.15.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.15.2.tgz", + "integrity": "sha512-kEN4r9RZl1xcsb4arGq89LrcVdOUFII/JSCwtTPJyv16mDwmPrcuEQwpxqZHeINvcsd7oK5O/rhdGlxFRaZwvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9247,9 +9283,9 @@ } }, "node_modules/eslint-plugin-n": { - "version": "17.24.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.24.0.tgz", - "integrity": "sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==", + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.0.1.tgz", + "integrity": "sha512-q3ARhk+eZRc7myR0KHx+R3/GJeOHF+Ir6PK95Pu2tEX8Sl/4BIpmmVLva2kPrjC2gCmn6WHlHm+3yeo6Rxhycw==", "dev": true, "license": "MIT", "dependencies": { @@ -9260,17 +9296,26 @@ "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", - "semver": "^7.6.3", - "ts-declaration-location": "^1.0.6" + "semver": "^7.6.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" }, "peerDependencies": { - "eslint": ">=8.23.0" + "eslint": ">=8.57.1", + "ts-declaration-location": "^1.0.6", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "ts-declaration-location": { + "optional": true + }, + "typescript": { + "optional": true + } } }, "node_modules/eslint-plugin-n/node_modules/globals": { @@ -9287,9 +9332,9 @@ } }, "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -9364,9 +9409,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -9380,7 +9425,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react/node_modules/resolve": { @@ -10840,9 +10885,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -18270,42 +18315,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-declaration-location": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz", - "integrity": "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==", - "dev": true, - "funding": [ - { - "type": "ko-fi", - "url": "https://ko-fi.com/rebeccastevens" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/ts-declaration-location" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "picomatch": "^4.0.2" - }, - "peerDependencies": { - "typescript": ">=4.0.0" - } - }, - "node_modules/ts-declaration-location/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -18504,16 +18513,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", - "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.0", - "@typescript-eslint/parser": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0" + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -19419,9 +19428,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "peer": true, diff --git a/package.json b/package.json index 66547f8b2..ab9d21e10 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "devDependencies": { "@babel/cli": "^7.16.7", "@babel/core": "^7.16.7", + "@babel/plugin-transform-runtime": "^7.29.0", "@babel/preset-env": "^7.16.7", "@changesets/cli": "^2.30.0", "@changesets/get-github-info": "^0.8.0", @@ -86,7 +87,7 @@ "deepmerge": "^4.2.2", "del-cli": "^7.0.0", "eslint": "^9.28.0", - "eslint-config-webpack": "^4.9.5", + "eslint-config-webpack": "^4.9.6", "execa": "^9.6.1", "express": "^5.1.0", "express-4": "npm:express@^4", diff --git a/tsconfig.client.json b/tsconfig.client.json index 012c20eb0..ccf339ad5 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "esnext", - "lib": ["es2022", "dom", "webworker"], + "lib": ["es5", "dom", "webworker", "es2022.error"], "module": "nodenext", "moduleResolution": "nodenext", "allowJs": true, From 4a68e44dc02b8f4752bf96ea571967601c5bf52c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 19:46:49 -0500 Subject: [PATCH 13/13] fixup! --- tsconfig.client.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.client.json b/tsconfig.client.json index ccf339ad5..479ffba6d 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -2,8 +2,8 @@ "compilerOptions": { "target": "esnext", "lib": ["es5", "dom", "webworker", "es2022.error"], - "module": "nodenext", - "moduleResolution": "nodenext", + "module": "esnext", + "moduleResolution": "bundler", "allowJs": true, "checkJs": true, "noEmit": true,