Visitar URL original
fix: handle middleware teardown in plugin mode (#5703) · webpack/webpack-dev-server@a451839 · GitHub
Skip to content

Commit a451839

Browse files
authored
fix: handle middleware teardown in plugin mode (#5703)
* fix: handle middleware teardown in plugin mode to prevent TypeError on compiler close * fix: implement middleware teardown in plugin mode to trigger rebuilds correctly * test: add callbacks for server.invalidate() in plugin mode
1 parent c2d23a7 commit a451839

3 files changed

Lines changed: 202 additions & 12 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: 55 additions & 12 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
}
@@ -3660,19 +3699,23 @@ class Server {
36603699
);
36613700

36623701
if (this.middleware) {
3663-
await /** @type {Promise<void>} */ (
3664-
new Promise((resolve, reject) => {
3665-
/** @type {import("webpack-dev-middleware").API<Request, Response>} */
3666-
(this.middleware).close((error) => {
3667-
if (error) {
3668-
reject(error);
3669-
return;
3670-
}
3702+
// In plugin mode the middleware has no `watching` of its own to close
3703+
// (the host's `compiler.close()` handles it).
3704+
if (!this.isPlugin) {
3705+
await /** @type {Promise<void>} */ (
3706+
new Promise((resolve, reject) => {
3707+
/** @type {import("webpack-dev-middleware").API<Request, Response>} */
3708+
(this.middleware).close((error) => {
3709+
if (error) {
3710+
reject(error);
3711+
return;
3712+
}
36713713

3672-
resolve();
3673-
});
3674-
})
3675-
);
3714+
resolve();
3715+
});
3716+
})
3717+
);
3718+
}
36763719

36773720
this.middleware = undefined;
36783721
}

‎test/e2e/api-plugin.test.js‎

Lines changed: 146 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";
@@ -109,6 +110,23 @@ describe("API (plugin)", () => {
109110
stopSpy.mockRestore();
110111
});
111112

113+
it("should tear down without error when the compiler is closed", async () => {
114+
// In plugin mode the host owns `watching`, so `stop()` must not call
115+
// `middleware.close()` — otherwise `compiler.close` surfaces a TypeError.
116+
const compiler = webpack(config);
117+
const server = new Server({ port });
118+
119+
server.apply(compiler);
120+
121+
await compile(compiler, port);
122+
123+
const closeError = await new Promise((resolve) => {
124+
compiler.close((error) => resolve(error));
125+
});
126+
127+
expect(closeError).toBeFalsy();
128+
});
129+
112130
it("should stay passive in build mode (compiler.run)", async () => {
113131
// The shared fixture writes output to "/", which would be unwritable
114132
// outside of webpack-dev-middleware's in-memory FS. Use a tmp dir so the
@@ -227,6 +245,134 @@ describe("API (plugin)", () => {
227245
});
228246
});
229247

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+
343+
it("should invoke the callback after `server.invalidate()` rebuilds in plugin mode", async () => {
344+
const compiler = webpack(config);
345+
const server = new Server({ port });
346+
server.apply(compiler);
347+
348+
await compile(compiler, port);
349+
350+
// The callback fires once the invalidated build finishes.
351+
await new Promise((resolve) => {
352+
server.invalidate(resolve);
353+
});
354+
355+
await new Promise((resolve) => {
356+
compiler.close(resolve);
357+
});
358+
});
359+
360+
it("should invoke the callback when `server.invalidate()` runs with no active watching", async () => {
361+
const compiler = webpack(config);
362+
const server = new Server({ port });
363+
server.apply(compiler);
364+
365+
// No `compiler.watch()` — there is no `watching`, so invalidate is a no-op
366+
// that still calls the callback.
367+
await new Promise((resolve) => {
368+
server.invalidate(resolve);
369+
});
370+
371+
await new Promise((resolve) => {
372+
compiler.close(resolve);
373+
});
374+
});
375+
230376
it("should use constructor options instead of compiler.options.devServer", async () => {
231377
// Plugin reads its options from its constructor argument; values on
232378
// `compiler.options.devServer` are intentionally ignored. This protects

0 commit comments

Comments
 (0)