@ti-engine/web-framework 1.19.0 → 1.20.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/.env +4 -4
- package/CHANGELOG.md +384 -353
- package/README.md +73 -73
- package/bin/build/post-install.js +18 -18
- package/bin/localization/web-server-labels.json +27 -27
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
- package/bin/static/fragments/components/component-notification-bar.html +21 -21
- package/bin/static/fragments/components/component-sidebar.html +33 -33
- package/bin/static/fragments/components/component-tooltip.html +10 -10
- package/bin/static/fragments/components/component-topbar.html +5 -5
- package/bin/static/fragments/frame-administration.html +2 -2
- package/bin/static/fragments/frame-application.html +18 -18
- package/bin/static/fragments/frame-dashboard.html +2 -2
- package/bin/static/fragments/frame-login.html +119 -119
- package/bin/static/fragments/frame-not-found.html +2 -2
- package/bin/static/fragments/frame-profile.html +2 -2
- package/bin/static/index.html +22 -22
- package/bin/static/scripts/ti-charts.js +1591 -1591
- package/bin/static/scripts/ti-framework.css +3194 -3194
- package/bin/static/scripts/ti-framework.js +1427 -1427
- package/bin/static/scripts/ti-theme-black-glass.css +216 -216
- package/bin/static/scripts/ti-theme-daylight.css +87 -87
- package/bin/web-app-manager.js +660 -663
- package/bin/web-server.js +936 -937
- package/bin/web-server.json +48 -48
- package/components/admin-config-handlers.js +95 -92
- package/components/auth-manager.js +438 -442
- package/components/authorization.js +135 -135
- package/components/config-change-notifier.js +98 -98
- package/components/config-registry.js +257 -260
- package/components/config-service.js +363 -360
- package/components/config-store.js +244 -246
- package/components/definitions.types.js +28 -26
- package/components/session-store.js +113 -110
- package/components/user.js +134 -132
- package/components/web-config-env.js +85 -85
- package/components/web-handlers.js +803 -800
- package/package.json +139 -67
- package/types/bin/web-app-manager.d.ts +194 -0
- package/types/bin/web-server.d.ts +373 -0
- package/types/components/admin-config-handlers.d.ts +11 -0
- package/types/components/auth-manager.d.ts +125 -0
- package/types/components/authorization.d.ts +54 -0
- package/types/components/config-change-notifier.d.ts +73 -0
- package/types/components/config-registry.d.ts +149 -0
- package/types/components/config-service.d.ts +218 -0
- package/types/components/config-store.d.ts +128 -0
- package/types/components/definitions.types.d.ts +31 -0
- package/types/components/session-store.d.ts +56 -0
- package/types/components/user.d.ts +83 -0
- package/types/components/web-config-env.d.ts +17 -0
- package/types/components/web-handlers.d.ts +23 -0
|
@@ -1,135 +1,135 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
-
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
-
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
-
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
-
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Framework-level authorization helpers. Provides the privileged `admin` role used to gate configuration-editing
|
|
11
|
-
* (and other administrative) routes, plus Express guards. The `admin` role is sourced from a deployment allowlist
|
|
12
|
-
* (`auth.admins` in the web-server config) and applied to the session *after* the application's `augmentSession`
|
|
13
|
-
* hook runs, so it is additive and cannot be clobbered by an app's own (domain) role assignment.
|
|
14
|
-
*
|
|
15
|
-
* @module authorization
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The privileged role required to administer configuration. A string value so it never collides with an
|
|
22
|
-
* application's own role codes (e.g. competence uses numeric role codes).
|
|
23
|
-
*
|
|
24
|
-
* @type {string}
|
|
25
|
-
*/
|
|
26
|
-
const ADMIN_ROLE = "admin";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* @param {*} value
|
|
30
|
-
* @returns {string} Trimmed, lower-cased string form (so email/username matching is case-insensitive).
|
|
31
|
-
*/
|
|
32
|
-
function normalizeIdentity( value ) {
|
|
33
|
-
return String( value == null ? "" : value ).trim().toLowerCase();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Returns `true` if the user matches any entry in the admin allowlist. An entry may match the user's `userID`,
|
|
38
|
-
* `username`, or `email` (case-insensitive).
|
|
39
|
-
*
|
|
40
|
-
* @param {Object} user A session user (`{ userID, username, email, roles, ... }`).
|
|
41
|
-
* @param {string[]} admins The configured allowlist of admin identifiers.
|
|
42
|
-
* @returns {boolean}
|
|
43
|
-
*/
|
|
44
|
-
function isAdminIdentity( user, admins ) {
|
|
45
|
-
if ( !user || !Array.isArray( admins ) || admins.length === 0 ) {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
const candidates = new Set( [ user.userID, user.username, user.email ].map( normalizeIdentity ).filter( ( value ) => value.length > 0 ) );
|
|
49
|
-
return admins.some( ( entry ) => candidates.has( normalizeIdentity( entry ) ) );
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Adds the `admin` role to the session user (additively, no duplicates) when the user is in the allowlist.
|
|
54
|
-
* Safe to call with an empty/missing allowlist or session — it is then a no-op. Returns the session for chaining.
|
|
55
|
-
*
|
|
56
|
-
* @param {Object} session
|
|
57
|
-
* @param {string[]} [admins]
|
|
58
|
-
* @returns {Object} The (possibly modified) session.
|
|
59
|
-
*/
|
|
60
|
-
function applyAdminRole( session, admins ) {
|
|
61
|
-
if ( session && session.user && isAdminIdentity( session.user, admins ) ) {
|
|
62
|
-
const roles = Array.isArray( session.user.roles ) ? session.user.roles.slice() : [];
|
|
63
|
-
if ( !roles.includes( ADMIN_ROLE ) ) {
|
|
64
|
-
roles.push( ADMIN_ROLE );
|
|
65
|
-
}
|
|
66
|
-
session.user.roles = roles;
|
|
67
|
-
}
|
|
68
|
-
return session;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* @param {Object} session
|
|
73
|
-
* @param {Array<string|number>} roles
|
|
74
|
-
* @returns {boolean} `true` if the session user holds any of the given roles.
|
|
75
|
-
*/
|
|
76
|
-
function hasAnyRole( session, roles ) {
|
|
77
|
-
const userRoles = ( session && session.user && Array.isArray( session.user.roles ) ) ? session.user.roles : [];
|
|
78
|
-
return roles.some( ( role ) => userRoles.includes( role ) );
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Pure access decision for a resource (e.g. an HTML fragment) that declares a set of required roles. A resource with
|
|
83
|
-
* no required roles (`null` / `undefined` / empty) is public — any (authenticated) user may access it; otherwise the
|
|
84
|
-
* user must hold at least one of the required roles. Roles are treated opaquely, so this works equally for numeric
|
|
85
|
-
* application role codes and the string `admin` role — there is no implicit hierarchy (an `admin`-gated resource is
|
|
86
|
-
* reachable only by holders of the `admin` role, never by a high numeric role). Backs {@link TiWebAppManager#verifyAccess}.
|
|
87
|
-
*
|
|
88
|
-
* @param {Array<string|number>} [requiredRoles] The roles permitted to access the resource; empty/absent = public.
|
|
89
|
-
* @param {Array<string|number>} [userRoles] The roles held by the current session user.
|
|
90
|
-
* @returns {boolean}
|
|
91
|
-
*/
|
|
92
|
-
function isAccessAllowed( requiredRoles, userRoles ) {
|
|
93
|
-
if ( requiredRoles === null || requiredRoles === undefined ) {
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
if ( !Array.isArray( requiredRoles ) ) {
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
if ( requiredRoles.length === 0 ) {
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
const roles = Array.isArray( userRoles ) ? userRoles : [];
|
|
103
|
-
return requiredRoles.some( ( role ) => roles.includes( role ) );
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Express middleware factory that admits a request only if its session user holds at least one of the given roles.
|
|
108
|
-
* Responds `401` when unauthenticated (no session user) and `403` when authenticated but lacking the role.
|
|
109
|
-
*
|
|
110
|
-
* @param {...(string|number)} roles
|
|
111
|
-
* @returns {
|
|
112
|
-
*/
|
|
113
|
-
function requireRole( ...roles ) {
|
|
114
|
-
return ( request, response, next ) => {
|
|
115
|
-
const user = request && request.session && request.session.user;
|
|
116
|
-
if ( !user ) {
|
|
117
|
-
response.status( exceptions.httpCode.C_401 ).end();
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if ( !hasAnyRole( request.session, roles ) ) {
|
|
121
|
-
response.status( exceptions.httpCode.C_403 ).end();
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
next();
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Express middleware that admits only `admin`-role users.
|
|
130
|
-
*
|
|
131
|
-
* @type {
|
|
132
|
-
*/
|
|
133
|
-
const requireAdmin = requireRole( ADMIN_ROLE );
|
|
134
|
-
|
|
135
|
-
module.exports = { ADMIN_ROLE, isAdminIdentity, applyAdminRole, hasAnyRole, isAccessAllowed, requireRole, requireAdmin };
|
|
1
|
+
/*
|
|
2
|
+
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
+
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
+
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Framework-level authorization helpers. Provides the privileged `admin` role used to gate configuration-editing
|
|
11
|
+
* (and other administrative) routes, plus Express guards. The `admin` role is sourced from a deployment allowlist
|
|
12
|
+
* (`auth.admins` in the web-server config) and applied to the session *after* the application's `augmentSession`
|
|
13
|
+
* hook runs, so it is additive and cannot be clobbered by an app's own (domain) role assignment.
|
|
14
|
+
*
|
|
15
|
+
* @module authorization
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The privileged role required to administer configuration. A string value so it never collides with an
|
|
22
|
+
* application's own role codes (e.g. competence uses numeric role codes).
|
|
23
|
+
*
|
|
24
|
+
* @type {string}
|
|
25
|
+
*/
|
|
26
|
+
const ADMIN_ROLE = "admin";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {*} value
|
|
30
|
+
* @returns {string} Trimmed, lower-cased string form (so email/username matching is case-insensitive).
|
|
31
|
+
*/
|
|
32
|
+
function normalizeIdentity( value ) {
|
|
33
|
+
return String( value == null ? "" : value ).trim().toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns `true` if the user matches any entry in the admin allowlist. An entry may match the user's `userID`,
|
|
38
|
+
* `username`, or `email` (case-insensitive).
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} user A session user (`{ userID, username, email, roles, ... }`).
|
|
41
|
+
* @param {string[]} admins The configured allowlist of admin identifiers.
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function isAdminIdentity( user, admins ) {
|
|
45
|
+
if ( !user || !Array.isArray( admins ) || admins.length === 0 ) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const candidates = new Set( [ user.userID, user.username, user.email ].map( normalizeIdentity ).filter( ( value ) => value.length > 0 ) );
|
|
49
|
+
return admins.some( ( entry ) => candidates.has( normalizeIdentity( entry ) ) );
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Adds the `admin` role to the session user (additively, no duplicates) when the user is in the allowlist.
|
|
54
|
+
* Safe to call with an empty/missing allowlist or session — it is then a no-op. Returns the session for chaining.
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} session
|
|
57
|
+
* @param {string[]} [admins]
|
|
58
|
+
* @returns {Object} The (possibly modified) session.
|
|
59
|
+
*/
|
|
60
|
+
function applyAdminRole( session, admins ) {
|
|
61
|
+
if ( session && session.user && isAdminIdentity( session.user, admins ) ) {
|
|
62
|
+
const roles = Array.isArray( session.user.roles ) ? session.user.roles.slice() : [];
|
|
63
|
+
if ( !roles.includes( ADMIN_ROLE ) ) {
|
|
64
|
+
roles.push( ADMIN_ROLE );
|
|
65
|
+
}
|
|
66
|
+
session.user.roles = roles;
|
|
67
|
+
}
|
|
68
|
+
return session;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {Object} session
|
|
73
|
+
* @param {Array<string|number>} roles
|
|
74
|
+
* @returns {boolean} `true` if the session user holds any of the given roles.
|
|
75
|
+
*/
|
|
76
|
+
function hasAnyRole( session, roles ) {
|
|
77
|
+
const userRoles = ( session && session.user && Array.isArray( session.user.roles ) ) ? session.user.roles : [];
|
|
78
|
+
return roles.some( ( role ) => userRoles.includes( role ) );
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pure access decision for a resource (e.g. an HTML fragment) that declares a set of required roles. A resource with
|
|
83
|
+
* no required roles (`null` / `undefined` / empty) is public — any (authenticated) user may access it; otherwise the
|
|
84
|
+
* user must hold at least one of the required roles. Roles are treated opaquely, so this works equally for numeric
|
|
85
|
+
* application role codes and the string `admin` role — there is no implicit hierarchy (an `admin`-gated resource is
|
|
86
|
+
* reachable only by holders of the `admin` role, never by a high numeric role). Backs {@link TiWebAppManager#verifyAccess}.
|
|
87
|
+
*
|
|
88
|
+
* @param {Array<string|number>} [requiredRoles] The roles permitted to access the resource; empty/absent = public.
|
|
89
|
+
* @param {Array<string|number>} [userRoles] The roles held by the current session user.
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
function isAccessAllowed( requiredRoles, userRoles ) {
|
|
93
|
+
if ( requiredRoles === null || requiredRoles === undefined ) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if ( !Array.isArray( requiredRoles ) ) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if ( requiredRoles.length === 0 ) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
const roles = Array.isArray( userRoles ) ? userRoles : [];
|
|
103
|
+
return requiredRoles.some( ( role ) => roles.includes( role ) );
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Express middleware factory that admits a request only if its session user holds at least one of the given roles.
|
|
108
|
+
* Responds `401` when unauthenticated (no session user) and `403` when authenticated but lacking the role.
|
|
109
|
+
*
|
|
110
|
+
* @param {...(string|number)} roles
|
|
111
|
+
* @returns {(request: Object, response: Object, next: Function) => void}
|
|
112
|
+
*/
|
|
113
|
+
function requireRole( ...roles ) {
|
|
114
|
+
return ( request, response, next ) => {
|
|
115
|
+
const user = request && request.session && request.session.user;
|
|
116
|
+
if ( !user ) {
|
|
117
|
+
response.status( exceptions.httpCode.C_401 ).end();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if ( !hasAnyRole( request.session, roles ) ) {
|
|
121
|
+
response.status( exceptions.httpCode.C_403 ).end();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
next();
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Express middleware that admits only `admin`-role users.
|
|
130
|
+
*
|
|
131
|
+
* @type {(request: Object, response: Object, next: Function) => void}
|
|
132
|
+
*/
|
|
133
|
+
const requireAdmin = requireRole( ADMIN_ROLE );
|
|
134
|
+
|
|
135
|
+
module.exports = { ADMIN_ROLE, isAdminIdentity, applyAdminRole, hasAnyRole, isAccessAllowed, requireRole, requireAdmin };
|
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
-
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
-
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
-
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
-
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const { EventEmitter } = require( "node:events" );
|
|
10
|
-
const logger = require( "@ti-engine/core/logger" );
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @typedef {Object} ConfigChangeEvent
|
|
14
|
-
* @property {string} changeSetID
|
|
15
|
-
* @property {string[]} configKeys The configuration documents affected by the change.
|
|
16
|
-
* @property {string} adminID Who committed the change.
|
|
17
|
-
* @property {string} timestamp ISO timestamp.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
const CONFIG_CHANGED = "config:changed";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Notifies subscribers that configuration changed, so they can react (e.g. invalidate an in-memory cache, or push a
|
|
24
|
-
* live update to an admin UI). This is the **in-process** implementation of a deliberately transport-agnostic
|
|
25
|
-
* contract — `publish(event)` (fire-and-forget) and `subscribe(listener) → unsubscribe`.
|
|
26
|
-
*
|
|
27
|
-
* **Designed for an eventual switch to a reusable core pub/sub.** Cross-instance propagation is out of scope for v1
|
|
28
|
-
* (the store-backed model already makes a committed change visible to every instance via the shared Redis cache;
|
|
29
|
-
* this emitter exists to invalidate optional *in-memory* caches and drive live UI within a process). When a Redis
|
|
30
|
-
* (or other) pub/sub primitive lands in `@ti-engine/core`, a drop-in implementation of this same contract can be
|
|
31
|
-
* provided and injected into {@link ConfigService} — no change to publishers or subscribers. To keep that swap
|
|
32
|
-
* behavior-safe, **delivery here is already asynchronous** (matching cross-instance transports); subscribers must
|
|
33
|
-
* not assume synchronous delivery, and the event payload is plain serializable JSON so it survives a wire transport.
|
|
34
|
-
*
|
|
35
|
-
* @class ConfigChangeNotifier
|
|
36
|
-
* @public
|
|
37
|
-
*/
|
|
38
|
-
class ConfigChangeNotifier {
|
|
39
|
-
|
|
40
|
-
#emitter = new EventEmitter();
|
|
41
|
-
|
|
42
|
-
constructor() {
|
|
43
|
-
this.#emitter.setMaxListeners( 0 );
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Publishes a configuration-change event to all subscribers. Fire-and-forget; delivery is asynchronous.
|
|
48
|
-
*
|
|
49
|
-
* @method
|
|
50
|
-
* @param {ConfigChangeEvent} event
|
|
51
|
-
* @returns {ConfigChangeEvent} The (frozen) event that will be delivered.
|
|
52
|
-
* @public
|
|
53
|
-
*/
|
|
54
|
-
publish( event ) {
|
|
55
|
-
const payload = Object.freeze( { ...event } );
|
|
56
|
-
setImmediate( () => {
|
|
57
|
-
for ( const listener of this.#emitter.listeners( CONFIG_CHANGED ) ) {
|
|
58
|
-
try {
|
|
59
|
-
listener( payload );
|
|
60
|
-
} catch ( error ) {
|
|
61
|
-
// A misbehaving subscriber must not break delivery to the others or crash the process.
|
|
62
|
-
logger.log( `Config-change subscriber threw: ${ error && error.message ? error.message : error }`, logger.logSeverity.WARNING );
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
} );
|
|
66
|
-
return payload;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Subscribes a listener to configuration-change events.
|
|
71
|
-
*
|
|
72
|
-
* @method
|
|
73
|
-
* @param {
|
|
74
|
-
* @returns {
|
|
75
|
-
* @public
|
|
76
|
-
*/
|
|
77
|
-
subscribe( listener ) {
|
|
78
|
-
this.#emitter.on( CONFIG_CHANGED, listener );
|
|
79
|
-
return () => {
|
|
80
|
-
this.#emitter.off( CONFIG_CHANGED, listener );
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* @method
|
|
86
|
-
* @returns {number} The current number of subscribers.
|
|
87
|
-
* @public
|
|
88
|
-
*/
|
|
89
|
-
subscriberCount() {
|
|
90
|
-
return this.#emitter.listenerCount( CONFIG_CHANGED );
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const instance = new ConfigChangeNotifier();
|
|
96
|
-
module.exports = ConfigChangeNotifier;
|
|
97
|
-
|
|
98
|
-
|
|
1
|
+
/*
|
|
2
|
+
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
+
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
+
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { EventEmitter } = require( "node:events" );
|
|
10
|
+
const logger = require( "@ti-engine/core/logger" );
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} ConfigChangeEvent
|
|
14
|
+
* @property {string} changeSetID
|
|
15
|
+
* @property {string[]} configKeys The configuration documents affected by the change.
|
|
16
|
+
* @property {string} adminID Who committed the change.
|
|
17
|
+
* @property {string} timestamp ISO timestamp.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const CONFIG_CHANGED = "config:changed";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Notifies subscribers that configuration changed, so they can react (e.g. invalidate an in-memory cache, or push a
|
|
24
|
+
* live update to an admin UI). This is the **in-process** implementation of a deliberately transport-agnostic
|
|
25
|
+
* contract — `publish(event)` (fire-and-forget) and `subscribe(listener) → unsubscribe`.
|
|
26
|
+
*
|
|
27
|
+
* **Designed for an eventual switch to a reusable core pub/sub.** Cross-instance propagation is out of scope for v1
|
|
28
|
+
* (the store-backed model already makes a committed change visible to every instance via the shared Redis cache;
|
|
29
|
+
* this emitter exists to invalidate optional *in-memory* caches and drive live UI within a process). When a Redis
|
|
30
|
+
* (or other) pub/sub primitive lands in `@ti-engine/core`, a drop-in implementation of this same contract can be
|
|
31
|
+
* provided and injected into {@link ConfigService} — no change to publishers or subscribers. To keep that swap
|
|
32
|
+
* behavior-safe, **delivery here is already asynchronous** (matching cross-instance transports); subscribers must
|
|
33
|
+
* not assume synchronous delivery, and the event payload is plain serializable JSON so it survives a wire transport.
|
|
34
|
+
*
|
|
35
|
+
* @class ConfigChangeNotifier
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
class ConfigChangeNotifier {
|
|
39
|
+
|
|
40
|
+
#emitter = new EventEmitter();
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
this.#emitter.setMaxListeners( 0 );
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Publishes a configuration-change event to all subscribers. Fire-and-forget; delivery is asynchronous.
|
|
48
|
+
*
|
|
49
|
+
* @method
|
|
50
|
+
* @param {ConfigChangeEvent} event
|
|
51
|
+
* @returns {ConfigChangeEvent} The (frozen) event that will be delivered.
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
publish( event ) {
|
|
55
|
+
const payload = Object.freeze( { ...event } );
|
|
56
|
+
setImmediate( () => {
|
|
57
|
+
for ( const listener of this.#emitter.listeners( CONFIG_CHANGED ) ) {
|
|
58
|
+
try {
|
|
59
|
+
listener( payload );
|
|
60
|
+
} catch ( error ) {
|
|
61
|
+
// A misbehaving subscriber must not break delivery to the others or crash the process.
|
|
62
|
+
logger.log( `Config-change subscriber threw: ${ error && error.message ? error.message : error }`, logger.logSeverity.WARNING );
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} );
|
|
66
|
+
return payload;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Subscribes a listener to configuration-change events.
|
|
71
|
+
*
|
|
72
|
+
* @method
|
|
73
|
+
* @param {(event: ConfigChangeEvent) => void} listener
|
|
74
|
+
* @returns {() => void} An unsubscribe function.
|
|
75
|
+
* @public
|
|
76
|
+
*/
|
|
77
|
+
subscribe( listener ) {
|
|
78
|
+
this.#emitter.on( CONFIG_CHANGED, listener );
|
|
79
|
+
return () => {
|
|
80
|
+
this.#emitter.off( CONFIG_CHANGED, listener );
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @method
|
|
86
|
+
* @returns {number} The current number of subscribers.
|
|
87
|
+
* @public
|
|
88
|
+
*/
|
|
89
|
+
subscriberCount() {
|
|
90
|
+
return this.#emitter.listenerCount( CONFIG_CHANGED );
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const instance = new ConfigChangeNotifier();
|
|
96
|
+
module.exports = ConfigChangeNotifier;
|
|
97
|
+
ConfigChangeNotifier.instance = instance;
|
|
98
|
+
ConfigChangeNotifier.CONFIG_CHANGED = CONFIG_CHANGED;
|