Skip to content

Commit 0f7859b

Browse files
committed
feat: implement hot module replacement middleware (#2321)
* fix(test): correct output path typo in webpack.array.warning fixture The first compiler entry used `../../outputs/...` which escaped the test directory and wrote artifacts to the repository root, outside of the `/test/outputs` paths covered by `.gitignore` and `.prettierignore`. * feat: implement hot module replacement middleware Adds a `hot: true | { path, heartbeat, log, statsOptions }` option that turns the dev middleware into a Server-Sent Events endpoint publishing `building`, `built` and `sync` payloads from the webpack compiler. The hot endpoint defaults to `/__webpack_hmr` and is served by the existing middleware - no separate `app.use()` call is required. `close()` tears down clients and the heartbeat timer. * test: add tests for hot middleware Covers schema validation of the `hot` option (success and failure cases with snapshots), unit tests for `pathMatch`, `formatErrors`, `buildModuleMap` and `createEventStream`, and integration tests that verify SSE headers, the default and custom hot paths, MultiCompiler support, `close()` teardown, and the `log` option (custom function and `log: false`). * test: add unit and integration tests for hot middleware functionality * feat: enhance honoWrapper to support Web ReadableStream for hot middleware responses * feat: add TypeScript definitions for hot module replacement functionality * test(hot): cover publish, sync-on-connect, headers and close behavior Ports the remaining unit-level cases from webpack-hot-middleware that were not already covered by the framework matrix in middleware.test.js: - the public `publish()` API broadcasts custom payloads - a client connecting after a build receives a `sync` event initialised from the last stats - HTTP/1 clients get `Connection: keep-alive`, HTTP/2 clients do not - when `stats.name` is empty the published payload falls back to `compilation.name` - a single broadcast reaches every attached client - after `close()` further compiler events do not produce writes * docs: document the hot option in README * docs: list the hot option in the README options table * refactor: replace EXPECTED_ANY with specific types in hot module definitions * docs: update README to clarify default stats options for SSE payload * refactor: remove log option from hot middleware and update related documentation * docs: update default value for hot option in README to false
1 parent c372588 commit 0f7859b

12 files changed

Lines changed: 1316 additions & 1 deletion

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ See [below](#other-servers) for an example of use with fastify.
7979
| **[`writeToDisk`](#writetodisk)** | `boolean\|Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your `webpack` configuration. |
8080
| **[`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. |
8181
| **[`modifyResponseData`](#modifyresponsedata)** | `Function` | `undefined` | Allows to set up a callback to change the response data. |
82+
| **[`hot`](#hot)** | `boolean\|Object` | `false` | Enables a Server-Sent Events endpoint that drives the browser HMR client. |
8283
| **[`forwardError`](#forwarderror)** | `boolean` | `false` | Enable or disable forwarding errors to the next middleware. |
8384

8485
The middleware accepts an `options` Object. The following is a property reference for the Object.
@@ -312,6 +313,44 @@ middleware(compiler, {
312313
});
313314
```
314315

316+
### hot
317+
318+
Type: `Boolean | Object`
319+
Default: `false`
320+
321+
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`.
322+
323+
```js
324+
const webpack = require("webpack");
325+
326+
const compiler = webpack({
327+
/* Webpack configuration with HotModuleReplacementPlugin and the client entry */
328+
});
329+
330+
middleware(compiler, { hot: true });
331+
```
332+
333+
#### `hot.path`
334+
335+
Type: `String`
336+
Default: `'/__webpack_hmr'`
337+
338+
Path the SSE endpoint is served at. Must match the `path` option used by the client.
339+
340+
#### `hot.heartbeat`
341+
342+
Type: `Number`
343+
Default: `10000`
344+
345+
Heartbeat interval (in milliseconds) used to keep the SSE connection alive when no compilation events are produced.
346+
347+
#### `hot.statsOptions`
348+
349+
Type: `Boolean | Object`
350+
Default: `undefined`
351+
352+
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.
353+
315354
## Hot Module Replacement client
316355

317356
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`:

src/hot.js

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
/** @typedef {import("webpack").Compiler} Compiler */
2+
/** @typedef {import("webpack").MultiCompiler} MultiCompiler */
3+
/** @typedef {ReturnType<Compiler["getInfrastructureLogger"]>} Logger */
4+
/** @typedef {import("webpack").Stats} Stats */
5+
/** @typedef {import("webpack").MultiStats} MultiStats */
6+
/** @typedef {import("webpack").StatsCompilation} StatsCompilation */
7+
/** @typedef {import("webpack").StatsError} StatsError */
8+
/** @typedef {import("webpack").StatsModule} StatsModule */
9+
/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */
10+
/** @typedef {import("./index.js").ServerResponse} ServerResponse */
11+
12+
/** @typedef {NonNullable<import("webpack").Configuration["stats"]>} StatsOptions */
13+
14+
/**
15+
* @typedef {object} HotOptions
16+
* @property {string=} path the path the SSE endpoint is served at
17+
* @property {number=} heartbeat heartbeat interval in milliseconds
18+
* @property {StatsOptions=} statsOptions webpack stats options used when serializing compilation results
19+
*/
20+
21+
/**
22+
* @typedef {object} Payload
23+
* @property {string} action action
24+
* @property {string=} name name
25+
* @property {number=} time time
26+
* @property {string=} hash hash
27+
* @property {string[]=} warnings warnings
28+
* @property {string[]=} errors errors
29+
* @property {Record<string, string>=} modules modules
30+
*/
31+
32+
/**
33+
* @typedef {object} EventStream
34+
* @property {(req: IncomingMessage, res: ServerResponse) => void} handler attach a new client
35+
* @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client
36+
* @property {() => void} close end every client and stop the heartbeat
37+
*/
38+
39+
const HOT_DEFAULT_PATH = "/__webpack_hmr";
40+
const HOT_DEFAULT_HEARTBEAT = 10 * 1000;
41+
const PLUGIN_NAME = "DevMiddleware";
42+
43+
/**
44+
* @param {string | undefined} url url
45+
* @param {string} expected expected pathname
46+
* @returns {boolean} true when the url pathname matches the expected path
47+
*/
48+
function pathMatch(url, expected) {
49+
if (!url) return false;
50+
51+
try {
52+
return new URL(url, "http://localhost").pathname === expected;
53+
} catch {
54+
return false;
55+
}
56+
}
57+
58+
/**
59+
* @param {number} heartbeat heartbeat interval in milliseconds
60+
* @param {Logger} logger logger
61+
* @returns {EventStream} event stream
62+
*/
63+
function createEventStream(heartbeat, logger) {
64+
let clientId = 0;
65+
/** @type {Map<number, ServerResponse>} */
66+
let clients = new Map();
67+
68+
/**
69+
* @param {(client: ServerResponse) => void} fn each client callback
70+
*/
71+
const everyClient = (fn) => {
72+
for (const client of clients.values()) {
73+
fn(client);
74+
}
75+
};
76+
77+
const interval = setInterval(() => {
78+
everyClient((client) => {
79+
client.write("data: 💓\n\n");
80+
});
81+
}, heartbeat);
82+
83+
// Don't block process exit on the heartbeat timer.
84+
if (typeof interval.unref === "function") {
85+
interval.unref();
86+
}
87+
88+
return {
89+
close() {
90+
clearInterval(interval);
91+
everyClient((client) => {
92+
if (!client.writableEnded) {
93+
client.end();
94+
}
95+
});
96+
clients = new Map();
97+
},
98+
handler(req, res) {
99+
/** @type {Record<string, string>} */
100+
const headers = {
101+
"Access-Control-Allow-Origin": "*",
102+
"Content-Type": "text/event-stream;charset=utf-8",
103+
"Cache-Control": "no-cache, no-transform",
104+
// While behind nginx, the event stream should not be buffered:
105+
// http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
106+
"X-Accel-Buffering": "no",
107+
};
108+
109+
const { httpVersion, socket } = req;
110+
const isHttp1 = !(Number.parseInt(httpVersion, 10) >= 2);
111+
112+
if (isHttp1) {
113+
if (socket && typeof socket.setKeepAlive === "function") {
114+
socket.setKeepAlive(true);
115+
}
116+
headers.Connection = "keep-alive";
117+
}
118+
119+
res.writeHead(200, headers);
120+
res.write("\n");
121+
122+
const id = clientId++;
123+
clients.set(id, res);
124+
logger.log(`Client connected (${clients.size} active)`);
125+
126+
req.on("close", () => {
127+
if (!res.writableEnded) {
128+
res.end();
129+
}
130+
clients.delete(id);
131+
logger.log(`Client disconnected (${clients.size} active)`);
132+
});
133+
},
134+
publish(payload) {
135+
everyClient((client) => {
136+
client.write(`data: ${JSON.stringify(payload)}\n\n`);
137+
});
138+
},
139+
};
140+
}
141+
142+
/**
143+
* @param {(string | StatsError)[]} errors errors or warnings
144+
* @returns {string[]} flat strings
145+
*/
146+
function formatErrors(errors) {
147+
if (!errors || errors.length === 0) {
148+
return [];
149+
}
150+
151+
if (typeof errors[0] === "string") {
152+
return /** @type {string[]} */ (errors);
153+
}
154+
155+
return /** @type {StatsError[]} */ (errors).map((error) => {
156+
const moduleName = error.moduleName || "";
157+
const loc = error.loc || "";
158+
159+
return `${moduleName} ${loc}\n${error.message}`;
160+
});
161+
}
162+
163+
/**
164+
* @param {Stats} stats stats
165+
* @param {StatsOptions} statsOptions stats options
166+
* @returns {StatsCompilation} json stats with compilation reference attached
167+
*/
168+
function normalizeStats(stats, statsOptions) {
169+
const statsJson = stats.toJson(statsOptions);
170+
171+
if (stats.compilation) {
172+
statsJson.compilation = stats.compilation;
173+
}
174+
175+
return statsJson;
176+
}
177+
178+
/**
179+
* @param {StatsCompilation} stats normalized stats
180+
* @returns {StatsCompilation[]} extracted bundles
181+
*/
182+
function extractBundles(stats) {
183+
if (stats.modules) {
184+
return [stats];
185+
}
186+
187+
if (stats.children && stats.children.length > 0) {
188+
return stats.children;
189+
}
190+
191+
return [stats];
192+
}
193+
194+
/**
195+
* @param {StatsModule[]} modules modules
196+
* @returns {Record<string, string>} module id to name map
197+
*/
198+
function buildModuleMap(modules) {
199+
/** @type {Record<string, string>} */
200+
const map = {};
201+
202+
for (const item of modules) {
203+
map[/** @type {string | number} */ (item.id)] = /** @type {string} */ (
204+
item.name
205+
);
206+
}
207+
208+
return map;
209+
}
210+
211+
/**
212+
* @param {string} action action
213+
* @param {Stats | MultiStats} statsResult stats result
214+
* @param {EventStream} eventStream event stream
215+
* @param {StatsOptions | undefined} statsOptions stats options
216+
*/
217+
function publishStats(action, statsResult, eventStream, statsOptions) {
218+
const resultStatsOptions = {
219+
all: false,
220+
hash: true,
221+
timings: true,
222+
errors: true,
223+
warnings: true,
224+
...(statsOptions && typeof statsOptions === "object" ? statsOptions : {}),
225+
};
226+
227+
/** @type {StatsCompilation[]} */
228+
let bundles;
229+
230+
// Multi-compiler stats have stats for each child compiler.
231+
if ("stats" in statsResult) {
232+
bundles = statsResult.stats.flatMap((stats) =>
233+
extractBundles(normalizeStats(stats, resultStatsOptions)),
234+
);
235+
} else {
236+
bundles = extractBundles(normalizeStats(statsResult, resultStatsOptions));
237+
}
238+
239+
for (const stats of bundles) {
240+
let name = stats.name || "";
241+
242+
// Fallback to compilation name when there is a single bundle.
243+
if (!name && stats.compilation) {
244+
name = stats.compilation.name || "";
245+
}
246+
247+
eventStream.publish({
248+
name,
249+
action,
250+
time: stats.time,
251+
hash: stats.hash,
252+
warnings: formatErrors(stats.warnings || []),
253+
errors: formatErrors(stats.errors || []),
254+
modules: buildModuleMap(stats.modules || []),
255+
});
256+
}
257+
}
258+
259+
/**
260+
* @typedef {object} HotInstance
261+
* @property {string} path path the SSE endpoint is served at
262+
* @property {(req: IncomingMessage, res: ServerResponse) => void} handle attach the request as a SSE client
263+
* @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client
264+
* @property {() => void} close end every client and detach the heartbeat
265+
*/
266+
267+
/**
268+
* @param {Compiler | MultiCompiler} compiler compiler
269+
* @param {HotOptions | true} userOptions options
270+
* @returns {HotInstance} hot instance
271+
*/
272+
function createHot(compiler, userOptions) {
273+
const options = userOptions === true ? {} : userOptions;
274+
const path = options.path || HOT_DEFAULT_PATH;
275+
const heartbeat = options.heartbeat || HOT_DEFAULT_HEARTBEAT;
276+
const { statsOptions } = options;
277+
const logger = compiler.getInfrastructureLogger("webpack-dev-middleware");
278+
279+
let eventStream = createEventStream(heartbeat, logger);
280+
logger.log(`Hot module replacement enabled, serving events at "${path}"`);
281+
/** @type {Stats | MultiStats | null} */
282+
let latestStats = null;
283+
let closed = false;
284+
285+
const onInvalid = () => {
286+
if (closed) return;
287+
288+
latestStats = null;
289+
290+
eventStream.publish({ action: "building" });
291+
};
292+
293+
/** @param {Stats | MultiStats} statsResult stats result */
294+
const onDone = (statsResult) => {
295+
if (closed) return;
296+
297+
latestStats = statsResult;
298+
publishStats("built", latestStats, eventStream, statsOptions);
299+
};
300+
301+
compiler.hooks.invalid.tap(PLUGIN_NAME, onInvalid);
302+
compiler.hooks.done.tap(PLUGIN_NAME, onDone);
303+
304+
return {
305+
path,
306+
handle(req, res) {
307+
if (closed) return;
308+
309+
eventStream.handler(req, res);
310+
311+
if (latestStats) {
312+
publishStats("sync", latestStats, eventStream, statsOptions);
313+
}
314+
},
315+
publish(payload) {
316+
if (closed) return;
317+
318+
eventStream.publish(payload);
319+
},
320+
close() {
321+
if (closed) return;
322+
323+
// Can't remove compiler plugins, so we set a flag and noop if closed.
324+
// https://github.com/webpack/tapable/issues/32#issuecomment-350644466
325+
closed = true;
326+
eventStream.close();
327+
eventStream = /** @type {EventStream} */ (/** @type {unknown} */ (null));
328+
},
329+
};
330+
}
331+
332+
module.exports = createHot;
333+
module.exports.HOT_DEFAULT_HEARTBEAT = HOT_DEFAULT_HEARTBEAT;
334+
module.exports.HOT_DEFAULT_PATH = HOT_DEFAULT_PATH;
335+
module.exports.buildModuleMap = buildModuleMap;
336+
module.exports.createEventStream = createEventStream;
337+
module.exports.createHot = createHot;
338+
module.exports.formatErrors = formatErrors;
339+
module.exports.pathMatch = pathMatch;
340+
module.exports.publishStats = publishStats;

0 commit comments

Comments
 (0)