diff --git a/.changeset/dev-server-6-plugin.md b/.changeset/dev-server-6-plugin.md new file mode 100644 index 00000000000..082a1bd9661 --- /dev/null +++ b/.changeset/dev-server-6-plugin.md @@ -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. 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/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 55e86a0c5a0..454d4b33cec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12544,7 +12544,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", - "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -22436,6 +22435,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", diff --git a/packages/webpack-cli/package.json b/packages/webpack-cli/package.json index 457588ec62a..8d6c3df7e66 100644 --- a/packages/webpack-cli/package.json +++ b/packages/webpack-cli/package.json @@ -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", diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 8a324a51054..1ad7b026741 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -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[] = []; - if (this.#needWatchStdin(compiler)) { + if (!isDevServerPlugin && this.#needWatchStdin(compiler)) { process.stdin.on("end", () => { Promise.all(servers.map((server) => server.stop())).then(() => { process.exit(0); @@ -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) { @@ -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); + } - servers.push(server as unknown as InstanceType); + appliedDevServers += 1; } catch (error) { if (this.isValidationError(error as Error)) { this.logger.error((error as Error).message); @@ -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: { @@ -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 { let compiler: Compiler | MultiCompiler; let stringifyChunked: typeof stringifyChunkedType; @@ -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", () => { 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..6da3dc3ee3d --- /dev/null +++ b/test/serve/automatic-ports/automatic-ports.test.js @@ -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); + } + }, + ); +}); 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/serve-error-handling.test.js b/test/serve/error-handling/serve-error-handling.test.js new file mode 100644 index 00000000000..ad9cf01c168 --- /dev/null +++ b/test/serve/error-handling/serve-error-handling.test.js @@ -0,0 +1,131 @@ +"use strict"; + +/* eslint-disable jest/require-top-level-describe -- Version-gated describe */ + +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 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 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/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, + }, +};