From 927a9caf6e951d8cd954ad1b19ea6001962a853a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 14:04:49 -0500 Subject: [PATCH 1/5] 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 8a324a51054..5ff5f07f045 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: { @@ -3658,6 +3732,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; @@ -3755,32 +3858,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 149217883d919a2a792cf901893966986266ed06 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 14:15:04 -0500 Subject: [PATCH 2/5] 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 5ff5f07f045..0f1003dc5a4 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(); @@ -3732,14 +3735,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. @@ -3753,7 +3756,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 07eade81e7c6b957edd1e2e70cd9d7363183bb84 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 14:45:45 -0500 Subject: [PATCH 3/5] 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 2dbdfc9963a3bc49107e4d4b164fe07207021d74 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 14:59:56 -0500 Subject: [PATCH 4/5] 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 55e86a0c5a0..454d4b33cec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12544,7 +12544,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" @@ -22436,6 +22435,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": "^0.8.0", diff --git a/packages/webpack-cli/package.json b/packages/webpack-cli/package.json index 457588ec62a..8d6c3df7e66 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": "^0.8.0", diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 0f1003dc5a4..6c77a7c1db8 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 9d25338acf14fe9812c70a17881f0d5f852444ee Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 5 Sep 2026 15:08:04 -0500 Subject: [PATCH 5/5] 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 6c77a7c1db8..1ad7b026741 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; +};