@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 +17 -0
- package/README.md +45 -5
- package/bin/cdm-build.js +35 -0
- package/package.json +21 -16
- package/src/cdm-build/CDMBuilder.js +497 -0
- package/src/cdm-build/index.js +5 -0
- package/src/index.js +1 -0
- package/src/local-html5-repo/LocalHTML5Repo.js +24 -15
- package/src/migration-check/MigrationCheck.js +1 -1
- package/src/rate-limiting/RateLimiting.js +1 -1
- package/src/rate-limiting/redis/common.js +1 -1
- package/src/redis-client/RedisClient.js +101 -35
- package/src/replication-cache/ReplicationCache.js +1 -1
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
|
-
-
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
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).
|
package/bin/cdm-build.js
ADDED
|
@@ -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
|
+
"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
|
-
"
|
|
31
|
-
"
|
|
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.
|
|
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.
|
|
60
|
-
"@sap/cds": "^9.
|
|
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.
|
|
63
|
-
"eslint": "^
|
|
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.
|
|
66
|
-
"eslint-plugin-n": "^17.
|
|
67
|
-
"jest": "^30.
|
|
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.
|
|
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;
|
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
|
|
15
|
-
const
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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 (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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"
|
|
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?.
|
|
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) {
|