diff --git a/.changeset/reuse-dev-middleware-client.md b/.changeset/reuse-dev-middleware-client.md new file mode 100644 index 0000000000..ca742f4d27 --- /dev/null +++ b/.changeset/reuse-dev-middleware-client.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": minor +--- + +The browser client, the error overlay and both transports now come from webpack-dev-middleware, so this package has no client code of its own. Every option keeps its name and meaning, `allowedHosts` and the origin checks still apply here, and this server's `{ type }` messages are still sent next to the middleware's, so React Refresh keeps working. `client/index.js`, `client/socket.js` and `client/clients/WebSocketClient.js` still resolve while the client's internal files are gone, and the console wording, the overlay and the progress indicator are now the middleware's. `client.webSocketTransport: "sse"` adds Server-Sent Events, a universal build now runs in Node without the client throwing, and `sendMessage()`, `getClientEntry()` and `getClientHotEntry()` still work, a subclass's overrides included, but are deprecated. diff --git a/.gitignore b/.gitignore index bf65d04489..866d495151 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ test/fixtures/reload-config/main.css test/fixtures/reload-config-2/main.css test/fixtures/worker-config-dev-server-false/public !/test/fixtures/static-config/public/node_modules +test/fixtures/compatibility-config/changing.cjs diff --git a/README.md b/README.md index 8fe8d14d5d..76f490fd9d 100644 --- a/README.md +++ b/README.md @@ -259,10 +259,13 @@ See [**the documentation**][docs-url] for more use cases and options. ## Browser Support -While `webpack-dev-server` transpiles the client (browser) scripts to an ES5 -state, the project only officially supports the _last two versions of major -browsers_. We simply don't have the resources to support every whacky -browser out there. +The browser runtime is [webpack-dev-middleware]'s, which ships as ES5 and uses +no built-in newer than ES5 beyond `Promise`, the transport in use and what hot +module replacement itself needs. As before, the project only officially +supports the _last two versions of major browsers_. We simply don't have the +resources to support every whacky browser out there. + +[webpack-dev-middleware]: https://github.com/webpack/webpack-dev-middleware#client-options If you find a bug with an obscure / old browser, we would actively welcome a Pull Request to resolve the bug. diff --git a/client-src/clients/WebSocketClient.js b/client-src/clients/WebSocketClient.js deleted file mode 100644 index b9d1711031..0000000000 --- a/client-src/clients/WebSocketClient.js +++ /dev/null @@ -1,42 +0,0 @@ -import { log } from "../utils/log.js"; - -/** @typedef {import("../index.js").EXPECTED_ANY} EXPECTED_ANY */ - -/** - * @implements {CommunicationClient} - */ -export default class WebSocketClient { - /** - * @param {string} url url to connect - */ - constructor(url) { - this.client = new WebSocket(url); - this.client.onerror = (error) => { - log.error(error); - }; - } - - /** - * @param {(...args: EXPECTED_ANY[]) => void} fn function - */ - onOpen(fn) { - this.client.onopen = fn; - } - - /** - * @param {(...args: EXPECTED_ANY[]) => void} fn function - */ - onClose(fn) { - this.client.onclose = fn; - } - - // call f with the message string as the first argument - /** - * @param {(...args: EXPECTED_ANY[]) => void} fn function - */ - onMessage(fn) { - this.client.onmessage = (err) => { - fn(err.data); - }; - } -} diff --git a/client-src/globals.d.ts b/client-src/globals.d.ts deleted file mode 100644 index bdf8a44f49..0000000000 --- a/client-src/globals.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -declare interface CommunicationClient { - onOpen(fn: (...args: any[]) => void): void; - onClose(fn: (...args: any[]) => void): void; - onMessage(fn: (...args: any[]) => void): void; -} - -declare interface CommunicationClientConstructor { - new (url: string): CommunicationClient; // Defines a constructor that takes a string and returns a GreeterInstance -} - -declare const __webpack_dev_server_client__: - | CommunicationClientConstructor - | { default: CommunicationClientConstructor } - | undefined; - -declare module "ansi-html-community" { - function ansiHtmlCommunity(str: string): string; - - namespace ansiHtmlCommunity { - function setColors(colors: Record): void; - } - - export default ansiHtmlCommunity; -} diff --git a/client-src/index.js b/client-src/index.js deleted file mode 100644 index 59aafb8036..0000000000 --- a/client-src/index.js +++ /dev/null @@ -1,832 +0,0 @@ -/* global __resourceQuery, __webpack_hash__ */ -// @ts-expect-error -import hotEmitter from "webpack/hot/emitter.js"; -// @ts-expect-error -import webpackHotLog from "webpack/hot/log.js"; -import { createOverlay, formatProblem } from "./overlay.js"; -import { defineProgressElement, isProgressSupported } from "./progress.js"; -import socket from "./socket.js"; -import { log, setLogLevel } from "./utils/log.js"; -import sendMessage from "./utils/sendMessage.js"; - -// eslint-disable-next-line jsdoc/reject-any-type -/** @typedef {any} EXPECTED_ANY */ - -/** - * @typedef {object} RawOverlayOptions - * @property {string=} warnings warnings - * @property {string=} errors errors - * @property {string=} runtimeErrors runtime errors - * @property {string=} trustedTypesPolicyName trusted types policy name - */ - -/** - * @typedef {object} OverlayOptions - * @property {(boolean | ((error: Error) => boolean))=} warnings warnings - * @property {(boolean | ((error: Error) => boolean))=} errors errors - * @property {(boolean | ((error: Error) => boolean))=} runtimeErrors runtime errors - * @property {string=} trustedTypesPolicyName trusted types policy name - */ - -/** @typedef {false | true | "none" | "error" | "warn" | "info" | "log" | "verbose"} LogLevel */ - -/** - * @typedef {object} Options - * @property {boolean} hot true when hot enabled, otherwise false - * @property {boolean} liveReload true when live reload enabled, otherwise false - * @property {boolean | "linear" | "circular"} progress progress display mode - * @property {boolean | OverlayOptions} overlay overlay options - * @property {LogLevel=} logging logging level - * @property {number=} reconnect count of allowed reconnection - */ - -/** - * @typedef {object} Status - * @property {boolean} isUnloading true when unloaded, otherwise false - * @property {string} currentHash current hash - * @property {string=} previousHash previous hash - */ - -/** - * @param {boolean | RawOverlayOptions | OverlayOptions} overlayOptions overlay options - */ -const decodeOverlayOptions = (overlayOptions) => { - if (typeof overlayOptions === "object") { - const requiredOptions = ["warnings", "errors", "runtimeErrors"]; - - for (let i = 0; i < requiredOptions.length; i++) { - const property = - /** @type {keyof Omit} */ - (requiredOptions[i]); - - if (typeof overlayOptions[property] === "string") { - const overlayFilterFunctionString = decodeURIComponent( - overlayOptions[property], - ); - - /** @type {OverlayOptions} */ - (overlayOptions)[property] = /** @type {(error: Error) => boolean} */ ( - // eslint-disable-next-line no-new-func - new Function( - "message", - `var callback = ${overlayFilterFunctionString} - return callback(message)`, - ) - ); - } - } - } -}; - -/** - * @type {Status} - */ -const status = { - isUnloading: false, - currentHash: __webpack_hash__, -}; - -/** - * @returns {string} current script source - */ -const getCurrentScriptSource = () => { - // `document.currentScript` is the most accurate way to find the current script, - // but is not supported in all browsers. - if (document.currentScript) { - return /** @type {string} */ (document.currentScript.getAttribute("src")); - } - - // Fallback to getting all scripts running in the document. - const scriptElements = document.scripts || []; - const scriptElementsWithSrc = Array.prototype.filter.call( - scriptElements, - (element) => element.getAttribute("src"), - ); - - if (scriptElementsWithSrc.length > 0) { - const currentScript = - scriptElementsWithSrc[scriptElementsWithSrc.length - 1]; - - return currentScript.getAttribute("src"); - } - - // Fail as there was no script to use. - throw new Error("[webpack-dev-server] Failed to get current script source."); -}; - -/** @typedef {{ hot?: string, ["live-reload"]?: string, progress?: string, reconnect?: string, logging?: LogLevel, overlay?: string, fromCurrentScript?: boolean }} AdditionalParsedURL */ -/** @typedef {Partial & AdditionalParsedURL} ParsedURL */ - -/** - * @param {string} resourceQuery resource query - * @returns {ParsedURL} parsed URL - */ -const parseURL = (resourceQuery) => { - /** @type {ParsedURL} */ - let result = {}; - - if (typeof resourceQuery === "string" && resourceQuery !== "") { - const searchParams = resourceQuery.slice(1).split("&"); - - for (let i = 0; i < searchParams.length; i++) { - const parameter = searchParams[i].replace(/\+/g, " "); - const separator = parameter.indexOf("="); - const key = separator === -1 ? parameter : parameter.slice(0, separator); - const value = separator === -1 ? "" : parameter.slice(separator + 1); - - try { - /** @type {EXPECTED_ANY} */ - (result)[decodeURIComponent(key)] = decodeURIComponent(value); - } catch { - // Ignore malformed percent escapes without preventing client startup. - } - } - } else { - // Else, get the url from the ', ); + // A middleware mounted through `setupMiddlewares` in place of this + // server's — a Hono app's `honoWrapper` — serves the files, and this + // server still keeps the hot endpoint, so every app connects. expect(consoleMessages.map((message) => message.text())).toEqual([ - "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", - "[HMR] Waiting for update signal from WDS...", "Hey.", + "[webpack-dev-server] connected", ]); expect(pageErrors).toHaveLength(0); }); diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index dc84603003..b88e44d148 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -306,8 +306,11 @@ describe("client option", () => { { title: 'as a path ("ws")', client: { + // The published path, which is webpack-dev-middleware's WebSocket + // client re-exported — a transport named by module rather than by + // the `"ws"` shorthand, which is what this option is for. webSocketTransport: fileURLToPath( - import.meta.resolve("../../client-src/clients/WebSocketClient.js"), + import.meta.resolve("../../client/clients/WebSocketClient.js"), ), }, webSocketServer: "ws", diff --git a/test/e2e/compatibility.test.js b/test/e2e/compatibility.test.js new file mode 100644 index 0000000000..ab5ca7843a --- /dev/null +++ b/test/e2e/compatibility.test.js @@ -0,0 +1,478 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { expect } from "expect"; +import webpack from "webpack"; +import Server from "../../lib/Server.js"; +import config from "../fixtures/compatibility-config/webpack.config.js"; +import runBrowser from "../helpers/run-browser.js"; +import waitFor from "../helpers/wait-for.js"; +import portsMap from "../ports-map.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const [port, ownSocketPort] = portsMap.compatibility; +const CHANGING = path.resolve( + __dirname, + "../fixtures/compatibility-config/changing.cjs", +); +const OVERLAY_ID = "webpack-dev-server-client-overlay"; +const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; + +/** + * Rewrite the module the fixture app requires, and so rebuild it. + * @param {string} content the module's source + */ +function writeChanging(content) { + fs.writeFileSync(CHANGING, content); +} + +/** + * @param {import("webpack").Compiler} compiler compiler + * @returns {boolean} whether `HotModuleReplacementPlugin` was applied to it + */ +function hasHmrPlugin(compiler) { + return compiler.hooks.compilation.taps.some( + (tap) => tap.name === "HotModuleReplacementPlugin", + ); +} + +/** + * Start a server over the fixture, open a page on it, and collect what the + * page logs. + * @param {Record} options dev server options + * @param {Record=} extra extra webpack configuration + * @returns {Promise<{ server: Server, compiler: import("webpack").Compiler, page: import("puppeteer").Page, browser: import("puppeteer").Browser, messages: string[], errors: Error[], stop: () => Promise }>} what is running + */ +async function open(options, extra = {}) { + const compiler = webpack({ ...config, ...extra }); + const server = new Server({ port, ...options }, compiler); + + await server.start(); + + const { page, browser } = await runBrowser(); + const messages = []; + const errors = []; + + page + .on("console", (message) => { + messages.push(message.text()); + }) + .on("pageerror", (error) => { + errors.push(error); + }); + + return { + server, + compiler, + page, + browser, + messages, + errors, + stop: async () => { + await browser.close(); + await server.stop(); + }, + }; +} + +// What worked against webpack-dev-server 6.0 and has to keep working now its +// client and its socket are webpack-dev-middleware's. +describe("compatibility", () => { + before(() => { + writeChanging("module.exports = 1;\n"); + }); + + after(() => { + fs.rmSync(CHANGING, { force: true }); + }); + + // `@pmmmwh/react-refresh-webpack-plugin` bundles `require( + // "webpack-dev-server/client/socket")` into every page by default, and + // reads this server's own messages off the connection it finds there. + it("lets React Refresh's overlay read the build from the socket", async () => { + const { page, server, stop } = await open({ port }); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + await page.waitForFunction(() => globalThis.socketReady === true, { + timeout: 30000, + }); + + writeChanging("this is not valid javascript {{{\n"); + + await page.waitForFunction( + () => + globalThis.legacyMessages.some( + (message) => message.type === "errors", + ), + { timeout: 30000 }, + ); + + const errors = await page.evaluate( + () => + globalThis.legacyMessages.find((message) => message.type === "errors") + .data, + ); + + // Webpack's own error objects, as the plugin formats them. + expect(errors[0].message).toContain("Module parse failed"); + + writeChanging("module.exports = 2;\n"); + + await page.waitForFunction( + () => + globalThis.legacyMessages.some((message) => message.type === "ok"), + { timeout: 30000 }, + ); + + expect(server.webSocketServer.clients.length).toBeGreaterThan(0); + } finally { + writeChanging("module.exports = 1;\n"); + await stop(); + } + }); + + it("still sends this server's messages as they were", async () => { + const { page, server, stop } = await open({ port }); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + await page.waitForFunction(() => globalThis.socketReady === true, { + timeout: 30000, + }); + + await new Promise((resolve) => { + server.invalidate(resolve); + }); + + await page.waitForFunction( + () => + globalThis.legacyMessages.some( + (message) => message.type === "still-ok", + ), + { timeout: 30000 }, + ); + + const types = await page.evaluate(() => + globalThis.legacyMessages.map((message) => message.type), + ); + + expect(types).toContain("invalid"); + expect(types).toContain("still-ok"); + } finally { + await stop(); + } + }); + + // `devServer.sendMessage(devServer.webSocketServer.clients, ...)`, as the + // changelog has documented since v4. + it("reloads the page on sendMessage's static-changed, and lists the clients", async () => { + const { page, server, stop } = await open({ port }); + const connections = []; + + server.webSocketServer.implementation.on("connection", (client) => { + connections.push(client); + }); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + await page.waitForFunction(() => globalThis.socketReady === true, { + timeout: 30000, + }); + await waitFor(() => server.webSocketServer.clients.length > 0); + await page.evaluate(() => { + globalThis.notReloaded = true; + }); + + server.sendMessage(server.webSocketServer.clients, "static-changed"); + + await page.waitForFunction(() => globalThis.notReloaded === undefined, { + timeout: 30000, + }); + + expect(connections.length).toBeGreaterThan(0); + } finally { + await stop(); + } + }); + + it("shows the overlay under this server's Trusted Types policy name", async () => { + writeChanging("broken for trusted types {{{\n"); + + const { page, errors, stop } = await open({ + port, + headers: { + "Content-Security-Policy": + "require-trusted-types-for 'script'; trusted-types webpack-dev-server#overlay", + }, + }); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + await page.waitForSelector(`#${OVERLAY_ID}`, { timeout: 30000 }); + + expect(errors.map((error) => error.message).join("\n")).not.toContain( + "Trusted Type", + ); + } finally { + writeChanging("module.exports = 1;\n"); + await stop(); + } + }); + + it("hands an overlay filter a problem with a message, as it always was", async () => { + writeChanging("FILTERED-OUT {{{\n"); + + const { page, messages, stop } = await open({ + port, + client: { + overlay: { + errors: (error) => !error.message.includes("FILTERED-OUT"), + // A module that does not parse also throws when it runs, which is + // the other overlay; this is about the build's. + runtimeErrors: false, + }, + }, + }); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + await waitFor(() => + messages.some((text) => text.includes("FILTERED-OUT")), + ); + + // Logged, and left out of the overlay by the filter. + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + + writeChanging("shown in the overlay {{{\n"); + + await page.waitForSelector(`#${OVERLAY_ID}`, { timeout: 30000 }); + } finally { + writeChanging("module.exports = 1;\n"); + await stop(); + } + }); + + it("shows no building indicator unless client.progress asks for one", async () => { + const { page, stop } = await open({ port }); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + await page.waitForFunction(() => globalThis.socketReady === true, { + timeout: 30000, + }); + await page.evaluate((id) => { + globalThis.indicatorSeen = false; + new MutationObserver(() => { + if (document.querySelector(`#${id}`)) { + globalThis.indicatorSeen = true; + } + }).observe(document.body, { childList: true, subtree: true }); + }, INDICATOR_ID); + + writeChanging("module.exports = 3;\n"); + + await page.waitForFunction( + () => + globalThis.legacyMessages.some((message) => message.type === "ok"), + { timeout: 30000 }, + ); + + expect(await page.evaluate(() => globalThis.indicatorSeen)).toBe(false); + } finally { + writeChanging("module.exports = 1;\n"); + await stop(); + } + }); + + it("connects a client entry written by hand, bare or with the old query", async () => { + const query = new URLSearchParams({ + protocol: "ws:", + hostname: "0.0.0.0", + port: String(port), + pathname: "/ws", + logging: "info", + reconnect: "10", + hot: "true", + "live-reload": "true", + }); + + for (const entry of [ + "webpack-dev-server/client/index.js", + `webpack-dev-server/client/index.js?${query}`, + ]) { + const { page, messages, stop } = await open( + { port, client: false }, + { entry: [entry, "./app.js"] }, + ); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + await waitFor(() => + messages.some((text) => text.includes("] connected")), + ); + + // Labelled as this server's, unless the query names a `logging` of its + // own, which replaces the label along with the level. + if (!entry.includes("?")) { + expect( + messages.find((text) => text.includes("] connected")), + ).toContain("[webpack-dev-server]"); + } + } finally { + await stop(); + } + } + }); + + it("uses a client transport of someone else's in place of the built-in one", async () => { + const { page, messages, stop } = await open({ + port, + client: { + webSocketTransport: + require.resolve("../fixtures/custom-client/CustomWebSocketClient.js"), + }, + }); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + + // The fixture logs "open" from its own `onOpen`. + await waitFor(() => messages.includes("open")); + + expect(messages).toContain("open"); + } finally { + await stop(); + } + }); + + it("hands webSocketServer.options to the ws server, a port of its own included", async () => { + let verified = 0; + const { page, messages, stop } = await open({ + port, + webSocketServer: { + type: "ws", + options: { + host: "127.0.0.1", + port: ownSocketPort, + verifyClient: () => { + verified += 1; + + return true; + }, + }, + }, + }); + + try { + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: "domcontentloaded", + }); + await waitFor(() => + messages.some((text) => text.includes("] connected")), + ); + + expect(verified).toBeGreaterThan(0); + } finally { + await stop(); + } + }); + + it("keeps the plugin and adds no client with client: false", async () => { + const compiler = webpack(config); + const server = new Server({ port, client: false }, compiler); + + await server.start(); + + try { + expect(hasHmrPlugin(compiler)).toBe(true); + } finally { + await server.stop(); + } + }); + + it("adds no plugin with client: false and hot: false", async () => { + const compiler = webpack(config); + const server = new Server( + { port, client: false, hot: false, liveReload: false }, + compiler, + ); + + await server.start(); + + try { + expect(hasHmrPlugin(compiler)).toBe(false); + } finally { + await server.stop(); + } + }); + + // A server bundle hot-reloads itself through `module.hot`, and a project + // told not to add the plugin itself has relied on this server for it. + it("gives the server half of a multi-compiler build the plugin too", async () => { + const compiler = webpack([ + { ...config, name: "web" }, + { ...config, name: "server", target: "node", plugins: [] }, + ]); + const server = new Server({ port }, compiler); + + await server.start(); + + try { + expect(compiler.compilers.map(hasHmrPlugin)).toEqual([true, true]); + } finally { + await server.stop(); + } + }); + + it("still answers getClientEntry and getClientHotEntry, with a deprecation", async () => { + const warnings = []; + const onWarning = (warning) => { + warnings.push(warning.code); + }; + + process.on("warning", onWarning); + + try { + const server = new Server({ port, hot: "only" }, webpack(config)); + + expect(server.getClientEntry()).toBe( + path.resolve(__dirname, "../../client/index.js"), + ); + expect(server.getClientHotEntry()).toBe( + require.resolve("webpack/hot/only-dev-server"), + ); + + await waitFor(() => warnings.length >= 2); + + expect(warnings).toEqual( + expect.arrayContaining([ + "DEP_WEBPACK_DEV_SERVER_GET_CLIENT_ENTRY", + "DEP_WEBPACK_DEV_SERVER_GET_CLIENT_HOT_ENTRY", + ]), + ); + } finally { + process.off("warning", onWarning); + } + }); +}); diff --git a/test/e2e/entry.test.js b/test/e2e/entry.test.js index e8f488e2b0..912ac36ef7 100644 --- a/test/e2e/entry.test.js +++ b/test/e2e/entry.test.js @@ -11,8 +11,8 @@ import portsMap from "../ports-map.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const port = portsMap.entry; -const HOT_ENABLED_MESSAGE = - "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled."; +// What the client says once it has connected to the server. +const HOT_ENABLED_MESSAGE = "[webpack-dev-server] connected"; const waitForConsoleLogFinished = async (consoleLogs) => { await new Promise((resolve) => { diff --git a/test/e2e/event-source.test.js b/test/e2e/event-source.test.js new file mode 100644 index 0000000000..d7603026f7 --- /dev/null +++ b/test/e2e/event-source.test.js @@ -0,0 +1,277 @@ +import http from "node:http"; +import path from "node:path"; +import { afterEach, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { expect } from "expect"; +import fs from "graceful-fs"; +import webpack from "webpack"; +import Server from "../../lib/Server.js"; +import reloadConfig from "../fixtures/reload-config/webpack.config.js"; +import runBrowser from "../helpers/run-browser.js"; +import waitFor from "../helpers/wait-for.js"; +import portsMap from "../ports-map.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Two: the host-check case needs a server of its own, since `allowedHosts` +// cannot be changed once one is running. +const [port, rejectPort] = portsMap["event-source"]; + +const cssFilePath = path.resolve( + __dirname, + "../fixtures/reload-config/main.css", +); + +/** + * Read one stream the way a browser's `EventSource` would, and report the + * frames it carried. + * @param {number} listeningOn port the server is on + * @param {Record} headers request headers + * @param {number | ((frames: string[]) => boolean)} want how many frames to wait for, or what to wait for + * @returns {Promise<{ status: number, contentType: string, frames: string[] }>} what arrived + */ +function readStream(listeningOn, headers, want) { + return new Promise((resolve) => { + /** @type {string[]} */ + const frames = []; + let status = 0; + let contentType = ""; + + const finish = () => resolve({ status, contentType, frames }); + const timer = setTimeout(finish, 10000); + const request = http.get( + { host: "localhost", port: listeningOn, path: "/ws", headers }, + (res) => { + status = /** @type {number} */ (res.statusCode); + contentType = /** @type {string} */ (res.headers["content-type"]); + res.setEncoding("utf8"); + res.on("data", (chunk) => { + for (const line of chunk.split("\n")) { + if (line.startsWith("data: ")) { + frames.push(line.slice(6)); + } + } + + if ( + typeof want === "function" ? want(frames) : frames.length >= want + ) { + clearTimeout(timer); + request.destroy(); + finish(); + } + }); + }, + ); + + request.on("error", () => { + clearTimeout(timer); + finish(); + }); + }); +} + +// Server-Sent Events are the second transport this package can speak, and the +// only one served by the middleware rather than by an upgrade. Everything +// above the wire is supposed to be unable to tell the difference, which is +// what these check. +describe("Server-Sent Events transport", () => { + let browser; + let server; + + afterEach(async () => { + if (browser) { + await browser.close(); + browser = undefined; + } + + if (server) { + await server.stop(); + server = undefined; + } + + if (fs.existsSync(cssFilePath)) { + fs.unlinkSync(cssFilePath); + } + }); + + it("connects and refreshes content with a hot update", async () => { + fs.writeFileSync(cssFilePath, "body { background-color: rgb(0, 0, 255); }"); + + const compiler = webpack(reloadConfig); + + server = new Server( + { port, client: { webSocketTransport: "sse" }, hot: true }, + compiler, + ); + + await server.start(); + + // Naming the client's transport is enough: the endpoint that serves it is + // picked to match, rather than leaving a stream client pointed at a + // WebSocket server. + expect(server.options.webSocketServer.type).toBe("sse"); + + const launched = await runBrowser(); + + ({ browser } = launched); + + const { page } = launched; + const consoleMessages = []; + + page.on("console", (message) => { + consoleMessages.push(message.text()); + }); + + // Not `networkidle0`: the stream is a request that never ends, so the + // network is never idle while the client is connected. + await page.goto(`http://localhost:${port}/`, { + waitUntil: "domcontentloaded", + }); + + const backgroundBefore = await page.evaluate( + () => globalThis.getComputedStyle(document.body).backgroundColor, + ); + + expect(backgroundBefore).toBe("rgb(0, 0, 255)"); + + fs.writeFileSync(cssFilePath, "body { background-color: rgb(255, 0, 0); }"); + + await page.waitForFunction( + () => + globalThis.getComputedStyle(document.body).backgroundColor === + "rgb(255, 0, 0)", + { timeout: 60000 }, + ); + + // The page took the rebuild as an update over a stream it opened itself — + // no upgrade, no second server — and said so in the words it uses for a + // socket, since nothing above the wire knows which transport it is on. + expect( + consoleMessages.filter((message) => message.includes("connected")), + ).toHaveLength(1); + expect( + consoleMessages.filter((message) => message.includes("Hot updated")), + ).toHaveLength(1); + expect( + consoleMessages.filter((message) => message.includes("Disconnected")), + ).toHaveLength(0); + }); + + it("catches a client up as soon as it joins", async () => { + const compiler = webpack(reloadConfig); + + fs.writeFileSync(cssFilePath, "body { background-color: rgb(0, 0, 255); }"); + + server = new Server( + { + port, + client: { webSocketTransport: "sse" }, + hot: true, + }, + compiler, + ); + + await server.start(); + // A build to be caught up on: joined before the first one finished, the + // stream would carry that build as it happened instead. + await new Promise((resolve) => { + server.middleware.waitUntilValid(resolve); + }); + + const { status, contentType, frames } = await readStream( + port, + { + accept: "text/event-stream", + host: `localhost:${port}`, + origin: `http://localhost:${port}`, + }, + (received) => + received.some((frame) => JSON.parse(frame).action === "sync"), + ); + + expect(status).toBe(200); + expect(contentType).toContain("text/event-stream"); + + // The state of the build, as `sync` for the runtime — and, next to it, + // this server's own messages, for whatever reads the stream directly. + const parsed = frames.map((frame) => JSON.parse(frame)); + + expect(parsed.map((message) => message.action)).toContain("sync"); + expect(parsed.map((message) => message.type)).toEqual( + expect.arrayContaining(["hash", "ok"]), + ); + }); + + it("keeps this package's host check on the stream", async () => { + const compiler = webpack(reloadConfig); + + fs.writeFileSync(cssFilePath, "body { background-color: rgb(0, 0, 255); }"); + + server = new Server( + { + port: rejectPort, + client: { webSocketTransport: "sse" }, + allowedHosts: ["example.test"], + }, + compiler, + ); + + await server.start(); + + // Every connection to the stream, until it is closed. + const clients = new Set(); + + server.middleware.onConnect((client) => { + clients.add(client); + client.on("close", () => { + clients.delete(client); + }); + }); + + const { frames } = await readStream( + rejectPort, + { + accept: "text/event-stream", + host: `localhost:${rejectPort}`, + origin: "http://not-allowed.test", + }, + 1, + ); + + // The middleware serves the stream, but who may read it is still this + // package's to say — it is handed the request the client connected with + // and closes the ones it does not want. + expect(JSON.parse(frames[0])).toEqual({ + action: "error", + message: "Invalid Host/Origin header", + }); + + await waitFor(() => clients.size === 0); + + expect([...clients]).toHaveLength(0); + }); + + it("takes a stream that carries no origin as one of its own", async () => { + const compiler = webpack(reloadConfig); + + fs.writeFileSync(cssFilePath, "body { background-color: rgb(0, 0, 255); }"); + + server = new Server( + { port, client: { webSocketTransport: "sse" }, hot: true }, + compiler, + ); + + await server.start(); + + // What a browser actually sends: `EventSource` puts no `Origin` on a + // same-origin request, unlike a WebSocket handshake, which carries one + // either way. Refusing it would refuse every page this server serves. + const { frames } = await readStream( + port, + { accept: "text/event-stream", host: `localhost:${port}` }, + 1, + ); + + expect(JSON.parse(frames[0]).action).not.toBe("error"); + }); +}); diff --git a/test/e2e/hot-and-live-reload.test.js b/test/e2e/hot-and-live-reload.test.js index 2fd17195e0..826afffcb0 100644 --- a/test/e2e/hot-and-live-reload.test.js +++ b/test/e2e/hot-and-live-reload.test.js @@ -23,7 +23,30 @@ const cssFilePath = path.resolve( "../fixtures/reload-config/main.css", ); -const INVALID_MESSAGE = "[webpack-dev-server] App updated. Recompiling..."; +// What a rebuild reports about itself is different on every run and on every +// machine: how long it took, and the absolute path of the file that changed. +const root = path.resolve(__dirname, "../.."); + +/** + * @param {string} text a console message + * @returns {string} the message without the parts that vary between runs + */ +function normalize(text) { + return ( + text + .replaceAll(root, "") + // The path of the file, which a Windows machine writes with backslashes. + .replaceAll(/[^\s)]*/g, (path) => path.replaceAll("\\", "/")) + .replace(/rebuilt in \d+ms/, "rebuilt in