Skip to content

Commit d4ee8bd

Browse files
fix: assign distinct ports to dev servers sharing devServer options (#4886)
* test: use the shared free port helper in the new serve tests Ports from the OS can be taken by other connections before the dev server listens, the helper takes them from a range of the Jest worker. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL * fix: assign distinct ports to dev servers sharing devServer options Configurations can share one devServer object, the port assigned to the first dev server was written to it and reused by the others. Enable the test for multiple dev servers without ports. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL
1 parent 9bcb6d5 commit d4ee8bd

9 files changed

Lines changed: 99 additions & 76 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack-cli": patch
3+
---
4+
5+
fix: assign distinct ports to dev servers of configurations that share the same `devServer` object.

‎packages/webpack-cli/src/webpack-cli.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2245,7 +2245,8 @@ class WebpackCLI {
22452245
const getDevServerOptions = (
22462246
devServerOptions: DevServerConfiguration | undefined,
22472247
): DevServerConfiguration => {
2248-
const configuration: DevServerConfiguration = devServerOptions || {};
2248+
// Configurations can share options, so a port assigned to one isn't used by others
2249+
const configuration: DevServerConfiguration = { ...devServerOptions };
22492250
const args: Record<string, WebpackArgument> = {};
22502251
const values: ProcessedArguments = {};
22512252

‎test/serve/automatic-ports/automatic-ports.test.js‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,7 @@
22

33
const net = require("node:net");
44
const [devServerVersion] = require("webpack-dev-server/package.json").version;
5-
const { runWatch } = require("../../utils/test-utils");
6-
7-
/**
8-
* Ports below the ephemeral ranges (32768+ on Linux, 49152+ on Windows and macOS), so outgoing
9-
* connections can't take the next ports, which the CLI picks for dev servers without a port
10-
* @returns {Promise<number>} free port
11-
*/
12-
const getFreePort = async () => {
13-
const { default: getPort, portNumbers } = await import("get-port");
14-
15-
return getPort({ port: portNumbers(20000, 30000) });
16-
};
5+
const { getFreePort, runWatch } = require("../../utils/test-utils");
176

187
const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe;
198

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,25 @@
1-
const { getFreePort } = require("../../utils/test-utils");
21
const WebpackCLITestPlugin = require("../../utils/webpack-cli-test-plugin");
3-
const { devServerConfig } = require("./helper/base-dev-server.config");
2+
const devServerConfig = require("./helper/base-dev-server.config");
43

5-
module.exports = async () => {
6-
const port1 = await getFreePort();
7-
const port2 = await getFreePort();
8-
9-
return [
10-
{
11-
name: "one",
12-
mode: "development",
13-
devtool: false,
14-
output: {
15-
filename: "first-output/[name].js",
16-
},
17-
devServer: {
18-
...devServerConfig,
19-
port: port1,
20-
},
21-
plugins: [new WebpackCLITestPlugin(["mode", "output"], false, "hooks.compilation.taps")],
4+
module.exports = [
5+
{
6+
name: "one",
7+
mode: "development",
8+
devtool: false,
9+
output: {
10+
filename: "first-output/[name].js",
2211
},
23-
{
24-
name: "two",
25-
mode: "development",
26-
devtool: false,
27-
entry: "./src/other.js",
28-
output: {
29-
filename: "second-output/[name].js",
30-
},
31-
devServer: {
32-
...devServerConfig,
33-
port: port2,
34-
},
12+
devServer: devServerConfig,
13+
plugins: [new WebpackCLITestPlugin(["mode", "output"], false, "hooks.compilation.taps")],
14+
},
15+
{
16+
name: "two",
17+
mode: "development",
18+
devtool: false,
19+
entry: "./src/other.js",
20+
output: {
21+
filename: "second-output/[name].js",
3522
},
36-
];
37-
};
23+
devServer: devServerConfig,
24+
},
25+
];

‎test/serve/basic/serve-basic.test.js‎

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use strict";
22

33
const path = require("node:path");
4+
const [devServerVersion] = require("webpack-dev-server/package.json").version;
45
const {
56
getFreePort,
67
normalizeStderr,
@@ -10,6 +11,8 @@ const {
1011

1112
const testPath = path.resolve(__dirname);
1213

14+
const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe;
15+
1316
const normalStdKillOptions = {
1417
stdoutKillStr: /webpack \d+\.\d+\.\d/,
1518
stderrKillStr: /Content not from webpack is served from/,
@@ -95,21 +98,64 @@ describe("basic serve usage", () => {
9598
expect(stdout).toContain("second-output/main.js");
9699
});
97100

98-
// TODO need fix in future, edge case
99-
// eslint-disable-next-line jest/no-disabled-tests
100-
it.skip("should work in multi compiler mode with multiple dev servers", async () => {
101-
const { stderr, stdout } = await runWatch(
102-
__dirname,
103-
["serve", "--config", "multi-dev-server.config.js"],
104-
normalStdKillOptions,
105-
);
101+
// TODO in the next major release, remove the condition, only dev servers with plugin support
102+
// select distinct ports for configurations without them
103+
describeDevServer6("with multiple dev servers", () => {
104+
it("should work in multi compiler mode without ports", async () => {
105+
const ports = [];
106+
const responses = {};
107+
const { exitCode, stdout } = await runWatch(
108+
__dirname,
109+
["serve", "--config", "multi-dev-server.config.js", "--watch-options-stdin"],
110+
{
111+
// Automatic ports start from the base one
112+
env: { WEBPACK_DEV_SERVER_BASE_PORT: String(port) },
113+
handler: (proc) => {
114+
let output = "";
115+
116+
proc.stderr.on("data", (chunk) => {
117+
output += chunk.toString();
118+
119+
if (ports.length > 0) {
120+
return;
121+
}
122+
123+
const found = [...output.matchAll(/Loopback: http:\/\/localhost:(\d+)\//g)].map(
124+
([, found]) => found,
125+
);
126+
127+
if (found.length < 2) {
128+
return;
129+
}
130+
131+
ports.push(...found);
132+
133+
// Every dev server serves all compilers
134+
Promise.all(
135+
ports.flatMap((port) =>
136+
["first-output", "second-output"].map((directory) =>
137+
fetch(
138+
`http://127.0.0.1:${port}/dev-server-my-public-path/${directory}/main.js`,
139+
).then((response) => {
140+
responses[`${port}:${directory}`] = response.status;
141+
}),
142+
),
143+
),
144+
).finally(() => {
145+
proc.stdin.end();
146+
});
147+
});
148+
},
149+
},
150+
);
106151

107-
expect(normalizeStderr(stderr)).toMatchSnapshot("stderr");
108-
expect(stdout).toContain("HotModuleReplacementPlugin");
109-
expect(stdout).toContain("one");
110-
expect(stdout).toContain("first-output/main.js");
111-
expect(stdout).toContain("two");
112-
expect(stdout).toContain("second-output/main.js");
152+
expect(exitCode).toBe(0);
153+
expect(new Set(ports).size).toBe(2);
154+
expect(Object.values(responses)).toEqual([200, 200, 200, 200]);
155+
expect(stdout).toContain("HotModuleReplacementPlugin");
156+
expect(stdout).toContain("first-output/main.js");
157+
expect(stdout).toContain("second-output/main.js");
158+
});
113159
});
114160

115161
it('should work with the "--mode" option', async () => {

‎test/serve/error-handling/serve-error-handling.test.js‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@ const net = require("node:net");
77
const os = require("node:os");
88
const path = require("node:path");
99
const [devServerVersion] = require("webpack-dev-server/package.json").version;
10-
const { run, runWatch } = require("../../utils/test-utils");
11-
12-
const getGetPort = () => import("get-port");
10+
const { getFreePort, run, runWatch } = require("../../utils/test-utils");
1311

1412
// Only plugin mode uses the CLI's watch callback.
1513
const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe;
@@ -18,7 +16,7 @@ describeDevServer6("serve error handling", () => {
1816
let port;
1917

2018
beforeEach(async () => {
21-
port = await (await getGetPort()).default();
19+
port = await getFreePort();
2220
});
2321

2422
test("should print the stats with errors and keep serving when the compilation fails", async () => {

‎test/serve/per-compiler/serve-per-compiler.test.js‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44

55
const path = require("node:path");
66
const [devServerVersion] = require("webpack-dev-server/package.json").version;
7-
const { runWatch } = require("../../utils/test-utils");
8-
9-
const getGetPort = () => import("get-port");
7+
const { getFreePort, runWatch } = require("../../utils/test-utils");
108

119
/**
1210
* Starts `webpack serve` and requests every bundle from every dev server once they listen.
@@ -16,9 +14,8 @@ const getGetPort = () => import("get-port");
1614
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string, responses: Record<string, number> }>} result
1715
*/
1816
const serve = async (args, config = "webpack.config.js", env = {}) => {
19-
const { default: getPort } = await getGetPort();
20-
const appPort = await getPort();
21-
const adminPort = await getPort({ exclude: [appPort] });
17+
const appPort = await getFreePort();
18+
const adminPort = await getFreePort([appPort]);
2219
const names = config === "webpack.config.js" ? ["app", "admin"] : ["app"];
2320
const ports = { app: appPort, admin: adminPort };
2421
const responses = {};

‎test/serve/rebuild/serve-rebuild.test.js‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
const { readFileSync, writeFileSync } = require("node:fs");
44
const { resolve } = require("node:path");
55
const [devServerVersion] = require("webpack-dev-server/package.json").version;
6-
const { processKill, runWatch } = require("../../utils/test-utils");
7-
8-
const getGetPort = () => import("get-port");
6+
const { getFreePort, processKill, runWatch } = require("../../utils/test-utils");
97

108
const entryPath = resolve(__dirname, "./src/index.js");
119
const originalEntry = readFileSync(entryPath, "utf8");
@@ -16,7 +14,7 @@ describe("serve recompilation", () => {
1614
let port;
1715

1816
beforeEach(async () => {
19-
port = await (await getGetPort()).default();
17+
port = await getFreePort();
2018
});
2119

2220
afterEach(() => {

‎test/utils/test-utils.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,14 +408,15 @@ const cleanStderr = (stderr) =>
408408
* tests that install packages) can take it before the dev server listens, and Jest
409409
* workers running in parallel can get the same one. So each worker takes ports from its
410410
* own range below the ephemeral ones.
411+
* @param {number[]=} exclude ports to skip
411412
* @returns {Promise<number>} free port
412413
*/
413-
const getFreePort = async () => {
414+
const getFreePort = async (exclude = []) => {
414415
const { default: getPort, portNumbers } = await import("get-port");
415416
// 120 ranges keep ports below 32768, where the ephemeral range of Linux starts
416417
const from = 20000 + ((Number(process.env.JEST_WORKER_ID || 1) - 1) % 120) * 100;
417418

418-
return getPort({ port: portNumbers(from, from + 99) });
419+
return getPort({ port: portNumbers(from, from + 99), exclude });
419420
};
420421

421422
const uniqueDirectoryForTest = async () => {

0 commit comments

Comments
 (0)