diff --git a/.cspell.json b/.cspell.json index c4133a96b2..730420f579 100644 --- a/.cspell.json +++ b/.cspell.json @@ -19,6 +19,7 @@ "colorette", "selfsigned", "portfinder", + "watchings", "xlink", "instanceof", "Heyo", diff --git a/lib/Server.js b/lib/Server.js index db89afbb02..38577856c9 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3466,6 +3466,45 @@ class Server { * @param {import("webpack-dev-middleware").Callback=} callback callback */ invalidate(callback = () => {}) { + // In plugin mode the host owns `compiler.watch()`, so the middleware has no + // `watching` of its own — invalidate the host's watching(s) directly to + // trigger a rebuild (each child's own `watching` for a `MultiCompiler`). + if (this.isPlugin) { + const compilers = + /** @type {MultiCompiler} */ (this.compiler).compilers || + /** @type {Compiler[]} */ ([this.compiler]); + + /** @type {NonNullable[]} */ + const watchings = []; + + for (const compiler of compilers) { + if (compiler.watching) { + watchings.push(compiler.watching); + } + } + + if (watchings.length === 0) { + callback(); + return; + } + + let pending = watchings.length; + + const onInvalidated = () => { + pending -= 1; + + if (pending === 0) { + callback(); + } + }; + + for (const watching of watchings) { + watching.invalidate(onInvalidated); + } + + return; + } + if (this.middleware) { this.middleware.invalidate(callback); } @@ -3660,19 +3699,23 @@ class Server { ); if (this.middleware) { - await /** @type {Promise} */ ( - new Promise((resolve, reject) => { - /** @type {import("webpack-dev-middleware").API} */ - (this.middleware).close((error) => { - if (error) { - reject(error); - return; - } + // In plugin mode the middleware has no `watching` of its own to close + // (the host's `compiler.close()` handles it). + if (!this.isPlugin) { + await /** @type {Promise} */ ( + new Promise((resolve, reject) => { + /** @type {import("webpack-dev-middleware").API} */ + (this.middleware).close((error) => { + if (error) { + reject(error); + return; + } - resolve(); - }); - }) - ); + resolve(); + }); + }) + ); + } this.middleware = undefined; } diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 1d786ca818..405f828429 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -1,4 +1,5 @@ import fs from "node:fs"; +import http from "node:http"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; @@ -109,6 +110,23 @@ describe("API (plugin)", () => { stopSpy.mockRestore(); }); + it("should tear down without error when the compiler is closed", async () => { + // In plugin mode the host owns `watching`, so `stop()` must not call + // `middleware.close()` — otherwise `compiler.close` surfaces a TypeError. + const compiler = webpack(config); + const server = new Server({ port }); + + server.apply(compiler); + + await compile(compiler, port); + + const closeError = await new Promise((resolve) => { + compiler.close((error) => resolve(error)); + }); + + expect(closeError).toBeFalsy(); + }); + 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 @@ -227,6 +245,134 @@ describe("API (plugin)", () => { }); }); + it("should trigger a rebuild via `server.invalidate()` in plugin mode", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + server.apply(compiler); + + 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()); + + if (!initialOkSeen && type === "ok") { + initialOkSeen = true; + // Must invalidate the host's `watching` (the middleware has none in + // plugin mode) instead of throwing. + server.invalidate(); + return; + } + + if (type === "invalid") { + ws.close(); + resolve(true); + } + }); + }); + + expect(sawInvalid).toBe(true); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should trigger a rebuild via the /webpack-dev-server/invalidate route in plugin mode", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + server.apply(compiler); + + 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()); + + if (!initialOkSeen && type === "ok") { + initialOkSeen = true; + // Hit the route as a browser would (same-origin, so it passes the + // cross-origin check) — it must trigger a rebuild, not crash. + http + .get( + `http://127.0.0.1:${port}/webpack-dev-server/invalidate`, + { + headers: { + host: `127.0.0.1:${port}`, + origin: `http://127.0.0.1:${port}`, + }, + }, + (res) => res.resume(), + ) + .on("error", reject); + return; + } + + if (type === "invalid") { + ws.close(); + resolve(true); + } + }); + }); + + expect(sawInvalid).toBe(true); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should invoke the callback after `server.invalidate()` rebuilds in plugin mode", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + server.apply(compiler); + + await compile(compiler, port); + + // The callback fires once the invalidated build finishes. + await new Promise((resolve) => { + server.invalidate(resolve); + }); + + await new Promise((resolve) => { + compiler.close(resolve); + }); + }); + + it("should invoke the callback when `server.invalidate()` runs with no active watching", async () => { + const compiler = webpack(config); + const server = new Server({ port }); + server.apply(compiler); + + // No `compiler.watch()` — there is no `watching`, so invalidate is a no-op + // that still calls the callback. + await new Promise((resolve) => { + server.invalidate(resolve); + }); + + 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