From a09e00a93a175eeea704fb13011f9f3f61f15a24 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 09:47:45 -0500 Subject: [PATCH 1/9] feat: add plugin support Allow webpack-dev-server to run as a webpack plugin. The server can now be created without explicitly passing a compiler, integrates with the compiler lifecycle to prevent multiple starts on recompilation, ensures clean shutdown, and supports MultiCompiler setups with multiple independent plugin servers. --- lib/Server.js | 137 +++++++++++++++++++++++++++++++++++++----- types/lib/Server.d.ts | 29 +++++++-- 2 files changed, 145 insertions(+), 21 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index bedba64ad3..f03c2c5d56 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] @@ -341,11 +343,14 @@ class Server { baseDataPath: "options", }); - this.compiler = compiler; - /** - * @type {ReturnType} - */ - this.logger = this.compiler.getInfrastructureLogger("webpack-dev-server"); + if (compiler) { + this.compiler = compiler; + + /** + * @type {ReturnType} + */ + this.logger = this.compiler.getInfrastructureLogger(pluginName); + } this.options = options; /** * @type {FSWatcher[]} @@ -372,6 +377,11 @@ class Server { */ this.currentHash = undefined; + /** + * @private + * @type {boolean} + */ + this.isPlugin = false; } static get schema() { @@ -558,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}`); } /** @@ -1250,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); } @@ -1349,7 +1359,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") { @@ -1645,7 +1660,7 @@ class Server { this.server.emit("progress-update", { percent, msg, pluginName }); } }, - ).apply(this.compiler); + ).apply(/** @type {Compiler | MultiCompiler} */ (this.compiler)); } /** @@ -1732,7 +1747,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(); @@ -1833,13 +1848,16 @@ class Server { * @returns {void} */ setupHooks() { - this.compiler.hooks.invalid.tap("webpack-dev-server", () => { + const compiler = /** @type {Compiler | MultiCompiler} */ (this.compiler); + + compiler.hooks.invalid.tap(pluginName, () => { if (this.webSocketServer) { this.sendMessage(this.webSocketServer.clients, "invalid"); } }); - this.compiler.hooks.done.tap( - "webpack-dev-server", + + compiler.hooks.done.tap( + pluginName, /** * @param {Stats | MultiStats} stats stats */ @@ -1892,6 +1910,7 @@ class Server { * @returns {Promise} */ async setupMiddlewares() { + if (this.compiler === undefined) return; /** * @type {Middleware[]} */ @@ -2395,8 +2414,10 @@ class Server { // middleware for serving webpack bundle /** @type {import("webpack-dev-middleware").API} */ this.middleware = webpackDevMiddleware( + // @ts-expect-error this.compiler, this.options.devMiddleware, + this.isPlugin, ); } @@ -3365,6 +3386,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) { @@ -3416,7 +3446,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 }; @@ -3568,6 +3604,75 @@ class Server { .then(() => callback(), callback) .catch(callback); } + + /** + * @param {Compiler | MultiCompiler} compiler compiler + * @returns {void} + */ + apply(compiler) { + this.compiler = compiler; + this.isPlugin = true; + this.logger = this.compiler.getInfrastructureLogger(pluginName); + + /** @type {Promise | undefined} */ + let setupPromise; + let listening = false; + let stopped = false; + + const childCompilers = /** @type {MultiCompiler} */ (compiler) + .compilers || [compiler]; + const seenFirstDone = new WeakSet(); + let firstDoneCount = 0; + + // 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); + + /** + * @returns {Promise} promise + */ + const ensureSetup = () => { + if (isBuildMode()) return Promise.resolve(); + if (!setupPromise) { + setupPromise = this.setup(); + } + return setupPromise; + }; + + /** + * @param {Compiler} childCompiler child compiler + * @returns {Promise} promise + */ + const onChildDone = async (childCompiler) => { + if (listening || isBuildMode()) return; + if (seenFirstDone.has(childCompiler)) return; + seenFirstDone.add(childCompiler); + firstDoneCount++; + if (firstDoneCount < childCompilers.length) return; + listening = true; + await ensureSetup(); + await this.listen(); + }; + + 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); + } + } } export default Server; diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index 09d14b22cc..2d9e68210d 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} */ @@ -1532,6 +1531,11 @@ declare class Server< * @type {string | undefined} */ private currentHash; + /** + * @private + * @type {boolean} + */ + private isPlugin; /** * @private * @param {Compiler} compiler compiler @@ -1741,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 */ @@ -1753,6 +1767,11 @@ declare class Server< * @param {((err?: Error) => void)=} callback callback */ stopCallback(callback?: ((err?: Error) => void) | undefined): void; + /** + * @param {Compiler | MultiCompiler} compiler compiler + * @returns {void} + */ + apply(compiler: Compiler | MultiCompiler): void; #private; } /** From 33fedccb5310b42653035e7224755404abc59eb3 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 09:49:14 -0500 Subject: [PATCH 2/9] test: add tests for plugin support Add e2e coverage for the plugin API (api-plugin.test.js), MultiCompiler and multiple independent plugin servers, build-mode behavior, and related logging snapshots. Add the compile helper and port mappings used by these tests. --- .../api-plugin.test.js.snap.webpack5 | 47 ++ .../logging.test.js.snap.webpack5 | 50 ++ test/e2e/api-plugin.test.js | 447 ++++++++++++++++++ test/e2e/logging.test.js | 121 +++++ test/helpers/compile.js | 37 ++ test/ports-map.js | 2 + 6 files changed, 704 insertions(+) create mode 100644 test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 create mode 100644 test/e2e/api-plugin.test.js create mode 100644 test/helpers/compile.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..7bdfd110a4 --- /dev/null +++ b/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5 @@ -0,0 +1,47 @@ +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...", + "one", +] +`; + +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 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 2`] = ` +[] +`; + +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...", + "Hey.", +] +`; + +exports[`API (plugin) > plugin in webpack config > should work with output.clean: true 2`] = ` +[] +`; + +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...", + "Hey.", +] +`; + +exports[`API (plugin) > should work with plugin API 2`] = ` +[] +`; 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/api-plugin.test.js b/test/e2e/api-plugin.test.js new file mode 100644 index 0000000000..fedb0feebe --- /dev/null +++ b/test/e2e/api-plugin.test.js @@ -0,0 +1,447 @@ +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) => { + 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 = spyOn(server, "setup"); + const listenSpy = 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 = spyOn(server, "stop"); + + server.apply(compiler); + + await compile(compiler, port); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + + expect(stopSpy).toHaveBeenCalledTimes(1); + 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 = spyOn(server, "setup"); + const listenSpy = 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); + }); + }); + + 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 (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("MultiCompiler", () => { + it("should work with plugin API", async (t) => { + 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); + 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 call setup and listen once across all child compilers", async () => { + const compiler = webpack(multiCompilerConfig); + const server = new Server({ port }); + const setupSpy = spyOn(server, "setup"); + const listenSpy = 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 = spyOn(server, "stop"); + + server.apply(compiler); + + await compile(compiler, port); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + + 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); + }); + } + }); + }); +}); diff --git a/test/e2e/logging.test.js b/test/e2e/logging.test.js index 33457fb8ab..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"; @@ -245,4 +246,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); + }); + } + }); + } + }); }); diff --git a/test/helpers/compile.js b/test/helpers/compile.js new file mode 100644 index 0000000000..77ddefa9ae --- /dev/null +++ b/test/helpers/compile.js @@ -0,0 +1,37 @@ +const waitForServer = async (port, timeout = 10000) => { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + await fetch(`http://127.0.0.1:${port}/`); + return; + } catch { + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + } + + throw new Error(`Server on port ${port} not ready after ${timeout}ms`); +}; + +export default (compiler, port = null) => + new Promise((resolve, reject) => { + const watching = compiler.watch({}, async (error, stats) => { + if (error) { + watching.close(); + return reject(error); + } + + if (port) { + try { + await waitForServer(port); + } catch (err) { + watching.close(); + return reject(err); + } + } + + resolve({ stats, watching }); + }); + }); diff --git a/test/ports-map.js b/test/ports-map.js index f9f5d9dcfe..35cb4061cb 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -78,6 +78,8 @@ const listOfTests = { "options-request-response": 2, app: 1, "cross-origin-request": 2, + "api-plugin": 1, + "api-plugin-multi": 2, }; let startPort = 8089; From 07b85dc74728fca68de6b0af5774e29d991c1de3 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 09:49:34 -0500 Subject: [PATCH 3/9] docs: add example for using webpack-dev-server as a plugin Add a runnable example (app, webpack config and README) showing how to configure and use webpack-dev-server as a webpack plugin. --- examples/api/plugin/README.md | 46 +++++++++++++++++++++++++++ examples/api/plugin/app.js | 6 ++++ examples/api/plugin/webpack.config.js | 27 ++++++++++++++++ 3 files changed, 79 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..019dfaebcd --- /dev/null +++ b/examples/api/plugin/README.md @@ -0,0 +1,46 @@ +# 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 + +- 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/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 0a6871c28ee7ec46e72a2894757f6483c39aa525 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 09:53:16 -0500 Subject: [PATCH 4/9] chore: add changeset for plugin support --- .changeset/add-plugin-support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-plugin-support.md diff --git a/.changeset/add-plugin-support.md b/.changeset/add-plugin-support.md new file mode 100644 index 0000000000..7abfc5acde --- /dev/null +++ b/.changeset/add-plugin-support.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": minor +--- + +Add plugin support. `webpack-dev-server` can now be used as a webpack plugin, integrating with the compiler lifecycle without explicitly passing a compiler, preventing multiple server starts on recompilation, ensuring clean shutdown, and supporting `MultiCompiler` setups with multiple independent plugin servers. From 6c929cb26e830c5a2a3259c33f037cf615f2ec2c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 11:01:42 -0500 Subject: [PATCH 5/9] test: add test for in-memory file system behavior in plugin --- test/e2e/api-plugin.test.js | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index fedb0feebe..070284608d 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, it } from "node:test"; @@ -135,6 +136,46 @@ describe("API (plugin)", () => { }); }); + it("should serve from the in-memory file system, not the real disk", async () => { + // Point output at a real (but non-existent) tmp dir so we can assert that + // nothing is flushed to it. The plugin must hand the compiler + // webpack-dev-middleware's in-memory file system before the first build + // writes anything. + const outputPath = path.join(os.tmpdir(), `wds-plugin-memfs-${Date.now()}`); + const compiler = webpack({ + ...config, + output: { + ...config.output, + path: outputPath, + }, + }); + const server = new Server({ port }); + + server.apply(compiler); + + await compile(compiler, port); + + // `outputFileSystem` was swapped, so it is never the Node.js `fs`. + expect(compiler.outputFileSystem).not.toBe(fs); + + // Assets live in memory... + const inMemoryFiles = compiler.outputFileSystem.readdirSync(outputPath); + expect(inMemoryFiles).toContain("main.js"); + expect(inMemoryFiles).toContain("index.html"); + + // ...and the real disk stays untouched. + expect(fs.existsSync(outputPath)).toBe(false); + + // It is also reachable over HTTP, proving the server reads from that same + // in-memory file system. + const response = await fetch(`http://127.0.0.1:${port}/main.js`); + expect(response.status).toBe(200); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + it("should send 'invalid' to WebSocket clients when recompilation is triggered", async () => { const compiler = webpack(config); const server = new Server({ port }); From 75801a94e479b125ec62815b1de9eedca84dc648 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 11:56:44 -0500 Subject: [PATCH 6/9] refactor: update hooks to use watchRun instead of beforeCompile for better handling of compiler events --- lib/Server.js | 79 +++++++++++++++++++++---------------- test/e2e/api-plugin.test.js | 2 +- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index f03c2c5d56..eb74ec49df 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3616,61 +3616,74 @@ class Server { /** @type {Promise | undefined} */ let setupPromise; + let inWatchMode = false; let listening = false; let stopped = false; - const childCompilers = /** @type {MultiCompiler} */ (compiler) - .compilers || [compiler]; - const seenFirstDone = new WeakSet(); - let firstDoneCount = 0; - - // 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); - + // `setup()` boots webpack-dev-middleware, which replaces the compiler's + // `outputFileSystem` with an in-memory one. That swap has to happen before + // the first compilation writes its assets, otherwise the first build lands + // on the real disk — so it runs on `watchRun`, at the start of a watch run, + // before anything is emitted. Guarded so the async `setup()` runs at most + // once across rebuilds. /** * @returns {Promise} promise */ const ensureSetup = () => { - if (isBuildMode()) return Promise.resolve(); if (!setupPromise) { setupPromise = this.setup(); } return setupPromise; }; + // `watchRun` and `done` are tapped on the compiler directly — no iteration + // over `MultiCompiler.compilers`: + // - `watchRun` is a `MultiHook` on a `MultiCompiler`, so the tap is forwarded + // to every child and awaited. It stays `tapPromise` so a failing `setup()` + // rejects the user's `watch()` callback. It only fires in watch mode, which + // is how we know a one-shot `compiler.run()` build is not in play. + // - `done` on a `MultiCompiler` is the aggregate `SyncHook` that fires once + // after every child finishes, so the server starts exactly once. Being a + // `SyncHook`, it can only be `tap`ped; `listen()` runs detached. + const { hooks } = /** @type {Compiler} */ (compiler); + + hooks.watchRun.tapPromise(pluginName, () => { + inWatchMode = true; + return ensureSetup(); + }); + + hooks.done.tap(pluginName, () => { + // `done` also fires for a one-shot `compiler.run()` build, where no + // `watchRun` ran; staying passive lets that build finish and exit. + if (listening || !inWatchMode) return; + listening = true; + ensureSetup() + .then(() => this.listen()) + .catch((error) => { + this.logger.error(error); + }); + }); + /** - * @param {Compiler} childCompiler child compiler * @returns {Promise} promise */ - const onChildDone = async (childCompiler) => { - if (listening || isBuildMode()) return; - if (seenFirstDone.has(childCompiler)) return; - seenFirstDone.add(childCompiler); - firstDoneCount++; - if (firstDoneCount < childCompilers.length) return; - listening = true; - await ensureSetup(); - await this.listen(); - }; - - const onChildShutdown = async () => { + const onShutdown = async () => { if (stopped) return; stopped = true; - setupPromise = undefined; - listening = false; await this.stop(); }; + // Teardown is the one place a loop is unavoidable. A `MultiCompiler` has no + // `shutdown` hook, and its aggregate `watchClose` does NOT fire on + // `compiler.close()` (only on `watching.close()`), so the only signal that + // survives `compiler.close()` is each child's own `shutdown`. Tapping it + // with `tapPromise` also lets `compiler.close()` await the server actually + // stopping, so the port is released before the next start. + const childCompilers = /** @type {MultiCompiler} */ (compiler) + .compilers || [compiler]; + for (const childCompiler of childCompilers) { - childCompiler.hooks.beforeCompile.tapPromise(pluginName, ensureSetup); - childCompiler.hooks.done.tapPromise(pluginName, () => - onChildDone(childCompiler), - ); - childCompiler.hooks.shutdown.tapPromise(pluginName, onChildShutdown); + childCompiler.hooks.shutdown.tapPromise(pluginName, onShutdown); } } } diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 070284608d..f65fc47feb 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -253,7 +253,7 @@ describe("API (plugin)", () => { 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 + // `watchRun.tapPromise` handler and reach `compiler.watch()`'s // user callback as an error. const server = new Server({ port, From 6b935d74f164bdd66a270b94fd935c286f49c38e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 12:33:35 -0500 Subject: [PATCH 7/9] test: add hot module replacement tests for plugin API --- test/e2e/api-plugin.test.js | 90 ++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index f65fc47feb..20e3dcebac 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -1,7 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, it } from "node:test"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; import { expect } from "expect"; import { spyOn } from "jest-mock"; import webpack from "webpack"; @@ -9,13 +10,21 @@ 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 reloadConfig from "../fixtures/reload-config/webpack.config.js"; import compile from "../helpers/compile.js"; import runBrowser from "../helpers/run-browser.js"; import portsMap from "../ports-map.js"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const port = portsMap["api-plugin"]; const [portA, portB] = portsMap["api-plugin-multi"]; +const cssFilePath = path.resolve( + __dirname, + "../fixtures/reload-config/main.css", +); + describe("API (plugin)", () => { it("should work with plugin API", async (t) => { const compiler = webpack(config); @@ -485,4 +494,83 @@ describe("API (plugin)", () => { } }); }); + + describe("hot module replacement", () => { + beforeEach(() => { + fs.writeFileSync( + cssFilePath, + "body { background-color: rgb(0, 0, 255); }", + ); + }); + + afterEach(() => { + fs.unlinkSync(cssFilePath); + }); + + it("should apply hot module replacement updates", async () => { + const server = new Server({ port, hot: true }); + const compiler = webpack({ + ...reloadConfig, + plugins: [...reloadConfig.plugins, server], + }); + + await compile(compiler, port); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + let doneHotUpdate = false; + + page + .on("pageerror", (error) => { + pageErrors.push(error); + }) + .on("request", (request) => { + if (/\.hot-update\.json$/.test(request.url())) { + doneHotUpdate = true; + } + }); + + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "networkidle0", + }); + + const backgroundColorBefore = await page.evaluate( + () => getComputedStyle(document.body)["background-color"], + ); + + expect(backgroundColorBefore).toBe("rgb(0, 0, 255)"); + + fs.writeFileSync( + cssFilePath, + "body { background-color: rgb(255, 0, 0); }", + ); + + // The change must arrive through HMR (a `.hot-update.json` fetch), + // not a full page reload. + await page.waitForFunction( + () => + getComputedStyle(document.body)["background-color"] === + "rgb(255, 0, 0)", + ); + + expect(doneHotUpdate).toBe(true); + expect(pageErrors).toEqual([]); + + // The hot-update delta is served from webpack-dev-middleware's in-memory + // file system, never the real disk. + expect(compiler.outputFileSystem).not.toBe(fs); + const outputFiles = compiler.outputFileSystem.readdirSync("/"); + expect( + outputFiles.some((file) => /\.hot-update\.json$/.test(file)), + ).toBe(true); + } finally { + await browser.close(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + } + }); + }); }); From e9da18964ce9db87cb121d4e8049768f18d4ae6d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 13:08:04 -0500 Subject: [PATCH 8/9] test: enhance output.clean behavior test for in-memory asset serving --- test/e2e/api-plugin.test.js | 67 ++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 20e3dcebac..ba81b3475a 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -327,10 +327,21 @@ describe("API (plugin)", () => { } }); - it("should work with output.clean: true", async (t) => { + it("should serve rebuilt assets from memory with output.clean: true", async () => { + // A dedicated, editable entry so a rebuild produces observably different + // output. `output.clean` wipes the output directory before each build, so + // this exercises that the fresh assets are written back to the in-memory + // file system (and the stale ones are gone) rather than the real disk. + const entryPath = path.join( + os.tmpdir(), + `wds-clean-entry-${Date.now()}.js`, + ); + fs.writeFileSync(entryPath, 'globalThis.MARKER = "before-rebuild";\n'); + const server = new Server({ port }); const compiler = webpack({ ...config, + entry: entryPath, output: { ...config.output, clean: true, @@ -340,32 +351,48 @@ describe("API (plugin)", () => { 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", + const fetchBundle = async () => { + const response = await fetch(`http://127.0.0.1:${port}/main.js`); + return response.text(); + }; + + // The first build is served from the in-memory file system. + expect(await fetchBundle()).toContain("before-rebuild"); + + // Edit the source and let the watcher rebuild it under `output.clean`. + fs.writeFileSync(entryPath, 'globalThis.MARKER = "after-rebuild";\n'); + await new Promise((resolve, reject) => { + const start = Date.now(); + const interval = setInterval(async () => { + let bundle; + try { + bundle = await fetchBundle(); + } catch { + return; + } + if (bundle.includes("after-rebuild")) { + clearInterval(interval); + resolve(); + } else if (Date.now() - start > 30000) { + clearInterval(interval); + reject(new Error("rebuild was not served in time")); + } + }, 200); }); - expect(response.status()).toBe(200); - t.assert.snapshot(consoleMessages.map((message) => message.text())); - t.assert.snapshot(pageErrors); + // The rebuilt bundle is served fresh and the stale build is gone. + const rebuilt = await fetchBundle(); + expect(rebuilt).toContain("after-rebuild"); + expect(rebuilt).not.toContain("before-rebuild"); + + // ...and it still lives in the in-memory file system, not the disk. + expect(compiler.outputFileSystem).not.toBe(fs); } finally { - await browser.close(); await new Promise((resolve) => { compiler.close(resolve); }); + fs.unlinkSync(entryPath); } }); }); From 6efe969303e2e95d96c328632738e6c04b5a05da Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 15 Jun 2026 13:43:21 -0500 Subject: [PATCH 9/9] test: log listen() failure instead of throwing in server setup --- test/e2e/api-plugin.test.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index ba81b3475a..1d786ca818 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -289,6 +289,42 @@ describe("API (plugin)", () => { }); }); + it("should log a listen() failure instead of throwing", async () => { + // The server is started from the `done` hook, which is a `SyncHook`, so + // `listen()` runs detached. A failure there must be logged, not surface as + // an unhandled rejection. + const compiler = webpack(config); + const server = new Server({ port }); + server.apply(compiler); + + const listenError = new Error("listen failed"); + const listenSpy = spyOn(server, "listen").mockImplementation(() => + Promise.reject(listenError), + ); + const errorSpy = spyOn(server.logger, "error"); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("listen error was not logged")), + 30000, + ); + errorSpy.mockImplementation(() => { + clearTimeout(timer); + resolve(); + }); + compiler.watch({}, () => {}); + }); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith(listenError); + + listenSpy.mockRestore(); + errorSpy.mockRestore(); + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + describe("plugin in webpack config", () => { it("should work when added to webpack config plugins array", async (t) => { const server = new Server({ port });