-
-
Notifications
You must be signed in to change notification settings - Fork 382
feat: implement hot module replacement middleware #2321
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
abae345
fix(test): correct output path typo in webpack.array.warning fixture
bjohansebas 4311384
feat: implement hot module replacement middleware
bjohansebas d33caab
test: add tests for hot middleware
bjohansebas 584ae3c
test: add unit and integration tests for hot middleware functionality
bjohansebas 8c0c1ba
feat: enhance honoWrapper to support Web ReadableStream for hot middl…
bjohansebas 7e1784a
feat: add TypeScript definitions for hot module replacement functiona…
bjohansebas 1f98b28
test(hot): cover publish, sync-on-connect, headers and close behavior
bjohansebas 2c7c11b
docs: document the hot option in README
bjohansebas a27e4f0
docs: list the hot option in the README options table
bjohansebas eb8a589
refactor: replace EXPECTED_ANY with specific types in hot module defi…
bjohansebas a848a98
docs: update README to clarify default stats options for SSE payload
bjohansebas 59147b3
refactor: remove log option from hot middleware and update related do…
bjohansebas 34078c4
docs: update default value for hot option in README to false
bjohansebas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,340 @@ | ||
| /** @typedef {import("webpack").Compiler} Compiler */ | ||
| /** @typedef {import("webpack").MultiCompiler} MultiCompiler */ | ||
| /** @typedef {ReturnType<Compiler["getInfrastructureLogger"]>} Logger */ | ||
| /** @typedef {import("webpack").Stats} Stats */ | ||
| /** @typedef {import("webpack").MultiStats} MultiStats */ | ||
| /** @typedef {import("webpack").StatsCompilation} StatsCompilation */ | ||
| /** @typedef {import("webpack").StatsError} StatsError */ | ||
| /** @typedef {import("webpack").StatsModule} StatsModule */ | ||
| /** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ | ||
| /** @typedef {import("./index.js").ServerResponse} ServerResponse */ | ||
|
|
||
| /** @typedef {NonNullable<import("webpack").Configuration["stats"]>} StatsOptions */ | ||
|
|
||
| /** | ||
| * @typedef {object} HotOptions | ||
| * @property {string=} path the path the SSE endpoint is served at | ||
| * @property {number=} heartbeat heartbeat interval in milliseconds | ||
| * @property {StatsOptions=} 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<string, string>=} 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 | ||
| * @param {Logger} logger logger | ||
| * @returns {EventStream} event stream | ||
| */ | ||
| function createEventStream(heartbeat, logger) { | ||
| let clientId = 0; | ||
| /** @type {Map<number, ServerResponse>} */ | ||
| 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 interval.unref === "function") { | ||
| interval.unref(); | ||
| } | ||
|
|
||
| return { | ||
| close() { | ||
| clearInterval(interval); | ||
| everyClient((client) => { | ||
| if (!client.writableEnded) { | ||
| client.end(); | ||
| } | ||
| }); | ||
| clients = new Map(); | ||
| }, | ||
| handler(req, res) { | ||
| /** @type {Record<string, string>} */ | ||
| const headers = { | ||
| "Access-Control-Allow-Origin": "*", | ||
| "Content-Type": "text/event-stream;charset=utf-8", | ||
| "Cache-Control": "no-cache, no-transform", | ||
| // While behind nginx, the event stream should not be buffered: | ||
| // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering | ||
| "X-Accel-Buffering": "no", | ||
| }; | ||
|
|
||
| const { httpVersion, socket } = req; | ||
| const isHttp1 = !(Number.parseInt(httpVersion, 10) >= 2); | ||
|
|
||
| if (isHttp1) { | ||
| if (socket && typeof socket.setKeepAlive === "function") { | ||
| socket.setKeepAlive(true); | ||
| } | ||
| headers.Connection = "keep-alive"; | ||
| } | ||
|
|
||
| res.writeHead(200, headers); | ||
| res.write("\n"); | ||
|
|
||
| const id = clientId++; | ||
| clients.set(id, res); | ||
| logger.log(`Client connected (${clients.size} active)`); | ||
|
|
||
| req.on("close", () => { | ||
| if (!res.writableEnded) { | ||
| res.end(); | ||
| } | ||
| clients.delete(id); | ||
| logger.log(`Client disconnected (${clients.size} active)`); | ||
| }); | ||
| }, | ||
| publish(payload) { | ||
| everyClient((client) => { | ||
| client.write(`data: ${JSON.stringify(payload)}\n\n`); | ||
| }); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * @param {(string | StatsError)[]} errors errors or warnings | ||
| * @returns {string[]} flat strings | ||
| */ | ||
| function formatErrors(errors) { | ||
| if (!errors || errors.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| if (typeof errors[0] === "string") { | ||
| return /** @type {string[]} */ (errors); | ||
| } | ||
|
|
||
| return /** @type {StatsError[]} */ (errors).map((error) => { | ||
| const moduleName = error.moduleName || ""; | ||
| const loc = error.loc || ""; | ||
|
|
||
| return `${moduleName} ${loc}\n${error.message}`; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * @param {Stats} stats stats | ||
| * @param {StatsOptions} statsOptions stats options | ||
| * @returns {StatsCompilation} 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 {StatsCompilation} stats normalized stats | ||
| * @returns {StatsCompilation[]} extracted bundles | ||
| */ | ||
| function extractBundles(stats) { | ||
| if (stats.modules) { | ||
| return [stats]; | ||
| } | ||
|
|
||
| if (stats.children && stats.children.length > 0) { | ||
| return stats.children; | ||
| } | ||
|
|
||
| return [stats]; | ||
| } | ||
|
|
||
| /** | ||
| * @param {StatsModule[]} modules modules | ||
| * @returns {Record<string, string>} module id to name map | ||
| */ | ||
| function buildModuleMap(modules) { | ||
| /** @type {Record<string, string>} */ | ||
| const map = {}; | ||
|
|
||
| for (const item of modules) { | ||
| map[/** @type {string | number} */ (item.id)] = /** @type {string} */ ( | ||
| item.name | ||
| ); | ||
| } | ||
|
|
||
| return map; | ||
| } | ||
|
|
||
| /** | ||
| * @param {string} action action | ||
| * @param {Stats | MultiStats} statsResult stats result | ||
| * @param {EventStream} eventStream event stream | ||
| * @param {StatsOptions | undefined} statsOptions stats options | ||
| */ | ||
| function publishStats(action, statsResult, eventStream, statsOptions) { | ||
| const resultStatsOptions = { | ||
| all: false, | ||
| hash: true, | ||
| timings: true, | ||
| errors: true, | ||
| warnings: true, | ||
| ...(statsOptions && typeof statsOptions === "object" ? statsOptions : {}), | ||
| }; | ||
|
|
||
| /** @type {StatsCompilation[]} */ | ||
| let bundles; | ||
|
|
||
| // Multi-compiler stats have stats for each child compiler. | ||
| if ("stats" in statsResult) { | ||
| bundles = statsResult.stats.flatMap((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 || ""; | ||
| } | ||
|
|
||
| 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 { statsOptions } = options; | ||
| const logger = compiler.getInfrastructureLogger("webpack-dev-middleware"); | ||
|
|
||
| let eventStream = createEventStream(heartbeat, logger); | ||
| logger.log(`Hot module replacement enabled, serving events at "${path}"`); | ||
| /** @type {Stats | MultiStats | null} */ | ||
| let latestStats = null; | ||
| let closed = false; | ||
|
|
||
| const onInvalid = () => { | ||
| if (closed) return; | ||
|
|
||
| latestStats = null; | ||
|
|
||
| eventStream.publish({ action: "building" }); | ||
| }; | ||
|
|
||
| /** @param {Stats | MultiStats} statsResult stats result */ | ||
| const onDone = (statsResult) => { | ||
| if (closed) return; | ||
|
|
||
| latestStats = statsResult; | ||
| publishStats("built", latestStats, eventStream, 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) { | ||
| publishStats("sync", latestStats, eventStream, 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; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally we need only require stats, it is slow down build
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what do you mean?