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/radar/index.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { gzip } from 'node:zlib';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const gzipAsync = promisify(gzip);
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
|
+
import TejLogger from 'tej-logger';
|
|
10
|
+
|
|
11
|
+
const logger = new TejLogger('Tejas.Radar');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* AsyncLocalStorage instance for propagating trace context across async
|
|
15
|
+
* boundaries within a single request. Middleware sets `{ traceId }` on
|
|
16
|
+
* entry; downstream code can read it via `traceStore.getStore()?.traceId`.
|
|
17
|
+
*/
|
|
18
|
+
export const traceStore = new AsyncLocalStorage();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Attempt to read the `name` field from the nearest package.json at startup.
|
|
22
|
+
* Returns null if the file cannot be read or parsed.
|
|
23
|
+
* @returns {Promise<string|null>}
|
|
24
|
+
*/
|
|
25
|
+
async function readPackageJsonName() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(join(process.cwd(), 'package.json'), 'utf8');
|
|
28
|
+
return JSON.parse(raw).name ?? null;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
logger.warn(`Could not read package.json name: ${err?.message ?? err}`);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Recursively walk a plain object/array and replace the value of any key whose
|
|
37
|
+
* lowercase name appears in `blocklist` with the string `"*"`. Returns a new
|
|
38
|
+
* deep-cloned structure; the original is never mutated.
|
|
39
|
+
*
|
|
40
|
+
* Non-object values (strings, numbers, null, …) are returned as-is.
|
|
41
|
+
*
|
|
42
|
+
* @param {unknown} value
|
|
43
|
+
* @param {Set<string>} blocklist Lower-cased field names to mask.
|
|
44
|
+
* @returns {unknown}
|
|
45
|
+
*/
|
|
46
|
+
function deepMask(value, blocklist) {
|
|
47
|
+
if (value === null || typeof value !== 'object') return value;
|
|
48
|
+
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
return value.map((item) => deepMask(item, blocklist));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = Object.create(null);
|
|
54
|
+
for (const [k, v] of Object.entries(value)) {
|
|
55
|
+
result[k] = blocklist.has(k.toLowerCase()) ? '*' : deepMask(v, blocklist);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the headers object to include in the metric record based on the
|
|
62
|
+
* `capture.headers` configuration value:
|
|
63
|
+
* - `false` → null (default; nothing sent)
|
|
64
|
+
* - `true` → shallow copy of all headers
|
|
65
|
+
* - `string[]` → object containing only the listed header names
|
|
66
|
+
*
|
|
67
|
+
* @param {Record<string, string>|undefined} rawHeaders
|
|
68
|
+
* @param {boolean|string[]} captureHeaders
|
|
69
|
+
* @returns {Record<string, string>|null}
|
|
70
|
+
*/
|
|
71
|
+
function buildHeaders(rawHeaders, captureHeaders) {
|
|
72
|
+
if (!captureHeaders || !rawHeaders) return null;
|
|
73
|
+
if (captureHeaders === true) return { ...rawHeaders };
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
captureHeaders
|
|
76
|
+
.map((k) => [k, rawHeaders[k.toLowerCase()]])
|
|
77
|
+
.filter(([, v]) => v != null),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Attempt to parse a JSON string. Returns the parsed value on success, or
|
|
83
|
+
* `null` on failure. Used for response bodies which may not always be JSON.
|
|
84
|
+
*
|
|
85
|
+
* @param {string|undefined|null} raw
|
|
86
|
+
* @returns {unknown}
|
|
87
|
+
*/
|
|
88
|
+
function parseJsonSafe(raw) {
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(raw);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
logger.warn(`parseJsonSafe: JSON parse failed — ${err?.message ?? err}`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Factory that returns a te.js-compatible `(ammo, next)` middleware which
|
|
100
|
+
* captures HTTP request metrics and forwards them to the Tejas Radar collector.
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} [config]
|
|
103
|
+
* @param {string} [config.apiKey] Bearer token (rdr_xxx). Falls back to RADAR_API_KEY env. Required.
|
|
104
|
+
* @param {string} [config.projectName] Project identifier. Falls back to RADAR_PROJECT_NAME env, then package.json `name`, then "tejas-app".
|
|
105
|
+
* @param {number} [config.flushInterval] Milliseconds between periodic flushes (default 2000).
|
|
106
|
+
* @param {number} [config.batchSize] Flush immediately when batch reaches this size (default 100).
|
|
107
|
+
* @param {number} [config.maxQueueSize] Maximum events held in memory before oldest are dropped (default 10000).
|
|
108
|
+
* @param {Function} [config.transport] Custom transport `(events) => Promise<{ok, status}>`.
|
|
109
|
+
* Defaults to gzip-compressed HTTP POST to the collector.
|
|
110
|
+
* @param {string[]} [config.ignore] Request paths to skip (default ['/health']).
|
|
111
|
+
* @param {Object} [config.capture] Controls what additional data is captured beyond metrics.
|
|
112
|
+
* @param {boolean} [config.capture.request] Capture and send request body (default false).
|
|
113
|
+
* @param {boolean} [config.capture.response] Capture and send response body (default false).
|
|
114
|
+
* @param {boolean|string[]} [config.capture.headers] Capture request headers. `true` sends all headers;
|
|
115
|
+
* a `string[]` sends only the named headers (allowlist);
|
|
116
|
+
* `false` (default) sends nothing.
|
|
117
|
+
* @param {Object} [config.mask] Client-side masking applied before data is sent.
|
|
118
|
+
* @param {string[]} [config.mask.fields] Extra field names to mask in request/response bodies.
|
|
119
|
+
* These are merged with the collector's server-side GDPR blocklist.
|
|
120
|
+
* Note: the collector enforces its own non-bypassable masking
|
|
121
|
+
* regardless of this setting.
|
|
122
|
+
* @returns {Promise<Function>} Middleware function `(ammo, next)`
|
|
123
|
+
*/
|
|
124
|
+
async function radarMiddleware(config = {}) {
|
|
125
|
+
// RADAR_COLLECTOR_URL is an undocumented internal escape hatch used only
|
|
126
|
+
// during local development. In production, telemetry always goes to the
|
|
127
|
+
// hosted collector and this env var should not be set.
|
|
128
|
+
const collectorUrl =
|
|
129
|
+
process.env.RADAR_COLLECTOR_URL ?? 'http://localhost:3100';
|
|
130
|
+
|
|
131
|
+
const apiKey = config.apiKey ?? process.env.RADAR_API_KEY ?? null;
|
|
132
|
+
|
|
133
|
+
const projectName =
|
|
134
|
+
config.projectName ??
|
|
135
|
+
process.env.RADAR_PROJECT_NAME ??
|
|
136
|
+
(await readPackageJsonName()) ??
|
|
137
|
+
'tejas-app';
|
|
138
|
+
|
|
139
|
+
const flushInterval = config.flushInterval ?? 2000;
|
|
140
|
+
const batchSize = config.batchSize ?? 100;
|
|
141
|
+
const maxQueueSize = config.maxQueueSize ?? 10_000;
|
|
142
|
+
const ignorePaths = new Set(config.ignore ?? ['/health']);
|
|
143
|
+
|
|
144
|
+
const capture = Object.freeze({
|
|
145
|
+
request: config.capture?.request === true,
|
|
146
|
+
response: config.capture?.response === true,
|
|
147
|
+
headers: config.capture?.headers ?? false,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Build the client-side field blocklist from developer-supplied extra fields.
|
|
151
|
+
// The collector enforces its own non-bypassable GDPR blocklist server-side;
|
|
152
|
+
// this is an additional best-effort layer for application-specific fields.
|
|
153
|
+
const clientMaskBlocklist = new Set(
|
|
154
|
+
(config.mask?.fields ?? []).map((f) => f.toLowerCase()),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (!apiKey) {
|
|
158
|
+
const mw = (_ammo, next) => next();
|
|
159
|
+
mw._radarStatus = {
|
|
160
|
+
feature: 'Radar',
|
|
161
|
+
ok: null,
|
|
162
|
+
detail: 'disabled (no API key)',
|
|
163
|
+
};
|
|
164
|
+
return mw;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ingestUrl = `${collectorUrl}/ingest`;
|
|
168
|
+
const healthUrl = `${collectorUrl}/health`;
|
|
169
|
+
const authHeader = `Bearer ${apiKey}`;
|
|
170
|
+
|
|
171
|
+
/** @type {Array<Object>} */
|
|
172
|
+
let batch = [];
|
|
173
|
+
let connected = false;
|
|
174
|
+
let retryQueue = null;
|
|
175
|
+
let retryCount = 0;
|
|
176
|
+
const MAX_RETRIES = 3;
|
|
177
|
+
|
|
178
|
+
async function defaultHttpTransport(events) {
|
|
179
|
+
const json = JSON.stringify(events);
|
|
180
|
+
const compressed = await gzipAsync(Buffer.from(json));
|
|
181
|
+
return fetch(ingestUrl, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: {
|
|
184
|
+
'Content-Type': 'application/json',
|
|
185
|
+
'Content-Encoding': 'gzip',
|
|
186
|
+
Authorization: authHeader,
|
|
187
|
+
},
|
|
188
|
+
body: compressed,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const send = config.transport ?? defaultHttpTransport;
|
|
193
|
+
|
|
194
|
+
/** @type {{ feature: string, ok: boolean, detail: string }} */
|
|
195
|
+
let radarStatus;
|
|
196
|
+
try {
|
|
197
|
+
const healthRes = await fetch(healthUrl);
|
|
198
|
+
if (healthRes.ok) {
|
|
199
|
+
radarStatus = {
|
|
200
|
+
feature: 'Radar',
|
|
201
|
+
ok: true,
|
|
202
|
+
detail: `connected (${collectorUrl})`,
|
|
203
|
+
};
|
|
204
|
+
} else {
|
|
205
|
+
radarStatus = {
|
|
206
|
+
feature: 'Radar',
|
|
207
|
+
ok: false,
|
|
208
|
+
detail: `collector returned ${healthRes.status} (${collectorUrl})`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
radarStatus = {
|
|
213
|
+
feature: 'Radar',
|
|
214
|
+
ok: false,
|
|
215
|
+
detail: `unreachable (${collectorUrl})`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function sendPayload(payload) {
|
|
220
|
+
try {
|
|
221
|
+
const res = await send(payload);
|
|
222
|
+
if (res.ok) {
|
|
223
|
+
if (!connected) {
|
|
224
|
+
connected = true;
|
|
225
|
+
logger.info(
|
|
226
|
+
`Connected — project: "${projectName}", collector: ${collectorUrl}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
if (res.status === 401) {
|
|
232
|
+
if (connected) connected = false;
|
|
233
|
+
logger.warn(
|
|
234
|
+
'Radar API key rejected by collector. Telemetry will not be recorded.',
|
|
235
|
+
);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
logger.warn(`Radar flush failed: ${err.message}`);
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function flush() {
|
|
246
|
+
if (retryQueue) {
|
|
247
|
+
const ok = await sendPayload(retryQueue);
|
|
248
|
+
if (ok) {
|
|
249
|
+
retryQueue = null;
|
|
250
|
+
retryCount = 0;
|
|
251
|
+
} else {
|
|
252
|
+
retryCount++;
|
|
253
|
+
if (retryCount >= MAX_RETRIES) {
|
|
254
|
+
logger.warn(
|
|
255
|
+
`Radar dropping ${retryQueue.length} events after ${MAX_RETRIES} failed retries`,
|
|
256
|
+
);
|
|
257
|
+
retryQueue = null;
|
|
258
|
+
retryCount = 0;
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (batch.length === 0) return;
|
|
265
|
+
const payload = batch;
|
|
266
|
+
batch = [];
|
|
267
|
+
|
|
268
|
+
const ok = await sendPayload(payload);
|
|
269
|
+
if (!ok) {
|
|
270
|
+
retryQueue = payload;
|
|
271
|
+
retryCount = 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const timer = setInterval(flush, flushInterval);
|
|
276
|
+
if (timer.unref) timer.unref();
|
|
277
|
+
|
|
278
|
+
function radarCapture(ammo, next) {
|
|
279
|
+
const startTime = Date.now();
|
|
280
|
+
const traceId = randomUUID().replace(/-/g, '');
|
|
281
|
+
|
|
282
|
+
ammo.res.on('finish', () => {
|
|
283
|
+
const path = ammo.endpoint ?? ammo.path ?? '/';
|
|
284
|
+
|
|
285
|
+
if (ammo.method === 'OPTIONS' || ignorePaths.has(path)) return;
|
|
286
|
+
|
|
287
|
+
const status = ammo.res.statusCode;
|
|
288
|
+
const endTimestamp = Date.now();
|
|
289
|
+
const duration = endTimestamp - startTime;
|
|
290
|
+
const payloadSize = Buffer.byteLength(
|
|
291
|
+
JSON.stringify(ammo.payload ?? {}),
|
|
292
|
+
'utf8',
|
|
293
|
+
);
|
|
294
|
+
const responseSize = Buffer.byteLength(ammo.dispatchedData ?? '', 'utf8');
|
|
295
|
+
const ip = ammo.ip ?? null;
|
|
296
|
+
const userAgent = ammo.headers?.['user-agent'] ?? null;
|
|
297
|
+
const headers = buildHeaders(ammo.headers, capture.headers);
|
|
298
|
+
const requestBody = capture.request
|
|
299
|
+
? deepMask(ammo.payload ?? null, clientMaskBlocklist)
|
|
300
|
+
: null;
|
|
301
|
+
const responseBody = capture.response
|
|
302
|
+
? deepMask(parseJsonSafe(ammo.dispatchedData), clientMaskBlocklist)
|
|
303
|
+
: null;
|
|
304
|
+
|
|
305
|
+
function pushEvents() {
|
|
306
|
+
const errorInfo = ammo._errorInfo ?? null;
|
|
307
|
+
|
|
308
|
+
let errorField = null;
|
|
309
|
+
if (status >= 400 && errorInfo) {
|
|
310
|
+
errorField = JSON.stringify({
|
|
311
|
+
message: errorInfo.message ?? null,
|
|
312
|
+
type: errorInfo.type ?? null,
|
|
313
|
+
devInsight: errorInfo.devInsight ?? null,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const incoming = status >= 400 ? 2 : 1;
|
|
318
|
+
if (batch.length + incoming > maxQueueSize) {
|
|
319
|
+
const overflow = batch.length + incoming - maxQueueSize;
|
|
320
|
+
batch.splice(0, overflow);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
batch.push({
|
|
324
|
+
type: 'log',
|
|
325
|
+
projectName,
|
|
326
|
+
method: ammo.method,
|
|
327
|
+
path,
|
|
328
|
+
status,
|
|
329
|
+
duration_ms: duration,
|
|
330
|
+
payload_size: payloadSize,
|
|
331
|
+
response_size: responseSize,
|
|
332
|
+
timestamp: endTimestamp,
|
|
333
|
+
ip,
|
|
334
|
+
traceId,
|
|
335
|
+
user_agent: userAgent,
|
|
336
|
+
headers,
|
|
337
|
+
request_body: requestBody,
|
|
338
|
+
response_body: responseBody,
|
|
339
|
+
error: errorField,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (status >= 400) {
|
|
343
|
+
const message = errorInfo?.message ?? `HTTP ${status}`;
|
|
344
|
+
const fingerprint = createHash('sha256')
|
|
345
|
+
.update(`${message}:${path}`)
|
|
346
|
+
.digest('hex');
|
|
347
|
+
batch.push({
|
|
348
|
+
type: 'error',
|
|
349
|
+
projectName,
|
|
350
|
+
fingerprint,
|
|
351
|
+
message,
|
|
352
|
+
stack: errorInfo?.stack ?? null,
|
|
353
|
+
endpoint: `${ammo.method} ${path}`,
|
|
354
|
+
traceId,
|
|
355
|
+
timestamp: endTimestamp,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (batch.length >= batchSize) flush();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (ammo._llmPromise) {
|
|
363
|
+
const timeout = new Promise((resolve) => {
|
|
364
|
+
const t = setTimeout(resolve, 30000);
|
|
365
|
+
if (t.unref) t.unref();
|
|
366
|
+
});
|
|
367
|
+
Promise.race([ammo._llmPromise, timeout])
|
|
368
|
+
.catch(() => {})
|
|
369
|
+
.then(pushEvents);
|
|
370
|
+
} else {
|
|
371
|
+
pushEvents();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
traceStore.run({ traceId }, () => next());
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
radarCapture._radarStatus = radarStatus;
|
|
379
|
+
return radarCapture;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export default radarMiddleware;
|
package/rate-limit/base.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import TejError from '../server/error.js';
|
|
2
2
|
import MemoryStorage from './storage/memory.js';
|
|
3
3
|
import RedisStorage from './storage/redis.js';
|
|
4
|
-
import dbManager from '../database/index.js';
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Base rate limiter class implementing common functionality for rate limiting algorithms
|
|
@@ -15,11 +14,9 @@ import dbManager from '../database/index.js';
|
|
|
15
14
|
* object is provided (tokenBucketConfig, slidingWindowConfig, or fixedWindowConfig).
|
|
16
15
|
*
|
|
17
16
|
* @example
|
|
18
|
-
* // Using with Redis storage and token bucket algorithm
|
|
19
17
|
* const limiter = new TokenBucketRateLimiter({
|
|
20
18
|
* maxRequests: 10,
|
|
21
19
|
* timeWindowSeconds: 60,
|
|
22
|
-
* store: 'redis',
|
|
23
20
|
* tokenBucketConfig: {
|
|
24
21
|
* refillRate: 0.5,
|
|
25
22
|
* burstSize: 15
|
|
@@ -40,7 +37,9 @@ class RateLimiter {
|
|
|
40
37
|
* For token bucket, this affects the default refill rate calculation.
|
|
41
38
|
* @param {string} [options.keyPrefix='rl:'] - Prefix for storage keys. Useful when implementing different rate limit
|
|
42
39
|
* rules with different prefixes (e.g., 'rl:api:', 'rl:web:').
|
|
43
|
-
* @param {string} [options.store='memory'] - Storage backend
|
|
40
|
+
* @param {string|Object} [options.store='memory'] - Storage backend: 'memory' (default) or
|
|
41
|
+
* { type: 'redis', url: 'redis://...', ...redisOptions }.
|
|
42
|
+
* In-memory storage is not shared across processes; use Redis for distributed deployments.
|
|
44
43
|
* @param {Object} [options.tokenBucketConfig] - Token bucket algorithm specific options
|
|
45
44
|
* @param {Object} [options.slidingWindowConfig] - Sliding window algorithm specific options
|
|
46
45
|
* @param {Object} [options.fixedWindowConfig] - Fixed window algorithm specific options
|
|
@@ -101,18 +100,16 @@ class RateLimiter {
|
|
|
101
100
|
}
|
|
102
101
|
: null;
|
|
103
102
|
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
if (!dbManager.hasConnection('redis')) {
|
|
107
|
-
throw new TejError(
|
|
108
|
-
500,
|
|
109
|
-
'Redis store selected but no Redis connection available. Call withRedis() first.',
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
const redisClient = dbManager.getConnection('redis');
|
|
113
|
-
this.storage = new RedisStorage(redisClient);
|
|
114
|
-
} else {
|
|
103
|
+
const store = this.options.store;
|
|
104
|
+
if (!store || store === 'memory') {
|
|
115
105
|
this.storage = new MemoryStorage();
|
|
106
|
+
} else if (typeof store === 'object' && store.type === 'redis') {
|
|
107
|
+
this.storage = new RedisStorage(store);
|
|
108
|
+
} else {
|
|
109
|
+
throw new TejError(
|
|
110
|
+
400,
|
|
111
|
+
`Invalid store config. Use 'memory' or { type: 'redis', url: '...' }.`,
|
|
112
|
+
);
|
|
116
113
|
}
|
|
117
114
|
}
|
|
118
115
|
|
package/rate-limit/index.js
CHANGED
|
@@ -2,7 +2,6 @@ import TejError from '../server/error.js';
|
|
|
2
2
|
import FixedWindowRateLimiter from './algorithms/fixed-window.js';
|
|
3
3
|
import SlidingWindowRateLimiter from './algorithms/sliding-window.js';
|
|
4
4
|
import TokenBucketRateLimiter from './algorithms/token-bucket.js';
|
|
5
|
-
import dbManager from '../database/index.js';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Creates a rate limiting middleware function with the specified algorithm and storage
|
|
@@ -14,9 +13,11 @@ import dbManager from '../database/index.js';
|
|
|
14
13
|
* - 'token-bucket': Best for handling traffic bursts
|
|
15
14
|
* - 'sliding-window': Best for smooth rate limiting
|
|
16
15
|
* - 'fixed-window': Simplest approach
|
|
17
|
-
* @param {string} [options.store='memory'] - Storage backend to use:
|
|
18
|
-
* - 'memory': In-memory storage (default)
|
|
19
|
-
* - 'redis': Redis
|
|
16
|
+
* @param {string|Object} [options.store='memory'] - Storage backend to use:
|
|
17
|
+
* - 'memory': In-memory storage (default, single-instance only)
|
|
18
|
+
* - { type: 'redis', url: 'redis://...' }: Redis storage for distributed deployments.
|
|
19
|
+
* The redis npm package is auto-installed on first use.
|
|
20
|
+
* Any extra properties are forwarded to node-redis createClient.
|
|
20
21
|
* @param {Object} [options.algorithmOptions] - Algorithm-specific options
|
|
21
22
|
* @param {Function} [options.keyGenerator] - Optional function to generate unique identifiers
|
|
22
23
|
* @param {Object} [options.headerFormat] - Rate limit header format configuration
|
|
@@ -39,36 +40,32 @@ function rateLimiter(options) {
|
|
|
39
40
|
...limiterOptions
|
|
40
41
|
} = options;
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const configMap = Object.create(null);
|
|
44
|
+
configMap['token-bucket'] = 'tokenBucketConfig';
|
|
45
|
+
configMap['sliding-window'] = 'slidingWindowConfig';
|
|
46
|
+
configMap['fixed-window'] = 'fixedWindowConfig';
|
|
47
|
+
|
|
48
|
+
const configKey = configMap[algorithm];
|
|
49
|
+
if (!configKey) {
|
|
44
50
|
throw new TejError(
|
|
45
51
|
400,
|
|
46
|
-
|
|
52
|
+
`Invalid algorithm: ${algorithm}. Must be one of: ${Object.keys(configMap).join(', ')}`,
|
|
47
53
|
);
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
const configMap = {
|
|
52
|
-
'token-bucket': 'tokenBucketConfig',
|
|
53
|
-
'sliding-window': 'slidingWindowConfig',
|
|
54
|
-
'fixed-window': 'fixedWindowConfig',
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const configKey = configMap[algorithm];
|
|
58
|
-
if (!configKey) {
|
|
56
|
+
if (typeof store === 'object' && store.type === 'redis' && !store.url) {
|
|
59
57
|
throw new TejError(
|
|
60
58
|
400,
|
|
61
|
-
`
|
|
59
|
+
`Redis store requires a url. Provide store: { type: "redis", url: "redis://..." }`,
|
|
62
60
|
);
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
const limiterConfig = {
|
|
63
|
+
const limiterConfig = Object.freeze({
|
|
67
64
|
maxRequests: limiterOptions.maxRequests,
|
|
68
65
|
timeWindowSeconds: limiterOptions.timeWindowSeconds,
|
|
69
66
|
[configKey]: limiterOptions.algorithmOptions || {},
|
|
70
|
-
store,
|
|
71
|
-
};
|
|
67
|
+
store,
|
|
68
|
+
});
|
|
72
69
|
|
|
73
70
|
// Create the appropriate limiter instance
|
|
74
71
|
let limiter;
|
|
@@ -128,7 +125,7 @@ function rateLimiter(options) {
|
|
|
128
125
|
|
|
129
126
|
// Return middleware function
|
|
130
127
|
return async (ammo, next) => {
|
|
131
|
-
const key = keyGenerator(ammo);
|
|
128
|
+
const key = keyGenerator(ammo) ?? ammo.ip ?? 'unknown';
|
|
132
129
|
const result = await limiter.consume(key);
|
|
133
130
|
|
|
134
131
|
setRateLimitHeaders(ammo, result);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for the rate limiter middleware factory.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import rateLimiter from './index.js';
|
|
6
|
+
|
|
7
|
+
function makeAmmo(ip = '127.0.0.1') {
|
|
8
|
+
return {
|
|
9
|
+
ip,
|
|
10
|
+
res: {
|
|
11
|
+
setHeader: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
throw: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('rateLimiter', () => {
|
|
18
|
+
it('should throw on invalid algorithm', () => {
|
|
19
|
+
expect(() =>
|
|
20
|
+
rateLimiter({
|
|
21
|
+
maxRequests: 10,
|
|
22
|
+
timeWindowSeconds: 60,
|
|
23
|
+
algorithm: 'invalid-algo',
|
|
24
|
+
}),
|
|
25
|
+
).toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return a middleware function', () => {
|
|
29
|
+
const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
|
|
30
|
+
expect(typeof mw).toBe('function');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should call next when under limit', async () => {
|
|
34
|
+
const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
|
|
35
|
+
const ammo = makeAmmo();
|
|
36
|
+
const next = vi.fn().mockResolvedValue(undefined);
|
|
37
|
+
await mw(ammo, next);
|
|
38
|
+
expect(next).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should use fallback key "unknown" when ammo.ip is undefined', async () => {
|
|
42
|
+
const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
|
|
43
|
+
const ammo = makeAmmo(undefined);
|
|
44
|
+
const next = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
await mw(ammo, next);
|
|
46
|
+
expect(next).toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should throw on invalid store config', () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
rateLimiter({
|
|
52
|
+
maxRequests: 10,
|
|
53
|
+
timeWindowSeconds: 60,
|
|
54
|
+
store: 'postgres',
|
|
55
|
+
}),
|
|
56
|
+
).toThrow(/Invalid store config/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should throw when redis store has no url', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
rateLimiter({
|
|
62
|
+
maxRequests: 10,
|
|
63
|
+
timeWindowSeconds: 60,
|
|
64
|
+
store: { type: 'redis' },
|
|
65
|
+
}),
|
|
66
|
+
).toThrow(/requires a url/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should accept redis store config without throwing on creation', () => {
|
|
70
|
+
vi.mock('./storage/redis.js', () => ({
|
|
71
|
+
default: class MockRedisStorage {
|
|
72
|
+
async get() {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
async set() {}
|
|
76
|
+
async increment() {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
async delete() {}
|
|
80
|
+
},
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
expect(() =>
|
|
84
|
+
rateLimiter({
|
|
85
|
+
maxRequests: 10,
|
|
86
|
+
timeWindowSeconds: 60,
|
|
87
|
+
store: { type: 'redis', url: 'redis://localhost:6379' },
|
|
88
|
+
}),
|
|
89
|
+
).not.toThrow();
|
|
90
|
+
|
|
91
|
+
vi.restoreAllMocks();
|
|
92
|
+
});
|
|
93
|
+
});
|