diff --git a/.changeset/deprecate-hot-progress.md b/.changeset/deprecate-hot-progress.md new file mode 100644 index 000000000..932bd297a --- /dev/null +++ b/.changeset/deprecate-hot-progress.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": patch +--- + +Deprecated the `hot.progress` option; it will be removed in the next major release and keeps working until then. It applied `ProgressPlugin` to your compiler, which leaves a server that applies one itself — webpack-dev-server does — with two of them on one compiler. Apply it yourself and hand the result to [`publish`](https://github.com/webpack/webpack-dev-middleware#publishpayload), rounding the percent and dropping a tick that repeats one as the option did for you. The browser end of this, `hot.client.progress`, is unaffected and stays. diff --git a/.changeset/hot-token.md b/.changeset/hot-token.md new file mode 100644 index 000000000..ac170a599 --- /dev/null +++ b/.changeset/hot-token.md @@ -0,0 +1,37 @@ +--- +"webpack-dev-middleware": minor +--- + +Added `hot.token`: a secret the injected client carries and the hot endpoint +requires, so reaching the stream takes something a page has to have been given +rather than a header the browser may or may not send. + +`hot.cors` is answered by `Origin`, and that is its weakness. A browser omits +`Origin` and the whole `Sec-Fetch-*` family when the destination is not +potentially trustworthy — plain `http` to anything but `localhost`, which +`host: "0.0.0.0"` gives you. webpack-dev-server shipped two fixes built on +those headers and both were bypassed exactly that way, CVE-2026-6402 and then +CVE-2026-14620. A token asks the browser to volunteer nothing. + +Off by default on both transports, and `true` in the next major release. A +token only reaches the browser on the entry the middleware adds, and `inject` +being on does not mean an entry was added: it is skipped when every entry point +already pulls the client in, when `hot.transport` is a function, and for a +non-web target. Requiring one by default would turn each of those into a `403` +on every client. Set `token: true` to turn it on, and if you do so where no +client was injected the middleware warns rather than leaving you with an +unexplained refusal. + +With the client injected, that is all it takes: it is handed the token and puts +it on its connection url. `hot.inject: false` turns the requirement off — the +token travels in the entry the middleware adds, so with nothing injected there +is no way to hand one over. A configuration that lists the client entry itself +is built before the middleware exists and cannot carry a minted token, so give +it a fixed one both sides know, or read the minted one from `instance.token`. + +What it does not protect: the client reads the token from its entry query, so +it is a string in the bundle. Anything that can already read the bundle +cross-origin reads the token with it, and over plain `http` to a non-localhost +address nothing stops that unless your server sends +`Cross-Origin-Resource-Policy`. This hardens every case where the bundle is not +readable, and is defence in depth in the case where it is. diff --git a/.changeset/instance-publish.md b/.changeset/instance-publish.md new file mode 100644 index 000000000..3dfb2499a --- /dev/null +++ b/.changeset/instance-publish.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": minor +--- + +Added `publish(payload)` to the instance, which puts a payload of your own on the hot stream. The middleware publishes what it knows about — a build starting, finishing, failing — and anything else a server measures is its own; `ProgressPlugin` is the example. The bundled client already renders `{ action: "progress" }`, so a server that applies the plugin itself now has somewhere to put what it reports. Nothing is sent when no client is connected, and it does nothing when `hot` is off. diff --git a/.changeset/readme-anchors.md b/.changeset/readme-anchors.md new file mode 100644 index 000000000..741f6676d --- /dev/null +++ b/.changeset/readme-anchors.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": patch +--- + +Fixed two broken links in the README: the `etag` row of the options table and the two references to the `attach` method pointed at headings that do not exist. diff --git a/.cspell.json b/.cspell.json index 3171632a4..098a14d77 100644 --- a/.cspell.json +++ b/.cspell.json @@ -43,7 +43,8 @@ "expressjs", "wildcarded", "jshttp", - "realpath" + "realpath", + "Rsbuild" ], "ignorePaths": [ "CHANGELOG.md", diff --git a/README.md b/README.md index b29686b71..9cd5fb22a 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ See [below](#other-servers) for an example of use with fastify. | **[`index`](#index)** | `boolean\|string` | `index.html` | If `false` (but not `undefined`), the server will not respond to requests to the root URL. | | **[`mimeTypes`](#mimetypes)** | `Object` | `undefined` | Allows to register custom mime types or extension mappings. | | **[`mimeTypeDefault`](#mimetypedefault)** | `string` | `undefined` | Allows to register a default mime type when we can't determine the content type. | -| **[`etag`](#tag)** | `boolean\| "weak"\| "strong"` | `undefined` | Enable or disable etag generation. | +| **[`etag`](#etag)** | `boolean\| "weak"\| "strong"` | `undefined` | Enable or disable etag generation. | | **[`lastModified`](#lastmodified)** | `boolean` | `undefined` | Enable or disable `Last-Modified` header. Uses the file system's last modified value. | | **[`cacheControl`](#cachecontrol)** | `boolean\|number\|string\|Object` | `undefined` | Enable or disable setting `Cache-Control` response header. | | **[`cacheImmutable`](#cacheimmutable)** | `boolean` | `undefined` | Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets. | @@ -337,6 +337,7 @@ The object form accepts these options: | **[`server`](#hotserver)** | `object` | `undefined` | HTTP server the `'ws'` transport answers upgrades on. | | **[`progress`](#hotprogress)** | `boolean` | `false` | Publish compilation progress events to the clients. | | **[`cors`](#hotcors)** | `boolean \| string \| string[] \| RegExp \| function \| object` | see below | Which origins may reach the endpoint from a page on another one, over either transport. | +| **[`token`](#hottoken)** | `boolean \| string` | see below | A secret the injected client carries and the endpoint requires, over either transport. | | **[`inject`](#hotinject)** | `boolean` | `true` | Add the client entry and `HotModuleReplacementPlugin`. | | **[`statsOptions`](#hotstatsoptions)** | `object` | `undefined` | Deprecated — do not use; see [`stats`](#stats). | @@ -349,7 +350,7 @@ How events reach the clients. `'sse'` serves them as [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) from the middleware itself, which needs nothing else. -`'ws'` serves them over a WebSocket. It needs the optional [`ws`](https://www.npmjs.com/package/ws) package (`npm install ws`), and an HTTP server to answer upgrades on — a handshake is an upgrade the server answers, which the middleware never sees. Give it [`hot.server`](#hotserver), or hand the server over later with the middleware's [`attach`](#attach) method: +`'ws'` serves them over a WebSocket. It needs the optional [`ws`](https://www.npmjs.com/package/ws) package (`npm install ws`), and an HTTP server to answer upgrades on — a handshake is an upgrade the server answers, which the middleware never sees. Give it [`hot.server`](#hotserver), or hand the server over later with the middleware's [`attach`](#attachserver) method: ```js const server = http.createServer(instance); @@ -443,14 +444,20 @@ Heartbeat interval (in milliseconds) used to keep the connection alive when no c Type: `Object` Default: `undefined` -HTTP server the [`'ws'`](#hottransport) transport answers upgrades on, when it already exists where the middleware is built. Otherwise hand it over later with the middleware's [`attach`](#attach) method. Ignored by `'sse'`, which is answered by the middleware itself. +HTTP server the [`'ws'`](#hottransport) transport answers upgrades on, when it already exists where the middleware is built. Otherwise hand it over later with the middleware's [`attach`](#attachserver) method. Ignored by `'sse'`, which is answered by the middleware itself. #### `hot.progress` Type: `Boolean` Default: `false` -Publish compilation progress events (`{ action: "progress", percent, message }`) to the clients using webpack's `ProgressPlugin`. The bundled client shows the percentage in its building badge (see the client `progress` option). +> [!WARNING] +> +> Deprecated, and removed in the next major release. Use [`publish`](#publishpayload) instead — the example is there. + +Applies webpack's `ProgressPlugin` and publishes what it reports (`{ action: "progress", percent, message }`). The bundled client shows the percentage in its building badge, which the client `progress` option configures. + +Deciding to measure a build is the server's call rather than the middleware's, and a server that applies `ProgressPlugin` already — webpack-dev-server does — ends up with two of them on one compiler. The client still renders the payload; what changes is who sends it. #### `hot.cors` @@ -537,6 +544,62 @@ A **function** [transport](#hottransport) of your own is handed the option as it > > This is about who may reach the endpoint, and nothing else. It does not decide who may reach the **assets** the middleware serves, which is your server's to answer — with a `Cross-Origin-Resource-Policy` response header, or with whatever your framework's own CORS middleware does. +#### `hot.token` + +Type: `Boolean | String` +Default: `false` + +A secret the injected client carries and the endpoint requires. Reaching the stream then takes something a page has to have been **given**, rather than a header a browser may or may not send. + +[`cors`](#hotcors) is answered by `Origin`, and that is the weakness: a browser omits `Origin` and the whole `Sec-Fetch-*` family when the destination is not [potentially trustworthy](https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy) — plain `http` to anything but `localhost`, which is what `host: '0.0.0.0'` gives you. webpack-dev-server shipped two fixes built on those headers and both were bypassed exactly that way ([CVE-2026-6402](https://github.com/advisories/GHSA-79cf-xcqc-c78w), then [CVE-2026-14620](https://github.com/advisories/GHSA-f5vj-f2hx-8m93)). A token asks the browser to volunteer nothing. + +**Off by default, on both transports**, and `true` in the next major release. A token only reaches the browser on the entry this middleware adds, and `inject` being on does not mean an entry was added — it is skipped when every entry point already pulls the client in, when [`hot.transport`](#hottransport) is a function, and for a non-web target. Requiring one by default would turn each of those into a `403` on every client. + +Turn it on, which is all the normal setup needs — the client is injected, so it is handed the token and uses it: + +```js +app.use(middleware(compiler, { hot: { token: true } })); +``` + +If you turn it on where no client was injected, the middleware says so rather than leaving you with an unexplained `403`: + +``` +[webpack-dev-middleware] 'hot.token' requires a token on the endpoint, but no +client entry was added to hand one over, so every client will be refused. +``` + +> [!IMPORTANT] +> +> **What a token does not protect.** The client reads it from its entry query, so it is a string in the bundle. Anything that can already read your bundle cross-origin can read the token out of it — and over plain `http` to a non-`localhost` address, nothing stops that unless your server sends `Cross-Origin-Resource-Policy`. The token hardens every case where the bundle is not readable; where it is, your source has already gone and the stream is the smaller loss. Closing that needs the response header and a `Host` allowlist, which are [your server's](#security) to set. + +**Wiring the client yourself.** The token travels in the entry this middleware adds, so `hot.inject: false` turns the requirement off — there would be no way to hand one over, and requiring it would refuse a client you wired correctly. + +A configuration that already lists the client as an entry is the other half of that: it is built before the middleware exists, so it cannot carry a token minted per run. Give it one of your own instead, which both sides can know in advance: + +```js +const token = "a-secret-of-my-own"; + +// webpack.config.js +entry: [`webpack-dev-middleware/client?token=${token}`, "./src/index.js"]; + +// and the middleware +app.use(middleware(compiler, { hot: { transport: "ws", token } })); +``` + +Or read the minted one off the instance, for a client you serve yourself: + +```js +const instance = middleware(compiler, { + hot: { transport: "ws", token: true }, +}); + +app.get("/my-client-config.json", (_req, res) => { + res.json({ token: instance.token }); +}); +``` + +`false` requires none, which is the default. + #### `hot.inject` Type: `Boolean` @@ -1086,6 +1149,61 @@ Required: `Yes` Called once per client, in the order the subscribers were added. +### `publish(payload)` + +Put a payload of your own on the hot stream. + +The middleware publishes what it knows about — a build starting, finishing, failing. Anything else a server measures is its own, and `ProgressPlugin` is the example: deciding to instrument a build is the server's call, and the middleware is only what carries the result. + +The bundled client already renders `{ action: "progress" }` in its building badge, so a server with its own plugin has somewhere to put it: + +```js +const compiler = webpack(config); +const instance = middleware(compiler, { hot: true }); + +// A `ProgressPlugin` callback fires far more often than the whole number +// changes, so the percent is rounded and a tick that repeats one is dropped — +// without that, most of these would be a message on the wire saying what the +// last one said. This is what `hot.progress` did for you. +let lastPercent = -1; + +new webpack.ProgressPlugin((percent, message) => { + const rounded = Math.round(percent * 100); + + if (rounded === lastPercent) { + return; + } + + lastPercent = rounded; + instance.publish({ action: "progress", percent: rounded, message }); +}).apply(compiler); + +app.use(instance); +``` + +That replaces [`hot.progress`](#hotprogress), which applied the plugin for you and is deprecated — a server that applies `ProgressPlugin` already would otherwise have two of them on one compiler. The indicator itself is unaffected: whether a `progress` payload is drawn is [`hot.client.progress`](#client-options), which is the browser's end of this and stays. + +Rounding and de-duplicating are the two things `hot.progress` did that become yours, which is why the example above does both. + +It is not only for progress. Any action the clients understand can be published, and `{ action: "reload" }` is the other useful one — every page loads itself again, whatever `hot` and `liveReload` are set to: + +```js +chokidar.watch("content/**/*.md").on("change", () => { + instance.publish({ action: "reload" }); +}); +``` + +Nothing is sent when no client is connected, so a caller does not have to ask whether anyone is listening — but that is the only traffic it saves, and with a page open every call is a message. Keeping a chatty source down to what changed, as above, is the caller's. Does nothing when `hot` is disabled. + +#### Parameters + +##### `payload` + +Type: `{ action: String, ...}` +Required: `Yes` + +An `action` the clients understand, and whatever that action carries. The built-in actions are `building`, `progress`, `built`, `sync` and `reload`. + ### `close(callback)` Instructs `webpack-dev-middleware` instance to stop watching for file changes. diff --git a/client-src/index.js b/client-src/index.js index 5ee4d2a04..77df4f2aa 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -14,6 +14,7 @@ import { log, setLogLevel } from "./utils/log.js"; import reloadPage from "./utils/reload.js"; import sendMessage from "./utils/send-message.js"; import stripAnsi from "./utils/strip-ansi.js"; +import withToken from "./utils/with-token.js"; /** @typedef {import("./utils/log.js").LogLevel} LogLevel */ @@ -45,6 +46,7 @@ import stripAnsi from "./utils/strip-ansi.js"; * @property {string} urlPrefix prefix of the page-url parameters that turn `hot` and `liveReload` off for one page * @property {LogLevel} logging logger level * @property {string} name limit updates to this compilation name + * @property {string} token the secret the endpoint requires, when it requires one, put on the connection url — empty when it requires none * @property {boolean} autoConnect connect immediately when the entry runs * @property {number=} reconnect how many times to reconnect before giving up, unset to use the transport's default * @property {boolean | "circular" | "linear"} progress show an indicator while a rebuild is in progress — `true` and `"circular"` a small badge, `"linear"` a thin bar across the top of the viewport @@ -62,6 +64,10 @@ const options = { urlPrefix: "webpack-dev-middleware", logging: "info", name: "", + // The secret the endpoint requires, when it requires one. Put on the url + // rather than sent as a header: neither `EventSource` nor `WebSocket` lets a + // page set one. + token: "", autoConnect: true, progress: true, }; @@ -172,6 +178,7 @@ function setOverrides(overrides) { // Where the page connects, which may be an absolute url rather than a path // when the endpoint is on another origin. if (overrides.path) options.path = overrides.path; + if (overrides.token) options.token = overrides.token; if (overrides.timeout) { const timeout = Number(overrides.timeout); @@ -274,19 +281,23 @@ function getClient() { function createClientSocket() { const isEventSource = options.transport !== "ws"; - return createSocket(getClient(), /** @type {string} */ (options.path), { - clientOptions: { timeout: options.timeout }, - // Server-Sent Events are retried for as long as the page is open, at the - // steady interval its watchdog already uses: a dev server is expected to - // come back, and a tab left open over a restart has to find it again. - retries: isEventSource ? Infinity : options.reconnect, - retryDelay: isEventSource - ? () => /** @type {number} */ (options.timeout) - : undefined, - onDisconnect: () => { - sendMessage("Close"); + return createSocket( + getClient(), + withToken(/** @type {string} */ (options.path), options.token), + { + clientOptions: { timeout: options.timeout }, + // Server-Sent Events are retried for as long as the page is open, at the + // steady interval its watchdog already uses: a dev server is expected to + // come back, and a tab left open over a restart has to find it again. + retries: isEventSource ? Infinity : options.reconnect, + retryDelay: isEventSource + ? () => /** @type {number} */ (options.timeout) + : undefined, + onDisconnect: () => { + sendMessage("Close"); + }, }, - }); + ); } const WRAPPER_KEY = "__wdmEventSourceWrapper"; diff --git a/client-src/utils/with-token.js b/client-src/utils/with-token.js new file mode 100644 index 000000000..ffb8f926f --- /dev/null +++ b/client-src/utils/with-token.js @@ -0,0 +1,35 @@ +/** + * Put the token on an endpoint url. + * + * On the url because neither `EventSource` nor `WebSocket` lets a page set a + * request header, so there is nowhere else to put it. + * + * Set rather than appended: a path that already carries a `token` would end up + * with two, and the endpoint reads the first — so the one passed here would be + * the one ignored. Taken apart by hand rather than through `URL`, which would + * need a base and would turn a relative path into an absolute one; this keeps + * whatever shape it was given, absolute url, rooted path or relative. + * @param {string} path the endpoint, which may already carry a query, a fragment, or both + * @param {string | undefined} token the token, when the endpoint requires one + * @returns {string} the endpoint to connect to + */ +export default function withToken(path, token) { + if (!token) { + return path; + } + + const hashAt = path.indexOf("#"); + const hash = hashAt === -1 ? "" : path.slice(hashAt); + const withoutHash = hashAt === -1 ? path : path.slice(0, hashAt); + const queryAt = withoutHash.indexOf("?"); + const before = queryAt === -1 ? withoutHash : withoutHash.slice(0, queryAt); + const query = new URLSearchParams( + queryAt === -1 ? "" : withoutHash.slice(queryAt + 1), + ); + + query.set("token", token); + + // Before the fragment, where a query belongs — appending would have put the + // token inside it. + return `${before}?${query}${hash}`; +} diff --git a/src/hot.js b/src/hot.js index 5b782340d..dff06e583 100644 --- a/src/hot.js +++ b/src/hot.js @@ -29,6 +29,7 @@ * @property {("sse" | "ws")=} transport which transport the runtime speaks, `hot.transport` by default * @property {string=} path where the runtime connects, `hot.path` by default; may be an absolute url for an endpoint on another origin * @property {string=} name limit the runtime to one compilation's builds, the compilation's own name by default + * @property {string=} token the secret the runtime puts on its connection url, `hot.token` by default * @property {(boolean | Record)=} overlay show build problems and uncaught runtime errors in an overlay * @property {(boolean | "circular" | "linear")=} progress show an indicator while a rebuild is in progress * @property {boolean=} hot apply a build through Hot Module Replacement @@ -51,6 +52,7 @@ * @property {StatsOptions=} statsOptions deprecated, removed in the next major release — webpack stats options used when serializing compilation results * @property {boolean=} progress publish compilation progress events to the clients * @property {CorsOption=} cors which origins may reach the endpoint from a page on another one; the local ones by default + * @property {(boolean | string)=} token a secret the injected client carries and the endpoint requires; `true` mints one per run, a string uses that one, `false` requires none. Defaults to `false` on both transports; `true` in the next major release * @property {boolean=} inject add the hot client entry and `HotModuleReplacementPlugin` to the compilation (default `true`); turn it off to wire them yourself * @property {HotClientOptions=} client options handed to the browser runtime through its entry query */ @@ -128,7 +130,7 @@ * built-in two are made of whatever this returns. * @template {EXPECTED_ANY} [TClient=StreamClient] * @callback ClientStreamFactory - * @param {{ path: string, heartbeat: number, cors: CorsOption | undefined }} options the endpoint's path and heartbeat interval, and the origins it is meant to allow + * @param {{ path: string, heartbeat: number, cors: CorsOption | undefined, token: string | false }} options the endpoint's path and heartbeat interval, the origins it is meant to allow, and the token it should require * @param {Logger} logger logger * @returns {ClientStream} client stream */ @@ -141,7 +143,10 @@ // module paths and source frames a failed build reports. Both transports // honour it now, each the only way it can be honoured on that wire: the event // stream withholds the grant, and an upgrade is refused. -const { HOT_DEFAULT_CORS_SSE, applyCors, resolveCors } = require("./utils.js"); +// The CORS rules, and the default each transport starts from, live with the +// transport that applies them in `./servers`. What is left here is the mint +// that hands one token to whichever of them is built. +const { resolveToken } = require("./utils.js"); const HOT_DEFAULT_PATH = "/__webpack_hmr"; const HOT_DEFAULT_HEARTBEAT = 10 * 1000; @@ -149,18 +154,20 @@ const HOT_DEFAULT_TRANSPORT = "sse"; const PLUGIN_NAME = "DevMiddleware"; /** - * @param {string | undefined} url url - * @param {string} expected expected pathname - * @returns {boolean} true when the url pathname matches the expected path + * Load the module for the transport that was chosen, and only that one. + * + * Neither is reached until a `createHot` call picks it: a project on the + * default Server-Sent Events never parses the WebSocket server or `ws`, one on + * a WebSocket never parses the event stream, and a transport of its own parses + * neither. Spelled out per name rather than built from a variable so the path + * stays statically analysable — a bundler has to be able to see both. + * @param {"EventSourceServer" | "WebSocketServer"} name which server + * @returns {EXPECTED_ANY} its factory */ -function pathMatch(url, expected) { - if (!url) return false; - - try { - return new URL(url, "http://localhost").pathname === expected; - } catch { - return false; - } +function requireServer(name) { + return name === "WebSocketServer" + ? require("./servers/WebSocketServer.js") + : require("./servers/EventSourceServer.js"); } // What a transport has to do for itself. Missing one of these would throw from @@ -237,171 +244,6 @@ function checkClientStream(stream) { return stream; } -/** - * @param {number} heartbeat heartbeat interval in milliseconds - * @param {Logger} logger logger - * @param {CorsOption=} cors which origins may read the stream, the local ones by default - * @returns {EventStream} event stream - */ -function createEventStream(heartbeat, logger, cors) { - const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_SSE); - let clientId = 0; - /** @type {Map} */ - let clients = new Map(); - /** @type {((client: StreamClient, req: IncomingMessage) => void) | undefined} */ - let onConnectFn; - - /** - * Run the callback for every client that can still be written to — a - * response ended between two `close` events would throw on write. - * @param {(client: ServerResponse) => void} fn each client callback - */ - const everyClient = (fn) => { - for (const client of clients.values()) { - if (!client.writableEnded) { - fn(client); - } - } - }; - - // Runs only while clients are connected: started with the first client, - // stopped with the last one. - /** @type {ReturnType | null} */ - let interval = null; - - const startHeartbeat = () => { - if (interval !== null) { - return; - } - - interval = setInterval(() => { - everyClient((client) => { - client.write("data: 💓\n\n"); - }); - }, heartbeat); - - // Don't block process exit on the heartbeat timer. - if (typeof interval.unref === "function") { - interval.unref(); - } - }; - - const stopHeartbeat = () => { - if (interval !== null) { - clearInterval(interval); - interval = null; - } - }; - - return { - close() { - stopHeartbeat(); - everyClient((client) => { - client.end(); - }); - clients = new Map(); - }, - hasClients() { - return clients.size > 0; - }, - onConnect(fn) { - onConnectFn = fn; - }, - handler(req, res) { - // A response another middleware already started can no longer become an - // SSE stream — end it instead of crashing on writeHead. - if (res.headersSent) { - if (!res.writableEnded) { - res.end(); - } - return; - } - - /** @type {Record} */ - const headers = { - "Content-Type": "text/event-stream;charset=utf-8", - "Cache-Control": "no-cache, no-transform", - // While behind nginx, the event stream should not be buffered: - // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering - "X-Accel-Buffering": "no", - }; - - applyCors(corsGrant, req, headers); - - const { httpVersion, socket } = req; - const isHttp1 = !(Number.parseInt(httpVersion, 10) >= 2); - - if (isHttp1) { - if (socket && typeof socket.setKeepAlive === "function") { - socket.setKeepAlive(true); - } - headers.Connection = "keep-alive"; - } - - res.writeHead(200, headers); - res.write("\n"); - - const id = clientId++; - clients.set(id, res); - startHeartbeat(); - logger.log(`Client connected (${clients.size} active)`); - - const disconnect = () => { - if (!clients.has(id)) { - return; - } - - if (!res.writableEnded) { - res.end(); - } - - clients.delete(id); - - if (clients.size === 0) { - stopHeartbeat(); - } - - logger.log(`Client disconnected (${clients.size} active)`); - }; - - req.on("close", disconnect); - - // A request that died before the handshake finished never emits `close` - // again, so it would stay in `clients` forever. - if (req.destroyed) { - disconnect(); - - return; - } - - if (onConnectFn) { - onConnectFn(res, req); - } - }, - publish(payload) { - // With no clients connected there is nothing to serialize for. - if (clients.size === 0) { - return; - } - - const frame = `data: ${JSON.stringify(payload)}\n\n`; - - everyClient((client) => { - client.write(frame); - }); - }, - publishTo(client, payload) { - const res = /** @type {ServerResponse} */ (client); - - if (res.writableEnded) { - return; - } - - res.write(`data: ${JSON.stringify(payload)}\n\n`); - }, - }; -} - /** * @param {(string | StatsError)[]} errors errors or warnings * @returns {string[]} flat strings @@ -601,6 +443,7 @@ function publishBundles(bundles, previousBundles, eventStream) { * @typedef {object} HotInstance * @property {string} path path the endpoint is served at * @property {("sse" | "ws" | ClientStreamFactory)} transport how events reach the clients + * @property {string | false} token the secret the endpoint requires, or false when it requires none; the injected client is given it * @property {(server: HttpServer) => void} attach answer WebSocket upgrades on this server, a no-op for Server-Sent Events * @property {(req: IncomingMessage, socket: Duplex, head: Buffer) => boolean} handleUpgrade answer one WebSocket upgrade, for a caller that owns the server's `upgrade` event and wants to decide each one; returns false when the request is not the endpoint's, or the transport does not answer upgrades * @property {(fn: (client: EXPECTED_ANY, req: IncomingMessage) => void) => void} onConnect called with each client once it has joined, and the request it joined with, before anything is published to it @@ -622,6 +465,13 @@ function createHot(compiler, userOptions, statsOption) { const transport = options.transport || HOT_DEFAULT_TRANSPORT; const { cors } = options; const { statsOptions } = options; + // `inject: false` turns it off: the token reaches the browser through the + // entry this middleware adds, so with nothing injected there is no way to + // hand one over, and requiring it would refuse a client the developer wired + // correctly. Off by default either way — see `HOT_DEFAULT_TOKEN`. + const token = resolveToken( + options.inject === false ? (options.token ?? false) : options.token, + ); const logger = compiler.getInfrastructureLogger("webpack-dev-middleware"); // TODO in the next major release remove `statsOptions` and this warning @@ -638,20 +488,22 @@ function createHot(compiler, userOptions, statsOption) { if (typeof transport === "function") { eventStream = checkClientStream( - transport({ heartbeat, path, cors }, logger), + transport({ heartbeat, path, cors, token }, logger), ); transportName = "a custom transport"; } else if (transport === "ws") { - // Required here rather than at the top: it pulls in `ws`, and the default - // transport is Server-Sent Events, so a project that never asks for a - // WebSocket should not pay to load either. - - const createWebSocketStream = require("./servers/WebSocketServer.js"); - - eventStream = createWebSocketStream({ heartbeat, path, cors }, logger); + eventStream = requireServer("WebSocketServer")( + { heartbeat, path, cors, token }, + logger, + ); transportName = "a WebSocket"; } else { - eventStream = createEventStream(heartbeat, logger, cors); + eventStream = requireServer("EventSourceServer")( + heartbeat, + logger, + cors, + token, + ); transportName = "Server-Sent Events"; } @@ -700,7 +552,12 @@ function createHot(compiler, userOptions, statsOption) { eventStream.attach(options.server); } + // TODO in the next major release remove `progress` and this warning if (options.progress) { + logger.warn( + "The 'hot.progress' option is deprecated and will be removed in the next major release. Measuring a build is the server's call, not the middleware's: a server that applies 'ProgressPlugin' itself — webpack-dev-server does — ends up with two of them on one compiler. Apply it yourself and hand what it reports to the middleware's 'publish' method, rounding the percent and dropping a tick that repeats one as this option did for you — the example is at https://github.com/webpack/webpack-dev-middleware#publishpayload. Until then this keeps working.", + ); + const { webpack } = "compilers" in compiler ? compiler.compilers[0] : compiler; @@ -778,6 +635,7 @@ function createHot(compiler, userOptions, statsOption) { return { path, transport, + token, attach(server) { if (closed || !eventStream.attach) { return; @@ -817,6 +675,12 @@ function createHot(compiler, userOptions, statsOption) { publish(payload) { if (closed) return; + // No `hasClients` means the transport did not offer to answer, so the + // payload goes to it and it decides. This is a public entry point — + // something outside publishing on every ProgressPlugin tick should not + // pay for a stream nobody is reading. + if (eventStream.hasClients && !eventStream.hasClients()) return; + eventStream.publish(payload); }, close() { @@ -834,14 +698,21 @@ function createHot(compiler, userOptions, statsOption) { } module.exports = createHot; -module.exports.HOT_DEFAULT_CORS_SSE = HOT_DEFAULT_CORS_SSE; module.exports.HOT_DEFAULT_HEARTBEAT = HOT_DEFAULT_HEARTBEAT; module.exports.HOT_DEFAULT_PATH = HOT_DEFAULT_PATH; module.exports.HOT_DEFAULT_TRANSPORT = HOT_DEFAULT_TRANSPORT; module.exports.checkClientStream = checkClientStream; -module.exports.createEventStream = createEventStream; +/** + * Kept as an export, loaded on the first call rather than with this module. + * @param {number} heartbeat heartbeat interval in milliseconds + * @param {Logger} logger logger + * @param {CorsOption=} cors which origins may read the stream + * @param {(string | false)=} token the token the endpoint requires, or false for none + * @returns {EventStream} event stream + */ +module.exports.createEventStream = (heartbeat, logger, cors, token) => + requireServer("EventSourceServer")(heartbeat, logger, cors, token); module.exports.createHot = createHot; module.exports.formatErrors = formatErrors; -module.exports.pathMatch = pathMatch; module.exports.publishBundles = publishBundles; module.exports.toBundles = toBundles; diff --git a/src/index.js b/src/index.js index c274ec2ef..a1d31c24a 100644 --- a/src/index.js +++ b/src/index.js @@ -169,6 +169,11 @@ const noop = () => {}; * @returns {boolean} true when the endpoint answered the upgrade, false when the request was not its own — or `hot` is off, or the transport answers no upgrades */ +/** + * @callback Publish + * @param {import("./hot").Payload | { action: string }} payload the payload to publish to every client + */ + /** * @callback OnConnect * @param {(client: EXPECTED_ANY, req: IncomingMessage) => void} fn called with each client once it has joined, and the request it joined with, before anything is published to it @@ -189,6 +194,8 @@ const noop = () => {}; * @property {Attach} attach answer WebSocket upgrades on this server * @property {HandleUpgrade} handleUpgrade answer one WebSocket upgrade, for a server that owns its own `upgrade` event * @property {OnConnect} onConnect called with each client that joins, and the request it joined with + * @property {Publish} publish put a payload of your own on the hot stream, for what a server measures itself — a no-op when `hot` is off + * @property {(string | false | undefined)=} token the secret the hot endpoint requires, for a client of your own to put on the url; false when it requires none, undefined when `hot` is off * @property {Close} close close * @property {Context} context context */ @@ -577,6 +584,9 @@ function wdm(compiler, options = {}, isPlugin = false) { transport: hotOptions.transport || "sse", inject: hotOptions.inject, client: hotOptions.client, + // The one `createHot` minted just above, so the client it injects and + // the endpoint it serves agree. + token: context.hot?.token, }, /** @type {Logger} */ (context.logger), ); @@ -692,6 +702,28 @@ function wdm(compiler, options = {}, isPlugin = false) { } }; + // Put a payload of your own on the hot stream. + // + // The middleware publishes what it knows about — a build starting, finishing, + // failing. Anything else a server measures is its own: `ProgressPlugin` is + // the example, and it belongs to whoever decided to measure rather than to + // the middleware that carries the result. The client already renders + // `{ action: "progress" }`, so a server with its own plugin has everywhere to + // put it and needed only this. + // + // A no-op when `hot` is off, so a caller does not have to ask first. + instance.publish = (payload) => { + if (filledContext.hot) { + filledContext.hot.publish(payload); + } + }; + + // The secret the hot endpoint requires, for a client of your own: the + // injected one is handed it through its entry query, but anything you wrote + // yourself has to put it on the url. `false` when the endpoint requires + // none, `undefined` when `hot` is off. + instance.token = filledContext.hot ? filledContext.hot.token : undefined; + instance.waitUntilValid = (callback = noop) => { middleware.ready(filledContext, callback); }; diff --git a/src/middleware.js b/src/middleware.js index ab5606443..c92156e6e 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -2,8 +2,6 @@ const path = require("node:path"); const querystring = require("node:querystring"); const { finished } = require("node:stream"); -const { pathMatch: hotPathMatch } = require("./hot"); - const { createReadStreamOrReadFile, destroyStream, @@ -23,6 +21,7 @@ const { memorize, parseHttpDate, parseTokenList, + pathMatch: hotPathMatch, pipe, removeResponseHeader, send, diff --git a/src/options.check.js b/src/options.check.js index 06e98fb8d..cd6fa3a5f 100644 --- a/src/options.check.js +++ b/src/options.check.js @@ -2,4 +2,4 @@ // DO NOT MODIFY BY HAND. Run `npm run fix:schema-check` to update. /* eslint-disable */ // @ts-nocheck -"use strict";module.exports = validate10;module.exports.default = validate10;const schema11 = {"definitions":{"CorsOrigin":{"description":"An origin, written as a browser sends it (scheme, host and port, no trailing slash), several of them, a pattern, or a function asked about each.","anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"},{"type":"array","items":{"anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"}]},"minItems":1},{"instanceof":"Function"}]}},"type":"object","properties":{"mimeTypes":{"description":"Allows a user to register custom mime types or extension mappings.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypes","type":"object"},"mimeTypeDefault":{"description":"Allows a user to register a default mime type when we can't determine the content type.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypedefault","type":"string"},"writeToDisk":{"description":"Allows to write generated files on disk.","link":"https://github.com/webpack/webpack-dev-middleware#writetodisk","anyOf":[{"type":"boolean"},{"instanceof":"Function"}]},"methods":{"description":"Allows to pass the list of HTTP request methods accepted by the middleware.","link":"https://github.com/webpack/webpack-dev-middleware#methods","type":"array","items":{"type":"string","minLength":1}},"headers":{"anyOf":[{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"key":{"description":"key of header.","type":"string"},"value":{"description":"value of header.","type":"string"}}},"minItems":1},{"type":"object"},{"instanceof":"Function"}],"description":"Allows to pass custom HTTP headers on each request","link":"https://github.com/webpack/webpack-dev-middleware#headers"},"publicPath":{"description":"The `publicPath` specifies the public URL address of the output files when referenced in a browser.","link":"https://github.com/webpack/webpack-dev-middleware#publicpath","anyOf":[{"enum":["auto"]},{"type":"string"},{"instanceof":"Function"}]},"stats":{"description":"Stats options object or preset name.","link":"https://github.com/webpack/webpack-dev-middleware#stats","anyOf":[{"enum":["none","summary","errors-only","errors-warnings","minimal","normal","detailed","verbose"]},{"type":"boolean"},{"type":"object","additionalProperties":true}]},"serverSideRender":{"description":"Instructs the module to enable or disable the server-side rendering mode.","link":"https://github.com/webpack/webpack-dev-middleware#serversiderender","type":"boolean"},"outputFileSystem":{"description":"Set the default file system which will be used by webpack as primary destination of generated files.","link":"https://github.com/webpack/webpack-dev-middleware#outputfilesystem","type":"object"},"index":{"description":"Allows to serve an index of the directory.","link":"https://github.com/webpack/webpack-dev-middleware#index","anyOf":[{"type":"boolean"},{"type":"string","minLength":1}]},"modifyResponseData":{"description":"Allows to set up a callback to change the response data.","link":"https://github.com/webpack/webpack-dev-middleware#modifyresponsedata","instanceof":"Function"},"etag":{"description":"Enable or disable etag generation.","link":"https://github.com/webpack/webpack-dev-middleware#etag","enum":["weak","strong"]},"lastModified":{"description":"Enable or disable `Last-Modified` header. Uses the file system's last modified value.","link":"https://github.com/webpack/webpack-dev-middleware#lastmodified","type":"boolean"},"cacheControl":{"description":"Enable or disable setting `Cache-Control` response header.","link":"https://github.com/webpack/webpack-dev-middleware#cachecontrol","anyOf":[{"type":"boolean"},{"type":"number"},{"type":"string","minLength":1},{"type":"object","properties":{"maxAge":{"type":"number"},"immutable":{"type":"boolean"}},"additionalProperties":false}]},"cacheImmutable":{"description":"Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets (i.e. asset with a hash in file name like `image.a4c12bde.jpg`).","link":"https://github.com/webpack/webpack-dev-middleware#cacheimmutable","type":"boolean"},"forwardError":{"description":"Enable or disable forwarding errors to next middleware.","link":"https://github.com/webpack/webpack-dev-middleware#forwarderrors","type":"boolean"},"hot":{"description":"Enable hot module replacement over a Server-Sent Events or WebSocket endpoint.","link":"https://github.com/webpack/webpack-dev-middleware#hot","anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":false,"properties":{"transport":{"description":"How events reach the clients: `sse`, `ws` (needs the optional `ws` dependency and an HTTP server to answer upgrades on, given as `server` or through the middleware's `attach` method), or a function building a transport of your own.","anyOf":[{"enum":["sse","ws"]},{"instanceof":"Function"}]},"path":{"description":"The path the endpoint is served at. Must start with a slash and carry no query string or fragment.","type":"string","pattern":"^/[^?#]*$"},"heartbeat":{"description":"Heartbeat interval (in milliseconds) used to keep the connection alive.","type":"number","minimum":1},"server":{"description":"HTTP server the `ws` transport answers upgrades on, when it is already built. Otherwise hand it over later with the middleware's `attach` method.","type":"object","additionalProperties":true},"progress":{"description":"Publish compilation progress events to the clients.","type":"boolean"},"cors":{"description":"Which origins may read the Server-Sent Events endpoint from a page on another one. Local origins only by default. `true` grants every origin, which lets any site the developer has open read the build's errors, including the source frames webpack puts in them.","link":"https://github.com/webpack/webpack-dev-middleware#hotcors","anyOf":[{"type":"boolean"},{"$ref":"#/definitions/CorsOrigin"},{"type":"object","additionalProperties":false,"properties":{"origin":{"description":"Which origins may read it, as Vite and `expressjs/cors` are configured.","anyOf":[{"type":"boolean"},{"$ref":"#/definitions/CorsOrigin"}]}}}]},"statsOptions":{"description":"Deprecated, do not use, will be removed in the next major release. Use the `stats` option instead, which decides whether a payload carries errors and warnings.","type":"object","additionalProperties":true},"inject":{"description":"Add the hot client entry and HotModuleReplacementPlugin to the compilation. Turn it off to wire them yourself.","link":"https://github.com/webpack/webpack-dev-middleware#hot","type":"boolean"},"client":{"description":"Options handed to the browser runtime through its entry query.","link":"https://github.com/webpack/webpack-dev-middleware#hot","type":"object","additionalProperties":false,"properties":{"transport":{"description":"Which transport the runtime speaks: `sse` or `ws`. Defaults to the resolved `hot.transport`.","enum":["sse","ws"]},"path":{"description":"Where the runtime connects. Defaults to the resolved `hot.path`; may be an absolute url for an endpoint reached on another origin or through a proxy.","type":"string","minLength":1},"name":{"description":"Limit the runtime to one compilation's builds. Defaults to the compilation's own name.","type":"string"},"overlay":{"description":"Show build problems and uncaught runtime errors in an overlay.","anyOf":[{"type":"boolean"},{"type":"object"}]},"progress":{"description":"Show an indicator while a rebuild is in progress.","anyOf":[{"type":"boolean"},{"enum":["circular","linear"]}]},"hot":{"description":"Apply a build through Hot Module Replacement.","type":"boolean"},"liveReload":{"description":"Reload the page on a build that changed something, when `hot` is off.","type":"boolean"},"reload":{"description":"Reload the page when an update cannot be applied.","type":"boolean"},"urlPrefix":{"description":"Names the page-url parameters that turn `hot` and `liveReload` off for a single page.","type":"string","minLength":1},"logging":{"description":"How much the runtime logs to the browser console.","enum":["none","error","warn","info","log","verbose"]},"reconnect":{"description":"How many times to reconnect before giving up.","type":"number","minimum":0},"timeout":{"description":"How long the runtime tolerates silence before reconnecting, in milliseconds.","type":"number","exclusiveMinimum":0},"autoConnect":{"description":"Connect as soon as the entry runs.","type":"boolean"},"dynamicPublicPath":{"description":"Prefix the endpoint path with the bundle's public path at runtime.","type":"boolean"}}}}}]}},"additionalProperties":false};const schema12 = {"description":"An origin, written as a browser sends it (scheme, host and port, no trailing slash), several of them, a pattern, or a function asked about each.","anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"},{"type":"array","items":{"anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"}]},"minItems":1},{"instanceof":"Function"}]};const func2 = Object.prototype.hasOwnProperty;const pattern0 = new RegExp("^/[^?#]*$", "u");function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){const _errs1 = errors;for(const key0 in data){if(!(func2.call(schema11.properties, key0))){validate10.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs1 === errors){if(data.mimeTypes !== undefined){let data0 = data.mimeTypes;const _errs2 = errors;if(!(data0 && typeof data0 == "object" && !Array.isArray(data0))){validate10.errors = [{instancePath:instancePath+"/mimeTypes",schemaPath:"#/properties/mimeTypes/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid0 = _errs2 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypeDefault !== undefined){const _errs5 = errors;if(typeof data.mimeTypeDefault !== "string"){validate10.errors = [{instancePath:instancePath+"/mimeTypeDefault",schemaPath:"#/properties/mimeTypeDefault/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs5 === errors;}else {var valid0 = true;}if(valid0){if(data.writeToDisk !== undefined){let data2 = data.writeToDisk;const _errs8 = errors;const _errs9 = errors;let valid1 = false;const _errs10 = errors;if(typeof data2 !== "boolean"){const err0 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}var _valid0 = _errs10 === errors;valid1 = valid1 || _valid0;if(!valid1){const _errs12 = errors;if(!(data2 instanceof Function)){const err1 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}var _valid0 = _errs12 === errors;valid1 = valid1 || _valid0;}if(!valid1){const err2 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs9;if(vErrors !== null){if(_errs9){vErrors.length = _errs9;}else {vErrors = null;}}}var valid0 = _errs8 === errors;}else {var valid0 = true;}if(valid0){if(data.methods !== undefined){let data3 = data.methods;const _errs14 = errors;if(errors === _errs14){if(Array.isArray(data3)){var valid2 = true;const len0 = data3.length;for(let i0=0; i0=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}else {const err41 = {instancePath:instancePath+"/hot/heartbeat",schemaPath:"#/properties/hot/anyOf/1/properties/heartbeat/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}var valid12 = _errs105 === errors;}else {var valid12 = true;}if(valid12){if(data22.server !== undefined){let data26 = data22.server;const _errs107 = errors;if(errors === _errs107){if(data26 && typeof data26 == "object" && !Array.isArray(data26)){}else {const err42 = {instancePath:instancePath+"/hot/server",schemaPath:"#/properties/hot/anyOf/1/properties/server/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}var valid12 = _errs107 === errors;}else {var valid12 = true;}if(valid12){if(data22.progress !== undefined){const _errs110 = errors;if(typeof data22.progress !== "boolean"){const err43 = {instancePath:instancePath+"/hot/progress",schemaPath:"#/properties/hot/anyOf/1/properties/progress/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}var valid12 = _errs110 === errors;}else {var valid12 = true;}if(valid12){if(data22.cors !== undefined){let data28 = data22.cors;const _errs112 = errors;const _errs113 = errors;let valid14 = false;const _errs114 = errors;if(typeof data28 !== "boolean"){const err44 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/properties/hot/anyOf/1/properties/cors/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}var _valid8 = _errs114 === errors;valid14 = valid14 || _valid8;if(!valid14){const _errs116 = errors;const _errs118 = errors;let valid16 = false;const _errs119 = errors;if(errors === _errs119){if(typeof data28 === "string"){if(data28.length < 1){const err45 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}else {const err46 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}var _valid9 = _errs119 === errors;valid16 = valid16 || _valid9;if(!valid16){const _errs121 = errors;if(!(data28 instanceof RegExp)){const err47 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}var _valid9 = _errs121 === errors;valid16 = valid16 || _valid9;if(!valid16){const _errs122 = errors;if(errors === _errs122){if(Array.isArray(data28)){if(data28.length < 1){const err48 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/2/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}else {var valid17 = true;const len2 = data28.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}else {const err92 = {instancePath:instancePath+"/hot/client/reconnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/reconnect/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}}var valid25 = _errs187 === errors;}else {var valid25 = true;}if(valid25){if(data34.timeout !== undefined){let data46 = data34.timeout;const _errs189 = errors;if(errors === _errs189){if(typeof data46 == "number"){if(data46 <= 0 || isNaN(data46)){const err93 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/timeout/exclusiveMinimum",keyword:"exclusiveMinimum",params:{comparison: ">", limit: 0},message:"must be > 0"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}}else {const err94 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/timeout/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}}var valid25 = _errs189 === errors;}else {var valid25 = true;}if(valid25){if(data34.autoConnect !== undefined){const _errs191 = errors;if(typeof data34.autoConnect !== "boolean"){const err95 = {instancePath:instancePath+"/hot/client/autoConnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/autoConnect/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}var valid25 = _errs191 === errors;}else {var valid25 = true;}if(valid25){if(data34.dynamicPublicPath !== undefined){const _errs193 = errors;if(typeof data34.dynamicPublicPath !== "boolean"){const err96 = {instancePath:instancePath+"/hot/client/dynamicPublicPath",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/dynamicPublicPath/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}var valid25 = _errs193 === errors;}else {var valid25 = true;}}}}}}}}}}}}}}}}else {const err97 = {instancePath:instancePath+"/hot/client",schemaPath:"#/properties/hot/anyOf/1/properties/client/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}}var valid12 = _errs158 === errors;}else {var valid12 = true;}}}}}}}}}}}else {const err98 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}var _valid6 = _errs96 === errors;valid11 = valid11 || _valid6;}if(!valid11){const err99 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs93;if(vErrors !== null){if(_errs93){vErrors.length = _errs93;}else {vErrors = null;}}}var valid0 = _errs92 === errors;}else {var valid0 = true;}}}}}}}}}}}}}}}}}}}else {validate10.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate10.errors = vErrors;return errors === 0;} \ No newline at end of file +"use strict";module.exports = validate10;module.exports.default = validate10;const schema11 = {"definitions":{"CorsOrigin":{"description":"An origin, written as a browser sends it (scheme, host and port, no trailing slash), several of them, a pattern, or a function asked about each.","anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"},{"type":"array","items":{"anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"}]},"minItems":1},{"instanceof":"Function"}]}},"type":"object","properties":{"mimeTypes":{"description":"Allows a user to register custom mime types or extension mappings.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypes","type":"object"},"mimeTypeDefault":{"description":"Allows a user to register a default mime type when we can't determine the content type.","link":"https://github.com/webpack/webpack-dev-middleware#mimetypedefault","type":"string"},"writeToDisk":{"description":"Allows to write generated files on disk.","link":"https://github.com/webpack/webpack-dev-middleware#writetodisk","anyOf":[{"type":"boolean"},{"instanceof":"Function"}]},"methods":{"description":"Allows to pass the list of HTTP request methods accepted by the middleware.","link":"https://github.com/webpack/webpack-dev-middleware#methods","type":"array","items":{"type":"string","minLength":1}},"headers":{"anyOf":[{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"key":{"description":"key of header.","type":"string"},"value":{"description":"value of header.","type":"string"}}},"minItems":1},{"type":"object"},{"instanceof":"Function"}],"description":"Allows to pass custom HTTP headers on each request","link":"https://github.com/webpack/webpack-dev-middleware#headers"},"publicPath":{"description":"The `publicPath` specifies the public URL address of the output files when referenced in a browser.","link":"https://github.com/webpack/webpack-dev-middleware#publicpath","anyOf":[{"enum":["auto"]},{"type":"string"},{"instanceof":"Function"}]},"stats":{"description":"Stats options object or preset name.","link":"https://github.com/webpack/webpack-dev-middleware#stats","anyOf":[{"enum":["none","summary","errors-only","errors-warnings","minimal","normal","detailed","verbose"]},{"type":"boolean"},{"type":"object","additionalProperties":true}]},"serverSideRender":{"description":"Instructs the module to enable or disable the server-side rendering mode.","link":"https://github.com/webpack/webpack-dev-middleware#serversiderender","type":"boolean"},"outputFileSystem":{"description":"Set the default file system which will be used by webpack as primary destination of generated files.","link":"https://github.com/webpack/webpack-dev-middleware#outputfilesystem","type":"object"},"index":{"description":"Allows to serve an index of the directory.","link":"https://github.com/webpack/webpack-dev-middleware#index","anyOf":[{"type":"boolean"},{"type":"string","minLength":1}]},"modifyResponseData":{"description":"Allows to set up a callback to change the response data.","link":"https://github.com/webpack/webpack-dev-middleware#modifyresponsedata","instanceof":"Function"},"etag":{"description":"Enable or disable etag generation.","link":"https://github.com/webpack/webpack-dev-middleware#etag","enum":["weak","strong"]},"lastModified":{"description":"Enable or disable `Last-Modified` header. Uses the file system's last modified value.","link":"https://github.com/webpack/webpack-dev-middleware#lastmodified","type":"boolean"},"cacheControl":{"description":"Enable or disable setting `Cache-Control` response header.","link":"https://github.com/webpack/webpack-dev-middleware#cachecontrol","anyOf":[{"type":"boolean"},{"type":"number"},{"type":"string","minLength":1},{"type":"object","properties":{"maxAge":{"type":"number"},"immutable":{"type":"boolean"}},"additionalProperties":false}]},"cacheImmutable":{"description":"Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets (i.e. asset with a hash in file name like `image.a4c12bde.jpg`).","link":"https://github.com/webpack/webpack-dev-middleware#cacheimmutable","type":"boolean"},"forwardError":{"description":"Enable or disable forwarding errors to next middleware.","link":"https://github.com/webpack/webpack-dev-middleware#forwarderrors","type":"boolean"},"hot":{"description":"Enable hot module replacement over a Server-Sent Events or WebSocket endpoint.","link":"https://github.com/webpack/webpack-dev-middleware#hot","anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":false,"properties":{"transport":{"description":"How events reach the clients: `sse`, `ws` (needs the optional `ws` dependency and an HTTP server to answer upgrades on, given as `server` or through the middleware's `attach` method), or a function building a transport of your own.","anyOf":[{"enum":["sse","ws"]},{"instanceof":"Function"}]},"path":{"description":"The path the endpoint is served at. Must start with a slash and carry no query string or fragment.","type":"string","pattern":"^/[^?#]*$"},"heartbeat":{"description":"Heartbeat interval (in milliseconds) used to keep the connection alive.","type":"number","minimum":1},"server":{"description":"HTTP server the `ws` transport answers upgrades on, when it is already built. Otherwise hand it over later with the middleware's `attach` method.","type":"object","additionalProperties":true},"progress":{"description":"Publish compilation progress events to the clients.","type":"boolean"},"token":{"description":"A secret the injected client carries and the endpoint requires, so reaching the stream takes something a page has to have been given rather than a header the browser may not send. `true` mints one per run, a string uses that one — for a client of your own that has to build the url itself — and `false` requires none. Defaults to `false`, because requiring one would refuse a client this middleware did not inject; `true` in the next major release.","link":"https://github.com/webpack/webpack-dev-middleware#hottoken","anyOf":[{"type":"boolean"},{"type":"string","minLength":1}]},"cors":{"description":"Which origins may read the Server-Sent Events endpoint from a page on another one. Local origins only by default. `true` grants every origin, which lets any site the developer has open read the build's errors, including the source frames webpack puts in them.","link":"https://github.com/webpack/webpack-dev-middleware#hotcors","anyOf":[{"type":"boolean"},{"$ref":"#/definitions/CorsOrigin"},{"type":"object","additionalProperties":false,"properties":{"origin":{"description":"Which origins may read it, as Vite and `expressjs/cors` are configured.","anyOf":[{"type":"boolean"},{"$ref":"#/definitions/CorsOrigin"}]}}}]},"statsOptions":{"description":"Deprecated, do not use, will be removed in the next major release. Use the `stats` option instead, which decides whether a payload carries errors and warnings.","type":"object","additionalProperties":true},"inject":{"description":"Add the hot client entry and HotModuleReplacementPlugin to the compilation. Turn it off to wire them yourself.","link":"https://github.com/webpack/webpack-dev-middleware#hot","type":"boolean"},"client":{"description":"Options handed to the browser runtime through its entry query.","link":"https://github.com/webpack/webpack-dev-middleware#hot","type":"object","additionalProperties":false,"properties":{"transport":{"description":"Which transport the runtime speaks: `sse` or `ws`. Defaults to the resolved `hot.transport`.","enum":["sse","ws"]},"path":{"description":"Where the runtime connects. Defaults to the resolved `hot.path`; may be an absolute url for an endpoint reached on another origin or through a proxy.","type":"string","minLength":1},"name":{"description":"Limit the runtime to one compilation's builds. Defaults to the compilation's own name.","type":"string"},"token":{"description":"The secret the runtime puts on its connection url. The middleware sets this to whatever 'hot.token' resolved to, so it only needs setting for a client pointed at another endpoint that requires a different one.","type":"string"},"overlay":{"description":"Show build problems and uncaught runtime errors in an overlay.","anyOf":[{"type":"boolean"},{"type":"object"}]},"progress":{"description":"Show an indicator while a rebuild is in progress.","anyOf":[{"type":"boolean"},{"enum":["circular","linear"]}]},"hot":{"description":"Apply a build through Hot Module Replacement.","type":"boolean"},"liveReload":{"description":"Reload the page on a build that changed something, when `hot` is off.","type":"boolean"},"reload":{"description":"Reload the page when an update cannot be applied.","type":"boolean"},"urlPrefix":{"description":"Names the page-url parameters that turn `hot` and `liveReload` off for a single page.","type":"string","minLength":1},"logging":{"description":"How much the runtime logs to the browser console.","enum":["none","error","warn","info","log","verbose"]},"reconnect":{"description":"How many times to reconnect before giving up.","type":"number","minimum":0},"timeout":{"description":"How long the runtime tolerates silence before reconnecting, in milliseconds.","type":"number","exclusiveMinimum":0},"autoConnect":{"description":"Connect as soon as the entry runs.","type":"boolean"},"dynamicPublicPath":{"description":"Prefix the endpoint path with the bundle's public path at runtime.","type":"boolean"}}}}}]}},"additionalProperties":false};const schema12 = {"description":"An origin, written as a browser sends it (scheme, host and port, no trailing slash), several of them, a pattern, or a function asked about each.","anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"},{"type":"array","items":{"anyOf":[{"type":"string","minLength":1},{"instanceof":"RegExp"}]},"minItems":1},{"instanceof":"Function"}]};const func2 = Object.prototype.hasOwnProperty;const pattern0 = new RegExp("^/[^?#]*$", "u");function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){const _errs1 = errors;for(const key0 in data){if(!(func2.call(schema11.properties, key0))){validate10.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs1 === errors){if(data.mimeTypes !== undefined){let data0 = data.mimeTypes;const _errs2 = errors;if(!(data0 && typeof data0 == "object" && !Array.isArray(data0))){validate10.errors = [{instancePath:instancePath+"/mimeTypes",schemaPath:"#/properties/mimeTypes/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid0 = _errs2 === errors;}else {var valid0 = true;}if(valid0){if(data.mimeTypeDefault !== undefined){const _errs5 = errors;if(typeof data.mimeTypeDefault !== "string"){validate10.errors = [{instancePath:instancePath+"/mimeTypeDefault",schemaPath:"#/properties/mimeTypeDefault/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs5 === errors;}else {var valid0 = true;}if(valid0){if(data.writeToDisk !== undefined){let data2 = data.writeToDisk;const _errs8 = errors;const _errs9 = errors;let valid1 = false;const _errs10 = errors;if(typeof data2 !== "boolean"){const err0 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}var _valid0 = _errs10 === errors;valid1 = valid1 || _valid0;if(!valid1){const _errs12 = errors;if(!(data2 instanceof Function)){const err1 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}var _valid0 = _errs12 === errors;valid1 = valid1 || _valid0;}if(!valid1){const err2 = {instancePath:instancePath+"/writeToDisk",schemaPath:"#/properties/writeToDisk/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs9;if(vErrors !== null){if(_errs9){vErrors.length = _errs9;}else {vErrors = null;}}}var valid0 = _errs8 === errors;}else {var valid0 = true;}if(valid0){if(data.methods !== undefined){let data3 = data.methods;const _errs14 = errors;if(errors === _errs14){if(Array.isArray(data3)){var valid2 = true;const len0 = data3.length;for(let i0=0; i0=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}else {const err41 = {instancePath:instancePath+"/hot/heartbeat",schemaPath:"#/properties/hot/anyOf/1/properties/heartbeat/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}var valid12 = _errs105 === errors;}else {var valid12 = true;}if(valid12){if(data22.server !== undefined){let data26 = data22.server;const _errs107 = errors;if(errors === _errs107){if(data26 && typeof data26 == "object" && !Array.isArray(data26)){}else {const err42 = {instancePath:instancePath+"/hot/server",schemaPath:"#/properties/hot/anyOf/1/properties/server/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}var valid12 = _errs107 === errors;}else {var valid12 = true;}if(valid12){if(data22.progress !== undefined){const _errs110 = errors;if(typeof data22.progress !== "boolean"){const err43 = {instancePath:instancePath+"/hot/progress",schemaPath:"#/properties/hot/anyOf/1/properties/progress/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}var valid12 = _errs110 === errors;}else {var valid12 = true;}if(valid12){if(data22.token !== undefined){let data28 = data22.token;const _errs112 = errors;const _errs113 = errors;let valid14 = false;const _errs114 = errors;if(typeof data28 !== "boolean"){const err44 = {instancePath:instancePath+"/hot/token",schemaPath:"#/properties/hot/anyOf/1/properties/token/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}var _valid8 = _errs114 === errors;valid14 = valid14 || _valid8;if(!valid14){const _errs116 = errors;if(errors === _errs116){if(typeof data28 === "string"){if(data28.length < 1){const err45 = {instancePath:instancePath+"/hot/token",schemaPath:"#/properties/hot/anyOf/1/properties/token/anyOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}else {const err46 = {instancePath:instancePath+"/hot/token",schemaPath:"#/properties/hot/anyOf/1/properties/token/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}var _valid8 = _errs116 === errors;valid14 = valid14 || _valid8;}if(!valid14){const err47 = {instancePath:instancePath+"/hot/token",schemaPath:"#/properties/hot/anyOf/1/properties/token/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}else {errors = _errs113;if(vErrors !== null){if(_errs113){vErrors.length = _errs113;}else {vErrors = null;}}}var valid12 = _errs112 === errors;}else {var valid12 = true;}if(valid12){if(data22.cors !== undefined){let data29 = data22.cors;const _errs119 = errors;const _errs120 = errors;let valid15 = false;const _errs121 = errors;if(typeof data29 !== "boolean"){const err48 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/properties/hot/anyOf/1/properties/cors/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}var _valid9 = _errs121 === errors;valid15 = valid15 || _valid9;if(!valid15){const _errs123 = errors;const _errs125 = errors;let valid17 = false;const _errs126 = errors;if(errors === _errs126){if(typeof data29 === "string"){if(data29.length < 1){const err49 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}var _valid10 = _errs126 === errors;valid17 = valid17 || _valid10;if(!valid17){const _errs128 = errors;if(!(data29 instanceof RegExp)){const err51 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/1/instanceof",keyword:"instanceof",params:{},message:"must pass \"instanceof\" keyword validation"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}var _valid10 = _errs128 === errors;valid17 = valid17 || _valid10;if(!valid17){const _errs129 = errors;if(errors === _errs129){if(Array.isArray(data29)){if(data29.length < 1){const err52 = {instancePath:instancePath+"/hot/cors",schemaPath:"#/definitions/CorsOrigin/anyOf/2/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}else {var valid18 = true;const len2 = data29.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}}else {const err97 = {instancePath:instancePath+"/hot/client/reconnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/reconnect/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}}var valid26 = _errs196 === errors;}else {var valid26 = true;}if(valid26){if(data35.timeout !== undefined){let data48 = data35.timeout;const _errs198 = errors;if(errors === _errs198){if(typeof data48 == "number"){if(data48 <= 0 || isNaN(data48)){const err98 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/timeout/exclusiveMinimum",keyword:"exclusiveMinimum",params:{comparison: ">", limit: 0},message:"must be > 0"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}else {const err99 = {instancePath:instancePath+"/hot/client/timeout",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/timeout/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}}var valid26 = _errs198 === errors;}else {var valid26 = true;}if(valid26){if(data35.autoConnect !== undefined){const _errs200 = errors;if(typeof data35.autoConnect !== "boolean"){const err100 = {instancePath:instancePath+"/hot/client/autoConnect",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/autoConnect/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}var valid26 = _errs200 === errors;}else {var valid26 = true;}if(valid26){if(data35.dynamicPublicPath !== undefined){const _errs202 = errors;if(typeof data35.dynamicPublicPath !== "boolean"){const err101 = {instancePath:instancePath+"/hot/client/dynamicPublicPath",schemaPath:"#/properties/hot/anyOf/1/properties/client/properties/dynamicPublicPath/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}var valid26 = _errs202 === errors;}else {var valid26 = true;}}}}}}}}}}}}}}}}}else {const err102 = {instancePath:instancePath+"/hot/client",schemaPath:"#/properties/hot/anyOf/1/properties/client/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}var valid12 = _errs165 === errors;}else {var valid12 = true;}}}}}}}}}}}}else {const err103 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}}var _valid6 = _errs96 === errors;valid11 = valid11 || _valid6;}if(!valid11){const err104 = {instancePath:instancePath+"/hot",schemaPath:"#/properties/hot/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs93;if(vErrors !== null){if(_errs93){vErrors.length = _errs93;}else {vErrors = null;}}}var valid0 = _errs92 === errors;}else {var valid0 = true;}}}}}}}}}}}}}}}}}}}else {validate10.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate10.errors = vErrors;return errors === 0;} \ No newline at end of file diff --git a/src/options.json b/src/options.json index 77c7d63c0..e8ec9a4ed 100644 --- a/src/options.json +++ b/src/options.json @@ -251,6 +251,19 @@ "description": "Publish compilation progress events to the clients.", "type": "boolean" }, + "token": { + "description": "A secret the injected client carries and the endpoint requires, so reaching the stream takes something a page has to have been given rather than a header the browser may not send. `true` mints one per run, a string uses that one — for a client of your own that has to build the url itself — and `false` requires none. Defaults to `false`, because requiring one would refuse a client this middleware did not inject; `true` in the next major release.", + "link": "https://github.com/webpack/webpack-dev-middleware#hottoken", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "minLength": 1 + } + ] + }, "cors": { "description": "Which origins may read the Server-Sent Events endpoint from a page on another one. Local origins only by default. `true` grants every origin, which lets any site the developer has open read the build's errors, including the source frames webpack puts in them.", "link": "https://github.com/webpack/webpack-dev-middleware#hotcors", @@ -309,6 +322,10 @@ "description": "Limit the runtime to one compilation's builds. Defaults to the compilation's own name.", "type": "string" }, + "token": { + "description": "The secret the runtime puts on its connection url. The middleware sets this to whatever 'hot.token' resolved to, so it only needs setting for a client pointed at another endpoint that requires a different one.", + "type": "string" + }, "overlay": { "description": "Show build problems and uncaught runtime errors in an overlay.", "anyOf": [ diff --git a/src/servers/EventSourceServer.js b/src/servers/EventSourceServer.js new file mode 100644 index 000000000..146f86530 --- /dev/null +++ b/src/servers/EventSourceServer.js @@ -0,0 +1,203 @@ +/** @typedef {import("node:http").IncomingMessage} IncomingMessage */ +/** @typedef {import("../index.js").ServerResponse} ServerResponse */ +/** @typedef {import("../hot.js").Logger} Logger */ +/** @typedef {import("../hot.js").Payload} Payload */ +/** @typedef {import("../hot.js").EventStream} EventStream */ +/** @typedef {import("../hot.js").StreamClient} StreamClient */ +/** @typedef {import("../hot.js").CorsOption} CorsOption */ + +const { applyCors, isTokenValid, resolveCors } = require("../utils.js"); + +// TODO in the next major release default the Server-Sent Events endpoint to +// `CORS_LOCAL_ORIGINS` too, so one default covers both transports, and say so +// in the changelog as a breaking change. +// +// Until 8.4 the endpoint answered every request with +// `Access-Control-Allow-Origin: *`, inherited from `webpack-hot-middleware`, +// and no option could turn it off. That grant let any site a developer had +// open read the stream — and with it the module paths and source frames a +// failed build reports — so the local-origins set is what it should be. But +// narrowing it would stop a page served from anywhere else reading its own +// build, which is a break, and this release is a minor. So it stays as it +// shipped, and `cors` is how you narrow it today. +const HOT_DEFAULT_CORS_SSE = true; + +/** + * @param {number} heartbeat heartbeat interval in milliseconds + * @param {Logger} logger logger + * @param {CorsOption=} cors which origins may read the stream, the local ones by default + * @param {(string | false)=} token the token the endpoint requires, or false for none + * @returns {EventStream} event stream + */ +function createEventStream(heartbeat, logger, cors, token = false) { + const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_SSE); + let clientId = 0; + /** @type {Map} */ + let clients = new Map(); + /** @type {((client: StreamClient, req: IncomingMessage) => void) | undefined} */ + let onConnectFn; + + /** + * Run the callback for every client that can still be written to — a + * response ended between two `close` events would throw on write. + * @param {(client: ServerResponse) => void} fn each client callback + */ + const everyClient = (fn) => { + for (const client of clients.values()) { + if (!client.writableEnded) { + fn(client); + } + } + }; + + // Runs only while clients are connected: started with the first client, + // stopped with the last one. + /** @type {ReturnType | null} */ + let interval = null; + + const startHeartbeat = () => { + if (interval !== null) { + return; + } + + interval = setInterval(() => { + everyClient((client) => { + client.write("data: 💓\n\n"); + }); + }, heartbeat); + + // Don't block process exit on the heartbeat timer. + if (typeof interval.unref === "function") { + interval.unref(); + } + }; + + const stopHeartbeat = () => { + if (interval !== null) { + clearInterval(interval); + interval = null; + } + }; + + return { + close() { + stopHeartbeat(); + everyClient((client) => { + client.end(); + }); + clients = new Map(); + }, + hasClients() { + return clients.size > 0; + }, + onConnect(fn) { + onConnectFn = fn; + }, + handler(req, res) { + // A response another middleware already started can no longer become an + // SSE stream — end it instead of crashing on writeHead. + if (res.headersSent) { + if (!res.writableEnded) { + res.end(); + } + return; + } + + // Before the stream, and without the CORS grant: a caller that does not + // carry the token is told nothing about who may read this endpoint. + if (!isTokenValid(token, req)) { + logger.warn( + `A request to "${req.url}" was refused: it carried no valid 'token'. The injected client is given one; a client of your own has to pass it, or set 'hot.token' to a value it can use.`, + ); + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Forbidden"); + return; + } + + /** @type {Record} */ + const headers = { + "Content-Type": "text/event-stream;charset=utf-8", + "Cache-Control": "no-cache, no-transform", + // While behind nginx, the event stream should not be buffered: + // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering + "X-Accel-Buffering": "no", + }; + + applyCors(corsGrant, req, headers); + + const { httpVersion, socket } = req; + const isHttp1 = !(Number.parseInt(httpVersion, 10) >= 2); + + if (isHttp1) { + if (socket && typeof socket.setKeepAlive === "function") { + socket.setKeepAlive(true); + } + headers.Connection = "keep-alive"; + } + + res.writeHead(200, headers); + res.write("\n"); + + const id = clientId++; + clients.set(id, res); + startHeartbeat(); + logger.log(`Client connected (${clients.size} active)`); + + const disconnect = () => { + if (!clients.has(id)) { + return; + } + + if (!res.writableEnded) { + res.end(); + } + + clients.delete(id); + + if (clients.size === 0) { + stopHeartbeat(); + } + + logger.log(`Client disconnected (${clients.size} active)`); + }; + + req.on("close", disconnect); + + // A request that died before the handshake finished never emits `close` + // again, so it would stay in `clients` forever. + if (req.destroyed) { + disconnect(); + + return; + } + + if (onConnectFn) { + onConnectFn(res, req); + } + }, + publish(payload) { + // With no clients connected there is nothing to serialize for. + if (clients.size === 0) { + return; + } + + const frame = `data: ${JSON.stringify(payload)}\n\n`; + + everyClient((client) => { + client.write(frame); + }); + }, + publishTo(client, payload) { + const res = /** @type {ServerResponse} */ (client); + + if (res.writableEnded) { + return; + } + + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }, + }; +} + +module.exports = createEventStream; +module.exports.createEventStream = createEventStream; diff --git a/src/servers/WebSocketServer.js b/src/servers/WebSocketServer.js index 1d006dd27..08567246f 100644 --- a/src/servers/WebSocketServer.js +++ b/src/servers/WebSocketServer.js @@ -9,11 +9,16 @@ /** @typedef {import("../hot.js").CorsOption} CorsOption */ const { - HOT_DEFAULT_CORS_WS, + CORS_LOCAL_ORIGINS, + isTokenValid, isUpgradeAllowed, resolveCors, } = require("../utils.js"); +// The WebSocket transport is new in this release, so there is no behavior to +// keep and it starts where the other one is going. +const HOT_DEFAULT_CORS_WS = CORS_LOCAL_ORIGINS; + // How often a client is pinged to find out whether it is still there. A client // that has not answered the previous ping is dropped rather than pinged again. const WS_DEFAULT_HEARTBEAT = 10 * 1000; @@ -41,10 +46,14 @@ function requireWsServer() { * @param {string} options.path the path the endpoint is served at * @param {number} options.heartbeat heartbeat interval in milliseconds * @param {CorsOption=} options.cors which origins may connect, the local ones by default + * @param {(string | false)=} options.token the token the endpoint requires, or false for none * @param {Logger} logger logger * @returns {ClientStream} client stream */ -function createWebSocketStream({ path, heartbeat, cors }, logger) { +function createWebSocketStream( + { path, heartbeat, cors, token = false }, + logger, +) { const WebSocketServerImplementation = requireWsServer(); const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_WS); /** @type {Set} */ @@ -173,6 +182,19 @@ function createWebSocketStream({ path, heartbeat, cors }, logger) { // attention to what comes back, so the `cors` option can only be honoured // on this wire by refusing the upgrade — before it completes, rather than // closing the client afterwards, so nothing is ever published to it. + if (!isTokenValid(token, req)) { + logger.warn( + `An upgrade to "${req.url}" was refused: it carried no valid 'token'. The injected client is given one; a client of your own has to pass it, or set 'hot.token' to a value it can use.`, + ); + + socket.write( + "HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + ); + socket.destroy(); + + return true; + } + if (!isUpgradeAllowed(corsGrant, req)) { logger.warn( `A client from the origin "${req.headers.origin}" was refused. Add it to the 'hot.cors' option to allow it.`, diff --git a/src/utils.js b/src/utils.js index 53de697e3..db61d8570 100644 --- a/src/utils.js +++ b/src/utils.js @@ -730,23 +730,37 @@ function nodeReadableToWebStream(stream) { const CORS_LOCAL_ORIGINS = /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/; -// TODO in the next major release default the Server-Sent Events endpoint to -// `CORS_LOCAL_ORIGINS` too, so one default covers both transports, and say so -// in the changelog as a breaking change. +// A secret the injected client carries and the endpoint requires, so reaching +// the stream takes something a page has to have been given rather than a +// header a browser may or may not send. // -// Until 8.4 the endpoint answered every request with -// `Access-Control-Allow-Origin: *`, inherited from `webpack-hot-middleware`, -// and no option could turn it off. That grant let any site a developer had -// open read the stream — and with it the module paths and source frames a -// failed build reports — so the local-origins set is what it should be. But -// narrowing it would stop a page served from anywhere else reading its own -// build, which is a break, and this release is a minor. So it stays as it -// shipped, and `cors` is how you narrow it today. -const HOT_DEFAULT_CORS_SSE = true; - -// The WebSocket transport is new in this release, so there is no behavior to -// keep and it starts where the other one is going. -const HOT_DEFAULT_CORS_WS = CORS_LOCAL_ORIGINS; +// Why a secret and not a better header check: `cors` is answered by `Origin`, +// and the `Origin`/`Sec-Fetch-*` family is absent entirely when the +// destination is not potentially trustworthy — plain `http` to anything but +// `localhost`. webpack-dev-server shipped two fixes built on those headers and +// both were bypassed that way (CVE-2026-6402, then CVE-2026-14620). A token +// does not ask the browser to volunteer anything. +// +// What it does not do: the client reads it from its entry query, so it is a +// string in the bundle. An attacker who can already read the bundle +// cross-origin — the same plain-`http`-to-a-LAN-address case, where nothing +// sets `Cross-Origin-Resource-Policy` — reads the token with it. Closing that +// needs the response header and a host allowlist, which belong to whoever owns +// the server. This hardens every case where the bundle is not readable, and is +// defence in depth in the case where it is. +// +// Off by default on BOTH transports, because requiring one by default breaks +// a client the middleware did not inject — and `inject` being on does not mean +// a client was injected. An entry is skipped when every entry point already +// pulls the client in (the developer wired it themselves, which the README +// documents), when `hot.transport` is a function, and for a non-web target. +// In each of those the endpoint would demand a token nothing had been given, +// and every client would be refused with a `403`. +// +// TODO in the next major release default both to `true`, alongside the `cors` +// default above, and hand the token to a client the middleware did not inject +// some way that does not depend on the entry query. +const HOT_DEFAULT_TOKEN = false; /** * The resolved answer to "may this origin read the stream": no origin may, any @@ -928,6 +942,88 @@ function isUpgradeAllowed(grant, req) { return grant === "*" || grant(origin); } +/** + * The token the endpoint will require, if any. + * @param {boolean | string | undefined} option the `hot.token` option + * @returns {string | false} the token, or false when the endpoint requires none + */ +function resolveToken(option) { + // A token of your own, for a consumer that has to be able to construct the + // url without being handed one — a script, or a client you wrote. + if (typeof option === "string") { + return option.length > 0 ? option : false; + } + + const wanted = option ?? HOT_DEFAULT_TOKEN; + + if (!wanted) { + return false; + } + + // 9 bytes rather than a round 8 or 16: `base64url` encodes it without + // padding, so the query carries 12 characters and no `=`. The same size Vite + // and Rsbuild use for theirs. + return crypto.randomBytes(9).toString("base64url"); +} + +/** + * Does a url's pathname match an expected path exactly? + * + * Pathname only: the hot endpoint is reached with a query on it — the client's + * options, and the token — and with a fragment from a page that has one. + * @param {string | undefined} url url + * @param {string} expected expected pathname + * @returns {boolean} true when the url pathname matches the expected path + */ +function pathMatch(url, expected) { + if (!url) return false; + + try { + return new URL(url, "http://localhost").pathname === expected; + } catch { + return false; + } +} + +/** + * Does the request carry the token the endpoint requires? + * + * Compared in constant time. The comparison is not a plausible oracle — a + * token lives for one run of one dev server — but a length-dependent early + * return would be the kind of thing a reader has to reason about, and + * `timingSafeEqual` costs nothing here. + * @param {string | false} expected the resolved token, or false when none is required + * @param {IncomingMessage} req the request + * @returns {boolean} true when the request may proceed + */ +function isTokenValid(expected, req) { + if (expected === false) { + return true; + } + + let given; + + try { + given = new URL( + /** @type {string} */ (req.url), + "http://localhost", + ).searchParams.get("token"); + } catch { + return false; + } + + if (typeof given !== "string") { + return false; + } + + const a = Buffer.from(given); + const b = Buffer.from(expected); + + // `timingSafeEqual` throws on a length mismatch rather than returning false, + // and the length of the expected token is not a secret. + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + // -------------------------------------------------------------------------- // Media types // @@ -1413,8 +1509,11 @@ function clientQuery(client) { * The client is given the endpoint, the transport and the browser options * through its resource query, so it agrees with the server by construction * rather than by the developer keeping two settings in step. + */ + +/** * @param {Compiler[]} compilers compilers to modify - * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions }} options resolved hot options + * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions, token?: string | false }} options resolved hot options * @param {Logger} logger logger */ function injectHotClient(compilers, options, logger) { @@ -1423,6 +1522,9 @@ function injectHotClient(compilers, options, logger) { } let warned = false; + // A token only reaches the browser on the entry added below, so a required + // one with nothing added would refuse every client. + let injected = false; // What the developer set in node, which wins over everything below it: these // are the same options the query carries, so either spelling reaches the @@ -1482,6 +1584,10 @@ function injectHotClient(compilers, options, logger) { /** @type {Record} */ const query = { path: options.path, transport }; + if (options.token) { + query.token = options.token; + } + if (compilation) { query.name = compilation; } @@ -1489,6 +1595,8 @@ function injectHotClient(compilers, options, logger) { const search = new URLSearchParams({ ...query, ...client }).toString(); const entry = `${clientEntry()}?${search}`; + injected = true; + if (missing === null) { // No entry point has one, so a single entry every one of them gets. new webpack.EntryPlugin(compiler.context, entry, { @@ -1519,12 +1627,26 @@ function injectHotClient(compilers, options, logger) { new webpack.HotModuleReplacementPlugin().apply(compiler); } } + + // Every path above can decline to add an entry — every entry point already + // pulls the client in, `hot.transport` is a function, the target is not the + // web — and the endpoint still requires whatever token it was given. Said + // here rather than left as a `403` with no explanation. + // + // Without the token itself in it. A minted one is different every run, so + // printing it would invite exactly the wrong fix — pasting a value that is + // already stale — and infrastructure warnings travel into CI output, where + // a secret has no business being. + if (options.token && !injected) { + logger.warn( + "'hot.token' requires a token on the endpoint, but no client entry was added to hand one over, so every client will be refused. Put 'token=' on the query of the client you added yourself, reading it from the middleware's 'token' property, or set a fixed 'hot.token' both sides know — or 'hot.token: false' to require none.", + ); + } } module.exports = { CORS_LOCAL_ORIGINS, - HOT_DEFAULT_CORS_SSE, - HOT_DEFAULT_CORS_WS, + HOT_DEFAULT_TOKEN, applyCors, clientQuery, createMimeTypes, @@ -1547,6 +1669,7 @@ module.exports = { initState, injectHotClient, isSameOrigin, + isTokenValid, isUpgradeAllowed, isWebTarget, matchOrigin, @@ -1555,9 +1678,11 @@ module.exports = { nodeReadableToWebStream, parseHttpDate, parseTokenList, + pathMatch, pipe, removeResponseHeader, resolveCors, + resolveToken, send, setResponseHeader, setState, diff --git a/test/__snapshots__/validation-options.test.js.snap.webpack5 b/test/__snapshots__/validation-options.test.js.snap.webpack5 index 2b25c2fff..57b7ba68a 100644 --- a/test/__snapshots__/validation-options.test.js.snap.webpack5 +++ b/test/__snapshots__/validation-options.test.js.snap.webpack5 @@ -128,7 +128,7 @@ exports[`validation should throw an error on the "hot" option with "{"statsOptio exports[`validation should throw an error on the "hot" option with "{"transport":"websocket"}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot should be one of these: - boolean | object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? } -> Enable hot module replacement over a Server-Sent Events or WebSocket endpoint. -> Read more at https://github.com/webpack/webpack-dev-middleware#hot Details: @@ -144,7 +144,7 @@ exports[`validation should throw an error on the "hot" option with "{"transport" exports[`validation should throw an error on the "hot" option with "{"transport":true}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot should be one of these: - boolean | object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? } -> Enable hot module replacement over a Server-Sent Events or WebSocket endpoint. -> Read more at https://github.com/webpack/webpack-dev-middleware#hot Details: @@ -160,31 +160,31 @@ exports[`validation should throw an error on the "hot" option with "{"transport" exports[`validation should throw an error on the "hot" option with "{"unknown":true}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot has an unknown property 'unknown'. These properties are valid: - object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? }" `; exports[`validation should throw an error on the "hot" option with "0" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot should be one of these: - boolean | object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? } -> Enable hot module replacement over a Server-Sent Events or WebSocket endpoint. -> Read more at https://github.com/webpack/webpack-dev-middleware#hot Details: * options.hot should be a boolean. * options.hot should be an object: - object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? }" `; exports[`validation should throw an error on the "hot" option with "foo" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.hot should be one of these: - boolean | object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? } + boolean | object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? } -> Enable hot module replacement over a Server-Sent Events or WebSocket endpoint. -> Read more at https://github.com/webpack/webpack-dev-middleware#hot Details: * options.hot should be a boolean. * options.hot should be an object: - object { transport?, path?, heartbeat?, server?, progress?, cors?, statsOptions?, inject?, client? }" + object { transport?, path?, heartbeat?, server?, progress?, token?, cors?, statsOptions?, inject?, client? }" `; exports[`validation should throw an error on the "index" option with "{}" value 1`] = ` diff --git a/test/client-with-token.test.js b/test/client-with-token.test.js new file mode 100644 index 000000000..9cce5da54 --- /dev/null +++ b/test/client-with-token.test.js @@ -0,0 +1,59 @@ +import withToken from "../client-src/utils/with-token"; + +describe("putting the token on the endpoint", () => { + it("leaves the path alone when there is no token", () => { + expect(withToken("/__webpack_hmr", undefined)).toBe("/__webpack_hmr"); + expect(withToken("/__webpack_hmr", "")).toBe("/__webpack_hmr"); + }); + + it("adds a query to a path that has none", () => { + expect(withToken("/__webpack_hmr", "abc")).toBe("/__webpack_hmr?token=abc"); + }); + + it("joins a query the path already carries", () => { + expect(withToken("/__webpack_hmr?name=main", "abc")).toBe( + "/__webpack_hmr?name=main&token=abc", + ); + }); + + // The one that made this a function rather than a concatenation: appending + // would leave two `token` parameters, and the endpoint reads the first — so + // the token configured here would be the one ignored. + it("replaces a token the path already carries", () => { + expect(withToken("/__webpack_hmr?token=stale", "fresh")).toBe( + "/__webpack_hmr?token=fresh", + ); + expect(withToken("/__webpack_hmr?token=a&token=b", "fresh")).toBe( + "/__webpack_hmr?token=fresh", + ); + }); + + it("keeps the query ahead of a fragment, rather than inside it", () => { + expect(withToken("/__webpack_hmr#frag", "abc")).toBe( + "/__webpack_hmr?token=abc#frag", + ); + expect(withToken("/__webpack_hmr?name=main#frag", "abc")).toBe( + "/__webpack_hmr?name=main&token=abc#frag", + ); + }); + + it("keeps an absolute url absolute", () => { + expect(withToken("ws://127.0.0.1:8080/__webpack_hmr", "abc")).toBe( + "ws://127.0.0.1:8080/__webpack_hmr?token=abc", + ); + expect(withToken("//127.0.0.1:8080/__webpack_hmr", "abc")).toBe( + "//127.0.0.1:8080/__webpack_hmr?token=abc", + ); + }); + + it("keeps a relative path relative", () => { + // `URL` would have needed a base and returned an absolute url for this. + expect(withToken("__webpack_hmr", "abc")).toBe("__webpack_hmr?token=abc"); + }); + + it("escapes a token that needs it", () => { + expect(withToken("/__webpack_hmr", "a b&c=d")).toBe( + "/__webpack_hmr?token=a+b%26c%3Dd", + ); + }); +}); diff --git a/test/e2e/cors.test.js b/test/e2e/cors.test.js index 85c537fb8..2295a3a03 100644 --- a/test/e2e/cors.test.js +++ b/test/e2e/cors.test.js @@ -147,7 +147,15 @@ describe("reading the event stream from another origin (browser)", () => { await page.goto(`http://${host}:${port}/`); - return connectFromPage(`ws://127.0.0.1:${port}/__webpack_hmr`); + // The fixture asks for a token, and this socket is built here rather + // than taken from the injected client — so it carries the one the + // middleware resolved. Without it the endpoint refuses before it ever + // looks at the origin, which is what this test is about. + const token = encodeURIComponent(hotApp.instance.token); + + return connectFromPage( + `ws://127.0.0.1:${port}/__webpack_hmr?token=${token}`, + ); }; it("refuses an origin the default does not allow", async () => { diff --git a/test/e2e/live-reload.test.js b/test/e2e/live-reload.test.js index 7258cac44..f73f24798 100644 --- a/test/e2e/live-reload.test.js +++ b/test/e2e/live-reload.test.js @@ -258,8 +258,8 @@ describe("live reload (browser)", () => { await plantReloadMarker(page); // What a server watching files of its own would publish — nothing here - // came from a compilation. - app.instance.context.hot.publish({ + // came from a compilation — through the public method it has for it. + app.instance.publish({ action: "reload", file: "static/index.html", }); diff --git a/test/e2e/worker.test.js b/test/e2e/worker.test.js index 0489e7527..821e5d4ab 100644 --- a/test/e2e/worker.test.js +++ b/test/e2e/worker.test.js @@ -12,6 +12,11 @@ jest.setTimeout(400000); const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); +// This fixture asks for a token and wires the client entry itself — before the +// middleware exists, so a minted one could not be in the query. A fixed one is +// what hand-wiring requires. +const WORKER_TOKEN = "worker-fixed-token"; + /** * A worker that reports what it is running and takes updates in place. * @param {string} text what this version reports @@ -49,7 +54,10 @@ async function createWorkerApp({ transport = "sse", bare = false } = {}) { // exactly as injection would carry it. entry: bare ? [entryFile] - : [`${CLIENT_ENTRY}?transport=${transport}`, entryFile], + : [ + `${CLIENT_ENTRY}?transport=${transport}&token=${WORKER_TOKEN}`, + entryFile, + ], output: { path: path.join(dir, "dist"), filename: "worker.js" }, plugins: bare ? [] : [new webpack.HotModuleReplacementPlugin()], infrastructureLogging: { level: "none" }, @@ -58,7 +66,9 @@ async function createWorkerApp({ transport = "sse", bare = false } = {}) { watchOptions: { aggregateTimeout: 50, poll: 100 }, }); - const instance = middleware(compiler, { hot: { transport } }); + const instance = middleware(compiler, { + hot: { transport, token: WORKER_TOKEN }, + }); /** @type {((stats: EXPECTED_ANY) => void)[]} */ const buildWaiters = []; diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js index 700d3d7e9..82a637163 100644 --- a/test/helpers/hot-app.js +++ b/test/helpers/hot-app.js @@ -8,6 +8,13 @@ const webpack = require("webpack"); const middleware = require("../../src"); const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); + +// These fixtures ask for a token, so the e2e runs cover an endpoint that +// requires one. They wire the client entry themselves, which happens before +// the middleware exists, so there is no minted token to put in the query — a +// fixed one is what a developer wiring their own entry has to use, so it is +// what the fixtures use. +const E2E_TOKEN = "e2e-fixed-token"; const CLIENT_SRC = path.join(__dirname, "..", "..", "client-src"); const COLLECT_COVERAGE = Boolean(process.env.E2E_COVERAGE); @@ -63,6 +70,7 @@ function pageHtml(scripts) { * @param {string=} publicPath output public path * @param {boolean=} hmrPlugin include HotModuleReplacementPlugin * @param {boolean=} bare omit the client entry and the plugin, leaving both to the middleware + * @param {string=} token the token a hand-wired client entry has to carry, "" when the middleware injects one * @returns {EXPECTED_ANY} webpack configuration */ function makeConfig( @@ -73,10 +81,14 @@ function makeConfig( publicPath = "/", hmrPlugin = true, bare = false, + token = "", ) { - const clientQuery = name - ? `?name=${name}${query ? `&${query.replace(/^\?/, "")}` : ""}` - : query; + const parts = [ + ...(name ? [`name=${name}`] : []), + ...(query ? [query.replace(/^\?/, "")] : []), + ...(token ? [`token=${token}`] : []), + ]; + const clientQuery = parts.length > 0 ? `?${parts.join("&")}` : ""; return { ...(name ? { name } : {}), @@ -188,6 +200,7 @@ async function createHotApp({ undefined, undefined, bare, + transport === "ws" && !bare ? E2E_TOKEN : "", ); }); scripts = apps.map((app) => `/${app.name}.js`); @@ -202,6 +215,7 @@ async function createHotApp({ publicPath, hmrPlugin, bare, + transport === "ws" && !bare ? E2E_TOKEN : "", ); scripts = [`${publicPath}main.js`]; } @@ -220,7 +234,14 @@ async function createHotApp({ instance = middleware(compiler, { hot: transport === "ws" && hot - ? { ...(hot === true ? {} : hot), transport: "ws" } + ? { + ...(hot === true ? {} : hot), + transport: "ws", + // Fixed, so the entry wired above can carry it. `bare` has the + // middleware inject the client, which would be handed a minted + // one, but one token for both paths keeps the fixtures alike. + token: E2E_TOKEN, + } : hot, stats, }); diff --git a/test/hot.test.js b/test/hot.test.js index 66fef958c..8277839fc 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -4,10 +4,11 @@ import { problemLine } from "../client-src/problem"; import createHot, { createEventStream, formatErrors, - pathMatch, toBundles, } from "../src/hot"; -import { CORS_LOCAL_ORIGINS } from "../src/utils"; +// `pathMatch` lives with the other request helpers now; the endpoint it routes +// to is still what these cases are about, so they stay here. +import { CORS_LOCAL_ORIGINS, pathMatch } from "../src/utils"; jest.spyOn(globalThis.console, "log").mockImplementation(); @@ -1265,6 +1266,27 @@ describe("createHot", () => { hot.close(); }); + it("warns that progress is deprecated", () => { + const warnings = []; + const compiler = makeFakeCompiler({ + log() {}, + warn: (m) => warnings.push(m), + }); + + compiler.webpack = { + ProgressPlugin: class { + apply() {} + }, + }; + + const hot = createHot(compiler, { progress: true }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'hot.progress' option is deprecated"); + + hot.close(); + }); + it("forwards custom statsOptions to stats.toJson", () => { const compiler = makeFakeCompiler(); const hot = createHot(compiler, { @@ -1458,7 +1480,12 @@ describe("createHot over a WebSocket", () => { cleanups.push(stop); - return { hot, url: `ws://127.0.0.1:${port}${hot.path}`, stop }; + // The injected client is handed the token through its entry query, so a + // test client carries it the same way whenever one was asked for. The + // tests that are about the token build their own url. + const token = hot.token ? `?token=${encodeURIComponent(hot.token)}` : ""; + + return { hot, url: `ws://127.0.0.1:${port}${hot.path}${token}`, stop }; } /** @@ -1603,10 +1630,10 @@ describe("createHot over a WebSocket", () => { await until(() => joined.length > 0); // Same as the Server-Sent Events stream: the request a client arrived - // with, headers and all, so a caller can judge it. - expect(joined[0].headers.host).toBe( - endpoint.url.replace("ws://", "").replace(endpoint.hot.path, ""), - ); + // with, headers and all, so a caller can judge it. Read off the url rather + // than stripped out of it, so the token query does not end up in the + // expected host. + expect(joined[0].headers.host).toBe(new URL(endpoint.url).host); }); it("publishes nothing to a client a subscriber closed", async () => { @@ -1685,6 +1712,98 @@ describe("createHot over a WebSocket", () => { // subject to CORS, so a browser sends `Origin` and pays no attention to what // comes back. Refused before the handshake completes, so nothing is ever // published to a client that should not have one. + describe("the token", () => { + // Off by default on both transports: a token only reaches the browser on + // the entry the middleware adds, and there are several ways for no entry + // to be added — so requiring one by default would refuse every client of + // a setup that wired itself. + it("is required of nobody by default", async () => { + const endpoint = await serveOverWs(makeFakeCompiler()); + + expect(endpoint.hot.token).toBe(false); + + const { socket } = await connect(endpoint.url); + + expect(socket.readyState).toBe(socket.OPEN); + }); + + it("is minted per run when it is turned on", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: true }); + + expect(typeof endpoint.hot.token).toBe("string"); + expect(/** @type {string} */ (endpoint.hot.token).length).toBeGreaterThan( + 8, + ); + + const second = await serveOverWs(makeFakeCompiler(), { token: true }); + + expect(second.hot.token).not.toBe(endpoint.hot.token); + }); + + it("refuses a handshake that carries none", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: true }); + const withoutToken = endpoint.url.replace(/\?token=.*$/, ""); + + await expect(connect(withoutToken)).rejects.toThrow( + "Unexpected server response: 403", + ); + }); + + it("refuses a handshake that carries the wrong one", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: true }); + const wrong = endpoint.url.replace(/\?token=.*$/, "?token=not-the-token"); + + await expect(connect(wrong)).rejects.toThrow( + "Unexpected server response: 403", + ); + }); + + it("refuses one that is right apart from its length", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: true }); + const truncated = endpoint.url.slice(0, -1); + + await expect(connect(truncated)).rejects.toThrow( + "Unexpected server response: 403", + ); + }); + + it("takes a token of your own, for a client you wrote", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { + token: "a-token-of-my-own", + }); + + expect(endpoint.hot.token).toBe("a-token-of-my-own"); + + const { socket } = await connect(endpoint.url); + + expect(socket.readyState).toBe(socket.OPEN); + }); + + it("requires none when it is turned off", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { token: false }); + + expect(endpoint.hot.token).toBe(false); + + const { socket } = await connect(endpoint.url); + + expect(socket.readyState).toBe(socket.OPEN); + }); + + // The token is checked before the origin, so a caller without one learns + // nothing about which origins the endpoint would have allowed. + it("is checked before the origin", async () => { + const endpoint = await serveOverWs(makeFakeCompiler(), { + token: true, + cors: "https://allowed.example", + }); + const withoutToken = endpoint.url.replace(/\?token=.*$/, ""); + + await expect( + connect(withoutToken, { origin: "https://allowed.example" }), + ).rejects.toThrow("Unexpected server response: 403"); + }); + }); + describe("the cross-origin grant", () => { it("refuses an origin the default does not allow", async () => { const endpoint = await serveOverWs(makeFakeCompiler()); @@ -1873,7 +1992,14 @@ describe("createHot over a transport of your own", () => { }); // Resolved, not raw: a transport should not have to re-apply the defaults. - expect(received).toEqual({ heartbeat: 1234, path: "/__custom" }); + // `cors` and `token` come through too, so a transport of your own can + // enforce the same two rules the built-in ones do. + expect(received).toEqual({ + cors: undefined, + heartbeat: 1234, + path: "/__custom", + token: false, + }); hot.close(); }); diff --git a/test/inject-client.test.js b/test/inject-client.test.js index 77ee95569..7b04d9b6f 100644 --- a/test/inject-client.test.js +++ b/test/inject-client.test.js @@ -231,6 +231,39 @@ describe("injectHotClient", () => { ); }); + // The hazard this guards: a required token only reaches the browser on + // the entry added here, so asking for one where no entry is added would + // refuse every client with an unexplained 403. + it("says a token was required that no client was given", () => { + injectHotClient( + [documentedSetup()], + { + path: "/__webpack_hmr", + transport: "sse", + token: "a-token-nobody-gets", + }, + logger, + ); + + expect(warnings.join("\n")).toContain( + "no client entry was added to hand one over", + ); + // Not the token itself: a minted one is stale by the time anyone reads + // the warning, and infrastructure warnings travel into CI output. + expect(warnings.join("\n")).not.toContain("a-token-nobody-gets"); + expect(warnings.join("\n")).toContain("token="); + }); + + it("says nothing about a token when the client was injected", () => { + injectHotClient( + [compiler({ entry: "./app.js" })], + { path: "/__webpack_hmr", transport: "sse", token: "handed-over" }, + logger, + ); + + expect(warnings.join("\n")).not.toContain("no client entry was added"); + }); + it("adds the plugin to a project that only had the client entry", () => { // This one was broken before: the client was there, nothing applied the // update, and the runtime said so on every build. @@ -906,4 +939,23 @@ describe("node and the query take the same names", () => { it("is one set of names, with nothing on one side only", () => { expect(readByClient.toSorted()).toStrictEqual(takenInNode.toSorted()); }); + + // The third place, and the one that is easiest to forget: the typedef the + // published declarations are generated from. A name the schema takes that + // it omits is accepted at runtime and rejected by TypeScript, which is how + // `token` first shipped. + it("is in the typedef the declarations come from, as well", () => { + const hotSource = fs.readFileSync( + path.join(__dirname, "..", "src", "hot.js"), + "utf8", + ); + const [typedef] = /** @type {RegExpMatchArray} */ ( + hotSource.match(/@typedef \{object\} HotClientOptions[\s\S]*?\n \*\//) + ); + const declared = [ + ...typedef.matchAll(/@property \{[^}]+\} ([A-Za-z]+)/g), + ].map((found) => found[1]); + + expect(declared.toSorted()).toStrictEqual(takenInNode.toSorted()); + }); }); diff --git a/test/instance-hot-api.test.js b/test/instance-hot-api.test.js index b405130d6..515de5afb 100644 --- a/test/instance-hot-api.test.js +++ b/test/instance-hot-api.test.js @@ -74,6 +74,18 @@ describe("the hot API on the middleware instance", () => { expect(instance.handleUpgrade({}, {}, Buffer.alloc(0))).toBe(false); }); + + // What a server measures itself goes on the stream through here — + // `ProgressPlugin` being the one the middleware used to apply for you. + it("passes a payload of your own through to the endpoint", () => { + const instance = build({ hot: true }); + const publish = jest.spyOn(instance.context.hot, "publish"); + const payload = { action: "progress", percent: 42, message: "building" }; + + instance.publish(payload); + + expect(publish).toHaveBeenCalledWith(payload); + }); }); describe("with hot disabled", () => { @@ -88,5 +100,11 @@ describe("the hot API on the middleware instance", () => { expect(() => instance.onConnect(() => {})).not.toThrow(); }); + + it("takes a payload and drops it, rather than making the caller ask", () => { + const instance = build(); + + expect(() => instance.publish({ action: "progress" })).not.toThrow(); + }); }); }); diff --git a/types/client/index.d.ts b/types/client/index.d.ts index e508c2146..087763734 100644 --- a/types/client/index.d.ts +++ b/types/client/index.d.ts @@ -117,6 +117,10 @@ export type ClientOptions = { * limit updates to this compilation name */ name: string; + /** + * the secret the endpoint requires, when it requires one, put on the connection url — empty when it requires none + */ + token: string; /** * connect immediately when the entry runs */ diff --git a/types/client/utils/with-token.d.ts b/types/client/utils/with-token.d.ts new file mode 100644 index 000000000..1f40e69c8 --- /dev/null +++ b/types/client/utils/with-token.d.ts @@ -0,0 +1,19 @@ +/** + * Put the token on an endpoint url. + * + * On the url because neither `EventSource` nor `WebSocket` lets a page set a + * request header, so there is nowhere else to put it. + * + * Set rather than appended: a path that already carries a `token` would end up + * with two, and the endpoint reads the first — so the one passed here would be + * the one ignored. Taken apart by hand rather than through `URL`, which would + * need a base and would turn a relative path into an absolute one; this keeps + * whatever shape it was given, absolute url, rooted path or relative. + * @param {string} path the endpoint, which may already carry a query, a fragment, or both + * @param {string | undefined} token the token, when the endpoint requires one + * @returns {string} the endpoint to connect to + */ +export default function withToken( + path: string, + token: string | undefined, +): string; diff --git a/types/hot.d.ts b/types/hot.d.ts index 46a00df36..ec1d37799 100644 --- a/types/hot.d.ts +++ b/types/hot.d.ts @@ -3,6 +3,7 @@ export = createHot; * @typedef {object} HotInstance * @property {string} path path the endpoint is served at * @property {("sse" | "ws" | ClientStreamFactory)} transport how events reach the clients + * @property {string | false} token the secret the endpoint requires, or false when it requires none; the injected client is given it * @property {(server: HttpServer) => void} attach answer WebSocket upgrades on this server, a no-op for Server-Sent Events * @property {(req: IncomingMessage, socket: Duplex, head: Buffer) => boolean} handleUpgrade answer one WebSocket upgrade, for a caller that owns the server's `upgrade` event and wants to decide each one; returns false when the request is not the endpoint's, or the transport does not answer upgrades * @property {(fn: (client: EXPECTED_ANY, req: IncomingMessage) => void) => void} onConnect called with each client once it has joined, and the request it joined with, before anything is published to it @@ -23,7 +24,6 @@ declare function createHot( ): HotInstance; declare namespace createHot { export { - HOT_DEFAULT_CORS_SSE, HOT_DEFAULT_HEARTBEAT, HOT_DEFAULT_PATH, HOT_DEFAULT_TRANSPORT, @@ -31,7 +31,6 @@ declare namespace createHot { createEventStream, createHot, formatErrors, - pathMatch, publishBundles, toBundles, HotInstance, @@ -61,7 +60,6 @@ declare namespace createHot { EventStream, }; } -import { HOT_DEFAULT_CORS_SSE } from "./utils.js"; declare const HOT_DEFAULT_HEARTBEAT: number; declare const HOT_DEFAULT_PATH: "/__webpack_hmr"; declare const HOT_DEFAULT_TRANSPORT: "sse"; @@ -72,28 +70,17 @@ declare const HOT_DEFAULT_TRANSPORT: "sse"; declare function checkClientStream( stream: ClientStream, ): ClientStream; -/** - * @param {number} heartbeat heartbeat interval in milliseconds - * @param {Logger} logger logger - * @param {CorsOption=} cors which origins may read the stream, the local ones by default - * @returns {EventStream} event stream - */ declare function createEventStream( heartbeat: number, logger: Logger, cors?: CorsOption | undefined, + token?: (string | false) | undefined, ): EventStream; /** * @param {(string | StatsError)[]} errors errors or warnings * @returns {string[]} flat strings */ declare function formatErrors(errors: (string | StatsError)[]): string[]; -/** - * @param {string | undefined} url url - * @param {string} expected expected pathname - * @returns {boolean} true when the url pathname matches the expected path - */ -declare function pathMatch(url: string | undefined, expected: string): boolean; /** * Publish one event per bundle. Bundles whose hash did not change are * published as `sync`, so their clients do not fetch a hot-update manifest @@ -127,6 +114,10 @@ type HotInstance = { * how events reach the clients */ transport: "sse" | "ws" | ClientStreamFactory; + /** + * the secret the endpoint requires, or false when it requires none; the injected client is given it + */ + token: string | false; /** * answer WebSocket upgrades on this server, a no-op for Server-Sent Events */ @@ -199,6 +190,10 @@ type HotClientOptions = { * limit the runtime to one compilation's builds, the compilation's own name by default */ name?: string | undefined; + /** + * the secret the runtime puts on its connection url, `hot.token` by default + */ + token?: string | undefined; /** * show build problems and uncaught runtime errors in an overlay */ @@ -274,6 +269,10 @@ type HotOptions = { * which origins may reach the endpoint from a page on another one; the local ones by default */ cors?: CorsOption | undefined; + /** + * a secret the injected client carries and the endpoint requires; `true` mints one per run, a string uses that one, `false` requires none. Defaults to `false` on both transports; `true` in the next major release + */ + token?: (boolean | string) | undefined; /** * add the hot client entry and `HotModuleReplacementPlugin` to the compilation (default `true`); turn it off to wire them yourself */ @@ -436,6 +435,7 @@ type ClientStreamFactory = ( path: string; heartbeat: number; cors: CorsOption | undefined; + token: string | false; }, logger: Logger, ) => ClientStream; diff --git a/types/index.d.ts b/types/index.d.ts index 19fabb75e..8e3b8a3ee 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -56,6 +56,7 @@ declare namespace wdm { Invalidate, Attach, HandleUpgrade, + Publish, OnConnect, Close, AdditionalMethods, @@ -356,6 +357,13 @@ type HandleUpgrade = ( socket: import("node:stream").Duplex, head: Buffer, ) => boolean; +type Publish = ( + payload: + | import("./hot").Payload + | { + action: string; + }, +) => any; type OnConnect = ( fn: (client: EXPECTED_ANY, req: IncomingMessage) => void, ) => any; @@ -388,6 +396,14 @@ type AdditionalMethods< * called with each client that joins, and the request it joined with */ onConnect: OnConnect; + /** + * put a payload of your own on the hot stream, for what a server measures itself — a no-op when `hot` is off + */ + publish: Publish; + /** + * the secret the hot endpoint requires, for a client of your own to put on the url; false when it requires none, undefined when `hot` is off + */ + token?: (string | false | undefined) | undefined; /** * close */ diff --git a/types/servers/EventSourceServer.d.ts b/types/servers/EventSourceServer.d.ts new file mode 100644 index 000000000..bd54b47e6 --- /dev/null +++ b/types/servers/EventSourceServer.d.ts @@ -0,0 +1,33 @@ +export = createEventStream; +/** + * @param {number} heartbeat heartbeat interval in milliseconds + * @param {Logger} logger logger + * @param {CorsOption=} cors which origins may read the stream, the local ones by default + * @param {(string | false)=} token the token the endpoint requires, or false for none + * @returns {EventStream} event stream + */ +declare function createEventStream( + heartbeat: number, + logger: Logger, + cors?: CorsOption | undefined, + token?: (string | false) | undefined, +): EventStream; +declare namespace createEventStream { + export { + createEventStream, + IncomingMessage, + ServerResponse, + Logger, + Payload, + EventStream, + StreamClient, + CorsOption, + }; +} +type IncomingMessage = import("node:http").IncomingMessage; +type ServerResponse = import("../index.js").ServerResponse; +type Logger = import("../hot.js").Logger; +type Payload = import("../hot.js").Payload; +type EventStream = import("../hot.js").EventStream; +type StreamClient = import("../hot.js").StreamClient; +type CorsOption = import("../hot.js").CorsOption; diff --git a/types/servers/WebSocketServer.d.ts b/types/servers/WebSocketServer.d.ts index 954622926..d52598abb 100644 --- a/types/servers/WebSocketServer.d.ts +++ b/types/servers/WebSocketServer.d.ts @@ -7,6 +7,7 @@ export = createWebSocketStream; * @param {string} options.path the path the endpoint is served at * @param {number} options.heartbeat heartbeat interval in milliseconds * @param {CorsOption=} options.cors which origins may connect, the local ones by default + * @param {(string | false)=} options.token the token the endpoint requires, or false for none * @param {Logger} logger logger * @returns {ClientStream} client stream */ @@ -15,10 +16,12 @@ declare function createWebSocketStream( path, heartbeat, cors, + token, }: { path: string; heartbeat: number; cors?: CorsOption | undefined; + token?: (string | false) | undefined; }, logger: Logger, ): ClientStream; diff --git a/types/utils.d.ts b/types/utils.d.ts index 547faa258..47103e639 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -116,8 +116,7 @@ export type MimeDbEntry = { /** @typedef {import("./hot.js").CorsOption} CorsOption */ /** @typedef {import("./hot.js").CorsOrigin} CorsOrigin */ export const CORS_LOCAL_ORIGINS: RegExp; -export const HOT_DEFAULT_CORS_SSE: true; -export const HOT_DEFAULT_CORS_WS: RegExp; +export const HOT_DEFAULT_TOKEN: false; /** * Add the cross-origin grant the `cors` option asks for, if any. * @@ -340,8 +339,10 @@ export function initState< * The client is given the endpoint, the transport and the browser options * through its resource query, so it agrees with the server by construction * rather than by the developer keeping two settings in step. + */ +/** * @param {Compiler[]} compilers compilers to modify - * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions }} options resolved hot options + * @param {{ path: string, transport: NonNullable, inject?: boolean, client?: HotClientOptions, token?: string | false }} options resolved hot options * @param {Logger} logger logger */ export function injectHotClient( @@ -351,6 +352,7 @@ export function injectHotClient( transport: NonNullable; inject?: boolean; client?: HotClientOptions; + token?: string | false; }, logger: Logger, ): void; @@ -366,6 +368,21 @@ export function injectHotClient( * @returns {boolean} true when the two are the same origin */ export function isSameOrigin(req: IncomingMessage, origin: string): boolean; +/** + * Does the request carry the token the endpoint requires? + * + * Compared in constant time. The comparison is not a plausible oracle — a + * token lives for one run of one dev server — but a length-dependent early + * return would be the kind of thing a reader has to reason about, and + * `timingSafeEqual` costs nothing here. + * @param {string | false} expected the resolved token, or false when none is required + * @param {IncomingMessage} req the request + * @returns {boolean} true when the request may proceed + */ +export function isTokenValid( + expected: string | false, + req: IncomingMessage, +): boolean; /** * May this WebSocket handshake go ahead? * @@ -476,6 +493,16 @@ export function parseHttpDate(date: string): number; * @returns {string[]} tokens */ export function parseTokenList(str: string): string[]; +/** + * Does a url's pathname match an expected path exactly? + * + * Pathname only: the hot endpoint is reached with a query on it — the client's + * options, and the token — and with a fragment from a page that has one. + * @param {string | undefined} url url + * @param {string} expected expected pathname + * @returns {boolean} true when the url pathname matches the expected path + */ +export function pathMatch(url: string | undefined, expected: string): boolean; /** * @template {ServerResponse & ExpectedServerResponse} Response * @param {Response} res res @@ -501,6 +528,14 @@ export function removeResponseHeader< * @returns {CorsGrant} the resolved answer */ export function resolveCors(cors: CorsOption): CorsGrant; +/** + * The token the endpoint will require, if any. + * @param {boolean | string | undefined} option the `hot.token` option + * @returns {string | false} the token, or false when the endpoint requires none + */ +export function resolveToken( + option: boolean | string | undefined, +): string | false; /** * @template {ServerResponse & ExpectedServerResponse} Response * @param {Response} res res