Skip to content

Commit ebbeb03

Browse files
committed
feat: add support for multiple independent plugin servers and enhance port mapping
1 parent 4829d1d commit ebbeb03

3 files changed

Lines changed: 156 additions & 9 deletions

File tree

lib/Server.js

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,20 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i;
313313

314314
const pluginName = "webpack-dev-server";
315315

316+
/**
317+
* Tracks compilers that have an active standalone `Server` attached
318+
* (`new Server(options, compiler).start()`). When a compiler is in this set,
319+
* any `Server` plugin attached to it (or to a `MultiCompiler` that contains
320+
* it) stays passive — otherwise we'd try to bind the same port twice.
321+
* Particularly relevant for `webpack serve`, which creates its own standalone
322+
* server even when the user already added a `Server` instance to `plugins[]`.
323+
*
324+
* Uses a `WeakSet` so a compiler that is no longer referenced anywhere can be
325+
* garbage collected normally, without this module holding it alive.
326+
* @type {WeakSet<Compiler | MultiCompiler>}
327+
*/
328+
const activeStandaloneCompilers = new WeakSet();
329+
316330
/**
317331
* @template {BasicApplication} [A=ExpressApplication]
318332
* @template {BasicServer} [S=HTTPServer]
@@ -1340,7 +1354,12 @@ class Server {
13401354
}
13411355

13421356
if (typeof options.setupExitSignals === "undefined") {
1343-
options.setupExitSignals = true;
1357+
// In plugin mode, the host (e.g. `webpack-cli`) usually owns process
1358+
// signal handling and calls `compiler.close()` on shutdown, which fires
1359+
// our `shutdown` hook. Adding our own SIGINT/SIGTERM listeners on top of
1360+
// that would race with the host's handler and call `compiler.close()`
1361+
// twice.
1362+
options.setupExitSignals = !this.isPlugin;
13441363
}
13451364

13461365
if (typeof options.static === "undefined") {
@@ -3252,8 +3271,36 @@ class Server {
32523271
* @returns {Promise<void>}
32533272
*/
32543273
async start() {
3255-
await this.setup();
3256-
await this.listen();
3274+
this.#trackStandalone(true);
3275+
try {
3276+
await this.setup();
3277+
await this.listen();
3278+
} catch (error) {
3279+
this.#trackStandalone(false);
3280+
throw error;
3281+
}
3282+
}
3283+
3284+
/**
3285+
* @param {boolean} active whether to mark or unmark the compiler(s) as
3286+
* having an active standalone server
3287+
* @returns {void}
3288+
*/
3289+
#trackStandalone(active) {
3290+
if (!this.compiler) return;
3291+
const compilers = /** @type {MultiCompiler} */ (this.compiler)
3292+
.compilers || [this.compiler];
3293+
if (active) {
3294+
activeStandaloneCompilers.add(this.compiler);
3295+
for (const child of compilers) {
3296+
activeStandaloneCompilers.add(child);
3297+
}
3298+
} else {
3299+
activeStandaloneCompilers.delete(this.compiler);
3300+
for (const child of compilers) {
3301+
activeStandaloneCompilers.delete(child);
3302+
}
3303+
}
32573304
}
32583305

