Skip to content

Commit e538245

Browse files
committed
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.
1 parent 2c14eb2 commit e538245

4 files changed

Lines changed: 435 additions & 0 deletions

File tree

src/hot.js

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

0 commit comments

Comments
 (0)