te.js 2.1.6 → 2.2.1
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 +1 -12
- package/auto-docs/analysis/handler-analyzer.test.js +106 -0
- package/auto-docs/analysis/source-resolver.test.js +58 -0
- package/auto-docs/constants.js +13 -2
- package/auto-docs/openapi/generator.js +7 -5
- package/auto-docs/openapi/generator.test.js +132 -0
- package/auto-docs/openapi/spec-builders.js +39 -19
- package/cli/docs-command.js +44 -36
- package/cors/index.test.js +82 -0
- package/docs/README.md +1 -2
- package/docs/api-reference.md +124 -186
- package/docs/configuration.md +0 -13
- package/docs/getting-started.md +19 -21
- package/docs/rate-limiting.md +59 -58
- package/lib/llm/client.js +7 -2
- package/lib/llm/index.js +14 -1
- package/lib/llm/parse.test.js +60 -0
- package/package.json +3 -1
- package/radar/index.js +382 -0
- package/rate-limit/base.js +12 -15
- package/rate-limit/index.js +19 -22
- package/rate-limit/index.test.js +93 -0
- package/rate-limit/storage/memory.js +13 -13
- package/rate-limit/storage/redis-install.js +70 -0
- package/rate-limit/storage/redis.js +94 -52
- package/server/ammo/body-parser.js +156 -152
- package/server/ammo/body-parser.test.js +79 -0
- package/server/ammo/enhancer.js +8 -4
- package/server/ammo.js +138 -12
- package/server/context/request-context.js +51 -0
- package/server/context/request-context.test.js +53 -0
- package/server/endpoint.js +15 -0
- package/server/error.js +56 -3
- package/server/error.test.js +45 -0
- package/server/errors/channels/channels.test.js +148 -0
- package/server/errors/channels/index.js +1 -1
- package/server/errors/llm-cache.js +1 -1
- package/server/errors/llm-cache.test.js +160 -0
- package/server/errors/llm-error-service.js +1 -1
- package/server/errors/llm-rate-limiter.test.js +105 -0
- package/server/files/uploader.js +38 -26
- package/server/handler.js +1 -1
- package/server/targets/registry.js +3 -3
- package/server/targets/registry.test.js +108 -0
- package/te.js +233 -183
- package/utils/auto-register.js +1 -1
- package/utils/configuration.js +23 -9
- package/utils/configuration.test.js +58 -0
- package/utils/errors-llm-config.js +74 -8
- package/utils/request-logger.js +49 -3
- package/utils/startup.js +80 -0
- package/database/index.js +0 -165
- package/database/mongodb.js +0 -146
- package/database/redis.js +0 -201
- package/docs/database.md +0 -390
package/te.js
CHANGED
|
@@ -3,9 +3,9 @@ import { env, setEnv } from 'tej-env';
|
|
|
3
3
|
import TejLogger from 'tej-logger';
|
|
4
4
|
import rateLimiter from './rate-limit/index.js';
|
|
5
5
|
import corsMiddleware from './cors/index.js';
|
|
6
|
+
import radarMiddleware from './radar/index.js';
|
|
6
7
|
|
|
7
8
|
import targetRegistry from './server/targets/registry.js';
|
|
8
|
-
import dbManager from './database/index.js';
|
|
9
9
|
|
|
10
10
|
import { loadConfigFile, standardizeObj } from './utils/configuration.js';
|
|
11
11
|
|
|
@@ -13,22 +13,66 @@ import targetHandler from './server/handler.js';
|
|
|
13
13
|
import {
|
|
14
14
|
getErrorsLlmConfig,
|
|
15
15
|
validateErrorsLlmAtTakeoff,
|
|
16
|
+
verifyLlmConnection,
|
|
16
17
|
} from './utils/errors-llm-config.js';
|
|
17
18
|
import path from 'node:path';
|
|
18
19
|
import { pathToFileURL } from 'node:url';
|
|
19
20
|
import { readFile } from 'node:fs/promises';
|
|
21
|
+
import { readFrameworkVersion, fmtMs, statusLine } from './utils/startup.js';
|
|
20
22
|
import { findTargetFiles } from './utils/auto-register.js';
|
|
21
23
|
import { registerDocRoutes } from './auto-docs/ui/docs-ui.js';
|
|
24
|
+
import TejError from './server/error.js';
|
|
22
25
|
|
|
23
26
|
const logger = new TejLogger('Tejas');
|
|
24
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Performs a graceful shutdown: closes the HTTP server (if started), then exits.
|
|
30
|
+
* Invoked by process-level fatal error handlers. This is used to ensure that the server is closed properly
|
|
31
|
+
* when the process is terminated.
|
|
32
|
+
* @param {number} [exitCode=1]
|
|
33
|
+
*/
|
|
34
|
+
async function gracefulShutdown(exitCode = 1) {
|
|
35
|
+
const instance = Tejas.instance;
|
|
36
|
+
if (instance?.engine) {
|
|
37
|
+
try {
|
|
38
|
+
await new Promise((resolve) => instance.engine.close(resolve));
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore close errors during shutdown
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
process.exit(exitCode);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process.on('unhandledRejection', (reason) => {
|
|
47
|
+
process.stderr.write(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
level: 'fatal',
|
|
50
|
+
code: 'ERR_UNHANDLED_REJECTION',
|
|
51
|
+
reason: String(reason),
|
|
52
|
+
}) + '\n',
|
|
53
|
+
);
|
|
54
|
+
gracefulShutdown(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
process.on('uncaughtException', (error) => {
|
|
58
|
+
process.stderr.write(
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
level: 'fatal',
|
|
61
|
+
code: 'ERR_UNCAUGHT_EXCEPTION',
|
|
62
|
+
message: error.message,
|
|
63
|
+
stack: error.stack,
|
|
64
|
+
}) + '\n',
|
|
65
|
+
);
|
|
66
|
+
gracefulShutdown(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
25
69
|
/**
|
|
26
70
|
* Main Tejas Framework Class
|
|
27
71
|
*
|
|
28
72
|
* @class
|
|
29
73
|
* @description
|
|
30
74
|
* Tejas is a Node.js framework for building powerful backend services.
|
|
31
|
-
* It provides features like routing, middleware support,
|
|
75
|
+
* It provides features like routing, middleware support,
|
|
32
76
|
* and automatic target (route) registration.
|
|
33
77
|
*/
|
|
34
78
|
class Tejas {
|
|
@@ -41,28 +85,18 @@ class Tejas {
|
|
|
41
85
|
* @param {boolean} [args.log.http_requests] - Whether to log incoming HTTP requests
|
|
42
86
|
* @param {boolean} [args.log.exceptions] - Whether to log exceptions
|
|
43
87
|
* @param {Object} [args.db] - Database configuration options
|
|
44
|
-
* @param {Object} [args.withRedis] - Redis connection configuration
|
|
45
|
-
* @param {boolean} [args.withRedis.isCluster=false] - Whether to use Redis Cluster
|
|
46
|
-
* @param {Object} [args.withRedis.socket] - Redis socket connection options
|
|
47
|
-
* @param {string} [args.withRedis.socket.host] - Redis server hostname
|
|
48
|
-
* @param {number} [args.withRedis.socket.port] - Redis server port
|
|
49
|
-
* @param {boolean} [args.withRedis.socket.tls] - Whether to use TLS for connection
|
|
50
|
-
* @param {string} [args.withRedis.url] - Redis connection URL (alternative to socket config)
|
|
51
88
|
*/
|
|
52
89
|
constructor(args) {
|
|
53
90
|
if (Tejas.instance) return Tejas.instance;
|
|
54
91
|
Tejas.instance = this;
|
|
55
|
-
|
|
56
92
|
this.options = args || {};
|
|
57
|
-
|
|
58
|
-
this.generateConfiguration();
|
|
59
|
-
this.registerTargetsDir();
|
|
60
93
|
}
|
|
61
94
|
|
|
62
95
|
/**
|
|
63
96
|
* Generates and loads configuration from multiple sources
|
|
64
97
|
*
|
|
65
98
|
* @private
|
|
99
|
+
* @returns {Promise<void>}
|
|
66
100
|
* @description
|
|
67
101
|
* Loads and merges configuration from:
|
|
68
102
|
* 1. tejas.config.json file (lowest priority)
|
|
@@ -72,15 +106,15 @@ class Tejas {
|
|
|
72
106
|
* All configuration keys are standardized to uppercase and flattened.
|
|
73
107
|
* Sets default values for required configuration if not provided.
|
|
74
108
|
*/
|
|
75
|
-
generateConfiguration() {
|
|
76
|
-
const configVars = standardizeObj(loadConfigFile());
|
|
109
|
+
async generateConfiguration() {
|
|
110
|
+
const configVars = standardizeObj(await loadConfigFile());
|
|
77
111
|
const envVars = standardizeObj(process.env);
|
|
78
112
|
const userVars = standardizeObj(this.options);
|
|
79
113
|
|
|
80
|
-
const config = { ...configVars, ...envVars, ...userVars };
|
|
114
|
+
const config = Object.freeze({ ...configVars, ...envVars, ...userVars });
|
|
81
115
|
|
|
82
116
|
for (const key in config) {
|
|
83
|
-
if (
|
|
117
|
+
if (Object.hasOwn(config, key)) {
|
|
84
118
|
setEnv(key, config[key]);
|
|
85
119
|
}
|
|
86
120
|
}
|
|
@@ -89,6 +123,16 @@ class Tejas {
|
|
|
89
123
|
if (!env('PORT')) setEnv('PORT', 1403);
|
|
90
124
|
if (!env('BODY_MAX_SIZE')) setEnv('BODY_MAX_SIZE', 10 * 1024 * 1024); // 10MB default
|
|
91
125
|
if (!env('BODY_TIMEOUT')) setEnv('BODY_TIMEOUT', 30000); // 30 seconds default
|
|
126
|
+
|
|
127
|
+
// Validate port is a usable integer
|
|
128
|
+
const port = Number(env('PORT'));
|
|
129
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
130
|
+
throw new TejError(
|
|
131
|
+
500,
|
|
132
|
+
`Invalid PORT: "${env('PORT')}" — must be an integer between 1 and 65535`,
|
|
133
|
+
{ cause: new Error(`ERR_CONFIG_INVALID`) },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
92
136
|
}
|
|
93
137
|
|
|
94
138
|
/**
|
|
@@ -114,195 +158,110 @@ class Tejas {
|
|
|
114
158
|
}
|
|
115
159
|
|
|
116
160
|
/**
|
|
117
|
-
* Automatically registers target files from the configured directory
|
|
161
|
+
* Automatically registers target files from the configured directory.
|
|
162
|
+
* Returns a Promise so takeoff() can await it — ensures all targets are
|
|
163
|
+
* fully loaded before the server starts accepting connections.
|
|
118
164
|
*
|
|
119
165
|
* @private
|
|
120
|
-
* @
|
|
121
|
-
* Searches for and registers all files ending in 'target.js' from the
|
|
122
|
-
* directory specified by DIR_TARGETS environment variable.
|
|
123
|
-
* Target files define routes and their handlers.
|
|
124
|
-
*
|
|
166
|
+
* @returns {Promise<void>}
|
|
125
167
|
* @throws {Error} If target files cannot be registered
|
|
126
168
|
*/
|
|
127
|
-
registerTargetsDir() {
|
|
169
|
+
async registerTargetsDir() {
|
|
128
170
|
const baseDir = path.join(process.cwd(), process.env.DIR_TARGETS || '');
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
});
|
|
155
|
-
})
|
|
156
|
-
.catch((err) => {
|
|
157
|
-
logger.error(
|
|
158
|
-
`Tejas could not register target files. Error: ${err}`,
|
|
159
|
-
false,
|
|
160
|
-
);
|
|
161
|
-
});
|
|
171
|
+
try {
|
|
172
|
+
const targetFiles = await findTargetFiles();
|
|
173
|
+
if (!targetFiles?.length) return;
|
|
174
|
+
for (const file of targetFiles) {
|
|
175
|
+
const parentPath = file.path || '';
|
|
176
|
+
const fullPath = path.isAbsolute(parentPath)
|
|
177
|
+
? path.join(parentPath, file.name)
|
|
178
|
+
: path.join(baseDir, parentPath, file.name);
|
|
179
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
180
|
+
const groupId =
|
|
181
|
+
relativePath.replace(/\.target\.js$/i, '').replace(/\\/g, '/') ||
|
|
182
|
+
'index';
|
|
183
|
+
targetRegistry.setCurrentSourceGroup(groupId);
|
|
184
|
+
try {
|
|
185
|
+
await import(pathToFileURL(fullPath).href);
|
|
186
|
+
} finally {
|
|
187
|
+
targetRegistry.setCurrentSourceGroup(null);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.error(
|
|
192
|
+
`Tejas could not register target files. Error: ${err}`,
|
|
193
|
+
false,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
162
196
|
}
|
|
163
197
|
|
|
164
198
|
/**
|
|
165
199
|
* Starts the Tejas server
|
|
166
200
|
*
|
|
167
|
-
* @param {Object} [options] - Server configuration options
|
|
168
|
-
* @param {Object} [options.withRedis] - Redis connection options
|
|
169
|
-
* @param {Object} [options.withMongo] - MongoDB connection options (https://www.mongodb.com/docs/drivers/node/current/fundamentals/connection/)
|
|
170
201
|
* @description
|
|
171
202
|
* Creates and starts an HTTP server on the configured port.
|
|
172
|
-
* Optionally initializes Redis and/or MongoDB connections if configuration is provided.
|
|
173
|
-
* For Redis, accepts cluster flag and all connection options supported by node-redis package.
|
|
174
|
-
* For MongoDB, accepts all connection options supported by the official MongoDB Node.js driver.
|
|
175
203
|
*
|
|
176
204
|
* @example
|
|
177
205
|
* const app = new Tejas();
|
|
178
|
-
*
|
|
179
|
-
* // Start server with Redis and MongoDB
|
|
180
|
-
* app.takeoff({
|
|
181
|
-
* withRedis: {
|
|
182
|
-
* url: 'redis://alice:foobared@awesome.redis.server:6380',
|
|
183
|
-
* isCluster: false
|
|
184
|
-
* },
|
|
185
|
-
* withMongo: { url: 'mongodb://localhost:27017/mydatabase' }
|
|
186
|
-
* });
|
|
187
|
-
*
|
|
188
|
-
* // Start server with only Redis using defaults
|
|
189
|
-
* app.takeoff({
|
|
190
|
-
* withRedis: { url: 'redis://localhost:6379' }
|
|
191
|
-
* });
|
|
192
|
-
*
|
|
193
|
-
* // Start server without databases
|
|
194
206
|
* app.takeoff(); // Server starts on default port 1403
|
|
195
207
|
*/
|
|
196
|
-
takeoff(
|
|
208
|
+
async takeoff() {
|
|
209
|
+
const t0 = Date.now();
|
|
210
|
+
|
|
211
|
+
// Load configuration first (async file read)
|
|
212
|
+
await this.generateConfiguration();
|
|
213
|
+
|
|
214
|
+
// ── Startup banner ──────────────────────────────────────────────────
|
|
215
|
+
const version = await readFrameworkVersion();
|
|
216
|
+
const port = env('PORT');
|
|
217
|
+
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
218
|
+
const banner = [
|
|
219
|
+
'',
|
|
220
|
+
' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
|
|
221
|
+
` Tejas v${version}`,
|
|
222
|
+
` Port: ${port}`,
|
|
223
|
+
` PID: ${process.pid}`,
|
|
224
|
+
` Node: ${process.version}`,
|
|
225
|
+
` Env: ${nodeEnv}`,
|
|
226
|
+
' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
|
|
227
|
+
'',
|
|
228
|
+
].join('\n');
|
|
229
|
+
process.stdout.write(banner + '\n');
|
|
230
|
+
|
|
231
|
+
// ── Live feature status ────────────────────────────────────────────
|
|
232
|
+
const line = statusLine(process.stdout.isTTY);
|
|
233
|
+
|
|
234
|
+
if (this._radarStatus) {
|
|
235
|
+
const s = this._radarStatus;
|
|
236
|
+
line.finish(s.feature, s.ok, s.detail);
|
|
237
|
+
}
|
|
238
|
+
|
|
197
239
|
validateErrorsLlmAtTakeoff();
|
|
198
240
|
const errorsLlm = getErrorsLlmConfig();
|
|
199
241
|
if (errorsLlm.enabled) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
242
|
+
if (errorsLlm.verifyOnStart) {
|
|
243
|
+
line.start('LLM Errors', 'verifying model...');
|
|
244
|
+
const result = await verifyLlmConnection();
|
|
245
|
+
line.finish('LLM Errors', result.status.ok, result.status.detail);
|
|
246
|
+
} else {
|
|
247
|
+
line.finish(
|
|
248
|
+
'LLM Errors',
|
|
249
|
+
true,
|
|
250
|
+
`enabled (${errorsLlm.model || 'default model'}, mode: ${errorsLlm.mode})`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
203
253
|
}
|
|
204
|
-
this.engine = createServer(targetHandler);
|
|
205
|
-
this.engine.listen(env('PORT'), async () => {
|
|
206
|
-
logger.info(`Took off from port ${env('PORT')}`);
|
|
207
254
|
|
|
208
|
-
|
|
209
|
-
if (withMongo) await this.withMongo(withMongo);
|
|
210
|
-
});
|
|
255
|
+
await this.registerTargetsDir();
|
|
211
256
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Initializes a Redis connection
|
|
219
|
-
*
|
|
220
|
-
* @param {Object} [config] - Redis connection configuration
|
|
221
|
-
* @param {boolean} [config.isCluster=false] - Whether to use Redis Cluster
|
|
222
|
-
* @param {Object} [config.socket] - Redis socket connection options
|
|
223
|
-
* @param {string} [config.socket.host] - Redis server hostname
|
|
224
|
-
* @param {number} [config.socket.port] - Redis server port
|
|
225
|
-
* @param {boolean} [config.socket.tls] - Whether to use TLS for connection
|
|
226
|
-
* @param {string} [config.url] - Redis connection URL (alternative to socket config)
|
|
227
|
-
* @returns {Promise<Tejas>} Returns a Promise that resolves to this instance for chaining
|
|
228
|
-
*
|
|
229
|
-
* @example
|
|
230
|
-
* // Initialize Redis with URL
|
|
231
|
-
* await app.withRedis({
|
|
232
|
-
* url: 'redis://localhost:6379'
|
|
233
|
-
* }).withRateLimit({
|
|
234
|
-
* maxRequests: 100,
|
|
235
|
-
* store: 'redis'
|
|
236
|
-
* });
|
|
237
|
-
*
|
|
238
|
-
* @example
|
|
239
|
-
* // Initialize Redis with socket options
|
|
240
|
-
* await app.withRedis({
|
|
241
|
-
* socket: {
|
|
242
|
-
* host: 'localhost',
|
|
243
|
-
* port: 6379
|
|
244
|
-
* }
|
|
245
|
-
* });
|
|
246
|
-
*/
|
|
247
|
-
async withRedis(config) {
|
|
248
|
-
if (config) {
|
|
249
|
-
await dbManager.initializeConnection('redis', config);
|
|
250
|
-
} else {
|
|
251
|
-
logger.warn(
|
|
252
|
-
'No Redis configuration provided. Skipping Redis connection.',
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return this;
|
|
257
|
-
}
|
|
257
|
+
// ── Start HTTP server ───────────────────────────────────────────────
|
|
258
|
+
this.engine = createServer(targetHandler);
|
|
259
|
+
await new Promise((resolve) => this.engine.listen(port, resolve));
|
|
260
|
+
this.engine.on('error', (err) => logger.error(`Server error: ${err}`));
|
|
258
261
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
* @param {Object} [config] - MongoDB connection configuration
|
|
263
|
-
* @param {string} [config.uri] - MongoDB connection URI
|
|
264
|
-
* @param {Object} [config.options] - Additional MongoDB connection options
|
|
265
|
-
* @returns {Tejas} Returns a Promise that resolves to this instance for chaining
|
|
266
|
-
*
|
|
267
|
-
* @example
|
|
268
|
-
* // Initialize MongoDB with URI
|
|
269
|
-
* await app.withMongo({
|
|
270
|
-
* uri: 'mongodb://localhost:27017/myapp'
|
|
271
|
-
* });
|
|
272
|
-
*
|
|
273
|
-
* @example
|
|
274
|
-
* // Initialize MongoDB with options
|
|
275
|
-
* await app.withMongo({
|
|
276
|
-
* uri: 'mongodb://localhost:27017/myapp',
|
|
277
|
-
* options: {
|
|
278
|
-
* useNewUrlParser: true,
|
|
279
|
-
* useUnifiedTopology: true
|
|
280
|
-
* }
|
|
281
|
-
* });
|
|
282
|
-
*
|
|
283
|
-
* @example
|
|
284
|
-
* // Chain database connections
|
|
285
|
-
* await app
|
|
286
|
-
* .withMongo({
|
|
287
|
-
* uri: 'mongodb://localhost:27017/myapp'
|
|
288
|
-
* })
|
|
289
|
-
* .withRedis({
|
|
290
|
-
* url: 'redis://localhost:6379'
|
|
291
|
-
* })
|
|
292
|
-
* .withRateLimit({
|
|
293
|
-
* maxRequests: 100,
|
|
294
|
-
* store: 'redis'
|
|
295
|
-
* });
|
|
296
|
-
*/
|
|
297
|
-
withMongo(config) {
|
|
298
|
-
if (config) {
|
|
299
|
-
dbManager.initializeConnection('mongodb', config);
|
|
300
|
-
} else {
|
|
301
|
-
logger.warn(
|
|
302
|
-
'No MongoDB configuration provided. Skipping MongoDB connection.',
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
return this;
|
|
262
|
+
process.stdout.write(
|
|
263
|
+
`\n \x1b[32m\u2708 Ready on port ${port} in ${fmtMs(Date.now() - t0)}\x1b[0m\n\n`,
|
|
264
|
+
);
|
|
306
265
|
}
|
|
307
266
|
|
|
308
267
|
/**
|
|
@@ -322,6 +281,7 @@ class Tejas {
|
|
|
322
281
|
* @param {number} [config.rateLimit] - Max LLM calls per minute across all requests (default 10)
|
|
323
282
|
* @param {boolean} [config.cache] - Cache LLM results by throw site + error message to avoid repeated calls (default true)
|
|
324
283
|
* @param {number} [config.cacheTTL] - How long cached results are reused in milliseconds (default 3600000 = 1 hour)
|
|
284
|
+
* @param {boolean} [config.verifyOnStart] - Send a test prompt to the LLM at startup to verify connectivity (default false)
|
|
325
285
|
* @returns {Tejas} The Tejas instance for chaining
|
|
326
286
|
*
|
|
327
287
|
* @example
|
|
@@ -353,6 +313,8 @@ class Tejas {
|
|
|
353
313
|
if (config.cache != null) setEnv('ERRORS_LLM_CACHE', config.cache);
|
|
354
314
|
if (config.cacheTTL != null)
|
|
355
315
|
setEnv('ERRORS_LLM_CACHE_TTL', config.cacheTTL);
|
|
316
|
+
if (config.verifyOnStart != null)
|
|
317
|
+
setEnv('ERRORS_LLM_VERIFY_ON_START', config.verifyOnStart);
|
|
356
318
|
}
|
|
357
319
|
return this;
|
|
358
320
|
}
|
|
@@ -364,8 +326,10 @@ class Tejas {
|
|
|
364
326
|
* @param {number} [config.maxRequests=60] - Maximum number of requests allowed in the time window
|
|
365
327
|
* @param {number} [config.timeWindowSeconds=60] - Time window in seconds
|
|
366
328
|
* @param {string} [config.algorithm='sliding-window'] - Rate-limiting algorithm ('token-bucket', 'sliding-window', or 'fixed-window')
|
|
329
|
+
* @param {string|Object} [config.store='memory'] - Storage backend: 'memory' (default) or
|
|
330
|
+
* { type: 'redis', url: 'redis://...', ...redisOptions } for distributed deployments.
|
|
331
|
+
* In-memory storage is not shared across processes and may be inaccurate in distributed setups.
|
|
367
332
|
* @param {Object} [config.algorithmOptions] - Algorithm-specific options
|
|
368
|
-
* @param {Object} [config.redis] - Redis configuration for distributed rate limiting
|
|
369
333
|
* @param {Function} [config.keyGenerator] - Function to generate unique identifiers (defaults to IP-based)
|
|
370
334
|
* @param {Object} [config.headerFormat] - Rate limit header format configuration
|
|
371
335
|
* @returns {Tejas} The Tejas instance for chaining
|
|
@@ -408,6 +372,86 @@ class Tejas {
|
|
|
408
372
|
return this;
|
|
409
373
|
}
|
|
410
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Enables Tejas Radar telemetry — captures HTTP request metrics and forwards
|
|
377
|
+
* them to a Radar collector for real-time observability.
|
|
378
|
+
*
|
|
379
|
+
* All options fall back to environment variables and sensible defaults, so
|
|
380
|
+
* the minimum viable call is just `app.withRadar({ apiKey: 'rdr_xxx' })`.
|
|
381
|
+
* The project name is auto-detected from `package.json` if not supplied.
|
|
382
|
+
*
|
|
383
|
+
* @param {Object} [config] - Radar configuration
|
|
384
|
+
* @param {string} [config.collectorUrl] Collector base URL (default: RADAR_COLLECTOR_URL env or http://localhost:3100)
|
|
385
|
+
* @param {string} [config.apiKey] Bearer token `rdr_xxx` (default: RADAR_API_KEY env)
|
|
386
|
+
* @param {string} [config.projectName] Project identifier (default: RADAR_PROJECT_NAME env → package.json name → "tejas-app")
|
|
387
|
+
* @param {number} [config.flushInterval] Milliseconds between periodic flushes (default: 2000)
|
|
388
|
+
* @param {number} [config.batchSize] Flush immediately when batch reaches this size (default: 100)
|
|
389
|
+
* @param {string[]} [config.ignore] Request paths to skip (default: ['/health'])
|
|
390
|
+
*
|
|
391
|
+
* @param {Object} [config.capture] Controls what additional data is captured and sent to the collector.
|
|
392
|
+
* All capture options default to `false` — nothing beyond standard
|
|
393
|
+
* metrics is sent unless explicitly enabled.
|
|
394
|
+
* @param {boolean} [config.capture.request=false]
|
|
395
|
+
* Capture and send the request body. The body is a shallow copy of parsed
|
|
396
|
+
* query params and request body fields merged together. Only JSON-serialisable
|
|
397
|
+
* content is sent. The collector applies non-bypassable GDPR field masking
|
|
398
|
+
* server-side regardless of this setting.
|
|
399
|
+
* @param {boolean} [config.capture.response=false]
|
|
400
|
+
* Capture and send the response body. The response must be valid JSON;
|
|
401
|
+
* non-JSON responses are recorded as `null`. The collector applies
|
|
402
|
+
* non-bypassable GDPR field masking server-side.
|
|
403
|
+
* @param {boolean|string[]} [config.capture.headers=false]
|
|
404
|
+
* Capture request headers. Pass `true` to send all headers, or a `string[]`
|
|
405
|
+
* allowlist of specific header names to send (e.g. `['content-type', 'x-request-id']`).
|
|
406
|
+
* The collector always strips sensitive headers (`authorization`, `cookie`,
|
|
407
|
+
* `set-cookie`, `x-api-key`, etc.) server-side regardless of what is sent.
|
|
408
|
+
*
|
|
409
|
+
* @param {Object} [config.mask] Client-side masking applied to request/response bodies
|
|
410
|
+
* before data is sent to the collector.
|
|
411
|
+
* @param {string[]} [config.mask.fields] Extra field names (case-insensitive) to mask client-side.
|
|
412
|
+
* Matched field values are replaced with `"*"` before leaving
|
|
413
|
+
* the process. Use this for application-specific sensitive fields
|
|
414
|
+
* that are not on the collector's built-in GDPR blocklist.
|
|
415
|
+
* Note: the collector enforces its own non-bypassable masking
|
|
416
|
+
* layer server-side regardless of this setting.
|
|
417
|
+
*
|
|
418
|
+
* @returns {Promise<Tejas>} The Tejas instance for chaining
|
|
419
|
+
*
|
|
420
|
+
* @example
|
|
421
|
+
* await app.withRadar({ apiKey: process.env.RADAR_API_KEY });
|
|
422
|
+
* app.takeoff();
|
|
423
|
+
*
|
|
424
|
+
* @example
|
|
425
|
+
* app.withRadar({
|
|
426
|
+
* collectorUrl: 'https://collector.example.com',
|
|
427
|
+
* apiKey: process.env.RADAR_API_KEY,
|
|
428
|
+
* projectName: 'my-api',
|
|
429
|
+
* });
|
|
430
|
+
*
|
|
431
|
+
* @example
|
|
432
|
+
* // Capture request/response bodies and selected headers,
|
|
433
|
+
* // with extra client-side masking for app-specific fields.
|
|
434
|
+
* app.withRadar({
|
|
435
|
+
* apiKey: process.env.RADAR_API_KEY,
|
|
436
|
+
* capture: {
|
|
437
|
+
* request: true,
|
|
438
|
+
* response: true,
|
|
439
|
+
* headers: ['content-type', 'x-request-id'],
|
|
440
|
+
* },
|
|
441
|
+
* mask: {
|
|
442
|
+
* fields: ['account_number', 'internal_id'],
|
|
443
|
+
* },
|
|
444
|
+
* });
|
|
445
|
+
*/
|
|
446
|
+
async withRadar(config = {}) {
|
|
447
|
+
const mw = await radarMiddleware(config);
|
|
448
|
+
if (mw._radarStatus) {
|
|
449
|
+
this._radarStatus = mw._radarStatus;
|
|
450
|
+
}
|
|
451
|
+
this.midair(mw);
|
|
452
|
+
return this;
|
|
453
|
+
}
|
|
454
|
+
|
|
411
455
|
/**
|
|
412
456
|
* Serves the API documentation at GET /docs and GET /docs/openapi.json from a pre-generated spec file.
|
|
413
457
|
* Generate the spec with `tejas generate:docs`, then call this to serve it on your app.
|
|
@@ -449,6 +493,12 @@ export { default as Target } from './server/target.js';
|
|
|
449
493
|
export { default as TejFileUploader } from './server/files/uploader.js';
|
|
450
494
|
export { default as TejError } from './server/error.js';
|
|
451
495
|
export { listAllEndpoints };
|
|
496
|
+
export {
|
|
497
|
+
contextMiddleware,
|
|
498
|
+
getRequestId,
|
|
499
|
+
getRequestStore,
|
|
500
|
+
requestContext,
|
|
501
|
+
} from './server/context/request-context.js';
|
|
452
502
|
|
|
453
503
|
export default Tejas;
|
|
454
504
|
|
package/utils/auto-register.js
CHANGED
package/utils/configuration.js
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Configuration loading and normalization utilities.
|
|
3
|
+
*
|
|
4
|
+
* Loads `tejas.config.json` from `process.cwd()` and provides helpers to
|
|
5
|
+
* standardize and flatten config objects so they can be merged with env vars
|
|
6
|
+
* and constructor options.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs';
|
|
2
10
|
import { getAllEnv } from 'tej-env';
|
|
3
11
|
|
|
4
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Asynchronously read and parse `tejas.config.json` from the current working directory.
|
|
14
|
+
* Returns an empty null-prototype object if the file is missing or unreadable.
|
|
15
|
+
*
|
|
16
|
+
* @returns {Promise<Object>} Parsed config object (may be empty)
|
|
17
|
+
*/
|
|
18
|
+
const loadConfigFile = async () => {
|
|
5
19
|
try {
|
|
6
|
-
const data = fs.
|
|
20
|
+
const data = await fs.promises.readFile('tejas.config.json', 'utf8');
|
|
7
21
|
return JSON.parse(data);
|
|
8
22
|
} catch (err) {
|
|
9
|
-
return
|
|
23
|
+
return Object.create(null);
|
|
10
24
|
}
|
|
11
25
|
};
|
|
12
26
|
|
|
13
27
|
const keysToUpperCase = (obj) => {
|
|
14
|
-
if (!obj) return
|
|
15
|
-
const standardObj =
|
|
28
|
+
if (!obj) return Object.create(null);
|
|
29
|
+
const standardObj = Object.create(null);
|
|
16
30
|
|
|
17
31
|
for (const key in obj) {
|
|
18
|
-
if (
|
|
32
|
+
if (Object.hasOwn(obj, key)) {
|
|
19
33
|
const value = obj[key];
|
|
20
34
|
const upperKey = key.toUpperCase();
|
|
21
35
|
|
|
@@ -31,10 +45,10 @@ const keysToUpperCase = (obj) => {
|
|
|
31
45
|
};
|
|
32
46
|
|
|
33
47
|
const flattenObject = (obj, prefix = '') => {
|
|
34
|
-
let flattened =
|
|
48
|
+
let flattened = Object.create(null);
|
|
35
49
|
|
|
36
50
|
for (const key in obj) {
|
|
37
|
-
if (
|
|
51
|
+
if (Object.hasOwn(obj, key)) {
|
|
38
52
|
const value = obj[key];
|
|
39
53
|
const newKey = prefix ? `${prefix}_${key}` : key;
|
|
40
54
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for utils/configuration.js
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
|
|
6
|
+
describe('loadConfigFile', () => {
|
|
7
|
+
let loadConfigFile;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
// Use dynamic import and mock fs to avoid hitting real filesystem
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should return a null-prototype object on file not found', async () => {
|
|
19
|
+
vi.doMock('node:fs', () => ({
|
|
20
|
+
default: {},
|
|
21
|
+
promises: {
|
|
22
|
+
readFile: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
const mod = await import('./configuration.js');
|
|
26
|
+
const result = await mod.loadConfigFile();
|
|
27
|
+
expect(Object.getPrototypeOf(result)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should parse valid JSON config file', async () => {
|
|
31
|
+
const config = { port: 8080, db: { url: 'mongodb://localhost' } };
|
|
32
|
+
vi.doMock('node:fs', () => ({
|
|
33
|
+
default: {},
|
|
34
|
+
promises: {
|
|
35
|
+
readFile: vi.fn().mockResolvedValue(JSON.stringify(config)),
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
const mod = await import('./configuration.js');
|
|
39
|
+
const result = await mod.loadConfigFile();
|
|
40
|
+
expect(result.port).toBe(8080);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('standardizeObj', () => {
|
|
45
|
+
it('should uppercase keys and flatten nested objects', async () => {
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
const { standardizeObj } = await import('./configuration.js');
|
|
48
|
+
const result = standardizeObj({ db: { url: 'test', port: 5432 } });
|
|
49
|
+
expect(result['DB_URL']).toBe('test');
|
|
50
|
+
expect(result['DB_PORT']).toBe(5432);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle null/undefined gracefully', async () => {
|
|
54
|
+
const { standardizeObj } = await import('./configuration.js');
|
|
55
|
+
expect(() => standardizeObj(null)).not.toThrow();
|
|
56
|
+
expect(() => standardizeObj(undefined)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|