From 2b981e379c7b1a9c3da874e670fb0782e7b4caff Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 14 Aug 2026 14:36:43 +0200 Subject: [PATCH] feat(server): Add serveMiddleware API for Node.js server integration Expose `serveMiddleware` as a public API for mounting the UI5 middleware stack into an existing Express/Connect server without binding a port, starting live-reload, or installing the terminal error handler. The returned object provides `middleware` and `close`. Internal: Remove `buildServer` from the `serveMiddleware` return value. We want to understand use cases better before committing to it as part of the API contract. Mark `ProjectGraph#serve` as `@private` with a note that it backs `@ui5/server` and must remain stable for that internal usage. JIRA: CPOUI5FOUNDATION-1281 --- internal/documentation/docs/pages/Server.md | 36 ++++++++ package-lock.json | 86 +++++++++++++++++++ packages/project/lib/graph/ProjectGraph.js | 21 +++++ packages/server/lib/serveMiddleware.js | 5 +- packages/server/package.json | 1 + .../server/test/lib/server/serveMiddleware.js | 65 ++++++++------ 6 files changed, 185 insertions(+), 29 deletions(-) diff --git a/internal/documentation/docs/pages/Server.md b/internal/documentation/docs/pages/Server.md index 01a0b5eeff1..f4454fd5732 100644 --- a/internal/documentation/docs/pages/Server.md +++ b/internal/documentation/docs/pages/Server.md @@ -185,6 +185,42 @@ Once started with `ui5 serve`, the server automatically monitors changes to the Changes to configuration files or custom tasks require a server restart to take effect. ::: +## Integrating into an Existing Node.js Server + +Beyond starting its own HTTP server via [`serve`](../api/module-@ui5_server.html#.serve), `@ui5/server` exposes a [`serveMiddleware`](../api/module-@ui5_server.html#.serveMiddleware) API for integrating UI5 Server functionality into an **existing** Express or Connect application. This is the supported entry point for tools that embed a UI5 Server — for example test runners or framework plugins — instead of running `ui5 serve` as a standalone process. + +[`serveMiddleware`](../api/module-@ui5_server.html#.serveMiddleware)`(graph[, options][, error])` takes a [project graph](./Project.md) and resolves with: + +- `middleware` — a single Connect/Express-compatible request handler; mount it on your own app with `app.use(middleware)`. +- `close()` — an async teardown function that releases the `BuildServer`'s source watcher and build-cache handle. Call it when shutting down. + +Unlike `serve`, `serveMiddleware` does **not** bind a port, attach the live reload WebSocket server, or install the terminal HTML error handler — those remain the responsibility of the HTTP server you own. Error handling and the listener are yours to provide. + +```js +import express from "express"; +import {graphFromPackageDependencies} from "@ui5/project/graph"; +import {serveMiddleware} from "@ui5/server"; + +const graph = await graphFromPackageDependencies({cwd: process.cwd()}); +const {middleware, close} = await serveMiddleware(graph); + +const app = express(); +app.use(middleware); +const listener = app.listen(8080); + +// On teardown, stop the listener and release the BuildServer's watcher and cache handle: +listener.close(); +await close(); +``` + +Connect works the same way — `connect().use(middleware)`. + +`options` accepts the serving-related subset of [`serve`](../api/module-@ui5_server.html#.serve)'s options. See the [API Reference](../api/module-@ui5_server.html#.serveMiddleware) for the full signature. + +::: warning Serve a graph only once +A project graph can be served only once. Do not call both `serveMiddleware` and `serve` for the same graph. +::: + ## SSL Certificates When starting the UI5 Server in HTTPS- or HTTP/2 mode, for example by using UI5 CLI parameter `--h2`, you will be prompted for the automatic generation of a local SSL certificate if necessary. diff --git a/package-lock.json b/package-lock.json index 81010d05fba..dfeb702f455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7264,6 +7264,91 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -19076,6 +19161,7 @@ "@istanbuljs/esm-loader-hook": "^0.3.0", "@ui5/project": "^5.0.0-alpha.7", "ava": "^8.0.1", + "connect": "^3.7.0", "cross-env": "^10.1.0", "eslint": "^10.7.0", "esmock": "^2.7.6", diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 200a3fa9c0a..3a6cf7f5327 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -759,6 +759,27 @@ class ProjectGraph { }); } + /** + * Creates a {@link @ui5/project/build/BuildServer} for this graph and returns it. + * Called by @ui5/server to back the request-serving middleware stack. + * + * Kept private because the API surface is not yet stable enough for general use, + * but must remain compatible with the server package's internal usage. + * + * @private + * @param {object} parameters Parameters + * @param {boolean} [parameters.initialBuildRootProject=false] + * @param {string[]} [parameters.initialBuildIncludedDependencies=[]] + * @param {string[]} [parameters.initialBuildExcludedDependencies=[]] + * @param {boolean} [parameters.selfContained=false] + * @param {boolean} [parameters.jsdoc=false] + * @param {boolean} [parameters.createBuildManifest=false] + * @param {string[]} [parameters.includedTasks=[]] + * @param {string[]} [parameters.excludedTasks=[]] + * @param {module:@ui5/project/build/cache/Cache} [parameters.cache=Default] + * @param {string} [parameters.ui5DataDir] + * @returns {Promise<@ui5/project/build/BuildServer>} + */ async serve({ initialBuildRootProject = false, initialBuildIncludedDependencies = [], initialBuildExcludedDependencies = [], diff --git a/packages/server/lib/serveMiddleware.js b/packages/server/lib/serveMiddleware.js index a87e2cb9415..abdc7e0e57c 100644 --- a/packages/server/lib/serveMiddleware.js +++ b/packages/server/lib/serveMiddleware.js @@ -52,8 +52,8 @@ import {buildRouter} from "./serve/stack.js"; * outside of request handling. * @returns {Promise} Promise resolving with an object containing the * middleware (a connect/Express-compatible handler to be - * mounted via app.use()), the buildServer, and a - * close function releasing the BuildServer's watcher and cache. + * mounted via app.use()) and a close function + * releasing the BuildServer's watcher and cache. */ export default async function serveMiddleware(graph, { sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false, cache, @@ -74,7 +74,6 @@ export default async function serveMiddleware(graph, { let destroyed = false; return { middleware: router, - buildServer, close: async function close() { if (destroyed) { return; diff --git a/packages/server/package.json b/packages/server/package.json index 7874d15e344..831932b6ea2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -109,6 +109,7 @@ "@istanbuljs/esm-loader-hook": "^0.3.0", "@ui5/project": "^5.0.0-alpha.7", "ava": "^8.0.1", + "connect": "^3.7.0", "cross-env": "^10.1.0", "eslint": "^10.7.0", "esmock": "^2.7.6", diff --git a/packages/server/test/lib/server/serveMiddleware.js b/packages/server/test/lib/server/serveMiddleware.js index 9b9a8e087fc..c8633392bc0 100644 --- a/packages/server/test/lib/server/serveMiddleware.js +++ b/packages/server/test/lib/server/serveMiddleware.js @@ -2,18 +2,29 @@ import test from "ava"; import sinon from "sinon"; import esmock from "esmock"; import express from "express"; +import connect from "connect"; import supertest from "supertest"; import {graphFromPackageDependencies} from "@ui5/project/graph"; import serveMiddleware from "../../../lib/serveMiddleware.js"; import {INJECT_SCRIPT_TAG} from "../../../lib/liveReload/constants.js"; import {isolatedUi5DataDir} from "../../utils/buildCacheIsolation.js"; -// Integration: mount the returned middleware on a caller-owned express app and serve real -// requests through supertest, without binding a port or starting a UI5-owned HTTP server. +// Integration: mount the returned middleware on caller-owned HTTP frameworks and serve real +// requests through supertest, without binding a port or starting a UI5-owned HTTP server. The +// same middleware is mounted on both an Express app and a Connect app to prove it is a plain +// connect/Express-compatible handler and to satisfy the "multiple express and connect versions" +// acceptance criterion. A project graph can be served only once, so a single serveMiddleware +// result is shared across both hosts (mounting one handler on two apps is fine — it holds no +// per-app state). -let app; let close; -let request; +// Host frameworks the embedding middleware is expected to work with. supertest accepts either +// app instance (both are request-listener functions), so the request assertions are identical. +const hosts = [ + {name: "express", mount: (mw) => express().use(mw)}, + {name: "connect", mount: (mw) => connect().use(mw)}, +]; +const requests = {}; test.before(async (t) => { const graph = await graphFromPackageDependencies({ @@ -25,41 +36,43 @@ test.before(async (t) => { }); close = result.close; - app = express(); - app.use(result.middleware); - request = supertest(app); + for (const host of hosts) { + requests[host.name] = supertest(host.mount(result.middleware)); + } }); test.after.always(async () => { - await close(); + await close?.(); }); -async function get(path) { +async function get(request, path) { const res = await request.get(path); if (res.error) { - throw new Error(res.error); + throw res.error; } return res; } -test("Serves index.html through a caller-owned express app", async (t) => { - const res = await get("/index.html"); - t.is(res.statusCode, 200, "Correct HTTP status code"); - t.regex(res.headers["content-type"], /html/, "Correct content type"); - t.regex(res.text, /Application A<\/title>/, "Correct response"); -}); +for (const host of hosts) { + test(`Serves index.html through a caller-owned ${host.name} app`, async (t) => { + const res = await get(requests[host.name], "/index.html"); + t.is(res.statusCode, 200, "Correct HTTP status code"); + t.regex(res.headers["content-type"], /html/, "Correct content type"); + t.regex(res.text, /<title>Application A<\/title>/, "Correct response"); + }); -test("Serves the UI5 version info", async (t) => { - const res = await get("/resources/sap-ui-version.json"); - t.is(res.statusCode, 200, "Correct HTTP status code"); - t.regex(res.headers["content-type"], /json/, "Correct content type"); -}); + test(`Serves the UI5 version info through a caller-owned ${host.name} app`, async (t) => { + const res = await get(requests[host.name], "/resources/sap-ui-version.json"); + t.is(res.statusCode, 200, "Correct HTTP status code"); + t.regex(res.headers["content-type"], /json/, "Correct content type"); + }); -test("Does not inject the live-reload client script", async (t) => { - const res = await get("/index.html"); - t.false(res.text.includes(INJECT_SCRIPT_TAG), - "The live-reload client script is not injected in the embedding API"); -}); + test(`Does not inject the live-reload client script (${host.name})`, async (t) => { + const res = await get(requests[host.name], "/index.html"); + t.false(res.text.includes(INJECT_SCRIPT_TAG), + "The live-reload client script is not injected in the embedding API"); + }); +} // Unit: the module contract independent of a real graph. The returned middleware is the router // from the shared core, close() releases the BuildServer once and is idempotent, and the