diff --git a/.changeset/shared-dev-server-options.md b/.changeset/shared-dev-server-options.md new file mode 100644 index 00000000000..e881ffa9040 --- /dev/null +++ b/.changeset/shared-dev-server-options.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +fix: assign distinct ports to dev servers of configurations that share the same `devServer` object. diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 03cc7c66221..6633eb7ef60 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -2245,7 +2245,8 @@ class WebpackCLI { const getDevServerOptions = ( devServerOptions: DevServerConfiguration | undefined, ): DevServerConfiguration => { - const configuration: DevServerConfiguration = devServerOptions || {}; + // Configurations can share options, so a port assigned to one isn't used by others + const configuration: DevServerConfiguration = { ...devServerOptions }; const args: Record = {}; const values: ProcessedArguments = {}; diff --git a/test/serve/automatic-ports/automatic-ports.test.js b/test/serve/automatic-ports/automatic-ports.test.js index fe5c2ead9c7..7c3be9985f6 100644 --- a/test/serve/automatic-ports/automatic-ports.test.js +++ b/test/serve/automatic-ports/automatic-ports.test.js @@ -2,18 +2,7 @@ const net = require("node:net"); const [devServerVersion] = require("webpack-dev-server/package.json").version; -const { runWatch } = require("../../utils/test-utils"); - -/** - * Ports below the ephemeral ranges (32768+ on Linux, 49152+ on Windows and macOS), so outgoing - * connections can't take the next ports, which the CLI picks for dev servers without a port - * @returns {Promise} free port - */ -const getFreePort = async () => { - const { default: getPort, portNumbers } = await import("get-port"); - - return getPort({ port: portNumbers(20000, 30000) }); -}; +const { getFreePort, runWatch } = require("../../utils/test-utils"); const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; diff --git a/test/serve/basic/multi-dev-server.config.js b/test/serve/basic/multi-dev-server.config.js index 7519a9c3a96..64f091cde8c 100644 --- a/test/serve/basic/multi-dev-server.config.js +++ b/test/serve/basic/multi-dev-server.config.js @@ -1,37 +1,25 @@ -const { getFreePort } = require("../../utils/test-utils"); const WebpackCLITestPlugin = require("../../utils/webpack-cli-test-plugin"); -const { devServerConfig } = require("./helper/base-dev-server.config"); +const devServerConfig = require("./helper/base-dev-server.config"); -module.exports = async () => { - const port1 = await getFreePort(); - const port2 = await getFreePort(); - - return [ - { - name: "one", - mode: "development", - devtool: false, - output: { - filename: "first-output/[name].js", - }, - devServer: { - ...devServerConfig, - port: port1, - }, - plugins: [new WebpackCLITestPlugin(["mode", "output"], false, "hooks.compilation.taps")], +module.exports = [ + { + name: "one", + mode: "development", + devtool: false, + output: { + filename: "first-output/[name].js", }, - { - name: "two", - mode: "development", - devtool: false, - entry: "./src/other.js", - output: { - filename: "second-output/[name].js", - }, - devServer: { - ...devServerConfig, - port: port2, - }, + devServer: devServerConfig, + plugins: [new WebpackCLITestPlugin(["mode", "output"], false, "hooks.compilation.taps")], + }, + { + name: "two", + mode: "development", + devtool: false, + entry: "./src/other.js", + output: { + filename: "second-output/[name].js", }, - ]; -}; + devServer: devServerConfig, + }, +]; diff --git a/test/serve/basic/serve-basic.test.js b/test/serve/basic/serve-basic.test.js index c796b9bee73..845ff0cbf6f 100644 --- a/test/serve/basic/serve-basic.test.js +++ b/test/serve/basic/serve-basic.test.js @@ -1,6 +1,7 @@ "use strict"; const path = require("node:path"); +const [devServerVersion] = require("webpack-dev-server/package.json").version; const { getFreePort, normalizeStderr, @@ -10,6 +11,8 @@ const { const testPath = path.resolve(__dirname); +const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; + const normalStdKillOptions = { stdoutKillStr: /webpack \d+\.\d+\.\d/, stderrKillStr: /Content not from webpack is served from/, @@ -95,21 +98,64 @@ describe("basic serve usage", () => { expect(stdout).toContain("second-output/main.js"); }); - // TODO need fix in future, edge case - // eslint-disable-next-line jest/no-disabled-tests - it.skip("should work in multi compiler mode with multiple dev servers", async () => { - const { stderr, stdout } = await runWatch( - __dirname, - ["serve", "--config", "multi-dev-server.config.js"], - normalStdKillOptions, - ); + // TODO in the next major release, remove the condition, only dev servers with plugin support + // select distinct ports for configurations without them + describeDevServer6("with multiple dev servers", () => { + it("should work in multi compiler mode without ports", async () => { + const ports = []; + const responses = {}; + const { exitCode, stdout } = await runWatch( + __dirname, + ["serve", "--config", "multi-dev-server.config.js", "--watch-options-stdin"], + { + // Automatic ports start from the base one + env: { WEBPACK_DEV_SERVER_BASE_PORT: String(port) }, + handler: (proc) => { + let output = ""; + + proc.stderr.on("data", (chunk) => { + output += chunk.toString(); + + if (ports.length > 0) { + return; + } + + const found = [...output.matchAll(/Loopback: http:\/\/localhost:(\d+)\//g)].map( + ([, found]) => found, + ); + + if (found.length < 2) { + return; + } + + ports.push(...found); + + // Every dev server serves all compilers + Promise.all( + ports.flatMap((port) => + ["first-output", "second-output"].map((directory) => + fetch( + `http://127.0.0.1:${port}/dev-server-my-public-path/${directory}/main.js`, + ).then((response) => { + responses[`${port}:${directory}`] = response.status; + }), + ), + ), + ).finally(() => { + proc.stdin.end(); + }); + }); + }, + }, + ); - expect(normalizeStderr(stderr)).toMatchSnapshot("stderr"); - expect(stdout).toContain("HotModuleReplacementPlugin"); - expect(stdout).toContain("one"); - expect(stdout).toContain("first-output/main.js"); - expect(stdout).toContain("two"); - expect(stdout).toContain("second-output/main.js"); + expect(exitCode).toBe(0); + expect(new Set(ports).size).toBe(2); + expect(Object.values(responses)).toEqual([200, 200, 200, 200]); + expect(stdout).toContain("HotModuleReplacementPlugin"); + expect(stdout).toContain("first-output/main.js"); + expect(stdout).toContain("second-output/main.js"); + }); }); it('should work with the "--mode" option', async () => { diff --git a/test/serve/error-handling/serve-error-handling.test.js b/test/serve/error-handling/serve-error-handling.test.js index a5c3e399ffe..3c67d9c737a 100644 --- a/test/serve/error-handling/serve-error-handling.test.js +++ b/test/serve/error-handling/serve-error-handling.test.js @@ -7,9 +7,7 @@ const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const [devServerVersion] = require("webpack-dev-server/package.json").version; -const { run, runWatch } = require("../../utils/test-utils"); - -const getGetPort = () => import("get-port"); +const { getFreePort, run, runWatch } = require("../../utils/test-utils"); // Only plugin mode uses the CLI's watch callback. const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; @@ -18,7 +16,7 @@ describeDevServer6("serve error handling", () => { let port; beforeEach(async () => { - port = await (await getGetPort()).default(); + port = await getFreePort(); }); test("should print the stats with errors and keep serving when the compilation fails", async () => { diff --git a/test/serve/per-compiler/serve-per-compiler.test.js b/test/serve/per-compiler/serve-per-compiler.test.js index 1cc72dbb041..ca31d6e5265 100644 --- a/test/serve/per-compiler/serve-per-compiler.test.js +++ b/test/serve/per-compiler/serve-per-compiler.test.js @@ -4,9 +4,7 @@ const path = require("node:path"); const [devServerVersion] = require("webpack-dev-server/package.json").version; -const { runWatch } = require("../../utils/test-utils"); - -const getGetPort = () => import("get-port"); +const { getFreePort, runWatch } = require("../../utils/test-utils"); /** * Starts `webpack serve` and requests every bundle from every dev server once they listen. @@ -16,9 +14,8 @@ const getGetPort = () => import("get-port"); * @returns {Promise<{ exitCode: number, stdout: string, stderr: string, responses: Record }>} result */ const serve = async (args, config = "webpack.config.js", env = {}) => { - const { default: getPort } = await getGetPort(); - const appPort = await getPort(); - const adminPort = await getPort({ exclude: [appPort] }); + const appPort = await getFreePort(); + const adminPort = await getFreePort([appPort]); const names = config === "webpack.config.js" ? ["app", "admin"] : ["app"]; const ports = { app: appPort, admin: adminPort }; const responses = {}; diff --git a/test/serve/rebuild/serve-rebuild.test.js b/test/serve/rebuild/serve-rebuild.test.js index 9f2021cc55a..fd348b6eb35 100644 --- a/test/serve/rebuild/serve-rebuild.test.js +++ b/test/serve/rebuild/serve-rebuild.test.js @@ -3,9 +3,7 @@ const { readFileSync, writeFileSync } = require("node:fs"); const { resolve } = require("node:path"); const [devServerVersion] = require("webpack-dev-server/package.json").version; -const { processKill, runWatch } = require("../../utils/test-utils"); - -const getGetPort = () => import("get-port"); +const { getFreePort, processKill, runWatch } = require("../../utils/test-utils"); const entryPath = resolve(__dirname, "./src/index.js"); const originalEntry = readFileSync(entryPath, "utf8"); @@ -16,7 +14,7 @@ describe("serve recompilation", () => { let port; beforeEach(async () => { - port = await (await getGetPort()).default(); + port = await getFreePort(); }); afterEach(() => { diff --git a/test/utils/test-utils.js b/test/utils/test-utils.js index 7e87ddc67e4..927760a5908 100644 --- a/test/utils/test-utils.js +++ b/test/utils/test-utils.js @@ -408,14 +408,15 @@ const cleanStderr = (stderr) => * tests that install packages) can take it before the dev server listens, and Jest * workers running in parallel can get the same one. So each worker takes ports from its * own range below the ephemeral ones. + * @param {number[]=} exclude ports to skip * @returns {Promise} free port */ -const getFreePort = async () => { +const getFreePort = async (exclude = []) => { const { default: getPort, portNumbers } = await import("get-port"); // 120 ranges keep ports below 32768, where the ephemeral range of Linux starts const from = 20000 + ((Number(process.env.JEST_WORKER_ID || 1) - 1) % 120) * 100; - return getPort({ port: portNumbers(from, from + 99) }); + return getPort({ port: portNumbers(from, from + 99), exclude }); }; const uniqueDirectoryForTest = async () => {