From e939806fb623507c0828bea2471bd54e10b5ac41 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 16 Feb 2026 03:31:15 +0000 Subject: [PATCH 01/23] feat: plugin support --- lib/Server.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/Server.js b/lib/Server.js index 377ebbf36d..1f60cf17bc 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -326,6 +326,8 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i; * @property {typeof useFn} use */ +const pluginName = "webpack-dev-server"; + /** * @template {BasicApplication} [A=ExpressApplication] * @template {BasicServer} [S=HTTPServer] @@ -345,7 +347,7 @@ class Server { /** * @type {ReturnType} */ - this.logger = this.compiler.getInfrastructureLogger("webpack-dev-server"); + this.logger = this.compiler.getInfrastructureLogger(pluginName); this.options = options; /** * @type {FSWatcher[]} @@ -3542,6 +3544,23 @@ class Server { .then(() => callback(), callback) .catch(callback); } + + /** + * @param {Compiler} compiler compiler + * @returns {void} + */ + apply(compiler) { + const pluginName = this.constructor.name; + this.compiler = compiler; + + this.compiler.hooks.watchRun.tapPromise(pluginName, async () => { + await this.start(); + }); + + this.compiler.hooks.watchClose.tap(pluginName, async () => { + await this.stop(); + }); + } } export default Server; From 2a31a3581af017f8161899786f39fe0a50ff7f3d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 21 Feb 2026 18:12:12 +0000 Subject: [PATCH 02/23] fix: correct errors when a compiler is not passed to the constructor --- lib/Server.js | 26 +++++++++++++++++++------- test/e2e/api.test.js | 33 +++++++++++++++++++++++++++++++++ test/helpers/compile.js | 12 ++++++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 test/helpers/compile.js diff --git a/lib/Server.js b/lib/Server.js index 1f60cf17bc..6f72bcf8ff 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -343,11 +343,14 @@ class Server { baseDataPath: "options", }); - this.compiler = compiler; - /** - * @type {ReturnType} - */ - this.logger = this.compiler.getInfrastructureLogger(pluginName); + if (compiler) { + this.compiler = compiler; + + /** + * @type {ReturnType} + */ + this.logger = this.compiler.getInfrastructureLogger(pluginName); + } this.options = options; /** * @type {FSWatcher[]} @@ -1611,6 +1614,9 @@ class Server { * @returns {void} */ setupProgressPlugin() { + // In the case where there is no compiler and it’s not being used as a plugin. + if (this.compiler === undefined) return; + const { ProgressPlugin } = /** @type {MultiCompiler} */ (this.compiler).compilers @@ -1655,6 +1661,7 @@ class Server { * @returns {Promise} */ async initialize() { + if (this.compiler === undefined) return; this.setupHooks(); await this.setupApp(); @@ -1734,7 +1741,7 @@ class Server { needForceShutdown = true; this.stopCallback(() => { - if (typeof this.compiler.close === "function") { + if (typeof this.compiler?.close === "function") { this.compiler.close(() => { // eslint-disable-next-line n/no-process-exit process.exit(); @@ -1809,11 +1816,14 @@ class Server { * @returns {void} */ setupHooks() { + if (this.compiler === undefined) return; + this.compiler.hooks.invalid.tap("webpack-dev-server", () => { if (this.webSocketServer) { this.sendMessage(this.webSocketServer.clients, "invalid"); } }); + this.compiler.hooks.done.tap( "webpack-dev-server", /** @@ -1868,6 +1878,7 @@ class Server { * @returns {Promise} */ async setupMiddlewares() { + if (this.compiler === undefined) return; /** * @type {Middleware[]} */ @@ -2371,6 +2382,7 @@ class Server { // middleware for serving webpack bundle /** @type {import("webpack-dev-middleware").API} */ this.middleware = webpackDevMiddleware( + // @ts-expect-error this.compiler, this.options.devMiddleware, ); @@ -3550,8 +3562,8 @@ class Server { * @returns {void} */ apply(compiler) { - const pluginName = this.constructor.name; this.compiler = compiler; + this.logger = this.compiler.getInfrastructureLogger(pluginName); this.compiler.hooks.watchRun.tapPromise(pluginName, async () => { await this.start(); diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index b1a0ab6b4f..7d72b42b47 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -14,6 +14,39 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const port = portsMap.api; describe("API", () => { + it("should work with plugin API", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + + server.apply(compiler); + await compile(compiler); + + const { page, browser } = await runBrowser(); + + const pageErrors = []; + const consoleMessages = []; + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "networkidle0", + }); + + expect(consoleMessages.map((message) => message.text())).toMatchSnapshot( + "console messages", + ); + expect(pageErrors).toMatchSnapshot("page errors"); + + await browser.close(); + compiler.watching.close(); + }); + describe("WEBPACK_SERVE environment variable", () => { const OLD_ENV = process.env; let server; diff --git a/test/helpers/compile.js b/test/helpers/compile.js new file mode 100644 index 0000000000..2696e4b053 --- /dev/null +++ b/test/helpers/compile.js @@ -0,0 +1,12 @@ +"use strict"; + +module.exports = (compiler) => + new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + return reject(error); + } + + return resolve(stats); + }); + }); From 08237496b48f3bb23c371cf14af2af32c44e2d6f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 6 Mar 2026 20:34:57 +0000 Subject: [PATCH 03/23] feat: adapt webpack-dev-middleware.plugin --- lib/Server.js | 1 + types/lib/Server.d.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 6f72bcf8ff..6265746c8c 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -2385,6 +2385,7 @@ class Server { // @ts-expect-error this.compiler, this.options.devMiddleware, + true, ); } diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index 09d14b22cc..e645c9c82e 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -335,10 +335,6 @@ export type FunctionReturning = () => T; export type BasicApplication = { use: typeof useFn; }; -/** - * @typedef {object} BasicApplication - * @property {typeof useFn} use - */ /** * @template {BasicApplication} [A=ExpressApplication] * @template {BasicServer} [S=HTTPServer] @@ -1503,7 +1499,10 @@ declare class Server< * @param {Compiler | MultiCompiler} compiler compiler */ constructor(options: Configuration, compiler: Compiler | MultiCompiler); - compiler: import("webpack").Compiler | import("webpack").MultiCompiler; + compiler: + | import("webpack").Compiler + | import("webpack").MultiCompiler + | undefined; /** * @type {ReturnType} */ @@ -1753,6 +1752,11 @@ declare class Server< * @param {((err?: Error) => void)=} callback callback */ stopCallback(callback?: ((err?: Error) => void) | undefined): void; + /** + * @param {Compiler} compiler compiler + * @returns {void} + */ + apply(compiler: Compiler): void; #private; } /** From 3a74303b6e7ba8b6ad3399835d675150a9d2282b Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 6 Mar 2026 22:03:14 -0500 Subject: [PATCH 04/23] feat: enhance plugin API support and update tests for new compile behavior --- .../__snapshots__/api.test.js.snap.webpack5 | 12 ++++++ test/e2e/api.test.js | 15 ++++---- test/helpers/compile.js | 38 +++++++++++++++++-- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/test/e2e/__snapshots__/api.test.js.snap.webpack5 b/test/e2e/__snapshots__/api.test.js.snap.webpack5 index 9a55574007..4e47db2ed7 100644 --- a/test/e2e/__snapshots__/api.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/api.test.js.snap.webpack5 @@ -213,3 +213,15 @@ exports[`API > latest async API > should work with callback API 1`] = ` exports[`API > latest async API > should work with callback API 2`] = ` [] `; + +exports[`API > should work with plugin API 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", +] +`; + +exports[`API > should work with plugin API 2`] = ` +[] +`; diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index 7d72b42b47..69f2980253 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -14,12 +14,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const port = portsMap.api; describe("API", () => { - it("should work with plugin API", async () => { + it("should work with plugin API", async (t) => { const compiler = webpack(config); const server = new Server({ port }); server.apply(compiler); - await compile(compiler); + + // Use compile helper which waits for the server to be ready + const { watching } = await compile(compiler, port); const { page, browser } = await runBrowser(); @@ -38,13 +40,12 @@ describe("API", () => { waitUntil: "networkidle0", }); - expect(consoleMessages.map((message) => message.text())).toMatchSnapshot( - "console messages", - ); - expect(pageErrors).toMatchSnapshot("page errors"); + t.assert.snapshot(consoleMessages.map((message) => message.text())); + + t.assert.snapshot(pageErrors); await browser.close(); - compiler.watching.close(); + watching.close(); }); describe("WEBPACK_SERVE environment variable", () => { diff --git a/test/helpers/compile.js b/test/helpers/compile.js index 2696e4b053..36f1c480c9 100644 --- a/test/helpers/compile.js +++ b/test/helpers/compile.js @@ -1,12 +1,44 @@ "use strict"; -module.exports = (compiler) => +// Helper function to check if server is ready using fetch +const waitForServer = async (port, timeout = 10000) => { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + await fetch(`http://127.0.0.1:${port}/`); + return; // Server is ready + } catch { + // Server not ready yet, wait and retry + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + } + + throw new Error(`Server on port ${port} not ready after ${timeout}ms`); +}; + +module.exports = (compiler, port = null) => new Promise((resolve, reject) => { - compiler.run((error, stats) => { + const watching = compiler.watch({}, async (error, stats) => { if (error) { + watching.close(); return reject(error); } - return resolve(stats); + // If a port is provided, wait for the server to be ready + if (port) { + try { + await waitForServer(port); + } catch (err) { + watching.close(); + return reject(err); + } + } + + // Return both stats and watching for caller to manage + resolve({ stats, watching }); }); }); From 645e20999e131ef71309c12d8001db1049148f65 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 30 Mar 2026 15:00:58 -0500 Subject: [PATCH 05/23] feat: add isPlugin flag to Server class for plugin identification --- lib/Server.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Server.js b/lib/Server.js index 6265746c8c..fec813af25 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -377,6 +377,11 @@ class Server { */ this.currentHash = undefined; + /** + * @private + * @type {boolean} + */ + this.isPlugin = false; } static get schema() { @@ -2385,7 +2390,7 @@ class Server { // @ts-expect-error this.compiler, this.options.devMiddleware, - true, + this.isPlugin, ); } @@ -3564,6 +3569,7 @@ class Server { */ apply(compiler) { this.compiler = compiler; + this.isPlugin = true; this.logger = this.compiler.getInfrastructureLogger(pluginName); this.compiler.hooks.watchRun.tapPromise(pluginName, async () => { From 9835ceef845669b007e14919f359d9139da36917 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 30 Mar 2026 15:37:14 -0500 Subject: [PATCH 06/23] feat: prevent multiple server starts on recompilation and ensure clean shutdown --- lib/Server.js | 10 +++++++-- test/e2e/api.test.js | 53 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index fec813af25..b2b5781cf4 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3572,11 +3572,17 @@ class Server { this.isPlugin = true; this.logger = this.compiler.getInfrastructureLogger(pluginName); + let started = false; + this.compiler.hooks.watchRun.tapPromise(pluginName, async () => { - await this.start(); + if (!started) { + started = true; + await this.start(); + } }); - this.compiler.hooks.watchClose.tap(pluginName, async () => { + this.compiler.hooks.shutdown.tapPromise(pluginName, async () => { + started = false; await this.stop(); }); } diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index 69f2980253..6b318b8abf 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -20,8 +20,7 @@ describe("API", () => { server.apply(compiler); - // Use compile helper which waits for the server to be ready - const { watching } = await compile(compiler, port); + await compile(compiler, port); const { page, browser } = await runBrowser(); @@ -45,7 +44,55 @@ describe("API", () => { t.assert.snapshot(pageErrors); await browser.close(); - watching.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should not start the server multiple times on recompilation", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + const startSpy = jest.spyOn(server, "start"); + + server.apply(compiler); + + const { watching } = await compile(compiler, port); + + // Trigger a recompilation by invalidating + await new Promise((resolve) => { + watching.invalidate(() => { + resolve(); + }); + }); + + // Wait for the recompilation to finish + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + + expect(startSpy).toHaveBeenCalledTimes(1); + + startSpy.mockRestore(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should stop the server cleanly via compiler.close()", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + const stopSpy = jest.spyOn(server, "stop"); + + server.apply(compiler); + + await compile(compiler, port); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + + expect(stopSpy).toHaveBeenCalledTimes(1); + stopSpy.mockRestore(); }); describe("WEBPACK_SERVE environment variable", () => { From f52bb79dfff8c7e9bab4bc38545e98514e3b02c1 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 30 Mar 2026 15:39:46 -0500 Subject: [PATCH 07/23] fixup! --- types/lib/Server.d.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index e645c9c82e..71d83c0eaa 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -1531,6 +1531,11 @@ declare class Server< * @type {string | undefined} */ private currentHash; + /** + * @private + * @type {boolean} + */ + private isPlugin; /** * @private * @param {Compiler} compiler compiler From b597f610fba433e48017dfaa76df53dd6067682e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 30 Mar 2026 16:03:20 -0500 Subject: [PATCH 08/23] chore: more tests --- .../logging.test.js.snap.webpack5 | 50 ++++++++ test/e2e/logging.test.js | 120 ++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 index 2ed213cbd4..0ca274a740 100644 --- a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 @@ -1,3 +1,53 @@ +exports[`logging > plugin mode > should work and do not log messages about hot and live reloading is enabled 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement disabled, Live Reloading disabled, Progress disabled, Overlay enabled.", + "Hey.", +] +`; + +exports[`logging > plugin mode > should work and log errors by default 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", + "[webpack-dev-server] Errors while compiling. Reload prevented.", + "[webpack-dev-server] ERROR +Error from compilation", +] +`; + +exports[`logging > plugin mode > should work and log message about live reloading is enabled 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement disabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "Hey.", +] +`; + +exports[`logging > plugin mode > should work and log messages about hot and live reloading is enabled 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", +] +`; + +exports[`logging > plugin mode > should work and log warnings by default 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", + "[webpack-dev-server] Warnings while compiling.", + "[webpack-dev-server] WARNING +Warning from compilation", +] +`; + +exports[`logging > plugin mode > should work when the "client.logging" is "none" 1`] = ` +[ + "Hey.", +] +`; + exports[`logging > should work and do not log messages about hot and live reloading is enabled (ws) 1`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement disabled, Live Reloading disabled, Progress disabled, Overlay enabled.", diff --git a/test/e2e/logging.test.js b/test/e2e/logging.test.js index 33457fb8ab..06ca9f9a1e 100644 --- a/test/e2e/logging.test.js +++ b/test/e2e/logging.test.js @@ -245,4 +245,124 @@ describe("logging", () => { }); } } + + describe("plugin mode", () => { + const pluginCases = [ + { + title: + "should work and log messages about hot and live reloading is enabled", + devServerOptions: { + hot: true, + }, + }, + { + title: "should work and log message about live reloading is enabled", + devServerOptions: { + hot: false, + }, + }, + { + title: + "should work and do not log messages about hot and live reloading is enabled", + devServerOptions: { + liveReload: false, + hot: false, + }, + }, + { + title: "should work and log warnings by default", + webpackOptions: { + plugins: [ + { + apply(compiler) { + compiler.hooks.thisCompilation.tap( + "warnings-webpack-plugin", + (compilation) => { + compilation.warnings.push( + new Error("Warning from compilation"), + ); + }, + ); + }, + }, + new HTMLGeneratorPlugin(), + ], + }, + }, + { + title: "should work and log errors by default", + webpackOptions: { + plugins: [ + { + apply(compiler) { + compiler.hooks.thisCompilation.tap( + "warnings-webpack-plugin", + (compilation) => { + compilation.errors.push( + new Error("Error from compilation"), + ); + }, + ); + }, + }, + new HTMLGeneratorPlugin(), + ], + }, + }, + { + title: 'should work when the "client.logging" is "none"', + devServerOptions: { + client: { + logging: "none", + }, + }, + }, + ]; + + for (const testCase of pluginCases) { + it(`${testCase.title}`, async (t) => { + const compiler = webpack({ ...config, ...testCase.webpackOptions }); + const devServerOptions = { + port, + ...testCase.devServerOptions, + }; + const server = new Server(devServerOptions); + + server.apply(compiler); + + await compile(compiler, port); + + const { page, browser } = await runBrowser(); + + try { + const consoleMessages = []; + + page.on("console", (message) => { + consoleMessages.push(message); + }); + + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "networkidle0", + }); + + t.assert.snapshot( + consoleMessages.map((message) => + message + .text() + .replaceAll("\\", "/") + .replaceAll( + new RegExp(process.cwd().replaceAll("\\", "/"), "g"), + "", + ), + ), + ); + } finally { + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + }); + } + }); }); From f77edd9fe7d304112a9a74f39dae555330e95e6e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Apr 2026 14:03:04 -0500 Subject: [PATCH 09/23] feat: enhance server setup process --- lib/Server.js | 39 +++++++-- .../__snapshots__/api.test.js.snap.webpack5 | 24 ++++++ test/e2e/api.test.js | 81 +++++++++++++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index b2b5781cf4..257de3ef35 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3359,6 +3359,15 @@ class Server { * @returns {Promise} */ async start() { + await this.setup(); + await this.listen(); + } + + /** + * @private + * @returns {Promise} + */ + async setup() { await this.normalizeOptions(); if (this.options.ipc) { @@ -3410,7 +3419,13 @@ class Server { } await this.initialize(); + } + /** + * @private + * @returns {Promise} + */ + async listen() { const listenOptions = this.options.ipc ? { path: this.options.ipc } : { host: this.options.host, port: this.options.port }; @@ -3572,17 +3587,29 @@ class Server { this.isPlugin = true; this.logger = this.compiler.getInfrastructureLogger(pluginName); - let started = false; + /** @type {Promise | undefined} */ + let setupPromise; + let listening = false; - this.compiler.hooks.watchRun.tapPromise(pluginName, async () => { - if (!started) { - started = true; - await this.start(); + const ensureSetup = () => { + if (!setupPromise) { + setupPromise = this.setup(); } + return setupPromise; + }; + + this.compiler.hooks.beforeCompile.tapPromise(pluginName, ensureSetup); + + this.compiler.hooks.done.tapPromise(pluginName, async () => { + if (listening) return; + listening = true; + await ensureSetup(); + await this.listen(); }); this.compiler.hooks.shutdown.tapPromise(pluginName, async () => { - started = false; + setupPromise = undefined; + listening = false; await this.stop(); }); } diff --git a/test/e2e/__snapshots__/api.test.js.snap.webpack5 b/test/e2e/__snapshots__/api.test.js.snap.webpack5 index 4e47db2ed7..090427a0d2 100644 --- a/test/e2e/__snapshots__/api.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/api.test.js.snap.webpack5 @@ -214,6 +214,30 @@ exports[`API > latest async API > should work with callback API 2`] = ` [] `; +exports[`API > plugin in webpack config > should work when added to webpack config plugins array 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", +] +`; + +exports[`API > plugin in webpack config > should work when added to webpack config plugins array 2`] = ` +[] +`; + +exports[`API > plugin in webpack config > should work with output.clean: true 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", +] +`; + +exports[`API > plugin in webpack config > should work with output.clean: true 2`] = ` +[] +`; + exports[`API > should work with plugin API 1`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index 6b318b8abf..c21aab8e46 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -95,6 +95,87 @@ describe("API", () => { stopSpy.mockRestore(); }); + describe("plugin in webpack config", () => { + it("should work when added to webpack config plugins array", async (t) => { + const server = new Server({ port }); + const compiler = webpack({ + ...config, + plugins: [...config.plugins, server], + }); + + await compile(compiler, port); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + const consoleMessages = []; + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "networkidle0", + }); + + t.assert.snapshot(consoleMessages.map((message) => message.text())); + t.assert.snapshot(pageErrors); + } finally { + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + }); + + it("should work with output.clean: true", async (t) => { + const server = new Server({ port }); + const compiler = webpack({ + ...config, + output: { + ...config.output, + clean: true, + }, + plugins: [...config.plugins, server], + }); + + await compile(compiler, port); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + const consoleMessages = []; + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + const response = await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "networkidle0", + }); + + expect(response.status()).toBe(200); + t.assert.snapshot(consoleMessages.map((message) => message.text())); + t.assert.snapshot(pageErrors); + } finally { + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + }); + }); + describe("WEBPACK_SERVE environment variable", () => { const OLD_ENV = process.env; let server; From a1d3ef384f21d49d9d30b3f992530e64e758df81 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Apr 2026 14:15:52 -0500 Subject: [PATCH 10/23] refactor: remove unnecessary compiler checks in setup methods --- lib/Server.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 257de3ef35..191e2eed3e 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1619,9 +1619,6 @@ class Server { * @returns {void} */ setupProgressPlugin() { - // In the case where there is no compiler and it’s not being used as a plugin. - if (this.compiler === undefined) return; - const { ProgressPlugin } = /** @type {MultiCompiler} */ (this.compiler).compilers @@ -1658,7 +1655,7 @@ class Server { this.server.emit("progress-update", { percent, msg, pluginName }); } }, - ).apply(this.compiler); + ).apply(/** @type {Compiler | MultiCompiler} */ (this.compiler)); } /** @@ -1666,7 +1663,6 @@ class Server { * @returns {Promise} */ async initialize() { - if (this.compiler === undefined) return; this.setupHooks(); await this.setupApp(); @@ -1821,15 +1817,15 @@ class Server { * @returns {void} */ setupHooks() { - if (this.compiler === undefined) return; + const compiler = /** @type {Compiler | MultiCompiler} */ (this.compiler); - this.compiler.hooks.invalid.tap("webpack-dev-server", () => { + compiler.hooks.invalid.tap("webpack-dev-server", () => { if (this.webSocketServer) { this.sendMessage(this.webSocketServer.clients, "invalid"); } }); - this.compiler.hooks.done.tap( + compiler.hooks.done.tap( "webpack-dev-server", /** * @param {Stats | MultiStats} stats stats From 00af8c173b90372e4b21224f59794b7fdea0644e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Apr 2026 14:55:15 -0500 Subject: [PATCH 11/23] test: ensure server setup and listen methods are called once on recompilation --- test/e2e/api.test.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index c21aab8e46..017584ce21 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -52,7 +52,8 @@ describe("API", () => { it("should not start the server multiple times on recompilation", async () => { const compiler = webpack(config); const server = new Server({ port }); - const startSpy = jest.spyOn(server, "start"); + const setupSpy = jest.spyOn(server, "setup"); + const listenSpy = jest.spyOn(server, "listen"); server.apply(compiler); @@ -70,9 +71,11 @@ describe("API", () => { setTimeout(resolve, 2000); }); - expect(startSpy).toHaveBeenCalledTimes(1); + expect(setupSpy).toHaveBeenCalledTimes(1); + expect(listenSpy).toHaveBeenCalledTimes(1); - startSpy.mockRestore(); + setupSpy.mockRestore(); + listenSpy.mockRestore(); await new Promise((resolve) => { compiler.close(resolve); }); From 97df084fcb68b25931700f2ad805ee97af754531 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 13:21:18 -0500 Subject: [PATCH 12/23] test: add API plugin tests and snapshots for webpack config integration --- .../api-plugin.test.js.snap.webpack5 | 31 +++ test/e2e/api-plugin.test.js | 180 ++++++++++++++++++ test/ports-map.js | 1 + 3 files changed, 212 insertions(+) create mode 100644 test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 create mode 100644 test/e2e/api-plugin.test.js diff --git a/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 b/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 new file mode 100644 index 0000000000..59483b0daf --- /dev/null +++ b/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`API (plugin) plugin in webpack config should work when added to webpack config plugins array: console messages 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", +] +`; + +exports[`API (plugin) plugin in webpack config should work when added to webpack config plugins array: page errors 1`] = `[]`; + +exports[`API (plugin) plugin in webpack config should work with output.clean: true: console messages 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", +] +`; + +exports[`API (plugin) plugin in webpack config should work with output.clean: true: page errors 1`] = `[]`; + +exports[`API (plugin) should work with plugin API: console messages 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "Hey.", +] +`; + +exports[`API (plugin) should work with plugin API: page errors 1`] = `[]`; diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js new file mode 100644 index 0000000000..049d8e8e69 --- /dev/null +++ b/test/e2e/api-plugin.test.js @@ -0,0 +1,180 @@ +"use strict"; + +const webpack = require("webpack"); +const Server = require("../../lib/Server"); +const config = require("../fixtures/client-config/webpack.config"); +const compile = require("../helpers/compile"); +const runBrowser = require("../helpers/run-browser"); +const port = require("../ports-map")["api-plugin"]; + +describe("API (plugin)", () => { + it("should work with plugin API", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + + server.apply(compiler); + + await compile(compiler, port); + + const { page, browser } = await runBrowser(); + + const pageErrors = []; + const consoleMessages = []; + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "networkidle0", + }); + + expect(consoleMessages.map((message) => message.text())).toMatchSnapshot( + "console messages", + ); + expect(pageErrors).toMatchSnapshot("page errors"); + + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should not start the server multiple times on recompilation", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + const setupSpy = jest.spyOn(server, "setup"); + const listenSpy = jest.spyOn(server, "listen"); + + server.apply(compiler); + + const { watching } = await compile(compiler, port); + + // Trigger a recompilation by invalidating + await new Promise((resolve) => { + watching.invalidate(() => { + resolve(); + }); + }); + + // Wait for the recompilation to finish + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + + expect(setupSpy).toHaveBeenCalledTimes(1); + expect(listenSpy).toHaveBeenCalledTimes(1); + + setupSpy.mockRestore(); + listenSpy.mockRestore(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should stop the server cleanly via compiler.close()", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + const stopSpy = jest.spyOn(server, "stop"); + + server.apply(compiler); + + await compile(compiler, port); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + + expect(stopSpy).toHaveBeenCalledTimes(1); + stopSpy.mockRestore(); + }); + + describe("plugin in webpack config", () => { + it("should work when added to webpack config plugins array", async () => { + const server = new Server({ port }); + const compiler = webpack({ + ...config, + plugins: [...config.plugins, server], + }); + + await compile(compiler, port); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + const consoleMessages = []; + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "networkidle0", + }); + + expect( + consoleMessages.map((message) => message.text()), + ).toMatchSnapshot("console messages"); + expect(pageErrors).toMatchSnapshot("page errors"); + } finally { + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + }); + + it("should work with output.clean: true", async () => { + const server = new Server({ port }); + const compiler = webpack({ + ...config, + output: { + ...config.output, + clean: true, + }, + plugins: [...config.plugins, server], + }); + + await compile(compiler, port); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + const consoleMessages = []; + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + const response = await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "networkidle0", + }); + + expect(response.status()).toBe(200); + expect( + consoleMessages.map((message) => message.text()), + ).toMatchSnapshot("console messages"); + expect(pageErrors).toMatchSnapshot("page errors"); + } finally { + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + }); + }); +}); diff --git a/test/ports-map.js b/test/ports-map.js index e980495bc9..d417b406b5 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -78,6 +78,7 @@ const listOfTests = { "options-request-response": 2, app: 1, "cross-origin-request": 2, + "api-plugin": 1, }; let startPort = 8089; From 95d10c07d4b27adf0edd8d2af11e3858aecfe68d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 13:43:30 -0500 Subject: [PATCH 13/23] feat: enhance MultiCompiler support in server setup and add related tests --- lib/Server.js | 37 +++++++-- .../api-plugin.test.js.snap.webpack5 | 10 +++ test/e2e/api-plugin.test.js | 82 +++++++++++++++++++ 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 191e2eed3e..b960ddc438 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3575,7 +3575,7 @@ class Server { } /** - * @param {Compiler} compiler compiler + * @param {Compiler | MultiCompiler} compiler compiler * @returns {void} */ apply(compiler) { @@ -3586,7 +3586,11 @@ class Server { /** @type {Promise | undefined} */ let setupPromise; let listening = false; + let stopped = false; + /** + * @returns {Promise} promise + */ const ensureSetup = () => { if (!setupPromise) { setupPromise = this.setup(); @@ -3594,20 +3598,41 @@ class Server { return setupPromise; }; - this.compiler.hooks.beforeCompile.tapPromise(pluginName, ensureSetup); + const childCompilers = /** @type {MultiCompiler} */ (compiler) + .compilers || [compiler]; + const seenFirstDone = new WeakSet(); + let firstDoneCount = 0; - this.compiler.hooks.done.tapPromise(pluginName, async () => { + /** + * @param {Compiler} childCompiler child compiler + * @returns {Promise} promise + */ + const onChildDone = async (childCompiler) => { if (listening) return; + if (seenFirstDone.has(childCompiler)) return; + seenFirstDone.add(childCompiler); + firstDoneCount++; + if (firstDoneCount < childCompilers.length) return; listening = true; await ensureSetup(); await this.listen(); - }); + }; - this.compiler.hooks.shutdown.tapPromise(pluginName, async () => { + const onChildShutdown = async () => { + if (stopped) return; + stopped = true; setupPromise = undefined; listening = false; await this.stop(); - }); + }; + + for (const childCompiler of childCompilers) { + childCompiler.hooks.beforeCompile.tapPromise(pluginName, ensureSetup); + childCompiler.hooks.done.tapPromise(pluginName, () => + onChildDone(childCompiler), + ); + childCompiler.hooks.shutdown.tapPromise(pluginName, onChildShutdown); + } } } diff --git a/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 b/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 index 59483b0daf..bbf2712157 100644 --- a/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 @@ -1,5 +1,15 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`API (plugin) MultiCompiler should work with plugin API: console messages 1`] = ` +[ + "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", + "[HMR] Waiting for update signal from WDS...", + "one", +] +`; + +exports[`API (plugin) MultiCompiler should work with plugin API: page errors 1`] = `[]`; + exports[`API (plugin) plugin in webpack config should work when added to webpack config plugins array: console messages 1`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 049d8e8e69..b9f19679b4 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -3,6 +3,7 @@ const webpack = require("webpack"); const Server = require("../../lib/Server"); const config = require("../fixtures/client-config/webpack.config"); +const multiCompilerConfig = require("../fixtures/multi-compiler-two-configurations/webpack.config"); const compile = require("../helpers/compile"); const runBrowser = require("../helpers/run-browser"); const port = require("../ports-map")["api-plugin"]; @@ -177,4 +178,85 @@ describe("API (plugin)", () => { } }); }); + + describe("MultiCompiler", () => { + it("should work with plugin API", async () => { + const compiler = webpack(multiCompilerConfig); + const server = new Server({ port }); + + server.apply(compiler); + + await compile(compiler, port); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + const consoleMessages = []; + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + const response = await page.goto( + `http://127.0.0.1:${port}/one-main.html`, + { + waitUntil: "networkidle0", + }, + ); + + expect(response.status()).toBe(200); + expect( + consoleMessages.map((message) => message.text()), + ).toMatchSnapshot("console messages"); + expect(pageErrors).toMatchSnapshot("page errors"); + } finally { + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + }); + + it("should call setup and listen once across all child compilers", async () => { + const compiler = webpack(multiCompilerConfig); + const server = new Server({ port }); + const setupSpy = jest.spyOn(server, "setup"); + const listenSpy = jest.spyOn(server, "listen"); + + server.apply(compiler); + + await compile(compiler, port); + + expect(setupSpy).toHaveBeenCalledTimes(1); + expect(listenSpy).toHaveBeenCalledTimes(1); + + setupSpy.mockRestore(); + listenSpy.mockRestore(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should stop the server only once when all child compilers shut down", async () => { + const compiler = webpack(multiCompilerConfig); + const server = new Server({ port }); + const stopSpy = jest.spyOn(server, "stop"); + + server.apply(compiler); + + await compile(compiler, port); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + + expect(stopSpy).toHaveBeenCalledTimes(1); + stopSpy.mockRestore(); + }); + }); }); From 01dd36f0e3e1cd3c8785bf94d2f5d7fa5bb2f5e5 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 14:33:01 -0500 Subject: [PATCH 14/23] feat: add support for multiple independent plugin servers and enhance port mapping --- lib/Server.js | 91 +++++++++++++++++++++++++++++++++---- test/e2e/api-plugin.test.js | 73 +++++++++++++++++++++++++++++ test/ports-map.js | 1 + 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index b960ddc438..5fe2e53706 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -328,6 +328,20 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i; const pluginName = "webpack-dev-server"; +/** + * Tracks compilers that have an active standalone `Server` attached + * (`new Server(options, compiler).start()`). When a compiler is in this set, + * any `Server` plugin attached to it (or to a `MultiCompiler` that contains + * it) stays passive — otherwise we'd try to bind the same port twice. + * Particularly relevant for `webpack serve`, which creates its own standalone + * server even when the user already added a `Server` instance to `plugins[]`. + * + * Uses a `WeakSet` so a compiler that is no longer referenced anywhere can be + * garbage collected normally, without this module holding it alive. + * @type {WeakSet} + */ +const activeStandaloneCompilers = new WeakSet(); + /** * @template {BasicApplication} [A=ExpressApplication] * @template {BasicServer} [S=HTTPServer] @@ -1359,7 +1373,12 @@ class Server { } if (typeof options.setupExitSignals === "undefined") { - options.setupExitSignals = true; + // In plugin mode, the host (e.g. `webpack-cli`) usually owns process + // signal handling and calls `compiler.close()` on shutdown, which fires + // our `shutdown` hook. Adding our own SIGINT/SIGTERM listeners on top of + // that would race with the host's handler and call `compiler.close()` + // twice. + options.setupExitSignals = !this.isPlugin; } if (typeof options.static === "undefined") { @@ -3355,8 +3374,36 @@ class Server { * @returns {Promise} */ async start() { - await this.setup(); - await this.listen(); + this.#trackStandalone(true); + try { + await this.setup(); + await this.listen(); + } catch (error) { + this.#trackStandalone(false); + throw error; + } + } + + /** + * @param {boolean} active whether to mark or unmark the compiler(s) as + * having an active standalone server + * @returns {void} + */ + #trackStandalone(active) { + if (!this.compiler) return; + const compilers = /** @type {MultiCompiler} */ (this.compiler) + .compilers || [this.compiler]; + if (active) { + activeStandaloneCompilers.add(this.compiler); + for (const child of compilers) { + activeStandaloneCompilers.add(child); + } + } else { + activeStandaloneCompilers.delete(this.compiler); + for (const child of compilers) { + activeStandaloneCompilers.delete(child); + } + } } /** @@ -3473,6 +3520,8 @@ class Server { * @returns {Promise} */ async stop() { + this.#trackStandalone(false); + if (this.bonjour) { await /** @type {Promise} */ ( new Promise((resolve) => { @@ -3588,27 +3637,51 @@ class Server { let listening = false; let stopped = false; + const childCompilers = /** @type {MultiCompiler} */ (compiler) + .compilers || [compiler]; + const seenFirstDone = new WeakSet(); + let firstDoneCount = 0; + + // Returns true when a standalone `Server` is already attached to our + // compiler (or any of its children). This matters for `webpack serve`: + // it creates its own standalone server even if the user added a `Server` + // instance to `plugins[]`. In that case the plugin must stay passive — + // otherwise we'd try to bind the same port twice. Independent + // server/compiler pairs in the same process are unaffected because they + // don't share any compiler instance. + const isStandaloneRunning = () => { + if (activeStandaloneCompilers.has(compiler)) return true; + for (const child of childCompilers) { + if (activeStandaloneCompilers.has(child)) return true; + } + return false; + }; + + // A one-shot `compiler.run()` (plain `webpack` build) is detected when no + // child compiler is in watch mode. In that case we skip both `setup()` and + // `listen()` so the build can finish and the process can exit normally — + // the user is not in control of the plugin lifecycle here, so we stay + // silent rather than logging a warning. + const isBuildMode = () => + childCompilers.every((child) => !child.watching && !child.options.watch); + /** * @returns {Promise} promise */ const ensureSetup = () => { + if (isStandaloneRunning() || isBuildMode()) return Promise.resolve(); if (!setupPromise) { setupPromise = this.setup(); } return setupPromise; }; - const childCompilers = /** @type {MultiCompiler} */ (compiler) - .compilers || [compiler]; - const seenFirstDone = new WeakSet(); - let firstDoneCount = 0; - /** * @param {Compiler} childCompiler child compiler * @returns {Promise} promise */ const onChildDone = async (childCompiler) => { - if (listening) return; + if (listening || isStandaloneRunning() || isBuildMode()) return; if (seenFirstDone.has(childCompiler)) return; seenFirstDone.add(childCompiler); firstDoneCount++; diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index b9f19679b4..ccdd90cfd1 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -7,6 +7,7 @@ const multiCompilerConfig = require("../fixtures/multi-compiler-two-configuratio const compile = require("../helpers/compile"); const runBrowser = require("../helpers/run-browser"); const port = require("../ports-map")["api-plugin"]; +const [portA, portB] = require("../ports-map")["api-plugin-multi"]; describe("API (plugin)", () => { it("should work with plugin API", async () => { @@ -258,5 +259,77 @@ describe("API (plugin)", () => { expect(stopSpy).toHaveBeenCalledTimes(1); stopSpy.mockRestore(); }); + + it("should run two independent plugin servers on different child compilers", async () => { + const serverA = new Server({ port: portA }); + const serverB = new Server({ port: portB }); + const [configA, configB] = multiCompilerConfig; + const compiler = webpack([ + { ...configA, plugins: [...configA.plugins, serverA] }, + { ...configB, plugins: [...configB.plugins, serverB] }, + ]); + + await compile(compiler, portA); + // The second server is independent, but `compile()` only awaits one + // port, so poll the second one until it answers. + await new Promise((resolve) => { + const interval = setInterval(async () => { + try { + await fetch(`http://127.0.0.1:${portB}/`); + clearInterval(interval); + resolve(); + } catch { + // Server not ready yet; keep polling. + } + }, 100); + }); + + const { page, browser } = await runBrowser(); + + try { + const responseA = await page.goto( + `http://127.0.0.1:${portA}/one-main.html`, + { waitUntil: "networkidle0" }, + ); + expect(responseA.status()).toBe(200); + + const responseB = await page.goto( + `http://127.0.0.1:${portB}/two-main.html`, + { waitUntil: "networkidle0" }, + ); + expect(responseB.status()).toBe(200); + } finally { + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + }); + + it("should stay passive when a standalone server runs on the same compiler", async () => { + const compiler = webpack(config); + const pluginServer = new Server({ port }); + const standaloneServer = new Server({ port }, compiler); + + const pluginSetupSpy = jest.spyOn(pluginServer, "setup"); + const pluginListenSpy = jest.spyOn(pluginServer, "listen"); + + pluginServer.apply(compiler); + await standaloneServer.start(); + + try { + // The standalone server drives compilation through its own + // webpack-dev-middleware. The plugin's hooks fire during that + // compilation but must stay passive — so the plugin's own setup() and + // listen() are never called. + expect(pluginSetupSpy).not.toHaveBeenCalled(); + expect(pluginListenSpy).not.toHaveBeenCalled(); + } finally { + pluginSetupSpy.mockRestore(); + pluginListenSpy.mockRestore(); + await standaloneServer.stop(); + await pluginServer.stop(); + } + }); }); }); diff --git a/test/ports-map.js b/test/ports-map.js index d417b406b5..082f456e31 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -79,6 +79,7 @@ const listOfTests = { app: 1, "cross-origin-request": 2, "api-plugin": 1, + "api-plugin-multi": 2, }; let startPort = 8089; From 438b0d600718110940935db5bcd6d148b93560c6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 14:44:06 -0500 Subject: [PATCH 15/23] test: add test for passive behavior in build mode with compiler.run --- test/e2e/api-plugin.test.js | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index ccdd90cfd1..b959404a3c 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -1,5 +1,7 @@ "use strict"; +const os = require("node:os"); +const path = require("node:path"); const webpack = require("webpack"); const Server = require("../../lib/Server"); const config = require("../fixtures/client-config/webpack.config"); @@ -95,6 +97,42 @@ describe("API (plugin)", () => { stopSpy.mockRestore(); }); + it("should stay passive in build mode (compiler.run)", async () => { + // The shared fixture writes output to "/", which would be unwritable + // outside of webpack-dev-middleware's in-memory FS. Use a tmp dir so the + // real `compiler.run()` can flush its assets. + const compiler = webpack({ + ...config, + output: { + ...config.output, + path: path.join(os.tmpdir(), `wds-build-mode-${Date.now()}`), + }, + }); + const server = new Server({ port }); + const setupSpy = jest.spyOn(server, "setup"); + const listenSpy = jest.spyOn(server, "listen"); + + server.apply(compiler); + + // `compiler.run()` is a one-shot build (no watch). The plugin must stay + // passive so the build can finish and the process can exit normally. + await new Promise((resolve, reject) => { + compiler.run((error) => { + if (error) reject(error); + else resolve(); + }); + }); + + expect(setupSpy).not.toHaveBeenCalled(); + expect(listenSpy).not.toHaveBeenCalled(); + + setupSpy.mockRestore(); + listenSpy.mockRestore(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + describe("plugin in webpack config", () => { it("should work when added to webpack config plugins array", async () => { const server = new Server({ port }); From 6564733249282d8639a202d37cf8c4568798ebf5 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 14:46:20 -0500 Subject: [PATCH 16/23] fixup! --- types/lib/Server.d.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index 71d83c0eaa..2d9e68210d 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -1745,6 +1745,16 @@ declare class Server< * @returns {Promise} */ start(): Promise; + /** + * @private + * @returns {Promise} + */ + private setup; + /** + * @private + * @returns {Promise} + */ + private listen; /** * @param {((err?: Error) => void)=} callback callback */ @@ -1758,10 +1768,10 @@ declare class Server< */ stopCallback(callback?: ((err?: Error) => void) | undefined): void; /** - * @param {Compiler} compiler compiler + * @param {Compiler | MultiCompiler} compiler compiler * @returns {void} */ - apply(compiler: Compiler): void; + apply(compiler: Compiler | MultiCompiler): void; #private; } /** From 6c9118634014cb8ebe779a1486d489174d8dfc8d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 15:18:59 -0500 Subject: [PATCH 17/23] feat: add example for using webpack-dev-server as a plugin with configuration --- examples/api/plugin/README.md | 47 +++++++++++++++++++++++++++ examples/api/plugin/app.js | 6 ++++ examples/api/plugin/webpack.config.js | 27 +++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 examples/api/plugin/README.md create mode 100644 examples/api/plugin/app.js create mode 100644 examples/api/plugin/webpack.config.js diff --git a/examples/api/plugin/README.md b/examples/api/plugin/README.md new file mode 100644 index 0000000000..ef79106f4b --- /dev/null +++ b/examples/api/plugin/README.md @@ -0,0 +1,47 @@ +# API: Plugin + +Use `webpack-dev-server` as a webpack plugin by adding an instance to +`plugins[]`. The dev server starts when the first compilation finishes and +stops when the compiler closes — no separate `server.start()` call is needed. + +```js +// webpack.config.js +const WebpackDevServer = require("webpack-dev-server"); + +module.exports = { + // ... + plugins: [new WebpackDevServer({ port: 8080, open: true })], +}; +``` + +If you have existing `devServer` options in your config, spread them into the +plugin instance — the plugin reads its options from its constructor argument, +not from `config.devServer`: + +```js +const devServerOptions = { ...config.devServer, open: true }; +config.plugins.push(new WebpackDevServer(devServerOptions)); +``` + +## Run + +```console +npx webpack --watch +``` + +## What should happen + +1. Open `http://localhost:8080/` in your preferred browser. +2. You should see the text on the page itself change to read `Success!`. +3. Press `Ctrl+C` in the terminal — `webpack-cli` closes the compiler, which + fires the plugin's `shutdown` hook, stopping the dev server cleanly. + +## Notes + +- Use `webpack --watch`, not `webpack serve`. `webpack serve` creates its own + standalone dev server; if you also have a plugin instance in `plugins[]`, + the plugin detects the standalone server and stays passive to avoid binding + the same port twice. +- A plain `webpack` build (no watch) will not start the server — the plugin + detects build mode and stays passive so the build can finish and the process + can exit normally. diff --git a/examples/api/plugin/app.js b/examples/api/plugin/app.js new file mode 100644 index 0000000000..51cf4a396b --- /dev/null +++ b/examples/api/plugin/app.js @@ -0,0 +1,6 @@ +"use strict"; + +const target = document.querySelector("#target"); + +target.classList.add("pass"); +target.innerHTML = "Success!"; diff --git a/examples/api/plugin/webpack.config.js b/examples/api/plugin/webpack.config.js new file mode 100644 index 0000000000..b31e67e125 --- /dev/null +++ b/examples/api/plugin/webpack.config.js @@ -0,0 +1,27 @@ +"use strict"; + +const WebpackDevServer = require("../../../lib/Server"); +// our setup function adds behind-the-scenes bits to the config that all of our +// examples need +const { setup } = require("../../util"); + +const config = setup({ + context: __dirname, + entry: "./app.js", + output: { + filename: "bundle.js", + }, + stats: { + colors: true, + }, +}); + +// `setup()` populates `config.devServer.setupMiddlewares` so that the example +// layout assets (CSS, favicon, icons under `.assets/`) are served by the dev +// server. Forward those options to the plugin instance — without them the +// `` from the shared layout would 404. +config.plugins.push( + new WebpackDevServer({ ...config.devServer, port: 8090, open: true }), +); + +module.exports = config; From 3cbb12d74278d8239b53ea511b8537be1db3a4bb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 15:31:21 -0500 Subject: [PATCH 18/23] refactor: update README and remove redundant standalone server handling in Server class --- examples/api/plugin/README.md | 13 ++++--- lib/Server.js | 67 +++-------------------------------- test/e2e/api-plugin.test.js | 26 -------------- 3 files changed, 10 insertions(+), 96 deletions(-) diff --git a/examples/api/plugin/README.md b/examples/api/plugin/README.md index ef79106f4b..019dfaebcd 100644 --- a/examples/api/plugin/README.md +++ b/examples/api/plugin/README.md @@ -38,10 +38,9 @@ npx webpack --watch ## Notes -- Use `webpack --watch`, not `webpack serve`. `webpack serve` creates its own - standalone dev server; if you also have a plugin instance in `plugins[]`, - the plugin detects the standalone server and stays passive to avoid binding - the same port twice. -- A plain `webpack` build (no watch) will not start the server — the plugin - detects build mode and stays passive so the build can finish and the process - can exit normally. +- The plugin works with both `webpack --watch` and `webpack serve`. With + `webpack serve`, `webpack-cli` already creates its own standalone dev server + for the same compiler, so you would end up with two servers running. If + that's intentional (e.g. different ports/hosts), make sure the plugin's + `port` does not clash with the one `webpack-cli` resolves from + `config.devServer` and CLI args. Otherwise prefer one or the other. diff --git a/lib/Server.js b/lib/Server.js index 5fe2e53706..5163211cfc 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -328,20 +328,6 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i; const pluginName = "webpack-dev-server"; -/** - * Tracks compilers that have an active standalone `Server` attached - * (`new Server(options, compiler).start()`). When a compiler is in this set, - * any `Server` plugin attached to it (or to a `MultiCompiler` that contains - * it) stays passive — otherwise we'd try to bind the same port twice. - * Particularly relevant for `webpack serve`, which creates its own standalone - * server even when the user already added a `Server` instance to `plugins[]`. - * - * Uses a `WeakSet` so a compiler that is no longer referenced anywhere can be - * garbage collected normally, without this module holding it alive. - * @type {WeakSet} - */ -const activeStandaloneCompilers = new WeakSet(); - /** * @template {BasicApplication} [A=ExpressApplication] * @template {BasicServer} [S=HTTPServer] @@ -3374,36 +3360,8 @@ class Server { * @returns {Promise} */ async start() { - this.#trackStandalone(true); - try { - await this.setup(); - await this.listen(); - } catch (error) { - this.#trackStandalone(false); - throw error; - } - } - - /** - * @param {boolean} active whether to mark or unmark the compiler(s) as - * having an active standalone server - * @returns {void} - */ - #trackStandalone(active) { - if (!this.compiler) return; - const compilers = /** @type {MultiCompiler} */ (this.compiler) - .compilers || [this.compiler]; - if (active) { - activeStandaloneCompilers.add(this.compiler); - for (const child of compilers) { - activeStandaloneCompilers.add(child); - } - } else { - activeStandaloneCompilers.delete(this.compiler); - for (const child of compilers) { - activeStandaloneCompilers.delete(child); - } - } + await this.setup(); + await this.listen(); } /** @@ -3520,8 +3478,6 @@ class Server { * @returns {Promise} */ async stop() { - this.#trackStandalone(false); - if (this.bonjour) { await /** @type {Promise} */ ( new Promise((resolve) => { @@ -3642,21 +3598,6 @@ class Server { const seenFirstDone = new WeakSet(); let firstDoneCount = 0; - // Returns true when a standalone `Server` is already attached to our - // compiler (or any of its children). This matters for `webpack serve`: - // it creates its own standalone server even if the user added a `Server` - // instance to `plugins[]`. In that case the plugin must stay passive — - // otherwise we'd try to bind the same port twice. Independent - // server/compiler pairs in the same process are unaffected because they - // don't share any compiler instance. - const isStandaloneRunning = () => { - if (activeStandaloneCompilers.has(compiler)) return true; - for (const child of childCompilers) { - if (activeStandaloneCompilers.has(child)) return true; - } - return false; - }; - // A one-shot `compiler.run()` (plain `webpack` build) is detected when no // child compiler is in watch mode. In that case we skip both `setup()` and // `listen()` so the build can finish and the process can exit normally — @@ -3669,7 +3610,7 @@ class Server { * @returns {Promise} promise */ const ensureSetup = () => { - if (isStandaloneRunning() || isBuildMode()) return Promise.resolve(); + if (isBuildMode()) return Promise.resolve(); if (!setupPromise) { setupPromise = this.setup(); } @@ -3681,7 +3622,7 @@ class Server { * @returns {Promise} promise */ const onChildDone = async (childCompiler) => { - if (listening || isStandaloneRunning() || isBuildMode()) return; + if (listening || isBuildMode()) return; if (seenFirstDone.has(childCompiler)) return; seenFirstDone.add(childCompiler); firstDoneCount++; diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index b959404a3c..a327c2f4be 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -343,31 +343,5 @@ describe("API (plugin)", () => { }); } }); - - it("should stay passive when a standalone server runs on the same compiler", async () => { - const compiler = webpack(config); - const pluginServer = new Server({ port }); - const standaloneServer = new Server({ port }, compiler); - - const pluginSetupSpy = jest.spyOn(pluginServer, "setup"); - const pluginListenSpy = jest.spyOn(pluginServer, "listen"); - - pluginServer.apply(compiler); - await standaloneServer.start(); - - try { - // The standalone server drives compilation through its own - // webpack-dev-middleware. The plugin's hooks fire during that - // compilation but must stay passive — so the plugin's own setup() and - // listen() are never called. - expect(pluginSetupSpy).not.toHaveBeenCalled(); - expect(pluginListenSpy).not.toHaveBeenCalled(); - } finally { - pluginSetupSpy.mockRestore(); - pluginListenSpy.mockRestore(); - await standaloneServer.stop(); - await pluginServer.stop(); - } - }); }); }); From 5b9c8d2c5d08b2e0343f368f6259aa751814ea2a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 15:45:30 -0500 Subject: [PATCH 19/23] fixup! --- lib/Server.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 5163211cfc..3b86dd3fc9 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -568,14 +568,14 @@ class Server { } if (!dir) { - return path.resolve(cwd, ".cache/webpack-dev-server"); + return path.resolve(cwd, `.cache/${pluginName}`); } else if (process.versions.pnp === "1") { - return path.resolve(dir, ".pnp/.cache/webpack-dev-server"); + return path.resolve(dir, `.pnp/.cache/${pluginName}`); } else if (process.versions.pnp === "3") { - return path.resolve(dir, ".yarn/.cache/webpack-dev-server"); + return path.resolve(dir, `.yarn/.cache/${pluginName}`); } - return path.resolve(dir, "node_modules/.cache/webpack-dev-server"); + return path.resolve(dir, `node_modules/.cache/${pluginName}`); } /** @@ -1260,7 +1260,7 @@ class Server { if (typeof options.ipc === "boolean") { const isWindows = process.platform === "win32"; const pipePrefix = isWindows ? "\\\\.\\pipe\\" : os.tmpdir(); - const pipeName = "webpack-dev-server.sock"; + const pipeName = `${pluginName}.sock`; options.ipc = path.join(pipePrefix, pipeName); } @@ -1824,14 +1824,14 @@ class Server { setupHooks() { const compiler = /** @type {Compiler | MultiCompiler} */ (this.compiler); - compiler.hooks.invalid.tap("webpack-dev-server", () => { + compiler.hooks.invalid.tap(pluginName, () => { if (this.webSocketServer) { this.sendMessage(this.webSocketServer.clients, "invalid"); } }); compiler.hooks.done.tap( - "webpack-dev-server", + pluginName, /** * @param {Stats | MultiStats} stats stats */ From e7ff49e12e9c84b9f6884e221d16c5fa8e95d059 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 1 May 2026 16:00:39 -0500 Subject: [PATCH 20/23] test: add more tests --- test/e2e/api-plugin.test.js | 105 ++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index a327c2f4be..db3cfa5bb8 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -3,6 +3,7 @@ const os = require("node:os"); const path = require("node:path"); const webpack = require("webpack"); +const WebSocket = require("ws"); const Server = require("../../lib/Server"); const config = require("../fixtures/client-config/webpack.config"); const multiCompilerConfig = require("../fixtures/multi-compiler-two-configurations/webpack.config"); @@ -133,6 +134,110 @@ describe("API (plugin)", () => { }); }); + it("should send 'invalid' to WebSocket clients when recompilation is triggered", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + server.apply(compiler); + + const { watching } = await compile(compiler, port); + + const sawInvalid = await new Promise((resolve, reject) => { + let initialOkSeen = false; + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { + headers: { + host: `127.0.0.1:${port}`, + origin: `http://127.0.0.1:${port}`, + }, + }); + + ws.on("error", reject); + ws.on("message", (raw) => { + const { type } = JSON.parse(raw.toString()); + // Wait for the initial "ok" (sent right after the WS handshake), + // then trigger an invalidation. The server's `compiler.hooks.invalid` + // tap should push an "invalid" message before the next compile + // finishes. + if (!initialOkSeen && type === "ok") { + initialOkSeen = true; + watching.invalidate(); + return; + } + if (type === "invalid") { + ws.close(); + resolve(true); + } + }); + }); + + expect(sawInvalid).toBe(true); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should use constructor options instead of compiler.options.devServer", async () => { + // Plugin reads its options from its constructor argument; values on + // `compiler.options.devServer` are intentionally ignored. This protects + // the documented contract. + const compiler = webpack({ + ...config, + // Pretend an unrelated `devServer` block exists in the user's config. + // The plugin must not pick `port: portB` from it. + devServer: { port: portB, host: "0.0.0.0" }, + }); + const server = new Server({ port: portA }); + server.apply(compiler); + + await compile(compiler, portA); + + const responseA = await fetch(`http://127.0.0.1:${portA}/`); + expect(responseA.status).toBe(200); + + let portBReachable = true; + try { + await fetch(`http://127.0.0.1:${portB}/`); + } catch { + portBReachable = false; + } + expect(portBReachable).toBe(false); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should propagate setup errors via the watch callback", async () => { + const compiler = webpack(config); + // Using a URL as `static.directory` throws inside `normalizeOptions` + // during `setup()`. The rejection should bubble out through the + // `beforeCompile.tapPromise` handler and reach `compiler.watch()`'s + // user callback as an error. + const server = new Server({ + port, + static: "https://absolute-url.example/some/path", + }); + server.apply(compiler); + + const error = await new Promise((resolve, reject) => { + compiler.watch({}, (err) => { + if (err) { + resolve(err); + } else { + reject(new Error("expected setup to fail")); + } + }); + }); + + expect(error.message).toMatch( + /Using a URL as static.directory is not supported/, + ); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + describe("plugin in webpack config", () => { it("should work when added to webpack config plugins array", async () => { const server = new Server({ port }); From 1cd55ba18219662974887a34f07e815126161200 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Thu, 14 May 2026 15:44:59 -0500 Subject: [PATCH 21/23] fixup! --- .../api-plugin.test.js.snap.webpack5 | 26 ++++++---- test/e2e/api-plugin.test.js | 51 +++++++++---------- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 b/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 index bbf2712157..7bdfd110a4 100644 --- a/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 @@ -1,6 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`API (plugin) MultiCompiler should work with plugin API: console messages 1`] = ` +exports[`API (plugin) > MultiCompiler > should work with plugin API 1`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", "[HMR] Waiting for update signal from WDS...", @@ -8,9 +6,11 @@ exports[`API (plugin) MultiCompiler should work with plugin API: console message ] `; -exports[`API (plugin) MultiCompiler should work with plugin API: page errors 1`] = `[]`; +exports[`API (plugin) > MultiCompiler > should work with plugin API 2`] = ` +[] +`; -exports[`API (plugin) plugin in webpack config should work when added to webpack config plugins array: console messages 1`] = ` +exports[`API (plugin) > plugin in webpack config > should work when added to webpack config plugins array 1`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", "[HMR] Waiting for update signal from WDS...", @@ -18,9 +18,11 @@ exports[`API (plugin) plugin in webpack config should work when added to webpack ] `; -exports[`API (plugin) plugin in webpack config should work when added to webpack config plugins array: page errors 1`] = `[]`; +exports[`API (plugin) > plugin in webpack config > should work when added to webpack config plugins array 2`] = ` +[] +`; -exports[`API (plugin) plugin in webpack config should work with output.clean: true: console messages 1`] = ` +exports[`API (plugin) > plugin in webpack config > should work with output.clean: true 1`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", "[HMR] Waiting for update signal from WDS...", @@ -28,9 +30,11 @@ exports[`API (plugin) plugin in webpack config should work with output.clean: tr ] `; -exports[`API (plugin) plugin in webpack config should work with output.clean: true: page errors 1`] = `[]`; +exports[`API (plugin) > plugin in webpack config > should work with output.clean: true 2`] = ` +[] +`; -exports[`API (plugin) should work with plugin API: console messages 1`] = ` +exports[`API (plugin) > should work with plugin API 1`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", "[HMR] Waiting for update signal from WDS...", @@ -38,4 +42,6 @@ exports[`API (plugin) should work with plugin API: console messages 1`] = ` ] `; -exports[`API (plugin) should work with plugin API: page errors 1`] = `[]`; +exports[`API (plugin) > should work with plugin API 2`] = ` +[] +`; diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index db3cfa5bb8..852025ccc9 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -2,6 +2,9 @@ const os = require("node:os"); const path = require("node:path"); +const { describe, it } = require("node:test"); +const { expect } = require("expect"); +const { spyOn } = require("jest-mock"); const webpack = require("webpack"); const WebSocket = require("ws"); const Server = require("../../lib/Server"); @@ -13,7 +16,7 @@ const port = require("../ports-map")["api-plugin"]; const [portA, portB] = require("../ports-map")["api-plugin-multi"]; describe("API (plugin)", () => { - it("should work with plugin API", async () => { + it("should work with plugin API", async (t) => { const compiler = webpack(config); const server = new Server({ port }); @@ -38,10 +41,8 @@ describe("API (plugin)", () => { waitUntil: "networkidle0", }); - expect(consoleMessages.map((message) => message.text())).toMatchSnapshot( - "console messages", - ); - expect(pageErrors).toMatchSnapshot("page errors"); + t.assert.snapshot(consoleMessages.map((message) => message.text())); + t.assert.snapshot(pageErrors); await browser.close(); await new Promise((resolve) => { @@ -52,8 +53,8 @@ describe("API (plugin)", () => { it("should not start the server multiple times on recompilation", async () => { const compiler = webpack(config); const server = new Server({ port }); - const setupSpy = jest.spyOn(server, "setup"); - const listenSpy = jest.spyOn(server, "listen"); + const setupSpy = spyOn(server, "setup"); + const listenSpy = spyOn(server, "listen"); server.apply(compiler); @@ -84,7 +85,7 @@ describe("API (plugin)", () => { it("should stop the server cleanly via compiler.close()", async () => { const compiler = webpack(config); const server = new Server({ port }); - const stopSpy = jest.spyOn(server, "stop"); + const stopSpy = spyOn(server, "stop"); server.apply(compiler); @@ -110,8 +111,8 @@ describe("API (plugin)", () => { }, }); const server = new Server({ port }); - const setupSpy = jest.spyOn(server, "setup"); - const listenSpy = jest.spyOn(server, "listen"); + const setupSpy = spyOn(server, "setup"); + const listenSpy = spyOn(server, "listen"); server.apply(compiler); @@ -239,7 +240,7 @@ describe("API (plugin)", () => { }); describe("plugin in webpack config", () => { - it("should work when added to webpack config plugins array", async () => { + it("should work when added to webpack config plugins array", async (t) => { const server = new Server({ port }); const compiler = webpack({ ...config, @@ -266,10 +267,8 @@ describe("API (plugin)", () => { waitUntil: "networkidle0", }); - expect( - consoleMessages.map((message) => message.text()), - ).toMatchSnapshot("console messages"); - expect(pageErrors).toMatchSnapshot("page errors"); + t.assert.snapshot(consoleMessages.map((message) => message.text())); + t.assert.snapshot(pageErrors); } finally { await browser.close(); await new Promise((resolve) => { @@ -278,7 +277,7 @@ describe("API (plugin)", () => { } }); - it("should work with output.clean: true", async () => { + it("should work with output.clean: true", async (t) => { const server = new Server({ port }); const compiler = webpack({ ...config, @@ -310,10 +309,8 @@ describe("API (plugin)", () => { }); expect(response.status()).toBe(200); - expect( - consoleMessages.map((message) => message.text()), - ).toMatchSnapshot("console messages"); - expect(pageErrors).toMatchSnapshot("page errors"); + t.assert.snapshot(consoleMessages.map((message) => message.text())); + t.assert.snapshot(pageErrors); } finally { await browser.close(); await new Promise((resolve) => { @@ -324,7 +321,7 @@ describe("API (plugin)", () => { }); describe("MultiCompiler", () => { - it("should work with plugin API", async () => { + it("should work with plugin API", async (t) => { const compiler = webpack(multiCompilerConfig); const server = new Server({ port }); @@ -354,10 +351,8 @@ describe("API (plugin)", () => { ); expect(response.status()).toBe(200); - expect( - consoleMessages.map((message) => message.text()), - ).toMatchSnapshot("console messages"); - expect(pageErrors).toMatchSnapshot("page errors"); + t.assert.snapshot(consoleMessages.map((message) => message.text())); + t.assert.snapshot(pageErrors); } finally { await browser.close(); await new Promise((resolve) => { @@ -369,8 +364,8 @@ describe("API (plugin)", () => { it("should call setup and listen once across all child compilers", async () => { const compiler = webpack(multiCompilerConfig); const server = new Server({ port }); - const setupSpy = jest.spyOn(server, "setup"); - const listenSpy = jest.spyOn(server, "listen"); + const setupSpy = spyOn(server, "setup"); + const listenSpy = spyOn(server, "listen"); server.apply(compiler); @@ -389,7 +384,7 @@ describe("API (plugin)", () => { it("should stop the server only once when all child compilers shut down", async () => { const compiler = webpack(multiCompilerConfig); const server = new Server({ port }); - const stopSpy = jest.spyOn(server, "stop"); + const stopSpy = spyOn(server, "stop"); server.apply(compiler); From 2573bf2b6ecdea7cbeb785cdeefc55b8a8edaac2 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Thu, 14 May 2026 15:56:42 -0500 Subject: [PATCH 22/23] fixup! --- .../__snapshots__/api.test.js.snap.webpack5 | 36 ---- test/e2e/api.test.js | 165 ------------------ 2 files changed, 201 deletions(-) diff --git a/test/e2e/__snapshots__/api.test.js.snap.webpack5 b/test/e2e/__snapshots__/api.test.js.snap.webpack5 index 090427a0d2..9a55574007 100644 --- a/test/e2e/__snapshots__/api.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/api.test.js.snap.webpack5 @@ -213,39 +213,3 @@ exports[`API > latest async API > should work with callback API 1`] = ` exports[`API > latest async API > should work with callback API 2`] = ` [] `; - -exports[`API > plugin in webpack config > should work when added to webpack config plugins array 1`] = ` -[ - "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", - "[HMR] Waiting for update signal from WDS...", - "Hey.", -] -`; - -exports[`API > plugin in webpack config > should work when added to webpack config plugins array 2`] = ` -[] -`; - -exports[`API > plugin in webpack config > should work with output.clean: true 1`] = ` -[ - "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", - "[HMR] Waiting for update signal from WDS...", - "Hey.", -] -`; - -exports[`API > plugin in webpack config > should work with output.clean: true 2`] = ` -[] -`; - -exports[`API > should work with plugin API 1`] = ` -[ - "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", - "[HMR] Waiting for update signal from WDS...", - "Hey.", -] -`; - -exports[`API > should work with plugin API 2`] = ` -[] -`; diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index 017584ce21..b1a0ab6b4f 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -14,171 +14,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const port = portsMap.api; describe("API", () => { - it("should work with plugin API", async (t) => { - const compiler = webpack(config); - const server = new Server({ port }); - - server.apply(compiler); - - await compile(compiler, port); - - const { page, browser } = await runBrowser(); - - const pageErrors = []; - const consoleMessages = []; - - page - .on("console", (message) => { - consoleMessages.push(message); - }) - .on("pageerror", (error) => { - pageErrors.push(error); - }); - - await page.goto(`http://127.0.0.1:${port}/`, { - waitUntil: "networkidle0", - }); - - t.assert.snapshot(consoleMessages.map((message) => message.text())); - - t.assert.snapshot(pageErrors); - - await browser.close(); - await new Promise((resolve) => { - compiler.close(resolve); - }); - }); - - it("should not start the server multiple times on recompilation", async () => { - const compiler = webpack(config); - const server = new Server({ port }); - const setupSpy = jest.spyOn(server, "setup"); - const listenSpy = jest.spyOn(server, "listen"); - - server.apply(compiler); - - const { watching } = await compile(compiler, port); - - // Trigger a recompilation by invalidating - await new Promise((resolve) => { - watching.invalidate(() => { - resolve(); - }); - }); - - // Wait for the recompilation to finish - await new Promise((resolve) => { - setTimeout(resolve, 2000); - }); - - expect(setupSpy).toHaveBeenCalledTimes(1); - expect(listenSpy).toHaveBeenCalledTimes(1); - - setupSpy.mockRestore(); - listenSpy.mockRestore(); - await new Promise((resolve) => { - compiler.close(resolve); - }); - }); - - it("should stop the server cleanly via compiler.close()", async () => { - const compiler = webpack(config); - const server = new Server({ port }); - const stopSpy = jest.spyOn(server, "stop"); - - server.apply(compiler); - - await compile(compiler, port); - - await new Promise((resolve) => { - compiler.close(resolve); - }); - - expect(stopSpy).toHaveBeenCalledTimes(1); - stopSpy.mockRestore(); - }); - - describe("plugin in webpack config", () => { - it("should work when added to webpack config plugins array", async (t) => { - const server = new Server({ port }); - const compiler = webpack({ - ...config, - plugins: [...config.plugins, server], - }); - - await compile(compiler, port); - - const { page, browser } = await runBrowser(); - - try { - const pageErrors = []; - const consoleMessages = []; - - page - .on("console", (message) => { - consoleMessages.push(message); - }) - .on("pageerror", (error) => { - pageErrors.push(error); - }); - - await page.goto(`http://127.0.0.1:${port}/`, { - waitUntil: "networkidle0", - }); - - t.assert.snapshot(consoleMessages.map((message) => message.text())); - t.assert.snapshot(pageErrors); - } finally { - await browser.close(); - await new Promise((resolve) => { - compiler.close(resolve); - }); - } - }); - - it("should work with output.clean: true", async (t) => { - const server = new Server({ port }); - const compiler = webpack({ - ...config, - output: { - ...config.output, - clean: true, - }, - plugins: [...config.plugins, server], - }); - - await compile(compiler, port); - - const { page, browser } = await runBrowser(); - - try { - const pageErrors = []; - const consoleMessages = []; - - page - .on("console", (message) => { - consoleMessages.push(message); - }) - .on("pageerror", (error) => { - pageErrors.push(error); - }); - - const response = await page.goto(`http://127.0.0.1:${port}/`, { - waitUntil: "networkidle0", - }); - - expect(response.status()).toBe(200); - t.assert.snapshot(consoleMessages.map((message) => message.text())); - t.assert.snapshot(pageErrors); - } finally { - await browser.close(); - await new Promise((resolve) => { - compiler.close(resolve); - }); - } - }); - }); - describe("WEBPACK_SERVE environment variable", () => { const OLD_ENV = process.env; let server; From 2d6d746606c0523699bd5398c01aadddf9d211b1 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 16 May 2026 13:02:43 -0500 Subject: [PATCH 23/23] chore: convert to esm --- test/e2e/api-plugin.test.js | 32 ++++++++++++++++---------------- test/e2e/logging.test.js | 1 + test/helpers/compile.js | 11 ++--------- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 852025ccc9..fedb0feebe 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -1,19 +1,19 @@ -"use strict"; - -const os = require("node:os"); -const path = require("node:path"); -const { describe, it } = require("node:test"); -const { expect } = require("expect"); -const { spyOn } = require("jest-mock"); -const webpack = require("webpack"); -const WebSocket = require("ws"); -const Server = require("../../lib/Server"); -const config = require("../fixtures/client-config/webpack.config"); -const multiCompilerConfig = require("../fixtures/multi-compiler-two-configurations/webpack.config"); -const compile = require("../helpers/compile"); -const runBrowser = require("../helpers/run-browser"); -const port = require("../ports-map")["api-plugin"]; -const [portA, portB] = require("../ports-map")["api-plugin-multi"]; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { expect } from "expect"; +import { spyOn } from "jest-mock"; +import webpack from "webpack"; +import WebSocket from "ws"; +import Server from "../../lib/Server.js"; +import config from "../fixtures/client-config/webpack.config.js"; +import multiCompilerConfig from "../fixtures/multi-compiler-two-configurations/webpack.config.js"; +import compile from "../helpers/compile.js"; +import runBrowser from "../helpers/run-browser.js"; +import portsMap from "../ports-map.js"; + +const port = portsMap["api-plugin"]; +const [portA, portB] = portsMap["api-plugin-multi"]; describe("API (plugin)", () => { it("should work with plugin API", async (t) => { diff --git a/test/e2e/logging.test.js b/test/e2e/logging.test.js index 06ca9f9a1e..90d876bea8 100644 --- a/test/e2e/logging.test.js +++ b/test/e2e/logging.test.js @@ -6,6 +6,7 @@ import fs from "graceful-fs"; import webpack from "webpack"; import Server from "../../lib/Server.js"; import config from "../fixtures/client-config/webpack.config.js"; +import compile from "../helpers/compile.js"; import HTMLGeneratorPlugin from "../helpers/html-generator-plugin.js"; import runBrowser from "../helpers/run-browser.js"; import portsMap from "../ports-map.js"; diff --git a/test/helpers/compile.js b/test/helpers/compile.js index 36f1c480c9..77ddefa9ae 100644 --- a/test/helpers/compile.js +++ b/test/helpers/compile.js @@ -1,16 +1,11 @@ -"use strict"; - -// Helper function to check if server is ready using fetch const waitForServer = async (port, timeout = 10000) => { const start = Date.now(); while (Date.now() - start < timeout) { try { - // eslint-disable-next-line n/no-unsupported-features/node-builtins await fetch(`http://127.0.0.1:${port}/`); - return; // Server is ready + return; } catch { - // Server not ready yet, wait and retry await new Promise((resolve) => { setTimeout(resolve, 100); }); @@ -20,7 +15,7 @@ const waitForServer = async (port, timeout = 10000) => { throw new Error(`Server on port ${port} not ready after ${timeout}ms`); }; -module.exports = (compiler, port = null) => +export default (compiler, port = null) => new Promise((resolve, reject) => { const watching = compiler.watch({}, async (error, stats) => { if (error) { @@ -28,7 +23,6 @@ module.exports = (compiler, port = null) => return reject(error); } - // If a port is provided, wait for the server to be ready if (port) { try { await waitForServer(port); @@ -38,7 +32,6 @@ module.exports = (compiler, port = null) => } } - // Return both stats and watching for caller to manage resolve({ stats, watching }); }); });