From e418af920b289d5be86493cd148e198ccdcafbc8 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 14:04:49 -0500 Subject: [PATCH 1/9] feat: run webpack-dev-server as a compiler plugin --- .changeset/dev-server-6-plugin.md | 5 + .prettierignore | 1 + eslint.config.mjs | 1 + packages/webpack-cli/src/webpack-cli.ts | 142 ++++++++++++++---- ...rve-basic.test.js.snap.devServer6.webpack5 | 7 +- .../error-handling/bad-middleware.config.js | 7 + test/serve/error-handling/error.config.js | 4 + .../serve-error-handling.test.js | 74 +++++++++ .../error-handling/setup-failure.config.js | 9 ++ test/serve/error-handling/src/index.js | 1 + test/serve/error-handling/src/syntax-error.js | 1 + test/serve/error-handling/warning.config.js | 9 ++ test/serve/rebuild/multi.config.js | 21 +++ test/serve/rebuild/serve-rebuild.test.js | 101 +++++++++++++ test/serve/rebuild/src/index.js | 1 + test/serve/rebuild/webpack.config.js | 7 + 16 files changed, 353 insertions(+), 38 deletions(-) create mode 100644 .changeset/dev-server-6-plugin.md create mode 100644 test/serve/error-handling/bad-middleware.config.js create mode 100644 test/serve/error-handling/error.config.js create mode 100644 test/serve/error-handling/serve-error-handling.test.js create mode 100644 test/serve/error-handling/setup-failure.config.js create mode 100644 test/serve/error-handling/src/index.js create mode 100644 test/serve/error-handling/src/syntax-error.js create mode 100644 test/serve/error-handling/warning.config.js create mode 100644 test/serve/rebuild/multi.config.js create mode 100644 test/serve/rebuild/serve-rebuild.test.js create mode 100644 test/serve/rebuild/src/index.js create mode 100644 test/serve/rebuild/webpack.config.js diff --git a/.changeset/dev-server-6-plugin.md b/.changeset/dev-server-6-plugin.md new file mode 100644 index 00000000000..082a1bd9661 --- /dev/null +++ b/.changeset/dev-server-6-plugin.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": minor +--- + +feat: run `webpack-dev-server@6` as a compiler plugin. The CLI drives watch compilation, prints build stats and closes the compiler on shutdown. Older dev servers continue to manage their own compilation. diff --git a/.prettierignore b/.prettierignore index d9e48ba2fd2..74f76e5d54c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,6 +11,7 @@ test/build/config/error-commonjs/syntax-error.js test/build/config/error-array/webpack.config.js test/build/config/error-mjs/syntax-error.mjs test/configtest/with-config-path/syntax-error.config.js +test/serve/error-handling/src/syntax-error.js test/build/build-errors/stats.json /.nx/workspace-data \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index c4ad7725712..70ac3a81b39 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ export default defineConfig([ "test/configtest/with-config-path/syntax-error.config.js", "test/build/config-format/auto/webpack.config.js", "test/build/config-format/typescript-tsx/webpack.config.jsx", + "test/serve/error-handling/src/syntax-error.js", ]), { extends: [config], diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 971ce6b18e3..49f9457264f 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -2221,10 +2221,12 @@ class WebpackCLI { return; } - const DevServer: DevServerConstructor = cmd.context.devServer; + const DevServer: DevServerConstructor = devServer; + const isDevServerPlugin = + typeof (DevServer.prototype as { apply?: unknown }).apply === "function"; const servers: InstanceType[] = []; - if (this.#needWatchStdin(compiler)) { + if (!isDevServerPlugin && this.#needWatchStdin(compiler)) { process.stdin.on("end", () => { Promise.all(servers.map((server) => server.stop())).then(() => { process.exit(0); @@ -2240,6 +2242,7 @@ class WebpackCLI { const usedPorts: number[] = []; // @ts-expect-error different versions of the `Schema` type const devServerArgs = this.#getArguments(webpack, devServer.schema); + let appliedDevServers = 0; for (const compilerForDevServer of compilersForDevServer) { if (compilerForDevServer.options.devServer === false) { @@ -2283,11 +2286,23 @@ class WebpackCLI { } try { - const server = new DevServer(devServerConfiguration, compiler); + if (isDevServerPlugin) { + // v5 typings lack the plugin constructor. + const DevServerPlugin = DevServer as unknown as new ( + options: DevServerConfiguration, + ) => { apply(compiler: Compiler | MultiCompiler): void }; + + // Serve all child compilers, regardless of which defines devServer. + new DevServerPlugin(devServerConfiguration).apply(compiler); + } else { + const server = new DevServer(devServerConfiguration, compiler); - await server.start(); + await server.start(); + + servers.push(server as unknown as InstanceType); + } - servers.push(server as unknown as InstanceType); + appliedDevServers += 1; } catch (error) { if (this.isValidationError(error as Error)) { this.logger.error((error as Error).message); @@ -2299,10 +2314,69 @@ class WebpackCLI { } } - if (servers.length === 0) { + if (appliedDevServers === 0) { this.logger.error("No dev server configurations to run"); process.exit(2); } + + // Older servers manage compilation and signals themselves. + if (!isDevServerPlugin) { + return; + } + + // Closing the compiler stops the server through its shutdown hook. + this.#setupGracefulShutdown(compiler); + + if (this.#needWatchStdin(compiler)) { + process.stdin.on("end", () => { + compiler.close(() => { + process.exit(0); + }); + }); + process.stdin.resume(); + } + + const watchCallback = (error: Error | null, stats?: Stats | MultiStats): void => { + if (error) { + if (this.isValidationError(error)) { + this.logger.error(error.message); + } else { + this.logger.error(error); + } + + process.exit(2); + } + + if (!stats) { + return; + } + + if (stats.hasErrors() || (options.failOnWarnings && stats.hasWarnings())) { + process.exitCode = 1; + } + + // Plugin mode leaves stats output to the CLI. + const statsOptions = this.isMultipleCompiler(compiler) + ? { + children: compiler.compilers.map((compiler) => compiler.options.stats), + } + : compiler.options.stats; + + const printedStats = stats.toString(statsOptions as StatsOptions); + + if (printedStats) { + this.logger.raw(printedStats); + } + }; + + if (this.isMultipleCompiler(compiler)) { + compiler.watch( + compiler.compilers.map((compiler) => compiler.options.watchOptions || {}), + watchCallback, + ); + } else { + compiler.watch(compiler.options.watchOptions || {}, watchCallback); + } }, }, help: { @@ -3697,6 +3771,35 @@ class WebpackCLI { return Boolean(compiler.options.watchOptions?.stdin); } + #setupGracefulShutdown(compiler: Compiler | MultiCompiler): void { + let needForceShutdown = false; + + for (const signal of EXIT_SIGNALS) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + const listener = () => { + if (needForceShutdown) { + process.exit(0); + } + + // Keep fast shutdowns silent. + const timeout = setTimeout(() => { + this.logger.info( + "Gracefully shutting down. To force exit, press ^C again. Please wait...", + ); + }, 2000); + + needForceShutdown = true; + + compiler.close(() => { + clearTimeout(timeout); + process.exit(0); + }); + }; + + process.on(signal, listener); + } + } + async runWebpack(options: Options, isWatchCommand: boolean): Promise { let compiler: Compiler | MultiCompiler; let stringifyChunked: typeof stringifyChunkedType; @@ -3794,32 +3897,7 @@ class WebpackCLI { ); if (needGracefulShutdown(compiler)) { - let needForceShutdown = false; - - for (const signal of EXIT_SIGNALS) { - // eslint-disable-next-line @typescript-eslint/no-loop-func - const listener = () => { - if (needForceShutdown) { - process.exit(0); - } - - // Output message after delay to avoid extra logging - const timeout = setTimeout(() => { - this.logger.info( - "Gracefully shutting down. To force exit, press ^C again. Please wait...", - ); - }, 2000); - - needForceShutdown = true; - - compiler.close(() => { - clearTimeout(timeout); - process.exit(0); - }); - }; - - process.on(signal, listener); - } + this.#setupGracefulShutdown(compiler); if (this.#needWatchStdin(compiler)) { process.stdin.on("end", () => { diff --git a/test/serve/basic/__snapshots__/serve-basic.test.js.snap.devServer6.webpack5 b/test/serve/basic/__snapshots__/serve-basic.test.js.snap.devServer6.webpack5 index fcfe51b0fb8..a2909ebf0e4 100644 --- a/test/serve/basic/__snapshots__/serve-basic.test.js.snap.devServer6.webpack5 +++ b/test/serve/basic/__snapshots__/serve-basic.test.js.snap.devServer6.webpack5 @@ -66,12 +66,7 @@ exports[`basic serve usage should respect the "publicPath" option from configura `; exports[`basic serve usage should throw error when same ports in multicompiler: stderr 1`] = ` -" [webpack-dev-server] Project is running at: - [webpack-dev-server] Loopback: http://localhost:/, http://[::1]:/ - [webpack-dev-server] On Your Network (IPv4): http://x.x.x.x:/ - [webpack-dev-server] On Your Network (IPv6): http://[x:x:x:x:x:x:x:x]:/ - [webpack-dev-server] Content not from webpack is served from '/test/serve/basic/public' directory -[webpack-cli] ✖ Error: Unique ports must be specified for each devServer option in your webpack configuration. Alternatively, run only 1 devServer config using the --config-name flag to specify your desired config. +"[webpack-cli] ✖ Error: Unique ports must be specified for each devServer option in your webpack configuration. Alternatively, run only 1 devServer config using the --config-name flag to specify your desired config. at stack" `; diff --git a/test/serve/error-handling/bad-middleware.config.js b/test/serve/error-handling/bad-middleware.config.js new file mode 100644 index 00000000000..0a4e1d04d51 --- /dev/null +++ b/test/serve/error-handling/bad-middleware.config.js @@ -0,0 +1,7 @@ +module.exports = { + mode: "development", + entry: "./src/index.js", + devServer: { + devMiddleware: { unknownOption: true }, + }, +}; diff --git a/test/serve/error-handling/error.config.js b/test/serve/error-handling/error.config.js new file mode 100644 index 00000000000..99c695a55a1 --- /dev/null +++ b/test/serve/error-handling/error.config.js @@ -0,0 +1,4 @@ +module.exports = { + mode: "development", + entry: "./src/syntax-error.js", +}; diff --git a/test/serve/error-handling/serve-error-handling.test.js b/test/serve/error-handling/serve-error-handling.test.js new file mode 100644 index 00000000000..9aca2ef27e4 --- /dev/null +++ b/test/serve/error-handling/serve-error-handling.test.js @@ -0,0 +1,74 @@ +"use strict"; + +/* eslint-disable jest/require-top-level-describe -- Version-gated describe */ + +const [devServerVersion] = require("webpack-dev-server/package.json").version; +const { run, runWatch } = require("../../utils/test-utils"); + +const getGetPort = () => import("get-port"); + +// Only plugin mode uses the CLI's watch callback. +const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; + +describeDevServer6("serve error handling", () => { + let port; + + beforeEach(async () => { + port = await (await getGetPort()).default(); + }); + + test("should print the stats with errors and keep serving when the compilation fails", async () => { + const { stderr, stdout } = await runWatch( + __dirname, + ["serve", "--config", "error.config.js", "--port", port], + { + stdoutKillStr: /ERROR/, + stderrKillStr: /Project is running at:/, + }, + ); + + expect(stdout).toContain("ERROR in"); + expect(stderr).toContain("Project is running at:"); + }); + + test("should print the stats with warnings using the '--fail-on-warnings' option", async () => { + const { stderr, stdout } = await runWatch( + __dirname, + ["serve", "--config", "warning.config.js", "--fail-on-warnings", "--port", port], + { + stdoutKillStr: /WARNING/, + stderrKillStr: /Project is running at:/, + }, + ); + + expect(stdout).toContain("WARNING"); + expect(stderr).toContain("Project is running at:"); + }); + + test("should log the error and exit when the dev server fails inside the watch run", async () => { + const { exitCode, stderr, stdout } = await run(__dirname, [ + "serve", + "--config", + "setup-failure.config.js", + ]); + + expect(exitCode).toBe(2); + expect(stderr).toContain("Injected middleware failure"); + expect(stdout).toBeFalsy(); + }); + + test("should log the validation error and exit when a middleware option is invalid", async () => { + const { exitCode, stderr, stdout } = await run(__dirname, [ + "serve", + "--config", + "bad-middleware.config.js", + ]); + + expect(exitCode).toBe(2); + expect(stderr).toContain( + "Dev Middleware has been initialized using an options object that does not match the API schema", + ); + expect(stderr).not.toContain("at Server.setupMiddlewares"); + expect(stdout).toBeFalsy(); + }); +}); diff --git a/test/serve/error-handling/setup-failure.config.js b/test/serve/error-handling/setup-failure.config.js new file mode 100644 index 00000000000..fbeccb27712 --- /dev/null +++ b/test/serve/error-handling/setup-failure.config.js @@ -0,0 +1,9 @@ +module.exports = { + mode: "development", + entry: "./src/index.js", + devServer: { + setupMiddlewares: () => { + throw new Error("Injected middleware failure"); + }, + }, +}; diff --git a/test/serve/error-handling/src/index.js b/test/serve/error-handling/src/index.js new file mode 100644 index 00000000000..4f252c00368 --- /dev/null +++ b/test/serve/error-handling/src/index.js @@ -0,0 +1 @@ +console.log("ok"); diff --git a/test/serve/error-handling/src/syntax-error.js b/test/serve/error-handling/src/syntax-error.js new file mode 100644 index 00000000000..3e3ce78ef41 --- /dev/null +++ b/test/serve/error-handling/src/syntax-error.js @@ -0,0 +1 @@ +const broken = {; diff --git a/test/serve/error-handling/warning.config.js b/test/serve/error-handling/warning.config.js new file mode 100644 index 00000000000..3af9c0e5835 --- /dev/null +++ b/test/serve/error-handling/warning.config.js @@ -0,0 +1,9 @@ +module.exports = { + mode: "development", + entry: "./src/index.js", + performance: { + hints: "warning", + maxAssetSize: 1, + maxEntrypointSize: 1, + }, +}; diff --git a/test/serve/rebuild/multi.config.js b/test/serve/rebuild/multi.config.js new file mode 100644 index 00000000000..f029cb9f8c9 --- /dev/null +++ b/test/serve/rebuild/multi.config.js @@ -0,0 +1,21 @@ +module.exports = [ + { + name: "app", + mode: "development", + entry: "./src/index.js", + output: { filename: "app.js" }, + watchOptions: { + aggregateTimeout: 10, + }, + devServer: {}, + }, + { + name: "worker", + mode: "development", + entry: "./src/index.js", + output: { filename: "worker.js" }, + watchOptions: { + aggregateTimeout: 10, + }, + }, +]; diff --git a/test/serve/rebuild/serve-rebuild.test.js b/test/serve/rebuild/serve-rebuild.test.js new file mode 100644 index 00000000000..999b18a759f --- /dev/null +++ b/test/serve/rebuild/serve-rebuild.test.js @@ -0,0 +1,101 @@ +"use strict"; + +const { readFileSync, writeFileSync } = require("node:fs"); +const { resolve } = require("node:path"); +const { processKill, runWatch } = require("../../utils/test-utils"); + +const getGetPort = () => import("get-port"); + +const entryPath = resolve(__dirname, "./src/index.js"); +const originalEntry = readFileSync(entryPath, "utf8"); + +describe("serve recompilation", () => { + let port; + + beforeEach(async () => { + port = await (await getGetPort()).default(); + }); + + afterEach(() => { + writeFileSync(entryPath, originalEntry); + }); + + it("should recompile upon file change and log the stats again", async () => { + let compilations = 0; + + await runWatch(__dirname, ["serve", "--mode", "development", "--port", port], { + handler: (proc) => { + proc.stdout.on("data", (chunk) => { + const data = chunk.toString(); + + if (!data.includes("compiled successfully")) { + return; + } + + compilations += 1; + + if (compilations === 1) { + process.nextTick(() => { + writeFileSync(entryPath, originalEntry); + }); + } else { + processKill(proc); + } + }); + }, + }); + + expect(compilations).toBe(2); + }); + + it("should serve the updated bundle from memory after recompiling", async () => { + let compilations = 0; + let updatedBody; + + await runWatch(__dirname, ["serve", "--mode", "development", "--port", port], { + handler: (proc) => { + proc.stdout.on("data", (chunk) => { + const data = chunk.toString(); + + if (!data.includes("compiled successfully")) { + return; + } + + compilations += 1; + + if (compilations === 1) { + process.nextTick(() => { + writeFileSync(entryPath, "console.log('serve rebuild test updated');\n"); + }); + } else { + fetch(`http://127.0.0.1:${port}/main.js`) + .then((response) => response.text()) + .then((body) => { + updatedBody = body; + }) + .finally(() => { + processKill(proc); + }); + } + }); + }, + }); + + expect(updatedBody).toContain("serve rebuild test updated"); + }); + + it("should watch every compiler of a multi compiler with its own watch options", async () => { + const { stderr, stdout } = await runWatch( + __dirname, + ["serve", "--config", "multi.config.js", "--port", port], + { + stdoutKillStr: /compiled successfully/, + stderrKillStr: /Project is running at:/, + }, + ); + + expect(stdout).toContain("app:"); + expect(stdout).toContain("worker:"); + expect(stderr).toContain("Project is running at:"); + }); +}); diff --git a/test/serve/rebuild/src/index.js b/test/serve/rebuild/src/index.js new file mode 100644 index 00000000000..fe97d3579ec --- /dev/null +++ b/test/serve/rebuild/src/index.js @@ -0,0 +1 @@ +console.log("serve rebuild test"); diff --git a/test/serve/rebuild/webpack.config.js b/test/serve/rebuild/webpack.config.js new file mode 100644 index 00000000000..d2163db454e --- /dev/null +++ b/test/serve/rebuild/webpack.config.js @@ -0,0 +1,7 @@ +module.exports = { + mode: "development", + entry: "./src/index.js", + watchOptions: { + aggregateTimeout: 10, + }, +}; From ffc608f35ff80a61f3fb7f2b69910e863cdf7d02 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 14:15:04 -0500 Subject: [PATCH 2/9] fix: preserve exit status and avoid duplicate shutdown in plugin mode --- packages/webpack-cli/src/webpack-cli.ts | 15 ++-- .../serve-error-handling.test.js | 61 +++++++++++++- test/serve/error-handling/shutdown.config.js | 22 +++++ test/serve/rebuild/multi.config.js | 22 ++++- test/serve/rebuild/serve-rebuild.test.js | 81 +++++++++++++++---- test/serve/rebuild/src/worker.js | 1 + 6 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 test/serve/error-handling/shutdown.config.js create mode 100644 test/serve/rebuild/src/worker.js diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 49f9457264f..3ac6489f148 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -2293,7 +2293,10 @@ class WebpackCLI { ) => { apply(compiler: Compiler | MultiCompiler): void }; // Serve all child compilers, regardless of which defines devServer. - new DevServerPlugin(devServerConfiguration).apply(compiler); + new DevServerPlugin({ + ...devServerConfiguration, + setupExitSignals: false, + }).apply(compiler); } else { const server = new DevServer(devServerConfiguration, compiler); @@ -2325,12 +2328,12 @@ class WebpackCLI { } // Closing the compiler stops the server through its shutdown hook. - this.#setupGracefulShutdown(compiler); + this.#setupGracefulShutdown(compiler, true); if (this.#needWatchStdin(compiler)) { process.stdin.on("end", () => { compiler.close(() => { - process.exit(0); + process.exit(); }); }); process.stdin.resume(); @@ -3771,14 +3774,14 @@ class WebpackCLI { return Boolean(compiler.options.watchOptions?.stdin); } - #setupGracefulShutdown(compiler: Compiler | MultiCompiler): void { + #setupGracefulShutdown(compiler: Compiler | MultiCompiler, preserveExitCode = false): void { let needForceShutdown = false; for (const signal of EXIT_SIGNALS) { // eslint-disable-next-line @typescript-eslint/no-loop-func const listener = () => { if (needForceShutdown) { - process.exit(0); + process.exit(preserveExitCode ? process.exitCode : 0); } // Keep fast shutdowns silent. @@ -3792,7 +3795,7 @@ class WebpackCLI { compiler.close(() => { clearTimeout(timeout); - process.exit(0); + process.exit(preserveExitCode ? process.exitCode : 0); }); }; diff --git a/test/serve/error-handling/serve-error-handling.test.js b/test/serve/error-handling/serve-error-handling.test.js index 9aca2ef27e4..ad9cf01c168 100644 --- a/test/serve/error-handling/serve-error-handling.test.js +++ b/test/serve/error-handling/serve-error-handling.test.js @@ -18,7 +18,7 @@ describeDevServer6("serve error handling", () => { }); test("should print the stats with errors and keep serving when the compilation fails", async () => { - const { stderr, stdout } = await runWatch( + const { exitCode, stderr, stdout } = await runWatch( __dirname, ["serve", "--config", "error.config.js", "--port", port], { @@ -29,10 +29,11 @@ describeDevServer6("serve error handling", () => { expect(stdout).toContain("ERROR in"); expect(stderr).toContain("Project is running at:"); + expect(exitCode).toBe(1); }); test("should print the stats with warnings using the '--fail-on-warnings' option", async () => { - const { stderr, stdout } = await runWatch( + const { exitCode, stderr, stdout } = await runWatch( __dirname, ["serve", "--config", "warning.config.js", "--fail-on-warnings", "--port", port], { @@ -43,8 +44,64 @@ describeDevServer6("serve error handling", () => { expect(stdout).toContain("WARNING"); expect(stderr).toContain("Project is running at:"); + expect(exitCode).toBe(1); }); + test.each(process.platform === "win32" ? ["stdin"] : ["SIGINT", "SIGTERM", "stdin"])( + "should preserve failure status and close the compiler on %s", + async (trigger) => { + const { exitCode, stdout, stderr } = await runWatch( + __dirname, + [ + "serve", + "--config", + "shutdown.config.js", + "--fail-on-warnings", + "--watch-options-stdin", + "--port", + port, + ], + { + handler: (proc) => { + let output = ""; + let serverOutput = ""; + let stopping = false; + const stop = () => { + if ( + stopping || + !output.includes("WARNING") || + !serverOutput.includes("Server ready") + ) { + return; + } + + stopping = true; + + if (trigger === "stdin") { + proc.stdin.end(); + } else { + proc.kill(trigger); + } + }; + + proc.stdout.on("data", (chunk) => { + output += chunk.toString(); + stop(); + }); + proc.stderr.on("data", (chunk) => { + serverOutput += chunk.toString(); + stop(); + }); + }, + }, + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Server ready: 1 SIGINT, 1 SIGTERM"); + expect(stdout.match(/Compiler shutdown/g)).toHaveLength(1); + }, + ); + test("should log the error and exit when the dev server fails inside the watch run", async () => { const { exitCode, stderr, stdout } = await run(__dirname, [ "serve", diff --git a/test/serve/error-handling/shutdown.config.js b/test/serve/error-handling/shutdown.config.js new file mode 100644 index 00000000000..4eff6baf7a1 --- /dev/null +++ b/test/serve/error-handling/shutdown.config.js @@ -0,0 +1,22 @@ +const configuration = require("./warning.config"); + +module.exports = { + ...configuration, + devServer: { + setupExitSignals: true, + onListening: () => { + console.error( + `Server ready: ${process.listenerCount("SIGINT")} SIGINT, ${process.listenerCount("SIGTERM")} SIGTERM`, + ); + }, + }, + plugins: [ + { + apply(compiler) { + compiler.hooks.shutdown.tap("ShutdownTest", () => { + console.log("Compiler shutdown"); + }); + }, + }, + ], +}; diff --git a/test/serve/rebuild/multi.config.js b/test/serve/rebuild/multi.config.js index f029cb9f8c9..53b95145a7c 100644 --- a/test/serve/rebuild/multi.config.js +++ b/test/serve/rebuild/multi.config.js @@ -1,8 +1,25 @@ +const plugins = [ + { + apply(compiler) { + compiler.hooks.watchRun.tap("RebuildTest", () => { + console.log(`Watch ${compiler.name}: ${compiler.watching.watchOptions.aggregateTimeout}`); + }); + compiler.hooks.afterDone.tap("RebuildTest", () => { + console.log(`Built ${compiler.name}`); + }); + compiler.hooks.shutdown.tap("RebuildTest", () => { + console.log(`Closed ${compiler.name}`); + }); + }, + }, +]; + module.exports = [ { name: "app", mode: "development", entry: "./src/index.js", + plugins, output: { filename: "app.js" }, watchOptions: { aggregateTimeout: 10, @@ -12,10 +29,11 @@ module.exports = [ { name: "worker", mode: "development", - entry: "./src/index.js", + entry: "./src/worker.js", + plugins, output: { filename: "worker.js" }, watchOptions: { - aggregateTimeout: 10, + aggregateTimeout: 30, }, }, ]; diff --git a/test/serve/rebuild/serve-rebuild.test.js b/test/serve/rebuild/serve-rebuild.test.js index 999b18a759f..5829b1286d9 100644 --- a/test/serve/rebuild/serve-rebuild.test.js +++ b/test/serve/rebuild/serve-rebuild.test.js @@ -8,6 +8,8 @@ const getGetPort = () => import("get-port"); const entryPath = resolve(__dirname, "./src/index.js"); const originalEntry = readFileSync(entryPath, "utf8"); +const workerPath = resolve(__dirname, "./src/worker.js"); +const originalWorker = readFileSync(workerPath, "utf8"); describe("serve recompilation", () => { let port; @@ -18,6 +20,7 @@ describe("serve recompilation", () => { afterEach(() => { writeFileSync(entryPath, originalEntry); + writeFileSync(workerPath, originalWorker); }); it("should recompile upon file change and log the stats again", async () => { @@ -84,18 +87,68 @@ describe("serve recompilation", () => { expect(updatedBody).toContain("serve rebuild test updated"); }); - it("should watch every compiler of a multi compiler with its own watch options", async () => { - const { stderr, stdout } = await runWatch( - __dirname, - ["serve", "--config", "multi.config.js", "--port", port], - { - stdoutKillStr: /compiled successfully/, - stderrKillStr: /Project is running at:/, - }, - ); - - expect(stdout).toContain("app:"); - expect(stdout).toContain("worker:"); - expect(stderr).toContain("Project is running at:"); - }); + it.each(["app", "worker"])( + "should rebuild and serve changes to the %s compiler", + async (name) => { + let updatedBody; + let requestError; + const { stdout } = await runWatch( + __dirname, + ["serve", "--config", "multi.config.js", "--port", port], + { + handler: (proc) => { + let output = ""; + let serverOutput = ""; + let changed = false; + let fetching = false; + const check = () => { + if ( + !changed && + output.includes("Built app") && + output.includes("Built worker") && + serverOutput.includes("Project is running at:") + ) { + changed = true; + writeFileSync( + name === "app" ? entryPath : workerPath, + `console.log('updated ${name} bundle');\n`, + ); + } + + if (!fetching && output.split(`Built ${name}`).length === 3) { + fetching = true; + fetch(`http://127.0.0.1:${port}/${name}.js`) + .then((response) => response.text()) + .then((body) => { + updatedBody = body; + }) + .catch((error) => { + requestError = error; + }) + .finally(() => { + processKill(proc); + }); + } + }; + + proc.stdout.on("data", (chunk) => { + output += chunk.toString(); + check(); + }); + proc.stderr.on("data", (chunk) => { + serverOutput += chunk.toString(); + check(); + }); + }, + }, + ); + + expect(requestError).toBeUndefined(); + expect(updatedBody).toContain(`updated ${name} bundle`); + expect(stdout).toContain("Watch app: 10"); + expect(stdout).toContain("Watch worker: 30"); + expect(stdout.match(/Closed app/g)).toHaveLength(1); + expect(stdout.match(/Closed worker/g)).toHaveLength(1); + }, + ); }); diff --git a/test/serve/rebuild/src/worker.js b/test/serve/rebuild/src/worker.js new file mode 100644 index 00000000000..adfd954e154 --- /dev/null +++ b/test/serve/rebuild/src/worker.js @@ -0,0 +1 @@ +console.log("serve worker test"); From 621d8ed86bdc6f095994d1a8fb6cd2ef03bda6c4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 14:45:45 -0500 Subject: [PATCH 3/9] test: close dev server rebuild tests through stdin --- test/serve/rebuild/serve-rebuild.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/serve/rebuild/serve-rebuild.test.js b/test/serve/rebuild/serve-rebuild.test.js index 5829b1286d9..9f2021cc55a 100644 --- a/test/serve/rebuild/serve-rebuild.test.js +++ b/test/serve/rebuild/serve-rebuild.test.js @@ -2,6 +2,7 @@ const { readFileSync, writeFileSync } = require("node:fs"); const { resolve } = require("node:path"); +const [devServerVersion] = require("webpack-dev-server/package.json").version; const { processKill, runWatch } = require("../../utils/test-utils"); const getGetPort = () => import("get-port"); @@ -94,7 +95,7 @@ describe("serve recompilation", () => { let requestError; const { stdout } = await runWatch( __dirname, - ["serve", "--config", "multi.config.js", "--port", port], + ["serve", "--config", "multi.config.js", "--watch-options-stdin", "--port", port], { handler: (proc) => { let output = ""; @@ -126,7 +127,7 @@ describe("serve recompilation", () => { requestError = error; }) .finally(() => { - processKill(proc); + proc.stdin.end(); }); } }; @@ -147,8 +148,10 @@ describe("serve recompilation", () => { expect(updatedBody).toContain(`updated ${name} bundle`); expect(stdout).toContain("Watch app: 10"); expect(stdout).toContain("Watch worker: 30"); - expect(stdout.match(/Closed app/g)).toHaveLength(1); - expect(stdout.match(/Closed worker/g)).toHaveLength(1); + if (devServerVersion !== "5") { + expect(stdout.match(/Closed app/g)).toHaveLength(1); + expect(stdout.match(/Closed worker/g)).toHaveLength(1); + } }, ); }); From a97af7be745e6db1e46c3548d3055df6590f07e7 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 14:59:56 -0500 Subject: [PATCH 4/9] fix: assign distinct automatic ports in dev server plugin mode --- package-lock.json | 2 +- packages/webpack-cli/package.json | 1 + packages/webpack-cli/src/webpack-cli.ts | 56 ++++++++++++--- .../automatic-ports/automatic-ports.test.js | 72 +++++++++++++++++++ test/serve/automatic-ports/webpack.config.js | 18 +++++ 5 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 test/serve/automatic-ports/automatic-ports.test.js create mode 100644 test/serve/automatic-ports/webpack.config.js diff --git a/package-lock.json b/package-lock.json index 9860bab46e2..e5cd9ee9474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11815,7 +11815,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", - "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -21068,6 +21067,7 @@ "commander": "^14.0.3", "cross-spawn": "^7.0.6", "envinfo": "^7.21.0", + "get-port": "^7.2.0", "import-local": "^3.2.0", "interpret": "^3.1.1", "rechoir": "^1.0.0", diff --git a/packages/webpack-cli/package.json b/packages/webpack-cli/package.json index 21c7c36f92b..9ec4122c904 100644 --- a/packages/webpack-cli/package.json +++ b/packages/webpack-cli/package.json @@ -35,6 +35,7 @@ "commander": "^14.0.3", "cross-spawn": "^7.0.6", "envinfo": "^7.21.0", + "get-port": "^7.2.0", "import-local": "^3.2.0", "interpret": "^3.1.1", "rechoir": "^1.0.0", diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 3ac6489f148..830d3dcac09 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -2240,6 +2240,20 @@ class WebpackCLI { const compilersForDevServer = possibleCompilers.length > 0 ? possibleCompilers : [compilers[0]]; const usedPorts: number[] = []; + const devServerConfigurations: DevServerConfiguration[] = []; + const validatePort = ({ port }: DevServerConfiguration): void => { + if (port && port !== "auto") { + const portNumber = Number(port); + + if (usedPorts.includes(portNumber)) { + throw new Error( + "Unique ports must be specified for each devServer option in your webpack configuration. Alternatively, run only 1 devServer config using the --config-name flag to specify your desired config.", + ); + } + + usedPorts.push(portNumber); + } + }; // @ts-expect-error different versions of the `Schema` type const devServerArgs = this.#getArguments(webpack, devServer.schema); let appliedDevServers = 0; @@ -2273,20 +2287,45 @@ class WebpackCLI { this.#processArguments(webpack, args, devServerConfiguration, values); } - if (devServerConfiguration.port) { - const portNumber = Number(devServerConfiguration.port); + if (isDevServerPlugin) { + validatePort(devServerConfiguration); + } - if (usedPorts.includes(portNumber)) { - throw new Error( - "Unique ports must be specified for each devServer option in your webpack configuration. Alternatively, run only 1 devServer config using the --config-name flag to specify your desired config.", - ); - } + devServerConfigurations.push(devServerConfiguration); + } - usedPorts.push(portNumber); + for (const devServerConfiguration of devServerConfigurations) { + if (!isDevServerPlugin) { + validatePort(devServerConfiguration); } try { if (isDevServerPlugin) { + let { port } = devServerConfiguration; + + if ( + devServerConfigurations.length > 1 && + !devServerConfiguration.ipc && + (typeof port === "undefined" || port === "auto") + ) { + const { default: getPort, portNumbers } = await import("get-port"); + const basePort = Number.parseInt( + process.env.WEBPACK_DEV_SERVER_BASE_PORT ?? "8080", + 10, + ); + const host = devServerConfiguration.host + ? await DevServer.getHostname(devServerConfiguration.host) + : undefined; + + // Plugins select ports before any server starts listening. + port = await getPort({ + port: portNumbers(basePort, 65535), + host, + exclude: usedPorts, + }); + usedPorts.push(port); + } + // v5 typings lack the plugin constructor. const DevServerPlugin = DevServer as unknown as new ( options: DevServerConfiguration, @@ -2295,6 +2334,7 @@ class WebpackCLI { // Serve all child compilers, regardless of which defines devServer. new DevServerPlugin({ ...devServerConfiguration, + port, setupExitSignals: false, }).apply(compiler); } else { diff --git a/test/serve/automatic-ports/automatic-ports.test.js b/test/serve/automatic-ports/automatic-ports.test.js new file mode 100644 index 00000000000..6da3dc3ee3d --- /dev/null +++ b/test/serve/automatic-ports/automatic-ports.test.js @@ -0,0 +1,72 @@ +/* eslint-disable jest/require-top-level-describe -- Version-gated describe */ + +const net = require("node:net"); +const [devServerVersion] = require("webpack-dev-server/package.json").version; +const { runWatch } = require("../../utils/test-utils"); + +const getGetPort = () => import("get-port"); + +const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; + +describeDevServer6("automatic dev server ports", () => { + let occupied; + let basePort; + + beforeEach(async () => { + occupied = net.createServer(); + basePort = await (await getGetPort()).default(); + await new Promise((resolve, reject) => { + occupied.once("error", reject); + occupied.listen(basePort, "127.0.0.1", resolve); + }); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + occupied.close((error) => (error ? reject(error) : resolve())); + }); + }); + + test.each(["omitted", "auto", "explicit"])( + "should assign distinct available ports with %s ports", + async (mode) => { + const explicitPort = await (await getGetPort()).default(); + const args = ["serve", "--watch-options-stdin"]; + + if (mode === "auto") { + args.push("--env", "auto=true"); + } else if (mode === "explicit") { + args.push("--env", `explicit=${explicitPort}`); + } + + const { exitCode, stdout, stderr } = await runWatch(__dirname, args, { + env: { + WEBPACK_DEV_SERVER_BASE_PORT: String(mode === "explicit" ? explicitPort : basePort), + }, + handler: (proc) => { + let output = ""; + let stopping = false; + proc.stdout.on("data", (chunk) => { + output += chunk.toString(); + + if (!stopping && [...output.matchAll(/Listening \d: \d+\n/g)].length === 2) { + stopping = true; + proc.stdin.end(); + } + }); + }, + }); + + const ports = [...stdout.matchAll(/Listening \d: (\d+)/g)].map((match) => Number(match[1])); + expect(exitCode).toBe(0); + expect(ports).toHaveLength(2); + expect(new Set(ports).size).toBe(2); + expect(ports).not.toContain(basePort); + expect(stderr).not.toContain("EADDRINUSE"); + + if (mode === "explicit") { + expect(ports).toContain(explicitPort); + } + }, + ); +}); diff --git a/test/serve/automatic-ports/webpack.config.js b/test/serve/automatic-ports/webpack.config.js new file mode 100644 index 00000000000..20a0cdacc14 --- /dev/null +++ b/test/serve/automatic-ports/webpack.config.js @@ -0,0 +1,18 @@ +module.exports = (env) => + [0, 1].map((index) => ({ + name: `server-${index}`, + mode: "development", + entry: "../rebuild/src/index.js", + output: { filename: `server-${index}.js` }, + devServer: { + host: "127.0.0.1", + static: false, + hot: false, + client: false, + ...(env.explicit && index === 1 ? { port: Number(env.explicit) } : {}), + ...(env.auto ? { port: "auto" } : {}), + onListening(server) { + console.log(`Listening ${index}: ${server.server.address().port}`); + }, + }, + })); From d7a934d085e3cc92b90bd693560a42d0f52260cc Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 15:08:04 -0500 Subject: [PATCH 5/9] fix: respect dev middleware stats in plugin mode --- packages/webpack-cli/src/webpack-cli.ts | 34 ++++++++++---- .../middleware-stats/middleware-stats.test.js | 45 +++++++++++++++++++ test/serve/middleware-stats/webpack.config.js | 37 +++++++++++++++ 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 test/serve/middleware-stats/middleware-stats.test.js create mode 100644 test/serve/middleware-stats/webpack.config.js diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 830d3dcac09..3103ea3139c 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -2398,17 +2398,35 @@ class WebpackCLI { process.exitCode = 1; } - // Plugin mode leaves stats output to the CLI. - const statsOptions = this.isMultipleCompiler(compiler) - ? { - children: compiler.compilers.map((compiler) => compiler.options.stats), + // Each middleware can override stats for the whole compiler. + for (const devServerConfiguration of devServerConfigurations) { + const middlewareStats = devServerConfiguration.devMiddleware?.stats; + const getStatsOptions = (compiler: Compiler): StatsOptions => { + if (typeof middlewareStats === "undefined") { + return compiler.options.stats as StatsOptions; } - : compiler.options.stats; - const printedStats = stats.toString(statsOptions as StatsOptions); + const statsOptions: StatsOptions = + typeof middlewareStats === "boolean" + ? { preset: middlewareStats ? "normal" : "none" } + : typeof middlewareStats === "string" + ? { preset: middlewareStats } + : { ...middlewareStats }; - if (printedStats) { - this.logger.raw(printedStats); + if (typeof statsOptions.colors === "undefined") { + statsOptions.colors = (compiler.options.stats as StatsOptions).colors; + } + + return statsOptions; + }; + const statsOptions = this.isMultipleCompiler(compiler) + ? { children: compiler.compilers.map(getStatsOptions) } + : getStatsOptions(compiler); + const printedStats = stats.toString(statsOptions); + + if (printedStats) { + this.logger.raw(printedStats); + } } }; diff --git a/test/serve/middleware-stats/middleware-stats.test.js b/test/serve/middleware-stats/middleware-stats.test.js new file mode 100644 index 00000000000..ebe6192697c --- /dev/null +++ b/test/serve/middleware-stats/middleware-stats.test.js @@ -0,0 +1,45 @@ +const { runWatch } = require("../../utils/test-utils"); + +describe.each([false, true])("middleware stats (multi: %s)", (multi) => { + it.each(["none", "false", "true", "assets", "fallback"])( + "should respect %s stats", + async (kind) => { + const args = ["serve", "--watch-options-stdin", "--env", `kind=${kind}`]; + if (multi) { + args.push("--env", "multi=true"); + } + const { exitCode, stdout } = await runWatch(__dirname, args, { + handler: (proc) => { + let output = ""; + let stopping = false; + proc.stderr.on("data", (chunk) => { + output += chunk.toString(); + if ( + !stopping && + output.includes("Server ready") && + output.includes("Built first") && + (!multi || output.includes("Built second")) + ) { + stopping = true; + proc.stdin.end(); + } + }); + }, + }); + expect(exitCode).toBe(0); + if (kind === "none" || kind === "false" || kind === "fallback") { + expect(stdout).toBe(""); + } else { + expect(stdout).toContain("first.js"); + if (multi) { + expect(stdout).toContain("second.js"); + } + if (kind === "assets") { + expect(stdout).not.toContain("compiled successfully"); + } else { + expect(stdout).toContain("compiled successfully"); + } + } + }, + ); +}); diff --git a/test/serve/middleware-stats/webpack.config.js b/test/serve/middleware-stats/webpack.config.js new file mode 100644 index 00000000000..91708b0a1bc --- /dev/null +++ b/test/serve/middleware-stats/webpack.config.js @@ -0,0 +1,37 @@ +module.exports = (env) => { + const overrides = { + none: "none", + false: false, + true: true, + assets: { all: false, assets: true }, + }; + const makeConfig = (name) => ({ + name, + mode: "development", + entry: "../rebuild/src/index.js", + output: { filename: `${name}.js` }, + stats: env.kind === "none" || env.kind === "false" ? "normal" : "none", + plugins: [ + { + apply(compiler) { + compiler.hooks.afterDone.tap("StatsTest", () => { + console.error(`Built ${name}`); + }); + }, + }, + ], + }); + const first = makeConfig("first"); + first.devServer = { + host: "127.0.0.1", + port: 0, + static: false, + client: false, + hot: false, + devMiddleware: { stats: overrides[env.kind] }, + onListening() { + console.error("Server ready"); + }, + }; + return env.multi ? [first, makeConfig("second")] : first; +}; From 9c33fbfc406cc400595bbc3a01e1bf2c147a99f7 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:17:16 +0000 Subject: [PATCH 6/9] feat: add the `--dev-server-per-compiler` option to `webpack serve` In multi-compiler mode every dev server serves all compilers by default, the same as dev servers without plugin support do. With the new option, each dev server is applied as a plugin only to the compiler that defines it, and each compiler's stats are printed using the stats options of its own dev server. Dev servers without plugin support ignore the option with a warning. Also require webpack-dev-server@6.1 for development and mark the workaround for dev servers without plugin support for removal in the next major release. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- .changeset/dev-server-6-plugin.md | 2 + SERVE-OPTIONS-v6.md | 1 + package-lock.json | 2 +- package.json | 2 +- packages/webpack-cli/src/webpack-cli.ts | 159 ++++++++++++------ .../help.test.js.snap.devServer5.webpack5 | 11 ++ .../help.test.js.snap.devServer6.webpack5 | 11 ++ .../per-compiler/serve-per-compiler.test.js | 121 +++++++++++++ test/serve/per-compiler/single.config.js | 16 ++ test/serve/per-compiler/src/admin.js | 1 + test/serve/per-compiler/src/app.js | 1 + test/serve/per-compiler/webpack.config.js | 19 +++ 12 files changed, 295 insertions(+), 51 deletions(-) create mode 100644 test/serve/per-compiler/serve-per-compiler.test.js create mode 100644 test/serve/per-compiler/single.config.js create mode 100644 test/serve/per-compiler/src/admin.js create mode 100644 test/serve/per-compiler/src/app.js create mode 100644 test/serve/per-compiler/webpack.config.js diff --git a/.changeset/dev-server-6-plugin.md b/.changeset/dev-server-6-plugin.md index 082a1bd9661..bb1fc549ef0 100644 --- a/.changeset/dev-server-6-plugin.md +++ b/.changeset/dev-server-6-plugin.md @@ -3,3 +3,5 @@ --- feat: run `webpack-dev-server@6` as a compiler plugin. The CLI drives watch compilation, prints build stats and closes the compiler on shutdown. Older dev servers continue to manage their own compilation. + +In multi-compiler mode, every dev server serves all compilers by default. Use the new `--dev-server-per-compiler` option of `webpack serve` to serve each compiler only by the dev server from its own configuration. diff --git a/SERVE-OPTIONS-v6.md b/SERVE-OPTIONS-v6.md index f05f4e2b931..306439e14a6 100644 --- a/SERVE-OPTIONS-v6.md +++ b/SERVE-OPTIONS-v6.md @@ -18,6 +18,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. diff --git a/package-lock.json b/package-lock.json index e5cd9ee9474..576416e12bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "typescript": "^6.0.3", "webpack": "^5.112.0", "webpack-bundle-analyzer": "^5.4.0", - "webpack-dev-server": "^6.0.0" + "webpack-dev-server": "^6.1.0" }, "engines": { "node": ">=20.9.0" diff --git a/package.json b/package.json index b664e31b830..f7ea0b1baa7 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "typescript": "^6.0.3", "webpack": "^5.112.0", "webpack-bundle-analyzer": "^5.4.0", - "webpack-dev-server": "^6.0.0" + "webpack-dev-server": "^6.1.0" }, "peerDependencies": { "webpack": "5.x.x" diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 3103ea3139c..e22c6b8e35a 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -2175,7 +2175,21 @@ class WebpackCLI { }, options: (cmd) => { const { webpack, devServer } = cmd.context; - const webpackOptions = this.schemaToOptions(webpack, undefined, this.#CLIOptions); + const webpackOptions = this.schemaToOptions(webpack, undefined, [ + ...this.#CLIOptions, + { + name: "dev-server-per-compiler", + configs: [ + { + type: "enum", + values: [true], + }, + ], + description: + "Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server.", + hidden: false, + }, + ]); // @ts-expect-error different versions of the `Schema` type const devServerOptions = this.schemaToOptions(webpack, devServer.schema, undefined, { hidden: false, @@ -2222,8 +2236,22 @@ class WebpackCLI { } const DevServer: DevServerConstructor = devServer; + // TODO in the next major release, require `webpack-dev-server@6` and remove this check + // together with the `start()`/`stop()` workaround for dev servers without plugin support const isDevServerPlugin = typeof (DevServer.prototype as { apply?: unknown }).apply === "function"; + const isMultiCompiler = this.isMultipleCompiler(compiler); + let isDevServerPerCompiler = Boolean(options.devServerPerCompiler) && isMultiCompiler; + + if (isDevServerPerCompiler && !isDevServerPlugin) { + this.logger.warn( + "The '--dev-server-per-compiler' option requires webpack-dev-server with plugin support (v6 or later), all compilers will be served by every dev server.", + ); + + isDevServerPerCompiler = false; + } + + // TODO in the next major release, remove it, servers without plugin support only const servers: InstanceType[] = []; if (!isDevServerPlugin && this.#needWatchStdin(compiler)) { @@ -2235,12 +2263,15 @@ class WebpackCLI { process.stdin.resume(); } - const compilers = this.isMultipleCompiler(compiler) ? compiler.compilers : [compiler]; + const compilers = isMultiCompiler ? compiler.compilers : [compiler as Compiler]; const possibleCompilers = compilers.filter((compiler) => compiler.options.devServer); const compilersForDevServer = possibleCompilers.length > 0 ? possibleCompilers : [compilers[0]]; const usedPorts: number[] = []; - const devServerConfigurations: DevServerConfiguration[] = []; + const devServers: { + compiler: Compiler; + configuration: DevServerConfiguration; + }[] = []; const validatePort = ({ port }: DevServerConfiguration): void => { if (port && port !== "auto") { const portNumber = Number(port); @@ -2291,21 +2322,24 @@ class WebpackCLI { validatePort(devServerConfiguration); } - devServerConfigurations.push(devServerConfiguration); + devServers.push({ + compiler: compilerForDevServer, + configuration: devServerConfiguration, + }); } - for (const devServerConfiguration of devServerConfigurations) { + for (const { compiler: compilerForDevServer, configuration } of devServers) { if (!isDevServerPlugin) { - validatePort(devServerConfiguration); + validatePort(configuration); } try { if (isDevServerPlugin) { - let { port } = devServerConfiguration; + let { port } = configuration; if ( - devServerConfigurations.length > 1 && - !devServerConfiguration.ipc && + devServers.length > 1 && + !configuration.ipc && (typeof port === "undefined" || port === "auto") ) { const { default: getPort, portNumbers } = await import("get-port"); @@ -2313,8 +2347,8 @@ class WebpackCLI { process.env.WEBPACK_DEV_SERVER_BASE_PORT ?? "8080", 10, ); - const host = devServerConfiguration.host - ? await DevServer.getHostname(devServerConfiguration.host) + const host = configuration.host + ? await DevServer.getHostname(configuration.host) : undefined; // Plugins select ports before any server starts listening. @@ -2326,19 +2360,21 @@ class WebpackCLI { usedPorts.push(port); } - // v5 typings lack the plugin constructor. + // By default, every dev server serves all compilers, regardless of which defines + // `devServer`, the same as dev servers without plugin support do. + // The CLI owns signal handling and closes the compiler on shutdown. + // TODO in the next major release, use `DevServer` directly, the cast is only + // needed for `webpack-dev-server@5` typings, which lack the plugin constructor const DevServerPlugin = DevServer as unknown as new ( options: DevServerConfiguration, ) => { apply(compiler: Compiler | MultiCompiler): void }; - // Serve all child compilers, regardless of which defines devServer. - new DevServerPlugin({ - ...devServerConfiguration, - port, - setupExitSignals: false, - }).apply(compiler); + new DevServerPlugin({ ...configuration, port, setupExitSignals: false }).apply( + isDevServerPerCompiler ? compilerForDevServer : compiler, + ); } else { - const server = new DevServer(devServerConfiguration, compiler); + // TODO in the next major release, remove it, servers without plugin support only + const server = new DevServer(configuration, compiler); await server.start(); @@ -2362,7 +2398,8 @@ class WebpackCLI { process.exit(2); } - // Older servers manage compilation and signals themselves. + // TODO in the next major release, remove it, servers without plugin support manage + // compilation and signals themselves if (!isDevServerPlugin) { return; } @@ -2379,6 +2416,47 @@ class WebpackCLI { process.stdin.resume(); } + const getStatsOptions = ( + compiler: Compiler, + configuration?: DevServerConfiguration, + ): StatsOptions => { + const middlewareStats = configuration?.devMiddleware?.stats; + + if (typeof middlewareStats === "undefined") { + return compiler.options.stats as StatsOptions; + } + + const statsOptions: StatsOptions = + typeof middlewareStats === "boolean" + ? { preset: middlewareStats ? "normal" : "none" } + : typeof middlewareStats === "string" + ? { preset: middlewareStats } + : { ...middlewareStats }; + + if (typeof statsOptions.colors === "undefined") { + statsOptions.colors = (compiler.options.stats as StatsOptions).colors; + } + + return statsOptions; + }; + const printStats = ( + stats: Stats | MultiStats, + getConfiguration: (compiler: Compiler) => DevServerConfiguration | undefined, + ): void => { + const statsOptions = isMultiCompiler + ? { + children: compilers.map((compiler) => + getStatsOptions(compiler, getConfiguration(compiler)), + ), + } + : getStatsOptions(compiler as Compiler, getConfiguration(compiler as Compiler)); + const printedStats = stats.toString(statsOptions); + + if (printedStats) { + this.logger.raw(printedStats); + } + }; + const watchCallback = (error: Error | null, stats?: Stats | MultiStats): void => { if (error) { if (this.isValidationError(error)) { @@ -2398,39 +2476,22 @@ class WebpackCLI { process.exitCode = 1; } - // Each middleware can override stats for the whole compiler. - for (const devServerConfiguration of devServerConfigurations) { - const middlewareStats = devServerConfiguration.devMiddleware?.stats; - const getStatsOptions = (compiler: Compiler): StatsOptions => { - if (typeof middlewareStats === "undefined") { - return compiler.options.stats as StatsOptions; - } - - const statsOptions: StatsOptions = - typeof middlewareStats === "boolean" - ? { preset: middlewareStats ? "normal" : "none" } - : typeof middlewareStats === "string" - ? { preset: middlewareStats } - : { ...middlewareStats }; - - if (typeof statsOptions.colors === "undefined") { - statsOptions.colors = (compiler.options.stats as StatsOptions).colors; - } - - return statsOptions; - }; - const statsOptions = this.isMultipleCompiler(compiler) - ? { children: compiler.compilers.map(getStatsOptions) } - : getStatsOptions(compiler); - const printedStats = stats.toString(statsOptions); - - if (printedStats) { - this.logger.raw(printedStats); + if (isDevServerPerCompiler) { + // Each compiler is printed using the stats options of its own dev server + printStats( + stats, + (compiler) => + devServers.find((devServer) => devServer.compiler === compiler)?.configuration, + ); + } else { + // Each middleware prints stats for the whole compiler + for (const { configuration } of devServers) { + printStats(stats, () => configuration); } } }; - if (this.isMultipleCompiler(compiler)) { + if (isMultiCompiler) { compiler.watch( compiler.compilers.map((compiler) => compiler.options.watchOptions || {}), watchCallback, diff --git a/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 b/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 index e64cdf6d4b0..8a7700c845e 100644 --- a/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 +++ b/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 @@ -1048,6 +1048,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1170,6 +1171,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -1216,6 +1218,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1338,6 +1341,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1460,6 +1464,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1582,6 +1587,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1704,6 +1710,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -1750,6 +1757,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1872,6 +1880,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1994,6 +2003,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -2040,6 +2050,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. diff --git a/test/help/__snapshots__/help.test.js.snap.devServer6.webpack5 b/test/help/__snapshots__/help.test.js.snap.devServer6.webpack5 index 72507ea3f5c..91f77f7c6dd 100644 --- a/test/help/__snapshots__/help.test.js.snap.devServer6.webpack5 +++ b/test/help/__snapshots__/help.test.js.snap.devServer6.webpack5 @@ -1048,6 +1048,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1170,6 +1171,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -1216,6 +1218,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1338,6 +1341,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1460,6 +1464,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1582,6 +1587,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1704,6 +1710,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -1750,6 +1757,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1872,6 +1880,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1994,6 +2003,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -2040,6 +2050,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. diff --git a/test/serve/per-compiler/serve-per-compiler.test.js b/test/serve/per-compiler/serve-per-compiler.test.js new file mode 100644 index 00000000000..f27d4e38695 --- /dev/null +++ b/test/serve/per-compiler/serve-per-compiler.test.js @@ -0,0 +1,121 @@ +"use strict"; + +/* eslint-disable jest/require-top-level-describe -- Version-gated describe */ + +const [devServerVersion] = require("webpack-dev-server/package.json").version; +const { runWatch } = require("../../utils/test-utils"); + +const getGetPort = () => import("get-port"); + +/** + * Starts `webpack serve` and requests every bundle from every dev server once they listen. + * @param {string[]} args extra CLI arguments + * @param {string=} config configuration file + * @returns {Promise<{ exitCode: number, stdout: string, stderr: string, responses: Record }>} result + */ +const serve = async (args, config = "webpack.config.js") => { + const { default: getPort } = await getGetPort(); + const appPort = await getPort(); + const adminPort = await getPort({ exclude: [appPort] }); + const names = config === "webpack.config.js" ? ["app", "admin"] : ["app"]; + const ports = { app: appPort, admin: adminPort }; + const responses = {}; + + const result = await runWatch( + __dirname, + [ + "serve", + "--config", + config, + "--watch-options-stdin", + "--env", + `appPort=${appPort}`, + "--env", + `adminPort=${adminPort}`, + ...args, + ], + { + handler: (proc) => { + let output = ""; + let requesting = false; + + proc.stderr.on("data", (chunk) => { + output += chunk.toString(); + + if (requesting || !names.every((name) => output.includes(`Listening ${name}`))) { + return; + } + + requesting = true; + + Promise.all( + names.flatMap((server) => + names.map((bundle) => + fetch(`http://127.0.0.1:${ports[server]}/${bundle}.js`).then((response) => { + responses[`${server}:${bundle}`] = response.status; + }), + ), + ), + ).finally(() => { + proc.stdin.end(); + }); + }); + }, + }, + ); + + return { ...result, responses }; +}; + +const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; +const describeDevServer5 = devServerVersion === "5" ? describe : describe.skip; + +describeDevServer6("serve with a dev server per compiler", () => { + test("should serve all compilers by every dev server by default", async () => { + const { exitCode, responses, stdout } = await serve([]); + + expect(exitCode).toBe(0); + expect(responses).toEqual({ + "app:app": 200, + "app:admin": 200, + "admin:app": 200, + "admin:admin": 200, + }); + // Every dev server prints the stats of all compilers using its own options + expect(stdout.match(/app\.js/g)).toHaveLength(1); + expect(stdout.match(/admin\.js/g)).toHaveLength(1); + }); + + test("should serve each compiler only by its own dev server using the '--dev-server-per-compiler' option", async () => { + const { exitCode, responses, stdout } = await serve(["--dev-server-per-compiler"]); + + expect(exitCode).toBe(0); + expect(responses).toEqual({ + "app:app": 200, + "app:admin": 404, + "admin:app": 404, + "admin:admin": 200, + }); + // Each compiler is printed using the stats options of its own dev server + expect(stdout).toContain("app.js"); + expect(stdout).not.toContain("admin.js"); + }); + + test("should work with a single compiler using the '--dev-server-per-compiler' option", async () => { + const { exitCode, responses } = await serve(["--dev-server-per-compiler"], "single.config.js"); + + expect(exitCode).toBe(0); + expect(responses).toEqual({ "app:app": 200 }); + }); +}); + +describeDevServer5("serve with a dev server per compiler", () => { + test("should warn and ignore the '--dev-server-per-compiler' option without plugin support", async () => { + const { exitCode, stderr } = await serve(["--dev-server-per-compiler"]); + + expect(exitCode).toBe(0); + expect(stderr).toContain( + "The '--dev-server-per-compiler' option requires webpack-dev-server with plugin support", + ); + }); +}); diff --git a/test/serve/per-compiler/single.config.js b/test/serve/per-compiler/single.config.js new file mode 100644 index 00000000000..567ce86aade --- /dev/null +++ b/test/serve/per-compiler/single.config.js @@ -0,0 +1,16 @@ +module.exports = (env) => ({ + name: "app", + mode: "development", + entry: "./src/app.js", + output: { filename: "app.js" }, + devServer: { + host: "127.0.0.1", + port: Number(env.appPort), + static: false, + hot: false, + client: false, + onListening() { + console.error("Listening app"); + }, + }, +}); diff --git a/test/serve/per-compiler/src/admin.js b/test/serve/per-compiler/src/admin.js new file mode 100644 index 00000000000..358a8b581cb --- /dev/null +++ b/test/serve/per-compiler/src/admin.js @@ -0,0 +1 @@ +console.log("admin"); diff --git a/test/serve/per-compiler/src/app.js b/test/serve/per-compiler/src/app.js new file mode 100644 index 00000000000..702645f13de --- /dev/null +++ b/test/serve/per-compiler/src/app.js @@ -0,0 +1 @@ +console.log("app"); diff --git a/test/serve/per-compiler/webpack.config.js b/test/serve/per-compiler/webpack.config.js new file mode 100644 index 00000000000..c9d1c7b36ea --- /dev/null +++ b/test/serve/per-compiler/webpack.config.js @@ -0,0 +1,19 @@ +module.exports = (env) => + ["app", "admin"].map((name, index) => ({ + name, + mode: "development", + entry: `./src/${name}.js`, + output: { filename: `${name}.js` }, + stats: "none", + devServer: { + host: "127.0.0.1", + port: Number(env[`${name}Port`]), + static: false, + hot: false, + client: false, + devMiddleware: { stats: index === 0 ? { all: false, assets: true } : "none" }, + onListening() { + console.error(`Listening ${name}`); + }, + }, + })); From 04d3f13aadfd13e1416744f4b862155e9354926e Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:21:35 +0000 Subject: [PATCH 7/9] refactor: add dev servers as plugins and reuse the watch flow in `webpack serve` With plugin support, `webpack serve` adds dev servers to the loaded configuration as plugins: to the configuration that defines `devServer` with `--dev-server-per-compiler`, otherwise to the top-level `plugins` of the multi configuration. Then it compiles like `webpack watch`, reusing its stats output, so `--json` works too. The old flow for dev servers without plugin support is kept unchanged in the `else` branch. webpack before 5.112 doesn't apply `plugins` of multi configurations, so they are applied to the multi compiler before watching starts. TODOs mark both workarounds for removal in the next major release. Dev servers in plugin mode select ports before any of them listens, so the CLI still assigns distinct automatic ports and rejects duplicate explicit ports, otherwise one of the servers fails with EADDRINUSE. The automatic ports test now picks ports below the ephemeral range, which outgoing connections on Windows could take, and stops on EADDRINUSE instead of waiting for the test timeout. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- packages/webpack-cli/src/webpack-cli.ts | 462 +++++++++--------- .../automatic-ports/automatic-ports.test.js | 29 +- 2 files changed, 261 insertions(+), 230 deletions(-) diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index e22c6b8e35a..3b538b309c7 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -232,6 +232,16 @@ declare interface WebpackCallback { (err: null | Error, result?: MultiStats): void; } +interface MultiCompilerPlugin { + apply(compiler: MultiCompiler): void; +} + +// Changes the loaded configuration before the compiler is created, +// returns plugins which should be applied to the multi compiler +type ConfigureCompiler = ( + options: Configuration | MultiConfiguration, +) => PotentialPromise; + type ProcessedArguments = Parameters<(typeof webpack)["cli"]["processArguments"]>[2]; type Schema = Parameters<(typeof webpack)["cli"]["getArguments"]>[0]; @@ -2229,74 +2239,13 @@ class WebpackCLI { env: { WEBPACK_SERVE: true, ...options.env }, }; - const compiler = await this.createCompiler(webpackCLIOptions); - - if (!compiler) { - return; - } - const DevServer: DevServerConstructor = devServer; - // TODO in the next major release, require `webpack-dev-server@6` and remove this check - // together with the `start()`/`stop()` workaround for dev servers without plugin support - const isDevServerPlugin = - typeof (DevServer.prototype as { apply?: unknown }).apply === "function"; - const isMultiCompiler = this.isMultipleCompiler(compiler); - let isDevServerPerCompiler = Boolean(options.devServerPerCompiler) && isMultiCompiler; - - if (isDevServerPerCompiler && !isDevServerPlugin) { - this.logger.warn( - "The '--dev-server-per-compiler' option requires webpack-dev-server with plugin support (v6 or later), all compilers will be served by every dev server.", - ); - - isDevServerPerCompiler = false; - } - - // TODO in the next major release, remove it, servers without plugin support only - const servers: InstanceType[] = []; - - if (!isDevServerPlugin && this.#needWatchStdin(compiler)) { - process.stdin.on("end", () => { - Promise.all(servers.map((server) => server.stop())).then(() => { - process.exit(0); - }); - }); - process.stdin.resume(); - } - - const compilers = isMultiCompiler ? compiler.compilers : [compiler as Compiler]; - const possibleCompilers = compilers.filter((compiler) => compiler.options.devServer); - const compilersForDevServer = - possibleCompilers.length > 0 ? possibleCompilers : [compilers[0]]; - const usedPorts: number[] = []; - const devServers: { - compiler: Compiler; - configuration: DevServerConfiguration; - }[] = []; - const validatePort = ({ port }: DevServerConfiguration): void => { - if (port && port !== "auto") { - const portNumber = Number(port); - - if (usedPorts.includes(portNumber)) { - throw new Error( - "Unique ports must be specified for each devServer option in your webpack configuration. Alternatively, run only 1 devServer config using the --config-name flag to specify your desired config.", - ); - } - - usedPorts.push(portNumber); - } - }; // @ts-expect-error different versions of the `Schema` type const devServerArgs = this.#getArguments(webpack, devServer.schema); - let appliedDevServers = 0; - - for (const compilerForDevServer of compilersForDevServer) { - if (compilerForDevServer.options.devServer === false) { - continue; - } - - const devServerConfiguration: DevServerConfiguration = - compilerForDevServer.options.devServer || {}; - + const getDevServerOptions = ( + devServerOptions: DevServerConfiguration | undefined, + ): DevServerConfiguration => { + const configuration: DevServerConfiguration = devServerOptions || {}; const args: Record = {}; const values: ProcessedArguments = {}; @@ -2315,189 +2264,216 @@ class WebpackCLI { } if (Object.keys(values).length > 0) { - this.#processArguments(webpack, args, devServerConfiguration, values); + this.#processArguments(webpack, args, configuration, values); } - if (isDevServerPlugin) { - validatePort(devServerConfiguration); - } + return configuration; + }; + const usedPorts: number[] = []; + const validatePort = (port: DevServerConfiguration["port"]): void => { + if (port && port !== "auto") { + const portNumber = Number(port); - devServers.push({ - compiler: compilerForDevServer, - configuration: devServerConfiguration, - }); - } + if (usedPorts.includes(portNumber)) { + throw new Error( + "Unique ports must be specified for each devServer option in your webpack configuration. Alternatively, run only 1 devServer config using the --config-name flag to specify your desired config.", + ); + } - for (const { compiler: compilerForDevServer, configuration } of devServers) { - if (!isDevServerPlugin) { - validatePort(configuration); + usedPorts.push(portNumber); } + }; - try { - if (isDevServerPlugin) { - let { port } = configuration; - - if ( - devServers.length > 1 && - !configuration.ipc && - (typeof port === "undefined" || port === "auto") - ) { - const { default: getPort, portNumbers } = await import("get-port"); - const basePort = Number.parseInt( - process.env.WEBPACK_DEV_SERVER_BASE_PORT ?? "8080", - 10, - ); - const host = configuration.host - ? await DevServer.getHostname(configuration.host) - : undefined; - - // Plugins select ports before any server starts listening. - port = await getPort({ - port: portNumbers(basePort, 65535), - host, - exclude: usedPorts, - }); - usedPorts.push(port); - } + // TODO in the next major release, require `webpack-dev-server@6` and remove the `else` branch + if (typeof (DevServer.prototype as { apply?: unknown }).apply === "function") { + // TODO in the next major release, use `DevServer` directly, the cast is only + // needed for `webpack-dev-server@5` typings, which lack the plugin constructor + const DevServerPlugin = DevServer as unknown as new (options: DevServerConfiguration) => { + apply(compiler: Compiler | MultiCompiler): void; + }; - // By default, every dev server serves all compilers, regardless of which defines - // `devServer`, the same as dev servers without plugin support do. - // The CLI owns signal handling and closes the compiler on shutdown. - // TODO in the next major release, use `DevServer` directly, the cast is only - // needed for `webpack-dev-server@5` typings, which lack the plugin constructor - const DevServerPlugin = DevServer as unknown as new ( - options: DevServerConfiguration, - ) => { apply(compiler: Compiler | MultiCompiler): void }; - - new DevServerPlugin({ ...configuration, port, setupExitSignals: false }).apply( - isDevServerPerCompiler ? compilerForDevServer : compiler, + const compiler = await this.createCompiler( + webpackCLIOptions, + await this.#createWebpackCallback(webpackCLIOptions), + async (configOptions) => { + const isMultiConfiguration = this.isMultipleConfiguration(configOptions); + const configurations = isMultiConfiguration ? configOptions : [configOptions]; + const configurationsWithDevServer = configurations.filter( + (configuration) => configuration.devServer, ); - } else { - // TODO in the next major release, remove it, servers without plugin support only - const server = new DevServer(configuration, compiler); - - await server.start(); + const configurationsForDevServer = ( + configurationsWithDevServer.length > 0 + ? configurationsWithDevServer + : [configurations[0]] + ).filter((configuration) => (configuration.devServer as unknown) !== false); + const isDevServerPerCompiler = + !isMultiConfiguration || Boolean(options.devServerPerCompiler); + + if (configurationsForDevServer.length === 0) { + this.logger.error("No dev server configurations to run"); + process.exit(2); + } - servers.push(server as unknown as InstanceType); - } + // The CLI drives compilation like the `watch` command + for (const configuration of configurations) { + configuration.watch = true; + } - appliedDevServers += 1; - } catch (error) { - if (this.isValidationError(error as Error)) { - this.logger.error((error as Error).message); - } else { - this.logger.error(error); - } + // Plugins which should be applied to the multi compiler + const multiCompilerPlugins: InstanceType[] = []; + const devServers = configurationsForDevServer.map((configuration) => { + const devServerOptions = getDevServerOptions( + configuration.devServer as DevServerConfiguration, + ); - process.exit(2); - } - } + validatePort(devServerOptions.port); + + return { configuration, devServerOptions }; + }); + // Compilers which already print stats of a dev server + const configurationsWithMiddlewareStats = new Set(); + + for (const { configuration, devServerOptions } of devServers) { + // Servers select ports before any of them listens, so they can't do it themselves + if ( + devServers.length > 1 && + !devServerOptions.ipc && + (typeof devServerOptions.port === "undefined" || devServerOptions.port === "auto") + ) { + const { default: getPort, portNumbers } = await import("get-port"); + + devServerOptions.port = await getPort({ + port: portNumbers( + Number.parseInt(process.env.WEBPACK_DEV_SERVER_BASE_PORT ?? "8080", 10), + 65535, + ), + host: devServerOptions.host + ? await DevServer.getHostname(devServerOptions.host) + : undefined, + exclude: usedPorts, + }); + usedPorts.push(devServerOptions.port); + } - if (appliedDevServers === 0) { - this.logger.error("No dev server configurations to run"); - process.exit(2); - } + // By default, every dev server serves all compilers, the same as dev servers + // without plugin support do. The CLI handles signals and closes the compiler. + const plugin = new DevServerPlugin({ + ...devServerOptions, + setupExitSignals: false, + }); + const servedConfigurations = isDevServerPerCompiler + ? [configuration] + : configurations; + const middlewareStats = devServerOptions.devMiddleware?.stats; + + // The middleware leaves printing stats to the CLI, the first dev server wins + for (const servedConfiguration of servedConfigurations) { + if ( + typeof middlewareStats === "undefined" || + configurationsWithMiddlewareStats.has(servedConfiguration) + ) { + continue; + } - // TODO in the next major release, remove it, servers without plugin support manage - // compilation and signals themselves - if (!isDevServerPlugin) { - return; - } + const { colors } = servedConfiguration.stats as StatsOptions; - // Closing the compiler stops the server through its shutdown hook. - this.#setupGracefulShutdown(compiler, true); + servedConfiguration.stats = + typeof middlewareStats === "boolean" + ? { preset: middlewareStats ? "normal" : "none" } + : typeof middlewareStats === "string" + ? { preset: middlewareStats } + : { ...middlewareStats }; + servedConfiguration.stats.colors ??= colors; + configurationsWithMiddlewareStats.add(servedConfiguration); + } - if (this.#needWatchStdin(compiler)) { - process.stdin.on("end", () => { - compiler.close(() => { - process.exit(); - }); - }); - process.stdin.resume(); - } + if (isDevServerPerCompiler) { + configuration.plugins = [...(configuration.plugins || []), plugin]; + } else { + multiCompilerPlugins.push(plugin); + } + } - const getStatsOptions = ( - compiler: Compiler, - configuration?: DevServerConfiguration, - ): StatsOptions => { - const middlewareStats = configuration?.devMiddleware?.stats; + return multiCompilerPlugins; + }, + ); - if (typeof middlewareStats === "undefined") { - return compiler.options.stats as StatsOptions; + if (!compiler) { + return; } - const statsOptions: StatsOptions = - typeof middlewareStats === "boolean" - ? { preset: middlewareStats ? "normal" : "none" } - : typeof middlewareStats === "string" - ? { preset: middlewareStats } - : { ...middlewareStats }; + // Closing the compiler stops dev servers, failures keep the exit code + this.#setupGracefulShutdown(compiler, true); - if (typeof statsOptions.colors === "undefined") { - statsOptions.colors = (compiler.options.stats as StatsOptions).colors; + if (this.#needWatchStdin(compiler)) { + process.stdin.on("end", () => { + compiler.close(() => { + process.exit(); + }); + }); + process.stdin.resume(); } + } else { + const compiler = await this.createCompiler(webpackCLIOptions); - return statsOptions; - }; - const printStats = ( - stats: Stats | MultiStats, - getConfiguration: (compiler: Compiler) => DevServerConfiguration | undefined, - ): void => { - const statsOptions = isMultiCompiler - ? { - children: compilers.map((compiler) => - getStatsOptions(compiler, getConfiguration(compiler)), - ), - } - : getStatsOptions(compiler as Compiler, getConfiguration(compiler as Compiler)); - const printedStats = stats.toString(statsOptions); + if (!compiler) { + return; + } - if (printedStats) { - this.logger.raw(printedStats); + if (options.devServerPerCompiler) { + this.logger.warn( + "The '--dev-server-per-compiler' option requires webpack-dev-server with plugin support (v6 or later), all compilers will be served by every dev server.", + ); } - }; - const watchCallback = (error: Error | null, stats?: Stats | MultiStats): void => { - if (error) { - if (this.isValidationError(error)) { - this.logger.error(error.message); - } else { - this.logger.error(error); - } + const servers: InstanceType[] = []; - process.exit(2); + if (this.#needWatchStdin(compiler)) { + process.stdin.on("end", () => { + Promise.all(servers.map((server) => server.stop())).then(() => { + process.exit(0); + }); + }); + process.stdin.resume(); } - if (!stats) { - return; - } + const compilers = this.isMultipleCompiler(compiler) ? compiler.compilers : [compiler]; + const possibleCompilers = compilers.filter((compiler) => compiler.options.devServer); + const compilersForDevServer = + possibleCompilers.length > 0 ? possibleCompilers : [compilers[0]]; - if (stats.hasErrors() || (options.failOnWarnings && stats.hasWarnings())) { - process.exitCode = 1; - } + for (const compilerForDevServer of compilersForDevServer) { + if (compilerForDevServer.options.devServer === false) { + continue; + } - if (isDevServerPerCompiler) { - // Each compiler is printed using the stats options of its own dev server - printStats( - stats, - (compiler) => - devServers.find((devServer) => devServer.compiler === compiler)?.configuration, + const devServerConfiguration = getDevServerOptions( + compilerForDevServer.options.devServer, ); - } else { - // Each middleware prints stats for the whole compiler - for (const { configuration } of devServers) { - printStats(stats, () => configuration); + + validatePort(devServerConfiguration.port); + + try { + const server = new DevServer(devServerConfiguration, compiler); + + await server.start(); + + servers.push(server as unknown as InstanceType); + } catch (error) { + if (this.isValidationError(error as Error)) { + this.logger.error((error as Error).message); + } else { + this.logger.error(error); + } + + process.exit(2); } } - }; - if (isMultiCompiler) { - compiler.watch( - compiler.compilers.map((compiler) => compiler.options.watchOptions || {}), - watchCallback, - ); - } else { - compiler.watch(compiler.options.watchOptions || {}, watchCallback); + if (servers.length === 0) { + this.logger.error("No dev server configurations to run"); + process.exit(2); + } } }, }, @@ -3838,6 +3814,7 @@ class WebpackCLI { async createCompiler( options: Options, callback?: WebpackCallback, + configure?: ConfigureCompiler, ): Promise { const { webpack } = options; @@ -3846,6 +3823,12 @@ class WebpackCLI { } const config = await this.loadConfig(options); + + const multiCompilerPlugins = (await configure?.(config.options)) || []; + const [major, minor] = webpack.version.split(".").map(Number); + // TODO in the next major release, require `webpack@^5.112.0` and remove it + const isMultiCompilerPluginsSupported = major > 5 || minor >= 112; + let compiler: Compiler | MultiCompiler; const webpackCallback = callback @@ -3862,9 +3845,31 @@ class WebpackCLI { try { // Branch on the configuration shape so each call matches one `webpack()` overload if (this.isMultipleConfiguration(config.options)) { - compiler = webpackCallback - ? webpack(config.options, webpackCallback)! - : webpack(config.options); + if (multiCompilerPlugins.length > 0 && !isMultiCompilerPluginsSupported) { + // TODO in the next major release, remove it, older versions of webpack don't apply + // `plugins` of multi configurations, so apply them before the compilation starts + for (const options of config.options) { + options.watch = false; + } + + compiler = webpack(config.options); + + for (const plugin of multiCompilerPlugins) { + plugin.apply(compiler); + } + + if (webpackCallback) { + compiler.watch( + config.options.map((options) => options.watchOptions || {}), + webpackCallback, + ); + } + } else { + config.options.plugins = [...(config.options.plugins || []), ...multiCompilerPlugins]; + compiler = webpackCallback + ? webpack(config.options, webpackCallback)! + : webpack(config.options); + } } else { compiler = webpackCallback ? webpack(config.options, webpackCallback)! @@ -3922,8 +3927,11 @@ class WebpackCLI { } } - async runWebpack(options: Options, isWatchCommand: boolean): Promise { - let compiler: Compiler | MultiCompiler; + #isMultiStats(stats: Stats | MultiStats): stats is MultiStats { + return "stats" in stats; + } + + async #createWebpackCallback(options: Options): Promise { let stringifyChunked: typeof stringifyChunkedType; let Readable: typeof ReadableType; @@ -3932,7 +3940,7 @@ class WebpackCLI { ({ Readable } = await import("node:stream")); } - const callback: WebpackCallback = (error, stats): void => { + return (error, stats): void => { if (error) { this.logger.error(error); process.exit(2); @@ -3942,15 +3950,15 @@ class WebpackCLI { process.exitCode = 1; } - if (!compiler || !stats) { + if (!stats) { return; } - const statsOptions = this.isMultipleCompiler(compiler) + const statsOptions = this.#isMultiStats(stats) ? { - children: compiler.compilers.map((compiler) => compiler.options.stats), + children: stats.stats.map((stats) => stats.compilation.compiler.options.stats), } - : compiler.options.stats; + : stats.compilation.compiler.options.stats; if (options.json) { const handleWriteError = (error: Error) => { @@ -3987,6 +3995,10 @@ class WebpackCLI { } } }; + } + + async runWebpack(options: Options, isWatchCommand: boolean): Promise { + const callback = await this.#createWebpackCallback(options); const env = isWatchCommand || options.watch @@ -4000,7 +4012,7 @@ class WebpackCLI { options.isWatchingLikeCommand = true; } - compiler = await this.createCompiler(options, callback); + const compiler = await this.createCompiler(options, callback); if (!compiler) { return; diff --git a/test/serve/automatic-ports/automatic-ports.test.js b/test/serve/automatic-ports/automatic-ports.test.js index 6da3dc3ee3d..fe5c2ead9c7 100644 --- a/test/serve/automatic-ports/automatic-ports.test.js +++ b/test/serve/automatic-ports/automatic-ports.test.js @@ -4,7 +4,16 @@ const net = require("node:net"); const [devServerVersion] = require("webpack-dev-server/package.json").version; const { runWatch } = require("../../utils/test-utils"); -const getGetPort = () => import("get-port"); +/** + * Ports below the ephemeral ranges (32768+ on Linux, 49152+ on Windows and macOS), so outgoing + * connections can't take the next ports, which the CLI picks for dev servers without a port + * @returns {Promise} free port + */ +const getFreePort = async () => { + const { default: getPort, portNumbers } = await import("get-port"); + + return getPort({ port: portNumbers(20000, 30000) }); +}; const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; @@ -14,7 +23,7 @@ describeDevServer6("automatic dev server ports", () => { beforeEach(async () => { occupied = net.createServer(); - basePort = await (await getGetPort()).default(); + basePort = await getFreePort(); await new Promise((resolve, reject) => { occupied.once("error", reject); occupied.listen(basePort, "127.0.0.1", resolve); @@ -30,7 +39,7 @@ describeDevServer6("automatic dev server ports", () => { test.each(["omitted", "auto", "explicit"])( "should assign distinct available ports with %s ports", async (mode) => { - const explicitPort = await (await getGetPort()).default(); + const explicitPort = await getFreePort(); const args = ["serve", "--watch-options-stdin"]; if (mode === "auto") { @@ -46,12 +55,22 @@ describeDevServer6("automatic dev server ports", () => { handler: (proc) => { let output = ""; let stopping = false; + const stop = () => { + stopping = true; + proc.stdin.end(); + }; + proc.stdout.on("data", (chunk) => { output += chunk.toString(); if (!stopping && [...output.matchAll(/Listening \d: \d+\n/g)].length === 2) { - stopping = true; - proc.stdin.end(); + stop(); + } + }); + // Fail fast when a server can't listen instead of waiting for the test timeout + proc.stderr.on("data", (chunk) => { + if (!stopping && chunk.toString().includes("EADDRINUSE")) { + stop(); } }); }, From f37a6bccf87894a712e56ccfdaebda259dc423b1 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:09:07 +0000 Subject: [PATCH 8/9] refactor: select dev server ports with the dev server API Plugin mode dev servers select ports before any of them listens, so the CLI picks distinct automatic ports itself. It now uses the dev server's own `getFreePort()` (starting each search after the previously picked port) instead of the `get-port` dependency, which is removed from `webpack-cli`. Also simplify the plugin setup: one loop creates the dev servers and one loop prepares the configurations (watch mode and middleware stats). Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- package-lock.json | 2 +- packages/webpack-cli/package.json | 1 - packages/webpack-cli/src/webpack-cli.ts | 120 ++++++++++++------------ 3 files changed, 60 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 576416e12bb..49a920cf6da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11815,6 +11815,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -21067,7 +21068,6 @@ "commander": "^14.0.3", "cross-spawn": "^7.0.6", "envinfo": "^7.21.0", - "get-port": "^7.2.0", "import-local": "^3.2.0", "interpret": "^3.1.1", "rechoir": "^1.0.0", diff --git a/packages/webpack-cli/package.json b/packages/webpack-cli/package.json index 9ec4122c904..21c7c36f92b 100644 --- a/packages/webpack-cli/package.json +++ b/packages/webpack-cli/package.json @@ -35,7 +35,6 @@ "commander": "^14.0.3", "cross-spawn": "^7.0.6", "envinfo": "^7.21.0", - "get-port": "^7.2.0", "import-local": "^3.2.0", "interpret": "^3.1.1", "rechoir": "^1.0.0", diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 3b538b309c7..dbfad250f3d 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -2296,42 +2296,38 @@ class WebpackCLI { webpackCLIOptions, await this.#createWebpackCallback(webpackCLIOptions), async (configOptions) => { - const isMultiConfiguration = this.isMultipleConfiguration(configOptions); - const configurations = isMultiConfiguration ? configOptions : [configOptions]; - const configurationsWithDevServer = configurations.filter( - (configuration) => configuration.devServer, - ); - const configurationsForDevServer = ( - configurationsWithDevServer.length > 0 - ? configurationsWithDevServer - : [configurations[0]] - ).filter((configuration) => (configuration.devServer as unknown) !== false); + const configurations = this.isMultipleConfiguration(configOptions) + ? configOptions + : [configOptions]; const isDevServerPerCompiler = - !isMultiConfiguration || Boolean(options.devServerPerCompiler); + configurations.length === 1 || Boolean(options.devServerPerCompiler); + // Configurations with `devServer`, otherwise the first one + const devServers = ( + configurations.some((configuration) => configuration.devServer) + ? configurations.filter((configuration) => configuration.devServer) + : [configurations[0]] + ) + .filter((configuration) => (configuration.devServer as unknown) !== false) + .map((configuration) => { + const devServerOptions = getDevServerOptions( + configuration.devServer as DevServerConfiguration, + ); + + // Validate all explicit ports before assigning automatic ones + validatePort(devServerOptions.port); - if (configurationsForDevServer.length === 0) { + return { configuration, devServerOptions }; + }); + + if (devServers.length === 0) { this.logger.error("No dev server configurations to run"); process.exit(2); } - // The CLI drives compilation like the `watch` command - for (const configuration of configurations) { - configuration.watch = true; - } - + const basePort = process.env.WEBPACK_DEV_SERVER_BASE_PORT; + let nextBasePort = Number.parseInt(basePort ?? "8080", 10); // Plugins which should be applied to the multi compiler const multiCompilerPlugins: InstanceType[] = []; - const devServers = configurationsForDevServer.map((configuration) => { - const devServerOptions = getDevServerOptions( - configuration.devServer as DevServerConfiguration, - ); - - validatePort(devServerOptions.port); - - return { configuration, devServerOptions }; - }); - // Compilers which already print stats of a dev server - const configurationsWithMiddlewareStats = new Set(); for (const { configuration, devServerOptions } of devServers) { // Servers select ports before any of them listens, so they can't do it themselves @@ -2340,18 +2336,15 @@ class WebpackCLI { !devServerOptions.ipc && (typeof devServerOptions.port === "undefined" || devServerOptions.port === "auto") ) { - const { default: getPort, portNumbers } = await import("get-port"); - - devServerOptions.port = await getPort({ - port: portNumbers( - Number.parseInt(process.env.WEBPACK_DEV_SERVER_BASE_PORT ?? "8080", 10), - 65535, - ), - host: devServerOptions.host - ? await DevServer.getHostname(devServerOptions.host) - : undefined, - exclude: usedPorts, - }); + const host = await DevServer.getHostname(devServerOptions.host as string); + + // The dev server searches for a free port from the base one + do { + process.env.WEBPACK_DEV_SERVER_BASE_PORT = String(nextBasePort); + devServerOptions.port = Number(await DevServer.getFreePort("auto", host)); + nextBasePort = devServerOptions.port + 1; + } while (usedPorts.includes(devServerOptions.port)); + usedPorts.push(devServerOptions.port); } @@ -2361,36 +2354,41 @@ class WebpackCLI { ...devServerOptions, setupExitSignals: false, }); - const servedConfigurations = isDevServerPerCompiler - ? [configuration] - : configurations; - const middlewareStats = devServerOptions.devMiddleware?.stats; + + if (isDevServerPerCompiler) { + configuration.plugins = [...(configuration.plugins || []), plugin]; + } else { + multiCompilerPlugins.push(plugin); + } + } + + if (typeof basePort === "undefined") { + delete process.env.WEBPACK_DEV_SERVER_BASE_PORT; + } else { + process.env.WEBPACK_DEV_SERVER_BASE_PORT = basePort; + } + + for (const configuration of configurations) { + // The CLI drives compilation like the `watch` command + configuration.watch = true; // The middleware leaves printing stats to the CLI, the first dev server wins - for (const servedConfiguration of servedConfigurations) { - if ( - typeof middlewareStats === "undefined" || - configurationsWithMiddlewareStats.has(servedConfiguration) - ) { - continue; - } + const middlewareStats = ( + isDevServerPerCompiler + ? devServers.find((devServer) => devServer.configuration === configuration) + : devServers[0] + )?.devServerOptions.devMiddleware?.stats; - const { colors } = servedConfiguration.stats as StatsOptions; + if (typeof middlewareStats !== "undefined") { + const { colors } = configuration.stats as StatsOptions; - servedConfiguration.stats = + configuration.stats = typeof middlewareStats === "boolean" ? { preset: middlewareStats ? "normal" : "none" } : typeof middlewareStats === "string" ? { preset: middlewareStats } : { ...middlewareStats }; - servedConfiguration.stats.colors ??= colors; - configurationsWithMiddlewareStats.add(servedConfiguration); - } - - if (isDevServerPerCompiler) { - configuration.plugins = [...(configuration.plugins || []), plugin]; - } else { - multiCompilerPlugins.push(plugin); + configuration.stats.colors ??= colors; } } From 595494723da0304b6ebce7209792554162753844 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:09:14 +0000 Subject: [PATCH 9/9] fix: don't detect browsers in `webpack info` on Windows On Windows, `envinfo` starts the browsers themselves to get their versions, which can hang, e.g. the `info` tests time out on Windows CI runners. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- packages/webpack-cli/src/webpack-cli.ts | 30 +++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index dbfad250f3d..808e1b6c522 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -1886,18 +1886,24 @@ class WebpackCLI { } else { const defaultInformation: EnvinfoConfig = { Binaries: ["Node", "Yarn", "npm", "pnpm"], - Browsers: [ - "Brave Browser", - "Chrome", - "Chrome Canary", - "Edge", - "Firefox", - "Firefox Developer Edition", - "Firefox Nightly", - "Internet Explorer", - "Safari", - "Safari Technology Preview", - ], + // On Windows, `envinfo` starts the browsers themselves to get their versions, + // which can hang (e.g. on CI), so they are skipped there + ...(process.platform === "win32" + ? {} + : { + Browsers: [ + "Brave Browser", + "Chrome", + "Chrome Canary", + "Edge", + "Firefox", + "Firefox Developer Edition", + "Firefox Nightly", + "Internet Explorer", + "Safari", + "Safari Technology Preview", + ], + }), // @ts-expect-error No in types Monorepos: ["Yarn Workspaces", "Lerna"], System: ["OS", "CPU", "Memory"],