From 0d3ae83718c81560ac36947c5801a3799087fd22 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 8 Jun 2026 15:58:17 +0200 Subject: [PATCH 1/3] feat(server): Add liveReload middleware Adds optional live reload support to the server: - New `serveLiveReloadClient` middleware serves the client at `/.ui5/liveReload/client.js`. - The `serveResources` middleware injects the client script tag into HTML responses when `liveReload` is enabled. - A WebSocket server attaches to the HTTP server and notifies connected clients when the BuildServer emits a `sourcesChanged` event. The feature is opt-in via the new `liveReload` option to `serve()` and defaults to `false`. JIRA: CPOUI5FOUNDATION-1224 --- internal/documentation/docs/pages/Server.md | 30 ++ package-lock.json | 22 + packages/server/eslint.config.js | 14 + packages/server/lib/helper/getPathname.js | 16 + packages/server/lib/liveReload/client.js | 112 +++++ packages/server/lib/liveReload/constants.js | 8 + packages/server/lib/liveReload/server.js | 183 ++++++++ .../lib/middleware/MiddlewareManager.js | 23 +- .../server/lib/middleware/MiddlewareUtil.js | 6 +- .../lib/middleware/helper/injectHtml.js | 49 ++ .../server/lib/middleware/helper/isFresh.js | 15 + .../server/lib/middleware/liveReloadClient.js | 87 ++++ .../lib/middleware/middlewareRepository.js | 1 + .../server/lib/middleware/serveResources.js | 39 +- packages/server/lib/server.js | 24 +- packages/server/nyc.config.js | 3 + packages/server/package.json | 1 + .../test/lib/server/helper/getPathname.js | 19 + .../server/liveReload/client.integration.js | 365 +++++++++++++++ .../test/lib/server/liveReload/server.js | 431 ++++++++++++++++++ .../server/middleware/MiddlewareManager.js | 3 +- .../lib/server/middleware/MiddlewareUtil.js | 8 +- .../server/middleware/helper/injectHtml.js | 142 ++++++ .../lib/server/middleware/liveReloadClient.js | 217 +++++++++ .../lib/server/middleware/serveResources.js | 172 ++++++- 25 files changed, 1954 insertions(+), 36 deletions(-) create mode 100644 packages/server/lib/helper/getPathname.js create mode 100644 packages/server/lib/liveReload/client.js create mode 100644 packages/server/lib/liveReload/constants.js create mode 100644 packages/server/lib/liveReload/server.js create mode 100644 packages/server/lib/middleware/helper/injectHtml.js create mode 100644 packages/server/lib/middleware/helper/isFresh.js create mode 100644 packages/server/lib/middleware/liveReloadClient.js create mode 100644 packages/server/test/lib/server/helper/getPathname.js create mode 100644 packages/server/test/lib/server/liveReload/client.integration.js create mode 100644 packages/server/test/lib/server/liveReload/server.js create mode 100644 packages/server/test/lib/server/middleware/helper/injectHtml.js create mode 100644 packages/server/test/lib/server/middleware/liveReloadClient.js diff --git a/internal/documentation/docs/pages/Server.md b/internal/documentation/docs/pages/Server.md index 64fd885fe4a..4a79f6c808f 100644 --- a/internal/documentation/docs/pages/Server.md +++ b/internal/documentation/docs/pages/Server.md @@ -40,6 +40,7 @@ A project can also add custom middleware to the server by using the [Custom Serv | `csp` | See chapter [csp](#csp) | | `compression` | Standard [Express compression middleware](http://expressjs.com/en/resources/middleware/compression.html) | | `cors` | Standard [Express cors middleware](http://expressjs.com/en/resources/middleware/cors.html) | +| `liveReloadClient` | See chapter [liveReload](#livereload) | | `discovery` | See chapter [discovery](#discovery) | | `serveResources` | See chapter [serveResources](#serveresources) | | `testRunner` | See chapter [testRunner](#testrunner) | @@ -67,6 +68,35 @@ With `serveCSPReports` set to `true`, the CSP reports are collected and can be d This middleware lists project files with URLs under several `/discovery` endpoints. This is exclusively used by the OpenUI5 test suite application. +### liveReload + +Live reload automatically refreshes the browser whenever you change a source file in your project — no manual reload needed. This shortens the edit/test cycle during development. + +#### Usage + +Live reload is **enabled by default** with `ui5 serve`. Open your app in the browser, edit a source file, save — the page reloads. + +To control it: + +- **CLI flag**: `ui5 serve --live-reload` / `--no-live-reload` +- **Project configuration** (specVersion 5.0+): `server.settings.liveReload` in `ui5.yaml`. See [Configuration](./Configuration.md). + +When the dev server is restarted, the browser automatically reconnects and reloads once the server is back. Saving multiple files at once triggers a single reload, not one per file. + +#### Technical Details + +The following describes how the middleware works internally. This is relevant for advanced users and custom middleware developers — not required for regular usage. + +When live reload is active, the UI5 server opens a WebSocket connection that notifies the browser of source changes and triggers a page reload. + +Reloads are driven by the `BuildServer`, which emits a debounced `sourcesChanged` event whenever watched source files change. A burst of changes therefore results in a single reload notification. + +The `liveReloadClient` middleware serves the client script at `/.ui5/liveReload/client.js`. The script tag is automatically injected into HTML responses by the `serveResources` middleware. + +To prevent intermediate proxies from idle-closing the WebSocket, the client sends a keepalive message every 30 seconds while the connection is open. The server echoes the same message back. + +When the WebSocket connection is lost (e.g. because the server was restarted), the client polls the WebSocket endpoint every second and reloads the page once the server accepts connections again. While the browser tab is hidden, polling pauses until it becomes visible again. + ### serveResources This middleware resolves requests using the [ui5-fs](https://github.com/SAP/ui5-fs)-file system abstraction. diff --git a/package-lock.json b/package-lock.json index df26098f779..5b7d5f15024 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18110,6 +18110,27 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -18775,6 +18796,7 @@ "portscanner": "^2.2.0", "router": "^2.2.0", "spdy": "^4.0.2", + "ws": "^8.21.0", "yesno": "^0.4.0" }, "devDependencies": { diff --git a/packages/server/eslint.config.js b/packages/server/eslint.config.js index dd7d4027eac..436d653b035 100644 --- a/packages/server/eslint.config.js +++ b/packages/server/eslint.config.js @@ -1,3 +1,4 @@ +import globals from "globals"; import eslintCommonConfig from "../../eslint.common.config.js"; export default [ @@ -8,5 +9,18 @@ export default [ ignores: [ "lib/middleware/testRunner/", ] + }, + { + // Live reload client script runs in the browser, not Node.js + files: ["lib/liveReload/client.js"], + languageOptions: { + globals: { + ...globals.browser, + }, + sourceType: "script", + // Specifically setting the ecmaVersion here, in alignment with current UI5 browser support, + // to allow independent changes of the common config without affecting the browser code. + ecmaVersion: 2023, + } } ]; diff --git a/packages/server/lib/helper/getPathname.js b/packages/server/lib/helper/getPathname.js new file mode 100644 index 00000000000..4a23d6616a4 --- /dev/null +++ b/packages/server/lib/helper/getPathname.js @@ -0,0 +1,16 @@ +import parseurl from "parseurl"; + +/** + * Returns the [pathname]{@link https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname} + * of a given request. Any escape sequences will be decoded. + * + * @private + * @param {object} req Request object + * @returns {string} [Pathname]{@link https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname} + * of the given request + */ +export default function getPathname(req) { + let {pathname} = parseurl(req); + pathname = decodeURIComponent(pathname); + return pathname; +} diff --git a/packages/server/lib/liveReload/client.js b/packages/server/lib/liveReload/client.js new file mode 100644 index 00000000000..451938b3f68 --- /dev/null +++ b/packages/server/lib/liveReload/client.js @@ -0,0 +1,112 @@ +(function() { + const WS_PATH = "__UI5_LR_WS_PATH__"; + const WS_TOKEN = "__UI5_LR_WS_TOKEN__"; + // Periodic ping keeps traffic flowing on the WebSocket so that intermediate + // proxies don't idle-close the connection. + const PING_INTERVAL_MS = 30000; + // Interval between reconnect probes after the server connection is lost. + const RECONNECT_INTERVAL_MS = 1000; + + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const wsBase = proto + "//" + location.host + WS_PATH; + const wsUrl = wsBase + "?token=" + WS_TOKEN; // Token is base64url-encoded, so URL-safe without encoding. + const pingMessage = JSON.stringify({type: "ping"}); + let willUnload = false; + window.addEventListener("beforeunload", function() { + willUnload = true; + }); + + const ws = new WebSocket(wsUrl); + const pingInterval = setInterval(function() { + if (ws.readyState === WebSocket.OPEN) { + ws.send(pingMessage); + } + }, PING_INTERVAL_MS); + ws.addEventListener("close", function() { + clearInterval(pingInterval); + if (willUnload) { + return; + } + // Server connection lost: poll until it accepts WebSockets again, then reload. + waitForServer().then(function() { + location.reload(); + }); + }); + ws.addEventListener("message", function(e) { + try { + const msg = JSON.parse(e.data); + if (msg.type === "reload") { + location.reload(); + } + // Ignore other message types (e.g. "ping" echo) + } catch { + // ignore malformed messages + } + }); + + function probeServer() { + return new Promise(function(resolve) { + let probe; + try { + // Reconnect probe: stale page can't know the new token, so use the + // "ui5-ping" subprotocol. Server accepts the handshake and closes + // immediately without enrolling the socket — confirms server-up only. + probe = new WebSocket(wsBase, "ui5-ping"); + } catch { + resolve(false); + return; + } + function done(ok) { + probe.removeEventListener("open", onOpen); + probe.removeEventListener("error", onError); + try { + probe.close(); + } catch { + // ignore + } + resolve(ok); + } + function onOpen() { + done(true); + } + function onError() { + done(false); + } + probe.addEventListener("open", onOpen); + probe.addEventListener("error", onError); + }); + } + + function wait(ms) { + return new Promise(function(resolve) { + setTimeout(resolve, ms); + }); + } + + function waitVisible() { + return new Promise(function(resolve) { + function onChange() { + if (document.visibilityState === "visible") { + document.removeEventListener("visibilitychange", onChange); + resolve(); + } + } + document.addEventListener("visibilitychange", onChange); + }); + } + + function waitForServer() { + function loop() { + if (document.visibilityState !== "visible") { + return waitVisible().then(loop); + } + return probeServer().then(function(ok) { + if (ok) { + return; + } + return wait(RECONNECT_INTERVAL_MS).then(loop); + }); + } + return loop(); + } +})(); diff --git a/packages/server/lib/liveReload/constants.js b/packages/server/lib/liveReload/constants.js new file mode 100644 index 00000000000..06c2e00730d --- /dev/null +++ b/packages/server/lib/liveReload/constants.js @@ -0,0 +1,8 @@ +// Shared constants for the live reload feature. + +export const WS_PATH = "/.ui5/liveReload/ws"; + +export const CLIENT_SCRIPT_PATH = "/.ui5/liveReload/client.js"; + +export const INJECT_SCRIPT_TAG = `\n`; + diff --git a/packages/server/lib/liveReload/server.js b/packages/server/lib/liveReload/server.js new file mode 100644 index 00000000000..2f47c06d33d --- /dev/null +++ b/packages/server/lib/liveReload/server.js @@ -0,0 +1,183 @@ +import {timingSafeEqual} from "node:crypto"; +import {WebSocketServer} from "ws"; +import {getLogger} from "@ui5/logger"; +import {WS_PATH} from "./constants.js"; +import getPathname from "../helper/getPathname.js"; +const log = getLogger("server:liveReloadServer"); + +const RELOAD_MESSAGE = JSON.stringify({type: "reload"}); +const PING_MESSAGE = JSON.stringify({type: "ping"}); + +// Subprotocol used by reconnect probes from stale clients +const PING_SUBPROTOCOL = "ui5-ping"; + +/** + * Attaches a live reload WebSocket server to the given http.Server and wires it + * to the BuildServer's sourcesChanged event. + * + * Connected clients receive a {type: "reload"} message whenever + * sources change. Clients send periodic {type: "ping"} messages + * which the server echoes back, keeping traffic on the wire so that + * intermediate proxies don't idle-close the connection. + * + * Browser-originated upgrades (Origin header set) are gated by a per-process + * token passed as ?token= query parameter to mitigate Cross-site + * WebSocket Hijacking. Reconnect probes from stale clients use the + * ui5-ping subprotocol, which the server accepts and closes + * immediately without enrolling the socket. + * + * @param {object} parameters Parameters + * @param {http.Server} parameters.httpServer HTTP server to attach the upgrade handler to + * @param {@ui5/project/build/BuildServer} parameters.buildServer BuildServer instance to listen on + * @param {string} parameters.token Per-process token required for browser-originated upgrades + * @returns {{close: Function}} Handle with a close function to detach all listeners + * and shut down the WebSocket server + * @private + */ +export default function attachLiveReloadServer({httpServer, buildServer, token}) { + if (typeof token !== "string" || token.length === 0) { + throw new Error("attachLiveReloadServer: token is required"); + } + const tokenBuffer = Buffer.from(token); + const wss = new WebSocketServer({ + noServer: true, + // Echo "ui5-ping" back when the client offers it; reject any other + // subprotocol negotiation. Returning false means "no subprotocol selected". + handleProtocols: (protocols) => protocols.has(PING_SUBPROTOCOL) ? PING_SUBPROTOCOL : false, + // Gates browser-originated upgrades by token + verifyClient: (info) => isUpgradeAuthorized(info) + }); + const clients = new Set(); + + // 'error' events on EventEmitters with no listener crash the process. + wss.on("error", (err) => { + log.error(`Live reload WebSocket server error: ${err.message}`); + }); + + wss.on("connection", (ws) => { + // Reconnect probe from a stale client: confirm server-up and close without + // enrolling. No data is exchanged, so the token is not needed here. + if (ws.protocol === PING_SUBPROTOCOL) { + try { + ws.close(); + } catch { + // ignore + } + return; + } + clients.add(ws); + log.verbose(`Live reload client connected (${clients.size} active)`); + ws.on("error", (err) => { + log.verbose(`Live reload client socket error: ${err.message}`); + clients.delete(ws); + }); + ws.on("close", () => { + clients.delete(ws); + log.verbose(`Live reload client disconnected (${clients.size} active)`); + }); + ws.on("message", (data) => { + // Reply to client pings so intermediate proxies see traffic in both + // directions and don't idle-close the connection. + let type; + try { + type = JSON.parse(data.toString()).type; + } catch { + return; + } + if (type === "ping" && ws.readyState === ws.OPEN) { + try { + ws.send(PING_MESSAGE); + } catch (err) { + log.verbose(`Failed to send ping reply to live reload client: ${err.message}`); + } + } + }); + }); + + function onSourcesChanged() { + if (clients.size === 0) { + return; + } + log.verbose(`Notifying ${clients.size} live reload client(s)`); + for (const ws of clients) { + if (ws.readyState !== ws.OPEN) { + continue; + } + try { + ws.send(RELOAD_MESSAGE); + } catch (err) { + // One bad client must not break the broadcast for others. + log.verbose(`Failed to notify live reload client: ${err.message}`); + } + } + } + buildServer.on("sourcesChanged", onSourcesChanged); + + function isUpgradeAuthorized({req, origin}) { + // No Origin → not a browser upgrade. SOP doesn't apply, and these callers + // have full HTTP access already, so token check provides no real protection. + if (!origin) { + return true; + } + const headers = req.headers || {}; + // Subprotocol-bypassed probes don't carry a token; let them through to + // the upgrade and close them in the connection handler. + const offered = headers["sec-websocket-protocol"]; + if (offered && offered.split(",").map((s) => s.trim()).includes(PING_SUBPROTOCOL)) { + return true; + } + let providedToken; + try { + const url = new URL(req.url, "http://localhost"); + providedToken = url.searchParams.get("token"); + } catch { + return false; + } + if (typeof providedToken !== "string") { + return false; + } + const providedBuffer = Buffer.from(providedToken); + if (providedBuffer.length !== tokenBuffer.length) { + return false; + } + // Length is already equal — timingSafeEqual is safe to call. + return timingSafeEqual(providedBuffer, tokenBuffer); + } + + function onUpgrade(req, socket, head) { + let pathname; + try { + pathname = getPathname(req); + } catch { + return; + } + if (pathname !== WS_PATH) { + // Not ours — leave the socket alone for other upgrade handlers + return; + } + try { + // Authorization is performed via the verifyClient hook on the + // WebSocketServer; on reject ws aborts the handshake with a proper + // 401 response and closes the socket. + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + } catch (err) { + // handleUpgrade can throw on malformed upgrade requests. Destroy the + // socket so the request doesn't hang and no other handler tries to + // reuse the half-upgraded connection. + log.error(`Failed to handle live reload upgrade request: ${err.message}`); + socket.destroy(); + } + } + httpServer.on("upgrade", onUpgrade); + log.verbose("Live reload upgrade handler attached"); + + return { + close() { + httpServer.off("upgrade", onUpgrade); + buildServer.off("sourcesChanged", onSourcesChanged); + wss.close(); + } + }; +} diff --git a/packages/server/lib/middleware/MiddlewareManager.js b/packages/server/lib/middleware/MiddlewareManager.js index 51785181cc7..55f315f546e 100644 --- a/packages/server/lib/middleware/MiddlewareManager.js +++ b/packages/server/lib/middleware/MiddlewareManager.js @@ -33,7 +33,8 @@ const LEGACY_MIDDLEWARE_MAPPING = { class MiddlewareManager { constructor({graph, rootProject, sources, resources, buildReader, options = { sendSAPTargetCSP: false, - serveCSPReports: false + serveCSPReports: false, + liveReload: {active: false, token: null} }}) { if (!graph || !rootProject || !resources || !resources.all || !resources.rootProject || !resources.dependencies) { @@ -251,10 +252,26 @@ class MiddlewareManager { }); await this.addMiddleware("compression"); await this.addMiddleware("cors"); + await this.addMiddleware("liveReloadClient", { + wrapperCallback: ({middleware}) => + ({middlewareUtil}) => middleware({ + middlewareUtil, + active: this.options.liveReload.active, + token: this.options.liveReload.token, + }) + }); await this.addMiddleware("discovery", { mountPath: "/discovery" }); - await this.addMiddleware("serveResources"); + await this.addMiddleware("serveResources", { + wrapperCallback: ({middleware}) => { + return ({resources, middlewareUtil}) => middleware({ + resources, + middlewareUtil, + injectLiveReloadClient: this.options.liveReload.active + }); + } + }); await this.addMiddleware("testRunner"); await this.addMiddleware("versionInfo", { mountPath: "/resources/sap-ui-version.json" @@ -263,7 +280,7 @@ class MiddlewareManager { // as it will reject them with a 405 (Method not allowed) instead of 404 like our old tooling await this.addMiddleware("nonReadRequests"); await this.addMiddleware("serveIndex", { - wrapperCallback: ({middleware: middleware}) => { + wrapperCallback: ({middleware}) => { return ({resources, middlewareUtil}) => middleware({ resources, middlewareUtil, diff --git a/packages/server/lib/middleware/MiddlewareUtil.js b/packages/server/lib/middleware/MiddlewareUtil.js index eea1f003a42..4473e137e59 100644 --- a/packages/server/lib/middleware/MiddlewareUtil.js +++ b/packages/server/lib/middleware/MiddlewareUtil.js @@ -1,4 +1,3 @@ -import parseurl from "parseurl"; import mime from "mime-types"; import { createReaderCollection, @@ -8,6 +7,7 @@ import { createLinkReader, createFlatReader } from "@ui5/fs/resourceFactory"; +import getPathname from "../helper/getPathname.js"; /** * Convenience functions for UI5 Server middleware. @@ -55,9 +55,7 @@ class MiddlewareUtil { * @public */ getPathname(req) { - let {pathname} = parseurl(req); - pathname = decodeURIComponent(pathname); - return pathname; + return getPathname(req); } /** diff --git a/packages/server/lib/middleware/helper/injectHtml.js b/packages/server/lib/middleware/helper/injectHtml.js new file mode 100644 index 00000000000..09ab9cc6217 --- /dev/null +++ b/packages/server/lib/middleware/helper/injectHtml.js @@ -0,0 +1,49 @@ +// Anchor patterns for head-prepend injection. Mirrors Vite's html plugin so +// behavior stays predictable for users coming from that ecosystem. Patterns are +// case-insensitive and match the first occurrence; p1 captures leading +// space/tab indent (not newlines) so the injected block aligns with the anchor. +const headPrependInjectRE = /([ \t]*)]*>/i; +const htmlPrependInjectRE = /([ \t]*)]*>/i; +const doctypePrependInjectRE = //i; + +/** + * Inject a pre-serialized tag string into the head section of an HTML string. + * + * Resolution order (head-prepend semantics): opener → opener → + * → document start. The injected string is treated as opaque: + * the caller controls its internal formatting; this function only chooses the + * insertion point and reuses the anchor's indent on the line it adds. + * + * Implementation note: the callback form of String.prototype.replace is used + * throughout so that $-sequences (e.g. "$&", "$1") in the injected string are + * inserted verbatim instead of being interpreted as back-references. + * + * @param {string} html Source HTML (full document, fragment, or anything between). + * @param {string} tags Pre-serialized tag markup to insert. Empty string is a no-op. + * @returns {string} HTML with tags injected, or the input unchanged when tags is empty. + */ +export function injectHead(html, tags) { + if (!tags) { + return html; + } + + // Primary anchor: first child of . Anchor indent is reused before the + // tags so the injected line sits at the same column as the head opener. + if (headPrependInjectRE.test(html)) { + return html.replace(headPrependInjectRE, (match, p1) => `${match}\n${p1}${tags}`); + } + + // Fallback: after opener, no extra indent. Per spec, head-prepend + // does not consult or as secondary anchors. + if (htmlPrependInjectRE.test(html)) { + return html.replace(htmlPrependInjectRE, (match) => `${match}\n${tags}`); + } + + // Fallback: after , no extra indent. + if (doctypePrependInjectRE.test(html)) { + return html.replace(doctypePrependInjectRE, (match) => `${match}\n${tags}`); + } + + // Final fallback: prepend at position 0 with no separator. + return tags + html; +} diff --git a/packages/server/lib/middleware/helper/isFresh.js b/packages/server/lib/middleware/helper/isFresh.js new file mode 100644 index 00000000000..941efc67bdd --- /dev/null +++ b/packages/server/lib/middleware/helper/isFresh.js @@ -0,0 +1,15 @@ +import fresh from "fresh"; + +/** + * Checks whether the client has a fresh copy of the resource based on the + * ETag response header and the request's conditional headers. + * + * @param {object} req Request + * @param {object} res Response + * @returns {boolean} True if the client's cached copy is still fresh + */ +export default function isFresh(req, res) { + return fresh(req.headers, { + "etag": res.getHeader("ETag") + }); +} diff --git a/packages/server/lib/middleware/liveReloadClient.js b/packages/server/lib/middleware/liveReloadClient.js new file mode 100644 index 00000000000..fcb8cfd0488 --- /dev/null +++ b/packages/server/lib/middleware/liveReloadClient.js @@ -0,0 +1,87 @@ +import {readFile} from "node:fs/promises"; +import {fileURLToPath} from "node:url"; +import etag from "etag"; +import {CLIENT_SCRIPT_PATH, WS_PATH} from "../liveReload/constants.js"; +import isFresh from "./helper/isFresh.js"; + +const CLIENT_SCRIPT_FILE_PATH = fileURLToPath(new URL("../liveReload/client.js", import.meta.url)); + +const WS_PATH_PLACEHOLDER = "__UI5_LR_WS_PATH__"; +const WS_TOKEN_PLACEHOLDER = "__UI5_LR_WS_TOKEN__"; + +async function renderScript(token) { + const template = await readFile(CLIENT_SCRIPT_FILE_PATH, "utf8"); + const clientScript = template + .replace(WS_PATH_PLACEHOLDER, WS_PATH) + .replace(WS_TOKEN_PLACEHOLDER, token); + + // Fail fast on a broken template — better than silently serving a script that can't reach the server. + if ( + clientScript === template || + clientScript.includes(WS_PATH_PLACEHOLDER) || + clientScript.includes(WS_TOKEN_PLACEHOLDER) + ) { + throw new Error( + "liveReloadClient middleware: client.js template is missing one or more expected placeholders"); + } + return clientScript; +} + +/** + * Creates a middleware that serves the live reload client script at + * /.ui5/liveReload/client.js. The actual WebSocket server is + * wired up separately via attachLiveReloadServer in + * server.js. + * + * The script template carries the WebSocket path and per-process token via + * placeholders that are substituted once at construction time. + * + * @param {object} parameters Parameters + * @param {object} parameters.middlewareUtil [MiddlewareUtil]{@link @ui5/server/middleware/MiddlewareUtil} instance + * @param {boolean} [parameters.active=false] Whether live reload is enabled. When false, the middleware + * is mounted but passes all requests through without handling them. + * @param {string} [parameters.token] Per-process WebSocket token. Required when active is true. + * @returns {Promise} Express middleware function + */ +export default async function createLiveReloadClientMiddleware({middlewareUtil, active = false, token}) { + if (!active) { + return function liveReloadClientInactive(req, res, next) { + next(); + }; + } + + if (typeof token !== "string" || token.length === 0) { + throw new Error("liveReloadClient middleware: token is required when active"); + } + + // Read once per middleware instance and serve from memory. + const clientScript = await renderScript(token); + const clientScriptEtag = etag(clientScript); + + return function liveReloadClient(req, res, next) { + try { + if (req.method !== "GET") { + next(); + return; + } + + const path = middlewareUtil.getPathname(req); + + if (path === CLIENT_SCRIPT_PATH) { + res.setHeader("Content-Type", "application/javascript"); + res.setHeader("ETag", clientScriptEtag); + + if (isFresh(req, res)) { + res.statusCode = 304; + res.end(); + return; + } + res.end(clientScript); + } else { + next(); + } + } catch (err) { + next(err); + } + }; +} diff --git a/packages/server/lib/middleware/middlewareRepository.js b/packages/server/lib/middleware/middlewareRepository.js index 863380de18e..613af7a738e 100644 --- a/packages/server/lib/middleware/middlewareRepository.js +++ b/packages/server/lib/middleware/middlewareRepository.js @@ -2,6 +2,7 @@ const middlewareInfos = { compression: {path: "compression"}, cors: {path: "cors"}, csp: {path: "./csp.js"}, + liveReloadClient: {path: "./liveReloadClient.js"}, serveResources: {path: "./serveResources.js"}, serveIndex: {path: "./serveIndex.js"}, discovery: {path: "./discovery.js"}, diff --git a/packages/server/lib/middleware/serveResources.js b/packages/server/lib/middleware/serveResources.js index 2278c18543f..9b32b275b58 100644 --- a/packages/server/lib/middleware/serveResources.js +++ b/packages/server/lib/middleware/serveResources.js @@ -1,11 +1,7 @@ import etag from "etag"; -import fresh from "fresh"; - -function isFresh(req, res) { - return fresh(req.headers, { - "etag": res.getHeader("ETag") - }); -} +import {INJECT_SCRIPT_TAG} from "../liveReload/constants.js"; +import {injectHead} from "./helper/injectHtml.js"; +import isFresh from "./helper/isFresh.js"; /** * Creates and returns the middleware to serve project resources. @@ -14,9 +10,11 @@ function isFresh(req, res) { * @param {object} parameters Parameters * @param {@ui5/server/internal/MiddlewareManager.middlewareResources} parameters.resources Parameters * @param {object} parameters.middlewareUtil [MiddlewareUtil]{@link @ui5/server/middleware/MiddlewareUtil} instance + * @param {boolean} [parameters.injectLiveReloadClient=false] Whether to inject the live reload client into HTML + * responses * @returns {Function} Returns a server middleware closure. */ -function createMiddleware({resources, middlewareUtil}) { +function createMiddleware({resources, middlewareUtil, injectLiveReloadClient = false}) { return async function serveResources(req, res, next) { try { const pathname = middlewareUtil.getPathname(req); @@ -29,14 +27,21 @@ function createMiddleware({resources, middlewareUtil}) { const resourcePath = resource.getPath(); - const {contentType} = middlewareUtil.getMimeInfo(resourcePath); + const mimeInfo = middlewareUtil.getMimeInfo(resourcePath); if (!res.getHeader("Content-Type")) { - res.setHeader("Content-Type", contentType); + res.setHeader("Content-Type", mimeInfo.contentType); } + // Determine if the live reload client should be injected into the response + const inject = injectLiveReloadClient && mimeInfo.type === "text/html"; + // Enable ETag caching const resourceIntegrity = await resource.getIntegrity(); - res.setHeader("ETag", etag(resourceIntegrity)); + + const effectiveEtag = inject ? + etag(resourceIntegrity + ":" + INJECT_SCRIPT_TAG) : + etag(resourceIntegrity); + res.setHeader("ETag", effectiveEtag); if (isFresh(req, res)) { // client has a fresh copy of the resource @@ -44,8 +49,16 @@ function createMiddleware({resources, middlewareUtil}) { res.end(); return; } - - res.send(await resource.getBuffer()); + if (inject) { + const html = await resource.getString(); + // Inject as early as possible so the WebSocket connects before any app + // script runs. If a later script throws or hangs synchronously, a tag placed + // further down would never execute and the page would stay stuck — exactly + // when live reload is needed most. + res.send(injectHead(html, INJECT_SCRIPT_TAG)); + } else { + res.send(await resource.getBuffer()); + } } catch (err) { next(err); } diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index c5fde813861..0dacdcf0f20 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -1,6 +1,8 @@ +import {getRandomValues} from "node:crypto"; import express from "express"; import portscanner from "portscanner"; import MiddlewareManager from "./middleware/MiddlewareManager.js"; +import attachLiveReloadServer from "./liveReload/server.js"; import {createReaderCollection} from "@ui5/fs/resourceFactory"; import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; import {getLogger} from "@ui5/logger"; @@ -121,6 +123,7 @@ async function _addSsl({app, key, cert}) { * @param {string} [options.key] Path to private key to be used for https * @param {string} [options.cert] Path to certificate to be used for for https * @param {boolean} [options.simpleIndex=false] Use a simplified view for the server directory listing + * @param {boolean} [options.liveReload=false] Automatically reload connected browsers when project sources change * @param {boolean} [options.acceptRemoteConnections=false] If true, listens to remote connections and * not only to localhost connections * @param {boolean|module:@ui5/server.SAPTargetCSPOptions} [options.sendSAPTargetCSP=false] @@ -142,7 +145,7 @@ async function _addSsl({app, key, cert}) { export async function serve(graph, { port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false, sendSAPTargetCSP = false, - simpleIndex = false, serveCSPReports = false, cache = Cache.Default, + simpleIndex = false, liveReload = false, serveCSPReports = false, cache = Cache.Default, ui5DataDir, }, error) { const rootProject = graph.getRoot(); @@ -203,6 +206,13 @@ export async function serve(graph, { } }); + // Random 72 bits (9 * 8 bits), base64url-encoded to a 12-character string, should be sufficient for uniqueness. + // OWASP recommends at least 64 bits of entropy for session IDs: + // https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length + const webSocketToken = liveReload ? + Buffer.from(getRandomValues(new Uint8Array(9))).toString("base64url") : + null; + const middlewareManager = new MiddlewareManager({ graph, rootProject, @@ -211,7 +221,11 @@ export async function serve(graph, { options: { sendSAPTargetCSP, serveCSPReports, - simpleIndex + simpleIndex, + liveReload: { + active: liveReload, + token: webSocketToken + } } }); @@ -236,10 +250,16 @@ export async function serve(graph, { throw err; } + let liveReloadHandle; + if (liveReload) { + liveReloadHandle = attachLiveReloadServer({httpServer: server, buildServer, token: webSocketToken}); + } + return { h2, port, close: function(callback) { + liveReloadHandle?.close(); buildServer.destroy().then(() => { server.close(callback); }, () => { diff --git a/packages/server/nyc.config.js b/packages/server/nyc.config.js index a9ece69ca71..541c807802e 100644 --- a/packages/server/nyc.config.js +++ b/packages/server/nyc.config.js @@ -3,6 +3,9 @@ import nycCommonConfig from "../../nyc.common.config.js"; // Exclude TestRunner files from coverage as they are originally maintained at https://github.com/UI5/openui5/tree/master/src/sap.ui.core/test/sap/ui/qunit nycCommonConfig.exclude.push("lib/middleware/testRunner/TestRunner.js"); +// Tested via vm.runInContext (test/lib/server/liveReloadClient.integration.js), so nyc does not track coverage. +nycCommonConfig.exclude.push("lib/liveReload/client.js"); + export default { ...nycCommonConfig, "statements": 90, diff --git a/packages/server/package.json b/packages/server/package.json index a9da3e7b83e..7bdefafe66d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -103,6 +103,7 @@ "portscanner": "^2.2.0", "router": "^2.2.0", "spdy": "^4.0.2", + "ws": "^8.21.0", "yesno": "^0.4.0" }, "devDependencies": { diff --git a/packages/server/test/lib/server/helper/getPathname.js b/packages/server/test/lib/server/helper/getPathname.js new file mode 100644 index 00000000000..dab433e09b3 --- /dev/null +++ b/packages/server/test/lib/server/helper/getPathname.js @@ -0,0 +1,19 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; + +test.afterEach.always(() => { + sinon.restore(); +}); + +test.serial("getPathname decodes escape sequences", async (t) => { + const parseurlStub = sinon.stub().returns({pathname: "path%20name"}); + const getPathname = await esmock("../../../../lib/helper/getPathname.js", { + parseurl: parseurlStub + }); + const pathname = getPathname("req"); + + t.is(parseurlStub.callCount, 1, "parseurl got called once"); + t.is(parseurlStub.getCall(0).args[0], "req", "parseurl got called with correct argument"); + t.is(pathname, "path name", "Correct pathname returned"); +}); diff --git a/packages/server/test/lib/server/liveReload/client.integration.js b/packages/server/test/lib/server/liveReload/client.integration.js new file mode 100644 index 00000000000..e42b950327a --- /dev/null +++ b/packages/server/test/lib/server/liveReload/client.integration.js @@ -0,0 +1,365 @@ +import test from "ava"; +import sinon from "sinon"; +import vm from "node:vm"; +import http from "node:http"; +import {EventEmitter} from "node:events"; +import {readFile} from "node:fs/promises"; +import {fileURLToPath} from "node:url"; +import {WebSocket} from "ws"; +import attachLiveReloadServer from "../../../../lib/liveReload/server.js"; +import {WS_PATH} from "../../../../lib/liveReload/constants.js"; + +const TEST_TOKEN = "test-token"; + +const CLIENT_FILE = fileURLToPath( + new URL("../../../../lib/liveReload/client.js", import.meta.url) +); +const clientTemplate = await readFile(CLIENT_FILE, "utf8"); +const clientSource = clientTemplate + .replace("__UI5_LR_WS_PATH__", WS_PATH) + .replace("__UI5_LR_WS_TOKEN__", TEST_TOKEN); + +function startServer({port = 0} = {}) { + return new Promise((resolve, reject) => { + const httpServer = http.createServer(); + httpServer.once("error", reject); + httpServer.listen(port, "127.0.0.1", () => { + httpServer.removeListener("error", reject); + const buildServer = new EventEmitter(); + const handle = attachLiveReloadServer({httpServer, buildServer, token: TEST_TOKEN}); + resolve({httpServer, port: httpServer.address().port, buildServer, handle}); + }); + }); +} + +function runClient({port, visibilityState = "visible"} = {}) { + const state = {visibilityState}; + const windowListeners = {}; + const documentListeners = {}; + const registeredIntervals = []; + const registeredTimeouts = []; + const createdWebSockets = []; + + const fakeLocation = { + protocol: "http:", + host: `127.0.0.1:${port}`, + reload: sinon.stub(), + }; + + const fakeWindow = { + addEventListener(type, fn) { + if (!windowListeners[type]) { + windowListeners[type] = []; + } + windowListeners[type].push(fn); + }, + }; + + const fakeDocument = { + get visibilityState() { + return state.visibilityState; + }, + addEventListener(type, fn) { + if (!documentListeners[type]) { + documentListeners[type] = []; + } + documentListeners[type].push(fn); + }, + removeEventListener(type, fn) { + const listeners = documentListeners[type]; + if (!listeners) { + return; + } + const index = listeners.indexOf(fn); + if (index >= 0) { + listeners.splice(index, 1); + } + }, + }; + + // Wrap the real WebSocket constructor so we can track all instances + // created by client.js (for assertions and teardown). + const WebSocketProxy = new Proxy(WebSocket, { + construct(target, args) { + const instance = Reflect.construct(target, args); + createdWebSockets.push(instance); + return instance; + }, + }); + + const sandbox = { + WebSocket: WebSocketProxy, + location: fakeLocation, + window: fakeWindow, + document: fakeDocument, + + // Fake timers: both setInterval and setTimeout are captured so + // tests can fire callbacks on demand without real delays. + setInterval(fn, ms) { + const handle = {fn, ms}; + registeredIntervals.push(handle); + return handle; + }, + clearInterval(handle) { + const index = registeredIntervals.indexOf(handle); + if (index >= 0) { + registeredIntervals.splice(index, 1); + } + }, + setTimeout(fn, ms) { + const handle = {fn, ms}; + registeredTimeouts.push(handle); + return handle; + }, + clearTimeout(handle) { + const index = registeredTimeouts.indexOf(handle); + if (index >= 0) { + registeredTimeouts.splice(index, 1); + } + }, + + // client.js does not use URL or Buffer directly, but the `ws` + // library's WebSocket constructor internally needs them to parse + // the server address and handle binary frames. Inside a VM + // context, globals from the outer realm are not available unless + // explicitly provided. + URL, + Buffer, + }; + + vm.createContext(sandbox); + vm.runInContext(clientSource, sandbox, {filename: "client.js"}); + + return { + sandbox, + fakeLocation, + createdWebSockets, + registeredIntervals, + registeredTimeouts, + + firePing() { + registeredIntervals.slice().forEach((interval) => interval.fn()); + }, + fireNextTimeout() { + const handle = registeredTimeouts.shift(); + if (handle) { + handle.fn(); + } + }, + emitWindowEvent(type, ...args) { + const listeners = windowListeners[type] || []; + listeners.slice().forEach((fn) => fn(...args)); + }, + setVisibility(newState) { + state.visibilityState = newState; + const listeners = documentListeners.visibilitychange || []; + listeners.slice().forEach((fn) => fn()); + }, + }; +} + +// Polls `predicate` on real wall-clock time (not the sandbox's fake timers). +// Use this to await real WebSocket events from the server; use firePing() / +// fireNextTimeout() to drive client.js's sandboxed timers. +function waitFor(predicate, {timeout = 5000, step = 25} = {}) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + if (predicate()) { + return resolve(); + } + if (Date.now() - start > timeout) { + return reject(new Error("waitFor timed out")); + } + setTimeout(tick, step); + }; + tick(); + }); +} + +function closeHttp(httpServer) { + return new Promise((resolve) => httpServer.close(resolve)); +} + +// Terminates every WebSocket instance the client created so the upgraded +// socket is released. http.Server stops tracking sockets after a successful +// WebSocket upgrade — `close()` would otherwise wait forever for the +// connection to drain. Call this before closeHttp(). +function terminateClientSockets(client) { + for (const ws of client.createdWebSockets) { + if (ws.readyState !== WebSocket.CLOSED) { + try { + ws.terminate(); + } catch { + // ignore + } + } + } +} + +async function teardown({handle, httpServer, client}) { + if (handle) { + handle.close(); + } + if (client) { + terminateClientSockets(client); + } + if (httpServer && httpServer.listening) { + await closeHttp(httpServer); + } +} + +test.afterEach.always(() => { + sinon.restore(); +}); + +test.serial("Connect + reload broadcast", async (t) => { + const {httpServer, port, buildServer, handle} = await startServer(); + const client = runClient({port}); + try { + await waitFor(() => + client.createdWebSockets[0]?.readyState === WebSocket.OPEN + ); + buildServer.emit("sourcesChanged"); + await waitFor(() => client.fakeLocation.reload.callCount === 1); + t.is(client.fakeLocation.reload.callCount, 1); + } finally { + await teardown({handle, httpServer, client}); + } +}); + +test.serial("Client ping → server echo, no reload", async (t) => { + const {httpServer, port, handle} = await startServer(); + const client = runClient({port}); + try { + await waitFor(() => + client.createdWebSockets[0]?.readyState === WebSocket.OPEN + ); + + const ws = client.createdWebSockets[0]; + const pingEcho = new Promise((resolve) => ws.once("message", resolve)); + + client.firePing(); + const data = await pingEcho; + t.is(JSON.parse(data.toString()).type, "ping"); + t.is(client.fakeLocation.reload.callCount, 0); + } finally { + await teardown({handle, httpServer, client}); + } +}); + +test.serial("Server close triggers reconnect → reload after server returns", async (t) => { + const first = await startServer(); + const client = runClient({port: first.port}); + try { + await waitFor(() => + client.createdWebSockets[0]?.readyState === WebSocket.OPEN + ); + + await teardown({handle: first.handle, httpServer: first.httpServer, client}); + + // Wait until client observed the close and scheduled the reconnect probe + await waitFor(() => client.registeredTimeouts.length > 0); + + // Spin up a fresh server on the SAME port + const second = await startServer({port: first.port}); + try { + // Drive reconnect probes until the new server accepts and the client reloads + await waitFor(() => { + if (client.fakeLocation.reload.callCount >= 1) { + return true; + } + if (client.registeredTimeouts.length > 0) { + client.fireNextTimeout(); + } + return false; + }); + t.is(client.fakeLocation.reload.callCount, 1); + } finally { + await teardown({handle: second.handle, httpServer: second.httpServer, client}); + } + } catch (err) { + // First-server teardown already happened in the try block on the + // happy path; ensure client sockets are released if we threw earlier. + terminateClientSockets(client); + throw err; + } +}); + +test.serial("beforeunload suppresses reconnect-driven reload", async (t) => { + const {httpServer, port, handle} = await startServer(); + const client = runClient({port}); + try { + await waitFor(() => + client.createdWebSockets[0]?.readyState === WebSocket.OPEN + ); + + client.emitWindowEvent("beforeunload"); + await teardown({handle, httpServer, client}); + + // Wait for the close to register on the client + await waitFor(() => + client.createdWebSockets[0]?.readyState === WebSocket.CLOSED || + client.createdWebSockets[0]?.readyState === WebSocket.CLOSING); + + // Drain any scheduled timeouts; none should lead to a reload + while (client.registeredTimeouts.length > 0) { + client.fireNextTimeout(); + } + // Give any pending microtasks a chance + await new Promise((resolve) => setTimeout(resolve, 50)); + t.is(client.fakeLocation.reload.callCount, 0); + } catch (err) { + terminateClientSockets(client); + throw err; + } +}); + +test.serial("Hidden visibility pauses probing; visible resumes", async (t) => { + const first = await startServer(); + const client = runClient({port: first.port, visibilityState: "hidden"}); + try { + await waitFor(() => + client.createdWebSockets[0]?.readyState === WebSocket.OPEN + ); + + await teardown({handle: first.handle, httpServer: first.httpServer, client}); + + // Wait for client to observe close + await waitFor(() => + client.createdWebSockets[0]?.readyState === WebSocket.CLOSED || + client.createdWebSockets[0]?.readyState === WebSocket.CLOSING); + + // Drain timeouts: while hidden, the loop awaits visibility, so probes + // should NOT spawn new WebSocket instances. + while (client.registeredTimeouts.length > 0) { + client.fireNextTimeout(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + t.is(client.fakeLocation.reload.callCount, 0); + t.is(client.createdWebSockets.length, 1, + "No new WebSocket constructed while hidden"); + + // Restart server on same port + const second = await startServer({port: first.port}); + try { + // Flip visibility, which resolves the waitVisible() promise and runs the loop + client.setVisibility("visible"); + await waitFor(() => { + if (client.fakeLocation.reload.callCount >= 1) { + return true; + } + if (client.registeredTimeouts.length > 0) { + client.fireNextTimeout(); + } + return false; + }); + t.is(client.fakeLocation.reload.callCount, 1); + } finally { + await teardown({handle: second.handle, httpServer: second.httpServer, client}); + } + } catch (err) { + terminateClientSockets(client); + throw err; + } +}); diff --git a/packages/server/test/lib/server/liveReload/server.js b/packages/server/test/lib/server/liveReload/server.js new file mode 100644 index 00000000000..791b2016365 --- /dev/null +++ b/packages/server/test/lib/server/liveReload/server.js @@ -0,0 +1,431 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import {EventEmitter} from "node:events"; + +test.afterEach.always(() => { + sinon.restore(); +}); + +// Loads the module with `ws` replaced by a controllable FakeWebSocketServer +// that exposes the constructed instance for inspection. +async function loadModule() { + let lastInstance = null; + class FakeWebSocketServer extends EventEmitter { + constructor(opts) { + super(); + this.opts = opts; + this.handleUpgrade = sinon.stub().callsFake((req, socket, head, cb) => { + const ws = createFakeClient(); + cb(ws); + }); + this.close = sinon.stub(); + lastInstance = this; + } + } + const module = await esmock("../../../../lib/liveReload/server.js", { + "ws": {WebSocketServer: FakeWebSocketServer} + }); + return {module, getInstance: () => lastInstance}; +} + +function createFakeClient() { + const ws = new EventEmitter(); + ws.OPEN = 1; + ws.readyState = 1; + ws.send = sinon.stub(); + return ws; +} + +test("Instantiates WebSocketServer in noServer mode and attaches upgrade handler", async (t) => { + const {module, getInstance} = await loadModule(); + const httpServer = new EventEmitter(); + sinon.spy(httpServer, "on"); + const buildServer = new EventEmitter(); + + module.default({httpServer, buildServer, token: "test-token"}); + + t.truthy(getInstance()); + t.is(getInstance().opts.noServer, true); + t.is(typeof getInstance().opts.handleProtocols, "function", "handleProtocols configured"); + const upgradeListenerCalls = httpServer.on.getCalls().filter((c) => c.args[0] === "upgrade"); + t.is(upgradeListenerCalls.length, 1, "upgrade listener attached"); +}); + +test("Upgrade for matching path calls handleUpgrade and emits connection", async (t) => { + const {module, getInstance} = await loadModule(); + const httpServer = new EventEmitter(); + module.default({httpServer, buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + const socket = {destroy: sinon.stub()}; + const head = Buffer.alloc(0); + const upgradeReq = {url: "/.ui5/liveReload/ws"}; + + httpServer.emit("upgrade", upgradeReq, socket, head); + + t.is(wss.handleUpgrade.callCount, 1); + t.deepEqual(wss.handleUpgrade.getCall(0).args.slice(0, 3), [upgradeReq, socket, head]); +}); + +test("Upgrade for non-matching path is ignored", async (t) => { + const {module, getInstance} = await loadModule(); + const httpServer = new EventEmitter(); + module.default({httpServer, buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + const socket = {destroy: sinon.stub()}; + httpServer.emit("upgrade", {url: "/other/socket"}, socket, Buffer.alloc(0)); + + t.is(wss.handleUpgrade.callCount, 0); + t.is(socket.destroy.callCount, 0, "socket left for other handlers"); +}); + +test("Upgrade with malformed URL is ignored", async (t) => { + const {module, getInstance} = await loadModule(); + const httpServer = new EventEmitter(); + module.default({httpServer, buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + httpServer.emit("upgrade", {url: undefined}, {destroy: sinon.stub()}, Buffer.alloc(0)); + + t.is(wss.handleUpgrade.callCount, 0); +}); + +test("sourcesChanged: broadcasts {type: reload} JSON to OPEN clients", async (t) => { + const {module, getInstance} = await loadModule(); + const buildServer = new EventEmitter(); + module.default({httpServer: new EventEmitter(), buildServer, token: "test-token"}); + + const wss = getInstance(); + const open1 = createFakeClient(); + const open2 = createFakeClient(); + const closed = createFakeClient(); + closed.readyState = 3; // CLOSED + + wss.emit("connection", open1); + wss.emit("connection", open2); + wss.emit("connection", closed); + + buildServer.emit("sourcesChanged"); + + const expected = JSON.stringify({type: "reload"}); + t.is(open1.send.callCount, 1); + t.is(open1.send.getCall(0).args[0], expected); + t.is(open2.send.callCount, 1); + t.is(open2.send.getCall(0).args[0], expected); + t.is(closed.send.callCount, 0, "non-OPEN client not sent"); +}); + +test("sourcesChanged: skips broadcast when no clients connected", async (t) => { + const {module} = await loadModule(); + const buildServer = new EventEmitter(); + module.default({httpServer: new EventEmitter(), buildServer, token: "test-token"}); + + t.notThrows(() => buildServer.emit("sourcesChanged")); +}); + +test("Client close removes it from broadcast set", async (t) => { + const {module, getInstance} = await loadModule(); + const buildServer = new EventEmitter(); + module.default({httpServer: new EventEmitter(), buildServer, token: "test-token"}); + + const wss = getInstance(); + const ws1 = createFakeClient(); + const ws2 = createFakeClient(); + wss.emit("connection", ws1); + wss.emit("connection", ws2); + + ws1.emit("close"); + + buildServer.emit("sourcesChanged"); + + t.is(ws1.send.callCount, 0, "closed client not notified"); + t.is(ws2.send.callCount, 1, "remaining client still notified"); +}); + +test("close(): detaches upgrade handler, sourcesChanged listener, and closes wss", async (t) => { + const {module, getInstance} = await loadModule(); + const httpServer = new EventEmitter(); + const buildServer = new EventEmitter(); + + const handle = module.default({httpServer, buildServer, token: "test-token"}); + const wss = getInstance(); + + t.is(httpServer.listenerCount("upgrade"), 1); + t.is(buildServer.listenerCount("sourcesChanged"), 1); + + handle.close(); + + t.is(httpServer.listenerCount("upgrade"), 0, "upgrade listener removed"); + t.is(buildServer.listenerCount("sourcesChanged"), 0, "sourcesChanged listener removed"); + t.is(wss.close.callCount, 1, "wss.close called"); +}); + +test("Echoes ping message back to OPEN client", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + const ws = createFakeClient(); + wss.emit("connection", ws); + + ws.emit("message", Buffer.from(JSON.stringify({type: "ping"}))); + + t.is(ws.send.callCount, 1); + t.is(ws.send.getCall(0).args[0], JSON.stringify({type: "ping"})); +}); + +test("Does not echo ping when client is not OPEN", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + const ws = createFakeClient(); + wss.emit("connection", ws); + ws.readyState = 3; // CLOSED + + ws.emit("message", Buffer.from(JSON.stringify({type: "ping"}))); + + t.is(ws.send.callCount, 0); +}); + +test("Ignores non-ping messages from clients", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + const ws = createFakeClient(); + wss.emit("connection", ws); + + ws.emit("message", Buffer.from(JSON.stringify({type: "something-else"}))); + + t.is(ws.send.callCount, 0); +}); + +test("Ignores malformed JSON messages from clients", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + const ws = createFakeClient(); + wss.emit("connection", ws); + + t.notThrows(() => ws.emit("message", Buffer.from("not json"))); + t.is(ws.send.callCount, 0); +}); + +test("Attaches 'error' listener to wss to prevent unhandled error crash", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + t.true(wss.listenerCount("error") >= 1, "wss has error listener"); + t.notThrows(() => wss.emit("error", new Error("boom")), "emitting error does not crash"); +}); + +test("Client socket 'error' is handled and client is removed from broadcast set", async (t) => { + const {module, getInstance} = await loadModule(); + const buildServer = new EventEmitter(); + module.default({httpServer: new EventEmitter(), buildServer, token: "test-token"}); + + const wss = getInstance(); + const ws1 = createFakeClient(); + const ws2 = createFakeClient(); + wss.emit("connection", ws1); + wss.emit("connection", ws2); + + t.true(ws1.listenerCount("error") >= 1, "client has error listener"); + t.notThrows(() => ws1.emit("error", new Error("client boom"))); + + buildServer.emit("sourcesChanged"); + + t.is(ws1.send.callCount, 0, "errored client is removed and not notified"); + t.is(ws2.send.callCount, 1, "remaining client still notified"); +}); + +test("sourcesChanged: ws.send throwing for one client does not break loop for others", async (t) => { + const {module, getInstance} = await loadModule(); + const buildServer = new EventEmitter(); + module.default({httpServer: new EventEmitter(), buildServer, token: "test-token"}); + + const wss = getInstance(); + const bad = createFakeClient(); + bad.send = sinon.stub().throws(new Error("send failed")); + const good = createFakeClient(); + wss.emit("connection", bad); + wss.emit("connection", good); + + t.notThrows(() => buildServer.emit("sourcesChanged")); + t.is(bad.send.callCount, 1); + t.is(good.send.callCount, 1, "good client still notified after bad client throws"); +}); + +test("Ping reply: ws.send throwing does not propagate", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + const ws = createFakeClient(); + ws.send = sinon.stub().throws(new Error("send failed")); + wss.emit("connection", ws); + + t.notThrows(() => ws.emit("message", Buffer.from(JSON.stringify({type: "ping"})))); + t.is(ws.send.callCount, 1); +}); + +test("Upgrade: handleUpgrade throwing destroys the socket", async (t) => { + const {module, getInstance} = await loadModule(); + const httpServer = new EventEmitter(); + module.default({httpServer, buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + wss.handleUpgrade = sinon.stub().throws(new Error("upgrade failed")); + + const socket = {destroy: sinon.stub()}; + t.notThrows(() => httpServer.emit("upgrade", + {url: "/.ui5/liveReload/ws"}, socket, Buffer.alloc(0))); + + t.is(socket.destroy.callCount, 1, "socket destroyed after handleUpgrade throws"); +}); + +test("Constructor throws when token is missing", async (t) => { + const {module} = await loadModule(); + t.throws(() => module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter()}), + {message: /token is required/}); +}); + +test("handleProtocols selects ui5-ping when offered, rejects otherwise", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const {handleProtocols} = getInstance().opts; + t.is(handleProtocols(new Set(["ui5-ping"])), "ui5-ping"); + t.is(handleProtocols(new Set(["ui5-ping", "other"])), "ui5-ping"); + t.is(handleProtocols(new Set(["other"])), false); + t.is(handleProtocols(new Set()), false); +}); + +test("Browser upgrade (Origin set) without token is rejected by verifyClient", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const {verifyClient} = getInstance().opts; + t.is(verifyClient({ + origin: "http://evil.example", + req: { + url: "/.ui5/liveReload/ws", + headers: {} + } + }), false); +}); + +test("Browser upgrade with wrong-length token is rejected (no timingSafeEqual throw)", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const {verifyClient} = getInstance().opts; + const info = { + origin: "http://evil.example", + req: { + url: "/.ui5/liveReload/ws?token=short", + headers: {} + } + }; + t.notThrows(() => verifyClient(info)); + t.is(verifyClient(info), false); +}); + +test("Browser upgrade with wrong token of correct length is rejected", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const {verifyClient} = getInstance().opts; + t.is(verifyClient({ + origin: "http://evil.example", + req: { + url: "/.ui5/liveReload/ws?token=wrng-token", + headers: {} + } + }), false); +}); + +test("Browser upgrade with correct token is accepted", async (t) => { + const {module, getInstance} = await loadModule(); + const httpServer = new EventEmitter(); + module.default({httpServer, buildServer: new EventEmitter(), token: "test-token"}); + + const wss = getInstance(); + t.is(wss.opts.verifyClient({ + origin: "http://localhost:8080", + req: { + url: "/.ui5/liveReload/ws?token=test-token", + headers: {} + } + }), true); + + // onUpgrade should still call handleUpgrade (verifyClient does the gating + // inside ws). With our fake ws, verifyClient is not invoked from + // handleUpgrade — assert handleUpgrade was reached. + const socket = {write: sinon.stub(), destroy: sinon.stub()}; + httpServer.emit("upgrade", { + url: "/.ui5/liveReload/ws?token=test-token", + headers: {} + }, socket, Buffer.alloc(0)); + t.is(wss.handleUpgrade.callCount, 1); +}); + +test("Non-browser upgrade (no Origin) is accepted regardless of token", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const {verifyClient} = getInstance().opts; + t.is(verifyClient({req: {url: "/.ui5/liveReload/ws", headers: {}}}), true); +}); + +test("verifyClient: ui5-ping subprotocol bypass returns true regardless of token/origin", async (t) => { + const {module, getInstance} = await loadModule(); + module.default({httpServer: new EventEmitter(), buildServer: new EventEmitter(), token: "test-token"}); + + const {verifyClient} = getInstance().opts; + // no origin, no token + t.is(verifyClient({req: { + url: "/.ui5/liveReload/ws", + headers: {"sec-websocket-protocol": "ui5-ping"} + }}), true); + // origin set, no token + t.is(verifyClient({ + origin: "http://evil.example", + req: { + url: "/.ui5/liveReload/ws", + headers: {"sec-websocket-protocol": "ui5-ping"} + } + }), true); + // subprotocol list with extra entries still matches + t.is(verifyClient({ + origin: "http://evil.example", + req: { + url: "/.ui5/liveReload/ws", + headers: {"sec-websocket-protocol": "other, ui5-ping"} + } + }), true); +}); + +test("ui5-ping subprotocol probe is closed immediately and not enrolled", async (t) => { + const {module, getInstance} = await loadModule(); + const buildServer = new EventEmitter(); + module.default({httpServer: new EventEmitter(), buildServer, token: "test-token"}); + + const wss = getInstance(); + const probe = createFakeClient(); + probe.protocol = "ui5-ping"; + probe.close = sinon.stub(); + + wss.emit("connection", probe); + + t.is(probe.close.callCount, 1, "probe socket closed immediately"); + + // Verify probe is NOT enrolled in the broadcast set. + buildServer.emit("sourcesChanged"); + t.is(probe.send.callCount, 0, "probe receives no broadcast"); +}); diff --git a/packages/server/test/lib/server/middleware/MiddlewareManager.js b/packages/server/test/lib/server/middleware/MiddlewareManager.js index b2b12c122cf..f528d3c1413 100644 --- a/packages/server/test/lib/server/middleware/MiddlewareManager.js +++ b/packages/server/test/lib/server/middleware/MiddlewareManager.js @@ -462,7 +462,7 @@ test("addStandardMiddleware: Adds standard middleware in correct order", async ( const addMiddlewareStub = sinon.stub(middlewareManager, "addMiddleware").resolves(); await middlewareManager.addStandardMiddleware(); - t.is(addMiddlewareStub.callCount, 9, "Expected count of middleware got added"); + t.is(addMiddlewareStub.callCount, 10, "Expected count of middleware got added"); const addedMiddlewareNames = []; for (let i = 0; i < addMiddlewareStub.callCount; i++) { addedMiddlewareNames.push(addMiddlewareStub.getCall(i).args[0]); @@ -471,6 +471,7 @@ test("addStandardMiddleware: Adds standard middleware in correct order", async ( "csp", "compression", "cors", + "liveReloadClient", "discovery", "serveResources", "testRunner", diff --git a/packages/server/test/lib/server/middleware/MiddlewareUtil.js b/packages/server/test/lib/server/middleware/MiddlewareUtil.js index d62af6c9db9..ed68d4f062f 100644 --- a/packages/server/test/lib/server/middleware/MiddlewareUtil.js +++ b/packages/server/test/lib/server/middleware/MiddlewareUtil.js @@ -14,15 +14,15 @@ function getSpecificationVersion(specVersion) { } test.serial("getPathname", async (t) => { - const parseurlStub = sinon.stub().returns({pathname: "path%20name"}); + const getPathnameStub = sinon.stub().returns("path name"); const MiddlewareUtil = await esmock("../../../../lib/middleware/MiddlewareUtil.js", { - parseurl: parseurlStub + "../../../../lib/helper/getPathname.js": {default: getPathnameStub} }); const middlewareUtil = new MiddlewareUtil({graph: "graph", project: "project"}); const pathname = middlewareUtil.getPathname("req"); - t.is(parseurlStub.callCount, 1, "parseurl got called once"); - t.is(parseurlStub.getCall(0).args[0], "req", "parseurl got called with correct argument"); + t.is(getPathnameStub.callCount, 1, "getPathname helper got called once"); + t.is(getPathnameStub.getCall(0).args[0], "req", "getPathname got called with correct argument"); t.is(pathname, "path name", "Correct pathname returned"); }); diff --git a/packages/server/test/lib/server/middleware/helper/injectHtml.js b/packages/server/test/lib/server/middleware/helper/injectHtml.js new file mode 100644 index 00000000000..ab7114b71e8 --- /dev/null +++ b/packages/server/test/lib/server/middleware/helper/injectHtml.js @@ -0,0 +1,142 @@ +import test from "ava"; +import {injectHead} from "../../../../../lib/middleware/helper/injectHtml.js"; + +const TAG = ``; + +test("Empty tags string returns html unchanged", (t) => { + const html = ``; + t.is(injectHead(html, ""), html); +}); + +test(" opener: tags inserted immediately after opener on a new line", (t) => { + const html = `\n\n\n\n\n`; + const out = injectHead(html, TAG); + t.is(out, + `\n\n\n${TAG}\n\n\n`); +}); + +test(" opener with attributes still matches", (t) => { + const html = `x`; + const out = injectHead(html, TAG); + t.is(out, `\n${TAG}x`); +}); + +test(" match is case-insensitive ()", (t) => { + const html = ``; + const out = injectHead(html, TAG); + t.is(out, `\n${TAG}`); +}); + +test(" opener: anchor indent is reused on the injected line", (t) => { + const html = `\n\t\n\t\n`; + const out = injectHead(html, TAG); + // anchor indent (one tab) is reused before injected tags + t.is(out, `\n\t\n\t${TAG}\n\t\n`); +}); + +test(" opener: space indent is reused on the injected line", (t) => { + const html = `\n \n \n`; + const out = injectHead(html, TAG); + t.is(out, `\n \n ${TAG}\n \n`); +}); + +test("Only first occurrence is targeted", (t) => { + const html = ``; + const out = injectHead(html, TAG); + t.is(out, `\n${TAG}`); +}); + +test("Self-closing is treated as a normal opener (matches [^>]*>)", (t) => { + const html = ``; + const out = injectHead(html, TAG); + t.is(out, `\n${TAG}`); +}); + +test("Commented-out still matches the regex", (t) => { + const html = ``; + const out = injectHead(html, TAG); + // Regex does not parse comments; first inside the comment is matched. + // The space after "`); +}); + +test("No : falls through to opener anchor (no extra indent)", (t) => { + const html = `\n \n`; + const out = injectHead(html, TAG); + // Fallback paths emit tags without indent adjustment, on a new line. + t.is(out, `\n${TAG}\n \n`); +}); + +test("No : with attributes still matches", (t) => { + const html = ``; + const out = injectHead(html, TAG); + t.is(out, `\n${TAG}`); +}); + +test("No : does NOT consult as anchor", (t) => { + const html = ``; + const out = injectHead(html, TAG); + t.is(out, `${TAG}`); +}); + +test("No , no : anchor used", (t) => { + const html = `\n`; + const out = injectHead(html, TAG); + t.is(out, `\n${TAG}\n`); +}); + +test("Doctype match is case-insensitive ()", (t) => { + const html = `\n`; + const out = injectHead(html, TAG); + t.is(out, `\n${TAG}\n`); +}); + +test(" opener wins over doctype when no present", (t) => { + const html = `\n\n\n`; + const out = injectHead(html, TAG); + t.is(out, `\n\n${TAG}\n\n`); +}); + +test("None of the anchors: tags prepended at position 0 with no separator", (t) => { + const html = `just a fragment`; + const out = injectHead(html, TAG); + t.is(out, `${TAG}just a fragment`); +}); + +test("Empty html input: tags become entire output", (t) => { + t.is(injectHead("", TAG), TAG); +}); + +test("Plain-text html (no anchors at all): tags prepended", (t) => { + const html = `not html at all`; + t.is(injectHead(html, TAG), `${TAG}not html at all`); +}); + +test("Multi-line tags string is inserted opaquely (caller controls internal indent)", (t) => { + const tags = ``; + const html = `\n \n \n`; + const out = injectHead(html, tags); + // Anchor indent (" ") reused once before the tags; internal tag lines stay as authored. + t.is(out, `\n \n ${tags}\n \n`); +}); + +test("Tags string with $ and other regex specials is inserted literally (no replace-string interpretation)", (t) => { + const tags = ``; + const html = ``; + const out = injectHead(html, tags); + t.is(out, `\n${tags}`); +}); + +test("HTML body containing $-sequences is preserved verbatim", (t) => { + const html = `$1 $& $$ done`; + const out = injectHead(html, TAG); + t.is(out, `\n${TAG}$1 $& $$ done`); +}); + +test("Indent capture only matches space/tab (not newlines) before ", (t) => { + // Per regex /([ \t]*)]*>/i: leading whitespace capture is space/tab only. + // A newline directly before means p1 is empty, so injected line has no indent. + const html = `\n\n`; + const out = injectHead(html, TAG); + t.is(out, `\n\n${TAG}\n`); +}); diff --git a/packages/server/test/lib/server/middleware/liveReloadClient.js b/packages/server/test/lib/server/middleware/liveReloadClient.js new file mode 100644 index 00000000000..ae897c62136 --- /dev/null +++ b/packages/server/test/lib/server/middleware/liveReloadClient.js @@ -0,0 +1,217 @@ +import test from "ava"; +import sinon from "sinon"; +import etag from "etag"; +import {readFileSync} from "node:fs"; +import {fileURLToPath} from "node:url"; +import createLiveReloadClient from "../../../../lib/middleware/liveReloadClient.js"; +import { + INJECT_SCRIPT_TAG, + CLIENT_SCRIPT_PATH, + WS_PATH, +} from "../../../../lib/liveReload/constants.js"; + +const CLIENT_SCRIPT_FILE = fileURLToPath( + new URL("../../../../lib/liveReload/client.js", import.meta.url) +); +const clientScriptTemplate = readFileSync(CLIENT_SCRIPT_FILE, "utf8"); + +const TEST_TOKEN = "test-tkn-12"; + +function renderScript(token = TEST_TOKEN) { + return clientScriptTemplate + .replace("__UI5_LR_WS_PATH__", WS_PATH) + .replace("__UI5_LR_WS_TOKEN__", token); +} + +test.afterEach.always(() => { + sinon.restore(); +}); + +function createMiddlewareUtil() { + return { + getPathname: sinon.stub().callsFake((req) => req.url) + }; +} + +function createReq({url = "/", method = "GET", headers = {}} = {}) { + return {method, url, headers}; +} + +function createRes() { + const headers = {}; + return { + setHeader: sinon.stub().callsFake((name, value) => { + headers[name] = value; + }), + getHeader: sinon.stub().callsFake((name) => headers[name]), + end: sinon.stub(), + write: sinon.stub() + }; +} + +test("INJECT_SCRIPT_TAG points to client.js path (no token in HTML)", (t) => { + t.is(INJECT_SCRIPT_TAG, `\n`); + t.notRegex(INJECT_SCRIPT_TAG, /token/i, "tag must not carry token"); +}); + +test("Client script template: contains placeholders for WS_PATH and WS_TOKEN", (t) => { + t.regex(clientScriptTemplate, /__UI5_LR_WS_PATH__/); + t.regex(clientScriptTemplate, /__UI5_LR_WS_TOKEN__/); +}); + +test("Client script: hard-coded PING_INTERVAL_MS is 30000", (t) => { + t.regex(clientScriptTemplate, /PING_INTERVAL_MS\s*=\s*30000/); +}); + +test("Client script: hard-coded RECONNECT_INTERVAL_MS is 1000", (t) => { + t.regex(clientScriptTemplate, /RECONNECT_INTERVAL_MS\s*=\s*1000/); +}); + +test("active=false: returns pass-through middleware", async (t) => { + const middleware = await createLiveReloadClient({ + middlewareUtil: createMiddlewareUtil(), + active: false + }); + const next = sinon.stub(); + const res = createRes(); + + middleware(createReq({url: "/.ui5/liveReload/client.js"}), res, next); + middleware(createReq({url: "/anything"}), res, next); + + t.is(next.callCount, 2); + t.is(res.setHeader.callCount, 0); +}); + +test("active=true requires a token", async (t) => { + await t.throwsAsync(createLiveReloadClient({ + middlewareUtil: createMiddlewareUtil(), + active: true + }), {message: /token is required/}); +}); + +test("client.js: serves substituted WebSocket client script", async (t) => { + const middleware = await createLiveReloadClient({ + middlewareUtil: createMiddlewareUtil(), + active: true, + token: TEST_TOKEN + }); + const req = createReq({url: "/.ui5/liveReload/client.js"}); + const res = createRes(); + const next = sinon.stub(); + + middleware(req, res, next); + + t.is(next.callCount, 0); + t.true(res.setHeader.calledWith("Content-Type", "application/javascript")); + t.true(res.setHeader.calledWith("ETag", sinon.match.string)); + t.is(res.end.callCount, 1); + const body = res.end.getCall(0).args[0]; + t.is(body, renderScript(TEST_TOKEN), "Served body should be the substituted template"); + t.notRegex(body, /__UI5_LR_WS_PATH__/, "no leftover path placeholder"); + t.notRegex(body, /__UI5_LR_WS_TOKEN__/, "no leftover token placeholder"); + t.regex(body, new RegExp(`WS_PATH\\s*=\\s*"${WS_PATH}"`)); + t.regex(body, new RegExp(`WS_TOKEN\\s*=\\s*"${TEST_TOKEN}"`)); + t.regex(body, /new WebSocket\(/); + t.regex(body, /location\.reload\(\)/); + t.regex(body, /visibilitychange/); + t.regex(body, /visibilityState/); + t.regex(body, /beforeunload/); +}); + +test("client.js: returns 304 Not Modified when client cache is fresh", async (t) => { + const middleware = await createLiveReloadClient({ + middlewareUtil: createMiddlewareUtil(), + active: true, + token: TEST_TOKEN + }); + const clientEtag = etag(renderScript(TEST_TOKEN)); + const req = createReq({ + url: "/.ui5/liveReload/client.js", + headers: {"if-none-match": clientEtag} + }); + const res = createRes(); + const next = sinon.stub(); + + middleware(req, res, next); + + t.is(next.callCount, 0); + t.is(res.statusCode, 304); + t.is(res.end.callCount, 1); + t.is(res.end.getCall(0).args.length, 0, "end() called without a body"); + t.true(res.setHeader.calledWith("ETag", clientEtag)); +}); + +test("Different tokens produce different ETags (no stale-token caching)", async (t) => { + const m1 = await createLiveReloadClient({ + middlewareUtil: createMiddlewareUtil(), + active: true, + token: "tokenA-aaaa" + }); + const m2 = await createLiveReloadClient({ + middlewareUtil: createMiddlewareUtil(), + active: true, + token: "tokenB-bbbb" + }); + const next = sinon.stub(); + + const res1 = createRes(); + m1(createReq({url: "/.ui5/liveReload/client.js"}), res1, next); + const etag1 = res1.setHeader.getCalls().find((c) => c.args[0] === "ETag").args[1]; + + const res2 = createRes(); + m2(createReq({url: "/.ui5/liveReload/client.js"}), res2, next); + const etag2 = res2.setHeader.getCalls().find((c) => c.args[0] === "ETag").args[1]; + + t.not(etag1, etag2, "etag changes with token"); +}); + +test("Non-GET requests pass through to next", async (t) => { + const middleware = await createLiveReloadClient({ + middlewareUtil: createMiddlewareUtil(), + active: true, + token: TEST_TOKEN + }); + const res = createRes(); + const next = sinon.stub(); + + middleware(createReq({method: "POST", url: "/.ui5/liveReload/client.js"}), res, next); + + t.is(next.callCount, 1); + t.is(res.setHeader.callCount, 0); + t.is(res.end.callCount, 0); +}); + +test("Errors thrown in middleware are propagated to next(err)", async (t) => { + const middlewareUtil = createMiddlewareUtil(); + const error = new Error("boom"); + middlewareUtil.getPathname = sinon.stub().throws(error); + + const middleware = await createLiveReloadClient({ + middlewareUtil, + active: true, + token: TEST_TOKEN + }); + const res = createRes(); + const next = sinon.stub(); + + middleware(createReq({url: "/.ui5/liveReload/client.js"}), res, next); + + t.is(next.callCount, 1); + t.is(next.getCall(0).args[0], error); + t.is(res.setHeader.callCount, 0); +}); + +test("Unknown path passes through to next", async (t) => { + const middleware = await createLiveReloadClient({ + middlewareUtil: createMiddlewareUtil(), + active: true, + token: TEST_TOKEN + }); + const res = createRes(); + const next = sinon.stub(); + + middleware(createReq({url: "/some/other/path"}), res, next); + + t.is(next.callCount, 1); + t.is(res.setHeader.callCount, 0); +}); diff --git a/packages/server/test/lib/server/middleware/serveResources.js b/packages/server/test/lib/server/middleware/serveResources.js index f75dabfd654..98c45449925 100644 --- a/packages/server/test/lib/server/middleware/serveResources.js +++ b/packages/server/test/lib/server/middleware/serveResources.js @@ -1,7 +1,9 @@ import test from "ava"; import sinon from "sinon"; import etag from "etag"; +import esmock from "esmock"; import serveResourcesMiddleware from "../../../../lib/middleware/serveResources.js"; +import {INJECT_SCRIPT_TAG} from "../../../../lib/liveReload/constants.js"; import MiddlewareUtil from "../../../../lib/middleware/MiddlewareUtil.js"; test.afterEach.always(() => { @@ -12,14 +14,17 @@ function createMockResource({path = "/foo.js", buffer = Buffer.from("content"), return { getPath: sinon.stub().returns(path), getBuffer: sinon.stub().resolves(buffer), + getString: sinon.stub().resolves(buffer.toString()), getIntegrity: sinon.stub().resolves(integrity), }; } -function createMockResponse() { +function createMockResponse({headers = {}} = {}) { return { - getHeader: sinon.stub().returns(undefined), - setHeader: sinon.stub(), + getHeader: sinon.stub().callsFake((name) => headers[name]), + setHeader: sinon.stub().callsFake((name, value) => { + headers[name] = value; + }), send: sinon.stub(), end: sinon.stub(), statusCode: 200, @@ -28,7 +33,7 @@ function createMockResponse() { test.serial("Serves resource with correct Content-Type and ETag", async (t) => { const buffer = Buffer.from("hello world"); - const resource = createMockResource({path: "/app/index.html", buffer, integrity: "sha256-xyz"}); + const resource = createMockResource({path: "/app/script.js", buffer, integrity: "sha256-xyz"}); const resources = {all: {byPath: sinon.stub().resolves(resource)}}; const middleware = serveResourcesMiddleware({ @@ -36,7 +41,7 @@ test.serial("Serves resource with correct Content-Type and ETag", async (t) => { resources }); - const req = {url: "/app/index.html", headers: {}}; + const req = {url: "/app/script.js", headers: {}}; const res = createMockResponse(); const next = sinon.stub(); @@ -44,13 +49,105 @@ test.serial("Serves resource with correct Content-Type and ETag", async (t) => { t.is(next.callCount, 0); t.is(res.setHeader.getCall(0).args[0], "Content-Type"); - t.regex(res.setHeader.getCall(0).args[1], /html/); + t.regex(res.setHeader.getCall(0).args[1], /javascript/); t.is(res.setHeader.getCall(1).args[0], "ETag"); t.is(res.setHeader.getCall(1).args[1], etag("sha256-xyz")); t.is(res.send.callCount, 1); t.is(res.send.getCall(0).args[0], buffer); }); +test.serial("Injects liveReload tag into HTML right after ", async (t) => { + const html = "x

hi

"; + const buffer = Buffer.from(html); + const integrity = "sha256-xyz"; + const resource = createMockResource({path: "/app/index.html", buffer, integrity}); + + const resources = {all: {byPath: sinon.stub().resolves(resource)}}; + const middleware = serveResourcesMiddleware({ + middlewareUtil: new MiddlewareUtil({graph: "graph", project: "project"}), + resources, + injectLiveReloadClient: true + }); + + const req = {url: "/app/index.html", headers: {}}; + const res = createMockResponse(); + const next = sinon.stub(); + + await middleware(req, res, next); + + const etagCall = res.setHeader.getCalls().find((c) => c.args[0] === "ETag"); + t.truthy(etagCall); + t.is(etagCall.args[1], etag(integrity + ":" + INJECT_SCRIPT_TAG)); + t.is(res.send.callCount, 1); + t.is(res.send.getCall(0).args[0], html.replace("", "\n" + INJECT_SCRIPT_TAG)); +}); + +test.serial("Injects liveReload tag after with attributes", async (t) => { + const html = `x`; + const buffer = Buffer.from(html); + const resource = createMockResource({path: "/index.html", buffer, integrity: "sha256-xyz"}); + + const resources = {all: {byPath: sinon.stub().resolves(resource)}}; + const middleware = serveResourcesMiddleware({ + middlewareUtil: new MiddlewareUtil({graph: "graph", project: "project"}), + resources, + injectLiveReloadClient: true + }); + + const req = {url: "/index.html", headers: {}}; + const res = createMockResponse(); + const next = sinon.stub(); + + await middleware(req, res, next); + + t.is(res.send.callCount, 1); + t.is(res.send.getCall(0).args[0], html.replace(``, `\n` + INJECT_SCRIPT_TAG)); +}); + +test.serial("Injects liveReload tag after when is missing", async (t) => { + const html = "

no head

"; + const buffer = Buffer.from(html); + const resource = createMockResource({path: "/page.html", buffer, integrity: "sha256-xyz"}); + + const resources = {all: {byPath: sinon.stub().resolves(resource)}}; + const middleware = serveResourcesMiddleware({ + middlewareUtil: new MiddlewareUtil({graph: "graph", project: "project"}), + resources, + injectLiveReloadClient: true + }); + + const req = {url: "/page.html", headers: {}}; + const res = createMockResponse(); + const next = sinon.stub(); + + await middleware(req, res, next); + + t.is(res.send.callCount, 1); + t.is(res.send.getCall(0).args[0], html.replace("", "\n" + INJECT_SCRIPT_TAG)); +}); + +test.serial("Prepends liveReload tag to HTML without or ", async (t) => { + const html = "

fragment

"; + const buffer = Buffer.from(html); + const resource = createMockResource({path: "/page.html", buffer, integrity: "sha256-xyz"}); + + const resources = {all: {byPath: sinon.stub().resolves(resource)}}; + const middleware = serveResourcesMiddleware({ + middlewareUtil: new MiddlewareUtil({graph: "graph", project: "project"}), + resources, + injectLiveReloadClient: true + }); + + const req = {url: "/page.html", headers: {}}; + const res = createMockResponse(); + const next = sinon.stub(); + + await middleware(req, res, next); + + t.is(res.send.callCount, 1); + t.is(res.send.getCall(0).args[0], INJECT_SCRIPT_TAG + html); +}); + test.serial("Calls next() when resource is not found", async (t) => { const resources = {all: {byPath: sinon.stub().resolves(null)}}; const middleware = serveResourcesMiddleware({ @@ -82,7 +179,6 @@ test.serial("Returns 304 Not Modified when client cache is fresh", async (t) => const req = {url: "/foo.js", headers: {"if-none-match": etagValue}}; const res = createMockResponse(); - res.getHeader.withArgs("ETag").returns(etagValue); const next = sinon.stub(); await middleware(req, res, next); @@ -120,8 +216,7 @@ test.serial("Does not override existing Content-Type header", async (t) => { }); const req = {url: "/data.json", headers: {}}; - const res = createMockResponse(); - res.getHeader.withArgs("Content-Type").returns("application/xml"); + const res = createMockResponse({headers: {"Content-Type": "application/xml"}}); const next = sinon.stub(); await middleware(req, res, next); @@ -150,3 +245,62 @@ test.serial("Uses resource integrity for ETag generation", async (t) => { t.truthy(etagCall); t.is(etagCall.args[1], etag("sha512-uniqueHash")); }); + +test.serial("HTML: 304 when ETag matches injected ETag", async (t) => { + const integrity = "sha256-html-fresh"; + const clientEtag = etag(integrity + ":" + INJECT_SCRIPT_TAG); + const html = ""; + const resource = createMockResource({path: "/index.html", buffer: Buffer.from(html), integrity}); + + const resources = {all: {byPath: sinon.stub().resolves(resource)}}; + const middleware = serveResourcesMiddleware({ + middlewareUtil: new MiddlewareUtil({graph: "graph", project: "project"}), + resources, + injectLiveReloadClient: true + }); + + const req = {url: "/index.html", headers: {"if-none-match": clientEtag}}; + const res = createMockResponse(); + const next = sinon.stub(); + + await middleware(req, res, next); + + t.is(res.statusCode, 304); + t.is(res.end.callCount, 1); + t.is(res.send.callCount, 0); +}); + +test.serial("HTML: 200 when INJECT_SCRIPT_TAG changes invalidates client ETag", async (t) => { + const integrity = "sha256-stable"; + const newInjectTag = "\n"; + const html = ""; + + const {default: mockedMiddleware} = await esmock("../../../../lib/middleware/serveResources.js", { + "../../../../lib/liveReload/constants.js": {INJECT_SCRIPT_TAG: newInjectTag} + }); + + const resource = createMockResource({path: "/index.html", buffer: Buffer.from(html), integrity}); + const resources = {all: {byPath: sinon.stub().resolves(resource)}}; + const middleware = mockedMiddleware({ + middlewareUtil: new MiddlewareUtil({graph: "graph", project: "project"}), + resources, + injectLiveReloadClient: true + }); + + // Client cached ETag derived from the previous INJECT_SCRIPT_TAG + const staleClientEtag = etag(integrity + ":" + INJECT_SCRIPT_TAG); + const expectedServerEtag = etag(integrity + ":" + newInjectTag); + + const req = {url: "/index.html", headers: {"if-none-match": staleClientEtag}}; + const res = createMockResponse(); + const next = sinon.stub(); + + await middleware(req, res, next); + + t.not(res.statusCode, 304); + t.is(res.end.callCount, 0); + t.is(res.send.callCount, 1); + t.true(res.send.getCall(0).args[0].includes(newInjectTag)); + const etagCall = res.setHeader.getCalls().find((c) => c.args[0] === "ETag"); + t.is(etagCall.args[1], expectedServerEtag); +}); From 47331aaa9594201e049a970118d00d162da7717f Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 8 Jun 2026 16:00:46 +0200 Subject: [PATCH 2/3] feat(project): Add liveReload server setting and BuildServer event - Add new `server.settings.liveReload` boolean option to the project configuration schema, available with specVersion 5.0 and higher. - BuildServer now emits a debounced `sourcesChanged` event (100ms debounce) whenever watched source files change, so a burst of changes results in a single notification. JIRA: CPOUI5FOUNDATION-1224 --- .../documentation/docs/pages/Configuration.md | 7 + packages/project/lib/build/BuildServer.js | 26 +++- .../schema/specVersion/kind/project.json | 30 ++++ .../project/test/lib/build/BuildServer.js | 136 ++++++++++++++++++ .../schema/specVersion/kind/project.js | 44 ++++++ .../specVersion/kind/project/application.js | 32 +++++ .../specVersion/kind/project/component.js | 15 ++ .../specVersion/kind/project/library.js | 32 +++++ .../schema/specVersion/kind/project/module.js | 32 +++++ .../specVersion/kind/project/theme-library.js | 32 +++++ 10 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 packages/project/test/lib/build/BuildServer.js diff --git a/internal/documentation/docs/pages/Configuration.md b/internal/documentation/docs/pages/Configuration.md index 03c6a4a2e99..c6ccb30e923 100644 --- a/internal/documentation/docs/pages/Configuration.md +++ b/internal/documentation/docs/pages/Configuration.md @@ -650,6 +650,7 @@ server: settings: httpPort: 1337 httpsPort: 1443 + liveReload: true ``` ::: @@ -662,6 +663,8 @@ A project can also configure alternative default ports. If the configured port i The default and configured server ports can always be overwritten with the CLI parameter `--port`. +The `liveReload` setting controls whether the browser automatically reloads when project sources change. It defaults to `true` when running `ui5 serve` and can be overridden via the CLI parameter `--live-reload`. Requires [Specification Version](#specification-versions) 5.0 or higher. + ## Extension Configuration ::: details Example @@ -809,6 +812,10 @@ Version | UI5 CLI Release ### Specification Version 5.0 +**Features:** + +- Adds new server setting [`server.settings.liveReload`](#server-configuration) to control automatic browser reload on source changes + Specification Version 5.0 projects are supported by [UI5 CLI](https://github.com/UI5/cli) v5.0.0 and above. ### Specification Version 4.0 diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index b61c35e3d7b..b736e3be6b0 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -6,6 +6,10 @@ import {SourceChangedDuringBuildError} from "./cache/ProjectBuildCache.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("build:BuildServer"); +// Debounce window for the `sourcesChanged` event so a burst of file changes +// results in a single notification. +const SOURCES_CHANGED_DEBOUNCE_MS = 100; + class AbortBuildError extends Error { constructor(message) { super(message); @@ -44,6 +48,7 @@ class BuildServer extends EventEmitter { #pendingBuildRequest = new Set(); #activeBuild = null; #processBuildRequestsTimeout; + #sourcesChangedTimeout; #destroyed = false; #allReader; #rootReader; @@ -154,6 +159,7 @@ class BuildServer extends EventEmitter { async destroy() { this.#destroyed = true; clearTimeout(this.#processBuildRequestsTimeout); + clearTimeout(this.#sourcesChangedTimeout); await this.#watchHandler.destroy(); try { if (this.#activeBuild) { @@ -308,10 +314,14 @@ class BuildServer extends EventEmitter { this.#resourceChangeQueue.set(project.getName(), new Set([filePath])); } - // : Emit event debounced - // Emit change event immediately so that consumers can react to it (like browser reloading) - // const changedResourcePaths = [...changes.values()].flat(); - // this.emit("sourcesChanged", changedResourcePaths); + // Debounced emit so a burst of file changes results in a single reload notification + if (this.#sourcesChangedTimeout) { + clearTimeout(this.#sourcesChangedTimeout); + } + this.#sourcesChangedTimeout = setTimeout(() => { + this.#sourcesChangedTimeout = null; + this.emit("sourcesChanged"); + }, SOURCES_CHANGED_DEBOUNCE_MS); } #flushResourceChanges() { @@ -556,3 +566,11 @@ class ProjectBuildStatus { export default BuildServer; + +// Export internals for testing only +/* istanbul ignore else */ +if (process.env.NODE_ENV === "test") { + BuildServer.__internals__ = { + SOURCES_CHANGED_DEBOUNCE_MS + }; +} diff --git a/packages/project/lib/validation/schema/specVersion/kind/project.json b/packages/project/lib/validation/schema/specVersion/kind/project.json index 925af4aee84..1927061362a 100644 --- a/packages/project/lib/validation/schema/specVersion/kind/project.json +++ b/packages/project/lib/validation/schema/specVersion/kind/project.json @@ -24,6 +24,33 @@ } } } + }, + { + "if": { + "type": "object", + "properties": { + "specVersion": { + "not": {"const": "5.0"} + } + } + }, + "then": { + "type": "object", + "properties": { + "server": { + "type": "object", + "properties": { + "settings": { + "type": "object", + "not": { + "required": ["liveReload"] + }, + "errorMessage": "The 'liveReload' setting is only supported with specVersion '5.0' and higher." + } + } + } + } + } } ], "properties": { @@ -691,6 +718,9 @@ }, "httpsPort": { "type": "number" + }, + "liveReload": { + "type": "boolean" } } }, diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js new file mode 100644 index 00000000000..e79e147ef29 --- /dev/null +++ b/packages/project/test/lib/build/BuildServer.js @@ -0,0 +1,136 @@ +import test from "ava"; +import sinonGlobal from "sinon"; +import esmock from "esmock"; + +// Note: These tests are focused on the debounce behavior of the `sourcesChanged` event. +// The general BuildServer functionality is tested by the integration test at ./BuildServer.integration.js + +test.beforeEach(async (t) => { + const sinon = t.context.sinon = sinonGlobal.createSandbox(); + t.context.clock = sinon.useFakeTimers(); + + // Minimal graph stub: a single root project with no dependencies. + const rootProject = { + getName: () => "root.project", + }; + t.context.rootProject = rootProject; + t.context.graph = { + getRoot: () => rootProject, + getProjects: () => [rootProject], + getTransitiveDependencies: () => [], + getProject: (name) => name === "root.project" ? rootProject : undefined, + // The debounce path traverses dependents to invalidate them. With no dependents + // the iterator only yields the source project itself. + traverseDependents: function* (_projectName) { + yield {project: rootProject}; + }, + }; + t.context.projectBuilder = { + closeCacheManager: sinon.stub(), + }; + + // on()/watch() are stubbed to swallow BuildServer#initWatcher's wiring calls; the tests + // don't assert against them. Only destroy() is exercised (via BuildServer#destroy). + class FakeWatchHandler { + constructor() { + this.destroy = sinon.stub().resolves(); + this.on = sinon.stub(); + this.watch = sinon.stub().resolves(); + } + } + + const BuildServer = (await esmock("../../../lib/build/BuildServer.js", { + // BuildReader is constructed in the BuildServer constructor but not exercised here. + "../../../lib/build/BuildReader.js": class BuildReader {}, + "../../../lib/build/helpers/WatchHandler.js": FakeWatchHandler, + })).default; + t.context.BuildServer = BuildServer; + t.context.SOURCES_CHANGED_DEBOUNCE_MS = BuildServer.__internals__.SOURCES_CHANGED_DEBOUNCE_MS; + // Use the static factory so #watchHandler is initialized — needed for destroy() in some tests. + t.context.buildServer = await BuildServer.create( + t.context.graph, t.context.projectBuilder, false, [], []); +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); +}); + +test.serial("sourcesChanged: emitted once after debounce window for a single change", (t) => { + const {buildServer, rootProject, clock, SOURCES_CHANGED_DEBOUNCE_MS} = t.context; + const listener = t.context.sinon.stub(); + buildServer.on("sourcesChanged", listener); + + buildServer._projectResourceChanged(rootProject, "/foo.js", false); + + t.is(listener.callCount, 0, "Not emitted synchronously"); + + clock.tick(SOURCES_CHANGED_DEBOUNCE_MS - 1); + t.is(listener.callCount, 0, "Not emitted before window elapses"); + + clock.tick(1); + t.is(listener.callCount, 1, "Emitted exactly once after window elapses"); +}); + +test.serial("sourcesChanged: burst of changes within window collapses to one emit", (t) => { + const {buildServer, rootProject, clock, SOURCES_CHANGED_DEBOUNCE_MS} = t.context; + const listener = t.context.sinon.stub(); + buildServer.on("sourcesChanged", listener); + + // 5 rapid changes, each well within the debounce window. + for (let i = 0; i < 5; i++) { + buildServer._projectResourceChanged(rootProject, `/foo${i}.js`, false); + clock.tick(10); + } + + t.is(listener.callCount, 0, "No emit while bursts are within window"); + + // Advance past the remaining debounce window from the last change. + clock.tick(SOURCES_CHANGED_DEBOUNCE_MS); + t.is(listener.callCount, 1, "Burst collapsed to a single emit"); +}); + +test.serial("sourcesChanged: each change resets the debounce timer", (t) => { + const {buildServer, rootProject, clock, SOURCES_CHANGED_DEBOUNCE_MS} = t.context; + const listener = t.context.sinon.stub(); + buildServer.on("sourcesChanged", listener); + + buildServer._projectResourceChanged(rootProject, "/a.js", false); + clock.tick(SOURCES_CHANGED_DEBOUNCE_MS - 10); + // Second change just before the original timer would fire — must reset, not fire-then-reset. + buildServer._projectResourceChanged(rootProject, "/b.js", false); + clock.tick(SOURCES_CHANGED_DEBOUNCE_MS - 10); + t.is(listener.callCount, 0, + "Second change reset the timer; no emit yet despite > debounce window of total elapsed time"); + + clock.tick(10); + t.is(listener.callCount, 1, "Emitted once the reset window elapses"); +}); + +test.serial("sourcesChanged: separate change windows produce separate emits", (t) => { + const {buildServer, rootProject, clock, SOURCES_CHANGED_DEBOUNCE_MS} = t.context; + const listener = t.context.sinon.stub(); + buildServer.on("sourcesChanged", listener); + + buildServer._projectResourceChanged(rootProject, "/a.js", false); + clock.tick(SOURCES_CHANGED_DEBOUNCE_MS); + t.is(listener.callCount, 1, "First window emitted"); + + // A change after the first emit starts a new debounce window. + buildServer._projectResourceChanged(rootProject, "/b.js", false); + clock.tick(SOURCES_CHANGED_DEBOUNCE_MS); + t.is(listener.callCount, 2, "Second window produced a second emit"); +}); + +test.serial("sourcesChanged: destroy cancels a pending emit", async (t) => { + const {buildServer, rootProject, clock, SOURCES_CHANGED_DEBOUNCE_MS} = t.context; + const listener = t.context.sinon.stub(); + buildServer.on("sourcesChanged", listener); + + buildServer._projectResourceChanged(rootProject, "/a.js", false); + t.is(listener.callCount, 0, "Pre-destroy: pending emit not yet fired"); + + await buildServer.destroy(); + + clock.tick(SOURCES_CHANGED_DEBOUNCE_MS * 5); + t.is(listener.callCount, 0, "Pending sourcesChanged emit was cancelled by destroy()"); +}); diff --git a/packages/project/test/lib/validation/schema/specVersion/kind/project.js b/packages/project/test/lib/validation/schema/specVersion/kind/project.js index c2ed6e1e2c4..e2c5ef770a8 100644 --- a/packages/project/test/lib/validation/schema/specVersion/kind/project.js +++ b/packages/project/test/lib/validation/schema/specVersion/kind/project.js @@ -285,3 +285,47 @@ test("Legacy: Special characters in name (module)", async (t) => { } }); }); + +test("server.settings.liveReload (specVersion 5.0)", async (t) => { + await assertValidation(t, { + "specVersion": "5.0", + "type": "application", + "metadata": { + "name": "my-application" + }, + "server": { + "settings": { + "liveReload": true + } + } + }); +}); + +test("server.settings.liveReload (legacy specVersion)", async (t) => { + await assertValidation(t, { + "specVersion": "4.0", + "type": "application", + "metadata": { + "name": "my-application" + }, + "server": { + "settings": { + "liveReload": true + } + } + }, [{ + instancePath: "/server/settings", + keyword: "errorMessage", + message: "The 'liveReload' setting is only supported with specVersion '5.0' and higher.", + params: { + errors: [{ + emUsed: true, + instancePath: "/server/settings", + keyword: "not", + message: "must NOT be valid", + params: {}, + schemaPath: "#/allOf/1/then/properties/server/properties/settings/not", + }], + } + }]); +}); diff --git a/packages/project/test/lib/validation/schema/specVersion/kind/project/application.js b/packages/project/test/lib/validation/schema/specVersion/kind/project/application.js index 7717503126a..35f8aaacdfd 100644 --- a/packages/project/test/lib/validation/schema/specVersion/kind/project/application.js +++ b/packages/project/test/lib/validation/schema/specVersion/kind/project/application.js @@ -338,6 +338,38 @@ SpecificationVersion.getVersionsForRange(">=4.0").forEach(function(specVersion) } ]); }); + + test(`Server liveReload setting (specVersion ${specVersion})`, async (t) => { + const config = { + "specVersion": specVersion, + "type": "application", + "metadata": { + "name": "com.sap.ui5.test" + }, + "server": { + "settings": { + "liveReload": true + } + } + }; + if (new SpecificationVersion(specVersion).gte("5.0")) { + await assertValidation(t, config); + } else { + await assertValidation(t, config, [{ + instancePath: "/server/settings", + keyword: "errorMessage", + message: "The 'liveReload' setting is only supported with specVersion '5.0' and higher.", + params: { + errors: [{ + instancePath: "/server/settings", + keyword: "not", + message: "must NOT be valid", + params: {}, + }], + } + }]); + } + }); }); SpecificationVersion.getVersionsForRange("2.0 - 3.2").forEach(function(specVersion) { diff --git a/packages/project/test/lib/validation/schema/specVersion/kind/project/component.js b/packages/project/test/lib/validation/schema/specVersion/kind/project/component.js index b1104ba5f82..e34e5bcc345 100644 --- a/packages/project/test/lib/validation/schema/specVersion/kind/project/component.js +++ b/packages/project/test/lib/validation/schema/specVersion/kind/project/component.js @@ -960,6 +960,21 @@ SpecificationVersion.getVersionsForRange(">=5.0").forEach(function(specVersion) }, }]); }); + + test(`Server liveReload setting (specVersion ${specVersion})`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "my.component" + }, + "server": { + "settings": { + "liveReload": true + } + } + }); + }); }); project.defineTests(test, assertValidation, "component"); diff --git a/packages/project/test/lib/validation/schema/specVersion/kind/project/library.js b/packages/project/test/lib/validation/schema/specVersion/kind/project/library.js index b54459de214..0215060b2d4 100644 --- a/packages/project/test/lib/validation/schema/specVersion/kind/project/library.js +++ b/packages/project/test/lib/validation/schema/specVersion/kind/project/library.js @@ -265,6 +265,38 @@ SpecificationVersion.getVersionsForRange(">=4.0").forEach(function(specVersion) }, ]); }); + + test(`Server liveReload setting (specVersion ${specVersion})`, async (t) => { + const config = { + "specVersion": specVersion, + "type": "library", + "metadata": { + "name": "my.library" + }, + "server": { + "settings": { + "liveReload": true + } + } + }; + if (new SpecificationVersion(specVersion).gte("5.0")) { + await assertValidation(t, config); + } else { + await assertValidation(t, config, [{ + instancePath: "/server/settings", + keyword: "errorMessage", + message: "The 'liveReload' setting is only supported with specVersion '5.0' and higher.", + params: { + errors: [{ + instancePath: "/server/settings", + keyword: "not", + message: "must NOT be valid", + params: {}, + }], + } + }]); + } + }); }); SpecificationVersion.getVersionsForRange("2.0 - 3.2").forEach(function(specVersion) { diff --git a/packages/project/test/lib/validation/schema/specVersion/kind/project/module.js b/packages/project/test/lib/validation/schema/specVersion/kind/project/module.js index 3c121ab1ca2..fbda73ad0d9 100644 --- a/packages/project/test/lib/validation/schema/specVersion/kind/project/module.js +++ b/packages/project/test/lib/validation/schema/specVersion/kind/project/module.js @@ -174,6 +174,38 @@ SpecificationVersion.getVersionsForRange(">=2.5").forEach(function(specVersion) }); }); + test(`Server liveReload setting (specVersion ${specVersion})`, async (t) => { + const config = { + "specVersion": specVersion, + "type": "module", + "metadata": { + "name": "my-module" + }, + "server": { + "settings": { + "liveReload": true + } + } + }; + if (new SpecificationVersion(specVersion).gte("5.0")) { + await assertValidation(t, config); + } else { + await assertValidation(t, config, [{ + instancePath: "/server/settings", + keyword: "errorMessage", + message: "The 'liveReload' setting is only supported with specVersion '5.0' and higher.", + params: { + errors: [{ + instancePath: "/server/settings", + keyword: "not", + message: "must NOT be valid", + params: {}, + }], + } + }]); + } + }); + test(`module (specVersion ${specVersion}): builder/settings/includeDependency*`, async (t) => { await assertValidation(t, { "specVersion": specVersion, diff --git a/packages/project/test/lib/validation/schema/specVersion/kind/project/theme-library.js b/packages/project/test/lib/validation/schema/specVersion/kind/project/theme-library.js index 0ea1f564344..ff53402325b 100644 --- a/packages/project/test/lib/validation/schema/specVersion/kind/project/theme-library.js +++ b/packages/project/test/lib/validation/schema/specVersion/kind/project/theme-library.js @@ -167,6 +167,38 @@ SpecificationVersion.getVersionsForRange(">=2.0").forEach(function(specVersion) } }]); }); + + test(`Server liveReload setting (specVersion ${specVersion})`, async (t) => { + const config = { + "specVersion": specVersion, + "type": "theme-library", + "metadata": { + "name": "my.theme.library" + }, + "server": { + "settings": { + "liveReload": true + } + } + }; + if (new SpecificationVersion(specVersion).gte("5.0")) { + await assertValidation(t, config); + } else { + await assertValidation(t, config, [{ + instancePath: "/server/settings", + keyword: "errorMessage", + message: "The 'liveReload' setting is only supported with specVersion '5.0' and higher.", + params: { + errors: [{ + instancePath: "/server/settings", + keyword: "not", + message: "must NOT be valid", + params: {}, + }], + } + }]); + } + }); }); SpecificationVersion.getVersionsForRange(">=2.5").forEach(function(specVersion) { From 4ae71d90425ddebebe68781d2e4452927e3bb94b Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 8 Jun 2026 16:05:05 +0200 Subject: [PATCH 3/3] feat(cli): Enable liveReload by default for ui5 serve - Add new `--live-reload` CLI flag (defaults to true) for the `ui5 serve` command. Pass `--no-live-reload` to disable it. - The flag overrides the `server.settings.liveReload` setting in the project's `ui5.yaml`. - When neither the CLI flag nor the configuration sets a value, live reload is enabled by default. JIRA: CPOUI5FOUNDATION-1224 --- .../documentation/docs/updates/migrate-v5.md | 15 ++++ packages/cli/lib/cli/commands/serve.js | 20 ++++- packages/cli/test/lib/cli/commands/serve.js | 76 ++++++++++++++++++- 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/internal/documentation/docs/updates/migrate-v5.md b/internal/documentation/docs/updates/migrate-v5.md index 1612bd6dfa9..9ee2da8d8ed 100644 --- a/internal/documentation/docs/updates/migrate-v5.md +++ b/internal/documentation/docs/updates/migrate-v5.md @@ -17,6 +17,8 @@ Or update your global install via: `npm i --global @ui5/cli@next` - **Rename: Command Option `--cache-mode` is now `--snapshot-cache`** +- **@ui5/server: Live Reload is enabled by default for `ui5 serve`** + ## Node.js and npm Version Support @@ -206,6 +208,19 @@ Delete the custom `test/Test.qunit.html` file from your test directory. This fil Depending on your project setup, you might need to update additional paths in configuration files or test runners to reflect the new structure. The test suite is now served under the standard `/test-resources/` path with the component's full namespace (e.g. `/test-resources/sap/ui/demo/todo/testsuite.qunit.html`). +## Live Reload + +UI5 CLI v5 introduces a built-in [Live Reload](../pages/Server.md#livereload) feature for the development server. When running `ui5 serve`, the browser automatically reloads whenever project sources change. Live Reload is implemented via a WebSocket connection. + +Live Reload is **enabled by default**. It can be controlled via: + +- The new `--live-reload` CLI flag for `ui5 serve` (defaults to `true`). Pass `--no-live-reload` to disable it. +- The new `server.settings.liveReload` configuration option in `ui5.yaml`. This setting is only available with [Specification Version 5.0](../pages/Configuration#specification-version-5-0) and higher. + +::: warning Custom Live Reload Middleware +If your project uses a custom middleware that provides live reload functionality (e.g. [@sap-ux/reload-middleware](https://www.npmjs.com/package/@sap-ux/reload-middleware) or [ui5-middleware-livereload](https://www.npmjs.com/package/ui5-middleware-livereload)), the page may refresh more often than necessary when combined with the built-in feature. When upgrading, either remove the custom middleware or disable the built-in Live Reload via the `--no-live-reload` CLI flag or the `server.settings.liveReload` configuration option. +::: + ## Removal of Standard Server Middleware The following middleware has been removed from the [standard middlewares list](../pages/Server.md#standard-middleware): diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index c09eeaac56e..0446e8b82e9 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -36,6 +36,13 @@ serve.builder = function(cli) { default: false, type: "boolean" }) + .option("live-reload", { + describe: + "Automatically reload the browser when project sources change. " + + "Overrides the 'liveReload' setting in the project's server configuration", + defaultDescription: "true", + type: "boolean" + }) .option("accept-remote-connections", { describe: "Accept remote connections. By default the server only accepts connections from localhost", default: false, @@ -109,7 +116,7 @@ serve.builder = function(cli) { "The 'Default' behavior is to invalidate the cache after 9 hours. 'Force' uses the cache only and " + "does not create any requests. 'Off' invalidates any existing cache and updates from the repository", type: "string", - defaultDescription: "Default", // Use "defaultdescription" to allow undefined (needed for evaluation) + defaultDescription: "Default", // Use "defaultDescription" to allow undefined (needed for evaluation) choices: ["Default", "Force", "Off"], }) .example("ui5 serve", "Start a web server for the current project") @@ -165,11 +172,22 @@ serve.handler = async function(argv) { } } + let liveReload = argv.liveReload; + if (liveReload === undefined) { + const serverSettings = graph.getRoot().getServerSettings(); + if (serverSettings && serverSettings.liveReload !== undefined) { + liveReload = serverSettings.liveReload; + } else { + liveReload = true; + } + } + const serverConfig = { port, changePortIfInUse, h2: argv.h2, simpleIndex: !!argv.simpleIndex, + liveReload: !!liveReload, acceptRemoteConnections: !!argv.acceptRemoteConnections, cert: argv.h2 ? argv.cert : undefined, key: argv.h2 ? argv.key : undefined, diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js index b97d4b4fc28..f9d585a33d9 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -122,6 +122,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); t.is(typeof server.serve.getCall(0).args[2], "function"); @@ -171,6 +172,7 @@ URL: https://localhost:8443 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); @@ -223,6 +225,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -265,6 +268,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); @@ -312,6 +316,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); @@ -356,6 +361,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -393,7 +399,8 @@ URL: http://localhost:8080 port: 8080, sendSAPTargetCSP: false, serveCSPReports: false, - simpleIndex: false + simpleIndex: false, + liveReload: true } ]); }); @@ -434,7 +441,8 @@ URL: http://localhost:8080 port: 8080, sendSAPTargetCSP: false, serveCSPReports: false, - simpleIndex: false + simpleIndex: false, + liveReload: true } ]); }); @@ -473,6 +481,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -511,6 +520,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -549,6 +559,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -587,6 +598,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -626,6 +638,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -664,6 +677,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: true, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -702,6 +716,7 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: true, simpleIndex: false, + liveReload: true, } ]); }); @@ -740,10 +755,64 @@ URL: http://localhost:8080 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: true, + liveReload: true, } ]); }); +test.serial("ui5 serve --no-live-reload", async (t) => { + const {argv, serve, server} = t.context; + + argv.liveReload = false; + + serve.handler(argv); + await t.context.handlerReady; + + t.is(server.serve.callCount, 1); + t.is(server.serve.getCall(0).args[1].liveReload, false); +}); + +test.serial("ui5 serve --live-reload", async (t) => { + const {argv, serve, server} = t.context; + + argv.liveReload = true; + + serve.handler(argv); + await t.context.handlerReady; + + t.is(server.serve.callCount, 1); + t.is(server.serve.getCall(0).args[1].liveReload, true); +}); + +test.serial("ui5 serve with ui5.yaml liveReload=false setting", async (t) => { + const {argv, serve, server, getServerSettings} = t.context; + + getServerSettings.returns({ + liveReload: false + }); + + serve.handler(argv); + await t.context.handlerReady; + + t.is(server.serve.callCount, 1); + t.is(server.serve.getCall(0).args[1].liveReload, false); +}); + +test.serial("ui5 serve --live-reload overrides ui5.yaml liveReload setting", async (t) => { + const {argv, serve, server, getServerSettings} = t.context; + + argv.liveReload = true; + getServerSettings.returns({ + liveReload: false + }); + + serve.handler(argv); + await t.context.handlerReady; + + t.is(server.serve.callCount, 1); + t.is(server.serve.getCall(0).args[1].liveReload, true); +}); + test.serial("ui5 serve with ui5.yaml port setting", async (t) => { const {argv, serve, graph, server, fakeGraph, getServerSettings} = t.context; @@ -785,6 +854,7 @@ URL: http://localhost:3333 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); }); @@ -837,6 +907,7 @@ URL: https://localhost:4444 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]); @@ -896,6 +967,7 @@ URL: https://localhost:5555 sendSAPTargetCSP: false, serveCSPReports: false, simpleIndex: false, + liveReload: true, } ]);