te.js 2.2.0 → 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.
@@ -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 to use ('memory' or 'redis')
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
- // Initialize storage based on store type
105
- if (this.options.store === 'redis') {
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
 
@@ -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-based storage (requires global Redis config)
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,14 +40,6 @@ function rateLimiter(options) {
39
40
  ...limiterOptions
40
41
  } = options;
41
42
 
42
- // Check Redis connectivity if Redis store is selected
43
- if (store === 'redis' && !dbManager.hasConnection('redis', {})) {
44
- throw new TejError(
45
- 400,
46
- 'Redis store selected but no Redis connection found. Please use withRedis() before using withRateLimit()',
47
- );
48
- }
49
-
50
43
  const configMap = Object.create(null);
51
44
  configMap['token-bucket'] = 'tokenBucketConfig';
52
45
  configMap['sliding-window'] = 'slidingWindowConfig';
@@ -60,6 +53,13 @@ function rateLimiter(options) {
60
53
  );
61
54
  }
62
55
 
56
+ if (typeof store === 'object' && store.type === 'redis' && !store.url) {
57
+ throw new TejError(
58
+ 400,
59
+ `Redis store requires a url. Provide store: { type: "redis", url: "redis://..." }`,
60
+ );
61
+ }
62
+
63
63
  const limiterConfig = Object.freeze({
64
64
  maxRequests: limiterOptions.maxRequests,
65
65
  timeWindowSeconds: limiterOptions.timeWindowSeconds,
@@ -4,16 +4,7 @@
4
4
  import { describe, it, expect, vi } from 'vitest';
5
5
  import rateLimiter from './index.js';
6
6
 
7
- // Mock dbManager.hasConnection so we can test without a real DB
8
- vi.mock('../database/index.js', () => ({
9
- default: {
10
- hasConnection: vi.fn(() => false),
11
- initializeConnection: vi.fn(),
12
- },
13
- }));
14
-
15
7
  function makeAmmo(ip = '127.0.0.1') {
16
- const headers = {};
17
8
  return {
18
9
  ip,
19
10
  res: {
@@ -24,13 +15,6 @@ function makeAmmo(ip = '127.0.0.1') {
24
15
  }
25
16
 
26
17
  describe('rateLimiter', () => {
27
- it('should throw TejError when redis store selected but no connection', async () => {
28
- const TejError = (await import('../server/error.js')).default;
29
- expect(() =>
30
- rateLimiter({ maxRequests: 10, timeWindowSeconds: 60, store: 'redis' }),
31
- ).toThrow();
32
- });
33
-
34
18
  it('should throw on invalid algorithm', () => {
35
19
  expect(() =>
36
20
  rateLimiter({
@@ -61,4 +45,49 @@ describe('rateLimiter', () => {
61
45
  await mw(ammo, next);
62
46
  expect(next).toHaveBeenCalled();
63
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
+ });
64
93
  });
@@ -2,25 +2,25 @@ import RateLimitStorage from './base.js';
2
2
 
3
3
  /**
4
4
  * In-memory storage implementation for rate limiting
5
- *
5
+ *
6
6
  * @extends RateLimitStorage
7
7
  * @description
8
8
  * This storage backend uses a JavaScript Map to store rate limit data in memory.
9
- * It's suitable for single-instance applications or testing environments, but not
9
+ * It's suitable for single-instance applications or testing environments, but not
10
10
  * recommended for production use in distributed systems as data is not shared
11
11
  * between instances.
12
- *
12
+ *
13
13
  * Key features:
14
14
  * - Fast access (all data in memory)
15
15
  * - Automatic cleanup of expired entries
16
16
  * - No external dependencies
17
17
  * - Data is lost on process restart
18
18
  * - Not suitable for distributed systems
19
- *
19
+ *
20
20
  * @example
21
21
  * import { TokenBucketRateLimiter, MemoryStorage } from 'te.js/rate-limit';
22
- *
23
- * // Memory storage is used by default if no redis config is provided
22
+ *
23
+ * // Memory storage is the default storage backend
24
24
  * const limiter = new TokenBucketRateLimiter({
25
25
  * maxRequests: 60,
26
26
  * timeWindowSeconds: 60,
@@ -29,7 +29,7 @@ import RateLimitStorage from './base.js';
29
29
  * burstSize: 60
30
30
  * }
31
31
  * });
32
- *
32
+ *
33
33
  * // Or create storage instance explicitly
34
34
  * const storage = new MemoryStorage();
35
35
  * await storage.set('key', { counter: 5 }, 60); // Store for 60 seconds
@@ -45,7 +45,7 @@ class MemoryStorage extends RateLimitStorage {
45
45
 
46
46
  /**
47
47
  * Get stored data for a key, handling expiration
48
- *
48
+ *
49
49
  * @param {string} key - The storage key to retrieve data for
50
50
  * @returns {Promise<Object|null>} The stored data, or null if not found or expired
51
51
  */
@@ -61,7 +61,7 @@ class MemoryStorage extends RateLimitStorage {
61
61
 
62
62
  /**
63
63
  * Store data with expiration time
64
- *
64
+ *
65
65
  * @param {string} key - The storage key
66
66
  * @param {Object} value - The data to store
67
67
  * @param {number} ttl - Time-to-live in seconds
@@ -70,13 +70,13 @@ class MemoryStorage extends RateLimitStorage {
70
70
  async set(key, value, ttl) {
71
71
  this.store.set(key, {
72
72
  value,
73
- expireAt: Date.now() + ttl * 1000
73
+ expireAt: Date.now() + ttl * 1000,
74
74
  });
75
75
  }
76
76
 
77
77
  /**
78
78
  * Increment a numeric value in storage
79
- *
79
+ *
80
80
  * @param {string} key - The storage key to increment
81
81
  * @returns {Promise<number|null>} New value after increment, or null if key not found/expired
82
82
  */
@@ -90,7 +90,7 @@ class MemoryStorage extends RateLimitStorage {
90
90
 
91
91
  /**
92
92
  * Delete data for a key
93
- *
93
+ *
94
94
  * @param {string} key - The storage key to delete
95
95
  * @returns {Promise<void>}
96
96
  */
@@ -99,4 +99,4 @@ class MemoryStorage extends RateLimitStorage {
99
99
  }
100
100
  }
101
101
 
102
- export default MemoryStorage;
102
+ export default MemoryStorage;
@@ -0,0 +1,70 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import TejLogger from 'tej-logger';
5
+
6
+ const logger = new TejLogger('RedisAutoInstall');
7
+
8
+ /**
9
+ * Checks whether the `redis` npm package is available in the consuming
10
+ * project's package.json and node_modules.
11
+ *
12
+ * @returns {{ needsInstall: boolean, reason: string }}
13
+ */
14
+ export function checkRedisInstallation() {
15
+ const cwd = process.cwd();
16
+
17
+ const pkgPath = join(cwd, 'package.json');
18
+ if (!existsSync(pkgPath)) {
19
+ return {
20
+ needsInstall: true,
21
+ reason: 'No package.json found in project root',
22
+ };
23
+ }
24
+
25
+ let pkg;
26
+ try {
27
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
28
+ } catch {
29
+ return { needsInstall: true, reason: 'Unable to read package.json' };
30
+ }
31
+
32
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
33
+ const inPkgJson = 'redis' in deps;
34
+
35
+ const modulePath = join(cwd, 'node_modules', 'redis');
36
+ const inNodeModules = existsSync(modulePath);
37
+
38
+ if (inPkgJson && inNodeModules) {
39
+ return { needsInstall: false, reason: 'redis is already installed' };
40
+ }
41
+
42
+ if (inPkgJson && !inNodeModules) {
43
+ return {
44
+ needsInstall: true,
45
+ reason: 'redis is in package.json but not installed',
46
+ };
47
+ }
48
+
49
+ return { needsInstall: true, reason: 'redis is not in package.json' };
50
+ }
51
+
52
+ /**
53
+ * Synchronously installs the `redis` npm package into the consuming project.
54
+ * Throws if the installation fails.
55
+ */
56
+ export function installRedisSync() {
57
+ logger.info('Installing redis package …');
58
+ try {
59
+ execSync('npm install redis', {
60
+ cwd: process.cwd(),
61
+ stdio: 'pipe',
62
+ timeout: 60_000,
63
+ });
64
+ logger.info('redis package installed successfully.');
65
+ } catch (err) {
66
+ throw new Error(
67
+ `Failed to auto-install redis package. Install it manually with: npm install redis\n${err.message}`,
68
+ );
69
+ }
70
+ }
@@ -1,87 +1,129 @@
1
1
  import RateLimitStorage from './base.js';
2
+ import { checkRedisInstallation, installRedisSync } from './redis-install.js';
3
+ import TejLogger from 'tej-logger';
4
+
5
+ const logger = new TejLogger('RedisStorage');
2
6
 
3
7
  /**
4
- * Redis storage implementation for rate limiting
5
- *
6
- * @extends RateLimitStorage
7
- * @description
8
- * This storage backend uses Redis for distributed rate limiting across multiple application instances.
9
- * It's the recommended storage backend for production use in distributed systems as it provides
10
- * reliable rate limiting across all application instances.
8
+ * Redis-backed storage implementation for rate limiting.
11
9
  *
12
- * Key features:
13
- * - Distributed rate limiting (works across multiple app instances)
14
- * - Atomic operations for race condition prevention
15
- * - Automatic key expiration using Redis TTL
16
- * - Persistence options available through Redis configuration
17
- * - Clustering support for high availability
10
+ * Suitable for distributed / multi-instance deployments where rate-limit
11
+ * counters must be shared across processes.
18
12
  *
19
- * @example
20
- * import { TokenBucketRateLimiter } from 'te.js/rate-limit';
13
+ * The `redis` npm package is auto-installed into the consuming project
14
+ * on first use if it is not already present.
21
15
  *
22
- * // Use Redis storage for distributed rate limiting
23
- * const limiter = new TokenBucketRateLimiter({
24
- * maxRequests: 100,
25
- * timeWindowSeconds: 60,
26
- * store: 'redis', // Use Redis storage
27
- * tokenBucketConfig: {
28
- * refillRate: 2,
29
- * burstSize: 100
30
- * }
31
- * });
16
+ * @extends RateLimitStorage
32
17
  */
33
18
  class RedisStorage extends RateLimitStorage {
34
19
  /**
35
- * Initialize Redis storage with client
36
- *
37
- * @param {RedisClient} client - Connected Redis client instance
20
+ * @param {Object} config - Redis connection configuration
21
+ * @param {string} config.url - Redis connection URL (required)
22
+ * Remaining properties are forwarded to the node-redis `createClient` call.
38
23
  */
39
- constructor(client) {
24
+ constructor(config) {
40
25
  super();
41
- this.client = client;
26
+
27
+ if (!config?.url) {
28
+ throw new Error(
29
+ 'RedisStorage requires a url. Provide store: { type: "redis", url: "redis://..." }',
30
+ );
31
+ }
32
+
33
+ const { type: _type, url, ...redisOptions } = config;
34
+ this._url = url;
35
+ this._redisOptions = redisOptions;
36
+
37
+ this._client = null;
38
+ this._connectPromise = this._init();
39
+ }
40
+
41
+ /** Bootstrap: ensure redis is installed, create client, connect. */
42
+ async _init() {
43
+ const { needsInstall } = checkRedisInstallation();
44
+ if (needsInstall) {
45
+ installRedisSync();
46
+ }
47
+
48
+ // Dynamic import so the module is only resolved after auto-install
49
+ const { createClient } = await import('redis');
50
+
51
+ this._client = createClient({ url: this._url, ...this._redisOptions });
52
+
53
+ this._client.on('error', (err) => {
54
+ logger.error(`Redis client error: ${err.message}`);
55
+ });
56
+
57
+ await this._client.connect();
58
+ logger.info(`Connected to Redis at ${this._url}`);
59
+ }
60
+
61
+ /** Wait until the client is ready before any operation. */
62
+ async _ready() {
63
+ await this._connectPromise;
64
+ return this._client;
42
65
  }
43
66
 
44
67
  /**
45
- * Get stored data for a key
46
- *
47
- * @param {string} key - The storage key to retrieve
48
- * @returns {Promise<Object|null>} Stored value if found, null otherwise
68
+ * @param {string} key
69
+ * @returns {Promise<Object|null>}
49
70
  */
50
71
  async get(key) {
51
- const value = await this.client.get(key);
52
- return value ? JSON.parse(value) : null;
72
+ const client = await this._ready();
73
+ const raw = await client.get(key);
74
+ if (raw === null) return null;
75
+ try {
76
+ return JSON.parse(raw);
77
+ } catch {
78
+ return null;
79
+ }
53
80
  }
54
81
 
55
82
  /**
56
- * Store data with expiration time
57
- *
58
- * @param {string} key - The storage key
59
- * @param {Object} value - The data to store
60
- * @param {number} ttl - Time-to-live in seconds
61
- * @returns {Promise<void>}
83
+ * @param {string} key
84
+ * @param {Object} value
85
+ * @param {number} ttl - seconds
62
86
  */
63
87
  async set(key, value, ttl) {
64
- await this.client.set(key, JSON.stringify(value), { EX: ttl });
88
+ const client = await this._ready();
89
+ const seconds = Math.max(1, Math.ceil(ttl));
90
+ await client.setEx(key, seconds, JSON.stringify(value));
65
91
  }
66
92
 
67
93
  /**
68
- * Increment a counter value atomically
94
+ * Atomically increments the `counter` field inside the stored JSON value.
95
+ * Uses a Lua script to read-modify-write in a single round-trip.
69
96
  *
70
- * @param {string} key - The storage key to increment
71
- * @returns {Promise<number>} New value after increment
97
+ * @param {string} key
98
+ * @returns {Promise<number|null>}
72
99
  */
73
100
  async increment(key) {
74
- return await this.client.incr(key);
101
+ const client = await this._ready();
102
+
103
+ const script = `
104
+ local raw = redis.call('GET', KEYS[1])
105
+ if not raw then return nil end
106
+ local data = cjson.decode(raw)
107
+ data.counter = (data.counter or 0) + 1
108
+ local ttl = redis.call('TTL', KEYS[1])
109
+ if ttl > 0 then
110
+ redis.call('SETEX', KEYS[1], ttl, cjson.encode(data))
111
+ else
112
+ redis.call('SET', KEYS[1], cjson.encode(data))
113
+ end
114
+ return data.counter
115
+ `;
116
+
117
+ const result = await client.eval(script, { keys: [key] });
118
+ return result === null ? null : Number(result);
75
119
  }
76
120
 
77
121
  /**
78
- * Delete data for a key
79
- *
80
- * @param {string} key - The storage key to delete
81
- * @returns {Promise<void>}
122
+ * @param {string} key
82
123
  */
83
124
  async delete(key) {
84
- await this.client.del(key);
125
+ const client = await this._ready();
126
+ await client.del(key);
85
127
  }
86
128
  }
87
129
 
package/server/ammo.js CHANGED
@@ -436,11 +436,12 @@ class Ammo {
436
436
  codeContext: null,
437
437
  };
438
438
 
439
- // Fire-and-forget: capture context, call LLM, dispatch to channel.
439
+ // Run LLM in the background; expose the promise so the Radar middleware
440
+ // can await it before flushing events (ensures LLM data is captured).
440
441
  const method = this.method;
441
442
  const path = this.path;
442
443
  const self = this;
443
- captureCodeContext(stack)
444
+ this._llmPromise = captureCodeContext(stack)
444
445
  .then((codeContext) => {
445
446
  // Update _errorInfo with captured code context
446
447
  if (self._errorInfo) self._errorInfo.codeContext = codeContext;