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.
Files changed (55) hide show
  1. package/README.md +1 -12
  2. package/auto-docs/analysis/handler-analyzer.test.js +106 -0
  3. package/auto-docs/analysis/source-resolver.test.js +58 -0
  4. package/auto-docs/constants.js +13 -2
  5. package/auto-docs/openapi/generator.js +7 -5
  6. package/auto-docs/openapi/generator.test.js +132 -0
  7. package/auto-docs/openapi/spec-builders.js +39 -19
  8. package/cli/docs-command.js +44 -36
  9. package/cors/index.test.js +82 -0
  10. package/docs/README.md +1 -2
  11. package/docs/api-reference.md +124 -186
  12. package/docs/configuration.md +0 -13
  13. package/docs/getting-started.md +19 -21
  14. package/docs/rate-limiting.md +59 -58
  15. package/lib/llm/client.js +7 -2
  16. package/lib/llm/index.js +14 -1
  17. package/lib/llm/parse.test.js +60 -0
  18. package/package.json +3 -1
  19. package/radar/index.js +382 -0
  20. package/rate-limit/base.js +12 -15
  21. package/rate-limit/index.js +19 -22
  22. package/rate-limit/index.test.js +93 -0
  23. package/rate-limit/storage/memory.js +13 -13
  24. package/rate-limit/storage/redis-install.js +70 -0
  25. package/rate-limit/storage/redis.js +94 -52
  26. package/server/ammo/body-parser.js +156 -152
  27. package/server/ammo/body-parser.test.js +79 -0
  28. package/server/ammo/enhancer.js +8 -4
  29. package/server/ammo.js +138 -12
  30. package/server/context/request-context.js +51 -0
  31. package/server/context/request-context.test.js +53 -0
  32. package/server/endpoint.js +15 -0
  33. package/server/error.js +56 -3
  34. package/server/error.test.js +45 -0
  35. package/server/errors/channels/channels.test.js +148 -0
  36. package/server/errors/channels/index.js +1 -1
  37. package/server/errors/llm-cache.js +1 -1
  38. package/server/errors/llm-cache.test.js +160 -0
  39. package/server/errors/llm-error-service.js +1 -1
  40. package/server/errors/llm-rate-limiter.test.js +105 -0
  41. package/server/files/uploader.js +38 -26
  42. package/server/handler.js +1 -1
  43. package/server/targets/registry.js +3 -3
  44. package/server/targets/registry.test.js +108 -0
  45. package/te.js +233 -183
  46. package/utils/auto-register.js +1 -1
  47. package/utils/configuration.js +23 -9
  48. package/utils/configuration.test.js +58 -0
  49. package/utils/errors-llm-config.js +74 -8
  50. package/utils/request-logger.js +49 -3
  51. package/utils/startup.js +80 -0
  52. package/database/index.js +0 -165
  53. package/database/mongodb.js +0 -146
  54. package/database/redis.js +0 -201
  55. package/docs/database.md +0 -390
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { LLMErrorCache, getCache } from './llm-cache.js';
3
+
4
+ describe('LLMErrorCache', () => {
5
+ describe('constructor', () => {
6
+ it('uses provided ttl', () => {
7
+ const cache = new LLMErrorCache(5000);
8
+ expect(cache.ttl).toBe(5000);
9
+ });
10
+
11
+ it('defaults to 3600000 for invalid ttl', () => {
12
+ expect(new LLMErrorCache(0).ttl).toBe(3_600_000);
13
+ expect(new LLMErrorCache(-1).ttl).toBe(3_600_000);
14
+ });
15
+ });
16
+
17
+ describe('buildKey()', () => {
18
+ it('builds key from first snippet file, line, and error message', () => {
19
+ const cache = new LLMErrorCache(1000);
20
+ const codeContext = {
21
+ snippets: [{ file: '/app/routes/users.js', line: 42 }],
22
+ };
23
+ const error = new Error('User not found');
24
+ const key = cache.buildKey(codeContext, error);
25
+ expect(key).toBe('/app/routes/users.js:42:User not found');
26
+ });
27
+
28
+ it('handles string error', () => {
29
+ const cache = new LLMErrorCache(1000);
30
+ const codeContext = { snippets: [{ file: '/app/handler.js', line: 10 }] };
31
+ const key = cache.buildKey(codeContext, 'Validation failed');
32
+ expect(key).toBe('/app/handler.js:10:Validation failed');
33
+ });
34
+
35
+ it('handles no error (empty string suffix)', () => {
36
+ const cache = new LLMErrorCache(1000);
37
+ const codeContext = { snippets: [{ file: '/app/handler.js', line: 5 }] };
38
+ const key = cache.buildKey(codeContext, undefined);
39
+ expect(key).toBe('/app/handler.js:5:');
40
+ });
41
+
42
+ it('uses "unknown" when codeContext has no snippets', () => {
43
+ const cache = new LLMErrorCache(1000);
44
+ const key = cache.buildKey({ snippets: [] }, new Error('oops'));
45
+ expect(key).toBe('unknown:oops');
46
+ });
47
+
48
+ it('uses "unknown" when codeContext is missing', () => {
49
+ const cache = new LLMErrorCache(1000);
50
+ const key = cache.buildKey(null, new Error('oops'));
51
+ expect(key).toBe('unknown:oops');
52
+ });
53
+ });
54
+
55
+ describe('set() and get()', () => {
56
+ it('stores and retrieves a result', () => {
57
+ const cache = new LLMErrorCache(10_000);
58
+ cache.set('key1', { statusCode: 404, message: 'Not found' });
59
+ const result = cache.get('key1');
60
+ expect(result).toEqual({ statusCode: 404, message: 'Not found' });
61
+ });
62
+
63
+ it('returns null for missing keys', () => {
64
+ const cache = new LLMErrorCache(10_000);
65
+ expect(cache.get('nonexistent')).toBeNull();
66
+ });
67
+
68
+ it('does not return cachedAt in the result', () => {
69
+ const cache = new LLMErrorCache(10_000);
70
+ cache.set('key1', { statusCode: 500, message: 'Error' });
71
+ const result = cache.get('key1');
72
+ expect(result).not.toHaveProperty('cachedAt');
73
+ });
74
+
75
+ it('includes devInsight when stored', () => {
76
+ const cache = new LLMErrorCache(10_000);
77
+ cache.set('key1', {
78
+ statusCode: 404,
79
+ message: 'Not found',
80
+ devInsight: 'Check the ID param.',
81
+ });
82
+ const result = cache.get('key1');
83
+ expect(result.devInsight).toBe('Check the ID param.');
84
+ });
85
+ });
86
+
87
+ describe('TTL expiry', () => {
88
+ it('returns null for expired entries', () => {
89
+ vi.useFakeTimers();
90
+
91
+ const cache = new LLMErrorCache(1000);
92
+ cache.set('key1', { statusCode: 404, message: 'Not found' });
93
+ expect(cache.get('key1')).not.toBeNull();
94
+
95
+ vi.advanceTimersByTime(1001);
96
+
97
+ expect(cache.get('key1')).toBeNull();
98
+
99
+ vi.useRealTimers();
100
+ });
101
+
102
+ it('removes expired entry from the store on access', () => {
103
+ vi.useFakeTimers();
104
+
105
+ const cache = new LLMErrorCache(500);
106
+ cache.set('key1', { statusCode: 500, message: 'Error' });
107
+ expect(cache.size).toBe(1);
108
+
109
+ vi.advanceTimersByTime(600);
110
+ cache.get('key1');
111
+
112
+ expect(cache.size).toBe(0);
113
+
114
+ vi.useRealTimers();
115
+ });
116
+
117
+ it('non-expired entries remain accessible', () => {
118
+ vi.useFakeTimers();
119
+
120
+ const cache = new LLMErrorCache(5000);
121
+ cache.set('key1', { statusCode: 200, message: 'OK' });
122
+
123
+ vi.advanceTimersByTime(4999);
124
+
125
+ expect(cache.get('key1')).not.toBeNull();
126
+
127
+ vi.useRealTimers();
128
+ });
129
+ });
130
+
131
+ describe('size', () => {
132
+ it('tracks the number of entries', () => {
133
+ const cache = new LLMErrorCache(10_000);
134
+ expect(cache.size).toBe(0);
135
+ cache.set('a', { statusCode: 200, message: 'OK' });
136
+ cache.set('b', { statusCode: 404, message: 'Not found' });
137
+ expect(cache.size).toBe(2);
138
+ });
139
+ });
140
+ });
141
+
142
+ describe('getCache (singleton)', () => {
143
+ it('returns a LLMErrorCache instance', () => {
144
+ const cache = getCache(3600000);
145
+ expect(cache).toBeInstanceOf(LLMErrorCache);
146
+ });
147
+
148
+ it('returns same instance for same ttl', () => {
149
+ const a = getCache(3600000);
150
+ const b = getCache(3600000);
151
+ expect(a).toBe(b);
152
+ });
153
+
154
+ it('creates a new instance when ttl changes', () => {
155
+ const a = getCache(1000);
156
+ const b = getCache(2000);
157
+ expect(a).not.toBe(b);
158
+ expect(b.ttl).toBe(2000);
159
+ });
160
+ });
@@ -51,7 +51,7 @@ function buildPrompt(context) {
51
51
 
52
52
  let errorPart = '';
53
53
  if (error !== undefined && error !== null) {
54
- if (error instanceof Error) {
54
+ if (error != null && typeof error.message === 'string') {
55
55
  errorPart = `\nOptional error message (may be empty): ${error.message}`;
56
56
  } else {
57
57
  errorPart = `\nOptional error/message: ${String(error)}`;
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { LLMRateLimiter, getRateLimiter } from './llm-rate-limiter.js';
3
+
4
+ describe('LLMRateLimiter', () => {
5
+ describe('constructor', () => {
6
+ it('uses provided maxPerMinute', () => {
7
+ const limiter = new LLMRateLimiter(5);
8
+ expect(limiter.maxPerMinute).toBe(5);
9
+ });
10
+
11
+ it('defaults to 10 when maxPerMinute is invalid', () => {
12
+ expect(new LLMRateLimiter(0).maxPerMinute).toBe(10);
13
+ expect(new LLMRateLimiter(-1).maxPerMinute).toBe(10);
14
+ });
15
+
16
+ it('floors non-integer values', () => {
17
+ expect(new LLMRateLimiter(4.9).maxPerMinute).toBe(4);
18
+ });
19
+ });
20
+
21
+ describe('canCall() and record()', () => {
22
+ it('allows calls when under the limit', () => {
23
+ const limiter = new LLMRateLimiter(3);
24
+ expect(limiter.canCall()).toBe(true);
25
+ });
26
+
27
+ it('blocks calls when at the limit', () => {
28
+ const limiter = new LLMRateLimiter(2);
29
+ limiter.record();
30
+ limiter.record();
31
+ expect(limiter.canCall()).toBe(false);
32
+ });
33
+
34
+ it('allows calls again after recording up to max', () => {
35
+ const limiter = new LLMRateLimiter(1);
36
+ expect(limiter.canCall()).toBe(true);
37
+ limiter.record();
38
+ expect(limiter.canCall()).toBe(false);
39
+ });
40
+
41
+ it('remaining() returns correct count', () => {
42
+ const limiter = new LLMRateLimiter(3);
43
+ expect(limiter.remaining()).toBe(3);
44
+ limiter.record();
45
+ expect(limiter.remaining()).toBe(2);
46
+ limiter.record();
47
+ expect(limiter.remaining()).toBe(1);
48
+ limiter.record();
49
+ expect(limiter.remaining()).toBe(0);
50
+ });
51
+ });
52
+
53
+ describe('sliding window pruning', () => {
54
+ it('expires old timestamps after 60 seconds', () => {
55
+ vi.useFakeTimers();
56
+
57
+ const limiter = new LLMRateLimiter(2);
58
+ limiter.record();
59
+ limiter.record();
60
+ expect(limiter.canCall()).toBe(false);
61
+
62
+ vi.advanceTimersByTime(61_000);
63
+
64
+ expect(limiter.canCall()).toBe(true);
65
+
66
+ vi.useRealTimers();
67
+ });
68
+
69
+ it('only expires timestamps older than 60 seconds', () => {
70
+ vi.useFakeTimers();
71
+
72
+ const limiter = new LLMRateLimiter(2);
73
+ limiter.record();
74
+
75
+ vi.advanceTimersByTime(50_000);
76
+ limiter.record();
77
+
78
+ vi.advanceTimersByTime(15_000);
79
+ expect(limiter.canCall()).toBe(true);
80
+ expect(limiter.remaining()).toBe(1);
81
+
82
+ vi.useRealTimers();
83
+ });
84
+ });
85
+ });
86
+
87
+ describe('getRateLimiter (singleton)', () => {
88
+ it('returns a LLMRateLimiter instance', () => {
89
+ const limiter = getRateLimiter(10);
90
+ expect(limiter).toBeInstanceOf(LLMRateLimiter);
91
+ });
92
+
93
+ it('returns same instance for same maxPerMinute', () => {
94
+ const a = getRateLimiter(10);
95
+ const b = getRateLimiter(10);
96
+ expect(a).toBe(b);
97
+ });
98
+
99
+ it('creates a new instance when maxPerMinute changes', () => {
100
+ const a = getRateLimiter(5);
101
+ const b = getRateLimiter(15);
102
+ expect(a).not.toBe(b);
103
+ expect(b.maxPerMinute).toBe(15);
104
+ });
105
+ });
@@ -1,5 +1,5 @@
1
1
  import { filesize } from 'filesize';
2
- import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
3
  import TejError from './../error.js';
4
4
  import { extAndType, extract, paths } from './helper.js';
5
5
 
@@ -19,11 +19,11 @@ class TejFileUploader {
19
19
  file() {
20
20
  const keys = [...arguments];
21
21
  return async (ammo, next) => {
22
- if (!ammo.headers['content-type'].startsWith('multipart/form-data'))
22
+ if (!ammo.headers['content-type']?.startsWith('multipart/form-data'))
23
23
  return next();
24
24
 
25
25
  const payload = ammo.payload;
26
- const updatedPayload = {};
26
+ const updatedPayload = Object.create(null);
27
27
 
28
28
  for (const part in payload) {
29
29
  const obj = payload[part];
@@ -43,26 +43,32 @@ class TejFileUploader {
43
43
  if (!filename) continue;
44
44
 
45
45
  const { dir, absolute, relative } = paths(this.destination, filename);
46
- const size = filesize(obj.value.length,
47
- { output: 'object', round: 0 });
48
- const maxSize = filesize(this.maxFileSize,
49
- { output: 'object', round: 0 });
46
+ const size = filesize(obj.value.length, {
47
+ output: 'object',
48
+ round: 0,
49
+ });
50
+ const maxSize = filesize(this.maxFileSize, {
51
+ output: 'object',
52
+ round: 0,
53
+ });
50
54
  if (this.maxFileSize && obj.value.length > this.maxFileSize)
51
- throw new TejError(413,
52
- `File size exceeds ${maxSize.value} ${maxSize.symbol}`);
55
+ throw new TejError(
56
+ 413,
57
+ `File size exceeds ${maxSize.value} ${maxSize.symbol}`,
58
+ );
53
59
 
54
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
55
- fs.writeFileSync(absolute, obj.value, 'binary');
60
+ await fsp.mkdir(dir, { recursive: true });
61
+ await fsp.writeFile(absolute, obj.value, 'binary');
56
62
 
57
63
  updatedPayload[key] = {
58
64
  filename,
59
65
  extension: ext,
60
66
  path: {
61
67
  absolute: absolute,
62
- relative: relative
68
+ relative: relative,
63
69
  },
64
70
  mimetype: type,
65
- size
71
+ size,
66
72
  };
67
73
  }
68
74
  }
@@ -75,11 +81,11 @@ class TejFileUploader {
75
81
  files() {
76
82
  const keys = [...arguments];
77
83
  return async (ammo, next) => {
78
- if (!ammo.headers['content-type'].startsWith('multipart/form-data'))
84
+ if (!ammo.headers['content-type']?.startsWith('multipart/form-data'))
79
85
  return next();
80
86
 
81
87
  const payload = ammo.payload;
82
- const updatedPayload = {};
88
+ const updatedPayload = Object.create(null);
83
89
  const files = [];
84
90
 
85
91
  for (const part in payload) {
@@ -99,27 +105,33 @@ class TejFileUploader {
99
105
  if (!filename) continue;
100
106
 
101
107
  const { dir, absolute, relative } = paths(this.destination, filename);
102
- const size = filesize(obj.value.length,
103
- { output: 'object', round: 0 });
104
- const maxSize = filesize(this.maxFileSize,
105
- { output: 'object', round: 0 });
108
+ const size = filesize(obj.value.length, {
109
+ output: 'object',
110
+ round: 0,
111
+ });
112
+ const maxSize = filesize(this.maxFileSize, {
113
+ output: 'object',
114
+ round: 0,
115
+ });
106
116
  if (this.maxFileSize && obj.value.length > this.maxFileSize) {
107
- throw new TejError(413,
108
- `File size exceeds ${maxSize.value} ${maxSize.symbol}`);
117
+ throw new TejError(
118
+ 413,
119
+ `File size exceeds ${maxSize.value} ${maxSize.symbol}`,
120
+ );
109
121
  }
110
122
 
111
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
112
- fs.writeFileSync(absolute, obj.value, 'binary');
123
+ await fsp.mkdir(dir, { recursive: true });
124
+ await fsp.writeFile(absolute, obj.value, 'binary');
113
125
 
114
126
  files.push({
115
127
  key,
116
128
  filename,
117
129
  path: {
118
130
  absolute: absolute,
119
- relative: relative
131
+ relative: relative,
120
132
  },
121
133
  mimetype: type,
122
- size
134
+ size,
123
135
  });
124
136
  }
125
137
  }
@@ -128,7 +140,7 @@ class TejFileUploader {
128
140
  if (!acc[file.key]) acc[file.key] = [];
129
141
  acc[file.key].push(file);
130
142
  return acc;
131
- }, {});
143
+ }, Object.create(null));
132
144
 
133
145
  for (const key in groupedFilesByKey) {
134
146
  updatedPayload[key] = groupedFilesByKey[key];
package/server/handler.js CHANGED
@@ -140,7 +140,7 @@ const handler = async (req, res) => {
140
140
  return;
141
141
  }
142
142
 
143
- const url = req.url.split('?')[0];
143
+ const url = (req.url ?? '/').split('?')[0] || '/';
144
144
  const match = targetRegistry.aim(url);
145
145
  const ammo = new Ammo(req, res);
146
146
 
@@ -58,7 +58,7 @@ class TargetRegistry {
58
58
  });
59
59
 
60
60
  if (exactMatch) {
61
- return { target: exactMatch, params: {} };
61
+ return { target: exactMatch, params: Object.create(null) };
62
62
  }
63
63
 
64
64
  // Then, try parameterized route matching
@@ -104,7 +104,7 @@ class TargetRegistry {
104
104
  return null;
105
105
  }
106
106
 
107
- const params = {};
107
+ const params = Object.create(null);
108
108
 
109
109
  // Match each segment
110
110
  for (let i = 0; i < patternSegments.length; i++) {
@@ -150,7 +150,7 @@ class TargetRegistry {
150
150
  if (!acc[group]) acc[group] = [];
151
151
  acc[group].push(target.getPath());
152
152
  return acc;
153
- }, {});
153
+ }, Object.create(null));
154
154
  }
155
155
  return this.targets.map((target) => target.getPath());
156
156
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @fileoverview Tests for TargetRegistry routing logic.
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+
6
+ // Use a fresh instance per test by clearing the singleton
7
+ let TargetRegistry;
8
+ let registry;
9
+
10
+ beforeEach(async () => {
11
+ // Reset module cache to get fresh singleton
12
+ TargetRegistry = (await import('./registry.js')).default.constructor;
13
+ // Re-import to use existing singleton, but clear its state
14
+ const mod = await import('./registry.js');
15
+ registry = mod.default;
16
+ registry.targets = [];
17
+ registry.globalMiddlewares = [];
18
+ });
19
+
20
+ describe('TargetRegistry.aim', () => {
21
+ it('should return null for unmatched routes', () => {
22
+ expect(registry.aim('/api/users')).toBeNull();
23
+ });
24
+
25
+ it('should match exact path', () => {
26
+ const mockTarget = {
27
+ getPath: () => '/api/users',
28
+ getMethods: () => null,
29
+ };
30
+ registry.targets.push(mockTarget);
31
+ const result = registry.aim('/api/users');
32
+ expect(result).not.toBeNull();
33
+ expect(result.target).toBe(mockTarget);
34
+ expect(result.params).toBeDefined();
35
+ });
36
+
37
+ it('should match parameterized route and extract params', () => {
38
+ const mockTarget = {
39
+ getPath: () => '/api/users/:id',
40
+ getMethods: () => null,
41
+ };
42
+ registry.targets.push(mockTarget);
43
+ const result = registry.aim('/api/users/42');
44
+ expect(result).not.toBeNull();
45
+ expect(result.params.id).toBe('42');
46
+ });
47
+
48
+ it('should use Object.create(null) for params (no prototype pollution)', () => {
49
+ const mockTarget = {
50
+ getPath: () => '/api/:resource',
51
+ getMethods: () => null,
52
+ };
53
+ registry.targets.push(mockTarget);
54
+ const result = registry.aim('/api/users');
55
+ // Param key is the route parameter name ('resource'), not the URL value
56
+ expect(result.params['resource']).toBe('users');
57
+ // The params object must use null prototype (safe from prototype pollution)
58
+ expect(Object.getPrototypeOf(result.params)).toBeNull();
59
+ });
60
+
61
+ it('should not match routes with different segment counts', () => {
62
+ const mockTarget = {
63
+ getPath: () => '/api/users/:id',
64
+ getMethods: () => null,
65
+ };
66
+ registry.targets.push(mockTarget);
67
+ expect(registry.aim('/api/users')).toBeNull();
68
+ expect(registry.aim('/api/users/42/profile')).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe('TargetRegistry.getAllEndpoints', () => {
73
+ it('should return flat path list by default', () => {
74
+ registry.targets = [
75
+ {
76
+ getPath: () => '/api/users',
77
+ getMetadata: () => null,
78
+ getHandler: () => null,
79
+ },
80
+ {
81
+ getPath: () => '/api/posts',
82
+ getMetadata: () => null,
83
+ getHandler: () => null,
84
+ },
85
+ ];
86
+ expect(registry.getAllEndpoints()).toEqual(['/api/users', '/api/posts']);
87
+ });
88
+
89
+ it('should return grouped object when grouped=true', () => {
90
+ registry.targets = [
91
+ {
92
+ getPath: () => '/api/users',
93
+ getMetadata: () => null,
94
+ getHandler: () => null,
95
+ },
96
+ {
97
+ getPath: () => '/api/posts',
98
+ getMetadata: () => null,
99
+ getHandler: () => null,
100
+ },
101
+ ];
102
+ const grouped = registry.getAllEndpoints(true);
103
+ expect(grouped['api']).toContain('/api/users');
104
+ expect(grouped['api']).toContain('/api/posts');
105
+ // Result must be null-prototype dict
106
+ expect(Object.getPrototypeOf(grouped)).toBeNull();
107
+ });
108
+ });