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/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/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/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/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,
}
]);
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) {
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]*)
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 {Promisehi
"; + 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 = `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); +});