Skip to content

Commit 73a2468

Browse files
committed
feat: add support for multiple independent plugin servers and enhance port mapping
1 parent 0cb2816 commit 73a2468

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
@@ -328,6 +328,20 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i;
328328

329329
const pluginName = "webpack-dev-server";
330330

331+
/**
332+
* Tracks compilers that have an active standalone `Server` attached
333+
* (`new Server(options, compiler).start()`). When a compiler is in this set,
334+
* any `Server` plugin attached to it (or to a `MultiCompiler` that contains
335+
* it) stays passive — otherwise we'd try to bind the same port twice.
336+
* Particularly relevant for `webpack serve`, which creates its own standalone
337+
* server even when the user already added a `Server` instance to `plugins[]`.
338+
*
339+
* Uses a `WeakSet` so a compiler that is no longer referenced anywhere can be
340+
* garbage collected normally, without this module holding it alive.
341+
* @type {WeakSet<Compiler | MultiCompiler>}
342+
*/
343+
const activeStandaloneCompilers = new WeakSet();
344+
331345
/**
332346
* @template {BasicApplication} [A=ExpressApplication]
333347
* @template {BasicServer} [S=HTTPServer]
@@ -1359,7 +1373,12 @@ class Server {
13591373
}
13601374

13611375
if (typeof options.setupExitSignals === "undefined") {
1362-
options.setupExitSignals = true;
1376+
// In plugin mode, the host (e.g. `webpack-cli`) usually owns process
1377+
// signal handling and calls `compiler.close()` on shutdown, which fires
1378+
// our `shutdown` hook. Adding our own SIGINT/SIGTERM listeners on top of
1379+
// that would race with the host's handler and call `compiler.close()`
1380+
// twice.
1381+
options.setupExitSignals = !this.isPlugin;
13631382
}
13641383

13651384
if (typeof options.static === "undefined") {
@@ -3355,8 +3374,36 @@ class Server {
33553374
* @returns {Promise<void>}
33563375
*/
33573376
async start() {
3358-
await this.setup();
3359-
await this.listen();
3377+
this.#trackStandalone(true);
3378+
try {
3379+
await this.setup();
3380+
await this.listen();
3381+
} catch (error) {
3382+
this.#trackStandalone(false);
3383+
throw error;
3384+
}
3385+
}
3386+
3387+
/**
3388+
* @param {boolean} active whether to mark or unmark the compiler(s) as
3389+
* having an active standalone server
3390+
* @returns {void}
3391+
*/
3392+
#trackStandalone(active) {
3393+
if (!this.compiler) return;
3394+
const compilers = /** @type {MultiCompiler} */ (this.compiler)
3395+
.compilers || [this.compiler];
3396+
if (active) {
3397+
activeStandaloneCompilers.add(this.compiler);
3398+
for (const child of compilers) {
3399+
activeStandaloneCompilers.add(child);
3400+
}
3401+
} else {
3402+
activeStandaloneCompilers.delete(this.compiler);
3403+
for (const child of compilers) {
3404+
activeStandaloneCompilers.delete(child);
3405+
}
3406+
}
33603407
}
33613408

33623409
/**
@@ -3473,6 +3520,8 @@ class Server {
34733520
* @returns {Promise<void>}
34743521
*/
34753522
async stop() {
3523+
this.#trackStandalone(false);
3524+
34763525
if (this.bonjour) {
34773526
await /** @type {Promise<void>} */ (
34783527
new Promise((resolve) => {
@@ -3588,27 +3637,51 @@ class Server {
35883637
let listening = false;
35893638
let stopped = false;
35903639

3640+
const childCompilers = /** @type {MultiCompiler} */ (compiler)
3641+
.compilers || [compiler];
3642+
const seenFirstDone = new WeakSet();
3643+
let firstDoneCount = 0;
3644+
3645+
// Returns true when a standalone `Server` is already attached to our
3646+
// compiler (or any of its children). This matters for `webpack serve`:
3647+
// it creates its own standalone server even if the user added a `Server`
3648+
// instance to `plugins[]`. In that case the plugin must stay passive —
3649+
// otherwise we'd try to bind the same port twice. Independent
3650+
// server/compiler pairs in the same process are unaffected because they
3651+
// don't share any compiler instance.
3652+
const isStandaloneRunning = () => {
3653+
if (activeStandaloneCompilers.has(compiler)) return true;
3654+
for (const child of childCompilers) {
3655+
if (activeStandaloneCompilers.has(child)) return true;
3656+
}
3657+
return false;
3658+
};
3659+
3660+
// A one-shot `compiler.run()` (plain `webpack` build) is detected when no
3661+
// child compiler is in watch mode. In that case we skip both `setup()` and
3662+
// `listen()` so the build can finish and the process can exit normally —
3663+
// the user is not in control of the plugin lifecycle here, so we stay
3664+
// silent rather than logging a warning.
3665+
const isBuildMode = () =>
3666+
childCompilers.every((child) => !child.watching && !child.options.watch);
3667+
35913668
/**
35923669
* @returns {Promise<void>} promise
35933670
*/
35943671
const ensureSetup = () => {
3672+
if (isStandaloneRunning() || isBuildMode()) return Promise.resolve();
35953673
if (!setupPromise) {
35963674
setupPromise = this.setup();
35973675
}
35983676
return setupPromise;
35993677
};
36003678

3601-
const childCompilers = /** @type {MultiCompiler} */ (compiler)
3602-
.compilers || [compiler];
3603-
const seenFirstDone = new WeakSet();
3604-
let firstDoneCount = 0;
3605-
36063679
/**
36073680
* @param {Compiler} childCompiler child compiler
36083681
* @returns {Promise<void>} promise
36093682
*/
36103683
const onChildDone = async (childCompiler) => {
3611-
if (listening) return;
3684+
if (listening || isStandaloneRunning() || isBuildMode()) return;
36123685
if (seenFirstDone.has(childCompiler)) return;
36133686
seenFirstDone.add(childCompiler);
36143687
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
@@ -79,6 +79,7 @@ const listOfTests = {
7979
app: 1,
8080
"cross-origin-request": 2,
8181
"api-plugin": 1,
82+
"api-plugin-multi": 2,
8283
};
8384

8485
let startPort = 8089;

0 commit comments

Comments
 (0)