te.js 2.1.6 → 2.2.0
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/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/database/index.js +3 -1
- package/database/mongodb.js +17 -11
- package/database/redis.js +53 -44
- package/lib/llm/client.js +6 -1
- package/lib/llm/index.js +14 -1
- package/lib/llm/parse.test.js +60 -0
- package/package.json +3 -1
- package/radar/index.js +281 -0
- package/rate-limit/index.js +8 -11
- package/rate-limit/index.test.js +64 -0
- 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 +135 -10
- 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 +178 -49
- 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 +11 -8
- package/utils/request-logger.js +49 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for CORS middleware.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import corsMiddleware from './index.js';
|
|
6
|
+
|
|
7
|
+
function makeAmmo(method = 'GET', origin = 'https://example.com') {
|
|
8
|
+
const headers = {};
|
|
9
|
+
return {
|
|
10
|
+
req: { method, headers: { origin } },
|
|
11
|
+
res: {
|
|
12
|
+
_headers: {},
|
|
13
|
+
setHeader(name, value) {
|
|
14
|
+
this._headers[name.toLowerCase()] = value;
|
|
15
|
+
},
|
|
16
|
+
writeHead(code) {
|
|
17
|
+
this._status = code;
|
|
18
|
+
},
|
|
19
|
+
end() {
|
|
20
|
+
this._ended = true;
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('corsMiddleware', () => {
|
|
27
|
+
it('should set Access-Control-Allow-Origin to * by default', async () => {
|
|
28
|
+
const mw = corsMiddleware();
|
|
29
|
+
const ammo = makeAmmo();
|
|
30
|
+
const next = vi.fn();
|
|
31
|
+
await mw(ammo, next);
|
|
32
|
+
expect(ammo.res._headers['access-control-allow-origin']).toBe('*');
|
|
33
|
+
expect(next).toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should respond 204 and not call next for OPTIONS preflight', async () => {
|
|
37
|
+
const mw = corsMiddleware();
|
|
38
|
+
const ammo = makeAmmo('OPTIONS');
|
|
39
|
+
const next = vi.fn();
|
|
40
|
+
await mw(ammo, next);
|
|
41
|
+
expect(ammo.res._status).toBe(204);
|
|
42
|
+
expect(ammo.res._ended).toBe(true);
|
|
43
|
+
expect(next).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should allow specific origin from array', async () => {
|
|
47
|
+
const mw = corsMiddleware({ origin: ['https://example.com'] });
|
|
48
|
+
const ammo = makeAmmo('GET', 'https://example.com');
|
|
49
|
+
const next = vi.fn();
|
|
50
|
+
await mw(ammo, next);
|
|
51
|
+
expect(ammo.res._headers['access-control-allow-origin']).toBe(
|
|
52
|
+
'https://example.com',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should block origins not in array', async () => {
|
|
57
|
+
const mw = corsMiddleware({ origin: ['https://example.com'] });
|
|
58
|
+
const ammo = makeAmmo('GET', 'https://evil.com');
|
|
59
|
+
const next = vi.fn();
|
|
60
|
+
await mw(ammo, next);
|
|
61
|
+
expect(ammo.res._headers['access-control-allow-origin']).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should set Access-Control-Max-Age when maxAge provided', async () => {
|
|
65
|
+
const mw = corsMiddleware({ maxAge: 86400 });
|
|
66
|
+
const ammo = makeAmmo();
|
|
67
|
+
const next = vi.fn();
|
|
68
|
+
await mw(ammo, next);
|
|
69
|
+
expect(ammo.res._headers['access-control-max-age']).toBe('86400');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should set Access-Control-Allow-Credentials when credentials=true', async () => {
|
|
73
|
+
const mw = corsMiddleware({
|
|
74
|
+
credentials: true,
|
|
75
|
+
origin: 'https://example.com',
|
|
76
|
+
});
|
|
77
|
+
const ammo = makeAmmo('GET', 'https://example.com');
|
|
78
|
+
const next = vi.fn();
|
|
79
|
+
await mw(ammo, next);
|
|
80
|
+
expect(ammo.res._headers['access-control-allow-credentials']).toBe('true');
|
|
81
|
+
});
|
|
82
|
+
});
|
package/database/index.js
CHANGED
|
@@ -15,7 +15,9 @@ class DatabaseManager {
|
|
|
15
15
|
|
|
16
16
|
// Helper method for sleeping
|
|
17
17
|
async #sleep(ms) {
|
|
18
|
-
|
|
18
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
19
|
+
setTimeout(resolve, ms);
|
|
20
|
+
return promise;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
constructor() {
|
package/database/mongodb.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import TejLogger from 'tej-logger';
|
|
6
6
|
import TejError from '../server/error.js';
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@ const __dirname = path.dirname(__filename);
|
|
|
10
10
|
|
|
11
11
|
const logger = new TejLogger('MongoDBConnectionManager');
|
|
12
12
|
|
|
13
|
-
function checkMongooseInstallation() {
|
|
13
|
+
async function checkMongooseInstallation() {
|
|
14
14
|
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
|
15
15
|
const nodeModulesPath = path.join(
|
|
16
16
|
__dirname,
|
|
@@ -20,12 +20,18 @@ function checkMongooseInstallation() {
|
|
|
20
20
|
);
|
|
21
21
|
|
|
22
22
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const packageJson = JSON.parse(
|
|
24
|
+
await fs.promises.readFile(packageJsonPath, 'utf8'),
|
|
25
|
+
);
|
|
25
26
|
const inPackageJson = !!packageJson.dependencies?.mongoose;
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
let inNodeModules = false;
|
|
29
|
+
try {
|
|
30
|
+
await fs.promises.access(nodeModulesPath);
|
|
31
|
+
inNodeModules = true;
|
|
32
|
+
} catch {
|
|
33
|
+
inNodeModules = false;
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
return {
|
|
31
37
|
needsInstall: !inPackageJson || !inNodeModules,
|
|
@@ -94,7 +100,7 @@ function installMongooseSync() {
|
|
|
94
100
|
* @returns {Promise<mongoose.Connection>} Mongoose connection instance
|
|
95
101
|
*/
|
|
96
102
|
async function createConnection(config) {
|
|
97
|
-
const { needsInstall } = checkMongooseInstallation();
|
|
103
|
+
const { needsInstall } = await checkMongooseInstallation();
|
|
98
104
|
|
|
99
105
|
if (needsInstall) {
|
|
100
106
|
const installed = installMongooseSync();
|
|
@@ -106,7 +112,7 @@ async function createConnection(config) {
|
|
|
106
112
|
const { uri, options = {} } = config;
|
|
107
113
|
|
|
108
114
|
try {
|
|
109
|
-
const mongoose = await import('mongoose')
|
|
115
|
+
const { default: mongoose } = await import('mongoose');
|
|
110
116
|
const connection = await mongoose.createConnection(uri, options);
|
|
111
117
|
|
|
112
118
|
connection.on('error', (err) =>
|
package/database/redis.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import path from 'path';
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
4
|
import TejError from '../server/error.js';
|
|
5
5
|
import TejLogger from 'tej-logger';
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
@@ -10,14 +10,20 @@ const packagePath = `${process.cwd()}/node_modules/redis/dist/index.js`;
|
|
|
10
10
|
|
|
11
11
|
const logger = new TejLogger('RedisConnectionManager');
|
|
12
12
|
|
|
13
|
-
function checkRedisInstallation() {
|
|
13
|
+
async function checkRedisInstallation() {
|
|
14
14
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const packageJson = JSON.parse(
|
|
16
|
+
await fs.promises.readFile(packageJsonPath, 'utf8'),
|
|
17
|
+
);
|
|
17
18
|
const inPackageJson = !!packageJson.dependencies?.redis;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
let inNodeModules = false;
|
|
21
|
+
try {
|
|
22
|
+
await fs.promises.access(packagePath);
|
|
23
|
+
inNodeModules = true;
|
|
24
|
+
} catch {
|
|
25
|
+
inNodeModules = false;
|
|
26
|
+
}
|
|
21
27
|
|
|
22
28
|
return {
|
|
23
29
|
needsInstall: !inPackageJson || !inNodeModules,
|
|
@@ -87,7 +93,7 @@ function installRedisSync() {
|
|
|
87
93
|
* @returns {Promise<RedisClient|RedisCluster>} Redis client or cluster instance
|
|
88
94
|
*/
|
|
89
95
|
async function createConnection(config) {
|
|
90
|
-
const { needsInstall } = checkRedisInstallation();
|
|
96
|
+
const { needsInstall } = await checkRedisInstallation();
|
|
91
97
|
|
|
92
98
|
if (needsInstall) {
|
|
93
99
|
const installed = installRedisSync();
|
|
@@ -119,46 +125,49 @@ async function createConnection(config) {
|
|
|
119
125
|
let connectionAttempts = 0;
|
|
120
126
|
const maxRetries = options.maxRetries || 3;
|
|
121
127
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
reject(new TejError(500, 'Redis connection timeout'));
|
|
128
|
-
}
|
|
129
|
-
}, options.connectTimeout || 10000);
|
|
130
|
-
|
|
131
|
-
client.on('error', (err) => {
|
|
132
|
-
logger.error(`Redis connection error: ${err}`, true);
|
|
133
|
-
if (!hasConnected && connectionAttempts >= maxRetries) {
|
|
134
|
-
clearTimeout(connectionTimeout);
|
|
135
|
-
client.quit().catch(() => {});
|
|
136
|
-
reject(
|
|
137
|
-
new TejError(
|
|
138
|
-
500,
|
|
139
|
-
`Redis connection failed after ${maxRetries} attempts: ${err.message}`,
|
|
140
|
-
),
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
connectionAttempts++;
|
|
144
|
-
});
|
|
128
|
+
const {
|
|
129
|
+
promise: connectionPromise,
|
|
130
|
+
resolve: resolveConnection,
|
|
131
|
+
reject: rejectConnection,
|
|
132
|
+
} = Promise.withResolvers();
|
|
145
133
|
|
|
146
|
-
|
|
147
|
-
|
|
134
|
+
connectionTimeout = setTimeout(() => {
|
|
135
|
+
if (!hasConnected) {
|
|
136
|
+
client.quit().catch(() => {});
|
|
137
|
+
rejectConnection(new TejError(500, 'Redis connection timeout'));
|
|
138
|
+
}
|
|
139
|
+
}, options.connectTimeout || 10000);
|
|
140
|
+
|
|
141
|
+
client.on('error', (err) => {
|
|
142
|
+
logger.error(`Redis connection error: ${err}`, true);
|
|
143
|
+
if (!hasConnected && connectionAttempts >= maxRetries) {
|
|
148
144
|
clearTimeout(connectionTimeout);
|
|
149
|
-
|
|
150
|
-
|
|
145
|
+
client.quit().catch(() => {});
|
|
146
|
+
rejectConnection(
|
|
147
|
+
new TejError(
|
|
148
|
+
500,
|
|
149
|
+
`Redis connection failed after ${maxRetries} attempts: ${err.message}`,
|
|
150
|
+
),
|
|
151
151
|
);
|
|
152
|
-
}
|
|
152
|
+
}
|
|
153
|
+
connectionAttempts++;
|
|
154
|
+
});
|
|
153
155
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
client.on('connect', () => {
|
|
157
|
+
hasConnected = true;
|
|
158
|
+
clearTimeout(connectionTimeout);
|
|
159
|
+
logger.info(
|
|
160
|
+
`Redis connected on ${client?.options?.url ?? client?.options?.socket?.host}`,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
158
163
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
client.on('ready', () => {
|
|
165
|
+
logger.info('Redis ready');
|
|
166
|
+
resolveConnection(client);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
client.on('end', () => {
|
|
170
|
+
logger.info('Redis connection closed');
|
|
162
171
|
});
|
|
163
172
|
|
|
164
173
|
await client.connect();
|
package/lib/llm/client.js
CHANGED
|
@@ -20,7 +20,7 @@ class LLMProvider {
|
|
|
20
20
|
typeof options.timeout === 'number' && options.timeout > 0
|
|
21
21
|
? options.timeout
|
|
22
22
|
: DEFAULT_TIMEOUT;
|
|
23
|
-
this.options = options;
|
|
23
|
+
this.options = Object.freeze({ ...options });
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -30,6 +30,11 @@ class LLMProvider {
|
|
|
30
30
|
* @returns {Promise<{ content: string, usage: { prompt_tokens: number, completion_tokens: number, total_tokens: number } }>}
|
|
31
31
|
*/
|
|
32
32
|
async analyze(prompt) {
|
|
33
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
34
|
+
throw new TypeError(
|
|
35
|
+
'LLMProvider.analyze: prompt must be a non-empty string',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
33
38
|
const url = `${this.baseURL}/chat/completions`;
|
|
34
39
|
const headers = {
|
|
35
40
|
'Content-Type': 'application/json',
|
package/lib/llm/index.js
CHANGED
|
@@ -3,5 +3,18 @@
|
|
|
3
3
|
* Used by auto-docs, error-inference, and future LLM features.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* OpenAI-compatible LLM client.
|
|
8
|
+
* @see {@link ./client.js}
|
|
9
|
+
*/
|
|
6
10
|
export { LLMProvider, createProvider } from './client.js';
|
|
7
|
-
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* JSON parsing utilities for LLM responses.
|
|
14
|
+
* @see {@link ./parse.js}
|
|
15
|
+
*/
|
|
16
|
+
export {
|
|
17
|
+
extractJSON,
|
|
18
|
+
extractJSONArray,
|
|
19
|
+
reconcileOrderedTags,
|
|
20
|
+
} from './parse.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for lib/llm parse utilities (extractJSON, extractJSONArray, reconcileOrderedTags).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
extractJSON,
|
|
7
|
+
extractJSONArray,
|
|
8
|
+
reconcileOrderedTags,
|
|
9
|
+
} from './index.js';
|
|
10
|
+
|
|
11
|
+
describe('llm/parse', () => {
|
|
12
|
+
describe('extractJSON', () => {
|
|
13
|
+
it('extracts object from plain JSON string', () => {
|
|
14
|
+
const str = '{"name":"Users","description":"CRUD"}';
|
|
15
|
+
expect(extractJSON(str)).toEqual({ name: 'Users', description: 'CRUD' });
|
|
16
|
+
});
|
|
17
|
+
it('extracts first object from text with markdown', () => {
|
|
18
|
+
const str = 'Here is the result:\n```json\n{"summary":"Get item"}\n```';
|
|
19
|
+
expect(extractJSON(str)).toEqual({ summary: 'Get item' });
|
|
20
|
+
});
|
|
21
|
+
it('returns null for empty or no object', () => {
|
|
22
|
+
expect(extractJSON('')).toBeNull();
|
|
23
|
+
expect(extractJSON('no brace here')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('extractJSONArray', () => {
|
|
28
|
+
it('extracts array from string', () => {
|
|
29
|
+
const str = '["Users", "Auth", "Health"]';
|
|
30
|
+
expect(extractJSONArray(str)).toEqual(['Users', 'Auth', 'Health']);
|
|
31
|
+
});
|
|
32
|
+
it('returns null when no array', () => {
|
|
33
|
+
expect(extractJSONArray('')).toBeNull();
|
|
34
|
+
expect(extractJSONArray('nothing')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('reconcileOrderedTags', () => {
|
|
39
|
+
it('reorders tags by orderedTagNames', () => {
|
|
40
|
+
const tags = [
|
|
41
|
+
{ name: 'Health', description: '...' },
|
|
42
|
+
{ name: 'Users', description: '...' },
|
|
43
|
+
{ name: 'Auth', description: '...' },
|
|
44
|
+
];
|
|
45
|
+
const ordered = reconcileOrderedTags(['Users', 'Auth', 'Health'], tags);
|
|
46
|
+
expect(ordered.map((t) => t.name)).toEqual(['Users', 'Auth', 'Health']);
|
|
47
|
+
});
|
|
48
|
+
it('appends tags not in orderedTagNames', () => {
|
|
49
|
+
const tags = [{ name: 'Users' }, { name: 'Other' }];
|
|
50
|
+
const ordered = reconcileOrderedTags(['Users'], tags);
|
|
51
|
+
expect(ordered.map((t) => t.name)).toEqual(['Users', 'Other']);
|
|
52
|
+
});
|
|
53
|
+
it('returns copy of tags when orderedTagNames empty', () => {
|
|
54
|
+
const tags = [{ name: 'A' }];
|
|
55
|
+
const ordered = reconcileOrderedTags([], tags);
|
|
56
|
+
expect(ordered).toEqual([{ name: 'A' }]);
|
|
57
|
+
expect(ordered).not.toBe(tags);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "te.js",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "AI Native Node.js Framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "te.js",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"license": "ISC",
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^20.12.5",
|
|
21
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
21
22
|
"husky": "^9.0.11",
|
|
22
23
|
"lint-staged": "^15.2.2",
|
|
23
24
|
"prettier": "3.2.5",
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"te.js",
|
|
32
33
|
"cli",
|
|
33
34
|
"cors",
|
|
35
|
+
"radar",
|
|
34
36
|
"server",
|
|
35
37
|
"database",
|
|
36
38
|
"rate-limit",
|