Skip to content

Commit c372588

Browse files
committed
feat: add browser client runtime for HMR (#2323)
* feat: add browser client runtime for HMR Ports the browser client into `client-src/`, mirroring the layout used in `webpack-dev-server` (source in `client-src/`, built to `client/`). The client connects to the SSE endpoint via `EventSource`, parses query-string options from `__resourceQuery`, dispatches `building`, `built` and `sync` payloads, applies HMR through `process-update.js` and renders compile-time errors and warnings through an in-page overlay (`overlay.js`). Exposed via the `./client` subpath export so users can wire it as a webpack entry: `require('webpack-dev-middleware/client')`. The source is transpiled with a browser-targeted babel override and the resulting files are shipped under `/client`. * test: add browser client runtime tests Covers the public client API and key SSE handling paths in jsdom: EventSource connection on default and custom paths, ignored heartbeat messages, dispatch of building/built/sync to subscribers, custom handler for unknown actions, warnings on invalid JSON, EventSource wrapper caching across multiple entries, and timeout-driven reconnect. * test: expand browser client coverage to mirror webpack-hot-middleware Adds tests covering the original webpack-hot-middleware client suite (processUpdate invocations on built/sync, errored/warning behavior, overlay show/hide transitions, the overlayWarnings option, the name filter), while keeping the new coverage for heartbeat handling, invalid JSON warnings, EventSource wrapper caching across entries and timeout-driven reconnects. * ci: run on push and PRs against the hot-middleware umbrella branch * refactor(client): switch process-update to promise-only HMR API The ported logic called `module.hot.check`/`apply` with both a callback and a Promise-handling branch to support webpack < 2. In webpack 5 both paths fire, so the callback ran twice, triggering a redundant `module.hot.apply` on every update. Drop the legacy callback path and use the Promise API exclusively, which is the canonical webpack 5 contract and matches our peer dependency. * chore(client): type-check client-src with a dedicated tsconfig Mirrors the layout webpack-dev-server uses: a separate `tsconfig.client.json` (`noEmit`, browser-targeted libs, `webpack/module` augmentation) runs over `client-src/` via a new `lint:types-client` script, with a small `client-src/globals.d.ts` declaring `ansi-html-community` and the per-page singletons the client stores on `window`. Refines the JSDoc annotations in `client-src/index.js` and `client-src/process-update.js` so `module.hot`, `window` extensions and the HMR `ApplyOptions` type-check cleanly. * docs: document the browser client runtime in README Adds a 'Hot Module Replacement client' section explaining how to wire `webpack-dev-middleware/client` as a webpack entry, the query-string options that the runtime understands, and the programmatic `subscribe` / `subscribeAll` / `useCustomOverlay` / `setOptionsAndConnect` exports. * chore(client): adopt browser-outdated-recommended-commonjs eslint preset Switches the client-src lint config to the dedicated preset `eslint-config-webpack` ships for browser-targeted CommonJS code, the same family of preset webpack-dev-server uses for its own client. Brings the per-directory rule overrides down to two: `no-console` (legitimately used for HMR status messages) and `no-use-before-define` (relaxed for hoisted function declarations). Adjusts the source to satisfy the rest of the preset directly: adds `use strict` headers, fills in JSDoc for every function, renames `EventSourceWrapper` to `createEventSourceWrapper` (per `new-cap`), names the anonymous module exports, and reorders `performReload` before `handleError` so it is declared before use. * refactor(client): route logging through webpack's runtime logger Wraps `webpack/lib/logging/runtime` in a small `utils/log.js` module that exposes a level-based logger registered under the `webpack-dev-middleware` name (matching the infrastructure logger the server side already uses). Replaces every `console.log`/ `console.warn` call in the client and HMR update path with the equivalent `log.info`/`log.warn`/`log.error` calls so output is prefixed and gated by a single `logging` level. User-facing API: - `logging` query-string option accepts `none|error|warn|info|log|verbose` - The previous `log`, `warn`, `noInfo` and `quiet` flags are dropped in favour of `logging` Other cleanups enabled by this: - Drop the `no-console: off` exception from the client-src ESLint config - Update README's client option table accordingly - Add tests covering the new `logging` levels and the logger prefix * test(client): use snapshots for logger output assertions Replaces regex-based `some(([msg]) => /.../).toBe(true)` checks on the mocked console with `toMatchSnapshot()` over `mock.calls`. The snapshots capture the exact log lines including the `[webpack-dev-middleware]` prefix and per-call argument count, so any change to the log format surfaces in the test output instead of silently passing. Adds explicit assertions to the existing error / warning flow tests so `console.error` / `console.warn` mocks are not only silenced but also verified to be called with the expected output. * chore: document why client-src needs the ecmaVersion override `eslint-config-webpack@4.9.6` still ships `browser-outdated-recommended-commonjs` with `configs["javascript/es5"]` and no parser override, so `const` is rejected. The module variant of the same preset patches this upstream — we replicate the patch locally until the commonjs variant does the same. * refactor(client): migrate to ES modules and update Babel configuration * fixup!
1 parent b0edc58 commit c372588

17 files changed

Lines changed: 2218 additions & 28 deletions

.github/workflows/nodejs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ on:
55
branches:
66
- main
77
- next
8+
- hot-middleware
89
pull_request:
910
branches:
1011
- main
1112
- next
13+
- hot-middleware
1214

1315
permissions:
1416
contents: read

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ logs
66
npm-debug.log*
77
.eslintcache
88
.cspellcache
9+
/client
910
/dist
1011
/local
1112
/reports

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,77 @@ middleware(compiler, {
312312
});
313313
```
314314

315+
## Hot Module Replacement client
316+
317+
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`:
318+
319+
```js
320+
const webpack = require("webpack");
321+
322+
module.exports = {
323+
entry: ["webpack-dev-middleware/client", "./src/app.js"],
324+
plugins: [new webpack.HotModuleReplacementPlugin()],
325+
};
326+
```
327+
328+
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:
329+
330+
```js
331+
entry: [
332+
"webpack-dev-middleware/client?reload=true&overlay=false",
333+
"./src/app.js",
334+
];
335+
```
336+
337+
### Client options
338+
339+
| Name | Type | Default | Description |
340+
| :-----------------: | :-------: | :--------------: | :------------------------------------------------------------------------------------------------------------------ |
341+
| `path` | `string` | `/__webpack_hmr` | Path the SSE endpoint is served at. Must match the server `hot.path`. |
342+
| `timeout` | `number` | `20000` | Reconnection / heartbeat watchdog timeout in milliseconds. |
343+
| `overlay` | `boolean` | `true` | Show compile-time errors in an in-page overlay. |
344+
| `overlayWarnings` | `boolean` | `false` | Also show compile-time warnings in the overlay. |
345+
| `overlayStyles` | `Object` | `{}` | JSON object of CSS overrides for the overlay container. Pass JSON-encoded value via query string. |
346+
| `ansiColors` | `Object` | `{}` | JSON object overriding the ANSI → HTML color map used by the overlay. |
347+
| `reload` | `boolean` | `false` | Reload the page when an update cannot be applied through HMR. |
348+
| `logging` | `string` | `"info"` | Logger level — one of `"none"`, `"error"`, `"warn"`, `"info"`, `"log"`, `"verbose"`. Uses webpack's runtime logger. |
349+
| `name` | `string` | `""` | Restrict updates to a specific compilation name (useful with multi-compiler). |
350+
| `autoConnect` | `boolean` | `true` | Connect on load; set to `false` and call `setOptionsAndConnect()` manually. |
351+
| `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. |
352+
353+
### Programmatic API
354+
355+
`webpack-dev-middleware/client` also exports a few functions for advanced cases:
356+
357+
```js
358+
const hotClient = require("webpack-dev-middleware/client");
359+
360+
// Receive every HMR payload (building / built / sync / custom).
361+
hotClient.subscribeAll((payload) => {
362+
console.log("hot event", payload);
363+
});
364+
365+
// Receive payloads whose `action` is not recognised by the client (i.e. custom
366+
// payloads published via the server's `instance.context.hot.publish(...)`).
367+
hotClient.subscribe((payload) => {
368+
// do something
369+
});
370+
371+
// Replace the default error overlay with your own implementation.
372+
hotClient.useCustomOverlay({
373+
showProblems(type, lines) {
374+
/* ... */
375+
},
376+
clear() {
377+
/* ... */
378+
},
379+
});
380+
381+
// Connect manually when `autoConnect=false`. Accepts the same option keys as
382+
// the query-string API above.
383+
hotClient.setOptionsAndConnect({ path: "/__hmr" });
384+
```
385+
315386
## API
316387

317388
`webpack-dev-middleware` also provides convenience methods that can be use to

babel.config.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
1-
const MIN_BABEL_VERSION = 7;
2-
31
module.exports = (api) => {
4-
api.assertVersion(MIN_BABEL_VERSION);
52
api.cache(true);
63

74
return {
85
presets: [
96
[
107
"@babel/preset-env",
118
{
9+
modules: false,
1210
targets: {
13-
node: "20.9.0",
11+
esmodules: true,
12+
node: "0.12",
1413
},
1514
},
1615
],
1716
],
17+
env: {
18+
test: {
19+
presets: [
20+
[
21+
"@babel/preset-env",
22+
{
23+
targets: {
24+
node: "18.12.0",
25+
},
26+
},
27+
],
28+
],
29+
plugins: ["@babel/plugin-transform-runtime"],
30+
},
31+
},
1832
};
1933
};

client-src/globals.d.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/* eslint-disable */
2+
3+
declare module "ansi-html-community" {
4+
function ansiHtmlCommunity(str: string): string;
5+
namespace ansiHtmlCommunity {
6+
function setColors(colors: Record<string, string | string[]>): void;
7+
}
8+
export = ansiHtmlCommunity;
9+
}
10+
11+
interface ClientReporter {
12+
cleanProblemsCache(): void;
13+
problems(
14+
type: "errors" | "warnings",
15+
obj: { errors: string[]; warnings: string[]; name?: string },
16+
): boolean;
17+
success(): void;
18+
useCustomOverlay(customOverlay: unknown): void;
19+
}
20+
21+
interface EventSourceWrapper {
22+
addMessageListener(fn: (event: { data: string }) => void): void;
23+
}
24+
25+
interface Window {
26+
__wdmEventSourceWrapper?: Record<string, EventSourceWrapper>;
27+
__webpack_dev_middleware_hot_reporter__?: ClientReporter;
28+
}

0 commit comments

Comments
 (0)