diff --git a/.changeset/dev-server-6-plugin.md b/.changeset/dev-server-6-plugin.md new file mode 100644 index 00000000000..bb1fc549ef0 --- /dev/null +++ b/.changeset/dev-server-6-plugin.md @@ -0,0 +1,7 @@ +--- +"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. + +In multi-compiler mode, every dev server serves all compilers by default. Use the new `--dev-server-per-compiler` option of `webpack serve` to serve each compiler only by the dev server from its own configuration. diff --git a/.prettierignore b/.prettierignore index d9e48ba2fd2..74f76e5d54c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 \ No newline at end of file diff --git a/SERVE-OPTIONS-v6.md b/SERVE-OPTIONS-v6.md index f05f4e2b931..306439e14a6 100644 --- a/SERVE-OPTIONS-v6.md +++ b/SERVE-OPTIONS-v6.md @@ -18,6 +18,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. diff --git a/eslint.config.mjs b/eslint.config.mjs index c4ad7725712..70ac3a81b39 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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], diff --git a/package-lock.json b/package-lock.json index 9860bab46e2..49a920cf6da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "typescript": "^6.0.3", "webpack": "^5.112.0", "webpack-bundle-analyzer": "^5.4.0", - "webpack-dev-server": "^6.0.0" + "webpack-dev-server": "^6.1.0" }, "engines": { "node": ">=20.9.0" diff --git a/package.json b/package.json index b664e31b830..f7ea0b1baa7 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "typescript": "^6.0.3", "webpack": "^5.112.0", "webpack-bundle-analyzer": "^5.4.0", - "webpack-dev-server": "^6.0.0" + "webpack-dev-server": "^6.1.0" }, "peerDependencies": { "webpack": "5.x.x" diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 971ce6b18e3..03cc7c66221 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -232,6 +232,16 @@ declare interface WebpackCallback { (err: null | Error, result?: MultiStats): void; } +interface MultiCompilerPlugin { + apply(compiler: MultiCompiler): void; +} + +// Changes the loaded configuration before the compiler is created, +// returns plugins which should be applied to the multi compiler +type ConfigureCompiler = ( + options: Configuration | MultiConfiguration, +) => PotentialPromise; + type ProcessedArguments = Parameters<(typeof webpack)["cli"]["processArguments"]>[2]; type Schema = Parameters<(typeof webpack)["cli"]["getArguments"]>[0]; @@ -2175,7 +2185,21 @@ class WebpackCLI { }, options: (cmd) => { const { webpack, devServer } = cmd.context; - const webpackOptions = this.schemaToOptions(webpack, undefined, this.#CLIOptions); + const webpackOptions = this.schemaToOptions(webpack, undefined, [ + ...this.#CLIOptions, + { + name: "dev-server-per-compiler", + configs: [ + { + type: "enum", + values: [true], + }, + ], + description: + "Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server.", + hidden: false, + }, + ]); // @ts-expect-error different versions of the `Schema` type const devServerOptions = this.schemaToOptions(webpack, devServer.schema, undefined, { hidden: false, @@ -2215,40 +2239,13 @@ class WebpackCLI { env: { WEBPACK_SERVE: true, ...options.env }, }; - const compiler = await this.createCompiler(webpackCLIOptions); - - if (!compiler) { - return; - } - - const DevServer: DevServerConstructor = cmd.context.devServer; - const servers: InstanceType[] = []; - - if (this.#needWatchStdin(compiler)) { - process.stdin.on("end", () => { - Promise.all(servers.map((server) => server.stop())).then(() => { - process.exit(0); - }); - }); - process.stdin.resume(); - } - - const compilers = this.isMultipleCompiler(compiler) ? compiler.compilers : [compiler]; - const possibleCompilers = compilers.filter((compiler) => compiler.options.devServer); - const compilersForDevServer = - possibleCompilers.length > 0 ? possibleCompilers : [compilers[0]]; - const usedPorts: number[] = []; + const DevServer: DevServerConstructor = devServer; // @ts-expect-error different versions of the `Schema` type const devServerArgs = this.#getArguments(webpack, devServer.schema); - - for (const compilerForDevServer of compilersForDevServer) { - if (compilerForDevServer.options.devServer === false) { - continue; - } - - const devServerConfiguration: DevServerConfiguration = - compilerForDevServer.options.devServer || {}; - + const getDevServerOptions = ( + devServerOptions: DevServerConfiguration | undefined, + ): DevServerConfiguration => { + const configuration: DevServerConfiguration = devServerOptions || {}; const args: Record = {}; const values: ProcessedArguments = {}; @@ -2267,11 +2264,15 @@ class WebpackCLI { } if (Object.keys(values).length > 0) { - this.#processArguments(webpack, args, devServerConfiguration, values); + this.#processArguments(webpack, args, configuration, values); } - if (devServerConfiguration.port) { - const portNumber = Number(devServerConfiguration.port); + return configuration; + }; + const usedPorts: number[] = []; + const validatePort = (port: DevServerConfiguration["port"]): void => { + if (port && port !== "auto") { + const portNumber = Number(port); if (usedPorts.includes(portNumber)) { throw new Error( @@ -2281,27 +2282,212 @@ class WebpackCLI { usedPorts.push(portNumber); } + }; - try { - const server = new DevServer(devServerConfiguration, compiler); + // TODO in the next major release, require `webpack-dev-server@6` and remove the `else` branch + if (typeof (DevServer.prototype as { apply?: unknown }).apply === "function") { + // TODO in the next major release, use `DevServer` directly, the cast is only + // needed for `webpack-dev-server@5` typings, which lack the plugin constructor + const DevServerPlugin = DevServer as unknown as new (options: DevServerConfiguration) => { + apply(compiler: Compiler | MultiCompiler): void; + listen(): Promise; + }; + const plugins: InstanceType[] = []; + + const compiler = await this.createCompiler( + webpackCLIOptions, + await this.#createWebpackCallback(webpackCLIOptions), + async (configOptions) => { + const configurations = this.isMultipleConfiguration(configOptions) + ? configOptions + : [configOptions]; + const isDevServerPerCompiler = + configurations.length === 1 || Boolean(options.devServerPerCompiler); + // Configurations with `devServer`, otherwise the first one + const devServers = ( + configurations.some((configuration) => configuration.devServer) + ? configurations.filter((configuration) => configuration.devServer) + : [configurations[0]] + ) + .filter((configuration) => (configuration.devServer as unknown) !== false) + .map((configuration) => { + const devServerOptions = getDevServerOptions( + configuration.devServer as DevServerConfiguration, + ); - await server.start(); + // Validate all explicit ports before assigning automatic ones + validatePort(devServerOptions.port); - servers.push(server as unknown as InstanceType); - } catch (error) { - if (this.isValidationError(error as Error)) { - this.logger.error((error as Error).message); - } else { - this.logger.error(error); + return { configuration, devServerOptions }; + }); + + if (devServers.length === 0) { + this.logger.error("No dev server configurations to run"); + process.exit(2); + } + + const basePort = process.env.WEBPACK_DEV_SERVER_BASE_PORT; + let nextBasePort = Number.parseInt(basePort ?? "8080", 10); + // Plugins which should be applied to the multi compiler + const multiCompilerPlugins: InstanceType[] = []; + + for (const { configuration, devServerOptions } of devServers) { + // Servers select ports before any of them listens, so they can't do it themselves + if ( + devServers.length > 1 && + !devServerOptions.ipc && + (typeof devServerOptions.port === "undefined" || devServerOptions.port === "auto") + ) { + const host = await DevServer.getHostname(devServerOptions.host as string); + + // The dev server searches for a free port from the base one + do { + process.env.WEBPACK_DEV_SERVER_BASE_PORT = String(nextBasePort); + devServerOptions.port = Number(await DevServer.getFreePort("auto", host)); + nextBasePort = devServerOptions.port + 1; + } while (usedPorts.includes(devServerOptions.port)); + + usedPorts.push(devServerOptions.port); + } + + // By default, every dev server serves all compilers, the same as dev servers + // without plugin support do. The CLI handles signals and closes the compiler. + const plugin = new DevServerPlugin({ + ...devServerOptions, + setupExitSignals: false, + }); + + plugins.push(plugin); + + if (isDevServerPerCompiler) { + configuration.plugins = [...(configuration.plugins || []), plugin]; + } else { + multiCompilerPlugins.push(plugin); + } + } + + if (typeof basePort === "undefined") { + delete process.env.WEBPACK_DEV_SERVER_BASE_PORT; + } else { + process.env.WEBPACK_DEV_SERVER_BASE_PORT = basePort; + } + + for (const configuration of configurations) { + // The CLI drives compilation like the `watch` command + configuration.watch = true; + + // The middleware leaves printing stats to the CLI, the first dev server wins + const middlewareStats = ( + isDevServerPerCompiler + ? devServers.find((devServer) => devServer.configuration === configuration) + : devServers[0] + )?.devServerOptions.devMiddleware?.stats; + + if (typeof middlewareStats !== "undefined") { + configuration.stats = middlewareStats; + this.#setupStats(configuration); + } + } + + return multiCompilerPlugins; + }, + ); + + if (!compiler) { + return; + } + + // TODO remove it when webpack-dev-server reports listen failures to the compiler + // A dev server only logs a failure to listen, e.g. a port already in use, and the + // CLI would keep watching without it, so exit like dev servers without plugin + // support do + for (const plugin of plugins) { + const { listen } = plugin; + + plugin.listen = () => + listen.call(plugin).catch((error) => { + process.exitCode = 2; + + compiler.close(() => { + process.exit(); + }); + + throw error; + }); + } + + // Closing the compiler stops dev servers, failures keep the exit code + this.#setupGracefulShutdown(compiler, true); + + if (this.#needWatchStdin(compiler)) { + process.stdin.on("end", () => { + compiler.close(() => { + process.exit(); + }); + }); + process.stdin.resume(); + } + } else { + const compiler = await this.createCompiler(webpackCLIOptions); + + if (!compiler) { + return; + } + + if (options.devServerPerCompiler) { + this.logger.warn( + "The '--dev-server-per-compiler' option requires webpack-dev-server with plugin support (v6 or later), all compilers will be served by every dev server.", + ); + } + + const servers: InstanceType[] = []; + + if (this.#needWatchStdin(compiler)) { + process.stdin.on("end", () => { + Promise.all(servers.map((server) => server.stop())).then(() => { + process.exit(0); + }); + }); + process.stdin.resume(); + } + + const compilers = this.isMultipleCompiler(compiler) ? compiler.compilers : [compiler]; + const possibleCompilers = compilers.filter((compiler) => compiler.options.devServer); + const compilersForDevServer = + possibleCompilers.length > 0 ? possibleCompilers : [compilers[0]]; + + for (const compilerForDevServer of compilersForDevServer) { + if (compilerForDevServer.options.devServer === false) { + continue; } - process.exit(2); + const devServerConfiguration = getDevServerOptions( + compilerForDevServer.options.devServer, + ); + + validatePort(devServerConfiguration.port); + + try { + const server = new DevServer(devServerConfiguration, compiler); + + await server.start(); + + servers.push(server as unknown as InstanceType); + } catch (error) { + if (this.isValidationError(error as Error)) { + this.logger.error((error as Error).message); + } else { + this.logger.error(error); + } + + process.exit(2); + } } - } - if (servers.length === 0) { - this.logger.error("No dev server configurations to run"); - process.exit(2); + if (servers.length === 0) { + this.logger.error("No dev server configurations to run"); + process.exit(2); + } } }, }, @@ -3582,33 +3768,7 @@ class WebpackCLI { configuration.mode = process.env.NODE_ENV; } - // Setup stats - if (typeof configuration.stats === "undefined") { - configuration.stats = { preset: "normal" }; - } else if (typeof configuration.stats === "boolean") { - configuration.stats = configuration.stats ? { preset: "normal" } : { preset: "none" }; - } else if (typeof configuration.stats === "string") { - configuration.stats = { preset: configuration.stats }; - } - - let colors; - - // From arguments - if (typeof this.#isColorSupportChanged !== "undefined") { - colors = Boolean(this.#isColorSupportChanged); - } - // From stats - else if (typeof configuration.stats.colors !== "undefined") { - colors = configuration.stats.colors; - } - // Default - else { - colors = Boolean(this.colors.isColorSupported); - } - - if (Object.isExtensible(configuration.stats)) { - configuration.stats.colors = colors; - } + this.#setupStats(configuration); // Apply CLI plugin if (!configuration.plugins) { @@ -3639,9 +3799,39 @@ class WebpackCLI { return config; } + #setupStats(configuration: Configuration): void { + if (typeof configuration.stats === "undefined") { + configuration.stats = { preset: "normal" }; + } else if (typeof configuration.stats === "boolean") { + configuration.stats = configuration.stats ? { preset: "normal" } : { preset: "none" }; + } else if (typeof configuration.stats === "string") { + configuration.stats = { preset: configuration.stats }; + } + + let colors; + + // From arguments + if (typeof this.#isColorSupportChanged !== "undefined") { + colors = Boolean(this.#isColorSupportChanged); + } + // From stats + else if (typeof configuration.stats.colors !== "undefined") { + colors = configuration.stats.colors; + } + // Default + else { + colors = Boolean(this.colors.isColorSupported); + } + + if (Object.isExtensible(configuration.stats)) { + configuration.stats.colors = colors; + } + } + async createCompiler( options: Options, callback?: WebpackCallback, + configure?: ConfigureCompiler, ): Promise { const { webpack } = options; @@ -3650,6 +3840,12 @@ class WebpackCLI { } const config = await this.loadConfig(options); + + const multiCompilerPlugins = (await configure?.(config.options)) || []; + const [major, minor] = webpack.version.split(".").map(Number); + // TODO in the next major release, require `webpack@^5.112.0` and remove it + const isMultiCompilerPluginsSupported = major > 5 || minor >= 112; + let compiler: Compiler | MultiCompiler; const webpackCallback = callback @@ -3666,9 +3862,31 @@ class WebpackCLI { try { // Branch on the configuration shape so each call matches one `webpack()` overload if (this.isMultipleConfiguration(config.options)) { - compiler = webpackCallback - ? webpack(config.options, webpackCallback)! - : webpack(config.options); + if (multiCompilerPlugins.length > 0 && !isMultiCompilerPluginsSupported) { + // TODO in the next major release, remove it, older versions of webpack don't apply + // `plugins` of multi configurations, so apply them before the compilation starts + for (const options of config.options) { + options.watch = false; + } + + compiler = webpack(config.options); + + for (const plugin of multiCompilerPlugins) { + plugin.apply(compiler); + } + + if (webpackCallback) { + compiler.watch( + config.options.map((options) => options.watchOptions || {}), + webpackCallback, + ); + } + } else { + config.options.plugins = [...(config.options.plugins || []), ...multiCompilerPlugins]; + compiler = webpackCallback + ? webpack(config.options, webpackCallback)! + : webpack(config.options); + } } else { compiler = webpackCallback ? webpack(config.options, webpackCallback)! @@ -3697,8 +3915,40 @@ class WebpackCLI { return Boolean(compiler.options.watchOptions?.stdin); } - async runWebpack(options: Options, isWatchCommand: boolean): Promise { - let compiler: Compiler | MultiCompiler; + #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); + } + } + + #isMultiStats(stats: Stats | MultiStats): stats is MultiStats { + return "stats" in stats; + } + + async #createWebpackCallback(options: Options): Promise { let stringifyChunked: typeof stringifyChunkedType; let Readable: typeof ReadableType; @@ -3707,7 +3957,10 @@ class WebpackCLI { ({ Readable } = await import("node:stream")); } - const callback: WebpackCallback = (error, stats): void => { + // Whether the failure exit code comes from a previous build, so a successful rebuild resets it + let isFailedBuild = false; + + return (error, stats): void => { if (error) { this.logger.error(error); process.exit(2); @@ -3715,17 +3968,21 @@ class WebpackCLI { if (stats && (stats.hasErrors() || (options.failOnWarnings && stats.hasWarnings()))) { process.exitCode = 1; + isFailedBuild = true; + } else if (stats && isFailedBuild) { + process.exitCode = undefined; + isFailedBuild = false; } - if (!compiler || !stats) { + if (!stats) { return; } - const statsOptions = this.isMultipleCompiler(compiler) + const statsOptions = this.#isMultiStats(stats) ? { - children: compiler.compilers.map((compiler) => compiler.options.stats), + children: stats.stats.map((stats) => stats.compilation.compiler.options.stats), } - : compiler.options.stats; + : stats.compilation.compiler.options.stats; if (options.json) { const handleWriteError = (error: Error) => { @@ -3762,6 +4019,10 @@ class WebpackCLI { } } }; + } + + async runWebpack(options: Options, isWatchCommand: boolean): Promise { + const callback = await this.#createWebpackCallback(options); const env = isWatchCommand || options.watch @@ -3775,7 +4036,7 @@ class WebpackCLI { options.isWatchingLikeCommand = true; } - compiler = await this.createCompiler(options, callback); + const compiler = await this.createCompiler(options, callback); if (!compiler) { return; @@ -3794,32 +4055,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", () => { diff --git a/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 b/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 index e64cdf6d4b0..8a7700c845e 100644 --- a/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 +++ b/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 @@ -1048,6 +1048,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1170,6 +1171,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -1216,6 +1218,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1338,6 +1341,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1460,6 +1464,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1582,6 +1587,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1704,6 +1710,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -1750,6 +1757,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1872,6 +1880,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1994,6 +2003,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -2040,6 +2050,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. diff --git a/test/help/__snapshots__/help.test.js.snap.devServer6.webpack5 b/test/help/__snapshots__/help.test.js.snap.devServer6.webpack5 index 72507ea3f5c..91f77f7c6dd 100644 --- a/test/help/__snapshots__/help.test.js.snap.devServer6.webpack5 +++ b/test/help/__snapshots__/help.test.js.snap.devServer6.webpack5 @@ -1048,6 +1048,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1170,6 +1171,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -1216,6 +1218,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1338,6 +1341,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1460,6 +1464,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1582,6 +1587,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1704,6 +1710,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -1750,6 +1757,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1872,6 +1880,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. @@ -1994,6 +2003,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. --no-amd Negative 'amd' option. --bail Report the first error as a hard error instead of tolerating it. --no-bail Negative 'bail' option. @@ -2040,6 +2050,7 @@ Options -j, --json [pathToJsonFile] Prints result as JSON or store it in a file. --fail-on-warnings Stop webpack-cli process with non-zero exit code on warnings from webpack. --disable-interpret Disable interpret for loading the config file. + --dev-server-per-compiler Serve each compiler only by the dev server from its own configuration in multi-compiler mode, instead of serving all compilers by every dev server. -d, --devtool A developer tool to enhance debugging (false | eval | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map). --no-devtool Negative 'devtool' option. --entry A module that is loaded upon startup. Only the last one is exported. diff --git a/test/serve/automatic-ports/automatic-ports.test.js b/test/serve/automatic-ports/automatic-ports.test.js new file mode 100644 index 00000000000..fe5c2ead9c7 --- /dev/null +++ b/test/serve/automatic-ports/automatic-ports.test.js @@ -0,0 +1,91 @@ +/* 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"); + +/** + * 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 describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; + +describeDevServer6("automatic dev server ports", () => { + let occupied; + let basePort; + + beforeEach(async () => { + occupied = net.createServer(); + basePort = await getFreePort(); + 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 getFreePort(); + 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; + const stop = () => { + stopping = true; + proc.stdin.end(); + }; + + proc.stdout.on("data", (chunk) => { + output += chunk.toString(); + + if (!stopping && [...output.matchAll(/Listening \d: \d+\n/g)].length === 2) { + stop(); + } + }); + // Fail fast when a server can't listen instead of waiting for the test timeout + proc.stderr.on("data", (chunk) => { + if (!stopping && chunk.toString().includes("EADDRINUSE")) { + stop(); + } + }); + }, + }); + + 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); + } + }, + ); +}); diff --git a/test/serve/automatic-ports/webpack.config.js b/test/serve/automatic-ports/webpack.config.js new file mode 100644 index 00000000000..20a0cdacc14 --- /dev/null +++ b/test/serve/automatic-ports/webpack.config.js @@ -0,0 +1,18 @@ +module.exports = (env) => + [0, 1].map((index) => ({ + name: `server-${index}`, + mode: "development", + entry: "../rebuild/src/index.js", + output: { filename: `server-${index}.js` }, + devServer: { + host: "127.0.0.1", + static: false, + hot: false, + client: false, + ...(env.explicit && index === 1 ? { port: Number(env.explicit) } : {}), + ...(env.auto ? { port: "auto" } : {}), + onListening(server) { + console.log(`Listening ${index}: ${server.server.address().port}`); + }, + }, + })); diff --git a/test/serve/basic/__snapshots__/serve-basic.test.js.snap.devServer6.webpack5 b/test/serve/basic/__snapshots__/serve-basic.test.js.snap.devServer6.webpack5 index fcfe51b0fb8..a2909ebf0e4 100644 --- a/test/serve/basic/__snapshots__/serve-basic.test.js.snap.devServer6.webpack5 +++ b/test/serve/basic/__snapshots__/serve-basic.test.js.snap.devServer6.webpack5 @@ -66,12 +66,7 @@ exports[`basic serve usage should respect the "publicPath" option from configura `; exports[`basic serve usage should throw error when same ports in multicompiler: stderr 1`] = ` -" [webpack-dev-server] Project is running at: - [webpack-dev-server] Loopback: http://localhost:/, http://[::1]:/ - [webpack-dev-server] On Your Network (IPv4): http://x.x.x.x:/ - [webpack-dev-server] On Your Network (IPv6): http://[x:x:x:x:x:x:x:x]:/ - [webpack-dev-server] Content not from webpack is served from '/test/serve/basic/public' directory -[webpack-cli] ✖ 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. +"[webpack-cli] ✖ 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. at stack" `; diff --git a/test/serve/error-handling/bad-middleware.config.js b/test/serve/error-handling/bad-middleware.config.js new file mode 100644 index 00000000000..0a4e1d04d51 --- /dev/null +++ b/test/serve/error-handling/bad-middleware.config.js @@ -0,0 +1,7 @@ +module.exports = { + mode: "development", + entry: "./src/index.js", + devServer: { + devMiddleware: { unknownOption: true }, + }, +}; diff --git a/test/serve/error-handling/error.config.js b/test/serve/error-handling/error.config.js new file mode 100644 index 00000000000..99c695a55a1 --- /dev/null +++ b/test/serve/error-handling/error.config.js @@ -0,0 +1,4 @@ +module.exports = { + mode: "development", + entry: "./src/syntax-error.js", +}; diff --git a/test/serve/error-handling/recover.config.js b/test/serve/error-handling/recover.config.js new file mode 100644 index 00000000000..55243346f34 --- /dev/null +++ b/test/serve/error-handling/recover.config.js @@ -0,0 +1,4 @@ +module.exports = (env) => ({ + mode: "development", + entry: env.entry, +}); diff --git a/test/serve/error-handling/serve-error-handling.test.js b/test/serve/error-handling/serve-error-handling.test.js new file mode 100644 index 00000000000..1703b626a35 --- /dev/null +++ b/test/serve/error-handling/serve-error-handling.test.js @@ -0,0 +1,216 @@ +"use strict"; + +/* eslint-disable jest/require-top-level-describe -- Version-gated describe */ + +const fs = require("node:fs"); +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"); + +// Only plugin mode uses the CLI's watch callback. +const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; + +describeDevServer6("serve error handling", () => { + let port; + + beforeEach(async () => { + port = await (await getGetPort()).default(); + }); + + test("should print the stats with errors and keep serving when the compilation fails", async () => { + const { exitCode, stderr, stdout } = await runWatch( + __dirname, + ["serve", "--config", "error.config.js", "--port", port], + { + stdoutKillStr: /ERROR/, + stderrKillStr: /Project is running at:/, + }, + ); + + expect(stdout).toContain("ERROR in"); + expect(stderr).toContain("Project is running at:"); + expect(exitCode).toBe(1); + }); + + test("should reset the failure exit code after a successful rebuild", async () => { + // The real path, on macOS the temporary directory is behind a symlink the watcher can miss + const directory = fs.mkdtempSync( + path.join(fs.realpathSync(os.tmpdir()), "webpack-cli-serve-recover-"), + ); + const entry = path.join(directory, "index.js"); + + fs.writeFileSync(entry, "const broken = {;"); + + try { + const { exitCode, stdout } = await runWatch( + __dirname, + [ + "serve", + "--config", + "recover.config.js", + "--env", + `entry=${entry}`, + "--watch-options-stdin", + "--port", + port, + ], + { + handler: (proc) => { + let output = ""; + let fixing; + + proc.stdout.on("data", (chunk) => { + output += chunk.toString(); + + if (!fixing && output.includes("ERROR in")) { + // Rewrite until the watcher picks the fix up, it can start after the first write + const fix = () => fs.writeFileSync(entry, `console.log(${Date.now()});`); + + fix(); + fixing = setInterval(fix, 500); + } else if (fixing && output.includes("compiled successfully")) { + clearInterval(fixing); + proc.stdin.end(); + } + }); + proc.on("exit", () => clearInterval(fixing)); + }, + }, + ); + + expect(stdout).toContain("ERROR in"); + expect(stdout).toContain("compiled successfully"); + expect(exitCode).toBe(0); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + test("should print the stats with warnings using the '--fail-on-warnings' option", async () => { + const { exitCode, stderr, stdout } = await runWatch( + __dirname, + ["serve", "--config", "warning.config.js", "--fail-on-warnings", "--port", port], + { + stdoutKillStr: /WARNING/, + stderrKillStr: /Project is running at:/, + }, + ); + + expect(stdout).toContain("WARNING"); + expect(stderr).toContain("Project is running at:"); + expect(exitCode).toBe(1); + }); + + test.each(process.platform === "win32" ? ["stdin"] : ["SIGINT", "SIGTERM", "stdin"])( + "should preserve failure status and close the compiler on %s", + async (trigger) => { + const { exitCode, stdout, stderr } = await runWatch( + __dirname, + [ + "serve", + "--config", + "shutdown.config.js", + "--fail-on-warnings", + "--watch-options-stdin", + "--port", + port, + ], + { + handler: (proc) => { + let output = ""; + let serverOutput = ""; + let stopping = false; + const stop = () => { + if ( + stopping || + !output.includes("WARNING") || + !serverOutput.includes("Server ready") + ) { + return; + } + + stopping = true; + + if (trigger === "stdin") { + proc.stdin.end(); + } else { + proc.kill(trigger); + } + }; + + proc.stdout.on("data", (chunk) => { + output += chunk.toString(); + stop(); + }); + proc.stderr.on("data", (chunk) => { + serverOutput += chunk.toString(); + stop(); + }); + }, + }, + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Server ready: 1 SIGINT, 1 SIGTERM"); + expect(stdout.match(/Compiler shutdown/g)).toHaveLength(1); + }, + ); + + test("should log the error and exit when the dev server fails inside the watch run", async () => { + const { exitCode, stderr, stdout } = await run(__dirname, [ + "serve", + "--config", + "setup-failure.config.js", + ]); + + expect(exitCode).toBe(2); + expect(stderr).toContain("Injected middleware failure"); + expect(stdout).toBeFalsy(); + }); + + test("should log the error and exit when the port is already in use", async () => { + const server = net.createServer(); + + await new Promise((resolve) => { + server.listen(port, "127.0.0.1", resolve); + }); + + try { + const { exitCode, stderr } = await run(__dirname, [ + "serve", + "--config", + "recover.config.js", + "--env", + `entry=${path.resolve(__dirname, "src/index.js")}`, + "--host", + "127.0.0.1", + "--port", + port, + ]); + + expect(exitCode).toBe(2); + expect(stderr).toContain("EADDRINUSE"); + } finally { + server.close(); + } + }); + + test("should log the validation error and exit when a middleware option is invalid", async () => { + const { exitCode, stderr, stdout } = await run(__dirname, [ + "serve", + "--config", + "bad-middleware.config.js", + ]); + + expect(exitCode).toBe(2); + expect(stderr).toContain( + "Dev Middleware has been initialized using an options object that does not match the API schema", + ); + expect(stderr).not.toContain("at Server.setupMiddlewares"); + expect(stdout).toBeFalsy(); + }); +}); diff --git a/test/serve/error-handling/setup-failure.config.js b/test/serve/error-handling/setup-failure.config.js new file mode 100644 index 00000000000..fbeccb27712 --- /dev/null +++ b/test/serve/error-handling/setup-failure.config.js @@ -0,0 +1,9 @@ +module.exports = { + mode: "development", + entry: "./src/index.js", + devServer: { + setupMiddlewares: () => { + throw new Error("Injected middleware failure"); + }, + }, +}; diff --git a/test/serve/error-handling/shutdown.config.js b/test/serve/error-handling/shutdown.config.js new file mode 100644 index 00000000000..4eff6baf7a1 --- /dev/null +++ b/test/serve/error-handling/shutdown.config.js @@ -0,0 +1,22 @@ +const configuration = require("./warning.config"); + +module.exports = { + ...configuration, + devServer: { + setupExitSignals: true, + onListening: () => { + console.error( + `Server ready: ${process.listenerCount("SIGINT")} SIGINT, ${process.listenerCount("SIGTERM")} SIGTERM`, + ); + }, + }, + plugins: [ + { + apply(compiler) { + compiler.hooks.shutdown.tap("ShutdownTest", () => { + console.log("Compiler shutdown"); + }); + }, + }, + ], +}; diff --git a/test/serve/error-handling/src/index.js b/test/serve/error-handling/src/index.js new file mode 100644 index 00000000000..4f252c00368 --- /dev/null +++ b/test/serve/error-handling/src/index.js @@ -0,0 +1 @@ +console.log("ok"); diff --git a/test/serve/error-handling/src/syntax-error.js b/test/serve/error-handling/src/syntax-error.js new file mode 100644 index 00000000000..3e3ce78ef41 --- /dev/null +++ b/test/serve/error-handling/src/syntax-error.js @@ -0,0 +1 @@ +const broken = {; diff --git a/test/serve/error-handling/warning.config.js b/test/serve/error-handling/warning.config.js new file mode 100644 index 00000000000..3af9c0e5835 --- /dev/null +++ b/test/serve/error-handling/warning.config.js @@ -0,0 +1,9 @@ +module.exports = { + mode: "development", + entry: "./src/index.js", + performance: { + hints: "warning", + maxAssetSize: 1, + maxEntrypointSize: 1, + }, +}; diff --git a/test/serve/middleware-stats/middleware-stats.test.js b/test/serve/middleware-stats/middleware-stats.test.js new file mode 100644 index 00000000000..ebe6192697c --- /dev/null +++ b/test/serve/middleware-stats/middleware-stats.test.js @@ -0,0 +1,45 @@ +const { runWatch } = require("../../utils/test-utils"); + +describe.each([false, true])("middleware stats (multi: %s)", (multi) => { + it.each(["none", "false", "true", "assets", "fallback"])( + "should respect %s stats", + async (kind) => { + const args = ["serve", "--watch-options-stdin", "--env", `kind=${kind}`]; + if (multi) { + args.push("--env", "multi=true"); + } + const { exitCode, stdout } = await runWatch(__dirname, args, { + handler: (proc) => { + let output = ""; + let stopping = false; + proc.stderr.on("data", (chunk) => { + output += chunk.toString(); + if ( + !stopping && + output.includes("Server ready") && + output.includes("Built first") && + (!multi || output.includes("Built second")) + ) { + stopping = true; + proc.stdin.end(); + } + }); + }, + }); + expect(exitCode).toBe(0); + if (kind === "none" || kind === "false" || kind === "fallback") { + expect(stdout).toBe(""); + } else { + expect(stdout).toContain("first.js"); + if (multi) { + expect(stdout).toContain("second.js"); + } + if (kind === "assets") { + expect(stdout).not.toContain("compiled successfully"); + } else { + expect(stdout).toContain("compiled successfully"); + } + } + }, + ); +}); diff --git a/test/serve/middleware-stats/webpack.config.js b/test/serve/middleware-stats/webpack.config.js new file mode 100644 index 00000000000..91708b0a1bc --- /dev/null +++ b/test/serve/middleware-stats/webpack.config.js @@ -0,0 +1,37 @@ +module.exports = (env) => { + const overrides = { + none: "none", + false: false, + true: true, + assets: { all: false, assets: true }, + }; + const makeConfig = (name) => ({ + name, + mode: "development", + entry: "../rebuild/src/index.js", + output: { filename: `${name}.js` }, + stats: env.kind === "none" || env.kind === "false" ? "normal" : "none", + plugins: [ + { + apply(compiler) { + compiler.hooks.afterDone.tap("StatsTest", () => { + console.error(`Built ${name}`); + }); + }, + }, + ], + }); + const first = makeConfig("first"); + first.devServer = { + host: "127.0.0.1", + port: 0, + static: false, + client: false, + hot: false, + devMiddleware: { stats: overrides[env.kind] }, + onListening() { + console.error("Server ready"); + }, + }; + return env.multi ? [first, makeConfig("second")] : first; +}; diff --git a/test/serve/per-compiler/old-webpack.js b/test/serve/per-compiler/old-webpack.js new file mode 100644 index 00000000000..db576497ede --- /dev/null +++ b/test/serve/per-compiler/old-webpack.js @@ -0,0 +1,8 @@ +"use strict"; + +// Loaded via `WEBPACK_PACKAGE`: webpack before 5.112 doesn't apply `plugins` of multi configurations +const webpack = require("webpack"); + +module.exports = new Proxy(webpack, { + get: (target, property) => (property === "version" ? "5.111.0" : Reflect.get(target, property)), +}); diff --git a/test/serve/per-compiler/serve-per-compiler.test.js b/test/serve/per-compiler/serve-per-compiler.test.js new file mode 100644 index 00000000000..1cc72dbb041 --- /dev/null +++ b/test/serve/per-compiler/serve-per-compiler.test.js @@ -0,0 +1,138 @@ +"use strict"; + +/* eslint-disable jest/require-top-level-describe -- Version-gated describe */ + +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"); + +/** + * Starts `webpack serve` and requests every bundle from every dev server once they listen. + * @param {string[]} args extra CLI arguments + * @param {string=} config configuration file + * @param {Record=} env environment variables + * @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 names = config === "webpack.config.js" ? ["app", "admin"] : ["app"]; + const ports = { app: appPort, admin: adminPort }; + const responses = {}; + + const result = await runWatch( + __dirname, + [ + "serve", + "--config", + config, + "--watch-options-stdin", + "--env", + `appPort=${appPort}`, + "--env", + `adminPort=${adminPort}`, + ...args, + ], + { + env, + handler: (proc) => { + let output = ""; + let requesting = false; + + proc.stderr.on("data", (chunk) => { + output += chunk.toString(); + + if (requesting || !names.every((name) => output.includes(`Listening ${name}`))) { + return; + } + + requesting = true; + + Promise.all( + names.flatMap((server) => + names.map((bundle) => + fetch(`http://127.0.0.1:${ports[server]}/${bundle}.js`).then((response) => { + responses[`${server}:${bundle}`] = response.status; + }), + ), + ), + ).finally(() => { + proc.stdin.end(); + }); + }); + }, + }, + ); + + return { ...result, responses }; +}; + +const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; +const describeDevServer5 = devServerVersion === "5" ? describe : describe.skip; + +describeDevServer6("serve with a dev server per compiler", () => { + test("should serve all compilers by every dev server by default", async () => { + const { exitCode, responses, stdout } = await serve([]); + + expect(exitCode).toBe(0); + expect(responses).toEqual({ + "app:app": 200, + "app:admin": 200, + "admin:app": 200, + "admin:admin": 200, + }); + // Every dev server prints the stats of all compilers using its own options + expect(stdout.match(/app\.js/g)).toHaveLength(1); + expect(stdout.match(/admin\.js/g)).toHaveLength(1); + }); + + test("should serve all compilers by every dev server with webpack before 5.112", async () => { + const { exitCode, responses } = await serve([], "webpack.config.js", { + WEBPACK_PACKAGE: path.resolve(__dirname, "./old-webpack.js"), + }); + + expect(exitCode).toBe(0); + expect(responses).toEqual({ + "app:app": 200, + "app:admin": 200, + "admin:app": 200, + "admin:admin": 200, + }); + }); + + test("should serve each compiler only by its own dev server using the '--dev-server-per-compiler' option", async () => { + const { exitCode, responses, stdout } = await serve(["--dev-server-per-compiler"]); + + expect(exitCode).toBe(0); + expect(responses).toEqual({ + "app:app": 200, + "app:admin": 404, + "admin:app": 404, + "admin:admin": 200, + }); + // Each compiler is printed using the stats options of its own dev server + expect(stdout).toContain("app.js"); + expect(stdout).not.toContain("admin.js"); + }); + + test("should work with a single compiler using the '--dev-server-per-compiler' option", async () => { + const { exitCode, responses } = await serve(["--dev-server-per-compiler"], "single.config.js"); + + expect(exitCode).toBe(0); + expect(responses).toEqual({ "app:app": 200 }); + }); +}); + +describeDevServer5("serve with a dev server per compiler", () => { + test("should warn and ignore the '--dev-server-per-compiler' option without plugin support", async () => { + const { exitCode, stderr } = await serve(["--dev-server-per-compiler"]); + + expect(exitCode).toBe(0); + expect(stderr).toContain( + "The '--dev-server-per-compiler' option requires webpack-dev-server with plugin support", + ); + }); +}); diff --git a/test/serve/per-compiler/single.config.js b/test/serve/per-compiler/single.config.js new file mode 100644 index 00000000000..567ce86aade --- /dev/null +++ b/test/serve/per-compiler/single.config.js @@ -0,0 +1,16 @@ +module.exports = (env) => ({ + name: "app", + mode: "development", + entry: "./src/app.js", + output: { filename: "app.js" }, + devServer: { + host: "127.0.0.1", + port: Number(env.appPort), + static: false, + hot: false, + client: false, + onListening() { + console.error("Listening app"); + }, + }, +}); diff --git a/test/serve/per-compiler/src/admin.js b/test/serve/per-compiler/src/admin.js new file mode 100644 index 00000000000..358a8b581cb --- /dev/null +++ b/test/serve/per-compiler/src/admin.js @@ -0,0 +1 @@ +console.log("admin"); diff --git a/test/serve/per-compiler/src/app.js b/test/serve/per-compiler/src/app.js new file mode 100644 index 00000000000..702645f13de --- /dev/null +++ b/test/serve/per-compiler/src/app.js @@ -0,0 +1 @@ +console.log("app"); diff --git a/test/serve/per-compiler/webpack.config.js b/test/serve/per-compiler/webpack.config.js new file mode 100644 index 00000000000..c9d1c7b36ea --- /dev/null +++ b/test/serve/per-compiler/webpack.config.js @@ -0,0 +1,19 @@ +module.exports = (env) => + ["app", "admin"].map((name, index) => ({ + name, + mode: "development", + entry: `./src/${name}.js`, + output: { filename: `${name}.js` }, + stats: "none", + devServer: { + host: "127.0.0.1", + port: Number(env[`${name}Port`]), + static: false, + hot: false, + client: false, + devMiddleware: { stats: index === 0 ? { all: false, assets: true } : "none" }, + onListening() { + console.error(`Listening ${name}`); + }, + }, + })); diff --git a/test/serve/rebuild/multi.config.js b/test/serve/rebuild/multi.config.js new file mode 100644 index 00000000000..53b95145a7c --- /dev/null +++ b/test/serve/rebuild/multi.config.js @@ -0,0 +1,39 @@ +const plugins = [ + { + apply(compiler) { + compiler.hooks.watchRun.tap("RebuildTest", () => { + console.log(`Watch ${compiler.name}: ${compiler.watching.watchOptions.aggregateTimeout}`); + }); + compiler.hooks.afterDone.tap("RebuildTest", () => { + console.log(`Built ${compiler.name}`); + }); + compiler.hooks.shutdown.tap("RebuildTest", () => { + console.log(`Closed ${compiler.name}`); + }); + }, + }, +]; + +module.exports = [ + { + name: "app", + mode: "development", + entry: "./src/index.js", + plugins, + output: { filename: "app.js" }, + watchOptions: { + aggregateTimeout: 10, + }, + devServer: {}, + }, + { + name: "worker", + mode: "development", + entry: "./src/worker.js", + plugins, + output: { filename: "worker.js" }, + watchOptions: { + aggregateTimeout: 30, + }, + }, +]; diff --git a/test/serve/rebuild/serve-rebuild.test.js b/test/serve/rebuild/serve-rebuild.test.js new file mode 100644 index 00000000000..9f2021cc55a --- /dev/null +++ b/test/serve/rebuild/serve-rebuild.test.js @@ -0,0 +1,157 @@ +"use strict"; + +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 entryPath = resolve(__dirname, "./src/index.js"); +const originalEntry = readFileSync(entryPath, "utf8"); +const workerPath = resolve(__dirname, "./src/worker.js"); +const originalWorker = readFileSync(workerPath, "utf8"); + +describe("serve recompilation", () => { + let port; + + beforeEach(async () => { + port = await (await getGetPort()).default(); + }); + + afterEach(() => { + writeFileSync(entryPath, originalEntry); + writeFileSync(workerPath, originalWorker); + }); + + it("should recompile upon file change and log the stats again", async () => { + let compilations = 0; + + await runWatch(__dirname, ["serve", "--mode", "development", "--port", port], { + handler: (proc) => { + proc.stdout.on("data", (chunk) => { + const data = chunk.toString(); + + if (!data.includes("compiled successfully")) { + return; + } + + compilations += 1; + + if (compilations === 1) { + process.nextTick(() => { + writeFileSync(entryPath, originalEntry); + }); + } else { + processKill(proc); + } + }); + }, + }); + + expect(compilations).toBe(2); + }); + + it("should serve the updated bundle from memory after recompiling", async () => { + let compilations = 0; + let updatedBody; + + await runWatch(__dirname, ["serve", "--mode", "development", "--port", port], { + handler: (proc) => { + proc.stdout.on("data", (chunk) => { + const data = chunk.toString(); + + if (!data.includes("compiled successfully")) { + return; + } + + compilations += 1; + + if (compilations === 1) { + process.nextTick(() => { + writeFileSync(entryPath, "console.log('serve rebuild test updated');\n"); + }); + } else { + fetch(`http://127.0.0.1:${port}/main.js`) + .then((response) => response.text()) + .then((body) => { + updatedBody = body; + }) + .finally(() => { + processKill(proc); + }); + } + }); + }, + }); + + expect(updatedBody).toContain("serve rebuild test updated"); + }); + + it.each(["app", "worker"])( + "should rebuild and serve changes to the %s compiler", + async (name) => { + let updatedBody; + let requestError; + const { stdout } = await runWatch( + __dirname, + ["serve", "--config", "multi.config.js", "--watch-options-stdin", "--port", port], + { + handler: (proc) => { + let output = ""; + let serverOutput = ""; + let changed = false; + let fetching = false; + const check = () => { + if ( + !changed && + output.includes("Built app") && + output.includes("Built worker") && + serverOutput.includes("Project is running at:") + ) { + changed = true; + writeFileSync( + name === "app" ? entryPath : workerPath, + `console.log('updated ${name} bundle');\n`, + ); + } + + if (!fetching && output.split(`Built ${name}`).length === 3) { + fetching = true; + fetch(`http://127.0.0.1:${port}/${name}.js`) + .then((response) => response.text()) + .then((body) => { + updatedBody = body; + }) + .catch((error) => { + requestError = error; + }) + .finally(() => { + proc.stdin.end(); + }); + } + }; + + proc.stdout.on("data", (chunk) => { + output += chunk.toString(); + check(); + }); + proc.stderr.on("data", (chunk) => { + serverOutput += chunk.toString(); + check(); + }); + }, + }, + ); + + expect(requestError).toBeUndefined(); + expect(updatedBody).toContain(`updated ${name} bundle`); + expect(stdout).toContain("Watch app: 10"); + expect(stdout).toContain("Watch worker: 30"); + if (devServerVersion !== "5") { + expect(stdout.match(/Closed app/g)).toHaveLength(1); + expect(stdout.match(/Closed worker/g)).toHaveLength(1); + } + }, + ); +}); diff --git a/test/serve/rebuild/src/index.js b/test/serve/rebuild/src/index.js new file mode 100644 index 00000000000..fe97d3579ec --- /dev/null +++ b/test/serve/rebuild/src/index.js @@ -0,0 +1 @@ +console.log("serve rebuild test"); diff --git a/test/serve/rebuild/src/worker.js b/test/serve/rebuild/src/worker.js new file mode 100644 index 00000000000..adfd954e154 --- /dev/null +++ b/test/serve/rebuild/src/worker.js @@ -0,0 +1 @@ +console.log("serve worker test"); diff --git a/test/serve/rebuild/webpack.config.js b/test/serve/rebuild/webpack.config.js new file mode 100644 index 00000000000..d2163db454e --- /dev/null +++ b/test/serve/rebuild/webpack.config.js @@ -0,0 +1,7 @@ +module.exports = { + mode: "development", + entry: "./src/index.js", + watchOptions: { + aggregateTimeout: 10, + }, +};