Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/dev-server-6-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-cli": minor
---

feat: run `webpack-dev-server@6` as a compiler plugin. The CLI drives watch compilation, prints build stats and closes the compiler on shutdown. Older dev servers continue to manage their own compilation.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ test/build/config/error-commonjs/syntax-error.js
test/build/config/error-array/webpack.config.js
test/build/config/error-mjs/syntax-error.mjs
test/configtest/with-config-path/syntax-error.config.js
test/serve/error-handling/src/syntax-error.js
test/build/build-errors/stats.json

/.nx/workspace-data
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default defineConfig([
"test/configtest/with-config-path/syntax-error.config.js",
"test/build/config-format/auto/webpack.config.js",
"test/build/config-format/typescript-tsx/webpack.config.jsx",
"test/serve/error-handling/src/syntax-error.js",
]),
{
extends: [config],
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/webpack-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"commander": "^14.0.3",
"cross-spawn": "^7.0.6",
"envinfo": "^7.21.0",
"get-port": "^7.2.0",
"import-local": "^3.2.0",
"interpret": "^3.1.1",
"rechoir": "^0.8.0",
Expand Down
219 changes: 179 additions & 40 deletions packages/webpack-cli/src/webpack-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2221,10 +2221,12 @@ class WebpackCLI {
return;
}

const DevServer: DevServerConstructor = cmd.context.devServer;
const DevServer: DevServerConstructor = devServer;
const isDevServerPlugin =
typeof (DevServer.prototype as { apply?: unknown }).apply === "function";
const servers: InstanceType<DevServerConstructor>[] = [];

if (this.#needWatchStdin(compiler)) {
if (!isDevServerPlugin && this.#needWatchStdin(compiler)) {
process.stdin.on("end", () => {
Promise.all(servers.map((server) => server.stop())).then(() => {
process.exit(0);
Expand All @@ -2238,8 +2240,23 @@ class WebpackCLI {
const compilersForDevServer =
possibleCompilers.length > 0 ? possibleCompilers : [compilers[0]];
const usedPorts: number[] = [];
const devServerConfigurations: DevServerConfiguration[] = [];
const validatePort = ({ port }: DevServerConfiguration): void => {
if (port && port !== "auto") {
const portNumber = Number(port);

if (usedPorts.includes(portNumber)) {
throw new Error(
"Unique ports must be specified for each devServer option in your webpack configuration. Alternatively, run only 1 devServer config using the --config-name flag to specify your desired config.",
);
}

usedPorts.push(portNumber);
}
};
// @ts-expect-error different versions of the `Schema` type
const devServerArgs = this.#getArguments(webpack, devServer.schema);
let appliedDevServers = 0;

for (const compilerForDevServer of compilersForDevServer) {
if (compilerForDevServer.options.devServer === false) {
Expand Down Expand Up @@ -2270,24 +2287,65 @@ class WebpackCLI {
this.#processArguments(webpack, args, devServerConfiguration, values);
}

if (devServerConfiguration.port) {
const portNumber = Number(devServerConfiguration.port);
if (isDevServerPlugin) {
validatePort(devServerConfiguration);
}

if (usedPorts.includes(portNumber)) {
throw new Error(
"Unique ports must be specified for each devServer option in your webpack configuration. Alternatively, run only 1 devServer config using the --config-name flag to specify your desired config.",
);
}
devServerConfigurations.push(devServerConfiguration);
}

usedPorts.push(portNumber);
for (const devServerConfiguration of devServerConfigurations) {
if (!isDevServerPlugin) {
validatePort(devServerConfiguration);
}

try {
const server = new DevServer(devServerConfiguration, compiler);
if (isDevServerPlugin) {
let { port } = devServerConfiguration;

if (
devServerConfigurations.length > 1 &&
!devServerConfiguration.ipc &&
(typeof port === "undefined" || port === "auto")
) {
const { default: getPort, portNumbers } = await import("get-port");
const basePort = Number.parseInt(
process.env.WEBPACK_DEV_SERVER_BASE_PORT ?? "8080",
10,
);
const host = devServerConfiguration.host
? await DevServer.getHostname(devServerConfiguration.host)
: undefined;

// Plugins select ports before any server starts listening.
port = await getPort({
port: portNumbers(basePort, 65535),
host,
exclude: usedPorts,
});
usedPorts.push(port);
}

// v5 typings lack the plugin constructor.
const DevServerPlugin = DevServer as unknown as new (
options: DevServerConfiguration,
) => { apply(compiler: Compiler | MultiCompiler): void };

// Serve all child compilers, regardless of which defines devServer.
new DevServerPlugin({
...devServerConfiguration,
port,
setupExitSignals: false,
}).apply(compiler);
} else {
const server = new DevServer(devServerConfiguration, compiler);

await server.start();

await server.start();
servers.push(server as unknown as InstanceType<DevServerConstructor>);
}

servers.push(server as unknown as InstanceType<DevServerConstructor>);
appliedDevServers += 1;
} catch (error) {
if (this.isValidationError(error as Error)) {
this.logger.error((error as Error).message);
Expand All @@ -2299,10 +2357,87 @@ class WebpackCLI {
}
}

if (servers.length === 0) {
if (appliedDevServers === 0) {
this.logger.error("No dev server configurations to run");
process.exit(2);
}

// Older servers manage compilation and signals themselves.
if (!isDevServerPlugin) {
return;
}

// Closing the compiler stops the server through its shutdown hook.
this.#setupGracefulShutdown(compiler, true);

if (this.#needWatchStdin(compiler)) {
process.stdin.on("end", () => {
compiler.close(() => {
process.exit();
});
});
process.stdin.resume();
}

const watchCallback = (error: Error | null, stats?: Stats | MultiStats): void => {
if (error) {
if (this.isValidationError(error)) {
this.logger.error(error.message);
} else {
this.logger.error(error);
}

process.exit(2);
}

if (!stats) {
return;
}

if (stats.hasErrors() || (options.failOnWarnings && stats.hasWarnings())) {
process.exitCode = 1;
}

// Each middleware can override stats for the whole compiler.
for (const devServerConfiguration of devServerConfigurations) {
const middlewareStats = devServerConfiguration.devMiddleware?.stats;
const getStatsOptions = (compiler: Compiler): StatsOptions => {
if (typeof middlewareStats === "undefined") {
return compiler.options.stats as StatsOptions;
}

const statsOptions: StatsOptions =
typeof middlewareStats === "boolean"
? { preset: middlewareStats ? "normal" : "none" }
: typeof middlewareStats === "string"
? { preset: middlewareStats }
: { ...middlewareStats };

if (typeof statsOptions.colors === "undefined") {
statsOptions.colors = (compiler.options.stats as StatsOptions).colors;
}

return statsOptions;
};
const statsOptions = this.isMultipleCompiler(compiler)
? { children: compiler.compilers.map(getStatsOptions) }
: getStatsOptions(compiler);
const printedStats = stats.toString(statsOptions);

if (printedStats) {
this.logger.raw(printedStats);
}
}
};

if (this.isMultipleCompiler(compiler)) {
compiler.watch(
compiler.compilers.map((compiler) => compiler.options.watchOptions || {}),
watchCallback,
);
} else {
compiler.watch(compiler.options.watchOptions || {}, watchCallback);
}
},
},
help: {
Expand Down Expand Up @@ -3658,6 +3793,35 @@ class WebpackCLI {
return Boolean(compiler.options.watchOptions?.stdin);
}

#setupGracefulShutdown(compiler: Compiler | MultiCompiler, preserveExitCode = false): void {
let needForceShutdown = false;

for (const signal of EXIT_SIGNALS) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
const listener = () => {
if (needForceShutdown) {
process.exit(preserveExitCode ? process.exitCode : 0);
}

// Keep fast shutdowns silent.
const timeout = setTimeout(() => {
this.logger.info(
"Gracefully shutting down. To force exit, press ^C again. Please wait...",
);
}, 2000);

needForceShutdown = true;

compiler.close(() => {
clearTimeout(timeout);
process.exit(preserveExitCode ? process.exitCode : 0);
});
};

process.on(signal, listener);
}
}

async runWebpack(options: Options, isWatchCommand: boolean): Promise<void> {
let compiler: Compiler | MultiCompiler;
let stringifyChunked: typeof stringifyChunkedType;
Expand Down Expand Up @@ -3755,32 +3919,7 @@ class WebpackCLI {
);

if (needGracefulShutdown(compiler)) {
let needForceShutdown = false;

for (const signal of EXIT_SIGNALS) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
const listener = () => {
if (needForceShutdown) {
process.exit(0);
}

// Output message after delay to avoid extra logging
const timeout = setTimeout(() => {
this.logger.info(
"Gracefully shutting down. To force exit, press ^C again. Please wait...",
);
}, 2000);

needForceShutdown = true;

compiler.close(() => {
clearTimeout(timeout);
process.exit(0);
});
};

process.on(signal, listener);
}
this.#setupGracefulShutdown(compiler);

if (this.#needWatchStdin(compiler)) {
process.stdin.on("end", () => {
Expand Down
72 changes: 72 additions & 0 deletions test/serve/automatic-ports/automatic-ports.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/* eslint-disable jest/require-top-level-describe -- Version-gated describe */

const net = require("node:net");
const [devServerVersion] = require("webpack-dev-server/package.json").version;
const { runWatch } = require("../../utils/test-utils");

const getGetPort = () => import("get-port");

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

describeDevServer6("automatic dev server ports", () => {
let occupied;
let basePort;

beforeEach(async () => {
occupied = net.createServer();
basePort = await (await getGetPort()).default();
await new Promise((resolve, reject) => {
occupied.once("error", reject);
occupied.listen(basePort, "127.0.0.1", resolve);
});
});

afterEach(async () => {
await new Promise((resolve, reject) => {
occupied.close((error) => (error ? reject(error) : resolve()));
});
});

test.each(["omitted", "auto", "explicit"])(
"should assign distinct available ports with %s ports",
async (mode) => {
const explicitPort = await (await getGetPort()).default();
const args = ["serve", "--watch-options-stdin"];

if (mode === "auto") {
args.push("--env", "auto=true");
} else if (mode === "explicit") {
args.push("--env", `explicit=${explicitPort}`);
}

const { exitCode, stdout, stderr } = await runWatch(__dirname, args, {
env: {
WEBPACK_DEV_SERVER_BASE_PORT: String(mode === "explicit" ? explicitPort : basePort),
},
handler: (proc) => {
let output = "";
let stopping = false;
proc.stdout.on("data", (chunk) => {
output += chunk.toString();

if (!stopping && [...output.matchAll(/Listening \d: \d+\n/g)].length === 2) {
stopping = true;
proc.stdin.end();
}
});
},
});

const ports = [...stdout.matchAll(/Listening \d: (\d+)/g)].map((match) => Number(match[1]));
expect(exitCode).toBe(0);
expect(ports).toHaveLength(2);
expect(new Set(ports).size).toBe(2);
expect(ports).not.toContain(basePort);
expect(stderr).not.toContain("EADDRINUSE");

if (mode === "explicit") {
expect(ports).toContain(explicitPort);
}
},
);
});
Loading
Loading