@cap-js-community/common 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## Version 0.4.0 - 2026-03-10
9
+
10
+ ### Added
11
+
12
+ - Redis Sentinel Support
13
+ - Open dependencies to allow Redis 5
14
+
15
+ ## Version 0.3.5 - 2026-02-03
16
+
17
+ ### Fixed
18
+
19
+ - Express 5 (with CDS 9.7)
20
+
21
+ ### Added
22
+
23
+ - CDM Builder
24
+
8
25
  ## Version 0.3.4 - 2026-01-20
9
26
 
10
27
  ### Fixed
package/README.md CHANGED
@@ -15,11 +15,13 @@ This project provides common functionality for CDS services to be consumed with
15
15
 
16
16
  ## Table of Contents
17
17
 
18
- - [Replication Cache](#replication-cache)
19
- - [Migration Check](#migration-check)
20
- - [Rate Limiting](#rate-limiting)
21
- - [Redis Client](#redis-client)
22
- - [Local HTML5 Repository](#local-html5-repository)
18
+ - **Features:**
19
+ - [Replication Cache](#replication-cache)
20
+ - [Migration Check](#migration-check)
21
+ - [Rate Limiting](#rate-limiting)
22
+ - [Redis Client](#redis-client)
23
+ - [Local HTML5 Repository](#local-html5-repository)
24
+ - [CDM Builder](#cdm-builder)
23
25
  - [Support, Feedback, Contributing](#support-feedback-contributing)
24
26
  - [Code of Conduct](#code-of-conduct)
25
27
  - [Licensing](#licensing)
@@ -397,6 +399,31 @@ const mainClient = await RedisClient.create().createMainClientAndConnect(options
397
399
 
398
400
  For details on Redis `createClient` configuration options see [Redis Client Configuration](https://github.com/redis/node-redis/blob/master/docs/client-configuration.md).
399
401
 
402
+ ### Redis Sentinel Support
403
+
404
+ Redis Sentinel mode is activated when `credentials.sentinel_nodes` is present and takes priority over cluster mode.
405
+
406
+ ```json
407
+ {
408
+ "cds": {
409
+ "requires": {
410
+ "redis": {
411
+ "credentials": {
412
+ "sentinel_nodes": [
413
+ { "host": "sentinel1.example.com", "port": 26379 },
414
+ { "host": "sentinel2.example.com", "port": 26379 }
415
+ ],
416
+ "master_name": "myprimary"
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+ ```
423
+
424
+ The `master_name` identifies which Redis master the Sentinels monitor. Alternatively, the master name can be provided as a URI fragment in `credentials.uri` (e.g., `redis://host#myprimary`).
425
+ If `sentinel_nodes[].port` is omitted, it defaults to `26379`. Both `host` and `hostname` are accepted for node addresses.
426
+
400
427
  ## Local HTML5 Repository
401
428
 
402
429
  Developing HTML5 apps against hybrid environments including Approuter component requires a local HTML5 repository to directly test the changes to UI5 applications without deployment to a remote HTML5 repository.
@@ -424,6 +451,19 @@ Developing HTML5 apps against hybrid environments including Approuter component
424
451
  All apps and libraries located in `app` folder and containing an `ui5.yaml` are redirected to local HTML5 repository
425
452
  served from local file system. All other requests are proxied to the remote HTML5 repository.
426
453
 
454
+ ## CDM Builder
455
+
456
+ The CDM Builder allows to build a CDM file `cdm.json` from apps, roles and portal content:
457
+
458
+ - Build CDM: `cdm-build`
459
+
460
+ The generated CDM is (per default) generated at `app/cdm.json`.
461
+
462
+ ### Options:
463
+
464
+ - `-f, --force`: Overwrite existing CDM file. Default is `false`
465
+ - `-t, --target`: Specify target path for generated CDM file. Default is `app/cdm.json`
466
+
427
467
  ## Support, Feedback, Contributing
428
468
 
429
469
  This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js-community/common/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md).
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /* eslint-disable no-console */
5
+ /* eslint-disable n/no-process-exit */
6
+
7
+ const commander = require("commander");
8
+ const program = new commander.Command();
9
+
10
+ const packageJSON = require("../package.json");
11
+
12
+ const { CDMBuilder } = require("../src/cdm-build");
13
+
14
+ process.argv = process.argv.map((arg) => {
15
+ return arg.toLowerCase();
16
+ });
17
+
18
+ program
19
+ .version(packageJSON.version, "-v, --version")
20
+ .usage("[options]")
21
+ .option("-f, --force", "Force generation")
22
+ .option("-t, --target <target>", "Target path");
23
+
24
+ program.unknownOption = function () {};
25
+ program.parse(process.argv);
26
+
27
+ (async () => {
28
+ try {
29
+ const options = program.opts();
30
+ await new CDMBuilder(options).build();
31
+ } catch (err) {
32
+ console.error(err);
33
+ process.exit(-1);
34
+ }
35
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/common",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "CAP Node.js Community Common",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "engines": {
@@ -26,9 +26,12 @@
26
26
  "main": "index.js",
27
27
  "types": "index.d.ts",
28
28
  "bin": {
29
- "cdsmc": "bin/cdsmc.js",
30
- "lh5r": "bin/local-html5-repo.js",
31
- "local-html5-repo": "bin/local-html5-repo.js"
29
+ "cdsmc": "./bin/cdsmc.js",
30
+ "migration-check": "./bin/cdsmc.js",
31
+ "cdm": "./bin/cdm-build.js",
32
+ "cdm-build": "./bin/cdm-build.js",
33
+ "lh5r": "./bin/local-html5-repo.js",
34
+ "local-html5-repo": "./bin/local-html5-repo.js"
32
35
  },
33
36
  "scripts": {
34
37
  "start": "cds-serve",
@@ -47,27 +50,27 @@
47
50
  "audit": "npm audit --only=prod"
48
51
  },
49
52
  "dependencies": {
50
- "commander": "^14.0.2",
51
- "express": "^4.22.1",
53
+ "commander": "^14.0.3",
54
+ "express": "^4.22.1 || ^5.2.1",
52
55
  "http-proxy-middleware": "^3.0.5",
53
- "redis": "^4.7.1",
56
+ "redis": "^4.7.1 || ^5.11.0",
54
57
  "verror": "^1.10.1"
55
58
  },
56
59
  "devDependencies": {
57
60
  "@cap-js-community/common": "./",
58
61
  "@cap-js/cds-test": "^0.4.1",
59
- "@cap-js/sqlite": "^2.1.2",
60
- "@sap/cds": "^9.6.1",
62
+ "@cap-js/sqlite": "^2.2.0",
63
+ "@sap/cds": "^9.8.1",
61
64
  "@sap/cds-common-content": "^3.1.0",
62
- "@sap/cds-dk": "^9.6.0",
63
- "eslint": "^9.39.2",
65
+ "@sap/cds-dk": "^9.7.2",
66
+ "eslint": "^10.0.3",
64
67
  "eslint-config-prettier": "^10.1.8",
65
- "eslint-plugin-jest": "^29.12.1",
66
- "eslint-plugin-n": "^17.23.1",
67
- "jest": "^30.2.0",
68
+ "eslint-plugin-jest": "^29.15.0",
69
+ "eslint-plugin-n": "^17.24.0",
70
+ "jest": "^30.3.0",
68
71
  "jest-html-reporters": "^3.1.7",
69
72
  "jest-junit": "^16.0.0",
70
- "prettier": "^3.7.4",
73
+ "prettier": "^3.8.1",
71
74
  "shelljs": "^0.10.0"
72
75
  },
73
76
  "cds": {
@@ -76,7 +79,9 @@
76
79
  "vcap": {
77
80
  "label": "redis-cache"
78
81
  }
79
- }
82
+ },
83
+ "html5-repo": true,
84
+ "portal": true
80
85
  },
81
86
  "migrationCheck": {
82
87
  "baseDir": "migration-check",
@@ -0,0 +1,497 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const cds = require("@sap/cds");
6
+
7
+ const COMPONENT_NAME = "/cap-js-community-common/cdm-build";
8
+
9
+ const DEFAULT_ICON = "sap-icon://activity-2";
10
+ const DEFAULT_VERSION = "3.2.0";
11
+
12
+ const regexLanguageFile = /.*\.properties$/;
13
+ const regexLanguageSuffixFile = /.*?_([a-z]{2}(?:_[a-z]{2})?)\.properties/i;
14
+
15
+ class CDMBuilder {
16
+ constructor(options = {}) {
17
+ this.component = COMPONENT_NAME;
18
+
19
+ this.options = options;
20
+ this.rootPath = options.root || process.cwd();
21
+
22
+ const project = this.deriveProject();
23
+
24
+ this.appPath = options.app || path.join(this.rootPath, "app");
25
+ this.portalSitePath = options.portalSite || path.join(this.appPath, "portal", "portal-site");
26
+ this.cdmPath = options.cdm || path.join(this.portalSitePath, "CommonDataModel.json");
27
+ this.targetPath = options.target || path.join(this.appPath, "cdm.json");
28
+ this.xsSecurityPath = options.xsSecurity || path.join(this.rootPath, "xs-security.json");
29
+
30
+ this.icon = options.icon || DEFAULT_ICON;
31
+ this.version = options.version || DEFAULT_VERSION;
32
+ this.namespace = options.namespace || project.name;
33
+ this.description = options.description || project.description;
34
+ this.defaultLanguage = cds.env.i18n?.default_language || options.language || "en";
35
+ this.i18nPath = options.i18n || "i18n";
36
+ this.i18nData = this.loadI18n(path.join(this.portalSitePath, this.i18nPath));
37
+ this.i18nBundle = options.i18nBundle || false;
38
+ }
39
+
40
+ async build() {
41
+ if (!this.options.skipWrite && !this.options.force && fs.existsSync(this.targetPath)) {
42
+ cds.log(this.component).info("Generation is skipped. CDM file already exists", this.targetPath);
43
+ return;
44
+ }
45
+ const cdm = fs.existsSync(this.cdmPath)
46
+ ? require(this.cdmPath)
47
+ : {
48
+ _version: this.version,
49
+ identification: {
50
+ id: `${this.namespace}-flp`,
51
+ title: this.namespace,
52
+ entityType: "bundle",
53
+ },
54
+ payload: {},
55
+ };
56
+ const apps = this.lookupApps(this.appPath);
57
+ const roles = this.lookupRoles(apps);
58
+ this.enhanceExisting(cdm);
59
+ this.addCatalogsAndGroups(cdm, apps);
60
+ this.addSitesAndPages(cdm);
61
+ this.addHomepageApp(cdm);
62
+ this.addBusinessApps(cdm, apps);
63
+ this.addUrlTemplates(cdm);
64
+ this.addRoles(cdm, roles);
65
+ if (!this.options.skipWrite) {
66
+ fs.mkdirSync(path.dirname(this.targetPath), { recursive: true });
67
+ fs.writeFileSync(this.targetPath, JSON.stringify(Object.values(cdm.payload).flat(), null, 2));
68
+ }
69
+ return cdm;
70
+ }
71
+
72
+ deriveProject() {
73
+ const packageJSON = require(path.join(this.rootPath, "package.json"));
74
+ const rawName = packageJSON.name.split("/").pop();
75
+ const parts = rawName.split(/[-.]/);
76
+ const name = parts.join(".");
77
+ return {
78
+ name,
79
+ description: packageJSON.description || name,
80
+ };
81
+ }
82
+
83
+ enhanceExisting(cdm) {
84
+ cdm._version = this.version;
85
+ cdm.payload.catalogs ||= [];
86
+ let index = 1;
87
+ for (const catalog of cdm.payload.catalogs) {
88
+ catalog._version = this.version;
89
+ catalog.identification.id = `${this.namespace}-catalog${cdm.payload.catalogs.length > 1 ? `-${index++}` : ""}`;
90
+ for (const viz of catalog.payload.viz || []) {
91
+ viz.appId ??= viz.id;
92
+ }
93
+ if (!this.i18nBundle) {
94
+ delete catalog.identification.i18n;
95
+ catalog.texts = this.buildTexts(this.i18nData);
96
+ }
97
+ }
98
+ cdm.payload.groups ||= [];
99
+ index = 1;
100
+ for (const group of cdm.payload.groups) {
101
+ group._version = this.version;
102
+ group.identification.id = `${this.namespace}-group${cdm.payload.groups.length > 1 ? `-${index++}` : ""}`;
103
+ if (!this.i18nBundle) {
104
+ delete group.identification.i18n;
105
+ group.texts = this.buildTexts(this.i18nData);
106
+ }
107
+ }
108
+ delete cdm.payload.sites;
109
+ }
110
+
111
+ addCatalogsAndGroups(cdm, apps) {
112
+ cdm.payload.catalogs ||= [];
113
+ if (!cdm.payload.catalogs.length) {
114
+ const catalog = {
115
+ _version: this.version,
116
+ identification: {
117
+ id: `${this.namespace}-catalog`,
118
+ title: "{{title}}",
119
+ entityType: "catalog",
120
+ ...(this.i18nBundle ? { i18n: "i18n/catalog.properties" } : {}),
121
+ },
122
+ payload: { viz: [] },
123
+ ...(!this.i18nBundle ? { texts: this.buildTexts(this.i18nData, ["title"], this.description) } : {}),
124
+ };
125
+ for (const app of apps) {
126
+ catalog.payload.viz.push({
127
+ appId: app.appId,
128
+ vizId: app.defaultViz,
129
+ });
130
+ }
131
+ cdm.payload.catalogs.push(catalog);
132
+ }
133
+ cdm.payload.groups ||= [];
134
+ if (!cdm.payload.groups.length) {
135
+ const group = {
136
+ _version: this.version,
137
+ identification: {
138
+ id: `${this.namespace}-group`,
139
+ title: "{{title}}",
140
+ entityType: "group",
141
+ ...(this.i18nBundle ? { i18n: "i18n/group.properties" } : {}),
142
+ },
143
+ payload: { viz: [] },
144
+ ...(!this.i18nBundle ? { texts: this.buildTexts(this.i18nData, ["title"], this.description) } : {}),
145
+ };
146
+ for (const app of apps) {
147
+ group.payload.viz.push({
148
+ id: app.id,
149
+ appId: app.appId,
150
+ vizId: app.defaultViz,
151
+ });
152
+ }
153
+ cdm.payload.groups.push(group);
154
+ }
155
+ }
156
+
157
+ addSitesAndPages(cdm) {
158
+ cdm.payload.spaces ||= [];
159
+ cdm.payload.pages ||= [];
160
+ const space = {
161
+ _version: this.version,
162
+ identification: {
163
+ id: `${this.namespace}-space`,
164
+ title: "{{title}}",
165
+ entityType: "space",
166
+ ...(this.i18nBundle ? { i18n: "i18n/space.properties" } : {}),
167
+ },
168
+ payload: { contentNodes: [] },
169
+ ...(!this.i18nBundle ? { texts: this.buildTexts(this.i18nData, ["title"], this.description) } : {}),
170
+ };
171
+ cdm.payload.spaces.push(space);
172
+ let index = 1;
173
+ for (const group of cdm.payload.groups || []) {
174
+ const pageId = `${this.namespace}-page${cdm.payload.groups.length > 1 ? `-${index++}` : ""}`;
175
+ cdm.payload.pages.push({
176
+ _version: this.version,
177
+ identification: {
178
+ id: pageId,
179
+ title: "{{title}}",
180
+ entityType: "page",
181
+ ...(this.i18nBundle ? { i18n: "i18n/page.properties" } : {}),
182
+ },
183
+ payload: {
184
+ sections: [
185
+ {
186
+ id: `${this.namespace}-section`,
187
+ title: "{{title}}",
188
+ viz: group.payload.viz,
189
+ },
190
+ ],
191
+ },
192
+ ...(!this.i18nBundle ? { texts: this.buildTexts(this.i18nData, ["title"], this.description) } : {}),
193
+ });
194
+ space.payload.contentNodes.push({
195
+ type: "page",
196
+ id: pageId,
197
+ });
198
+ }
199
+ }
200
+
201
+ addHomepageApp(cdm) {
202
+ cdm.payload.apps ||= [];
203
+ cdm.payload.apps.push({
204
+ _version: this.version,
205
+ identification: {
206
+ id: `${this.namespace}-shell-home`,
207
+ entityType: "businessapp",
208
+ title: "{{title}}",
209
+ ...(this.i18nBundle ? { i18n: "i18n/businessapp.properties" } : {}),
210
+ },
211
+ payload: {
212
+ targetAppConfig: {
213
+ "sap.app": {
214
+ crossNavigation: {
215
+ inbounds: {
216
+ default: {
217
+ semanticObject: "Shell",
218
+ action: "home",
219
+ signature: {},
220
+ },
221
+ },
222
+ },
223
+ tags: {
224
+ keywords: [],
225
+ technicalAttributes: ["APPTYPE_HOMEPAGE"],
226
+ },
227
+ },
228
+ "sap.integration": {
229
+ urlTemplateId: `${this.namespace}-urltemplate-home`,
230
+ urlTemplateParams: { path: "" },
231
+ },
232
+ "sap.ui": {
233
+ icons: { icon: this.icon },
234
+ },
235
+ },
236
+ visualizations: {
237
+ default: {
238
+ vizType: "sap.ushell.StaticAppLauncher",
239
+ vizConfig: {
240
+ "sap.app": { title: "{{title}}" },
241
+ "sap.flp": {
242
+ target: { type: "IBN", inboundId: "default" },
243
+ },
244
+ },
245
+ },
246
+ },
247
+ defaultViz: "default",
248
+ },
249
+ ...(!this.i18nBundle ? { texts: this.buildTexts(this.i18nData, ["title"], this.description) } : {}),
250
+ });
251
+ }
252
+
253
+ addBusinessApps(cdm, apps) {
254
+ cdm.payload.apps ||= [];
255
+ for (const app of apps) {
256
+ const defaultViz = app.defaultViz;
257
+ if (!defaultViz) {
258
+ continue;
259
+ }
260
+ const icon = app.crossNavigation.inbounds[defaultViz].icon || this.icon;
261
+ const texts = this.buildAppTexts(app.i18n, app.id);
262
+ cdm.payload.apps.push({
263
+ _version: this.version,
264
+ identification: {
265
+ id: app.id,
266
+ entityType: "businessapp",
267
+ title: "{{title}}",
268
+ ...(this.i18nBundle ? { i18n: "i18n/app.properties" } : {}),
269
+ },
270
+ payload: {
271
+ targetAppConfig: {
272
+ "sap.app": { crossNavigation: app.crossNavigation },
273
+ "sap.integrations": [
274
+ {
275
+ navMode: "explace",
276
+ urlTemplateId: `${this.namespace}-urltemplate`,
277
+ urlTemplateParams: { path: "" },
278
+ },
279
+ ],
280
+ "sap.ui": { icons: { icon } },
281
+ },
282
+ visualizations: {
283
+ [defaultViz]: {
284
+ vizType: "sap.ushell.StaticAppLauncher",
285
+ vizConfig: {
286
+ "sap.app": { title: "{{title}}" },
287
+ "sap.flp": {
288
+ target: { type: "IBN", inboundId: defaultViz },
289
+ },
290
+ },
291
+ },
292
+ },
293
+ defaultViz,
294
+ },
295
+ ...(!this.i18nBundle ? { texts } : {}),
296
+ });
297
+ }
298
+ }
299
+
300
+ addUrlTemplates(cdm) {
301
+ cdm.payload.urlTemplates ||= [];
302
+ const templates = [
303
+ {
304
+ id: `${this.namespace}-urltemplate-home`,
305
+ template: "{+_baseUrl}{+path}#Shell-home{?intentParameters*}",
306
+ },
307
+ {
308
+ id: `${this.namespace}-urltemplate`,
309
+ template: "{+_baseUrl}{+path}#{+semanticObject}-{+action}{?intentParameters*}",
310
+ },
311
+ ];
312
+ for (const template of templates) {
313
+ cdm.payload.urlTemplates.push({
314
+ _version: this.version,
315
+ identification: {
316
+ id: template.id,
317
+ entityType: "urltemplate",
318
+ title: "{{title}}",
319
+ ...(this.i18nBundle ? { i18n: "i18n/urltemplate.properties" } : {}),
320
+ },
321
+ payload: {
322
+ urlTemplate: template.template,
323
+ parameters: {
324
+ mergeWith: "/urlTemplates/urltemplate.base/payload/parameters/names",
325
+ names: {
326
+ path: "{./sap.integration/urlTemplateParams/path}",
327
+ intentParameters: "{*}",
328
+ },
329
+ },
330
+ capabilities: { navigationMode: "standalone" },
331
+ },
332
+ ...(!this.i18nBundle ? { texts: this.buildTexts(this.i18nData) } : {}),
333
+ });
334
+ }
335
+ }
336
+
337
+ addRoles(cdm, roles) {
338
+ cdm.payload.roles ||= [];
339
+ let scopes = {};
340
+ if (fs.existsSync(this.xsSecurityPath)) {
341
+ const xsSecurity = require(this.xsSecurityPath);
342
+ scopes = Object.fromEntries(xsSecurity.scopes.map((scope) => [this.localScope(scope.name), scope]));
343
+ }
344
+ for (const role of Object.keys(roles)) {
345
+ const scope = scopes[role];
346
+ cdm.payload.roles.push({
347
+ _version: this.version,
348
+ identification: {
349
+ id: role,
350
+ entityType: "role",
351
+ title: "{{title}}",
352
+ ...(this.i18nBundle ? { i18n: "i18n/role.properties" } : {}),
353
+ },
354
+ payload: {
355
+ catalogs: [{ id: `${this.namespace}-catalog` }],
356
+ groups: [{ id: `${this.namespace}-group` }],
357
+ spaces: [{ id: `${this.namespace}-space` }],
358
+ apps: [...roles[role].map((app) => ({ id: app.id })), { id: `${this.namespace}-shell-home` }],
359
+ },
360
+ ...(!this.i18nBundle
361
+ ? {
362
+ texts: [
363
+ {
364
+ locale: "",
365
+ textDictionary: {
366
+ title: scope?.description || role,
367
+ },
368
+ },
369
+ ],
370
+ }
371
+ : {}),
372
+ });
373
+ }
374
+ }
375
+
376
+ lookupApps(appRoot) {
377
+ const apps = [];
378
+ const dirs = fs
379
+ .readdirSync(appRoot, { withFileTypes: true })
380
+ .filter((dir) => dir.isDirectory())
381
+ .map((dir) => dir.name);
382
+ for (const dir of dirs) {
383
+ const ui5Yaml = path.join(appRoot, dir, "ui5.yaml");
384
+ if (!fs.existsSync(ui5Yaml)) {
385
+ continue;
386
+ }
387
+ const manifestPath = path.join(appRoot, dir, "webapp", "manifest.json");
388
+ if (!fs.existsSync(manifestPath)) {
389
+ continue;
390
+ }
391
+ const manifest = require(manifestPath);
392
+ if (manifest["sap.flp"]?.type === "plugin") {
393
+ continue;
394
+ }
395
+ const appId = manifest["sap.app"]?.id;
396
+ if (!appId || appId.endsWith(".extension")) {
397
+ continue;
398
+ }
399
+ const crossNavigation = manifest["sap.app"]?.crossNavigation || {};
400
+ const defaultViz = Object.keys(crossNavigation.inbounds || {})[0];
401
+ const scopes = manifest["sap.platform.cf"]?.oAuthScopes?.map((s) => this.localScope(s)) || [];
402
+ const i18n = this.loadI18n(path.join(appRoot, dir, "webapp", "i18n"));
403
+ apps.push({
404
+ id: appId,
405
+ appId,
406
+ defaultViz,
407
+ crossNavigation,
408
+ scopes,
409
+ i18n,
410
+ });
411
+ }
412
+ return apps;
413
+ }
414
+
415
+ lookupRoles(apps) {
416
+ const roles = {};
417
+ for (const app of apps) {
418
+ for (const scope of app.scopes) {
419
+ roles[scope] ||= [];
420
+ roles[scope].push(app);
421
+ }
422
+ }
423
+ return roles;
424
+ }
425
+
426
+ localScope(scope) {
427
+ return scope.startsWith("$XSAPPNAME.") ? scope.slice(11) : scope;
428
+ }
429
+
430
+ loadI18n(dir) {
431
+ const result = {
432
+ [this.defaultLanguage]: {},
433
+ };
434
+ try {
435
+ for (const file of fs.readdirSync(dir)) {
436
+ if (!regexLanguageFile.test(file)) {
437
+ continue;
438
+ }
439
+ const texts = cds.load.properties(path.join(dir, file));
440
+ const match = file.match(regexLanguageSuffixFile);
441
+ const lang = match?.[1] || this.defaultLanguage;
442
+ result[lang] ||= {};
443
+ Object.assign(result[lang], texts);
444
+ }
445
+ } catch {
446
+ /* ignore */
447
+ }
448
+ return result;
449
+ }
450
+
451
+ buildTexts(i18n, keys, defaultText = "") {
452
+ const texts = [];
453
+ for (const locale of Object.keys(i18n)) {
454
+ texts.push({
455
+ locale,
456
+ textDictionary: keys
457
+ ? Object.fromEntries(keys.map((key) => [key, i18n[locale][key] || defaultText]))
458
+ : i18n[locale],
459
+ });
460
+ }
461
+ texts.push({
462
+ locale: "",
463
+ textDictionary: keys
464
+ ? Object.fromEntries(keys.map((key) => [key, i18n[this.defaultLanguage][key] || defaultText]))
465
+ : i18n[this.defaultLanguage],
466
+ });
467
+ return texts;
468
+ }
469
+
470
+ buildAppTexts(i18n, app) {
471
+ const texts = [];
472
+ for (const locale of Object.keys(i18n)) {
473
+ const title =
474
+ i18n[locale].fioriAppTitle || i18n[locale].appTitle || i18n[locale].app_title || i18n[locale].appTitleReworked;
475
+ if (!title) {
476
+ throw new Error(`Missing title for locale ${locale} in app "${app}"`);
477
+ }
478
+ texts.push({
479
+ locale,
480
+ textDictionary: { title },
481
+ });
482
+ }
483
+ texts.push({
484
+ locale: "",
485
+ textDictionary: {
486
+ title:
487
+ i18n[this.defaultLanguage]?.fioriAppTitle ||
488
+ i18n[this.defaultLanguage]?.appTitle ||
489
+ i18n[this.defaultLanguage]?.app_title ||
490
+ i18n[this.defaultLanguage]?.appTitleReworked,
491
+ },
492
+ });
493
+ return texts;
494
+ }
495
+ }
496
+
497
+ module.exports = CDMBuilder;
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ module.exports = {
4
+ CDMBuilder: require("./CDMBuilder"),
5
+ };
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  module.exports = {
4
+ CDMBuilder: require("./cdm-build").CDMBuilder,
4
5
  LocalHTML5Repo: require("./local-html5-repo").LocalHTML5Repo,
5
6
  MigrationCheck: require("./migration-check").MigrationCheck,
6
7
  RateLimiting: require("./rate-limiting").RateLimiting,
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
 
3
- /* eslint-disable no-console */
4
3
  /* eslint-disable n/no-process-exit */
5
4
 
6
5
  // Suppress deprecation warning in Node 22 due to http-proxy using util._extend()
@@ -10,23 +9,35 @@ const fs = require("fs");
10
9
  const path = require("path");
11
10
  const express = require("express");
12
11
  const { createProxyMiddleware } = require("http-proxy-middleware");
12
+ const cds = require("@sap/cds");
13
13
 
14
- const PORT = process.env.PORT || 3001;
15
- const DEFAULT_ENV_PATH = path.join(process.cwd(), "approuter/default-env.json");
14
+ const DEFAULT_ENV_NAME = "default-env.json";
15
+ const DEFAULT_ENV_PATHS = [
16
+ path.join(process.cwd(), "app", "router", DEFAULT_ENV_NAME),
17
+ path.join(process.cwd(), "approuter", DEFAULT_ENV_NAME),
18
+ ];
16
19
  const APP_ROOT = path.join(process.cwd(), "app");
20
+ const PORT = process.env.PORT || 3001;
17
21
 
18
- const COMPONENT_NAME = "/cap-js-community-common/localHTML5Repo";
22
+ const COMPONENT_NAME = "/cap-js-community-common/local-html5-repo";
19
23
 
20
24
  class LocalHTML5Repo {
21
25
  constructor(options) {
22
26
  this.component = COMPONENT_NAME;
23
27
  this.port = options?.port || PORT;
24
- this.path = options?.path || DEFAULT_ENV_PATH;
25
-
28
+ this.path = options?.path;
29
+ if (!this.path) {
30
+ for (const path of DEFAULT_ENV_PATHS) {
31
+ if (fs.existsSync(path)) {
32
+ this.path = path;
33
+ break;
34
+ }
35
+ }
36
+ }
26
37
  try {
27
38
  this.defaultEnv = require(this.path);
28
39
  } catch (err) {
29
- console.error(`Cannot read default-env.json at ${this.path}`);
40
+ cds.log(this.component).error(`Cannot read default-env.json at ${this.path}`);
30
41
  throw err;
31
42
  }
32
43
  }
@@ -35,7 +46,7 @@ class LocalHTML5Repo {
35
46
  return new Promise((resolve) => {
36
47
  this.adjustDefaultEnv();
37
48
 
38
- console.log("Registering apps:");
49
+ cds.log(this.component).info("Registering apps:");
39
50
  const app = express();
40
51
 
41
52
  // Serve every webapp
@@ -64,7 +75,7 @@ class LocalHTML5Repo {
64
75
  }),
65
76
  );
66
77
 
67
- console.log(`- ${name} [${type}] -> ${path.join(APP_ROOT, appDirectory)}`);
78
+ cds.log(this.component).info(`- ${name} [${type}] -> ${path.join(APP_ROOT, appDirectory)}`);
68
79
  }
69
80
  }
70
81
 
@@ -80,7 +91,7 @@ class LocalHTML5Repo {
80
91
  ws: true,
81
92
  logLevel: "warn",
82
93
  onError(err, req, res) {
83
- console.error("HTML5 Repo proxy error:", err.message);
94
+ cds.log(this.component).error("HTML5 Repo proxy error:", err.message);
84
95
  res.status(502).end("Bad Gateway");
85
96
  },
86
97
  });
@@ -89,7 +100,7 @@ class LocalHTML5Repo {
89
100
  app.use("/", html5RepoProxy);
90
101
 
91
102
  this.server = app.listen(this.port, () => {
92
- console.log(`Local HTML5 repository running on port ${this.port}`);
103
+ cds.log(this.component).info(`Local HTML5 repository running on port ${this.port}`);
93
104
  resolve(this.server);
94
105
  });
95
106
  });
@@ -118,10 +129,8 @@ class LocalHTML5Repo {
118
129
 
119
130
  writeDefaultEnv() {
120
131
  const url = this.defaultEnv.VCAP_SERVICES["html5-apps-repo"][0].credentials.uri;
121
-
122
- console.log(`Rewriting HTML5 Repo URL in default-env.json of approuter: ${url}`);
123
-
124
- fs.writeFileSync(DEFAULT_ENV_PATH, JSON.stringify(this.defaultEnv, null, 2) + "\n");
132
+ cds.log(this.component).info(`Rewriting HTML5 Repo URL in default-env.json of approuter: ${url}`);
133
+ fs.writeFileSync(this.path, JSON.stringify(this.defaultEnv, null, 2) + "\n");
125
134
  }
126
135
 
127
136
  extractNameAndType(content) {
@@ -5,7 +5,7 @@ const path = require("path");
5
5
  const fs = require("fs");
6
6
  const crypto = require("crypto");
7
7
 
8
- const COMPONENT_NAME = "/cap-js-community-common/migrationCheck";
8
+ const COMPONENT_NAME = "/cap-js-community-common/migration-check";
9
9
  const STRING_DEFAULT_LENGTH = 5000;
10
10
 
11
11
  const Checks = [releasedEntityCheck, newEntityCheck, uniqueIndexCheck, journalModeCheck];
@@ -7,7 +7,7 @@ const redisResetTime = require("./redis/resetTime");
7
7
 
8
8
  const { connectionCheck } = require("./redis/common");
9
9
 
10
- const COMPONENT_NAME = "/cap-js-community-common/rateLimiting";
10
+ const COMPONENT_NAME = "/cap-js-community-common/rate-limiting";
11
11
 
12
12
  class RateLimiting {
13
13
  constructor(service, { maxConcurrent, maxInWindow, window } = {}) {
@@ -4,7 +4,7 @@ const cds = require("@sap/cds");
4
4
 
5
5
  const { RedisClient } = require("../../redis-client");
6
6
 
7
- const COMPONENT_NAME = "/cap-js-community-common/rateLimiting";
7
+ const COMPONENT_NAME = "/cap-js-community-common/rate-limiting";
8
8
 
9
9
  async function connectionCheck() {
10
10
  return await RedisClient.create(COMPONENT_NAME).connectionCheck();
@@ -3,12 +3,13 @@
3
3
  const redis = require("redis");
4
4
  const cds = require("@sap/cds");
5
5
 
6
- const COMPONENT_NAME = "/cap-js-community-common/redisClient";
6
+ const COMPONENT_NAME = "/cap-js-community-common/redis-client";
7
7
  const LOG_AFTER_SEC = 5;
8
8
  const TIMEOUT_SHUTDOWN = 2500;
9
9
 
10
10
  class RedisClient {
11
11
  #clusterClient = false;
12
+ #sentinelClient = false;
12
13
  #beforeCloseHandler;
13
14
  constructor(name, env) {
14
15
  this.name = name;
@@ -51,27 +52,26 @@ class RedisClient {
51
52
  async createClientAndConnect(options, errorHandlerCreateClient, isConnectionCheck) {
52
53
  try {
53
54
  const client = this.createClientBase(options);
54
- if (!client) {
55
- return;
56
- }
57
- if (!isConnectionCheck) {
58
- client.on("error", (err) => {
59
- const dateNow = Date.now();
60
- if (dateNow - this.lastErrorLog > LOG_AFTER_SEC * 1000) {
61
- this.log.error("Error from redis client", err);
62
- this.lastErrorLog = dateNow;
63
- }
64
- });
55
+ if (client) {
56
+ if (!isConnectionCheck) {
57
+ client.on("error", (err) => {
58
+ const dateNow = Date.now();
59
+ if (dateNow - this.lastErrorLog > LOG_AFTER_SEC * 1000) {
60
+ this.log.error("Error from redis client", err);
61
+ this.lastErrorLog = dateNow;
62
+ }
63
+ });
65
64
 
66
- client.on("reconnecting", () => {
67
- const dateNow = Date.now();
68
- if (dateNow - this.lastErrorLog > LOG_AFTER_SEC * 1000) {
69
- this.log.info("Redis client trying reconnect...");
70
- this.lastErrorLog = dateNow;
71
- }
72
- });
65
+ client.on("reconnecting", () => {
66
+ const dateNow = Date.now();
67
+ if (dateNow - this.lastErrorLog > LOG_AFTER_SEC * 1000) {
68
+ this.log.info("Redis client trying reconnect...");
69
+ this.lastErrorLog = dateNow;
70
+ }
71
+ });
72
+ }
73
+ await client.connect();
73
74
  }
74
- await client.connect();
75
75
  return client;
76
76
  } catch (err) {
77
77
  errorHandlerCreateClient(err);
@@ -105,20 +105,24 @@ class RedisClient {
105
105
  createClientBase(redisOptions = {}) {
106
106
  const { credentials, options } =
107
107
  (this.env ? cds.env.requires[`redis-${this.env}`] : undefined) || cds.env.requires["redis"] || {};
108
- const socket = {
109
- host: credentials?.hostname ?? "127.0.0.1",
110
- tls: !!credentials?.tls,
111
- port: credentials?.port ?? 6379,
112
- ...options?.socket,
113
- ...redisOptions.socket,
114
- };
115
- const socketOptions = {
116
- ...options,
117
- ...redisOptions,
118
- password: redisOptions?.password ?? options?.password ?? credentials?.password,
119
- socket,
120
- };
108
+
121
109
  try {
110
+ if (credentials?.sentinel_nodes?.length > 0) {
111
+ return this.createSentinelClient(credentials, options, redisOptions);
112
+ }
113
+ const socket = {
114
+ host: credentials?.hostname ?? "127.0.0.1",
115
+ tls: !!credentials?.tls,
116
+ port: credentials?.port ?? 6379,
117
+ ...options?.socket,
118
+ ...redisOptions.socket,
119
+ };
120
+ const socketOptions = {
121
+ ...options,
122
+ ...redisOptions,
123
+ password: redisOptions?.password ?? options?.password ?? credentials?.password,
124
+ socket,
125
+ };
122
126
  if (credentials?.cluster_mode) {
123
127
  this.#clusterClient = true;
124
128
  return redis.createCluster({
@@ -128,8 +132,61 @@ class RedisClient {
128
132
  }
129
133
  return redis.createClient(socketOptions);
130
134
  } catch (err) {
131
- throw new Error("Error during create client with redis-cache service" + err);
135
+ throw new Error("Error during create client with redis-cache service", err);
136
+ }
137
+ }
138
+
139
+ createSentinelClient(credentials, options, redisOptions) {
140
+ const masterName = this.extractMasterName(credentials);
141
+ const sentinelNodes = credentials.sentinel_nodes.map((node) => ({
142
+ host: node.host ?? node.hostname,
143
+ port: node.port ?? 26379,
144
+ }));
145
+ const clientOptions = {
146
+ ...options,
147
+ ...redisOptions,
148
+ password: redisOptions?.password ?? options?.password ?? credentials?.password,
149
+ socket: {
150
+ tls: !!credentials?.tls,
151
+ ...options?.socket,
152
+ ...redisOptions?.socket,
153
+ },
154
+ };
155
+ this.#sentinelClient = true;
156
+ this.log.info("Creating Redis Sentinel client", { masterName, nodeCount: sentinelNodes.length });
157
+ return redis.createSentinel({
158
+ name: masterName,
159
+ sentinelRootNodes: sentinelNodes,
160
+ nodeClientOptions: clientOptions,
161
+ sentinelClientOptions: clientOptions,
162
+ passthroughClientErrorEvents: true,
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Extracts the Sentinel master name from credentials.
168
+ * Priority: master_name field > URI fragment
169
+ * @param {Object} credentials - Redis credentials
170
+ * @returns {string} Master name
171
+ * @throws {Error} If master name cannot be determined
172
+ */
173
+ extractMasterName(credentials) {
174
+ if (credentials?.master_name) {
175
+ return credentials.master_name;
176
+ }
177
+ if (credentials?.uri) {
178
+ try {
179
+ const url = new URL(credentials.uri);
180
+ if (url.hash && url.hash.length > 1) {
181
+ return url.hash.slice(1);
182
+ }
183
+ } catch (e) {
184
+ this.log.warn("Failed to parse master name from URI", e.message);
185
+ }
132
186
  }
187
+ throw new Error(
188
+ "Redis Sentinel master name not found. Provide credentials.master_name or include #mastername in credentials.uri",
189
+ );
133
190
  }
134
191
 
135
192
  subscribeChannel(options, channel, subscribeHandler) {
@@ -149,6 +206,9 @@ class RedisClient {
149
206
  subscribeChannels(options, errorHandlerCreateClient) {
150
207
  this.subscriberClientPromise = this.createClientAndConnect(options, errorHandlerCreateClient)
151
208
  .then((client) => {
209
+ if (!client) {
210
+ return;
211
+ }
152
212
  for (const channel in this.subscribedChannels) {
153
213
  const fn = this.subscribedChannels[channel];
154
214
  client._subscribedChannels ??= {};
@@ -214,7 +274,9 @@ class RedisClient {
214
274
 
215
275
  async resilientClientClose(client) {
216
276
  try {
217
- if (client?.quit) {
277
+ if (client?.close) {
278
+ await client.close();
279
+ } else if (client?.quit) {
218
280
  await client.quit();
219
281
  }
220
282
  } catch (err) {
@@ -248,6 +310,10 @@ class RedisClient {
248
310
  return this.#clusterClient;
249
311
  }
250
312
 
313
+ get isSentinel() {
314
+ return this.#sentinelClient;
315
+ }
316
+
251
317
  static create(name = "default", env) {
252
318
  env ??= name;
253
319
  RedisClient._create ??= {};
@@ -741,7 +741,7 @@ class ReplicationCacheEntry {
741
741
  selectQuery.replication = true;
742
742
  const chunkSize = this.cache.options.chunks;
743
743
  let offset = 0;
744
- let entries = [];
744
+ let entries;
745
745
  do {
746
746
  entries = await srcTx.run(selectQuery.limit(chunkSize, offset));
747
747
  if (entries.length > 0) {