Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"colorette",
"selfsigned",
"portfinder",
"watchings",
"xlink",
"instanceof",
"Heyo",
Expand Down
67 changes: 55 additions & 12 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Compiler["watching"]>[]} */
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);
}
Expand Down Expand Up @@ -3660,19 +3699,23 @@ class Server {
);

if (this.middleware) {
await /** @type {Promise<void>} */ (
new Promise((resolve, reject) => {
/** @type {import("webpack-dev-middleware").API<Request, Response>} */
(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<void>} */ (
new Promise((resolve, reject) => {
/** @type {import("webpack-dev-middleware").API<Request, Response>} */
(this.middleware).close((error) => {
if (error) {
reject(error);
return;
}

resolve();
});
})
);
resolve();
});
})
);
}

this.middleware = undefined;
}
Expand Down
146 changes: 146 additions & 0 deletions test/e2e/api-plugin.test.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading