diff --git a/.changeset/hot-middleware-migration.md b/.changeset/hot-middleware-migration.md new file mode 100644 index 000000000..fcc54ef64 --- /dev/null +++ b/.changeset/hot-middleware-migration.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": minor +--- + +Added a `hot` option that enables hot module replacement, replacing the need for `webpack-hot-middleware`. Pass `hot: true` to enable with defaults, or `hot: { path, heartbeat, log, statsOptions }` to customize. The client runtime is served by the middleware itself. diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 9b569be6c..488edf17f 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 diff --git a/.gitignore b/.gitignore index 13bb32b4c..6b5d2aaf1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ logs npm-debug.log* .eslintcache .cspellcache -/dist +/client +dist /local /reports /test/outputs diff --git a/README.md b/README.md index 5b67deb5a..676495526 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ See [below](#other-servers) for an example of use with fastify. | **[`writeToDisk`](#writetodisk)** | `boolean\|Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your `webpack` configuration. | | **[`outputFileSystem`](#outputfilesystem)** | `Object` | [`memfs`](https://github.com/streamich/memfs) | Set the default file system which will be used by webpack as primary destination of generated files. | | **[`modifyResponseData`](#modifyresponsedata)** | `Function` | `undefined` | Allows to set up a callback to change the response data. | +| **[`hot`](#hot)** | `boolean\|Object` | `false` | Enables a Server-Sent Events endpoint that drives the browser HMR client. | | **[`forwardError`](#forwarderror)** | `boolean` | `false` | Enable or disable forwarding errors to the next middleware. | The middleware accepts an `options` Object. The following is a property reference for the Object. @@ -312,6 +313,115 @@ middleware(compiler, { }); ``` +### hot + +Type: `Boolean | Object` +Default: `false` + +Enables hot module replacement by serving a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) endpoint that publishes the webpack compiler's `building`, `built` and `sync` events to connected clients. When `true`, defaults are used; pass an object to customise. Use this option together with the browser runtime shipped as `webpack-dev-middleware/client`. + +```js +const webpack = require("webpack"); + +const compiler = webpack({ + /* Webpack configuration with HotModuleReplacementPlugin and the client entry */ +}); + +middleware(compiler, { hot: true }); +``` + +#### `hot.path` + +Type: `String` +Default: `'/__webpack_hmr'` + +Path the SSE endpoint is served at. Must match the `path` option used by the client. + +#### `hot.heartbeat` + +Type: `Number` +Default: `10000` + +Heartbeat interval (in milliseconds) used to keep the SSE connection alive when no compilation events are produced. + +#### `hot.statsOptions` + +Type: `Boolean | Object` +Default: `undefined` + +Webpack stats options used when serializing compilation results for the SSE payload. Forwarded to `stats.toJson(...)`. By default only the minimal stats needed by the client are requested (`hash`, `timings`, `errors`, `warnings`) to avoid slowing down rebuilds. Pass `statsOptions: { modules: true }` if you want the module id → name map used for nicer client logging. + +## 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=false&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` | `true` | Fall back to a full page reload when an update cannot be applied through HMR (e.g. recovering from a broken build). Set to `false` to keep HMR-only. | +| `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 + +`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 diff --git a/babel.config.js b/babel.config.js index 700d9fd7a..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,11 +6,28 @@ module.exports = (api) => { [ "@babel/preset-env", { + modules: false, targets: { - node: "20.9.0", + esmodules: true, + node: "0.12", }, }, ], ], + env: { + test: { + presets: [ + [ + "@babel/preset-env", + { + targets: { + node: "18.12.0", + }, + }, + ], + ], + plugins: ["@babel/plugin-transform-runtime"], + }, + }, }; }; 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 new file mode 100644 index 000000000..106ab1f8e --- /dev/null +++ b/client-src/index.js @@ -0,0 +1,361 @@ +/* global __resourceQuery, __webpack_public_path__ */ + +import stripAnsi from "strip-ansi"; + +import configureOverlay from "./overlay.js"; +import applyUpdate from "./process-update.js"; +import { log, setLogLevel } from "./utils/log.js"; + +/** @typedef {import("./utils/log.js").LogLevel} LogLevel */ + +/** + * @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 {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 + * @property {boolean} overlayWarnings show warnings in the overlay too + * @property {Record} ansiColors overrides for ANSI → HTML color mapping + */ + +/** @type {ClientOptions} */ +const options = { + path: "/__webpack_hmr", + timeout: 20 * 1000, + overlay: true, + reload: true, + logging: "info", + name: "", + autoConnect: true, + overlayStyles: {}, + overlayWarnings: false, + ansiColors: {}, +}; + +setLogLevel(options.logging); + +/** + * @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.logging) { + options.logging = /** @type {LogLevel} */ (overrides.logging); + } + if (overrides.name) { + options.name = overrides.name; + } + + 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"; + } + + setLogLevel(options.logging); +} + +/** + * @typedef {(event: { data: string }) => void} MessageListener + */ + +/** + * @returns {{ addMessageListener: (fn: MessageListener) => void }} event source wrapper + */ +function createEventSourceWrapper() { + /** @type {EventSource} */ + let source; + let lastActivity = Date.now(); + /** @type {MessageListener[]} */ + const listeners = []; + /** @type {ReturnType} */ + let timer; + + const handleOnline = () => { + log.info("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)); + }; + + /** + * Open the EventSource connection. + */ + 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] = createEventSourceWrapper(); + } + return window[WRAPPER_KEY][path]; +} + +/** + * Subscribe the message handler to the shared event source wrapper. + */ +function connect() { + getEventSourceWrapper().addMessageListener((event) => { + if (event.data === "💓") { + return; + } + try { + processMessage(JSON.parse(event.data)); + } catch (err) { + log.warn(`Invalid HMR message: ${event.data}\n${err}`); + } + }); +} + +/** + * @param {Record} overrides overrides + */ +export 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 = configureOverlay({ + ansiColors: options.ansiColors, + overlayStyles: options.overlayStyles, + }); + } + + /** @type {string | null} */ + let previousProblems = null; + + /** + * @param {"errors" | "warnings"} type problem type + * @param {HMRPayload} obj payload + */ + const logProblems = (type, obj) => { + const newProblems = obj[type].map(stripAnsi).join("\n"); + if (previousProblems === newProblems) { + return; + } + previousProblems = newProblems; + + const name = obj.name ? `'${obj.name}' ` : ""; + const title = `bundle ${name}has ${obj[type].length} ${type}`; + if (type === "errors") { + log.error(title); + log.error(newProblems); + } else { + log.warn(title); + log.warn(newProblems); + } + }; + + return { + cleanProblemsCache() { + previousProblems = null; + }, + problems(type, obj) { + logProblems(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": { + log.info(`bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilding`); + break; + } + case "built": + case "sync": { + 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) { + return; + } + let shouldApply = true; + if (obj.errors.length > 0) { + if (reporter) reporter.problems("errors", obj); + shouldApply = false; + } else if (obj.warnings.length > 0) { + if (reporter) { + shouldApply = reporter.problems("warnings", obj); + } + } else if (reporter) { + reporter.cleanProblemsCache(); + reporter.success(); + } + if (shouldApply) { + applyUpdate(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") { + 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", + ); + } else if (options.autoConnect) { + connect(); + } +} + +/** + * @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 new file mode 100644 index 000000000..7e8065cad --- /dev/null +++ b/client-src/overlay.js @@ -0,0 +1,266 @@ +import ansiHTML from "ansi-html-community"; +import { encode as encodeHtmlEntity } from "html-entities"; + +// The backdrop dims the page and centers the error card. +const clientOverlay = document.createElement("div"); +clientOverlay.id = "webpack-dev-middleware-hot-overlay"; + +// The card is the visible panel that holds the problem messages. +const overlayCard = document.createElement("div"); +clientOverlay.append(overlayCard); + +// A close (×) button pinned to the top-right corner of the card. +const closeButton = document.createElement("button"); +closeButton.type = "button"; +closeButton.textContent = "×"; +closeButton.setAttribute("aria-label", "Close"); +closeButton.style.position = "absolute"; +closeButton.style.top = "8px"; +closeButton.style.right = "12px"; +closeButton.style.border = "none"; +closeButton.style.background = "transparent"; +closeButton.style.color = "#999999"; +closeButton.style.fontSize = "22px"; +closeButton.style.lineHeight = "1"; +closeButton.style.cursor = "pointer"; +closeButton.style.padding = "0"; +closeButton.addEventListener("click", () => { + clear(); +}); + +// Dismiss the overlay when clicking the backdrop (but not the card itself). +clientOverlay.addEventListener("click", (event) => { + if (event.target === clientOverlay) { + clear(); + } +}); + +// Dismiss the overlay when pressing Escape. +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + clear(); + } +}); + +/** @type {Record} */ +const backdropStyles = { + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 9999, + // webpack "Outer Space" (#2B3A42), translucent. + background: "rgba(43,58,66,0.72)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "32px", + boxSizing: "border-box", + overflow: "auto", +}; + +/** @type {Record} */ +const styles = { + // Dark panel; the top accent bar color is set per problem type in showProblems. + position: "relative", + background: "#101619", + color: "#f2f2f2", + lineHeight: "1.6", + whiteSpace: "pre-wrap", + fontFamily: "Menlo, Consolas, 'Courier New', monospace", + fontSize: "14px", + width: "100%", + maxWidth: "960px", + maxHeight: "90vh", + margin: "auto", + padding: "28px 32px", + boxSizing: "border-box", + borderRadius: "8px", + borderTop: "3px solid #ff3348", + boxShadow: "0 8px 40px rgba(0,0,0,0.5)", + overflow: "auto", + direction: "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 | string[]} hex color (without `#`) for the given type + */ +function problemColor(type) { + /** @type {Record} */ + const problemColors = { + errors: colors.red, + warnings: colors.yellow, + }; + return problemColors[type] || colors.red; +} + +/** + * @param {"errors" | "warnings"} type problem type + * @returns {string} HTML span with a colored badge + */ +function problemType(type) { + const color = problemColor(type); + return ( + `' + + `${type.slice(0, -1).toUpperCase()}` + ); +} + +/** + * Highlight the offending line of a code frame — the one webpack marks with a + * leading `>` gutter — so it stands out from the surrounding context lines. + * @param {string} html message HTML (already entity-encoded, so `>` is `>`) + * @returns {string} HTML with the error line wrapped in a colored span + */ +function highlightCodeFrame(html) { + return html + .split("\n") + .map((line) => + /^\s*>/.test(line) + ? '' + + `${line}` + : line, + ) + .join("\n"); +} + +/** + * Highlight the file references webpack reports. The header reference (the one + * with a `line:col` location, e.g. `./src/render.js 7:2`) is rendered as a file + * chip; bare paths elsewhere are just underlined. + * @param {string} html message HTML + * @returns {string} HTML with file references styled + */ +function highlightFilePath(html) { + return html.replace( + /(\.{1,2}\/[\w./-]+\.\w+)(:\d+:\d+|\s\d+:\d+)?/g, + (match, filePath, location) => { + if (!location) { + return ( + '${match}` + ); + } + + return `${filePath}${location}\n`; + }, + ); +} + +/** + * Turn bare `http(s)` URLs in the message into clickable links. + * @param {string} html message HTML + * @returns {string} HTML with URLs wrapped in anchor tags + */ +function linkify(html) { + return html.replace(/https?:\/\/[^\s<>"]+/g, (url) => { + // Keep trailing punctuation (e.g. a sentence-ending dot) out of the href. + const trailing = url.match(/[.,;:!?)\]}]+$/); + const cut = trailing ? trailing[0] : ""; + const href = url.slice(0, url.length - cut.length); + return ( + `${href}${cut}` + ); + }); +} + +/** + * @param {"errors" | "warnings"} type problem type + * @param {string[]} lines messages to render + */ +export function showProblems(type, lines) { + // Accent the top bar with the problem color (red for errors, yellow for warnings). + overlayCard.style.borderTopColor = `#${problemColor(type)}`; + overlayCard.innerHTML = ""; + overlayCard.append(closeButton); + for (const line of lines) { + const msg = linkify( + highlightFilePath(highlightCodeFrame(ansiHTML(encodeHtmlEntity(line)))), + ); + const div = document.createElement("div"); + div.style.marginBottom = "20px"; + div.innerHTML = `${problemType(type)} in ${msg}`; + overlayCard.append(div); + } + + const hint = document.createElement("div"); + hint.style.marginTop = "4px"; + hint.style.paddingTop = "16px"; + hint.style.borderTop = "1px solid #465e69"; + hint.style.color = "#999999"; + hint.style.fontSize = "13px"; + hint.textContent = "Click outside, press Esc, or fix the code to dismiss."; + overlayCard.append(hint); + + if (document.body) { + document.body.append(clientOverlay); + } +} + +/** + * Remove the overlay container from the DOM. + */ +export function clear() { + if (clientOverlay.parentNode) { + clientOverlay.remove(); + } +} + +/** + * @param {{ ansiColors?: Record, overlayStyles?: Record }} options options + * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api + */ +export default function configureOverlay(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(backdropStyles)) { + /** @type {EXPECTED_ANY} */ + (clientOverlay.style)[key] = backdropStyles[key]; + } + + for (const key of Object.keys(styles)) { + /** @type {EXPECTED_ANY} */ + (overlayCard.style)[key] = styles[key]; + } + + return { + showProblems, + clear, + }; +} + +// 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..1d00d7c97 --- /dev/null +++ b/client-src/process-update.js @@ -0,0 +1,145 @@ +/* global __webpack_hash__ */ + +import { log } from "./utils/log.js"; + +const hot = import.meta.webpackHot; + +if (!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 }; + +/** @type {webpack.ApplyOptions} */ +const applyOptions = { + ignoreUnaccepted: true, + ignoreDeclined: true, + ignoreErrored: true, + onUnaccepted(event) { + log.warn( + `Ignored an update to unaccepted module ${event.chain.join(" -> ")}`, + ); + }, + onDeclined(event) { + log.warn( + `Ignored an update to declined module ${event.chain.join(" -> ")}`, + ); + }, + onErrored(event) { + log.error(event.error); + log.warn( + `Ignored an error while updating module ${event.moduleId} (${event.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 }} options client options + */ +export default function applyUpdate(hash, moduleMap, options) { + const { reload } = options; + + /** + * Trigger a full page reload when HMR cannot apply the update. + */ + function performReload() { + if (reload) { + log.warn("Reloading page"); + window.location.reload(); + } + } + + /** + * @param {Error} err error + */ + function handleError(err) { + if (hot.status() in failureStatuses) { + log.warn("Cannot check for update (Full reload needed)"); + log.warn(err.stack || err.message); + performReload(); + return; + } + log.warn(`Update check failed: ${err.stack || err.message}`); + } + + /** + * @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( + (moduleId) => !renewedModules || !renewedModules.includes(moduleId), + ); + + if (unacceptedModules.length > 0) { + 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 (!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()) { + log.info("App is up to date."); + } + } + + /** + * Ask webpack for the next chunk of HMR updates and apply them. + */ + function check() { + hot + .check(false) + .then((updatedModules) => { + if (!updatedModules) { + log.warn("Cannot find update (Full reload needed)"); + log.warn("(Probably because of restarting the server)"); + performReload(); + return undefined; + } + + return hot.apply(applyOptions).then((renewedModules) => { + if (!upToDate()) check(); + logUpdates(updatedModules, renewedModules); + }); + }) + .catch(handleError); + } + + 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 new file mode 100644 index 000000000..56f1d82d4 --- /dev/null +++ b/client-src/utils/log.js @@ -0,0 +1,18 @@ +// @ts-expect-error -- no published types for this entry point +import logger from "webpack/lib/logging/runtime.js"; + +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) + */ +export function setLogLevel(level) { + logger.configureDefaultLogger({ level }); +} + +setLogLevel(DEFAULT_LEVEL); + +export const log = logger.getLogger(LOGGER_NAME); diff --git a/cspell.config.json b/cspell.config.json new file mode 100644 index 000000000..46f70162e --- /dev/null +++ b/cspell.config.json @@ -0,0 +1,21 @@ +{ + "ignorePaths": [ + "/client/**", + "/dist/**", + "/node_modules/**", + "/coverage/**", + "/test/outputs/**", + "/test/fixtures/**", + "CHANGELOG.md", + "cspell.config.json" + ], + "words": [ + "Consolas", + "cspellcache", + "darkgrey", + "eslintcache", + "esmodules", + "noopener", + "noreferrer" + ] +} diff --git a/eslint.config.mjs b/eslint.config.mjs index c2e588884..f92345455 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,11 @@ -import { defineConfig } from "eslint/config"; +import { defineConfig, globalIgnores } from "eslint/config"; import configs from "eslint-config-webpack/configs.js"; export default defineConfig([ + globalIgnores(["client/**/*", "examples/**/*"]), { extends: [configs["recommended-dirty"]], + ignores: ["client-src/**/*"], }, { files: ["test/helpers/runner.js"], @@ -11,4 +13,13 @@ export default defineConfig([ "n/hashbang": "off", }, }, + { + files: ["client-src/**/*"], + extends: [configs["browser-outdated-recommended-module"]], + rules: { + // Function declarations are hoisted; allow referencing them ahead of + // their definition for readability. + "no-use-before-define": ["error", { functions: false }], + }, + }, ]); diff --git a/examples/hot/README.md b/examples/hot/README.md new file mode 100644 index 000000000..4602cd629 --- /dev/null +++ b/examples/hot/README.md @@ -0,0 +1,43 @@ +# Hot module replacement example + +A minimal [Express](https://expressjs.com/) server that uses +`webpack-dev-middleware` with the `hot` option to enable hot module replacement +over [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). + +## What it shows + +- Enabling HMR with a single `{ hot: true }` option. +- Wiring the browser runtime shipped as `webpack-dev-middleware/client` as a + webpack entry, together with `HotModuleReplacementPlugin`. +- Accepting updates with `module.hot.accept` so edits apply without a full + page reload. + +## Files + +| File | Purpose | +| ------------------- | --------------------------------------------------------------- | +| `server.js` | Express server mounting the middleware with `hot: true`. | +| `webpack.config.js` | Adds the client runtime entry and `HotModuleReplacementPlugin`. | +| `src/index.js` | App entry that accepts updates via `module.hot`. | +| `src/render.js` | The module you edit to see HMR in action. | +| `public/index.html` | Demo page that loads the bundle. | + +## Running + +From the repository root, build the package first so `dist/` and `client/` +exist (the example imports `webpack-dev-middleware` and +`webpack-dev-middleware/client` by name): + +```bash +npm run build +node examples/hot/server.js +``` + +Then open and edit `examples/hot/src/render.js`. The +page updates in place — no reload. + +Open your browser's console to see the client runtime log the HMR lifecycle +(`[webpack-dev-middleware] connected`, `App is up to date.`, …). Server-side +logs (`Client connected`, build status) are printed through webpack's +[infrastructure logger](https://webpack.js.org/configuration/other-options/#infrastructurelogging); +set `infrastructureLogging: { level: "log" }` in the webpack config to see them. diff --git a/examples/hot/public/index.html b/examples/hot/public/index.html new file mode 100644 index 000000000..1c4b64b43 --- /dev/null +++ b/examples/hot/public/index.html @@ -0,0 +1,13 @@ + + + + + + webpack-dev-middleware — hot example + + +
+ + + + diff --git a/examples/hot/server.js b/examples/hot/server.js new file mode 100644 index 000000000..a59e08d3f --- /dev/null +++ b/examples/hot/server.js @@ -0,0 +1,25 @@ +const path = require("path"); +const express = require("express"); +const webpack = require("webpack"); +const middleware = require("webpack-dev-middleware"); +const config = require("./webpack.config.js"); + +const compiler = webpack(config); +const app = express(); + +// `hot: true` serves a Server-Sent Events endpoint (at `/__webpack_hmr` by +// default) that the browser runtime subscribes to. The bundled files are still +// served from memory at `output.publicPath`. +app.use(middleware(compiler, { hot: true })); + +// Serve the demo page for any non-asset request. +app.get("/", (req, res) => { + res.sendFile(path.join(__dirname, "public", "index.html")); +}); + +const port = process.env.PORT || 3000; + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Example app listening on http://localhost:${port}`); +}); diff --git a/examples/hot/src/index.js b/examples/hot/src/index.js new file mode 100644 index 000000000..e0d0aa2a1 --- /dev/null +++ b/examples/hot/src/index.js @@ -0,0 +1,11 @@ +import { render } from "./render.js"; + +render(); + +// Accept updates to `render.js` and re-run it so the DOM reflects the change +// without reloading the page. +if (module.hot) { + module.hot.accept("./render.js", () => { + render(); + }); +} diff --git a/examples/hot/src/render.js b/examples/hot/src/render.js new file mode 100644 index 000000000..e4d3812bf --- /dev/null +++ b/examples/hot/src/render.js @@ -0,0 +1,8 @@ +export function render() { + const root = document.getElementById("root"); + + // Edit this string (or anything below) and save — the page updates in place, + // without a full reload, thanks to hot module replacement. + root.textContent = + "Hello from webpack-dev-middleware hot module replacement!"; +} diff --git a/examples/hot/webpack.config.js b/examples/hot/webpack.config.js new file mode 100644 index 000000000..84827bc84 --- /dev/null +++ b/examples/hot/webpack.config.js @@ -0,0 +1,17 @@ +const path = require("path"); +const webpack = require("webpack"); + +module.exports = { + mode: "development", + // `webpack-dev-middleware/client` is the small runtime that subscribes to the + // SSE endpoint served by the `hot` option and applies the updates. Add it as + // the first entry next to your application code. + entry: ["webpack-dev-middleware/client", "./src/index.js"], + context: __dirname, + output: { + path: path.resolve(__dirname, "dist"), + publicPath: "/", + filename: "main.js", + }, + plugins: [new webpack.HotModuleReplacementPlugin()], +}; diff --git a/lint-staged.config.js b/lint-staged.config.js index 301084338..79bdc6d12 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"], + "*.js": ["eslint --cache --fix --no-warn-ignored"], }; diff --git a/package-lock.json b/package-lock.json index 41191cf54..3a4ce53a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,19 @@ "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", "@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", @@ -35,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", @@ -45,6 +49,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 +75,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.29.7", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.29.7.tgz", @@ -1558,6 +1584,41 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "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.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", @@ -2838,22 +2899,137 @@ "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", + "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", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", "optional": true, @@ -2862,9 +3038,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -3943,6 +4119,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", @@ -5027,9 +5231,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -5189,6 +5393,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", @@ -5295,6 +5511,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", @@ -5727,6 +5950,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5741,6 +5967,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5755,6 +5984,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5769,6 +6001,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5783,6 +6018,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5797,6 +6035,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6122,6 +6363,16 @@ "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", @@ -6217,6 +6468,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", @@ -7753,6 +8016,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", @@ -7832,6 +8160,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", @@ -8257,6 +8592,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", @@ -10851,11 +11199,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", @@ -10947,6 +11307,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", @@ -11516,6 +11904,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", @@ -12269,6 +12664,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", @@ -12805,6 +13223,83 @@ "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", + "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", @@ -15026,6 +15521,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "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", @@ -15404,6 +15906,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", @@ -16287,6 +16802,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", @@ -16427,6 +16949,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", @@ -17237,7 +17772,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" @@ -17274,7 +17808,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" @@ -17391,6 +17924,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", @@ -17665,6 +18205,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", @@ -17722,6 +18282,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", @@ -18301,6 +18874,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", @@ -18413,6 +18999,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", @@ -18651,6 +19274,28 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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", @@ -18664,6 +19309,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", diff --git a/package.json b/package.json index b87a0ce09..ab9d21e10 100644 --- a/package.json +++ b/package.json @@ -16,25 +16,36 @@ }, "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: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", "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,15 +57,19 @@ "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", "@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", @@ -72,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", @@ -82,6 +97,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", diff --git a/src/hot.js b/src/hot.js new file mode 100644 index 000000000..dd3f4090b --- /dev/null +++ b/src/hot.js @@ -0,0 +1,340 @@ +/** @typedef {import("webpack").Compiler} Compiler */ +/** @typedef {import("webpack").MultiCompiler} MultiCompiler */ +/** @typedef {ReturnType} Logger */ +/** @typedef {import("webpack").Stats} Stats */ +/** @typedef {import("webpack").MultiStats} MultiStats */ +/** @typedef {import("webpack").StatsCompilation} StatsCompilation */ +/** @typedef {import("webpack").StatsError} StatsError */ +/** @typedef {import("webpack").StatsModule} StatsModule */ +/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ +/** @typedef {import("./index.js").ServerResponse} ServerResponse */ + +/** @typedef {NonNullable} StatsOptions */ + +/** + * @typedef {object} HotOptions + * @property {string=} path the path the SSE endpoint is served at + * @property {number=} heartbeat heartbeat interval in milliseconds + * @property {StatsOptions=} statsOptions webpack stats options used when serializing compilation results + */ + +/** + * @typedef {object} Payload + * @property {string} action action + * @property {string=} name name + * @property {number=} time time + * @property {string=} hash hash + * @property {string[]=} warnings warnings + * @property {string[]=} errors errors + * @property {Record=} modules modules + */ + +/** + * @typedef {object} EventStream + * @property {(req: IncomingMessage, res: ServerResponse) => void} handler attach a new client + * @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client + * @property {() => void} close end every client and stop the heartbeat + */ + +const HOT_DEFAULT_PATH = "/__webpack_hmr"; +const HOT_DEFAULT_HEARTBEAT = 10 * 1000; +const PLUGIN_NAME = "DevMiddleware"; + +/** + * @param {string | undefined} url url + * @param {string} expected expected pathname + * @returns {boolean} true when the url pathname matches the expected path + */ +function pathMatch(url, expected) { + if (!url) return false; + + try { + return new URL(url, "http://localhost").pathname === expected; + } catch { + return false; + } +} + +/** + * @param {number} heartbeat heartbeat interval in milliseconds + * @param {Logger} logger logger + * @returns {EventStream} event stream + */ +function createEventStream(heartbeat, logger) { + let clientId = 0; + /** @type {Map} */ + let clients = new Map(); + + /** + * @param {(client: ServerResponse) => void} fn each client callback + */ + const everyClient = (fn) => { + for (const client of clients.values()) { + fn(client); + } + }; + + const interval = setInterval(() => { + everyClient((client) => { + client.write("data: 💓\n\n"); + }); + }, heartbeat); + + // Don't block process exit on the heartbeat timer. + if (typeof interval.unref === "function") { + interval.unref(); + } + + return { + close() { + clearInterval(interval); + everyClient((client) => { + if (!client.writableEnded) { + client.end(); + } + }); + clients = new Map(); + }, + handler(req, res) { + /** @type {Record} */ + const headers = { + "Access-Control-Allow-Origin": "*", + "Content-Type": "text/event-stream;charset=utf-8", + "Cache-Control": "no-cache, no-transform", + // While behind nginx, the event stream should not be buffered: + // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering + "X-Accel-Buffering": "no", + }; + + const { httpVersion, socket } = req; + const isHttp1 = !(Number.parseInt(httpVersion, 10) >= 2); + + if (isHttp1) { + if (socket && typeof socket.setKeepAlive === "function") { + socket.setKeepAlive(true); + } + headers.Connection = "keep-alive"; + } + + res.writeHead(200, headers); + res.write("\n"); + + const id = clientId++; + clients.set(id, res); + logger.log(`Client connected (${clients.size} active)`); + + req.on("close", () => { + if (!res.writableEnded) { + res.end(); + } + clients.delete(id); + logger.log(`Client disconnected (${clients.size} active)`); + }); + }, + publish(payload) { + everyClient((client) => { + client.write(`data: ${JSON.stringify(payload)}\n\n`); + }); + }, + }; +} + +/** + * @param {(string | StatsError)[]} errors errors or warnings + * @returns {string[]} flat strings + */ +function formatErrors(errors) { + if (!errors || errors.length === 0) { + return []; + } + + if (typeof errors[0] === "string") { + return /** @type {string[]} */ (errors); + } + + return /** @type {StatsError[]} */ (errors).map((error) => { + const moduleName = error.moduleName || ""; + const loc = error.loc || ""; + + return `${moduleName} ${loc}\n${error.message}`; + }); +} + +/** + * @param {Stats} stats stats + * @param {StatsOptions} statsOptions stats options + * @returns {StatsCompilation} json stats with compilation reference attached + */ +function normalizeStats(stats, statsOptions) { + const statsJson = stats.toJson(statsOptions); + + if (stats.compilation) { + statsJson.compilation = stats.compilation; + } + + return statsJson; +} + +/** + * @param {StatsCompilation} stats normalized stats + * @returns {StatsCompilation[]} extracted bundles + */ +function extractBundles(stats) { + if (stats.modules) { + return [stats]; + } + + if (stats.children && stats.children.length > 0) { + return stats.children; + } + + return [stats]; +} + +/** + * @param {StatsModule[]} modules modules + * @returns {Record} module id to name map + */ +function buildModuleMap(modules) { + /** @type {Record} */ + const map = {}; + + for (const item of modules) { + map[/** @type {string | number} */ (item.id)] = /** @type {string} */ ( + item.name + ); + } + + return map; +} + +/** + * @param {string} action action + * @param {Stats | MultiStats} statsResult stats result + * @param {EventStream} eventStream event stream + * @param {StatsOptions | undefined} statsOptions stats options + */ +function publishStats(action, statsResult, eventStream, statsOptions) { + const resultStatsOptions = { + all: false, + hash: true, + timings: true, + errors: true, + warnings: true, + ...(statsOptions && typeof statsOptions === "object" ? statsOptions : {}), + }; + + /** @type {StatsCompilation[]} */ + let bundles; + + // Multi-compiler stats have stats for each child compiler. + if ("stats" in statsResult) { + bundles = statsResult.stats.flatMap((stats) => + extractBundles(normalizeStats(stats, resultStatsOptions)), + ); + } else { + bundles = extractBundles(normalizeStats(statsResult, resultStatsOptions)); + } + + for (const stats of bundles) { + let name = stats.name || ""; + + // Fallback to compilation name when there is a single bundle. + if (!name && stats.compilation) { + name = stats.compilation.name || ""; + } + + eventStream.publish({ + name, + action, + time: stats.time, + hash: stats.hash, + warnings: formatErrors(stats.warnings || []), + errors: formatErrors(stats.errors || []), + modules: buildModuleMap(stats.modules || []), + }); + } +} + +/** + * @typedef {object} HotInstance + * @property {string} path path the SSE endpoint is served at + * @property {(req: IncomingMessage, res: ServerResponse) => void} handle attach the request as a SSE client + * @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client + * @property {() => void} close end every client and detach the heartbeat + */ + +/** + * @param {Compiler | MultiCompiler} compiler compiler + * @param {HotOptions | true} userOptions options + * @returns {HotInstance} hot instance + */ +function createHot(compiler, userOptions) { + const options = userOptions === true ? {} : userOptions; + const path = options.path || HOT_DEFAULT_PATH; + const heartbeat = options.heartbeat || HOT_DEFAULT_HEARTBEAT; + const { statsOptions } = options; + const logger = compiler.getInfrastructureLogger("webpack-dev-middleware"); + + let eventStream = createEventStream(heartbeat, logger); + logger.log(`Hot module replacement enabled, serving events at "${path}"`); + /** @type {Stats | MultiStats | null} */ + let latestStats = null; + let closed = false; + + const onInvalid = () => { + if (closed) return; + + latestStats = null; + + eventStream.publish({ action: "building" }); + }; + + /** @param {Stats | MultiStats} statsResult stats result */ + const onDone = (statsResult) => { + if (closed) return; + + latestStats = statsResult; + publishStats("built", latestStats, eventStream, statsOptions); + }; + + compiler.hooks.invalid.tap(PLUGIN_NAME, onInvalid); + compiler.hooks.done.tap(PLUGIN_NAME, onDone); + + return { + path, + handle(req, res) { + if (closed) return; + + eventStream.handler(req, res); + + if (latestStats) { + publishStats("sync", latestStats, eventStream, statsOptions); + } + }, + publish(payload) { + if (closed) return; + + eventStream.publish(payload); + }, + close() { + if (closed) return; + + // Can't remove compiler plugins, so we set a flag and noop if closed. + // https://github.com/webpack/tapable/issues/32#issuecomment-350644466 + closed = true; + eventStream.close(); + eventStream = /** @type {EventStream} */ (/** @type {unknown} */ (null)); + }, + }; +} + +module.exports = createHot; +module.exports.HOT_DEFAULT_HEARTBEAT = HOT_DEFAULT_HEARTBEAT; +module.exports.HOT_DEFAULT_PATH = HOT_DEFAULT_PATH; +module.exports.buildModuleMap = buildModuleMap; +module.exports.createEventStream = createEventStream; +module.exports.createHot = createHot; +module.exports.formatErrors = formatErrors; +module.exports.pathMatch = pathMatch; +module.exports.publishStats = publishStats; diff --git a/src/index.js b/src/index.js index 053d3b45e..429965524 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,14 @@ const fs = require("node:fs"); const path = require("node:path"); +// `stream/web` is flagged experimental by the n/eslint plugin until Node 21, +// but `ReadableStream` is stable in practice on Node 20.9+ (our minimum) and +// already pulled in by hono/web frameworks we integrate with. +// eslint-disable-next-line n/no-unsupported-features/node-builtins +const { ReadableStream } = require("node:stream/web"); const memfs = require("memfs"); const mime = require("mime-types"); +const { createHot } = require("./hot"); const middleware = require("./middleware"); const { nodeReadableToWebStream } = require("./utils"); @@ -16,6 +22,8 @@ const noop = () => {}; /** @typedef {import("webpack").MultiStats} MultiStats */ /** @typedef {import("fs").ReadStream} ReadStream */ /** @typedef {import("./middleware").FilenameWithExtra} FilenameWithExtra */ +/** @typedef {import("./hot").HotOptions} HotOptions */ +/** @typedef {import("./hot").HotInstance} HotInstance */ // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ @@ -76,6 +84,7 @@ const noop = () => {}; * @property {Watching | MultiWatching} watching watching * @property {Logger} logger logger * @property {OutputFileSystem} outputFileSystem output file system + * @property {HotInstance=} hot hot module replacement instance */ /** @@ -112,6 +121,7 @@ const noop = () => {}; * @property {(boolean | number | string | { maxAge?: number, immutable?: boolean })=} cacheControl options to generate cache headers * @property {boolean=} cacheImmutable is cache immutable * @property {boolean=} forwardError forward error to next middleware + * @property {(boolean | HotOptions)=} hot enable hot module replacement */ /** @@ -504,6 +514,10 @@ function wdm(compiler, options = {}, isPlugin = false) { compiler.hooks.invalid.tap(PLUGIN_NAME, invalid); compiler.hooks.done.tap(PLUGIN_NAME, done); + if (options.hot) { + context.hot = createHot(compiler, options.hot === true ? {} : options.hot); + } + const compilersToModify = isMultipleCompiler(compiler) ? compiler.compilers.filter((item) => item.options.devServer !== false) : [compiler]; @@ -600,6 +614,9 @@ function wdm(compiler, options = {}, isPlugin = false) { }; instance.close = (callback = noop) => { + if (filledContext.hot) { + filledContext.hot.close(); + } filledContext.watching.close(callback); }; @@ -935,6 +952,33 @@ function honoWrapper(compiler, options = {}, usePlugin = false) { let body; let isFinished = false; + // Hot middleware writes raw chunks via `res.writeHead` / `res.write` / `res.end`. + // Hono's response object is Web API-style, so we shim those methods on top of a + // Web ReadableStream that hono streams back to the client. + /** @type {ReadableStreamDefaultController | undefined} */ + let sseController; + let sseClosed = false; + /** @type {(() => void)[]} */ + const closeHandlers = []; + + /** + * @param {string} event event name + * @param {() => void} handler handler + * @returns {EXPECTED_ANY} req + */ + const reqOn = (event, handler) => { + if (event === "close") { + closeHandlers.push(handler); + if (sseClosed) handler(); + } + return req; + }; + + if (typeof (/** @type {EXPECTED_ANY} */ (req).on) !== "function") { + /** @type {EXPECTED_ANY} */ + (req).on = reqOn; + } + try { await new Promise( /** @@ -942,6 +986,72 @@ function honoWrapper(compiler, options = {}, usePlugin = false) { * @param {(reason?: Error) => void} reject reject */ (resolve, reject) => { + /** @type {EXPECTED_ANY} */ + (res).writeHead = + /** + * @param {number} statusCode status code + * @param {Record=} headers headers + */ + (statusCode, headers) => { + status = statusCode; + + if (headers) { + for (const name of Object.keys(headers)) { + context.res.headers.append(name, String(headers[name])); + } + } + + body = new ReadableStream({ + start(controller) { + sseController = controller; + }, + cancel() { + sseClosed = true; + for (const fn of closeHandlers) fn(); + }, + }); + isFinished = true; + resolve(); + }; + + /** @type {EXPECTED_ANY} */ + (res).write = + /** + * @param {string | Buffer} chunk chunk to write + * @returns {boolean} true when written + */ + (chunk) => { + if (!sseController || sseClosed) return false; + try { + sseController.enqueue( + typeof chunk === "string" + ? new TextEncoder().encode(chunk) + : chunk, + ); + } catch { + return false; + } + return true; + }; + + /** @type {EXPECTED_ANY} */ + (res).end = () => { + if (!sseController || sseClosed) return; + sseClosed = true; + try { + sseController.close(); + } catch { + // already closed + } + }; + + Object.defineProperty(res, "writableEnded", { + configurable: true, + get() { + return sseClosed; + }, + }); + /** * @param {import("fs").ReadStream} stream readable stream */ diff --git a/src/middleware.js b/src/middleware.js index e92ec449c..6f84d8a5b 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -4,6 +4,8 @@ const querystring = require("node:querystring"); const mime = require("mime-types"); const onFinishedStream = require("on-finished"); +const { pathMatch: hotPathMatch } = require("./hot"); + const { createReadStreamOrReadFile, destroyStream, @@ -376,6 +378,12 @@ function ready(context, callback, req) { */ function wrapper(context) { return async function middleware(req, res, next) { + // Intercept Server-Sent Events handshake when the `hot` option is enabled. + if (context.hot && hotPathMatch(getRequestURL(req), context.hot.path)) { + context.hot.handle(req, res); + return; + } + /** * @param {NodeJS.ErrnoException=} err an error * @returns {Promise} diff --git a/src/options.json b/src/options.json index 1e83adaa1..950051f7f 100644 --- a/src/options.json +++ b/src/options.json @@ -177,6 +177,43 @@ "description": "Enable or disable forwarding errors to next middleware.", "link": "https://github.com/webpack/webpack-dev-middleware#forwarderrors", "type": "boolean" + }, + "hot": { + "description": "Enable hot module replacement via a Server-Sent Events endpoint.", + "link": "https://github.com/webpack/webpack-dev-middleware#hot", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "description": "The path the SSE endpoint is served at.", + "type": "string", + "minLength": 1 + }, + "heartbeat": { + "description": "Heartbeat interval (in milliseconds) used to keep the SSE connection alive.", + "type": "number", + "minimum": 0 + }, + "statsOptions": { + "description": "Webpack stats options used when serializing compilation results.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "additionalProperties": true + } + ] + } + } + } + ] } }, "additionalProperties": false 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/__snapshots__/validation-options.test.js.snap.webpack5 b/test/__snapshots__/validation-options.test.js.snap.webpack5 index ec12f26e9..f95a41978 100644 --- a/test/__snapshots__/validation-options.test.js.snap.webpack5 +++ b/test/__snapshots__/validation-options.test.js.snap.webpack5 @@ -75,6 +75,48 @@ exports[`validation should throw an error on the "headers" option with "true" va * options.headers should be an instance of function." `; +exports[`validation should throw an error on the "hot" option with "{"heartbeat":-1}" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot.heartbeat should be >= 0. + -> Heartbeat interval (in milliseconds) used to keep the SSE connection alive." +`; + +exports[`validation should throw an error on the "hot" option with "{"path":""}" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot.path should be a non-empty string. + -> The path the SSE endpoint is served at." +`; + +exports[`validation should throw an error on the "hot" option with "{"unknown":true}" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot has an unknown property 'unknown'. These properties are valid: + object { path?, heartbeat?, statsOptions? }" +`; + +exports[`validation should throw an error on the "hot" option with "0" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot should be one of these: + boolean | object { path?, heartbeat?, statsOptions? } + -> Enable hot module replacement via a Server-Sent Events endpoint. + -> Read more at https://github.com/webpack/webpack-dev-middleware#hot + Details: + * options.hot should be a boolean. + * options.hot should be an object: + object { path?, heartbeat?, statsOptions? }" +`; + +exports[`validation should throw an error on the "hot" option with "foo" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot should be one of these: + boolean | object { path?, heartbeat?, statsOptions? } + -> Enable hot module replacement via a Server-Sent Events endpoint. + -> Read more at https://github.com/webpack/webpack-dev-middleware#hot + Details: + * options.hot should be a boolean. + * options.hot should be an object: + object { path?, heartbeat?, statsOptions? }" +`; + exports[`validation should throw an error on the "index" option with "{}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.index should be one of these: diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 000000000..e310b30b0 --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,701 @@ +/** + * @jest-environment jsdom + */ + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** @type {EXPECTED_ANY} */ +let processUpdate; +/** @type {{ showProblems: jest.Mock, clear: jest.Mock }} */ +let clientOverlay; + +jest.mock("../client-src/process-update", () => { + const fn = jest.fn(); + return fn; +}); + +jest.mock("../client-src/overlay", () => { + const overlay = { showProblems: jest.fn(), clear: jest.fn() }; + const factory = jest.fn(() => overlay); + factory.__getOverlay = () => overlay; + return factory; +}); + +/** + * @param {EXPECTED_ANY} obj message payload + * @returns {{ data: string }} fake SSE event + */ +function makeMessage(obj) { + return { data: typeof obj === "string" ? obj : JSON.stringify(obj) }; +} + +/** + * 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); + } + EventSourceStub.instances = instances; + EventSourceStub.lastInstance = () => instances[instances.length - 1]; + return EventSourceStub; +} + +/** + * 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(); + + 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(); + }); + + describe("with default options", () => { + let EventSourceStub; + let 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, "error").mockImplementation(() => {}); + client = loadClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("connects to /__webpack_hmr", () => { + expect(EventSourceStub.instances).toHaveLength(1); + expect(EventSourceStub.instances[0].url).toBe("/__webpack_hmr"); + }); + + it("triggers webpack on successful builds", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).toHaveBeenCalledTimes(1); + }); + + it("passes reload:true to the updater by default", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).toHaveBeenCalledWith( + "1234567890abcdef", + expect.anything(), + expect.objectContaining({ reload: true }), + ); + }); + + it("triggers webpack on successful syncs", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "sync", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).toHaveBeenCalledTimes(1); + }); + + 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); + }); + + 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" }); + }); + + 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(); + }); + + 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("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", + ]); + expect(console.error.mock.calls).toMatchSnapshot(); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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(); + // 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", () => { + 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); + }); + }); + + describe("with overlayWarnings: true", () => { + let EventSourceStub; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + loadClient("?overlayWarnings=true"); + }); + + 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", + ]); + expect(console.error.mock.calls).toMatchSnapshot(); + }); + + 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", + ]); + }); + + 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"], + ); + }); + }); + + describe("with name option", () => { + let EventSourceStub; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + loadClient("?name=test"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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(); + }); + }); + + describe("with reload disabled", () => { + let EventSourceStub; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + loadClient("?reload=false"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("passes reload:false to the updater", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(processUpdate).toHaveBeenCalledWith( + "1234567890abcdef", + expect.anything(), + expect.objectContaining({ reload: false }), + ); + }); + }); + + describe("connection lifecycle", () => { + let EventSourceStub; + let client; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); + 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("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); + }); + + 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); + // 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 is scheduled after `options.timeout` (20s). + jest.advanceTimersByTime(20 * 1000); + expect(EventSourceStub.instances).toHaveLength(2); + }); + }); + + 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 (including the [webpack-dev-middleware] prefix) by default", () => { + loadClient(); + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: [], + modules: [], + }), + ); + expect(console.info.mock.calls).toMatchSnapshot(); + }); + + 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", () => { + loadClient("?logging=warn&overlayWarnings=true"); + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: ["something"], + modules: [], + }), + ); + expect(console.info).not.toHaveBeenCalled(); + expect(console.warn.mock.calls).toMatchSnapshot(); + }); + + it("logging=error silences info and warn but keeps error", () => { + 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).toMatchSnapshot(); + }); + }); + + 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/); + }); + }); +}); diff --git a/test/fixtures/webpack.array.warning.config.js b/test/fixtures/webpack.array.warning.config.js index be0a5557f..415685d4b 100644 --- a/test/fixtures/webpack.array.warning.config.js +++ b/test/fixtures/webpack.array.warning.config.js @@ -9,7 +9,7 @@ module.exports = [ entry: './warning.js', output: { filename: 'bundle.js', - path: path.resolve(__dirname, '../../outputs/array-warning/js1'), + path: path.resolve(__dirname, '../outputs/array-warning/js1'), publicPath: '/static-one/', }, plugins: [ diff --git a/test/helpers/sse.js b/test/helpers/sse.js new file mode 100644 index 000000000..23c98883c --- /dev/null +++ b/test/helpers/sse.js @@ -0,0 +1,108 @@ +import http from "node:http"; + +/** + * @typedef {object} SseEvent + * @property {string=} action event action (building/built/sync/custom) + * @property {string=} name compilation name + * @property {string=} hash compilation hash + * @property {number=} time build time in ms + * @property {string[]=} errors errors + * @property {string[]=} warnings warnings + * @property {Record=} modules module id → name map + */ + +/** + * Open the SSE endpoint just long enough to capture the response headers, then + * force-close it (the stream never ends on its own). + * @param {import("supertest").Test} pending pending supertest request + * @returns {Promise} resolved response + */ +export async function readSseHandshake(pending) { + return new Promise((resolve, reject) => { + pending + .buffer(false) + .parse((res, cb) => { + res.on("data", () => {}); + res.on("end", () => cb(null, "")); + // SSE never closes on its own; force-close after we have headers. + setTimeout(() => res.destroy(), 50); + }) + .end((err, res) => { + if (err && err.code !== "ECONNRESET") { + reject(err); + return; + } + resolve(res); + }); + }); +} + +/** + * Read the SSE stream directly over HTTP (supertest buffers streaming bodies + * differently per framework), then close it and return the parsed payloads + * (heartbeats and the initial handshake newline are skipped). + * @param {import("node:http").Server & { info?: { port: number }, listener?: import("node:http").Server }} listeningServer the running server + * @param {string} requestPath SSE endpoint path + * @param {number} waitMs how long to collect events before closing + * @returns {Promise} parsed event payloads + */ +export async function readSseEvents( + listeningServer, + requestPath, + waitMs = 250, +) { + const httpServer = listeningServer.listener || listeningServer; + const address = + typeof httpServer.address === "function" ? httpServer.address() : null; + const port = + address && typeof address === "object" && address.port + ? address.port + : listeningServer.info && listeningServer.info.port + ? listeningServer.info.port + : 3000; + + return new Promise((resolve, reject) => { + let raw = ""; + const pending = http.get( + { host: "127.0.0.1", port, path: requestPath }, + (res) => { + res.setEncoding("utf8"); + res.on("data", (chunk) => { + raw += chunk; + }); + }, + ); + pending.on("error", (err) => { + if (err.code !== "ECONNRESET") reject(err); + }); + + setTimeout(() => { + pending.destroy(); + + /** @type {SseEvent[]} */ + const events = []; + for (const block of raw.split("\n\n")) { + const line = block.split("\n").find((item) => item.startsWith("data:")); + if (!line) continue; + const payload = line.slice("data:".length).trim(); + if (!payload || payload === "💓") continue; + try { + events.push(JSON.parse(payload)); + } catch { + // Ignore non-JSON frames. + } + } + resolve(events); + }, waitMs); + }); +} + +/** + * @param {{ waitUntilValid: (callback: () => void) => void }} devMiddleware middleware instance + * @returns {Promise} resolves once the first compilation is valid + */ +export async function waitUntilValid(devMiddleware) { + return new Promise((resolve) => { + devMiddleware.waitUntilValid(() => resolve()); + }); +} diff --git a/test/hot.test.js b/test/hot.test.js new file mode 100644 index 000000000..9fb4f01bd --- /dev/null +++ b/test/hot.test.js @@ -0,0 +1,388 @@ +import createHot, { + buildModuleMap, + createEventStream, + formatErrors, + pathMatch, +} from "../src/hot"; + +jest.spyOn(globalThis.console, "log").mockImplementation(); + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_OBJECT */ + +/** @type {EXPECTED_OBJECT} */ +const noopLogger = { log() {} }; + +/** + * Build a minimal compiler-like object so we can drive `invalid`/`done` from + * the test without spinning up webpack. + * @param {EXPECTED_OBJECT=} logger logger returned by getInfrastructureLogger + * @returns {{ hooks: EXPECTED_OBJECT, emitInvalid: () => void, emitDone: (stats: EXPECTED_OBJECT) => void }} fake compiler + */ +function makeFakeCompiler(logger = noopLogger) { + const invalidTaps = []; + const doneTaps = []; + return { + hooks: { + invalid: { tap: (_name, fn) => invalidTaps.push(fn) }, + done: { tap: (_name, fn) => doneTaps.push(fn) }, + }, + getInfrastructureLogger: () => logger, + emitInvalid() { + for (const fn of invalidTaps) fn(); + }, + emitDone(stats) { + for (const fn of doneTaps) fn(stats); + }, + }; +} + +/** + * Build a minimal Stats-like object that satisfies `publishStats`. + * @param {EXPECTED_OBJECT=} overrides field overrides applied on top of the defaults + * @returns {EXPECTED_OBJECT} fake stats + */ +function makeFakeStats(overrides = {}) { + return { + toJson() { + return { + time: 5, + hash: "abc", + warnings: [], + errors: [], + modules: [], + ...overrides, + }; + }, + compilation: undefined, + }; +} + +/** + * Attach a fake response to the given event stream and collect every chunk + * written to it. + * @param {EXPECTED_OBJECT} eventStream stream returned by createEventStream or hot instance + * @param {{ httpVersion?: string }=} reqOverrides overrides for the fake req + * @returns {{ res: EXPECTED_OBJECT, writes: string[], headers: EXPECTED_OBJECT }} captured state + */ +function attachClient(eventStream, reqOverrides = {}) { + const writes = []; + /** @type {EXPECTED_OBJECT | undefined} */ + let headers; + const res = { + writableEnded: false, + write: (chunk) => { + writes.push(chunk); + }, + writeHead: (_code, h) => { + headers = h; + }, + end: () => { + res.writableEnded = true; + }, + }; + const req = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: () => {}, + ...reqOverrides, + }; + eventStream.handler(req, res); + return { res, writes, headers }; +} + +describe("hot middleware (unit)", () => { + describe("pathMatch", () => { + it("matches exact pathname", () => { + expect(pathMatch("/__webpack_hmr", "/__webpack_hmr")).toBe(true); + }); + + it("strips query string", () => { + expect(pathMatch("/__webpack_hmr?name=app", "/__webpack_hmr")).toBe(true); + }); + + it("returns false on mismatch", () => { + expect(pathMatch("/bundle.js", "/__webpack_hmr")).toBe(false); + }); + + it("returns false when the path has a trailing segment", () => { + expect(pathMatch("/__webpack_hmr/extra", "/__webpack_hmr")).toBe(false); + }); + + it("returns false when url is undefined", () => { + expect(pathMatch(undefined, "/__webpack_hmr")).toBe(false); + }); + + it("returns false on malformed urls without throwing", () => { + expect(pathMatch("http://[bad", "/__webpack_hmr")).toBe(false); + }); + }); + + describe("formatErrors", () => { + it("returns an empty array when input is empty", () => { + expect(formatErrors([])).toEqual([]); + }); + + it("passes through string errors unchanged", () => { + expect(formatErrors(["boom"])).toEqual(["boom"]); + }); + + it("formats webpack 5 error objects", () => { + expect( + formatErrors([{ moduleName: "./foo.js", loc: "1:1", message: "boom" }]), + ).toEqual(["./foo.js 1:1\nboom"]); + }); + + it("tolerates missing moduleName and loc", () => { + expect(formatErrors([{ message: "boom" }])).toEqual([" \nboom"]); + }); + }); + + describe("buildModuleMap", () => { + it("maps id to name", () => { + expect( + buildModuleMap([ + { id: 1, name: "./a.js" }, + { id: 2, name: "./b.js" }, + ]), + ).toEqual({ 1: "./a.js", 2: "./b.js" }); + }); + }); + + describe("createEventStream", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("emits a heartbeat at the configured interval", () => { + const stream = createEventStream(1000, noopLogger); + const writes = []; + const fakeRes = { + writableEnded: false, + write: (chunk) => writes.push(chunk), + writeHead: () => {}, + end: () => {}, + }; + const fakeReq = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: () => {}, + }; + + stream.handler(fakeReq, fakeRes); + writes.length = 0; + jest.advanceTimersByTime(1000); + + expect(writes.some((w) => w.includes("💓"))).toBe(true); + + stream.close(); + }); + + it("publishes JSON payloads to attached clients", () => { + const stream = createEventStream(5000, noopLogger); + const writes = []; + const fakeRes = { + writableEnded: false, + write: (chunk) => writes.push(chunk), + writeHead: () => {}, + end: () => {}, + }; + const fakeReq = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: () => {}, + }; + + stream.handler(fakeReq, fakeRes); + stream.publish({ action: "built", hash: "abc" }); + + expect(writes.some((w) => w.includes('"action":"built"'))).toBe(true); + expect(writes.some((w) => w.includes('"hash":"abc"'))).toBe(true); + + stream.close(); + }); + + it("close ends connected clients", () => { + const stream = createEventStream(5000, noopLogger); + let ended = false; + const fakeRes = { + writableEnded: false, + write: () => {}, + writeHead: () => {}, + end: () => { + ended = true; + }, + }; + const fakeReq = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: () => {}, + }; + + stream.handler(fakeReq, fakeRes); + stream.close(); + + expect(ended).toBe(true); + }); + + it("sets Connection: keep-alive for HTTP/1 clients", () => { + const stream = createEventStream(5000, noopLogger); + const { headers } = attachClient(stream, { httpVersion: "1.1" }); + expect(headers.Connection).toBe("keep-alive"); + stream.close(); + }); + + it("does not set Connection: keep-alive for HTTP/2 clients", () => { + const stream = createEventStream(5000, noopLogger); + const { headers } = attachClient(stream, { httpVersion: "2.0" }); + expect(headers.Connection).toBeUndefined(); + stream.close(); + }); + + it("broadcasts events to every attached client", () => { + const stream = createEventStream(5000, noopLogger); + const clients = [ + attachClient(stream), + attachClient(stream), + attachClient(stream), + ]; + + for (const c of clients) c.writes.length = 0; + + stream.publish({ action: "built", hash: "xyz" }); + + for (const c of clients) { + expect(c.writes.some((w) => w.includes('"hash":"xyz"'))).toBe(true); + } + + stream.close(); + }); + + it("logs client connect and disconnect with the active count", () => { + const messages = []; + const stream = createEventStream(5000, { + log: (message) => messages.push(message), + }); + /** @type {() => void} */ + let closeHandler = () => {}; + const fakeReq = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: (event, fn) => { + if (event === "close") closeHandler = fn; + }, + }; + const fakeRes = { + writableEnded: false, + write: () => {}, + writeHead: () => {}, + end: () => {}, + }; + + stream.handler(fakeReq, fakeRes); + expect(messages).toContain("Client connected (1 active)"); + + closeHandler(); + expect(messages).toContain("Client disconnected (0 active)"); + + stream.close(); + }); + }); +}); + +describe("createHot", () => { + it("logs that HMR is enabled with the served path", () => { + const messages = []; + const compiler = makeFakeCompiler({ + log: (message) => messages.push(message), + }); + + const hot = createHot(compiler, { path: "/__hmr" }); + + expect(messages).toContain( + 'Hot module replacement enabled, serving events at "/__hmr"', + ); + + hot.close(); + }); + + it("exposes publish() so callers can broadcast custom payloads", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, {}); + const { writes } = attachClient({ handler: hot.handle }); + + hot.publish({ action: "custom-thing", payload: 42 }); + + expect( + writes.some( + (w) => + w.includes('"action":"custom-thing"') && w.includes('"payload":42'), + ), + ).toBe(true); + + hot.close(); + }); + + it("sends a sync payload to a client that connects after a build", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, {}); + + // A build finishes BEFORE anyone connects. + compiler.emitDone(makeFakeStats()); + + const { writes } = attachClient({ handler: hot.handle }); + + expect(writes.some((w) => w.includes('"action":"sync"'))).toBe(true); + + hot.close(); + }); + + it("falls back to compilation.name when stats name is empty", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, {}); + const { writes } = attachClient({ handler: hot.handle }); + + compiler.emitDone({ + toJson() { + return { + time: 1, + hash: "h", + warnings: [], + errors: [], + modules: [], + // no `name` here + }; + }, + compilation: { name: "child-bundle" }, + }); + + expect( + writes.some( + (w) => + w.includes('"name":"child-bundle"') && w.includes('"action":"built"'), + ), + ).toBe(true); + + hot.close(); + }); + + it("stops publishing after close() even if the compiler still emits", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, {}); + const { writes } = attachClient({ handler: hot.handle }); + + hot.close(); + writes.length = 0; + + // After close, the tap callbacks remain registered (tapable does not + // support removal), but they must noop instead of writing to clients. + compiler.emitInvalid(); + compiler.emitDone(makeFakeStats()); + + expect(writes).toHaveLength(0); + }); +}); diff --git a/test/middleware.test.js b/test/middleware.test.js index 8daed5c93..584bf31e1 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -32,6 +32,8 @@ import getCompiler from "./helpers/getCompiler"; import getCompilerHooks from "./helpers/getCompilerHooks"; +import { readSseEvents, readSseHandshake, waitUntilValid } from "./helpers/sse"; + // Suppress unnecessary stats output jest.spyOn(globalThis.console, "log").mockImplementation(); @@ -6946,4 +6948,192 @@ describe.each([ }); }); }); + + describe("hot", () => { + let instance; + let server; + let req; + + afterEach(async () => { + await close(server, instance); + }); + + it("serves SSE on the default hot path", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + const res = await readSseHandshake(req.get("/__webpack_hmr")); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + expect(res.headers["cache-control"]).toBe("no-cache, no-transform"); + expect(res.headers["x-accel-buffering"]).toBe("no"); + }); + + it("respects a custom hot path", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + hot: { path: "/__custom_hmr" }, + }, + ); + + const res = await readSseHandshake(req.get("/__custom_hmr")); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + }); + + it("does not intercept non-hot requests", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + const response = await req.get("/bundle.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"] || "").not.toMatch( + /text\/event-stream/, + ); + }); + + it("does not intercept the default hot path when hot is disabled", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + {}, + ); + + const response = await req.get("/__webpack_hmr"); + + expect(response.headers["content-type"] || "").not.toMatch( + /text\/event-stream/, + ); + }); + + it("works with MultiCompiler", async () => { + const compiler = getCompiler(webpackMultiConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + const res = await readSseHandshake(req.get("/__webpack_hmr")); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + }); + + it("sends a permissive CORS header", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + const res = await readSseHandshake(req.get("/__webpack_hmr")); + + expect(res.headers["access-control-allow-origin"]).toBe("*"); + }); + + describe("SSE payload", () => { + it("sends a sync event with the build hash to a client connecting after a build", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + await waitUntilValid(instance); + + const events = await readSseEvents(server, "/__webpack_hmr"); + const sync = events.find((event) => event.action === "sync"); + + expect(sync).toBeDefined(); + expect(typeof sync.hash).toBe("string"); + expect(sync.errors).toEqual([]); + }); + + it("sends a sync event per compilation for a MultiCompiler", async () => { + const compiler = getCompiler(webpackMultiConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + await waitUntilValid(instance); + + const events = await readSseEvents(server, "/__webpack_hmr"); + const syncs = events.filter((event) => event.action === "sync"); + + expect(syncs).toHaveLength(webpackMultiConfig.length); + for (const sync of syncs) { + expect(typeof sync.hash).toBe("string"); + } + }); + + it("streams building and built events on recompilation", async () => { + const compiler = getCompiler({ ...webpackConfig, watch: true }); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + await waitUntilValid(instance); + + const pending = readSseEvents(server, "/__webpack_hmr", 2000); + // Trigger a rebuild once the client is listening. + setTimeout(() => instance.invalidate(), 150); + const events = await pending; + const actions = events.map((event) => event.action); + + expect(actions).toContain("building"); + expect(actions).toContain("built"); + }); + + it("broadcasts to every connected client", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + await waitUntilValid(instance); + + const [first, second] = await Promise.all([ + readSseEvents(server, "/__webpack_hmr"), + readSseEvents(server, "/__webpack_hmr"), + ]); + + expect(first.some((event) => event.action === "sync")).toBe(true); + expect(second.some((event) => event.action === "sync")).toBe(true); + }); + }); + }); }); diff --git a/test/overlay.test.js b/test/overlay.test.js new file mode 100644 index 000000000..70f866e4d --- /dev/null +++ b/test/overlay.test.js @@ -0,0 +1,183 @@ +/** + * @jest-environment jsdom + */ + +import configureOverlay, { clear, showProblems } from "../client-src/overlay"; + +const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; + +/** + * @returns {HTMLElement | null} the overlay backdrop, if mounted + */ +function getOverlay() { + return document.getElementById(OVERLAY_ID); +} + +/** + * @returns {HTMLElement} the visible card element inside the backdrop + */ +function getCard() { + return /** @type {HTMLElement} */ ( + /** @type {HTMLElement} */ (getOverlay()).firstElementChild + ); +} + +/** + * @param {string} color expected normalized color + * @returns {HTMLElement | undefined} the first span rendered in that text color + */ +function findSpanByColor(color) { + return [...getCard().querySelectorAll("span")].find( + (span) => span.style.color === color, + ); +} + +describe("overlay", () => { + afterEach(() => { + clear(); + }); + + describe("showProblems", () => { + it("mounts the overlay on the document body", () => { + expect(getOverlay()).toBeNull(); + showProblems("errors", ["./a.js 1:1\nboom"]); + expect(getOverlay()).not.toBeNull(); + }); + + it("renders an ERROR badge in the error color for errors", () => { + showProblems("errors", ["boom"]); + const badge = getCard().querySelector("span"); + expect(badge.textContent).toBe("ERROR"); + expect(badge.style.backgroundColor).toBe("rgb(255, 51, 72)"); + }); + + it("renders a WARNING badge in the warning color for warnings", () => { + showProblems("warnings", ["careful"]); + const badge = getCard().querySelector("span"); + expect(badge.textContent).toBe("WARNING"); + expect(badge.style.backgroundColor).toBe("rgb(255, 211, 14)"); + }); + + it("colors the top accent bar red for errors and yellow for warnings", () => { + showProblems("errors", ["boom"]); + expect(getCard().style.borderTopColor).toBe("rgb(255, 51, 72)"); + showProblems("warnings", ["careful"]); + expect(getCard().style.borderTopColor).toBe("rgb(255, 211, 14)"); + }); + + it("highlights the file path and leaves the location uncolored", () => { + showProblems("errors", ["./src/render.js 7:2\nModule parse failed"]); + const pathSpan = [...getCard().querySelectorAll("span")].find( + (span) => span.textContent === "./src/render.js", + ); + expect(pathSpan).toBeDefined(); + expect(pathSpan.style.color).toBe("rgb(141, 214, 249)"); + // The `7:2` location is rendered as plain text, not inside the span. + expect(getCard().textContent).toContain("./src/render.js 7:2"); + }); + + it("highlights the offending code-frame line", () => { + showProblems("errors", ["./a.js 1:1\n> 1 | const x =\n | ^"]); + expect(findSpanByColor("rgb(255, 107, 107)")).toBeDefined(); + }); + + it("turns URLs into links that open in a new tab", () => { + showProblems("errors", ["See https://webpack.js.org/concepts#loaders"]); + const link = getCard().querySelector("a"); + expect(link).not.toBeNull(); + expect(link.getAttribute("href")).toBe( + "https://webpack.js.org/concepts#loaders", + ); + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("keeps trailing punctuation out of the link href", () => { + showProblems("errors", ["Docs: https://example.com/a."]); + const link = getCard().querySelector("a"); + expect(link.getAttribute("href")).toBe("https://example.com/a"); + expect(getCard().textContent).toContain("https://example.com/a."); + }); + + it("shows a dismiss hint", () => { + showProblems("errors", ["boom"]); + expect(getCard().textContent).toContain( + "Click outside, press Esc, or fix the code to dismiss.", + ); + }); + + it("replaces previous problems on each call", () => { + showProblems("errors", ["first"]); + showProblems("errors", ["second"]); + expect(getCard().textContent).toContain("second"); + expect(getCard().textContent).not.toContain("first"); + }); + }); + + describe("clear", () => { + it("removes the overlay from the DOM", () => { + showProblems("errors", ["boom"]); + expect(getOverlay()).not.toBeNull(); + clear(); + expect(getOverlay()).toBeNull(); + }); + + it("is a no-op when nothing is shown", () => { + expect(() => clear()).not.toThrow(); + }); + }); + + describe("dismiss", () => { + it("closes when pressing Escape", () => { + showProblems("errors", ["boom"]); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(getOverlay()).toBeNull(); + }); + + it("closes when clicking the backdrop", () => { + showProblems("errors", ["boom"]); + getOverlay().click(); + expect(getOverlay()).toBeNull(); + }); + + it("stays open when clicking inside the card", () => { + showProblems("errors", ["boom"]); + getCard().click(); + expect(getOverlay()).not.toBeNull(); + }); + + it("closes when clicking the close button", () => { + showProblems("errors", ["boom"]); + const closeButton = getCard().querySelector("button"); + expect(closeButton).not.toBeNull(); + closeButton.click(); + expect(getOverlay()).toBeNull(); + }); + }); + + describe("configureOverlay", () => { + it("returns the overlay API", () => { + const api = configureOverlay({}); + expect(typeof api.showProblems).toBe("function"); + expect(typeof api.clear).toBe("function"); + }); + + it("applies custom overlay styles to the card", () => { + configureOverlay({ overlayStyles: { maxWidth: "500px" } }); + showProblems("errors", ["boom"]); + expect(getCard().style.maxWidth).toBe("500px"); + }); + + it("honors custom ansi colors for the problem color", () => { + configureOverlay({ ansiColors: { red: "00ff00" } }); + showProblems("errors", ["boom"]); + expect(getCard().querySelector("span").style.backgroundColor).toBe( + "rgb(0, 255, 0)", + ); + expect(getCard().style.borderTopColor).toBe("rgb(0, 255, 0)"); + + // Restore the default so module-level state does not leak. + configureOverlay({ ansiColors: { red: "ff3348" } }); + }); + }); +}); diff --git a/test/validation-options.test.js b/test/validation-options.test.js index 79bb801b3..537b7084a 100644 --- a/test/validation-options.test.js +++ b/test/validation-options.test.js @@ -85,6 +85,18 @@ describe("validation", () => { success: [true, false], failure: ["foo", 0], }, + hot: { + success: [ + true, + false, + {}, + { path: "/__hmr" }, + { heartbeat: 1000 }, + { statsOptions: true }, + { statsOptions: { all: false } }, + ], + failure: ["foo", 0, { path: "" }, { heartbeat: -1 }, { unknown: true }], + }, }; // eslint-disable-next-line jsdoc/reject-any-type diff --git a/tsconfig.client.json b/tsconfig.client.json new file mode 100644 index 000000000..479ffba6d --- /dev/null +++ b/tsconfig.client.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es5", "dom", "webworker", "es2022.error"], + "module": "esnext", + "moduleResolution": "bundler", + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "types": ["node", "webpack/module"], + "skipDefaultLibCheck": true, + "esModuleInterop": true + }, + "include": ["./client-src/**/*"] +} diff --git a/types/hot.d.ts b/types/hot.d.ts new file mode 100644 index 000000000..cac94957c --- /dev/null +++ b/types/hot.d.ts @@ -0,0 +1,215 @@ +export = createHot; +/** + * @typedef {object} HotInstance + * @property {string} path path the SSE endpoint is served at + * @property {(req: IncomingMessage, res: ServerResponse) => void} handle attach the request as a SSE client + * @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client + * @property {() => void} close end every client and detach the heartbeat + */ +/** + * @param {Compiler | MultiCompiler} compiler compiler + * @param {HotOptions | true} userOptions options + * @returns {HotInstance} hot instance + */ +declare function createHot( + compiler: Compiler | MultiCompiler, + userOptions: HotOptions | true, +): HotInstance; +declare namespace createHot { + export { + HOT_DEFAULT_HEARTBEAT, + HOT_DEFAULT_PATH, + buildModuleMap, + createEventStream, + createHot, + formatErrors, + pathMatch, + publishStats, + HotInstance, + Compiler, + MultiCompiler, + Logger, + Stats, + MultiStats, + StatsCompilation, + StatsError, + StatsModule, + IncomingMessage, + ServerResponse, + StatsOptions, + HotOptions, + Payload, + EventStream, + }; +} +declare const HOT_DEFAULT_HEARTBEAT: number; +/** @typedef {import("webpack").Compiler} Compiler */ +/** @typedef {import("webpack").MultiCompiler} MultiCompiler */ +/** @typedef {ReturnType} Logger */ +/** @typedef {import("webpack").Stats} Stats */ +/** @typedef {import("webpack").MultiStats} MultiStats */ +/** @typedef {import("webpack").StatsCompilation} StatsCompilation */ +/** @typedef {import("webpack").StatsError} StatsError */ +/** @typedef {import("webpack").StatsModule} StatsModule */ +/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ +/** @typedef {import("./index.js").ServerResponse} ServerResponse */ +/** @typedef {NonNullable} StatsOptions */ +/** + * @typedef {object} HotOptions + * @property {string=} path the path the SSE endpoint is served at + * @property {number=} heartbeat heartbeat interval in milliseconds + * @property {StatsOptions=} statsOptions webpack stats options used when serializing compilation results + */ +/** + * @typedef {object} Payload + * @property {string} action action + * @property {string=} name name + * @property {number=} time time + * @property {string=} hash hash + * @property {string[]=} warnings warnings + * @property {string[]=} errors errors + * @property {Record=} modules modules + */ +/** + * @typedef {object} EventStream + * @property {(req: IncomingMessage, res: ServerResponse) => void} handler attach a new client + * @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client + * @property {() => void} close end every client and stop the heartbeat + */ +declare const HOT_DEFAULT_PATH: "/__webpack_hmr"; +/** + * @param {StatsModule[]} modules modules + * @returns {Record} module id to name map + */ +declare function buildModuleMap(modules: StatsModule[]): Record; +/** + * @param {number} heartbeat heartbeat interval in milliseconds + * @param {Logger} logger logger + * @returns {EventStream} event stream + */ +declare function createEventStream( + heartbeat: number, + logger: Logger, +): EventStream; +/** + * @param {(string | StatsError)[]} errors errors or warnings + * @returns {string[]} flat strings + */ +declare function formatErrors(errors: (string | StatsError)[]): string[]; +/** + * @param {string | undefined} url url + * @param {string} expected expected pathname + * @returns {boolean} true when the url pathname matches the expected path + */ +declare function pathMatch(url: string | undefined, expected: string): boolean; +/** + * @param {string} action action + * @param {Stats | MultiStats} statsResult stats result + * @param {EventStream} eventStream event stream + * @param {StatsOptions | undefined} statsOptions stats options + */ +declare function publishStats( + action: string, + statsResult: Stats | MultiStats, + eventStream: EventStream, + statsOptions: StatsOptions | undefined, +): void; +type HotInstance = { + /** + * path the SSE endpoint is served at + */ + path: string; + /** + * attach the request as a SSE client + */ + handle: (req: IncomingMessage, res: ServerResponse) => void; + /** + * publish a payload to every client + */ + publish: ( + payload: + | Payload + | { + action: string; + }, + ) => void; + /** + * end every client and detach the heartbeat + */ + close: () => void; +}; +type Compiler = import("webpack").Compiler; +type MultiCompiler = import("webpack").MultiCompiler; +type Logger = ReturnType; +type Stats = import("webpack").Stats; +type MultiStats = import("webpack").MultiStats; +type StatsCompilation = import("webpack").StatsCompilation; +type StatsError = import("webpack").StatsError; +type StatsModule = import("webpack").StatsModule; +type IncomingMessage = import("./index.js").IncomingMessage; +type ServerResponse = import("./index.js").ServerResponse; +type StatsOptions = NonNullable; +type HotOptions = { + /** + * the path the SSE endpoint is served at + */ + path?: string | undefined; + /** + * heartbeat interval in milliseconds + */ + heartbeat?: number | undefined; + /** + * webpack stats options used when serializing compilation results + */ + statsOptions?: StatsOptions | undefined; +}; +type Payload = { + /** + * action + */ + action: string; + /** + * name + */ + name?: string | undefined; + /** + * time + */ + time?: number | undefined; + /** + * hash + */ + hash?: string | undefined; + /** + * warnings + */ + warnings?: string[] | undefined; + /** + * errors + */ + errors?: string[] | undefined; + /** + * modules + */ + modules?: Record | undefined; +}; +type EventStream = { + /** + * attach a new client + */ + handler: (req: IncomingMessage, res: ServerResponse) => void; + /** + * publish a payload to every client + */ + publish: ( + payload: + | Payload + | { + action: string; + }, + ) => void; + /** + * end every client and stop the heartbeat + */ + close: () => void; +}; diff --git a/types/index.d.ts b/types/index.d.ts index 233a3d8db..7a29ffdd1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -28,6 +28,8 @@ declare namespace wdm { MultiStats, ReadStream, FilenameWithExtra, + HotOptions, + HotInstance, EXPECTED_ANY, EXPECTED_FUNCTION, ExtendedServerResponse, @@ -128,6 +130,8 @@ type Stats = import("webpack").Stats; type MultiStats = import("webpack").MultiStats; type ReadStream = import("fs").ReadStream; type FilenameWithExtra = import("./middleware").FilenameWithExtra; +type HotOptions = import("./hot").HotOptions; +type HotInstance = import("./hot").HotInstance; type EXPECTED_ANY = any; type EXPECTED_FUNCTION = Function; type ExtendedServerResponse = { @@ -210,6 +214,10 @@ type Context< * output file system */ outputFileSystem: OutputFileSystem; + /** + * hot module replacement instance + */ + hot?: HotInstance | undefined; }; type FilledContext< RequestInternal extends IncomingMessage = import("http").IncomingMessage, @@ -316,6 +324,10 @@ type Options< * forward error to next middleware */ forwardError?: boolean | undefined; + /** + * enable hot module replacement + */ + hot?: (boolean | HotOptions) | undefined; }; type Middleware< RequestInternal extends IncomingMessage = import("http").IncomingMessage,