webpack-dev-middleware
Version:
A development middleware for webpack
500 lines (464 loc) • 17.3 kB
JavaScript
;
/** @typedef {import("webpack").Compiler} Compiler */
/** @typedef {import("webpack").MultiCompiler} MultiCompiler */
/** @typedef {ReturnType<Compiler["getInfrastructureLogger"]>} Logger */
/** @typedef {import("webpack").Stats} Stats */
/** @typedef {import("webpack").MultiStats} MultiStats */
/** @typedef {import("webpack").StatsCompilation} StatsCompilation */
/** @typedef {import("webpack").StatsError} StatsError */
/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */
/** @typedef {import("./index.js").ServerResponse} ServerResponse */
// The object form only (no presets/booleans) — it is merged over the
// middleware's own base options, which string or boolean forms cannot be.
/** @typedef {import("webpack").StatsOptions} StatsOptions */
/** @typedef {import("webpack").Configuration["stats"]} MiddlewareStatsOption */
/**
* @typedef {object} HotOptions
* @property {string=} path the path the SSE endpoint is served at
* @property {number=} heartbeat heartbeat interval in milliseconds
* @property {StatsOptions=} statsOptions deprecated, removed in the next major release — webpack stats options used when serializing compilation results
* @property {boolean=} progress publish compilation progress events to the clients
*/
/**
* @typedef {object} Payload
* @property {string} action action
* @property {string=} file file that invalidated the compilation
* @property {string=} name name
* @property {number=} time time
* @property {string=} hash hash
* @property {number=} percent compilation progress (0-100)
* @property {string=} message progress message
* @property {string[]=} warnings warnings
* @property {string[]=} errors errors
*/
/**
* @typedef {object} EventStream
* @property {(req: IncomingMessage, res: ServerResponse) => void} handler attach a new client
* @property {() => boolean} hasClients true when at least one client is connected
* @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client
* @property {(res: ServerResponse, payload: Payload | { action: string }) => void} publishTo publish a payload to a single 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<number, ServerResponse>} */
let clients = new Map();
/**
* Run the callback for every client that can still be written to — a
* response ended between two `close` events would throw on write.
* @param {(client: ServerResponse) => void} fn each client callback
*/
const everyClient = fn => {
for (const client of clients.values()) {
if (!client.writableEnded) {
fn(client);
}
}
};
// Runs only while clients are connected: started with the first client,
// stopped with the last one.
/** @type {ReturnType<typeof setInterval> | null} */
let interval = null;
const startHeartbeat = () => {
if (interval !== null) {
return;
}
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();
}
};
const stopHeartbeat = () => {
if (interval !== null) {
clearInterval(interval);
interval = null;
}
};
return {
close() {
stopHeartbeat();
everyClient(client => {
client.end();
});
clients = new Map();
},
hasClients() {
return clients.size > 0;
},
handler(req, res) {
// A response another middleware already started can no longer become an
// SSE stream — end it instead of crashing on writeHead.
if (res.headersSent) {
if (!res.writableEnded) {
res.end();
}
return;
}
/** @type {Record<string, string>} */
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);
startHeartbeat();
logger.log(`Client connected (${clients.size} active)`);
const disconnect = () => {
if (!clients.has(id)) {
return;
}
if (!res.writableEnded) {
res.end();
}
clients.delete(id);
if (clients.size === 0) {
stopHeartbeat();
}
logger.log(`Client disconnected (${clients.size} active)`);
};
req.on("close", disconnect);
// A request that died before the handshake finished never emits `close`
// again, so it would stay in `clients` forever.
if (req.destroyed) {
disconnect();
}
},
publish(payload) {
// With no clients connected there is nothing to serialize for.
if (clients.size === 0) {
return;
}
const frame = `data: ${JSON.stringify(payload)}\n\n`;
everyClient(client => {
client.write(frame);
});
},
publishTo(res, payload) {
if (res.writableEnded) {
return;
}
res.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}`;
});
}
/**
* Which diagnostics a payload carries follows the middleware's `stats` option,
* so one setting governs what a build reports in the terminal and in the
* browser. Resolved per compilation, because only webpack knows what a preset
* like `"errors-only"` means.
* @param {Stats} stats stats
* @param {MiddlewareStatsOption} statsOption the middleware's `stats` option
* @returns {{ errors: boolean, warnings: boolean }} diagnostics to serialize
*/
function diagnosticsFrom(stats, statsOption) {
if (typeof statsOption === "undefined" || !stats.compilation) {
return {
errors: true,
warnings: true
};
}
const resolved = stats.compilation.createStatsOptions(statsOption, {
forToString: false
});
return {
errors: Boolean(resolved.errors),
warnings: Boolean(resolved.warnings)
};
}
/**
* @param {Stats} stats stats
* @param {StatsOptions} statsOptions stats options
* @returns {StatsCompilation} json stats with the compilation name resolved
*/
function normalizeStats(stats, statsOptions) {
const statsJson = stats.toJson(statsOptions);
// Resolved here so stored bundles do not retain Compilation objects.
if (!statsJson.name && stats.compilation) {
statsJson.name = stats.compilation.name || "";
}
return statsJson;
}
/**
* @param {Stats | MultiStats} statsResult stats result
* @param {StatsOptions | undefined} statsOptions deprecated `hot.statsOptions`
* @param {MiddlewareStatsOption=} statsOption the middleware's `stats` option
* @returns {StatsCompilation[]} normalized per-bundle stats
*/
function toBundles(statsResult, statsOptions, statsOption) {
/**
* @param {Stats} stats stats of one compilation
* @returns {StatsOptions} what to ask `toJson` for
*/
const optionsFor = stats => ({
all: false,
...diagnosticsFrom(stats, statsOption),
// TODO in the next major release remove `statsOptions` and this spread
...statsOptions,
// Not negotiable, whatever the options above ask for: without `hash` the
// client has nothing to compare and stops applying updates, without
// `timings` it reports `undefined` build times, and with `children` the
// payload would carry a child compilation's hash instead of the bundle's.
hash: true,
timings: true,
children: false
});
// Multi-compiler stats have stats for each child compiler.
if ("stats" in statsResult) {
return statsResult.stats.map(stats => normalizeStats(stats, optionsFor(stats)));
}
return [normalizeStats(statsResult, optionsFor(statsResult))];
}
/**
* @param {StatsCompilation} stats normalized per-bundle stats
* @param {"built" | "sync"} action action
* @returns {Payload} SSE payload
*/
function bundlePayload(stats, action) {
return {
name: stats.name || "",
action,
time: stats.time,
hash: stats.hash,
warnings: formatErrors(stats.warnings || []),
errors: formatErrors(stats.errors || [])
};
}
/**
* Publish one event per bundle. Bundles whose hash did not change are
* published as `sync`, so their clients do not fetch a hot-update manifest
* that was never emitted.
* @param {StatsCompilation[]} bundles bundles from the current build
* @param {StatsCompilation[] | null} previousBundles bundles from the previous build (null on the first build, which publishes everything as `built`)
* @param {EventStream} eventStream event stream
*/
function publishBundles(bundles, previousBundles, eventStream) {
// Grouped once up front so pairing stays linear with many child compilers.
/** @type {Map<string, StatsCompilation[]>} */
const previousByName = new Map();
if (previousBundles !== null) {
for (const bundle of previousBundles) {
const name = bundle.name || "";
const group = previousByName.get(name);
if (group) {
group.push(bundle);
} else {
previousByName.set(name, [bundle]);
}
}
}
/** @type {Map<string, number>} */
const occurrences = new Map();
for (const [index, stats] of bundles.entries()) {
const name = stats.name || "";
// Paired by name so a changing set of compilations (children appearing,
// config reloads) cannot compare a bundle against a sibling's hash.
// Webpack does not forbid duplicate names, so same-named bundles pair by
// occurrence; unnamed bundles fall back to their position.
let previous = null;
if (previousBundles !== null) {
if (name) {
const occurrence = occurrences.get(name) || 0;
occurrences.set(name, occurrence + 1);
const group = previousByName.get(name);
previous = group && group[occurrence] || null;
} else {
previous = previousBundles[index] || null;
}
}
const changed = previousBundles === null || previous === null || previous.hash !== stats.hash;
eventStream.publish(bundlePayload(stats, changed ? "built" : "sync"));
}
}
/**
* @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
* @param {MiddlewareStatsOption=} statsOption the middleware's `stats` option, which decides whether a payload carries errors and warnings
* @returns {HotInstance} hot instance
*/
function createHot(compiler, userOptions, statsOption) {
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");
// TODO in the next major release remove `statsOptions` and this warning
if (statsOptions) {
logger.warn("The 'hot.statsOptions' option is deprecated and will be removed in the next major release. Until then it still applies, apart from 'hash', 'timings' and 'children', which the client needs to apply an update. What a payload reports now follows the 'stats' option, and the browser console is the client's own '?logging=' and '?overlay=' options; to drop a warning everywhere at once, use webpack's 'ignoreWarnings'.");
}
let eventStream = createEventStream(heartbeat, logger);
logger.log(`Hot module replacement enabled, serving events at "${path}"`);
// `latestBundles` survives rebuilds so hashes can be compared per build.
/** @type {StatsCompilation[] | null} */
let latestBundles = null;
let valid = false;
let closed = false;
let lastProgressPercent = -1;
if (options.progress) {
const {
webpack
} = "compilers" in compiler ? compiler.compilers[0] : compiler;
// Published only when the rounded percent changes to keep the stream small.
new webpack.ProgressPlugin((percent, message) => {
if (closed || !eventStream.hasClients()) {
return;
}
const rounded = Math.round(percent * 100);
if (rounded === lastProgressPercent) {
return;
}
lastProgressPercent = rounded;
eventStream.publish({
action: "progress",
percent: rounded,
message: message || ""
});
}).apply(compiler);
}
/**
* @param {string=} name name of the compilation the hook belongs to
* @returns {(fileName?: string | null) => void} invalid hook handler
*/
const onInvalid = name => fileName => {
if (closed) return;
valid = false;
lastProgressPercent = -1;
/** @type {{ action: string, name?: string, file?: string }} */
const payload = {
action: "building"
};
// Named so clients can pair this event with the `built`/`sync` that
// follows it — the building indicator tracks in-flight builds per name.
if (name) {
payload.name = name;
}
// The invalid hook reports which file changed — forward it so clients
// can show what triggered the rebuild.
if (typeof fileName === "string" && fileName) {
payload.file = fileName;
}
eventStream.publish(payload);
};
/** @param {Stats | MultiStats} statsResult stats result */
const onDone = statsResult => {
if (closed) return;
const bundles = toBundles(statsResult, statsOptions, statsOption);
publishBundles(bundles, latestBundles, eventStream);
latestBundles = bundles;
valid = true;
};
// Tapped per child compiler rather than on the MultiCompiler hook, which
// does not say which compilation invalidated.
for (const child of "compilers" in compiler ? compiler.compilers : [compiler]) {
child.hooks.invalid.tap(PLUGIN_NAME, onInvalid(child.name));
}
compiler.hooks.done.tap(PLUGIN_NAME, onDone);
return {
path,
handle(req, res) {
// A request can race `close()` past the middleware intercept — end it
// instead of leaving it hanging without a response.
if (closed) {
res.writeHead(404);
res.end();
return;
}
eventStream.handler(req, res);
// Catch only the new client up, as `sync` events with the last hashes.
if (valid && latestBundles) {
for (const stats of latestBundles) {
eventStream.publishTo(res, bundlePayload(stats, "sync"));
}
}
},
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.createEventStream = createEventStream;
module.exports.createHot = createHot;
module.exports.formatErrors = formatErrors;
module.exports.pathMatch = pathMatch;
module.exports.publishBundles = publishBundles;
module.exports.toBundles = toBundles;