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;
diff --git a/lib/Server.js b/lib/Server.js
index 093e03ab62..5aacb419f7 100644
--- a/lib/Server.js
+++ b/lib/Server.js
@@ -311,6 +311,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]
@@ -326,11 +328,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[]}
@@ -357,6 +362,11 @@ class Server {
*/
this.currentHash = undefined;
+ /**
+ * @private
+ * @type {boolean}
+ */
+ this.isPlugin = false;
}
static get schema() {
@@ -544,14 +554,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}`);
}
/**
@@ -1231,7 +1241,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);
}
@@ -1330,7 +1340,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") {
@@ -1621,7 +1636,7 @@ class Server {
this.server.emit("progress-update", { percent, msg, pluginName });
}
},
- ).apply(this.compiler);
+ ).apply(/** @type {Compiler | MultiCompiler} */ (this.compiler));
}
/**
@@ -1704,7 +1719,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();
@@ -1779,13 +1794,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
*/
@@ -2329,8 +2347,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,
);
}
@@ -3237,6 +3257,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) {
@@ -3288,7 +3317,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 };
@@ -3429,6 +3464,76 @@ 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 && !child.options.watch);
+
+ /**
+ * @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);
+ }
+ }
}
module.exports = Server;
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..bbf2712157
--- /dev/null
+++ b/test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5
@@ -0,0 +1,41 @@
+// 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.",
+ "[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/__snapshots__/logging.test.js.snap.webpack5 b/test/e2e/__snapshots__/logging.test.js.snap.webpack5
index 4c8cc479f7..f9d55c0f6c 100644
--- a/test/e2e/__snapshots__/logging.test.js.snap.webpack5
+++ b/test/e2e/__snapshots__/logging.test.js.snap.webpack5
@@ -1,5 +1,55 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+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..db3cfa5bb8
--- /dev/null
+++ b/test/e2e/api-plugin.test.js
@@ -0,0 +1,452 @@
+"use strict";
+
+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");
+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 () => {
+ 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();
+ });
+
+ 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);
+ });
+ });
+
+ 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 });
+ 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);
+ });
+ }
+ });
+ });
+
+ 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();
+ });
+
+ 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 9ba530ba3d..aed449463f 100644
--- a/test/e2e/logging.test.js
+++ b/test/e2e/logging.test.js
@@ -5,6 +5,7 @@ const fs = require("graceful-fs");
const webpack = require("webpack");
const Server = require("../../lib/Server");
const config = require("../fixtures/client-config/webpack.config");
+const compile = require("../helpers/compile");
const HTMLGeneratorPlugin = require("../helpers/html-generator-plugin");
const runBrowser = require("../helpers/run-browser");
const port = require("../ports-map").logging;
@@ -241,4 +242,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 () => {
+ 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",
+ });
+
+ expect(
+ consoleMessages.map((message) =>
+ message
+ .text()
+ .replaceAll("\\", "/")
+ .replaceAll(
+ new RegExp(process.cwd().replaceAll("\\", "/"), "g"),
+ "",
+ ),
+ ),
+ ).toMatchSnapshot();
+ } 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..36f1c480c9
--- /dev/null
+++ b/test/helpers/compile.js
@@ -0,0 +1,44 @@
+"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
+ } 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) => {
+ const watching = compiler.watch({}, async (error, stats) => {
+ if (error) {
+ watching.close();
+ return reject(error);
+ }
+
+ // 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 });
+ });
+ });
diff --git a/test/ports-map.js b/test/ports-map.js
index d898970fdf..9bd885a471 100644
--- a/test/ports-map.js
+++ b/test/ports-map.js
@@ -80,6 +80,8 @@ const listOfTests = {
"options-request-response": 2,
app: 1,
"cross-origin-request": 2,
+ "api-plugin": 1,
+ "api-plugin-multi": 2,
};
let startPort = 8089;
diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts
index 80a7fbe2df..409c30e420 100644
--- a/types/lib/Server.d.ts
+++ b/types/lib/Server.d.ts
@@ -1,8 +1,4 @@
export = Server;
-/**
- * @typedef {object} BasicApplication
- * @property {typeof useFn} use
- */
/**
* @template {BasicApplication} [A=ExpressApplication]
* @template {BasicServer} [S=HTTPServer]
@@ -1167,7 +1163,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}
*/
@@ -1196,6 +1195,11 @@ declare class Server<
* @type {string | undefined}
*/
private currentHash;
+ /**
+ * @private
+ * @type {boolean}
+ */
+ private isPlugin;
/**
* @private
* @param {Compiler} compiler compiler
@@ -1397,6 +1401,16 @@ declare class Server<
* @returns {Promise}
*/
start(): Promise;
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ private setup;
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ private listen;
/**
* @param {((err?: Error) => void)=} callback callback
*/
@@ -1409,6 +1423,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;
}
declare namespace Server {