@igxjs/node-components 1.0.11 → 1.0.13
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/README.md +19 -19
- package/components/assets/template.html +111 -0
- package/components/http-handlers.js +46 -33
- package/components/jwt.js +14 -9
- package/components/logger.js +131 -0
- package/components/redis.js +38 -11
- package/components/router.js +13 -1
- package/components/session.js +217 -135
- package/index.d.ts +385 -44
- package/index.js +1 -0
- package/package.json +29 -5
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ npm install @igxjs/node-components
|
|
|
13
13
|
| Component | Description | Documentation |
|
|
14
14
|
|-----------|-------------|---------------|
|
|
15
15
|
| **SessionManager** | SSO session management with Redis/memory storage, supporting both session and token-based authentication | [View docs](./docs/session-manager.md) |
|
|
16
|
+
| **Logger** | High-performance logging utility with zero dependencies and smart color detection | [View docs](./docs/logger.md) |
|
|
16
17
|
| **FlexRouter** | Flexible routing with context paths and middleware | [View docs](./docs/flex-router.md) |
|
|
17
18
|
| **RedisManager** | Redis connection management with TLS support | [View docs](./docs/redis-manager.md) |
|
|
18
19
|
| **JWT Manager** | Secure JWT encryption/decryption with JWE | [View docs](./docs/jwt-manager.md) |
|
|
@@ -76,10 +77,11 @@ flexRouter.mount(app, '');
|
|
|
76
77
|
```javascript
|
|
77
78
|
import { JwtManager } from '@igxjs/node-components';
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
// Constructor uses UPPERCASE naming with JWT_ prefix
|
|
81
|
+
const jwt = new JwtManager({ SESSION_AGE: 64800000 });
|
|
80
82
|
const SECRET = process.env.JWT_SECRET;
|
|
81
83
|
|
|
82
|
-
// Create token
|
|
84
|
+
// Create token (encrypt method uses camelCase for per-call options)
|
|
83
85
|
const token = await jwt.encrypt({ userId: '123', email: 'user@example.com' }, SECRET);
|
|
84
86
|
|
|
85
87
|
// Verify token
|
|
@@ -143,7 +145,6 @@ Uses JWT bearer tokens instead of session cookies. When a user authenticates via
|
|
|
143
145
|
- `SSO_FAILURE_URL`: Redirect URL after failed SSO login
|
|
144
146
|
- `JWT_ALGORITHM`: JWT algorithm (default: `'dir'`)
|
|
145
147
|
- `JWT_ENCRYPTION`: Encryption algorithm (default: `'A256GCM'`)
|
|
146
|
-
- `JWT_EXPIRATION_TIME`: Token expiration time (default: `'10m'`)
|
|
147
148
|
- `JWT_CLOCK_TOLERANCE`: Clock skew tolerance in seconds (default: 30)
|
|
148
149
|
|
|
149
150
|
**Auth Methods:**
|
|
@@ -154,23 +155,23 @@ Uses JWT bearer tokens instead of session cookies. When a user authenticates via
|
|
|
154
155
|
|
|
155
156
|
**Token Storage (Client-Side):**
|
|
156
157
|
|
|
157
|
-
When using token-based authentication, the
|
|
158
|
-
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
window.location.replace(redirectUrl);
|
|
171
|
-
</script>
|
|
158
|
+
When using token-based authentication, the SSO callback returns an HTML page that stores the token in `localStorage` and redirects the user:
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
// The token is automatically stored in localStorage by the callback HTML page
|
|
162
|
+
// Default keys (customizable via SESSION_KEY and SESSION_EXPIRY_KEY config):
|
|
163
|
+
localStorage.getItem('session_token'); // JWT token
|
|
164
|
+
localStorage.getItem('session_expires_at'); // Expiry timestamp
|
|
165
|
+
|
|
166
|
+
// Making authenticated requests from the client:
|
|
167
|
+
const token = localStorage.getItem('session_token');
|
|
168
|
+
fetch('/api/protected', {
|
|
169
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
170
|
+
});
|
|
172
171
|
```
|
|
173
172
|
|
|
173
|
+
**Note:** The actual localStorage keys used are determined by the `SESSION_KEY` and `SESSION_EXPIRY_KEY` configuration options (defaults shown above).
|
|
174
|
+
|
|
174
175
|
## SessionManager Configuration Options
|
|
175
176
|
|
|
176
177
|
| Option | Type | Default | Description |
|
|
@@ -189,7 +190,6 @@ When using token-based authentication, the client-side HTML page stores the toke
|
|
|
189
190
|
| `REDIS_CERT_PATH` | string | - | Path to Redis TLS certificate |
|
|
190
191
|
| `JWT_ALGORITHM` | string | `'dir'` | JWT signing algorithm |
|
|
191
192
|
| `JWT_ENCRYPTION` | string | `'A256GCM'` | JWE encryption algorithm |
|
|
192
|
-
| `JWT_EXPIRATION_TIME` | string | `'10m'` | Token expiration duration |
|
|
193
193
|
| `JWT_CLOCK_TOLERANCE` | number | 30 | Clock skew tolerance in seconds |
|
|
194
194
|
| `JWT_SECRET_HASH_ALGORITHM` | string | `'SHA-256'` | Algorithm for hashing secrets |
|
|
195
195
|
| `JWT_ISSUER` | string | - | JWT issuer identifier |
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Sign in IBM Garage</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no,viewport-fit=cover,minimum-scale=1,maximum-scale=1,user-scalable=no">
|
|
6
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--foreground-color-primary: #444;
|
|
10
|
+
--font-size: 0.8rem;
|
|
11
|
+
--background-color-primary: #f1f1f199;
|
|
12
|
+
--gap-global: 0.4rem;
|
|
13
|
+
}
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
:root {
|
|
16
|
+
--background-color-primary: black;
|
|
17
|
+
--foreground-color-primary: #bbb;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
html, body {
|
|
21
|
+
padding: 0px;
|
|
22
|
+
margin: 0px auto;
|
|
23
|
+
height: 100%;
|
|
24
|
+
width: 100%;
|
|
25
|
+
}
|
|
26
|
+
body {
|
|
27
|
+
background-image: radial-gradient(circle at 20% 80%, rgba(255, 140, 140, 0.5), transparent 42%),
|
|
28
|
+
radial-gradient(circle at 80% 20%, rgba(140, 200, 255, 0.55), transparent 42%),
|
|
29
|
+
radial-gradient(circle at 40% 40%, rgba(255, 230, 140, 0.45), transparent 32%),
|
|
30
|
+
linear-gradient(135deg, rgba(160, 120, 240, 0.95), rgba(120, 160, 240, 0.95));
|
|
31
|
+
background-size: cover;
|
|
32
|
+
background-repeat: no-repeat;
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
font-family: Verdana, Helvetica, Arial, sans-serif;
|
|
37
|
+
font-size: var(--font-size);
|
|
38
|
+
color: var(--foreground-color-primary);
|
|
39
|
+
flex-flow: column;
|
|
40
|
+
position: relative;
|
|
41
|
+
z-index: 10;
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
}
|
|
44
|
+
body::before {
|
|
45
|
+
background: var(--background-color-primary);
|
|
46
|
+
content: "";
|
|
47
|
+
position: absolute;
|
|
48
|
+
top: 0;
|
|
49
|
+
left: 0;
|
|
50
|
+
right: 0;
|
|
51
|
+
bottom: 0;
|
|
52
|
+
z-index: -1;
|
|
53
|
+
backdrop-filter: blur(10px);
|
|
54
|
+
-webkit-backdrop-filter: blur(10px);
|
|
55
|
+
}
|
|
56
|
+
.wrapper {
|
|
57
|
+
width: 100%;
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: space-between;
|
|
61
|
+
box-sizing: border-box;
|
|
62
|
+
row-gap: var(--gap-global);
|
|
63
|
+
flex-direction: column;
|
|
64
|
+
padding: var(--gap-global);
|
|
65
|
+
}
|
|
66
|
+
.wrapper #failure {
|
|
67
|
+
display: none;
|
|
68
|
+
}
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<hr />
|
|
73
|
+
<div class="wrapper">
|
|
74
|
+
<div id="success">Redirecting... <a href="{{SSO_SUCCESS_URL}}" target="_blank" style="color: blue; text-decoration: underline;">(click here if not redirected)</a></div>
|
|
75
|
+
<div id="failure">
|
|
76
|
+
<p>Authentication failed. Please try again.</p>
|
|
77
|
+
<a href="{{SSO_FAILURE_URL}}">Return to login</a>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<script>
|
|
81
|
+
(function() {
|
|
82
|
+
let success = true;
|
|
83
|
+
|
|
84
|
+
// Check localStorage support first
|
|
85
|
+
if (typeof localStorage !== 'object' || typeof localStorage.setItem !== 'function') {
|
|
86
|
+
console.warn('localStorage not supported, falling back to session cookie');
|
|
87
|
+
success = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Try localStorage first
|
|
92
|
+
if (localStorage.setItem) {
|
|
93
|
+
localStorage.setItem('{{SESSION_DATA_KEY}}', '{{SESSION_DATA_VALUE}}');
|
|
94
|
+
localStorage.setItem('{{SESSION_EXPIRY_KEY}}', '{{SESSION_EXPIRY_VALUE}}');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fall back to simple navigation
|
|
98
|
+
location.href = '{{SSO_SUCCESS_URL}}';
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error('Redirect failed:', e);
|
|
101
|
+
success = false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!success) {
|
|
105
|
+
document.getElementById('success').style.display = 'none';
|
|
106
|
+
document.getElementById('failure').style.display = 'block';
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
</script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import { STATUS_CODES } from 'node:http';
|
|
2
|
+
import { Logger } from './logger.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
OK: 'OK',
|
|
5
|
-
CREATED: 'Created',
|
|
6
|
-
NO_CONTENT: 'No Content',
|
|
7
|
-
BAD_REQUEST: 'Bad Request',
|
|
8
|
-
UNAUTHORIZED: 'Unauthorized',
|
|
9
|
-
FORBIDDEN: 'Forbidden',
|
|
10
|
-
NOT_FOUND: 'Not Found',
|
|
11
|
-
NOT_ACCEPTABLE: 'Not Acceptable',
|
|
12
|
-
CONFLICT: 'Conflict',
|
|
13
|
-
LOCKED: 'Locked',
|
|
14
|
-
SYSTEM_FAILURE: 'System Error',
|
|
15
|
-
NOT_IMPLEMENTED: 'Not Implemented',
|
|
16
|
-
};
|
|
4
|
+
const logger = Logger.getInstance('httpError');
|
|
17
5
|
|
|
6
|
+
/**
|
|
7
|
+
* HTTP status codes
|
|
8
|
+
*/
|
|
18
9
|
export const httpCodes = {
|
|
19
10
|
OK: 200,
|
|
20
11
|
CREATED: 201,
|
|
@@ -30,23 +21,45 @@ export const httpCodes = {
|
|
|
30
21
|
NOT_IMPLEMENTED: 501,
|
|
31
22
|
};
|
|
32
23
|
|
|
24
|
+
/**
|
|
25
|
+
* HTTP status messages
|
|
26
|
+
*/
|
|
27
|
+
export const httpMessages = {
|
|
28
|
+
OK: 'OK',
|
|
29
|
+
CREATED: 'Created',
|
|
30
|
+
NO_CONTENT: 'No Content',
|
|
31
|
+
BAD_REQUEST: 'Bad Request',
|
|
32
|
+
UNAUTHORIZED: 'Unauthorized',
|
|
33
|
+
FORBIDDEN: 'Forbidden',
|
|
34
|
+
NOT_FOUND: 'Not Found',
|
|
35
|
+
NOT_ACCEPTABLE: 'Not Acceptable',
|
|
36
|
+
CONFLICT: 'Conflict',
|
|
37
|
+
LOCKED: 'Locked',
|
|
38
|
+
SYSTEM_FAILURE: 'Internal Server Error',
|
|
39
|
+
NOT_IMPLEMENTED: 'Not Implemented',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Custom Error class
|
|
44
|
+
*/
|
|
33
45
|
export class CustomError extends Error {
|
|
34
|
-
/** @type {number} */
|
|
35
|
-
code;
|
|
36
|
-
/** @type {object} */
|
|
37
|
-
data;
|
|
38
|
-
/** @type {object} */
|
|
39
|
-
error;
|
|
40
46
|
/**
|
|
41
|
-
*
|
|
42
|
-
* @param {
|
|
43
|
-
* @param {
|
|
47
|
+
* @param {number} code HTTP status code
|
|
48
|
+
* @param {string} message Error message
|
|
49
|
+
* @param {Error} [error] Original error object
|
|
50
|
+
* @param {object} [data] Additional error data
|
|
44
51
|
*/
|
|
45
|
-
constructor(code, message, error
|
|
52
|
+
constructor(code, message, error, data) {
|
|
46
53
|
super(message);
|
|
54
|
+
this.name = 'CustomError';
|
|
47
55
|
this.code = code;
|
|
48
56
|
this.error = error;
|
|
49
57
|
this.data = data;
|
|
58
|
+
|
|
59
|
+
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
|
60
|
+
if (Error.captureStackTrace) {
|
|
61
|
+
Error.captureStackTrace(this, CustomError);
|
|
62
|
+
}
|
|
50
63
|
}
|
|
51
64
|
}
|
|
52
65
|
|
|
@@ -85,21 +98,21 @@ export const httpErrorHandler = (err, req, res, next) => {
|
|
|
85
98
|
res.status(responseBody.status).json(responseBody);
|
|
86
99
|
|
|
87
100
|
// Log error details
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
logger.error('### ERROR ###');
|
|
102
|
+
logger.error(`${req.method} ${req.path}`);
|
|
90
103
|
|
|
91
104
|
// Log based on error type
|
|
92
105
|
if ([httpCodes.UNAUTHORIZED, httpCodes.FORBIDDEN, httpCodes.NOT_FOUND].includes(err.code)) {
|
|
93
|
-
|
|
106
|
+
logger.error('>>> Auth Error:', err.message);
|
|
94
107
|
} else {
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
logger.error('>>> Name:', err.name);
|
|
109
|
+
logger.error('>>> Message:', err.message);
|
|
97
110
|
if (err.stack) {
|
|
98
|
-
|
|
111
|
+
logger.error('>>> Stack:', err.stack);
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
|
|
115
|
+
logger.error('### /ERROR ###');
|
|
103
116
|
};
|
|
104
117
|
|
|
105
118
|
/**
|
|
@@ -177,7 +190,7 @@ export const httpHelper = {
|
|
|
177
190
|
* @returns {CustomError} Returns CustomError instance
|
|
178
191
|
*/
|
|
179
192
|
handleAxiosError(error, defaultMessage = 'An error occurred') {
|
|
180
|
-
|
|
193
|
+
logger.warn(`### TRY ERROR: ${defaultMessage} ###`);
|
|
181
194
|
// Extract error details
|
|
182
195
|
const errorCode = _getErrorCode(error);
|
|
183
196
|
const errorMessage = _getErrorMessage(error, defaultMessage);
|
|
@@ -196,4 +209,4 @@ export const httpHelper = {
|
|
|
196
209
|
*/
|
|
197
210
|
export const httpError = (code, message, error, data) => {
|
|
198
211
|
return new CustomError(code, message, error, data);
|
|
199
|
-
};
|
|
212
|
+
};
|
package/components/jwt.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
1
|
+
import { webcrypto as crypto } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { jwtDecrypt, EncryptJWT } from 'jose';
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ export class JwtManager {
|
|
|
13
13
|
/** @type {string} Encryption method */
|
|
14
14
|
encryption;
|
|
15
15
|
|
|
16
|
-
/** @type {
|
|
16
|
+
/** @type {number} Token expiration time */
|
|
17
17
|
expirationTime;
|
|
18
18
|
|
|
19
19
|
/** @type {number} Clock tolerance in seconds */
|
|
@@ -37,7 +37,7 @@ export class JwtManager {
|
|
|
37
37
|
* @typedef {Object} JwtManagerOptions JwtManager configuration options
|
|
38
38
|
* @property {string} [JWT_ALGORITHM='dir'] JWE algorithm (default: 'dir')
|
|
39
39
|
* @property {string} [JWT_ENCRYPTION='A256GCM'] Encryption method (default: 'A256GCM')
|
|
40
|
-
* @property {string} [JWT_EXPIRATION_TIME=
|
|
40
|
+
* @property {number|string} [JWT_EXPIRATION_TIME=64800] Token expiration time - number in seconds (e.g., 64800) or string with time suffix (e.g., '18h', '7d', '1080m') (default: 64800 = 18 hours)
|
|
41
41
|
* @property {string} [JWT_SECRET_HASH_ALGORITHM='SHA-256'] Hash algorithm (default: 'SHA-256')
|
|
42
42
|
* @property {string?} [JWT_ISSUER] Optional JWT issuer claim
|
|
43
43
|
* @property {string?} [JWT_AUDIENCE] Optional JWT audience claim
|
|
@@ -48,7 +48,8 @@ export class JwtManager {
|
|
|
48
48
|
constructor(options = {}) {
|
|
49
49
|
this.algorithm = options.JWT_ALGORITHM || 'dir';
|
|
50
50
|
this.encryption = options.JWT_ENCRYPTION || 'A256GCM';
|
|
51
|
-
|
|
51
|
+
// JWT_EXPIRATION_TIME is in seconds
|
|
52
|
+
this.expirationTime = options.JWT_EXPIRATION_TIME || 64800;
|
|
52
53
|
this.secretHashAlgorithm = options.JWT_SECRET_HASH_ALGORITHM || 'SHA-256';
|
|
53
54
|
this.issuer = options.JWT_ISSUER;
|
|
54
55
|
this.audience = options.JWT_AUDIENCE;
|
|
@@ -62,7 +63,7 @@ export class JwtManager {
|
|
|
62
63
|
* @typedef {Object} JwtEncryptOptions Encryption method options
|
|
63
64
|
* @property {string} [algorithm='dir'] JWE algorithm (overrides instance JWT_ALGORITHM)
|
|
64
65
|
* @property {string} [encryption='A256GCM'] Encryption method (overrides instance JWT_ENCRYPTION)
|
|
65
|
-
* @property {string} [expirationTime
|
|
66
|
+
* @property {number|string} [expirationTime] Token expiration time - number in seconds or string with time suffix like '1h', '30m', '7d' (overrides instance JWT_EXPIRATION_TIME)
|
|
66
67
|
* @property {string} [secretHashAlgorithm='SHA-256'] Hash algorithm for secret derivation (overrides instance JWT_SECRET_HASH_ALGORITHM)
|
|
67
68
|
* @property {string?} [issuer] Optional JWT issuer claim (overrides instance JWT_ISSUER)
|
|
68
69
|
* @property {string?} [audience] Optional JWT audience claim (overrides instance JWT_AUDIENCE)
|
|
@@ -90,13 +91,17 @@ export class JwtManager {
|
|
|
90
91
|
new TextEncoder().encode(secret)
|
|
91
92
|
);
|
|
92
93
|
|
|
94
|
+
// Convert number to string with 's' suffix, pass strings directly
|
|
95
|
+
const expTime = typeof expirationTime === 'number' ? `${expirationTime}s` : expirationTime;
|
|
96
|
+
|
|
93
97
|
const jwt = new EncryptJWT(data)
|
|
94
98
|
.setProtectedHeader({
|
|
95
99
|
alg: algorithm,
|
|
96
|
-
enc: encryption
|
|
100
|
+
enc: encryption,
|
|
101
|
+
typ: 'JWT',
|
|
97
102
|
})
|
|
98
103
|
.setIssuedAt()
|
|
99
|
-
.setExpirationTime(
|
|
104
|
+
.setExpirationTime(expTime); // Accepts: number (as seconds) or string (e.g., '18h', '7d')
|
|
100
105
|
|
|
101
106
|
// Add optional claims if provided
|
|
102
107
|
if (issuer) jwt.setIssuer(issuer);
|
|
@@ -122,7 +127,7 @@ export class JwtManager {
|
|
|
122
127
|
* @param {string} token JWT token to decrypt
|
|
123
128
|
* @param {string} secret Secret key or password for decryption
|
|
124
129
|
* @param {JwtDecryptOptions} [options] Per-call configuration overrides (camelCase naming convention)
|
|
125
|
-
* @returns {Promise<import('jose').JWTDecryptResult
|
|
130
|
+
* @returns {Promise<import('jose').JWTDecryptResult>} Returns decrypted JWT token
|
|
126
131
|
*/
|
|
127
132
|
async decrypt(token, secret, options = {}) {
|
|
128
133
|
const clockTolerance = options.clockTolerance ?? this.clockTolerance;
|
|
@@ -145,4 +150,4 @@ export class JwtManager {
|
|
|
145
150
|
|
|
146
151
|
return await jwtDecrypt(token, new Uint8Array(secretHash), decryptOptions);
|
|
147
152
|
}
|
|
148
|
-
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger utility for node-components
|
|
3
|
+
* Provides configurable logging with enable/disable functionality
|
|
4
|
+
* Uses singleton pattern to manage logger instances per component
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import { Logger } from './logger.js';
|
|
8
|
+
*
|
|
9
|
+
* // Recommended: Get logger instance (singleton pattern)
|
|
10
|
+
* const logger = Logger.getInstance('ComponentName');
|
|
11
|
+
*
|
|
12
|
+
* // With explicit enable/disable
|
|
13
|
+
* const logger = Logger.getInstance('ComponentName', true); // Always enabled
|
|
14
|
+
* const logger = Logger.getInstance('ComponentName', false); // Always disabled
|
|
15
|
+
*
|
|
16
|
+
* // Backward compatibility: Constructor still works
|
|
17
|
+
* const logger = new Logger('ComponentName');
|
|
18
|
+
*
|
|
19
|
+
* // Use logger
|
|
20
|
+
* logger.info('Operation completed');
|
|
21
|
+
* logger.error('Error occurred', error);
|
|
22
|
+
*/
|
|
23
|
+
export class Logger {
|
|
24
|
+
/** @type {Map<string, Logger>} */
|
|
25
|
+
static #instances = new Map();
|
|
26
|
+
|
|
27
|
+
/** @type {boolean} - Global flag to enable/disable colors */
|
|
28
|
+
static #colorsEnabled = true;
|
|
29
|
+
|
|
30
|
+
/** ANSI color codes for different log levels */
|
|
31
|
+
static #colors = {
|
|
32
|
+
reset: '\x1b[0m',
|
|
33
|
+
dim: '\x1b[2m', // debug - dim/gray
|
|
34
|
+
cyan: '\x1b[36m', // info - cyan
|
|
35
|
+
yellow: '\x1b[33m', // warn - yellow
|
|
36
|
+
red: '\x1b[31m', // error - red
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** @type {boolean} */
|
|
40
|
+
#enabled;
|
|
41
|
+
/** @type {string} */
|
|
42
|
+
#prefix;
|
|
43
|
+
/** @type {boolean} */
|
|
44
|
+
#useColors;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get or create a Logger instance (singleton pattern)
|
|
48
|
+
* @param {string} componentName Component name for log prefix
|
|
49
|
+
* @param {boolean} [enableLogging] Enable/disable logging. Defaults to NODE_ENV !== 'production'
|
|
50
|
+
* @returns {Logger} Logger instance
|
|
51
|
+
*/
|
|
52
|
+
static getInstance(componentName, enableLogging) {
|
|
53
|
+
const key = `${componentName}:${enableLogging ?? 'default'}`;
|
|
54
|
+
|
|
55
|
+
if (!Logger.#instances.has(key)) {
|
|
56
|
+
Logger.#instances.set(key, new Logger(componentName, enableLogging));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Logger.#instances.get(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Clear all logger instances (useful for testing)
|
|
64
|
+
*/
|
|
65
|
+
static clearInstances() {
|
|
66
|
+
Logger.#instances.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Disable colors globally for all logger instances
|
|
71
|
+
*/
|
|
72
|
+
static disableColors() {
|
|
73
|
+
Logger.#colorsEnabled = false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Enable colors globally for all logger instances
|
|
78
|
+
*/
|
|
79
|
+
static enableColors() {
|
|
80
|
+
Logger.#colorsEnabled = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a new Logger instance
|
|
85
|
+
* @param {string} componentName Component name for log prefix
|
|
86
|
+
* @param {boolean} [enableLogging] Enable/disable logging. Defaults to NODE_ENV !== 'production'
|
|
87
|
+
*/
|
|
88
|
+
constructor(componentName, enableLogging) {
|
|
89
|
+
// If enableLogging is explicitly set (true/false), use it
|
|
90
|
+
// Otherwise, default to enabled in development, disabled in production
|
|
91
|
+
this.#enabled = enableLogging ?? (process.env.NODE_ENV !== 'production');
|
|
92
|
+
this.#prefix = `[${componentName}]`;
|
|
93
|
+
|
|
94
|
+
// Determine if colors should be used:
|
|
95
|
+
// - Colors are globally enabled
|
|
96
|
+
// - Output is a terminal (TTY)
|
|
97
|
+
// - NO_COLOR environment variable is not set
|
|
98
|
+
this.#useColors = Logger.#colorsEnabled &&
|
|
99
|
+
process.stdout.isTTY &&
|
|
100
|
+
!process.env.NO_COLOR;
|
|
101
|
+
|
|
102
|
+
// Assign methods based on enabled flag to eliminate runtime checks
|
|
103
|
+
// This improves performance by avoiding conditional checks on every log call
|
|
104
|
+
if (this.#enabled) {
|
|
105
|
+
const colors = Logger.#colors;
|
|
106
|
+
|
|
107
|
+
if (this.#useColors) {
|
|
108
|
+
// Logging enabled with colors: colorize the prefix
|
|
109
|
+
this.debug = (...args) => console.debug(colors.dim + this.#prefix + colors.reset, ...args);
|
|
110
|
+
this.info = (...args) => console.info(colors.cyan + this.#prefix + colors.reset, ...args);
|
|
111
|
+
this.warn = (...args) => console.warn(colors.yellow + this.#prefix + colors.reset, ...args);
|
|
112
|
+
this.error = (...args) => console.error(colors.red + this.#prefix + colors.reset, ...args);
|
|
113
|
+
this.log = (...args) => console.log(this.#prefix, ...args);
|
|
114
|
+
} else {
|
|
115
|
+
// Logging enabled without colors: plain text
|
|
116
|
+
this.debug = (...args) => console.debug(this.#prefix, ...args);
|
|
117
|
+
this.info = (...args) => console.info(this.#prefix, ...args);
|
|
118
|
+
this.warn = (...args) => console.warn(this.#prefix, ...args);
|
|
119
|
+
this.error = (...args) => console.error(this.#prefix, ...args);
|
|
120
|
+
this.log = (...args) => console.log(this.#prefix, ...args);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
// Logging disabled: assign no-op functions that do nothing
|
|
124
|
+
this.debug = () => {};
|
|
125
|
+
this.info = () => {};
|
|
126
|
+
this.warn = () => {};
|
|
127
|
+
this.error = () => {};
|
|
128
|
+
this.log = () => {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/components/redis.js
CHANGED
|
@@ -1,42 +1,67 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
|
|
3
3
|
import { createClient } from '@redis/client';
|
|
4
|
+
import { Logger } from './logger.js';
|
|
4
5
|
|
|
5
6
|
export class RedisManager {
|
|
7
|
+
/** @type {Logger} */
|
|
8
|
+
#logger = Logger.getInstance('RedisManager');
|
|
9
|
+
|
|
6
10
|
/** @type {import('@redis/client').RedisClientType} */
|
|
7
11
|
#client = null;
|
|
8
12
|
/**
|
|
9
13
|
* Connect with Redis
|
|
14
|
+
* @param {string} redisUrl Redis connection URL
|
|
15
|
+
* @param {string} certPath Path to TLS certificate (required when using rediss:// protocol)
|
|
10
16
|
* @returns {Promise<boolean>} Returns `true` if Redis server is connected
|
|
17
|
+
* @throws {TypeError} If redisUrl is not a string
|
|
18
|
+
* @throws {Error} If TLS is enabled but certPath is invalid
|
|
11
19
|
*/
|
|
12
20
|
async connect(redisUrl, certPath) {
|
|
13
21
|
if (redisUrl?.length > 0) {
|
|
14
22
|
try {
|
|
23
|
+
// Validate redisUrl is a string
|
|
24
|
+
if (typeof redisUrl !== 'string') {
|
|
25
|
+
throw new TypeError(`Invalid redisUrl: expected string but received ${typeof redisUrl}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
/** @type {import('@redis/client').RedisClientOptions} */
|
|
16
29
|
const options = {
|
|
17
30
|
url: redisUrl,
|
|
18
31
|
socket: { tls: redisUrl.includes('rediss:'), ca: null },
|
|
19
32
|
};
|
|
20
|
-
|
|
33
|
+
|
|
34
|
+
if (options.socket.tls) {
|
|
35
|
+
// Validate certPath when TLS is enabled
|
|
36
|
+
if (!certPath || typeof certPath !== 'string') {
|
|
37
|
+
throw new Error('TLS certificate path is required when using rediss:// protocol');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!fs.existsSync(certPath)) {
|
|
41
|
+
throw new Error(`TLS certificate file not found at path: ${certPath}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
const caCert = fs.readFileSync(certPath);
|
|
22
45
|
options.socket.ca = caCert;
|
|
23
46
|
}
|
|
24
47
|
this.#client = createClient(options);
|
|
48
|
+
this.#logger.info('### REDIS CONNECTING ###');
|
|
25
49
|
this.#client.on('ready', () => {
|
|
26
|
-
|
|
50
|
+
this.#logger.info('### REDIS READY ###');
|
|
27
51
|
});
|
|
28
52
|
this.#client.on('reconnecting', (_res) => {
|
|
29
|
-
|
|
53
|
+
this.#logger.warn('### REDIS RECONNECTING ###');
|
|
30
54
|
});
|
|
31
55
|
this.#client.on('error', (error) => {
|
|
32
|
-
|
|
56
|
+
this.#logger.error(`### REDIS ERROR: ${error.message} ###`);
|
|
33
57
|
});
|
|
34
58
|
await this.#client.connect();
|
|
59
|
+
this.#logger.info('### REDIS CONNECTED SUCCESSFULLY ###');
|
|
35
60
|
return true;
|
|
36
61
|
}
|
|
37
62
|
catch (error) {
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
this.#logger.error('### REDIS CONNECT ERROR ###', error);
|
|
64
|
+
return false;
|
|
40
65
|
}
|
|
41
66
|
}
|
|
42
67
|
return false;
|
|
@@ -60,8 +85,8 @@ export class RedisManager {
|
|
|
60
85
|
const pongMessage = await this.#client.ping();
|
|
61
86
|
return 'PONG' === pongMessage;
|
|
62
87
|
} catch (error) {
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
this.#logger.error(`### REDIS PING ERROR: ${error.message} ###`);
|
|
89
|
+
return false;
|
|
65
90
|
}
|
|
66
91
|
return false;
|
|
67
92
|
}
|
|
@@ -70,7 +95,9 @@ export class RedisManager {
|
|
|
70
95
|
* Disconnect with Redis
|
|
71
96
|
* @returns {Promise<void>} Returns nothing
|
|
72
97
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
98
|
+
async disconnect() {
|
|
99
|
+
this.#logger.info('### REDIS DISCONNECTING ###');
|
|
100
|
+
await this.#client.quit();
|
|
101
|
+
this.#logger.info('### REDIS DISCONNECTED ###');
|
|
75
102
|
}
|
|
76
|
-
}
|
|
103
|
+
}
|
package/components/router.js
CHANGED
|
@@ -43,11 +43,23 @@ export class FlexRouter {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Mount router
|
|
46
|
+
* Mount router to Express application
|
|
47
47
|
* @param {import('@types/express').Express} app Express app
|
|
48
48
|
* @param {string} basePath Base path
|
|
49
|
+
* @throws {TypeError} If app is not a valid Express instance
|
|
50
|
+
* @throws {TypeError} If basePath is not a string
|
|
49
51
|
*/
|
|
50
52
|
mount(app, basePath) {
|
|
53
|
+
// Validate app is an Express instance (has 'use' method)
|
|
54
|
+
if (!app || typeof app.use !== 'function') {
|
|
55
|
+
throw new TypeError('Invalid Express app: app must be an Express application instance with a "use" method');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Validate basePath is a string
|
|
59
|
+
if (typeof basePath !== 'string') {
|
|
60
|
+
throw new TypeError(`Invalid basePath: expected string but received ${typeof basePath}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
const path = basePath.concat(this.context);
|
|
52
64
|
app.use(path, this.handlers, this.router);
|
|
53
65
|
}
|