From 27453ab3282d56f18695761f7dc1158090614619 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:17:49 +0000 Subject: [PATCH 1/4] feat: run as a plugin from the top-level plugins of a multi-configuration --- .changeset/feat-multi-compiler-plugin.md | 5 ++ examples/api/plugin/README.md | 17 ++++++ lib/Server.js | 24 +++++---- test/e2e/api-plugin.test.js | 67 ++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 .changeset/feat-multi-compiler-plugin.md diff --git a/.changeset/feat-multi-compiler-plugin.md b/.changeset/feat-multi-compiler-plugin.md new file mode 100644 index 0000000000..94905f464d --- /dev/null +++ b/.changeset/feat-multi-compiler-plugin.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": minor +--- + +Run as a plugin from the top-level `plugins` of a multi-configuration, serving every configuration from one server. diff --git a/examples/api/plugin/README.md b/examples/api/plugin/README.md index 739d3be42b..405152f732 100644 --- a/examples/api/plugin/README.md +++ b/examples/api/plugin/README.md @@ -23,6 +23,23 @@ const devServerOptions = { ...config.devServer, open: true }; config.plugins.push(new WebpackDevServer(devServerOptions)); ``` +## Multiple configurations + +To serve every configuration of a multi-configuration from one dev server, add +the instance to the top-level `plugins` of the exported array, next to +`parallelism`, rather than to one configuration's `plugins[]`. webpack applies +it once to the `MultiCompiler`; this needs a webpack version that supports +top-level `plugins` on a multi-configuration. + +```js +// webpack.config.js +const WebpackDevServer = require("webpack-dev-server"); + +module.exports = Object.assign([appConfig, workerConfig], { + plugins: [new WebpackDevServer({ port: 8080 })], +}); +``` + ## Run ```console diff --git a/lib/Server.js b/lib/Server.js index 382de1914a..15fdde7e70 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3873,17 +3873,23 @@ class Server { await this.stop(); }; - // Teardown is the one place a loop is unavoidable. A `MultiCompiler` has no - // `shutdown` hook, and its aggregate `watchClose` does NOT fire on - // `compiler.close()` (only on `watching.close()`), so the only signal that - // survives `compiler.close()` is each child's own `shutdown`. Tapping it + // `shutdown` survives `compiler.close()`, unlike the aggregate `watchClose` + // (only `watching.close()` fires it). On a `MultiCompiler` it is a + // `MultiHook` firing once per child, so `onShutdown` is guarded. Tapping it // with `tapPromise` also lets `compiler.close()` await the server actually // stopping, so the port is released before the next start. - const childCompilers = /** @type {MultiCompiler} */ (compiler) - .compilers || [compiler]; - - for (const childCompiler of childCompilers) { - childCompiler.hooks.shutdown.tapPromise(pluginName, onShutdown); + // TODO remove this fallback in the next major, once the minimum supported + // webpack (5.110.0) has `MultiCompiler.hooks.shutdown`, and tap + // `compiler.hooks.shutdown` alone. + const shutdownCompilers = /** @type {Compiler} */ (compiler).hooks.shutdown + ? [compiler] + : /** @type {MultiCompiler} */ (compiler).compilers; + + for (const shutdownCompiler of shutdownCompilers) { + /** @type {Compiler} */ (shutdownCompiler).hooks.shutdown.tapPromise( + pluginName, + onShutdown, + ); } } } diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 405f828429..b6e5ee86db 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -21,6 +21,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const port = portsMap["api-plugin"]; const [portA, portB] = portsMap["api-plugin-multi"]; +// TODO remove this check in the next major, once the minimum supported webpack +// applies the top-level `plugins` of a multi-configuration to the MultiCompiler. +const supportsMultiCompilerPlugins = + new webpack.MultiCompiler([], { plugins: [] }).options.plugins !== undefined; + const cssFilePath = path.resolve( __dirname, "../fixtures/reload-config/main.css", @@ -657,6 +662,68 @@ describe("API (plugin)", () => { stopSpy.mockRestore(); }); + it( + "should work from the top-level `plugins` of a multi-configuration", + { skip: !supportsMultiCompilerPlugins }, + async () => { + const server = new Server({ port }); + const setupSpy = spyOn(server, "setup"); + const listenSpy = spyOn(server, "listen"); + const stopSpy = spyOn(server, "stop"); + const compiler = webpack( + Object.assign([...multiCompilerConfig], { plugins: [server] }), + ); + + await compile(compiler, port); + + try { + const responseOne = await fetch( + `http://127.0.0.1:${port}/one-main.js`, + ); + const responseTwo = await fetch( + `http://127.0.0.1:${port}/two-main.js`, + ); + + expect(responseOne.status).toBe(200); + expect(responseTwo.status).toBe(200); + expect(setupSpy).toHaveBeenCalledTimes(1); + expect(listenSpy).toHaveBeenCalledTimes(1); + } finally { + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + + expect(stopSpy).toHaveBeenCalledTimes(1); + setupSpy.mockRestore(); + listenSpy.mockRestore(); + stopSpy.mockRestore(); + }, + ); + + it("should stop the server once on webpack without `MultiCompiler.hooks.shutdown`", async () => { + const compiler = webpack(multiCompilerConfig); + // A webpack older than 5.110.0: the same compiler, minus the hook. + const { shutdown, ...hooks } = compiler.hooks; + const olderCompiler = Object.create(compiler, { + hooks: { value: hooks }, + }); + const server = new Server({ port }); + const stopSpy = spyOn(server, "stop"); + + server.apply(olderCompiler); + + await compile(compiler, port); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + + expect(shutdown).toBeDefined(); + expect(stopSpy).toHaveBeenCalledTimes(1); + stopSpy.mockRestore(); + }); + it("should run two independent plugin servers on different child compilers", async () => { const serverA = new Server({ port: portA }); const serverB = new Server({ port: portB }); From 314ef8e7a4fd71fb13ec1942028968280db9b9c6 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:13:05 +0000 Subject: [PATCH 2/4] chore(deps): update webpack to 5.112.0 in the lockfile 5.112.0 applies a multi-configuration's top-level `plugins` to the MultiCompiler (webpack/webpack#22702), so the test serving both configurations from there runs instead of being skipped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- package-lock.json | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index b16fb54022..056dcb381e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6614,7 +6614,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.25.1", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -10173,6 +10175,8 @@ }, "node_modules/jest-worker": { "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -10939,6 +10943,8 @@ }, "node_modules/merge-stream": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "devOptional": true, "license": "MIT" }, @@ -11592,7 +11598,9 @@ } }, "node_modules/minimizer-webpack-plugin": { - "version": "5.10.1", + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.13.0.tgz", + "integrity": "sha512-OO1NF2zHj6sf4CNv2AsUWYcS8WEwkVKQDXxxKzlOxx1Xl+WlqYtVC+oNlGNwGkLAOnpj277BSHypWwRtd9w4JQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -14124,6 +14132,8 @@ }, "node_modules/supports-color": { "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -14819,7 +14829,9 @@ "license": "MIT" }, "node_modules/watchpack": { - "version": "2.5.2", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.6.2.tgz", + "integrity": "sha512-67nxRd7I5uAyn+KamYsCqU30iVgCgZ4aj8ZOGzmxs7zNPuvlnoYltm6+6JkOjEcD6WVXdu1BfH+gVQs3FUuW7Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -14845,7 +14857,9 @@ } }, "node_modules/webpack": { - "version": "5.111.1", + "version": "5.112.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.112.0.tgz", + "integrity": "sha512-NIBpsRuCll036haDDt/CfcTDfsWNR9Z782nMRMI9D377BsGcYzZDTNU5TyzhCJzPIcihAxMBaMAhq2WSaDrH8Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -14856,16 +14870,16 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.25.0", + "enhanced-resolve": "^5.26.0", "es-module-lexer": "^2.1.0", "events": "^3.2.0", "graceful-fs": "^4.2.11", "mime-db": "^1.54.0", - "minimizer-webpack-plugin": "^5.7.0", + "minimizer-webpack-plugin": "^5.13.0", "schema-utils": "^4.5.0", "tapable": "^2.3.0", - "watchpack": "^2.5.2", - "webpack-sources": "^3.5.1" + "watchpack": "^2.6.1", + "webpack-sources": "^3.6.0" }, "bin": { "webpack": "bin/webpack.js" @@ -14977,7 +14991,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.5.1", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.6.0.tgz", + "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", "devOptional": true, "license": "MIT", "engines": { From 662404de367a616dba6fe703a046c19993fbf453 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:23:20 +0000 Subject: [PATCH 3/4] docs: name the webpack version that supports top-level plugins Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- examples/api/plugin/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/api/plugin/README.md b/examples/api/plugin/README.md index 405152f732..3f9ffbe12d 100644 --- a/examples/api/plugin/README.md +++ b/examples/api/plugin/README.md @@ -28,8 +28,7 @@ config.plugins.push(new WebpackDevServer(devServerOptions)); To serve every configuration of a multi-configuration from one dev server, add the instance to the top-level `plugins` of the exported array, next to `parallelism`, rather than to one configuration's `plugins[]`. webpack applies -it once to the `MultiCompiler`; this needs a webpack version that supports -top-level `plugins` on a multi-configuration. +it once to the `MultiCompiler`; this needs webpack 5.112.0 or later. ```js // webpack.config.js From c4f06ae8edbd920a7ce63b17b1620356b9dbe342 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:32:56 +0000 Subject: [PATCH 4/4] chore: note the webpack peer bump for the next major in the fallback TODOs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- lib/Server.js | 5 +++-- test/e2e/api-plugin.test.js | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 78ca9d272e..9b0daa4bd4 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -4097,8 +4097,9 @@ class Server { // `MultiHook` firing once per child, so `onShutdown` is guarded. Tapping it // with `tapPromise` also lets `compiler.close()` await the server actually // stopping, so the port is released before the next start. - // TODO remove this fallback in the next major, once the minimum supported - // webpack (5.110.0) has `MultiCompiler.hooks.shutdown`, and tap + // TODO in the next major, bump the webpack peer dependency to `^5.112.0` + // (`MultiCompiler.hooks.shutdown` since 5.110.0, top-level `plugins` of a + // multi-configuration since 5.112.0), remove this fallback and tap // `compiler.hooks.shutdown` alone. const shutdownCompilers = /** @type {Compiler} */ (compiler).hooks.shutdown ? [compiler] diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index b6e5ee86db..006443795e 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -21,8 +21,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const port = portsMap["api-plugin"]; const [portA, portB] = portsMap["api-plugin-multi"]; -// TODO remove this check in the next major, once the minimum supported webpack -// applies the top-level `plugins` of a multi-configuration to the MultiCompiler. +// TODO in the next major, once the webpack peer dependency is `^5.112.0`, remove +// this check: the top-level `plugins` of a multi-configuration always apply. const supportsMultiCompilerPlugins = new webpack.MultiCompiler([], { plugins: [] }).options.plugins !== undefined; @@ -701,6 +701,8 @@ describe("API (plugin)", () => { }, ); + // TODO remove in the next major, together with the fallback it covers in + // `Server#apply()`, once the webpack peer dependency is `^5.112.0`. it("should stop the server once on webpack without `MultiCompiler.hooks.shutdown`", async () => { const compiler = webpack(multiCompilerConfig); // A webpack older than 5.110.0: the same compiler, minus the hook.