diff --git a/.changeset/hot-middleware-migration.md b/.changeset/hot-middleware-migration.md new file mode 100644 index 000000000..2ca630693 --- /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, progress, statsOptions }` to customize. The client runtime is served by the middleware itself. diff --git a/.cspell.json b/.cspell.json index ecd7d2e23..52bdc3ed9 100644 --- a/.cspell.json +++ b/.cspell.json @@ -24,7 +24,18 @@ "finalhandler", "hono", "rspack", - "malformed" + "apos", + "malformed", + "Consolas", + "cspellcache", + "CSSOM", + "darkgrey", + "eslintcache", + "esmodules", + "mbold", + "mred", + "noopener", + "noreferrer" ], "ignorePaths": [ "CHANGELOG.md", @@ -36,6 +47,8 @@ "coverage", "*.log", "./test/fixtures/**", - "./test/outputs/**" + "./test/outputs/**", + "client/**", + "examples/*/dist/**" ] } diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index e2bd47f7e..913694014 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..42f3ee4d8 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,271 @@ 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 start with a slash and 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.progress` + +Type: `Boolean` +Default: `undefined` + +Publish compilation progress events (`{ action: "progress", percent, message }`) to the clients using webpack's `ProgressPlugin`. The bundled client shows the percentage in its building badge (see the client `progress` option). + +#### `hot.statsOptions` + +Type: `Object` +Default: `undefined` + +Webpack stats options used when serializing compilation results for the SSE payload. Merged over the middleware's base options and forwarded to `stats.toJson(...)`. Only the object form is accepted — presets (`"errors-only"`) and booleans cannot be merged. By default only the minimal stats needed by the client are requested (`hash`, `timings`, `errors`, `warnings`) to avoid slowing down rebuilds. + +## 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\|Object` | `true` | In-page overlay for problems. Same value shape as webpack-dev-server's [`client.overlay`](https://webpack.js.org/configuration/dev-server/#overlay): a boolean, or a JSON object with `errors`, `warnings`, `runtimeErrors` (booleans or filter functions) and `trustedTypesPolicyName`. Partial objects are filled with `true`. Also accepts the webpack-dev-middleware extensions `styles` (CSS overrides for the overlay card), `ansiColors` (ANSI → HTML color map) and `openEditorEndpoint` (when set, file references become clickable and issue `GET ?fileName=` — the endpoint is provided by your server, e.g. a route calling [launch-editor](https://github.com/yyx990803/launch-editor)) and `paginate` (show one problem at a time with prev/next navigation, enabled by default — disable with `{"paginate":false}`). | +| `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). Enabled by default, unlike webpack-hot-middleware; 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. | +| `progress` | `boolean` | `true` | Show a small badge in the page while a rebuild is in progress (with the compilation percentage when the server enables `hot.progress`). Set to `false` to disable. | +| `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. The leading slash of `path` is stripped and no other normalization is applied, so the public path should end with `/`. | + +### 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" }); + +// Close the SSE connection and stop reconnecting (e.g. before tearing the +// page down). A later `setOptionsAndConnect` call opens a fresh connection. +hotClient.disconnect(); +``` + +The error overlay is also exposed as a standalone module so other tooling +(e.g. `webpack-dev-server`) can reuse it without the SSE client: + +```js +import configureOverlay, { + clear, + showProblems, +} from "webpack-dev-middleware/client/overlay"; + +const overlay = configureOverlay({ + // ansiColors, overlayStyles, trustedTypesPolicyName, catchRuntimeError, + // openEditorEndpoint, paginate +}); + +overlay.showProblems("errors", ["Something broke"]); +overlay.clear(); +``` + +The overlay state is a per-page singleton: every bundled copy of the module +renders into the same overlay. Multiple clients can report side by side by +passing a `source` — each source keeps its own slot and the overlay shows the +union, with errors from any source taking precedence over warnings: + +```js +overlay.showProblems("errors", ["Something broke"], "my-client"); +// Drop only this client's problems; other sources stay on screen. +overlay.clear("my-client"); +// Without a source, everything is dismissed (same as Esc / backdrop / ×). +overlay.clear(); +``` + +The building indicator is exposed the same way: + +```js +import { hide, show } from "webpack-dev-middleware/client/indicator"; + +show("Rebuilding…"); // pulsing dot +show("Rebuilding… 42%", 42); // progress ring +hide(); +``` + +The badge is a per-page singleton shared by every bundled copy of the module. +Concurrent builds can report through a `source` — the badge stays until every +source finished: + +```js +show("Rebuilding app…", undefined, "app"); +show("Rebuilding admin…", undefined, "admin"); +hide("app"); // still shown — "admin" is building +hide("admin"); // removed +hide(); // without a source: removed unconditionally +``` + +## Migrating from webpack-hot-middleware + +The `hot` option replaces [`webpack-hot-middleware`](https://github.com/webpack-contrib/webpack-hot-middleware): one middleware serves the assets and the SSE endpoint, and the client runtime ships under `webpack-dev-middleware/client`. The endpoint (`/__webpack_hmr`), the event stream, and the client query-string API stay compatible, so migrating is mostly renaming. + +On the server, remove `webpack-hot-middleware` and enable `hot` instead: + +```js +// Before +app.use(webpackDevMiddleware(compiler)); +app.use(webpackHotMiddleware(compiler, { heartbeat: 2000 })); + +// After +app.use(webpackDevMiddleware(compiler, { hot: { heartbeat: 2000 } })); +``` + +In the webpack configuration, swap the client entry (`HotModuleReplacementPlugin` stays): + +```js +// Before +module.exports = { + entry: ["webpack-hot-middleware/client?timeout=20000", "./src/app.js"], +}; + +// After +module.exports = { + entry: ["webpack-dev-middleware/client?timeout=20000", "./src/app.js"], +}; +``` + +Option mapping: + +| webpack-hot-middleware | webpack-dev-middleware | +| :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------- | +| server `path`, `heartbeat` | `hot.path`, `hot.heartbeat` (unchanged) | +| server `log`, `logLevel` | Removed — the middleware logs through the compiler's [infrastructure logger](https://webpack.js.org/configuration/infrastructurelogging/). | +| server `statsOptions` | `hot.statsOptions` (object form only) | +| client `path`, `timeout`, `name`, `autoConnect`, `dynamicPublicPath` | Unchanged. | +| client `reload` | Unchanged, but the default flipped to `true` — pass `reload=false` to keep the old HMR-only behavior. | +| client `noInfo`, `quiet` | `logging` (`quiet=true` → `logging=none`, `noInfo=true` → `logging=warn`). | +| client `overlay`, `overlayWarnings`, `overlayStyles`, `ansiColors` | A single `overlay` value: a boolean, or an object with `errors`, `warnings`, `styles`, `ansiColors` (see [client options](#client-options)). | + +The programmatic client API keeps the same names (`subscribe`, `subscribeAll`, `useCustomOverlay`, `setOptionsAndConnect`), and adds `disconnect()`. On the server, `webpackHotMiddleware.publish(...)` becomes `instance.context.hot.publish(...)`. + +## HMR notes and troubleshooting + +### Browser connection limits (many tabs) + +Each open tab keeps one SSE connection to the `hot.path` endpoint. Over +HTTP/1.1, browsers allow only ~6 concurrent connections per origin, so opening +many tabs can leave the extra ones hanging (browsers have marked this +[Won't Fix](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)). +Multiple webpack entries on the same page already share a single connection, +and the endpoint works over HTTP/2 out of the box — serve your development +server over HTTP/2 if you need many simultaneous tabs. + +### Filtering warnings + +Three layers, from build to presentation: + +- webpack's [`ignoreWarnings`](https://webpack.js.org/configuration/other-options/#ignorewarnings) removes them from the stats, so clients never receive them. +- `hot: { statsOptions: { warnings: false } }` keeps them in the build output but out of the SSE payload. +- On the client, `?overlay={"warnings":false}` hides them from the overlay and `?logging=error` from the console. + +### Paths and public paths + +- The client `path` option accepts absolute URLs (the endpoint sends + `Access-Control-Allow-Origin: *`), which allows connecting across ports or + hosts. Pages served over HTTPS need the endpoint over HTTPS too. +- For apps with nested routes (`/some/route`), use an absolute + `output.publicPath` (e.g. `"/"`): with a relative one the browser resolves + `*.hot-update.json` requests against the current route and they 404. + +### Custom events + +The server can broadcast arbitrary payloads and the client can react to them — +for example, forcing every open tab to reload on demand: + +```js +// Server +const instance = middleware(compiler, { hot: true }); +instance.context.hot.publish({ action: "reload-all" }); +``` + +```js +// Client +const hotClient = require("webpack-dev-middleware/client"); + +hotClient.subscribe((payload) => { + if (payload.action === "reload-all") { + globalThis.location.reload(); + } +}); +``` + ## 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..66aa2754e --- /dev/null +++ b/client-src/globals.d.ts @@ -0,0 +1,39 @@ +/* eslint-disable */ + +declare module "ansi-html-community" { + function ansiHtmlCommunity(str: string): string; + namespace ansiHtmlCommunity { + function setColors(colors: Record): void; + } + export = ansiHtmlCommunity; +} + +interface ClientReporter { + cleanProblemsCache(name: string): void; + problems( + type: "errors" | "warnings", + obj: { errors: string[]; warnings: string[]; name?: string }, + ): boolean; + success(obj?: { name?: string }): void; + useCustomOverlay(customOverlay: unknown): void; +} + +interface EventSourceWrapper { + addMessageListener(fn: (event: { data: string }) => void): void; + close(): void; +} + +interface OverlayTrustedTypesPolicy { + createHTML(value: string): string; +} + +interface Window { + __wdmEventSourceWrapper?: Record; + __webpack_dev_middleware_hot_reporter__?: ClientReporter; + trustedTypes?: { + createPolicy( + name: string, + rules: { createHTML(value: string): string }, + ): OverlayTrustedTypesPolicy; + }; +} diff --git a/client-src/index.js b/client-src/index.js new file mode 100644 index 000000000..41e2eb2ae --- /dev/null +++ b/client-src/index.js @@ -0,0 +1,564 @@ +/* global __resourceQuery, __webpack_public_path__ */ + +import * as indicator from "./indicator.js"; +import configureOverlay from "./overlay.js"; +import applyUpdate from "./process-update.js"; +import { log, setLogLevel } from "./utils/log.js"; +import stripAnsi from "./utils/strip-ansi.js"; + +/** @typedef {import("./utils/log.js").LogLevel} LogLevel */ + +/** + * Superset of webpack-dev-server's `client.overlay` object; `styles`, + * `ansiColors`, `openEditorEndpoint` and `paginate` are webpack-dev-middleware + * extensions. + * @typedef {object} OverlayOptions + * @property {(boolean | ((error: string) => boolean))=} errors show build errors in the overlay + * @property {(boolean | ((warning: string) => boolean))=} warnings show build warnings in the overlay + * @property {(boolean | ((error: Error) => boolean))=} runtimeErrors show uncaught runtime errors and unhandled rejections in the overlay + * @property {string=} trustedTypesPolicyName Trusted Types policy name used for the overlay's HTML + * @property {Record=} styles overrides for the overlay card CSS + * @property {Record=} ansiColors overrides for ANSI → HTML color mapping + * @property {string=} openEditorEndpoint endpoint the overlay calls (GET `?fileName=file:line:column`) when a file reference is clicked; empty disables it + * @property {boolean=} paginate show one problem at a time with prev/next navigation + */ + +/** + * @typedef {object} ClientOptions + * @property {string} path SSE endpoint path + * @property {number} timeout reconnection timeout in milliseconds + * @property {boolean | OverlayOptions} overlay enable the in-page error overlay (same value shape as webpack-dev-server's `client.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 {boolean} progress show a small badge while a rebuild is in progress + */ + +/** @type {ClientOptions} */ +const options = { + path: "/__webpack_hmr", + timeout: 20 * 1000, + overlay: true, + reload: true, + logging: "info", + name: "", + autoConnect: true, + progress: true, +}; + +/** + * Turn the string values that `errors`/`warnings`/`runtimeErrors` may carry + * in the resource query into filter functions (same behavior as + * webpack-dev-server). + * @param {boolean | OverlayOptions} overlayOptions overlay options + */ +function decodeOverlayOptions(overlayOptions) { + if (typeof overlayOptions === "object") { + for (const property of ["errors", "warnings", "runtimeErrors"]) { + const value = + overlayOptions[/** @type {keyof OverlayOptions} */ (property)]; + + if (typeof value === "string") { + const filterFunctionString = decodeURIComponent(value); + + /** @type {EXPECTED_ANY} */ (overlayOptions)[property] = + // eslint-disable-next-line no-new-func + new Function( + "message", + `var callback = ${filterFunctionString} + return callback(message)`, + ); + } + } + } +} + +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) { + // Same value shape as webpack-dev-server's `client.overlay`: a boolean or + // a JSON object with `errors`, `warnings`, `runtimeErrors` (booleans or + // encoded filter functions) and `trustedTypesPolicyName`. + try { + options.overlay = JSON.parse(overrides.overlay); + } catch { + options.overlay = overrides.overlay !== "false"; + } + + // Fill in default "true" params for partially-specified objects. + if (typeof options.overlay === "object") { + options.overlay = { + errors: true, + warnings: true, + runtimeErrors: true, + ...options.overlay, + }; + + decodeOverlayOptions(options.overlay); + } + } + 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.progress) { + options.progress = overrides.progress !== "false"; + } + + if (overrides.dynamicPublicPath) { + // `path` is appended like a filename (no leading slash); the public path + // itself is not normalized. + options.path = __webpack_public_path__ + options.path.replace(/^\//, ""); + } + + setLogLevel(options.logging); +} + +/** + * @typedef {(event: { data: string }) => void} MessageListener + */ + +/** + * @returns {{ addMessageListener: (fn: MessageListener) => void, close: () => void }} event source wrapper + */ +function createEventSourceWrapper() { + /** @type {EventSource} */ + let source; + let lastActivity = Date.now(); + /** @type {MessageListener[]} */ + const listeners = []; + /** @type {ReturnType} */ + let timer; + /** @type {ReturnType} */ + let reconnectTimer; + + 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); + } + }; + + /** + * Close the connection and stop the activity timer without scheduling a + * reconnection. A reconnection that is already pending is cancelled too, so + * closing during the reconnect window really is final. + */ + const close = () => { + clearInterval(timer); + clearTimeout(reconnectTimer); + source.close(); + }; + + const handleDisconnect = () => { + close(); + reconnectTimer = setTimeout(init, /** @type {number} */ (options.timeout)); + }; + + /** + * Open the EventSource connection and (re)start the inactivity watchdog — + * `handleDisconnect` stops the watchdog, so a reconnected source has to + * bring its own. + */ + function init() { + source = new window.EventSource(/** @type {string} */ (options.path)); + source.addEventListener("open", handleOnline); + source.addEventListener("error", handleDisconnect); + source.addEventListener("message", handleMessage); + + lastActivity = Date.now(); + clearInterval(timer); + timer = setInterval( + () => { + if ( + Date.now() - lastActivity > + /** @type {number} */ (options.timeout) + ) { + handleDisconnect(); + } + }, + /** @type {number} */ (options.timeout) / 2, + ); + } + + init(); + + return { + addMessageListener(fn) { + listeners.push(fn); + }, + close, + }; +} + +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(); +} + +/** + * Close the SSE connection for the current path and stop reconnecting. A + * later `setOptionsAndConnect` call opens a fresh connection. + */ +export function disconnect() { + const path = /** @type {string} */ (options.path); + const wrappers = window[WRAPPER_KEY]; + + if (wrappers && wrappers[path]) { + wrappers[path].close(); + delete wrappers[path]; + } +} + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** @typedef {{ name?: string, errors: string[], warnings: string[], hash: string, time?: number, action?: string, file?: string, percent?: number, message?: string }} HMRPayload */ + +/** + * @returns {{ + * cleanProblemsCache: (name: string) => void, + * problems: (type: "errors" | "warnings", obj: HMRPayload) => boolean, + * success: (obj?: HMRPayload) => void, + * useCustomOverlay: (customOverlay: EXPECTED_ANY) => void, + * }} reporter + */ +function createReporter() { + /** @type {EXPECTED_ANY} */ + let overlay; + if (typeof document !== "undefined" && options.overlay) { + // Same mapping as webpack-dev-server's createOverlay call, extended with + // the webpack-dev-middleware-specific keys. + overlay = configureOverlay( + typeof options.overlay === "object" + ? { + catchRuntimeError: options.overlay.runtimeErrors, + trustedTypesPolicyName: options.overlay.trustedTypesPolicyName, + ansiColors: options.overlay.ansiColors, + overlayStyles: options.overlay.styles, + openEditorEndpoint: options.overlay.openEditorEndpoint, + paginate: options.overlay.paginate, + } + : { + catchRuntimeError: options.overlay, + }, + ); + } + + // Console de-duplication cache, keyed per bundle name and type so interleaved + // multi-compiler payloads do not defeat it. + /** @type {Map} */ + const previousProblems = new Map(); + + // Live problems per compilation name. A multi-compiler publishes one event + // per bundle; a success from one bundle must not wipe another bundle's + // still-valid errors from the overlay. + /** @type {Map} */ + const problemsByName = new Map(); + + /** + * Resolve the show/hide/filter setting for a problem type. Same resolution + * as webpack-dev-server: a boolean overlay applies to both types; an object + * carries a boolean or a filter function per type. + * @param {"errors" | "warnings"} type problem type + * @param {string[]} problems problems of one bundle + * @returns {string[]} the problems the overlay should show + */ + const filterForOverlay = (type, problems) => { + const setting = + typeof options.overlay === "boolean" + ? options.overlay + : options.overlay && options.overlay[type]; + + if (!setting) { + return []; + } + + return typeof setting === "function" + ? problems.filter((message) => setting(message)) + : problems; + }; + + /** + * Render the union of every bundle's live problems, or clear the overlay + * when nothing is left. + * @returns {boolean} true when nothing is shown + */ + const renderOverlay = () => { + if (!overlay) { + return true; + } + + /** @type {string[]} */ + const errors = []; + /** @type {string[]} */ + const warnings = []; + + for (const entry of problemsByName.values()) { + errors.push(...filterForOverlay("errors", entry.errors)); + warnings.push(...filterForOverlay("warnings", entry.warnings)); + } + + if (errors.length > 0) { + overlay.showProblems("errors", errors); + return false; + } + + if (warnings.length > 0) { + overlay.showProblems("warnings", warnings); + return false; + } + + // Clear only this client's problems and the runtime errors — other + // clients sharing the overlay keep theirs. + overlay.clear(""); + overlay.clear("runtime"); + return true; + }; + + /** + * @param {"errors" | "warnings"} type problem type + * @param {HMRPayload} obj payload + */ + const logProblems = (type, obj) => { + const cacheKey = `${obj.name || ""}|${type}`; + const newProblems = obj[type].map(stripAnsi).join("\n"); + if (previousProblems.get(cacheKey) === newProblems) { + return; + } + previousProblems.set(cacheKey, 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(name) { + // Scoped to one bundle so a sibling's unchanged problems do not re-log. + previousProblems.delete(`${name}|errors`); + previousProblems.delete(`${name}|warnings`); + }, + problems(type, obj) { + logProblems(type, obj); + problemsByName.set(obj.name || "", { + errors: obj.errors || [], + warnings: obj.warnings || [], + }); + return renderOverlay(); + }, + success(obj) { + problemsByName.delete((obj && obj.name) || ""); + renderOverlay(); + }, + 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; + +// Name of the build that most recently reported `building` — progress +// payloads carry no name, so they are attributed to it. +let lastBuildingName = ""; + +/** + * @param {HMRPayload} obj payload + */ +function processMessage(obj) { + switch (obj.action) { + case "building": { + log.info( + `bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilding${ + obj.file ? ` (${obj.file} changed)` : "" + }`, + ); + if (options.progress && typeof document !== "undefined") { + lastBuildingName = obj.name || ""; + indicator.show( + obj.file ? `Rebuilding… (${obj.file})` : undefined, + undefined, + lastBuildingName, + ); + } + break; + } + case "progress": { + // Progress payloads carry no name — attribute them to the build that + // most recently reported `building`. + if (options.progress && typeof document !== "undefined") { + indicator.show( + `Rebuilding… ${obj.percent}%${obj.message ? ` (${obj.message})` : ""}`, + obj.percent, + lastBuildingName, + ); + } + break; + } + case "built": + case "sync": { + if (options.progress && typeof document !== "undefined") { + indicator.hide(obj.name || ""); + } + 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) { + // Warnings are reported (and possibly shown in the overlay) but do + // not block the update, matching webpack-dev-server. + if (reporter) { + reporter.problems("warnings", obj); + } + } else if (reporter) { + reporter.cleanProblemsCache(obj.name || ""); + reporter.success(obj); + } + if (shouldApply) { + applyUpdate(obj.hash, options, obj.name); + } + 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/indicator.js b/client-src/indicator.js new file mode 100644 index 000000000..2f71e8838 --- /dev/null +++ b/client-src/indicator.js @@ -0,0 +1,231 @@ +// Small badge shown while a rebuild is in progress. It lives in a shadow root +// so page styles cannot affect it; styles go through the CSSOM and SVG +// presentation attributes, which a strict `style-src` CSP allows. + +import theme from "./theme.js"; + +const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; +const SVG_NS = "http://www.w3.org/2000/svg"; +// Circumference of the progress ring (r = 6). +const RING_LENGTH = 2 * Math.PI * 6; + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** + * @typedef {object} IndicatorState + * @property {HTMLElement | null} host badge host element + * @property {HTMLElement | null} label label inside the badge + * @property {HTMLElement | null} dot pulsing dot (indeterminate mode) + * @property {SVGSVGElement | null} ring progress ring (determinate mode) + * @property {SVGCircleElement | null} ringValue ring value circle + * @property {Record} building sources with a build in progress — the badge hides only when every source finished + */ + +/** @returns {IndicatorState} fresh indicator state */ +function createIndicatorState() { + return { + host: null, + label: null, + dot: null, + ring: null, + ringValue: null, + building: {}, + }; +} + +// Shared through `window` so every bundled copy of this module drives one +// single badge instead of stacking duplicates (same pattern as the overlay). +const INDICATOR_STATE_KEY = "__webpack_dev_middleware_hot_indicator_state__"; + +/** @type {IndicatorState} */ +const state = (() => { + if (typeof window === "undefined") { + return createIndicatorState(); + } + + const holder = /** @type {EXPECTED_ANY} */ (window); + + if (!holder[INDICATOR_STATE_KEY]) { + holder[INDICATOR_STATE_KEY] = createIndicatorState(); + } else { + // Fill fields another package version may not have created, in place. + const defaults = createIndicatorState(); + + for (const key of Object.keys(defaults)) { + if (!(key in holder[INDICATOR_STATE_KEY])) { + holder[INDICATOR_STATE_KEY][key] = + defaults[/** @type {keyof IndicatorState} */ (key)]; + } + } + } + + return holder[INDICATOR_STATE_KEY]; +})(); + +/** + * @param {EXPECTED_ANY} element element + * @param {Record} style style map + */ +function applyStyle(element, style) { + for (const key of Object.keys(style)) { + element.style[key] = style[key]; + } +} + +/** + * Create (or reuse) the indicator host element. + */ +function ensureIndicator() { + if (state.host && state.host.parentNode) { + return; + } + + if (!document.body) { + return; + } + + state.host = document.createElement("div"); + state.host.id = INDICATOR_ID; + applyStyle(state.host, { + position: "fixed", + right: "16px", + bottom: "16px", + zIndex: 9999, + pointerEvents: "none", + }); + + const root = state.host.attachShadow({ mode: "open" }); + + const badge = document.createElement("div"); + applyStyle(badge, { + display: "flex", + alignItems: "center", + gap: "8px", + background: theme.panelTranslucent, + color: theme.text, + fontFamily: "Menlo, Consolas, 'Courier New', monospace", + fontSize: "12px", + padding: "6px 12px", + borderRadius: "16px", + boxShadow: "0 2px 12px rgba(0,0,0,0.35)", + }); + + // Indeterminate mode: a pulsing dot. + state.dot = document.createElement("span"); + applyStyle(state.dot, { + width: "8px", + height: "8px", + borderRadius: "50%", + background: theme.accent, + }); + + // Pulse through the Web Animations API — no