From d160c6874ec0baed161c9dee3b21512b33d59859 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 15:19:33 -0500 Subject: [PATCH 01/10] 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`. --- test/fixtures/webpack.array.warning.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/webpack.array.warning.config.js b/test/fixtures/webpack.array.warning.config.js index be0a5557f..415685d4b 100644 --- a/test/fixtures/webpack.array.warning.config.js +++ b/test/fixtures/webpack.array.warning.config.js @@ -9,7 +9,7 @@ module.exports = [ entry: './warning.js', output: { filename: 'bundle.js', - path: path.resolve(__dirname, '../../outputs/array-warning/js1'), + path: path.resolve(__dirname, '../outputs/array-warning/js1'), publicPath: '/static-one/', }, plugins: [ From bc3fdd59dba634b69d826e588b078470a98ea473 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 15:20:49 -0500 Subject: [PATCH 02/10] 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. --- src/hot.js | 356 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.js | 22 +++ src/middleware.js | 8 ++ src/options.json | 49 +++++++ 4 files changed, 435 insertions(+) create mode 100644 src/hot.js diff --git a/src/hot.js b/src/hot.js new file mode 100644 index 000000000..83d76a488 --- /dev/null +++ b/src/hot.js @@ -0,0 +1,356 @@ +/** @typedef {import("webpack").Compiler} Compiler */ +/** @typedef {import("webpack").MultiCompiler} MultiCompiler */ +/** @typedef {import("webpack").Stats} Stats */ +/** @typedef {import("webpack").MultiStats} MultiStats */ +/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ +/** @typedef {import("./index.js").ServerResponse} ServerResponse */ + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** + * @typedef {object} HotOptions + * @property {string=} path the path the SSE endpoint is served at + * @property {number=} heartbeat heartbeat interval in milliseconds + * @property {((message: string) => void) | false=} log logger + * @property {EXPECTED_ANY=} statsOptions webpack stats options used when serializing compilation results + */ + +/** + * @typedef {object} Payload + * @property {string} action action + * @property {string=} name name + * @property {number=} time time + * @property {string=} hash hash + * @property {string[]=} warnings warnings + * @property {string[]=} errors errors + * @property {Record=} modules modules + */ + +/** + * @typedef {object} EventStream + * @property {(req: IncomingMessage, res: ServerResponse) => void} handler attach a new client + * @property {(payload: Payload | { action: string }) => void} publish publish a payload to every 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 + * @returns {EventStream} event stream + */ +function createEventStream(heartbeat) { + let clientId = 0; + /** @type {Map} */ + let clients = new Map(); + + /** + * @param {(client: ServerResponse) => void} fn each client callback + */ + const everyClient = (fn) => { + for (const client of clients.values()) { + fn(client); + } + }; + + const interval = setInterval(() => { + everyClient((client) => { + client.write("data: 💓\n\n"); + }); + }, heartbeat); + + // Don't block process exit on the heartbeat timer. + if (typeof (/** @type {EXPECTED_ANY} */ (interval).unref) === "function") { + /** @type {EXPECTED_ANY} */ + (interval).unref(); + } + + return { + close() { + clearInterval(interval); + everyClient((client) => { + if (!(/** @type {EXPECTED_ANY} */ (client).writableEnded)) { + client.end(); + } + }); + clients = new Map(); + }, + handler(req, res) { + /** @type {Record} */ + 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 } = /** @type {EXPECTED_ANY} */ (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); + + req.on("close", () => { + if (!(/** @type {EXPECTED_ANY} */ (res).writableEnded)) { + res.end(); + } + clients.delete(id); + }); + }, + publish(payload) { + everyClient((client) => { + client.write(`data: ${JSON.stringify(payload)}\n\n`); + }); + }, + }; +} + +/** + * @param {EXPECTED_ANY[]} 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 errors.map((error) => { + const moduleName = error.moduleName || ""; + const loc = error.loc || ""; + + return `${moduleName} ${loc}\n${error.message}`; + }); +} + +/** + * @param {EXPECTED_ANY} stats stats + * @param {EXPECTED_ANY} statsOptions stats options + * @returns {EXPECTED_ANY} json stats with compilation reference attached + */ +function normalizeStats(stats, statsOptions) { + const statsJson = stats.toJson(statsOptions); + + if (stats.compilation) { + statsJson.compilation = stats.compilation; + } + + return statsJson; +} + +/** + * @param {EXPECTED_ANY} stats normalized stats + * @returns {EXPECTED_ANY[]} extracted bundles + */ +function extractBundles(stats) { + if (stats.modules) { + return [stats]; + } + + if (stats.children && stats.children.length > 0) { + return stats.children; + } + + return [stats]; +} + +/** + * @param {EXPECTED_ANY[]} modules modules + * @returns {Record} module id to name map + */ +function buildModuleMap(modules) { + /** @type {Record} */ + const map = {}; + + for (const item of modules) { + map[item.id] = item.name; + } + + return map; +} + +/** + * @param {string} action action + * @param {Stats | MultiStats} statsResult stats result + * @param {EventStream} eventStream event stream + * @param {((message: string) => void) | false} log logger or false to disable + * @param {EXPECTED_ANY} statsOptions stats options + */ +function publishStats(action, statsResult, eventStream, log, statsOptions) { + const resultStatsOptions = { + all: false, + cached: true, + children: true, + modules: true, + timings: true, + hash: true, + errors: true, + warnings: true, + ...(statsOptions && typeof statsOptions === "object" ? statsOptions : {}), + }; + + /** @type {EXPECTED_ANY[]} */ + let bundles = []; + + // Multi-compiler stats have stats for each child compiler. + if (/** @type {EXPECTED_ANY} */ (statsResult).stats) { + bundles = /** @type {EXPECTED_ANY} */ (statsResult).stats.flatMap( + /** + * @param {EXPECTED_ANY} stats stats + * @returns {EXPECTED_ANY[]} extracted bundles + */ + (stats) => extractBundles(normalizeStats(stats, resultStatsOptions)), + ); + } else { + bundles = extractBundles(normalizeStats(statsResult, resultStatsOptions)); + } + + for (const stats of bundles) { + let name = stats.name || ""; + + // Fallback to compilation name when there is a single bundle. + if (!name && stats.compilation) { + name = stats.compilation.name || ""; + } + + if (log) { + log( + `webpack built ${name ? `${name} ` : ""}${stats.hash} in ${stats.time}ms`, + ); + } + + eventStream.publish({ + name, + action, + time: stats.time, + hash: stats.hash, + warnings: formatErrors(stats.warnings || []), + errors: formatErrors(stats.errors || []), + modules: buildModuleMap(stats.modules), + }); + } +} + +/** + * @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 + * @returns {HotInstance} hot instance + */ +function createHot(compiler, userOptions) { + const options = userOptions === true ? {} : userOptions; + const path = options.path || HOT_DEFAULT_PATH; + const heartbeat = options.heartbeat || HOT_DEFAULT_HEARTBEAT; + const log = + options.log === false + ? false + : typeof options.log === "function" + ? options.log + : // eslint-disable-next-line no-console + console.log.bind(console); + const { statsOptions } = options; + + let eventStream = createEventStream(heartbeat); + /** @type {Stats | MultiStats | null} */ + let latestStats = null; + let closed = false; + + const onInvalid = () => { + if (closed) return; + + latestStats = null; + + if (log) log("webpack building..."); + + eventStream.publish({ action: "building" }); + }; + + /** @param {Stats | MultiStats} statsResult stats result */ + const onDone = (statsResult) => { + if (closed) return; + + latestStats = statsResult; + publishStats("built", latestStats, eventStream, log, statsOptions); + }; + + compiler.hooks.invalid.tap(PLUGIN_NAME, onInvalid); + compiler.hooks.done.tap(PLUGIN_NAME, onDone); + + return { + path, + handle(req, res) { + if (closed) return; + + eventStream.handler(req, res); + + if (latestStats) { + // Explicitly not passing in `log` so we don't double-log on the server. + publishStats("sync", latestStats, eventStream, false, statsOptions); + } + }, + 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.buildModuleMap = buildModuleMap; +module.exports.createEventStream = createEventStream; +module.exports.createHot = createHot; +module.exports.formatErrors = formatErrors; +module.exports.pathMatch = pathMatch; +module.exports.publishStats = publishStats; diff --git a/src/index.js b/src/index.js index 053d3b45e..ad32573c5 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ const path = require("node:path"); const memfs = require("memfs"); const mime = require("mime-types"); +const { createHot } = require("./hot"); const middleware = require("./middleware"); const { nodeReadableToWebStream } = require("./utils"); @@ -16,6 +17,8 @@ const noop = () => {}; /** @typedef {import("webpack").MultiStats} MultiStats */ /** @typedef {import("fs").ReadStream} ReadStream */ /** @typedef {import("./middleware").FilenameWithExtra} FilenameWithExtra */ +/** @typedef {import("./hot").HotOptions} HotOptions */ +/** @typedef {import("./hot").HotInstance} HotInstance */ // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ @@ -76,6 +79,7 @@ const noop = () => {}; * @property {Watching | MultiWatching} watching watching * @property {Logger} logger logger * @property {OutputFileSystem} outputFileSystem output file system + * @property {HotInstance=} hot hot module replacement instance */ /** @@ -112,6 +116,7 @@ const noop = () => {}; * @property {(boolean | number | string | { maxAge?: number, immutable?: boolean })=} cacheControl options to generate cache headers * @property {boolean=} cacheImmutable is cache immutable * @property {boolean=} forwardError forward error to next middleware + * @property {(boolean | HotOptions)=} hot enable hot module replacement */ /** @@ -504,6 +509,20 @@ function wdm(compiler, options = {}, isPlugin = false) { compiler.hooks.invalid.tap(PLUGIN_NAME, invalid); compiler.hooks.done.tap(PLUGIN_NAME, done); + if (options.hot) { + const hotUserOptions = options.hot === true ? {} : options.hot; + const userLog = hotUserOptions.log; + context.hot = createHot(compiler, { + ...hotUserOptions, + log: + userLog === undefined + ? /** @param {string} message message */ (message) => { + context.logger.log(message); + } + : userLog, + }); + } + const compilersToModify = isMultipleCompiler(compiler) ? compiler.compilers.filter((item) => item.options.devServer !== false) : [compiler]; @@ -600,6 +619,9 @@ function wdm(compiler, options = {}, isPlugin = false) { }; instance.close = (callback = noop) => { + if (filledContext.hot) { + filledContext.hot.close(); + } filledContext.watching.close(callback); }; diff --git a/src/middleware.js b/src/middleware.js index e92ec449c..6f84d8a5b 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -4,6 +4,8 @@ const querystring = require("node:querystring"); const mime = require("mime-types"); const onFinishedStream = require("on-finished"); +const { pathMatch: hotPathMatch } = require("./hot"); + const { createReadStreamOrReadFile, destroyStream, @@ -376,6 +378,12 @@ function ready(context, callback, req) { */ function wrapper(context) { return async function middleware(req, res, next) { + // Intercept Server-Sent Events handshake when the `hot` option is enabled. + if (context.hot && hotPathMatch(getRequestURL(req), context.hot.path)) { + context.hot.handle(req, res); + return; + } + /** * @param {NodeJS.ErrnoException=} err an error * @returns {Promise} diff --git a/src/options.json b/src/options.json index 1e83adaa1..c37ddf866 100644 --- a/src/options.json +++ b/src/options.json @@ -177,6 +177,55 @@ "description": "Enable or disable forwarding errors to next middleware.", "link": "https://github.com/webpack/webpack-dev-middleware#forwarderrors", "type": "boolean" + }, + "hot": { + "description": "Enable hot module replacement via a Server-Sent Events endpoint.", + "link": "https://github.com/webpack/webpack-dev-middleware#hot", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "description": "The path the SSE endpoint is served at.", + "type": "string", + "minLength": 1 + }, + "heartbeat": { + "description": "Heartbeat interval (in milliseconds) used to keep the SSE connection alive.", + "type": "number", + "minimum": 0 + }, + "log": { + "description": "Logger used to print build status. Pass `false` to disable.", + "anyOf": [ + { + "type": "boolean", + "enum": [false] + }, + { + "instanceof": "Function" + } + ] + }, + "statsOptions": { + "description": "Webpack stats options used when serializing compilation results.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "additionalProperties": true + } + ] + } + } + } + ] } }, "additionalProperties": false From fe9625d098e5eebacf9708852890af7a8ad41377 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 15:21:27 -0500 Subject: [PATCH 03/10] 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`). --- .../validation-options.test.js.snap.webpack5 | 57 +++ test/hot.test.js | 360 ++++++++++++++++++ test/validation-options.test.js | 21 + 3 files changed, 438 insertions(+) create mode 100644 test/hot.test.js diff --git a/test/__snapshots__/validation-options.test.js.snap.webpack5 b/test/__snapshots__/validation-options.test.js.snap.webpack5 index ec12f26e9..e74758140 100644 --- a/test/__snapshots__/validation-options.test.js.snap.webpack5 +++ b/test/__snapshots__/validation-options.test.js.snap.webpack5 @@ -75,6 +75,63 @@ exports[`validation should throw an error on the "headers" option with "true" va * options.headers should be an instance of function." `; +exports[`validation should throw an error on the "hot" option with "{"heartbeat":-1}" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot.heartbeat should be >= 0. + -> Heartbeat interval (in milliseconds) used to keep the SSE connection alive." +`; + +exports[`validation should throw an error on the "hot" option with "{"log":true}" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot should be one of these: + boolean | object { path?, heartbeat?, log?, statsOptions? } + -> Enable hot module replacement via a Server-Sent Events endpoint. + -> Read more at https://github.com/webpack/webpack-dev-middleware#hot + Details: + * options.hot.log should be one of these: + false | function + -> Logger used to print build status. Pass \`false\` to disable. + Details: + * options.hot.log should be false. + * options.hot.log should be an instance of function." +`; + +exports[`validation should throw an error on the "hot" option with "{"path":""}" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot.path should be a non-empty string. + -> The path the SSE endpoint is served at." +`; + +exports[`validation should throw an error on the "hot" option with "{"unknown":true}" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot has an unknown property 'unknown'. These properties are valid: + object { path?, heartbeat?, log?, statsOptions? }" +`; + +exports[`validation should throw an error on the "hot" option with "0" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot should be one of these: + boolean | object { path?, heartbeat?, log?, statsOptions? } + -> Enable hot module replacement via a Server-Sent Events endpoint. + -> Read more at https://github.com/webpack/webpack-dev-middleware#hot + Details: + * options.hot should be a boolean. + * options.hot should be an object: + object { path?, heartbeat?, log?, statsOptions? }" +`; + +exports[`validation should throw an error on the "hot" option with "foo" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.hot should be one of these: + boolean | object { path?, heartbeat?, log?, statsOptions? } + -> Enable hot module replacement via a Server-Sent Events endpoint. + -> Read more at https://github.com/webpack/webpack-dev-middleware#hot + Details: + * options.hot should be a boolean. + * options.hot should be an object: + object { path?, heartbeat?, log?, statsOptions? }" +`; + exports[`validation should throw an error on the "index" option with "{}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.index should be one of these: diff --git a/test/hot.test.js b/test/hot.test.js new file mode 100644 index 000000000..54de3dac9 --- /dev/null +++ b/test/hot.test.js @@ -0,0 +1,360 @@ +import express from "express"; +import request from "supertest"; + +import middleware from "../src"; +import { + HOT_DEFAULT_HEARTBEAT, + HOT_DEFAULT_PATH, + buildModuleMap, + createEventStream, + formatErrors, + pathMatch, +} from "../src/hot"; + +import webpackArrayConfig from "./fixtures/webpack.array.config"; +import webpackConfig from "./fixtures/webpack.config"; +import getCompiler from "./helpers/getCompiler"; + +jest.spyOn(globalThis.console, "log").mockImplementation(); + +describe("hot middleware (unit)", () => { + describe("pathMatch", () => { + it("matches exact pathname", () => { + expect(pathMatch("/__webpack_hmr", "/__webpack_hmr")).toBe(true); + }); + + it("strips query string", () => { + expect(pathMatch("/__webpack_hmr?name=app", "/__webpack_hmr")).toBe(true); + }); + + it("returns false on mismatch", () => { + expect(pathMatch("/bundle.js", "/__webpack_hmr")).toBe(false); + }); + + it("returns false when url is undefined", () => { + expect(pathMatch(undefined, "/__webpack_hmr")).toBe(false); + }); + + it("returns false on malformed urls without throwing", () => { + expect(pathMatch("http://[bad", "/__webpack_hmr")).toBe(false); + }); + }); + + describe("formatErrors", () => { + it("returns an empty array when input is empty", () => { + expect(formatErrors([])).toEqual([]); + }); + + it("passes through string errors unchanged", () => { + expect(formatErrors(["boom"])).toEqual(["boom"]); + }); + + it("formats webpack 5 error objects", () => { + expect( + formatErrors([{ moduleName: "./foo.js", loc: "1:1", message: "boom" }]), + ).toEqual(["./foo.js 1:1\nboom"]); + }); + + it("tolerates missing moduleName and loc", () => { + expect(formatErrors([{ message: "boom" }])).toEqual([" \nboom"]); + }); + }); + + describe("buildModuleMap", () => { + it("maps id to name", () => { + expect( + buildModuleMap([ + { id: 1, name: "./a.js" }, + { id: 2, name: "./b.js" }, + ]), + ).toEqual({ 1: "./a.js", 2: "./b.js" }); + }); + }); + + describe("createEventStream", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("emits a heartbeat at the configured interval", () => { + const stream = createEventStream(1000); + const writes = []; + const fakeRes = { + writableEnded: false, + write: (chunk) => writes.push(chunk), + writeHead: () => {}, + end: () => {}, + }; + const fakeReq = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: () => {}, + }; + + stream.handler(fakeReq, fakeRes); + writes.length = 0; + jest.advanceTimersByTime(1000); + + expect(writes.some((w) => w.includes("💓"))).toBe(true); + + stream.close(); + }); + + it("publishes JSON payloads to attached clients", () => { + const stream = createEventStream(5000); + const writes = []; + const fakeRes = { + writableEnded: false, + write: (chunk) => writes.push(chunk), + writeHead: () => {}, + end: () => {}, + }; + const fakeReq = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: () => {}, + }; + + stream.handler(fakeReq, fakeRes); + stream.publish({ action: "built", hash: "abc" }); + + expect(writes.some((w) => w.includes('"action":"built"'))).toBe(true); + expect(writes.some((w) => w.includes('"hash":"abc"'))).toBe(true); + + stream.close(); + }); + + it("close ends connected clients", () => { + const stream = createEventStream(5000); + let ended = false; + const fakeRes = { + writableEnded: false, + write: () => {}, + writeHead: () => {}, + end: () => { + ended = true; + }, + }; + const fakeReq = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: () => {}, + }; + + stream.handler(fakeReq, fakeRes); + stream.close(); + + expect(ended).toBe(true); + }); + }); +}); + +describe("hot middleware (integration)", () => { + /** + * @param {object=} hotOptions hot options + * @param {object=} extra additional middleware options + * @param {object=} config webpack config used to create the compiler + * @returns {Promise<{ app: import("express").Express, instance: ReturnType }>} app and middleware instance + */ + async function setup(hotOptions = true, extra = {}, config = webpackConfig) { + const compiler = getCompiler(config); + const instance = middleware(compiler, { hot: hotOptions, ...extra }); + const app = express(); + app.use(instance); + + await new Promise((resolve, reject) => { + instance.waitUntilValid((stats) => + stats && stats.hasErrors() ? reject(stats.toString()) : resolve(), + ); + }); + + return { app, instance }; + } + + it("exposes default constants", () => { + expect(HOT_DEFAULT_PATH).toBe("/__webpack_hmr"); + expect(HOT_DEFAULT_HEARTBEAT).toBe(10 * 1000); + }); + + it("serves SSE on the default path with correct headers", async () => { + const { app, instance } = await setup(true); + + try { + await new Promise((resolve, reject) => { + request(app) + .get(HOT_DEFAULT_PATH) + .buffer(false) + .parse((res, cb) => { + res.headers["content-type"] ||= ""; + // Detach as soon as we got the headers. + res.on("data", () => {}); + res.on("end", () => cb(null, "")); + // Force-close after a short window to terminate the SSE stream. + setTimeout(() => res.destroy(), 50); + }) + .end((err, res) => { + if (err && err.code !== "ECONNRESET") return reject(err); + try { + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + expect(res.headers["cache-control"]).toBe( + "no-cache, no-transform", + ); + expect(res.headers["x-accel-buffering"]).toBe("no"); + resolve(); + } catch (err_) { + reject(err_); + } + }); + }); + } finally { + await new Promise((resolve) => { + instance.close(resolve); + }); + } + }); + + it("respects a custom path", async () => { + const { app, instance } = await setup({ path: "/__custom_hmr" }); + + try { + // Custom path responds as SSE. + await new Promise((resolve, reject) => { + request(app) + .get("/__custom_hmr") + .buffer(false) + .parse((res, cb) => { + res.on("data", () => {}); + res.on("end", () => cb(null, "")); + setTimeout(() => res.destroy(), 50); + }) + .end((err, res) => { + if (err && err.code !== "ECONNRESET") return reject(err); + try { + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + resolve(); + } catch (err_) { + reject(err_); + } + }); + }); + + // Default path is no longer hooked. + const res = await request(app).get(HOT_DEFAULT_PATH); + expect(res.headers["content-type"] || "").not.toMatch(/event-stream/); + } finally { + await new Promise((resolve) => { + instance.close(resolve); + }); + } + }); + + it("does not intercept non-hot URLs", async () => { + const { app, instance } = await setup(true); + + try { + const res = await request(app).get("/bundle.js"); + expect(res.status).toBe(200); + expect(res.headers["content-type"] || "").not.toMatch(/event-stream/); + } finally { + await new Promise((resolve) => { + instance.close(resolve); + }); + } + }); + + it("works with MultiCompiler", async () => { + const { app, instance } = await setup(true, {}, webpackArrayConfig); + + try { + await new Promise((resolve, reject) => { + request(app) + .get(HOT_DEFAULT_PATH) + .buffer(false) + .parse((res, cb) => { + res.on("data", () => {}); + res.on("end", () => cb(null, "")); + setTimeout(() => res.destroy(), 50); + }) + .end((err, res) => { + if (err && err.code !== "ECONNRESET") return reject(err); + try { + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + resolve(); + } catch (err_) { + reject(err_); + } + }); + }); + } finally { + await new Promise((resolve) => { + instance.close(resolve); + }); + } + }); + + it("close() tears down hot connections", async () => { + const { instance } = await setup(true); + + // Closing must not throw nor leave open intervals. + await new Promise((resolve) => { + instance.close(resolve); + }); + + expect(() => + instance.context.hot.publish({ action: "noop" }), + ).not.toThrow(); + }); + + it("calls a custom log function on build events", async () => { + const messages = []; + const { instance } = await setup({ + log: (msg) => messages.push(msg), + }); + + try { + // Force an invalid + done cycle. + await new Promise((resolve) => { + instance.invalidate(() => resolve()); + }); + + expect(messages.some((m) => m.startsWith("webpack built"))).toBe(true); + } finally { + await new Promise((resolve) => { + instance.close(resolve); + }); + } + }); + + it("respects log: false (no console output from hot)", async () => { + const spy = jest + .spyOn(globalThis.console, "log") + .mockImplementation(() => {}); + + const { instance } = await setup({ log: false }); + + try { + await new Promise((resolve) => { + instance.invalidate(() => resolve()); + }); + + const fromHot = spy.mock.calls.filter( + ([msg]) => + typeof msg === "string" && + (msg.startsWith("webpack built") || msg === "webpack building..."), + ); + expect(fromHot).toHaveLength(0); + } finally { + await new Promise((resolve) => { + instance.close(resolve); + }); + spy.mockRestore(); + } + }); +}); diff --git a/test/validation-options.test.js b/test/validation-options.test.js index 79bb801b3..db77870c4 100644 --- a/test/validation-options.test.js +++ b/test/validation-options.test.js @@ -85,6 +85,27 @@ describe("validation", () => { success: [true, false], failure: ["foo", 0], }, + hot: { + success: [ + true, + false, + {}, + { path: "/__hmr" }, + { heartbeat: 1000 }, + { log: false }, + { log: () => {} }, + { statsOptions: true }, + { statsOptions: { all: false } }, + ], + failure: [ + "foo", + 0, + { path: "" }, + { heartbeat: -1 }, + { log: true }, + { unknown: true }, + ], + }, }; // eslint-disable-next-line jsdoc/reject-any-type From 421d754642fc83a6e02f4d3db8a02241b70e814e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 15:52:41 -0500 Subject: [PATCH 04/10] ci: run on push and PRs against the hot-middleware umbrella branch --- .github/workflows/nodejs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index b5008e777..fa10c0b64 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 From c10b2d08dca1f5b6918c03f789f93cc2352d13c3 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 15:56:25 -0500 Subject: [PATCH 05/10] test: add unit and integration tests for hot middleware functionality --- test/hot.test.js | 216 ---------------------------------------- test/middleware.test.js | 116 +++++++++++++++++++++ 2 files changed, 116 insertions(+), 216 deletions(-) diff --git a/test/hot.test.js b/test/hot.test.js index 54de3dac9..8e7ae3c0c 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -1,20 +1,10 @@ -import express from "express"; -import request from "supertest"; - -import middleware from "../src"; import { - HOT_DEFAULT_HEARTBEAT, - HOT_DEFAULT_PATH, buildModuleMap, createEventStream, formatErrors, pathMatch, } from "../src/hot"; -import webpackArrayConfig from "./fixtures/webpack.array.config"; -import webpackConfig from "./fixtures/webpack.config"; -import getCompiler from "./helpers/getCompiler"; - jest.spyOn(globalThis.console, "log").mockImplementation(); describe("hot middleware (unit)", () => { @@ -152,209 +142,3 @@ describe("hot middleware (unit)", () => { }); }); }); - -describe("hot middleware (integration)", () => { - /** - * @param {object=} hotOptions hot options - * @param {object=} extra additional middleware options - * @param {object=} config webpack config used to create the compiler - * @returns {Promise<{ app: import("express").Express, instance: ReturnType }>} app and middleware instance - */ - async function setup(hotOptions = true, extra = {}, config = webpackConfig) { - const compiler = getCompiler(config); - const instance = middleware(compiler, { hot: hotOptions, ...extra }); - const app = express(); - app.use(instance); - - await new Promise((resolve, reject) => { - instance.waitUntilValid((stats) => - stats && stats.hasErrors() ? reject(stats.toString()) : resolve(), - ); - }); - - return { app, instance }; - } - - it("exposes default constants", () => { - expect(HOT_DEFAULT_PATH).toBe("/__webpack_hmr"); - expect(HOT_DEFAULT_HEARTBEAT).toBe(10 * 1000); - }); - - it("serves SSE on the default path with correct headers", async () => { - const { app, instance } = await setup(true); - - try { - await new Promise((resolve, reject) => { - request(app) - .get(HOT_DEFAULT_PATH) - .buffer(false) - .parse((res, cb) => { - res.headers["content-type"] ||= ""; - // Detach as soon as we got the headers. - res.on("data", () => {}); - res.on("end", () => cb(null, "")); - // Force-close after a short window to terminate the SSE stream. - setTimeout(() => res.destroy(), 50); - }) - .end((err, res) => { - if (err && err.code !== "ECONNRESET") return reject(err); - try { - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toMatch(/text\/event-stream/); - expect(res.headers["cache-control"]).toBe( - "no-cache, no-transform", - ); - expect(res.headers["x-accel-buffering"]).toBe("no"); - resolve(); - } catch (err_) { - reject(err_); - } - }); - }); - } finally { - await new Promise((resolve) => { - instance.close(resolve); - }); - } - }); - - it("respects a custom path", async () => { - const { app, instance } = await setup({ path: "/__custom_hmr" }); - - try { - // Custom path responds as SSE. - await new Promise((resolve, reject) => { - request(app) - .get("/__custom_hmr") - .buffer(false) - .parse((res, cb) => { - res.on("data", () => {}); - res.on("end", () => cb(null, "")); - setTimeout(() => res.destroy(), 50); - }) - .end((err, res) => { - if (err && err.code !== "ECONNRESET") return reject(err); - try { - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toMatch(/text\/event-stream/); - resolve(); - } catch (err_) { - reject(err_); - } - }); - }); - - // Default path is no longer hooked. - const res = await request(app).get(HOT_DEFAULT_PATH); - expect(res.headers["content-type"] || "").not.toMatch(/event-stream/); - } finally { - await new Promise((resolve) => { - instance.close(resolve); - }); - } - }); - - it("does not intercept non-hot URLs", async () => { - const { app, instance } = await setup(true); - - try { - const res = await request(app).get("/bundle.js"); - expect(res.status).toBe(200); - expect(res.headers["content-type"] || "").not.toMatch(/event-stream/); - } finally { - await new Promise((resolve) => { - instance.close(resolve); - }); - } - }); - - it("works with MultiCompiler", async () => { - const { app, instance } = await setup(true, {}, webpackArrayConfig); - - try { - await new Promise((resolve, reject) => { - request(app) - .get(HOT_DEFAULT_PATH) - .buffer(false) - .parse((res, cb) => { - res.on("data", () => {}); - res.on("end", () => cb(null, "")); - setTimeout(() => res.destroy(), 50); - }) - .end((err, res) => { - if (err && err.code !== "ECONNRESET") return reject(err); - try { - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toMatch(/text\/event-stream/); - resolve(); - } catch (err_) { - reject(err_); - } - }); - }); - } finally { - await new Promise((resolve) => { - instance.close(resolve); - }); - } - }); - - it("close() tears down hot connections", async () => { - const { instance } = await setup(true); - - // Closing must not throw nor leave open intervals. - await new Promise((resolve) => { - instance.close(resolve); - }); - - expect(() => - instance.context.hot.publish({ action: "noop" }), - ).not.toThrow(); - }); - - it("calls a custom log function on build events", async () => { - const messages = []; - const { instance } = await setup({ - log: (msg) => messages.push(msg), - }); - - try { - // Force an invalid + done cycle. - await new Promise((resolve) => { - instance.invalidate(() => resolve()); - }); - - expect(messages.some((m) => m.startsWith("webpack built"))).toBe(true); - } finally { - await new Promise((resolve) => { - instance.close(resolve); - }); - } - }); - - it("respects log: false (no console output from hot)", async () => { - const spy = jest - .spyOn(globalThis.console, "log") - .mockImplementation(() => {}); - - const { instance } = await setup({ log: false }); - - try { - await new Promise((resolve) => { - instance.invalidate(() => resolve()); - }); - - const fromHot = spy.mock.calls.filter( - ([msg]) => - typeof msg === "string" && - (msg.startsWith("webpack built") || msg === "webpack building..."), - ); - expect(fromHot).toHaveLength(0); - } finally { - await new Promise((resolve) => { - instance.close(resolve); - }); - spy.mockRestore(); - } - }); -}); diff --git a/test/middleware.test.js b/test/middleware.test.js index 8daed5c93..fc18efa38 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -6946,4 +6946,120 @@ describe.each([ }); }); }); + + describe("hot", () => { + let instance; + let server; + let req; + + /** + * @param {import("supertest").Test} pending pending supertest request + * @returns {Promise} resolved response + */ + async function readSseHandshake(pending) { + return new Promise((resolve, reject) => { + pending + .buffer(false) + .parse((res, cb) => { + res.on("data", () => {}); + res.on("end", () => cb(null, "")); + // SSE never closes on its own; force-close after we have headers. + setTimeout(() => res.destroy(), 50); + }) + .end((err, res) => { + if (err && err.code !== "ECONNRESET") { + reject(err); + return; + } + resolve(res); + }); + }); + } + + afterEach(async () => { + await close(server, instance); + }); + + it("serves SSE on the default hot path", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + const res = await readSseHandshake(req.get("/__webpack_hmr")); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + expect(res.headers["cache-control"]).toBe("no-cache, no-transform"); + expect(res.headers["x-accel-buffering"]).toBe("no"); + }); + + it("respects a custom hot path", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + hot: { path: "/__custom_hmr" }, + }, + ); + + const res = await readSseHandshake(req.get("/__custom_hmr")); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + }); + + it("does not intercept non-hot requests", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + const response = await req.get("/bundle.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"] || "").not.toMatch( + /text\/event-stream/, + ); + }); + + it("does not intercept the default hot path when hot is disabled", async () => { + const compiler = getCompiler(webpackConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + {}, + ); + + const response = await req.get("/__webpack_hmr"); + + expect(response.headers["content-type"] || "").not.toMatch( + /text\/event-stream/, + ); + }); + + it("works with MultiCompiler", async () => { + const compiler = getCompiler(webpackMultiConfig); + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { hot: true }, + ); + + const res = await readSseHandshake(req.get("/__webpack_hmr")); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + }); + }); }); From 510520c1d0b3e038eeec6efd6b11d3f04f921e01 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 16:17:15 -0500 Subject: [PATCH 06/10] feat: enhance honoWrapper to support Web ReadableStream for hot middleware responses --- src/index.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/index.js b/src/index.js index ad32573c5..9030ddb88 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,10 @@ const fs = require("node:fs"); const path = require("node:path"); +// `stream/web` is flagged experimental by the n/eslint plugin until Node 21, +// but `ReadableStream` is stable in practice on Node 20.9+ (our minimum) and +// already pulled in by hono/web frameworks we integrate with. +// eslint-disable-next-line n/no-unsupported-features/node-builtins +const { ReadableStream } = require("node:stream/web"); const memfs = require("memfs"); const mime = require("mime-types"); @@ -957,6 +962,33 @@ function honoWrapper(compiler, options = {}, usePlugin = false) { let body; let isFinished = false; + // Hot middleware writes raw chunks via `res.writeHead` / `res.write` / `res.end`. + // Hono's response object is Web API-style, so we shim those methods on top of a + // Web ReadableStream that hono streams back to the client. + /** @type {ReadableStreamDefaultController | undefined} */ + let sseController; + let sseClosed = false; + /** @type {(() => void)[]} */ + const closeHandlers = []; + + /** + * @param {string} event event name + * @param {() => void} handler handler + * @returns {EXPECTED_ANY} req + */ + const reqOn = (event, handler) => { + if (event === "close") { + closeHandlers.push(handler); + if (sseClosed) handler(); + } + return req; + }; + + if (typeof (/** @type {EXPECTED_ANY} */ (req).on) !== "function") { + /** @type {EXPECTED_ANY} */ + (req).on = reqOn; + } + try { await new Promise( /** @@ -964,6 +996,72 @@ function honoWrapper(compiler, options = {}, usePlugin = false) { * @param {(reason?: Error) => void} reject reject */ (resolve, reject) => { + /** @type {EXPECTED_ANY} */ + (res).writeHead = + /** + * @param {number} statusCode status code + * @param {Record=} headers headers + */ + (statusCode, headers) => { + status = statusCode; + + if (headers) { + for (const name of Object.keys(headers)) { + context.res.headers.append(name, String(headers[name])); + } + } + + body = new ReadableStream({ + start(controller) { + sseController = controller; + }, + cancel() { + sseClosed = true; + for (const fn of closeHandlers) fn(); + }, + }); + isFinished = true; + resolve(); + }; + + /** @type {EXPECTED_ANY} */ + (res).write = + /** + * @param {string | Buffer} chunk chunk to write + * @returns {boolean} true when written + */ + (chunk) => { + if (!sseController || sseClosed) return false; + try { + sseController.enqueue( + typeof chunk === "string" + ? new TextEncoder().encode(chunk) + : chunk, + ); + } catch { + return false; + } + return true; + }; + + /** @type {EXPECTED_ANY} */ + (res).end = () => { + if (!sseController || sseClosed) return; + sseClosed = true; + try { + sseController.close(); + } catch { + // already closed + } + }; + + Object.defineProperty(res, "writableEnded", { + configurable: true, + get() { + return sseClosed; + }, + }); + /** * @param {import("fs").ReadStream} stream readable stream */ From b6210ee51b0460b97c4d554f174266f840e6ba66 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 16:22:05 -0500 Subject: [PATCH 07/10] feat: add TypeScript definitions for hot module replacement functionality --- types/hot.d.ts | 208 +++++++++++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 12 +++ 2 files changed, 220 insertions(+) create mode 100644 types/hot.d.ts diff --git a/types/hot.d.ts b/types/hot.d.ts new file mode 100644 index 000000000..8ca65e84d --- /dev/null +++ b/types/hot.d.ts @@ -0,0 +1,208 @@ +export = createHot; +/** + * @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 + * @returns {HotInstance} hot instance + */ +declare function createHot( + compiler: Compiler | MultiCompiler, + userOptions: HotOptions | true, +): HotInstance; +declare namespace createHot { + export { + HOT_DEFAULT_HEARTBEAT, + HOT_DEFAULT_PATH, + buildModuleMap, + createEventStream, + createHot, + formatErrors, + pathMatch, + publishStats, + HotInstance, + Compiler, + MultiCompiler, + Stats, + MultiStats, + IncomingMessage, + ServerResponse, + EXPECTED_ANY, + HotOptions, + Payload, + EventStream, + }; +} +declare const HOT_DEFAULT_HEARTBEAT: number; +/** @typedef {import("webpack").Compiler} Compiler */ +/** @typedef {import("webpack").MultiCompiler} MultiCompiler */ +/** @typedef {import("webpack").Stats} Stats */ +/** @typedef {import("webpack").MultiStats} MultiStats */ +/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ +/** @typedef {import("./index.js").ServerResponse} ServerResponse */ +/** @typedef {any} EXPECTED_ANY */ +/** + * @typedef {object} HotOptions + * @property {string=} path the path the SSE endpoint is served at + * @property {number=} heartbeat heartbeat interval in milliseconds + * @property {((message: string) => void) | false=} log logger + * @property {EXPECTED_ANY=} statsOptions webpack stats options used when serializing compilation results + */ +/** + * @typedef {object} Payload + * @property {string} action action + * @property {string=} name name + * @property {number=} time time + * @property {string=} hash hash + * @property {string[]=} warnings warnings + * @property {string[]=} errors errors + * @property {Record=} modules modules + */ +/** + * @typedef {object} EventStream + * @property {(req: IncomingMessage, res: ServerResponse) => void} handler attach a new client + * @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client + * @property {() => void} close end every client and stop the heartbeat + */ +declare const HOT_DEFAULT_PATH: "/__webpack_hmr"; +/** + * @param {EXPECTED_ANY[]} modules modules + * @returns {Record} module id to name map + */ +declare function buildModuleMap( + modules: EXPECTED_ANY[], +): Record; +/** + * @param {number} heartbeat heartbeat interval in milliseconds + * @returns {EventStream} event stream + */ +declare function createEventStream(heartbeat: number): EventStream; +/** + * @param {EXPECTED_ANY[]} errors errors or warnings + * @returns {string[]} flat strings + */ +declare function formatErrors(errors: EXPECTED_ANY[]): string[]; +/** + * @param {string | undefined} url url + * @param {string} expected expected pathname + * @returns {boolean} true when the url pathname matches the expected path + */ +declare function pathMatch(url: string | undefined, expected: string): boolean; +/** + * @param {string} action action + * @param {Stats | MultiStats} statsResult stats result + * @param {EventStream} eventStream event stream + * @param {((message: string) => void) | false} log logger or false to disable + * @param {EXPECTED_ANY} statsOptions stats options + */ +declare function publishStats( + action: string, + statsResult: Stats | MultiStats, + eventStream: EventStream, + log: ((message: string) => void) | false, + statsOptions: EXPECTED_ANY, +): void; +type HotInstance = { + /** + * path the SSE endpoint is served at + */ + path: string; + /** + * attach the request as a SSE client + */ + handle: (req: IncomingMessage, res: ServerResponse) => void; + /** + * publish a payload to every client + */ + publish: ( + payload: + | Payload + | { + action: string; + }, + ) => void; + /** + * end every client and detach the heartbeat + */ + close: () => void; +}; +type Compiler = import("webpack").Compiler; +type MultiCompiler = import("webpack").MultiCompiler; +type Stats = import("webpack").Stats; +type MultiStats = import("webpack").MultiStats; +type IncomingMessage = import("./index.js").IncomingMessage; +type ServerResponse = import("./index.js").ServerResponse; +type EXPECTED_ANY = any; +type HotOptions = { + /** + * the path the SSE endpoint is served at + */ + path?: string | undefined; + /** + * heartbeat interval in milliseconds + */ + heartbeat?: number | undefined; + /** + * logger + */ + log?: (((message: string) => void) | false) | undefined; + /** + * webpack stats options used when serializing compilation results + */ + statsOptions?: EXPECTED_ANY | undefined; +}; +type Payload = { + /** + * action + */ + action: string; + /** + * name + */ + name?: string | undefined; + /** + * time + */ + time?: number | undefined; + /** + * hash + */ + hash?: string | undefined; + /** + * warnings + */ + warnings?: string[] | undefined; + /** + * errors + */ + errors?: string[] | undefined; + /** + * modules + */ + modules?: Record | undefined; +}; +type EventStream = { + /** + * attach a new client + */ + handler: (req: IncomingMessage, res: ServerResponse) => void; + /** + * publish a payload to every client + */ + publish: ( + payload: + | Payload + | { + action: string; + }, + ) => void; + /** + * end every client and stop the heartbeat + */ + close: () => void; +}; diff --git a/types/index.d.ts b/types/index.d.ts index 233a3d8db..7a29ffdd1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -28,6 +28,8 @@ declare namespace wdm { MultiStats, ReadStream, FilenameWithExtra, + HotOptions, + HotInstance, EXPECTED_ANY, EXPECTED_FUNCTION, ExtendedServerResponse, @@ -128,6 +130,8 @@ type Stats = import("webpack").Stats; type MultiStats = import("webpack").MultiStats; type ReadStream = import("fs").ReadStream; type FilenameWithExtra = import("./middleware").FilenameWithExtra; +type HotOptions = import("./hot").HotOptions; +type HotInstance = import("./hot").HotInstance; type EXPECTED_ANY = any; type EXPECTED_FUNCTION = Function; type ExtendedServerResponse = { @@ -210,6 +214,10 @@ type Context< * output file system */ outputFileSystem: OutputFileSystem; + /** + * hot module replacement instance + */ + hot?: HotInstance | undefined; }; type FilledContext< RequestInternal extends IncomingMessage = import("http").IncomingMessage, @@ -316,6 +324,10 @@ type Options< * forward error to next middleware */ forwardError?: boolean | undefined; + /** + * enable hot module replacement + */ + hot?: (boolean | HotOptions) | undefined; }; type Middleware< RequestInternal extends IncomingMessage = import("http").IncomingMessage, From d8cdbbbf169639e0272f3d7812d2fa62b71250fb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 17:54:25 -0500 Subject: [PATCH 08/10] 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 --- test/hot.test.js | 192 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 1 deletion(-) diff --git a/test/hot.test.js b/test/hot.test.js index 8e7ae3c0c..aff342fc2 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -1,4 +1,4 @@ -import { +import createHot, { buildModuleMap, createEventStream, formatErrors, @@ -7,6 +7,85 @@ import { jest.spyOn(globalThis.console, "log").mockImplementation(); +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_OBJECT */ + +/** + * Build a minimal compiler-like object so we can drive `invalid`/`done` from + * the test without spinning up webpack. + * @returns {{ hooks: EXPECTED_OBJECT, emitInvalid: () => void, emitDone: (stats: EXPECTED_OBJECT) => void }} fake compiler + */ +function makeFakeCompiler() { + const invalidTaps = []; + const doneTaps = []; + return { + hooks: { + invalid: { tap: (_name, fn) => invalidTaps.push(fn) }, + done: { tap: (_name, fn) => doneTaps.push(fn) }, + }, + emitInvalid() { + for (const fn of invalidTaps) fn(); + }, + emitDone(stats) { + for (const fn of doneTaps) fn(stats); + }, + }; +} + +/** + * Build a minimal Stats-like object that satisfies `publishStats`. + * @param {EXPECTED_OBJECT=} overrides field overrides applied on top of the defaults + * @returns {EXPECTED_OBJECT} fake stats + */ +function makeFakeStats(overrides = {}) { + return { + toJson() { + return { + time: 5, + hash: "abc", + warnings: [], + errors: [], + modules: [], + ...overrides, + }; + }, + compilation: undefined, + }; +} + +/** + * Attach a fake response to the given event stream and collect every chunk + * written to it. + * @param {EXPECTED_OBJECT} eventStream stream returned by createEventStream or hot instance + * @param {{ httpVersion?: string }=} reqOverrides overrides for the fake req + * @returns {{ res: EXPECTED_OBJECT, writes: string[], headers: EXPECTED_OBJECT }} captured state + */ +function attachClient(eventStream, reqOverrides = {}) { + const writes = []; + /** @type {EXPECTED_OBJECT | undefined} */ + let headers; + const res = { + writableEnded: false, + write: (chunk) => { + writes.push(chunk); + }, + writeHead: (_code, h) => { + headers = h; + }, + end: () => { + res.writableEnded = true; + }, + }; + const req = { + httpVersion: "1.1", + socket: { setKeepAlive: () => {} }, + on: () => {}, + ...reqOverrides, + }; + eventStream.handler(req, res); + return { res, writes, headers }; +} + describe("hot middleware (unit)", () => { describe("pathMatch", () => { it("matches exact pathname", () => { @@ -140,5 +219,116 @@ describe("hot middleware (unit)", () => { expect(ended).toBe(true); }); + + it("sets Connection: keep-alive for HTTP/1 clients", () => { + const stream = createEventStream(5000); + const { headers } = attachClient(stream, { httpVersion: "1.1" }); + expect(headers.Connection).toBe("keep-alive"); + stream.close(); + }); + + it("does not set Connection: keep-alive for HTTP/2 clients", () => { + const stream = createEventStream(5000); + const { headers } = attachClient(stream, { httpVersion: "2.0" }); + expect(headers.Connection).toBeUndefined(); + stream.close(); + }); + + it("broadcasts events to every attached client", () => { + const stream = createEventStream(5000); + const clients = [ + attachClient(stream), + attachClient(stream), + attachClient(stream), + ]; + + for (const c of clients) c.writes.length = 0; + + stream.publish({ action: "built", hash: "xyz" }); + + for (const c of clients) { + expect(c.writes.some((w) => w.includes('"hash":"xyz"'))).toBe(true); + } + + stream.close(); + }); + }); +}); + +describe("createHot", () => { + it("exposes publish() so callers can broadcast custom payloads", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, { log: false }); + const { writes } = attachClient({ handler: hot.handle }); + + hot.publish({ action: "custom-thing", payload: 42 }); + + expect( + writes.some( + (w) => + w.includes('"action":"custom-thing"') && w.includes('"payload":42'), + ), + ).toBe(true); + + hot.close(); + }); + + it("sends a sync payload to a client that connects after a build", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, { log: false }); + + // A build finishes BEFORE anyone connects. + compiler.emitDone(makeFakeStats()); + + const { writes } = attachClient({ handler: hot.handle }); + + expect(writes.some((w) => w.includes('"action":"sync"'))).toBe(true); + + hot.close(); + }); + + it("falls back to compilation.name when stats name is empty", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, { log: false }); + const { writes } = attachClient({ handler: hot.handle }); + + compiler.emitDone({ + toJson() { + return { + time: 1, + hash: "h", + warnings: [], + errors: [], + modules: [], + // no `name` here + }; + }, + compilation: { name: "child-bundle" }, + }); + + expect( + writes.some( + (w) => + w.includes('"name":"child-bundle"') && w.includes('"action":"built"'), + ), + ).toBe(true); + + hot.close(); + }); + + it("stops publishing after close() even if the compiler still emits", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, { log: false }); + const { writes } = attachClient({ handler: hot.handle }); + + hot.close(); + writes.length = 0; + + // After close, the tap callbacks remain registered (tapable does not + // support removal), but they must noop instead of writing to clients. + compiler.emitInvalid(); + compiler.emitDone(makeFakeStats()); + + expect(writes).toHaveLength(0); }); }); From 9e0ae90bb8168fff61b94dfec33332f167b662b0 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 18:02:32 -0500 Subject: [PATCH 09/10] docs: document the hot option in README --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/README.md b/README.md index 5b67deb5a..998f8c6df 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,51 @@ middleware(compiler, { }); ``` +### hot + +Type: `Boolean | Object` +Default: `undefined` + +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 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.log` + +Type: `Function | false` +Default: the dev middleware's infrastructure logger + +Logger used to print build status (`webpack building...`, `webpack built in `). Pass `false` to disable logging from the hot middleware. + +#### `hot.statsOptions` + +Type: `Boolean | Object` +Default: `undefined` + +Webpack stats options used when serializing compilation results for the SSE payload. Forwarded to `stats.toJson(...)`. + ## API `webpack-dev-middleware` also provides convenience methods that can be use to From 177b18a6ce49e9d44b2d10873c53fe639ba4feca Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 18:06:18 -0500 Subject: [PATCH 10/10] docs: list the hot option in the README options table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 998f8c6df..a520ecf79 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` | `undefined` | 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.