32593306
/**
@@ -3370,6 +3417,8 @@ class Server {
33703417
* @returns {Promise<void>}
33713418
*/
33723419
async stop() {
3420+
this.#trackStandalone(false);
3421+
33733422
if (this.bonjour) {
33743423
await /** @type {Promise<void>} */ (
33753424
new Promise((resolve) => {
@@ -3474,27 +3523,51 @@ class Server {
34743523
let listening = false;
34753524
let stopped = false;
34763525

3526+
const childCompilers = /** @type {MultiCompiler} */ (compiler)
3527+
.compilers || [compiler];
3528+
const seenFirstDone = new WeakSet();
3529+
let firstDoneCount = 0;
3530+
3531+
// Returns true when a standalone `Server` is already attached to our
3532+
// compiler (or any of its children). This matters for `webpack serve`:
3533+
// it creates its own standalone server even if the user added a `Server`
3534+
// instance to `plugins[]`. In that case the plugin must stay passive —
3535+
// otherwise we'd try to bind the same port twice. Independent
3536+
// server/compiler pairs in the same process are unaffected because they
3537+
// don't share any compiler instance.
3538+
const isStandaloneRunning = () => {
3539+
if (activeStandaloneCompilers.has(compiler)) return true;
3540+
for (const child of childCompilers) {
3541+
if (activeStandaloneCompilers.has(child)) return true;
3542+
}
3543+
return false;
3544+
};
3545+
3546+
// A one-shot `compiler.run()` (plain `webpack` build) is detected when no
3547+
// child compiler is in watch mode. In that case we skip both `setup()` and
3548+
// `listen()` so the build can finish and the process can exit normally —
3549+
// the user is not in control of the plugin lifecycle here, so we stay
3550+
// silent rather than logging a warning.
3551+
const isBuildMode = () =>
3552+
childCompilers.every((child) => !child.watching && !child.options.watch);
3553+
34773554
/**
34783555
* @returns {Promise<void>} promise
34793556
*/
34803557
const ensureSetup = () => {
3558+
if (isStandaloneRunning() || isBuildMode()) return Promise.resolve();
34813559
if (!setupPromise) {
34823560
setupPromise = this.setup();
34833561
}
34843562
return setupPromise;
34853563
};
34863564

3487-
const childCompilers = /** @type {MultiCompiler} */ (compiler)
3488-
.compilers || [compiler];
3489-
const seenFirstDone = new WeakSet();
3490-
let firstDoneCount = 0;
3491-
34923565
/**
34933566
* @param {Compiler} childCompiler child compiler
34943567
* @returns {Promise<void>} promise
34953568
*/
34963569
const onChildDone = async (childCompiler) => {
3497-
if (listening) return;
3570+
if (listening || isStandaloneRunning() || isBuildMode()) return;
34983571
if (seenFirstDone.has(childCompiler)) return;
34993572
seenFirstDone.add(childCompiler);
35003573
firstDoneCount++;

test/e2e/api-plugin.test.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const multiCompilerConfig = require("../fixtures/multi-compiler-two-configuratio
77
const compile = require("../helpers/compile");
88
const runBrowser = require("../helpers/run-browser");
99
const port = require("../ports-map")["api-plugin"];
10+
const [portA, portB] = require("../ports-map")["api-plugin-multi"];
1011

1112
describe("API (plugin)", () => {
1213
it("should work with plugin API", async () => {
@@ -258,5 +259,77 @@ describe("API (plugin)", () => {
258259
expect(stopSpy).toHaveBeenCalledTimes(1);
259260
stopSpy.mockRestore();
260261
});
262+
263+
it("should run two independent plugin servers on different child compilers", async () => {
264+
const serverA = new Server({ port: portA });
265+
const serverB = new Server({ port: portB });
266+
const [configA, configB] = multiCompilerConfig;
267+
const compiler = webpack([
268+
{ ...configA, plugins: [...configA.plugins, serverA] },
269+
{ ...configB, plugins: [...configB.plugins, serverB] },
270+
]);
271+
272+
await compile(compiler, portA);
273+
// The second server is independent, but `compile()` only awaits one
274+
// port, so poll the second one until it answers.
275+
await new Promise((resolve) => {
276+
const interval = setInterval(async () => {
277+
try {
278+
await fetch(`http://127.0.0.1:${portB}/`);
279+
clearInterval(interval);
280+
resolve();
281+
} catch {
282+
// Server not ready yet; keep polling.
283+
}
284+
}, 100);
285+
});
286+
287+
const { page, browser } = await runBrowser();
288+
289+
try {
290+
const responseA = await page.goto(
291+
`http://127.0.0.1:${portA}/one-main.html`,
292+
{ waitUntil: "networkidle0" },
293+
);
294+
expect(responseA.status()).toBe(200);
295+
296+
const responseB = await page.goto(
297+
`http://127.0.0.1:${portB}/two-main.html`,
298+
{ waitUntil: "networkidle0" },
299+
);
300+
expect(responseB.status()).toBe(200);
301+
} finally {
302+
await browser.close();
303+
await new Promise((resolve) => {
304+
compiler.close(resolve);
305+
});
306+
}
307+
});
308+
309+
it("should stay passive when a standalone server runs on the same compiler", async () => {
310+
const compiler = webpack(config);
311+
const pluginServer = new Server({ port });
312+
const standaloneServer = new Server({ port }, compiler);
313+
314+
const pluginSetupSpy = jest.spyOn(pluginServer, "setup");
315+
const pluginListenSpy = jest.spyOn(pluginServer, "listen");
316+
317+
pluginServer.apply(compiler);
318+
await standaloneServer.start();
319+
320+
try {
321+
// The standalone server drives compilation through its own
322+
// webpack-dev-middleware. The plugin's hooks fire during that
323+
// compilation but must stay passive — so the plugin's own setup() and
324+
// listen() are never called.
325+
expect(pluginSetupSpy).not.toHaveBeenCalled();
326+
expect(pluginListenSpy).not.toHaveBeenCalled();
327+
} finally {
328+
pluginSetupSpy.mockRestore();
329+
pluginListenSpy.mockRestore();
330+
await standaloneServer.stop();
331+
await pluginServer.stop();
332+
}
333+
});
261334
});
262335
});

test/ports-map.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const listOfTests = {
8181
app: 1,
8282
"cross-origin-request": 2,
8383
"api-plugin": 1,
84+
"api-plugin-multi": 2,
8485
};
8586

8687
let startPort = 8089;

0 commit comments

Comments
 (0)