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 diff --git a/README.md b/README.md index 5b67deb5a..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. @@ -312,6 +313,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 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..9030ddb88 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,14 @@ 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"); +const { createHot } = require("./hot"); const middleware = require("./middleware"); const { nodeReadableToWebStream } = require("./utils"); @@ -16,6 +22,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 +84,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 +121,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 +514,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 +624,9 @@ function wdm(compiler, options = {}, isPlugin = false) { }; instance.close = (callback = noop) => { + if (filledContext.hot) { + filledContext.hot.close(); + } filledContext.watching.close(callback); }; @@ -935,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( /** @@ -942,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 */ 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 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/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: [ diff --git a/test/hot.test.js b/test/hot.test.js new file mode 100644 index 000000000..aff342fc2 --- /dev/null +++ b/test/hot.test.js @@ -0,0 +1,334 @@ +import createHot, { + buildModuleMap, + createEventStream, + formatErrors, + pathMatch, +} from "../src/hot"; + +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", () => { + 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); + }); + + 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); + }); +}); 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/); + }); + }); }); 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 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,