Skip to content

Commit 1988bba

Browse files
committed
fix: implement middleware teardown in plugin mode to trigger rebuilds correctly
1 parent 50eeb58 commit 1988bba

3 files changed

Lines changed: 136 additions & 0 deletions

File tree

.cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"colorette",
2020
"selfsigned",
2121
"portfinder",
22+
"watchings",
2223
"xlink",
2324
"instanceof",
2425
"Heyo",

lib/Server.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3466,6 +3466,45 @@ class Server {
34663466
* @param {import("webpack-dev-middleware").Callback=} callback callback
34673467
*/
34683468
invalidate(callback = () => {}) {
3469+
// In plugin mode the host owns `compiler.watch()`, so the middleware has no
3470+
// `watching` of its own — invalidate the host's watching(s) directly to
3471+
// trigger a rebuild (each child's own `watching` for a `MultiCompiler`).
3472+
if (this.isPlugin) {
3473+
const compilers =
3474+
/** @type {MultiCompiler} */ (this.compiler).compilers ||
3475+
/** @type {Compiler[]} */ ([this.compiler]);
3476+
3477+
/** @type {NonNullable<Compiler["watching"]>[]} */
3478+
const watchings = [];
3479+
3480+
for (const compiler of compilers) {
3481+
if (compiler.watching) {
3482+
watchings.push(compiler.watching);
3483+
}
3484+
}
3485+
3486+
if (watchings.length === 0) {
3487+
callback();
3488+
return;
3489+
}
3490+
3491+
let pending = watchings.length;
3492+
3493+
const onInvalidated = () => {
3494+
pending -= 1;
3495+
3496+
if (pending === 0) {
3497+
callback();
3498+
}
3499+
};
3500+
3501+
for (const watching of watchings) {
3502+
watching.invalidate(onInvalidated);
3503+
}
3504+
3505+
return;
3506+
}
3507+
34693508
if (this.middleware) {
34703509
this.middleware.invalidate(callback);
34713510
}

test/e2e/api-plugin.test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from "node:fs";
2+
import http from "node:http";
23
import os from "node:os";
34
import path from "node:path";
45
import { afterEach, beforeEach, describe, it } from "node:test";
@@ -244,6 +245,101 @@ describe("API (plugin)", () => {
244245
});
245246
});
246247

248+
it("should trigger a rebuild via `server.invalidate()` in plugin mode", async () => {
249+
const compiler = webpack(config);
250+
const server = new Server({ port });
251+
server.apply(compiler);
252+
253+
await compile(compiler, port);
254+
255+
const sawInvalid = await new Promise((resolve, reject) => {
256+
let initialOkSeen = false;
257+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, {
258+
headers: {
259+
host: `127.0.0.1:${port}`,
260+
origin: `http://127.0.0.1:${port}`,
261+
},
262+
});
263+
264+
ws.on("error", reject);
265+
ws.on("message", (raw) => {
266+
const { type } = JSON.parse(raw.toString());
267+
268+
if (!initialOkSeen && type === "ok") {
269+
initialOkSeen = true;
270+
// Must invalidate the host's `watching` (the middleware has none in
271+
// plugin mode) instead of throwing.
272+
server.invalidate();
273+
return;
274+
}
275+
276+
if (type === "invalid") {
277+
ws.close();
278+
resolve(true);
279+
}
280+
});
281+
});
282+
283+
expect(sawInvalid).toBe(true);
284+
285+
await new Promise((resolve) => {
286+
compiler.close(resolve);
287+
});
288+
});
289+
290+
it("should trigger a rebuild via the /webpack-dev-server/invalidate route in plugin mode", async () => {
291+
const compiler = webpack(config);
292+
const server = new Server({ port });
293+
server.apply(compiler);
294+
295+
await compile(compiler, port);
296+
297+
const sawInvalid = await new Promise((resolve, reject) => {
298+
let initialOkSeen = false;
299+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, {
300+
headers: {
301+
host: `127.0.0.1:${port}`,
302+
origin: `http://127.0.0.1:${port}`,
303+
},
304+
});
305+
306+
ws.on("error", reject);
307+
ws.on("message", (raw) => {
308+
const { type } = JSON.parse(raw.toString());
309+
310+
if (!initialOkSeen && type === "ok") {
311+
initialOkSeen = true;
312+
// Hit the route as a browser would (same-origin, so it passes the
313+
// cross-origin check) — it must trigger a rebuild, not crash.
314+
http
315+
.get(
316+
`http://127.0.0.1:${port}/webpack-dev-server/invalidate`,
317+
{
318+
headers: {
319+
host: `127.0.0.1:${port}`,
320+
origin: `http://127.0.0.1:${port}`,
321+
},
322+
},
323+
(res) => res.resume(),
324+
)
325+
.on("error", reject);
326+
return;
327+
}
328+
329+
if (type === "invalid") {
330+
ws.close();
331+
resolve(true);
332+
}
333+
});
334+
});
335+
336+
expect(sawInvalid).toBe(true);
337+
338+
await new Promise((resolve) => {
339+
compiler.close(resolve);
340+
});
341+
});
342+
247343
it("should use constructor options instead of compiler.options.devServer", async () => {
248344
// Plugin reads its options from its constructor argument; values on
249345
// `compiler.options.devServer` are intentionally ignored. This protects

0 commit comments

Comments
 (0)