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..3f9ffbe12d 100644 --- a/examples/api/plugin/README.md +++ b/examples/api/plugin/README.md @@ -23,6 +23,22 @@ 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 webpack 5.112.0 or later. + +```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 8c21d1508d..9b0daa4bd4 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -4092,17 +4092,24 @@ 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 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] + : /** @type {MultiCompiler} */ (compiler).compilers; + + for (const shutdownCompiler of shutdownCompilers) { + /** @type {Compiler} */ (shutdownCompiler).hooks.shutdown.tapPromise( + pluginName, + onShutdown, + ); } } } diff --git a/package-lock.json b/package-lock.json index 4d27c37cec..ae318257bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6663,7 +6663,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": { @@ -10228,6 +10230,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": { @@ -10985,6 +10989,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" }, @@ -11638,7 +11644,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": { @@ -14172,6 +14180,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": { @@ -14860,7 +14870,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": { @@ -14888,7 +14900,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": { @@ -14899,16 +14913,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" @@ -15020,7 +15034,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": { diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 405f828429..006443795e 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 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; + const cssFilePath = path.resolve( __dirname, "../fixtures/reload-config/main.css", @@ -657,6 +662,70 @@ 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(); + }, + ); + + // 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. + 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 });