@objectstack/runtime 4.0.0 → 4.0.2
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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +10 -0
- package/dist/index.cjs +198 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -1
- package/dist/index.d.ts +42 -1
- package/dist/index.js +198 -8
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/app-plugin.test.ts +8 -3
- package/src/app-plugin.ts +4 -8
- package/src/dispatcher-plugin.ts +107 -2
- package/src/http-dispatcher.root.test.ts +5 -2
- package/src/http-dispatcher.ts +157 -12
- package/vitest.config.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/runtime",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "ObjectStack Core Runtime & Query Engine",
|
|
6
6
|
"type": "module",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"zod": "^4.3.6",
|
|
18
|
-
"@objectstack/core": "4.0.
|
|
19
|
-
"@objectstack/rest": "4.0.
|
|
20
|
-
"@objectstack/spec": "4.0.
|
|
21
|
-
"@objectstack/types": "4.0.
|
|
18
|
+
"@objectstack/core": "4.0.2",
|
|
19
|
+
"@objectstack/rest": "4.0.2",
|
|
20
|
+
"@objectstack/spec": "4.0.2",
|
|
21
|
+
"@objectstack/types": "4.0.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"typescript": "^6.0.2",
|
package/src/app-plugin.test.ts
CHANGED
|
@@ -49,10 +49,15 @@ describe('AppPlugin', () => {
|
|
|
49
49
|
objects: []
|
|
50
50
|
};
|
|
51
51
|
const plugin = new AppPlugin(bundle);
|
|
52
|
-
|
|
52
|
+
|
|
53
|
+
// Mock the manifest service
|
|
54
|
+
const mockManifestService = { register: vi.fn() };
|
|
55
|
+
vi.mocked(mockContext.getService).mockReturnValue(mockManifestService);
|
|
56
|
+
|
|
53
57
|
await plugin.init(mockContext);
|
|
54
|
-
|
|
55
|
-
expect(mockContext.
|
|
58
|
+
|
|
59
|
+
expect(mockContext.getService).toHaveBeenCalledWith('manifest');
|
|
60
|
+
expect(mockManifestService.register).toHaveBeenCalledWith(bundle);
|
|
56
61
|
});
|
|
57
62
|
|
|
58
63
|
it('start should do nothing if no runtime hooks', async () => {
|
package/src/app-plugin.ts
CHANGED
|
@@ -41,17 +41,13 @@ export class AppPlugin implements Plugin {
|
|
|
41
41
|
version: this.version
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
// Register the app manifest
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
// Merge manifest with the bundle to ensure objects/apps are accessible at root
|
|
49
|
-
// This supports both Legacy Manifests and new Stack Definitions
|
|
50
|
-
const servicePayload = this.bundle.manifest
|
|
44
|
+
// Register the app manifest directly via the manifest service.
|
|
45
|
+
// This immediately decomposes the manifest into SchemaRegistry entries.
|
|
46
|
+
const servicePayload = this.bundle.manifest
|
|
51
47
|
? { ...this.bundle.manifest, ...this.bundle }
|
|
52
48
|
: this.bundle;
|
|
53
49
|
|
|
54
|
-
ctx.
|
|
50
|
+
ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
start = async (ctx: PluginContext) => {
|
package/src/dispatcher-plugin.ts
CHANGED
|
@@ -12,7 +12,19 @@ export interface DispatcherPluginConfig {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* Route definition emitted by service plugins (e.g. AIServicePlugin) via hooks.
|
|
16
|
+
* Minimal interface — matches the shape produced by `buildAIRoutes()`.
|
|
17
|
+
*/
|
|
18
|
+
interface RouteDefinition {
|
|
19
|
+
method: 'GET' | 'POST' | 'DELETE';
|
|
20
|
+
path: string;
|
|
21
|
+
description: string;
|
|
22
|
+
handler: (req: any) => Promise<any>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Send an HttpDispatcherResult through IHttpResponse.
|
|
27
|
+
* Differentiates between handled, unhandled (404), and special results.
|
|
16
28
|
*/
|
|
17
29
|
function sendResult(result: HttpDispatcherResult, res: any): void {
|
|
18
30
|
if (result.handled) {
|
|
@@ -32,7 +44,16 @@ function sendResult(result: HttpDispatcherResult, res: any): void {
|
|
|
32
44
|
return;
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
|
-
|
|
47
|
+
// Semantic 404: no route matched — include diagnostic info
|
|
48
|
+
res.status(404).json({
|
|
49
|
+
success: false,
|
|
50
|
+
error: {
|
|
51
|
+
message: 'Not Found',
|
|
52
|
+
code: 404,
|
|
53
|
+
type: 'ROUTE_NOT_FOUND',
|
|
54
|
+
hint: 'No handler matched this request. Check the API discovery endpoint for available routes.',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
function errorResponse(err: any, res: any): void {
|
|
@@ -96,6 +117,16 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
|
|
|
96
117
|
res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
|
|
97
118
|
});
|
|
98
119
|
|
|
120
|
+
// ── Health ──────────────────────────────────────────────────
|
|
121
|
+
server.get(`${prefix}/health`, async (_req: any, res: any) => {
|
|
122
|
+
try {
|
|
123
|
+
const result = await dispatcher.dispatch('GET', '/health', undefined, {}, { request: _req });
|
|
124
|
+
sendResult(result, res);
|
|
125
|
+
} catch (err: any) {
|
|
126
|
+
errorResponse(err, res);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
99
130
|
// ── Auth ────────────────────────────────────────────────────
|
|
100
131
|
server.post(`${prefix}/auth/login`, async (req: any, res: any) => {
|
|
101
132
|
try {
|
|
@@ -359,6 +390,80 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
|
|
|
359
390
|
});
|
|
360
391
|
|
|
361
392
|
ctx.logger.info('Dispatcher bridge routes registered', { prefix });
|
|
393
|
+
|
|
394
|
+
// ── Dynamic service routes (AI, etc.) ───────────────────
|
|
395
|
+
// Listen for route definitions emitted by service plugins.
|
|
396
|
+
// The AIServicePlugin emits 'ai:routes' with RouteDefinition[].
|
|
397
|
+
ctx.hook('ai:routes', async (routes: RouteDefinition[]) => {
|
|
398
|
+
if (!server) return;
|
|
399
|
+
for (const route of routes) {
|
|
400
|
+
// Strip the /api/v1 prefix if present (it's already in the path)
|
|
401
|
+
// and register on the HTTP server with the configured prefix.
|
|
402
|
+
const routePath = route.path.startsWith('/api/v1')
|
|
403
|
+
? route.path
|
|
404
|
+
: `${prefix}${route.path}`;
|
|
405
|
+
|
|
406
|
+
const handler = async (req: any, res: any) => {
|
|
407
|
+
try {
|
|
408
|
+
const result = await route.handler({
|
|
409
|
+
body: req.body,
|
|
410
|
+
params: req.params,
|
|
411
|
+
query: req.query,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (result.stream && result.events) {
|
|
415
|
+
// SSE streaming response
|
|
416
|
+
res.status(result.status);
|
|
417
|
+
|
|
418
|
+
// Apply headers from the route result if available
|
|
419
|
+
if (result.headers) {
|
|
420
|
+
for (const [k, v] of Object.entries(result.headers)) {
|
|
421
|
+
res.header(k, v);
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
res.header('Content-Type', 'text/event-stream');
|
|
425
|
+
res.header('Cache-Control', 'no-cache');
|
|
426
|
+
res.header('Connection', 'keep-alive');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Write the stream — events are pre-encoded SSE strings
|
|
430
|
+
if (typeof res.write === 'function' && typeof res.end === 'function') {
|
|
431
|
+
for await (const event of result.events) {
|
|
432
|
+
res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
|
|
433
|
+
}
|
|
434
|
+
res.end();
|
|
435
|
+
} else {
|
|
436
|
+
// Fallback: collect events into array
|
|
437
|
+
const events = [];
|
|
438
|
+
for await (const event of result.events) {
|
|
439
|
+
events.push(event);
|
|
440
|
+
}
|
|
441
|
+
res.json({ events });
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
res.status(result.status);
|
|
445
|
+
if (result.body !== undefined) {
|
|
446
|
+
res.json(result.body);
|
|
447
|
+
} else {
|
|
448
|
+
res.end();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch (err: any) {
|
|
452
|
+
errorResponse(err, res);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const m = route.method.toLowerCase();
|
|
457
|
+
if (m === 'get' && typeof server.get === 'function') {
|
|
458
|
+
server.get(routePath, handler);
|
|
459
|
+
} else if (m === 'post' && typeof server.post === 'function') {
|
|
460
|
+
server.post(routePath, handler);
|
|
461
|
+
} else if (m === 'delete' && typeof server.delete === 'function') {
|
|
462
|
+
server.delete(routePath, handler);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
|
|
466
|
+
});
|
|
362
467
|
},
|
|
363
468
|
};
|
|
364
469
|
}
|
|
@@ -61,13 +61,16 @@ describe('HttpDispatcher Root Handling', () => {
|
|
|
61
61
|
expect(data.routes).toBeDefined();
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it('should
|
|
64
|
+
it('should return semantic 404 for POST request to root path ("")', async () => {
|
|
65
65
|
const context = { request: {} };
|
|
66
66
|
const method = 'POST';
|
|
67
67
|
const path = '';
|
|
68
68
|
|
|
69
69
|
const result = await dispatcher.dispatch(method, path, {}, {}, context);
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
// The dispatcher now returns a typed 404 (ROUTE_NOT_FOUND) instead of { handled: false }
|
|
72
|
+
expect(result.handled).toBe(true);
|
|
73
|
+
expect(result.response?.status).toBe(404);
|
|
74
|
+
expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
|
|
72
75
|
});
|
|
73
76
|
});
|
package/src/http-dispatcher.ts
CHANGED
|
@@ -59,6 +59,25 @@ export class HttpDispatcher {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 404 Route Not Found — no route is registered for this path.
|
|
64
|
+
*/
|
|
65
|
+
private routeNotFound(route: string) {
|
|
66
|
+
return {
|
|
67
|
+
status: 404,
|
|
68
|
+
body: {
|
|
69
|
+
success: false,
|
|
70
|
+
error: {
|
|
71
|
+
code: 404,
|
|
72
|
+
message: `Route Not Found: ${route}`,
|
|
73
|
+
type: 'ROUTE_NOT_FOUND' as const,
|
|
74
|
+
route,
|
|
75
|
+
hint: 'No route is registered for this path. Check the API discovery endpoint for available routes.',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
private ensureBroker() {
|
|
63
82
|
if (!this.kernel.broker) {
|
|
64
83
|
throw { statusCode: 500, message: 'Kernel Broker not available' };
|
|
@@ -133,11 +152,14 @@ export class HttpDispatcher {
|
|
|
133
152
|
};
|
|
134
153
|
|
|
135
154
|
// Build per-service status map
|
|
155
|
+
// handlerReady: true means the dispatcher has a real, bound handler for this route.
|
|
156
|
+
// handlerReady: false means the route is present in the discovery table but may not
|
|
157
|
+
// yet have a concrete implementation or may be served by a stub.
|
|
136
158
|
const svcAvailable = (route?: string, provider?: string) => ({
|
|
137
|
-
enabled: true, status: 'available' as const, route, provider,
|
|
159
|
+
enabled: true, status: 'available' as const, handlerReady: true, route, provider,
|
|
138
160
|
});
|
|
139
161
|
const svcUnavailable = (name: string) => ({
|
|
140
|
-
enabled: false, status: 'unavailable' as const,
|
|
162
|
+
enabled: false, status: 'unavailable' as const, handlerReady: false,
|
|
141
163
|
message: `Install a ${name} plugin to enable`,
|
|
142
164
|
});
|
|
143
165
|
|
|
@@ -174,7 +196,7 @@ export class HttpDispatcher {
|
|
|
174
196
|
},
|
|
175
197
|
services: {
|
|
176
198
|
// Kernel-provided (always available via protocol implementation)
|
|
177
|
-
metadata: { enabled: true, status: 'degraded' as const, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
|
|
199
|
+
metadata: { enabled: true, status: 'degraded' as const, handlerReady: true, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
|
|
178
200
|
data: svcAvailable(routes.data, 'kernel'),
|
|
179
201
|
// Plugin-provided — only available when a plugin registers the service
|
|
180
202
|
auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable('auth'),
|
|
@@ -362,12 +384,14 @@ export class HttpDispatcher {
|
|
|
362
384
|
// /metadata/:type/:name
|
|
363
385
|
if (parts.length === 2) {
|
|
364
386
|
const [type, name] = parts;
|
|
387
|
+
// Extract optional package filter from query string
|
|
388
|
+
const packageId = query?.package || undefined;
|
|
365
389
|
|
|
366
390
|
// PUT /metadata/:type/:name (Save)
|
|
367
391
|
if (method === 'PUT' && body) {
|
|
368
392
|
// Try to get the protocol service directly
|
|
369
393
|
const protocol = await this.resolveService('protocol');
|
|
370
|
-
|
|
394
|
+
|
|
371
395
|
if (protocol && typeof protocol.saveMetaItem === 'function') {
|
|
372
396
|
try {
|
|
373
397
|
const result = await protocol.saveMetaItem({ type, name, item: body });
|
|
@@ -376,7 +400,7 @@ export class HttpDispatcher {
|
|
|
376
400
|
return { handled: true, response: this.error(e.message, 400) };
|
|
377
401
|
}
|
|
378
402
|
}
|
|
379
|
-
|
|
403
|
+
|
|
380
404
|
// Fallback to broker if protocol not available (legacy)
|
|
381
405
|
if (broker) {
|
|
382
406
|
try {
|
|
@@ -408,12 +432,12 @@ export class HttpDispatcher {
|
|
|
408
432
|
// If type is singular (e.g. 'app'), use it directly
|
|
409
433
|
// If plural (e.g. 'apps'), slice it
|
|
410
434
|
const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
|
|
411
|
-
|
|
435
|
+
|
|
412
436
|
// Try Protocol Service First (Preferred)
|
|
413
437
|
const protocol = await this.resolveService('protocol');
|
|
414
438
|
if (protocol && typeof protocol.getMetaItem === 'function') {
|
|
415
439
|
try {
|
|
416
|
-
const data = await protocol.getMetaItem({ type: singularType, name });
|
|
440
|
+
const data = await protocol.getMetaItem({ type: singularType, name, packageId });
|
|
417
441
|
return { handled: true, response: this.success(data) };
|
|
418
442
|
} catch (e: any) {
|
|
419
443
|
// Protocol might throw if not found or not supported
|
|
@@ -1188,25 +1212,138 @@ export class HttpDispatcher {
|
|
|
1188
1212
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
1189
1213
|
}
|
|
1190
1214
|
|
|
1215
|
+
/**
|
|
1216
|
+
* Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.)
|
|
1217
|
+
* Resolves the AI service and its built-in route handlers, then dispatches.
|
|
1218
|
+
*/
|
|
1219
|
+
async handleAI(subPath: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
1220
|
+
let aiService: any;
|
|
1221
|
+
try {
|
|
1222
|
+
aiService = await this.resolveService('ai');
|
|
1223
|
+
} catch {
|
|
1224
|
+
// AI service not registered
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (!aiService) {
|
|
1228
|
+
return {
|
|
1229
|
+
handled: true,
|
|
1230
|
+
response: {
|
|
1231
|
+
status: 404,
|
|
1232
|
+
body: { success: false, error: { message: 'AI service is not configured', code: 404 } },
|
|
1233
|
+
},
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// The AI service exposes route definitions via buildAIRoutes.
|
|
1238
|
+
// We match the request path against known AI route patterns.
|
|
1239
|
+
const fullPath = `/api/v1${subPath}`;
|
|
1240
|
+
|
|
1241
|
+
// Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id
|
|
1242
|
+
const matchRoute = (pattern: string, path: string): Record<string, string> | null => {
|
|
1243
|
+
const patternParts = pattern.split('/');
|
|
1244
|
+
const pathParts = path.split('/');
|
|
1245
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
1246
|
+
const params: Record<string, string> = {};
|
|
1247
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
1248
|
+
if (patternParts[i].startsWith(':')) {
|
|
1249
|
+
params[patternParts[i].substring(1)] = pathParts[i];
|
|
1250
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return params;
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
// Try to get route definitions from the AI service's cached routes
|
|
1258
|
+
const routes = (this.kernel as any).__aiRoutes as Array<{
|
|
1259
|
+
method: string; path: string; handler: (req: any) => Promise<any>;
|
|
1260
|
+
}> | undefined;
|
|
1261
|
+
|
|
1262
|
+
if (!routes) {
|
|
1263
|
+
return {
|
|
1264
|
+
handled: true,
|
|
1265
|
+
response: {
|
|
1266
|
+
status: 503,
|
|
1267
|
+
body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } },
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
for (const route of routes) {
|
|
1273
|
+
if (route.method !== method) continue;
|
|
1274
|
+
const params = matchRoute(route.path, fullPath);
|
|
1275
|
+
if (params === null) continue;
|
|
1276
|
+
|
|
1277
|
+
const result = await route.handler({ body, params, query });
|
|
1278
|
+
|
|
1279
|
+
if (result.stream && result.events) {
|
|
1280
|
+
// Return a streaming result for the adapter to handle
|
|
1281
|
+
return {
|
|
1282
|
+
handled: true,
|
|
1283
|
+
result: {
|
|
1284
|
+
type: 'stream',
|
|
1285
|
+
contentType: result.vercelDataStream
|
|
1286
|
+
? 'text/plain; charset=utf-8'
|
|
1287
|
+
: 'text/event-stream',
|
|
1288
|
+
events: result.events,
|
|
1289
|
+
vercelDataStream: result.vercelDataStream,
|
|
1290
|
+
headers: {
|
|
1291
|
+
'Content-Type': result.vercelDataStream
|
|
1292
|
+
? 'text/plain; charset=utf-8'
|
|
1293
|
+
: 'text/event-stream',
|
|
1294
|
+
'Cache-Control': 'no-cache',
|
|
1295
|
+
'Connection': 'keep-alive',
|
|
1296
|
+
},
|
|
1297
|
+
},
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
return {
|
|
1302
|
+
handled: true,
|
|
1303
|
+
response: {
|
|
1304
|
+
status: result.status,
|
|
1305
|
+
body: result.body,
|
|
1306
|
+
},
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
return {
|
|
1311
|
+
handled: true,
|
|
1312
|
+
response: this.routeNotFound(subPath),
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1191
1316
|
/**
|
|
1192
1317
|
* Main Dispatcher Entry Point
|
|
1193
1318
|
* Routes the request to the appropriate handler based on path and precedence
|
|
1194
1319
|
*/
|
|
1195
|
-
async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
1320
|
+
async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise<HttpDispatcherResult> {
|
|
1196
1321
|
const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
|
|
1197
1322
|
|
|
1198
1323
|
// 0. Discovery Endpoint (GET /discovery or GET /)
|
|
1199
1324
|
// Standard route: /discovery (protocol-compliant)
|
|
1200
1325
|
// Legacy route: / (empty path, for backward compatibility — MSW strips base URL)
|
|
1201
1326
|
if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') {
|
|
1202
|
-
|
|
1203
|
-
const info = await this.getDiscoveryInfo('');
|
|
1327
|
+
const info = await this.getDiscoveryInfo(prefix ?? '');
|
|
1204
1328
|
return {
|
|
1205
1329
|
handled: true,
|
|
1206
1330
|
response: this.success(info)
|
|
1207
1331
|
};
|
|
1208
1332
|
}
|
|
1209
1333
|
|
|
1334
|
+
// 0b. Health Endpoint (GET /health)
|
|
1335
|
+
if (cleanPath === '/health' && method === 'GET') {
|
|
1336
|
+
return {
|
|
1337
|
+
handled: true,
|
|
1338
|
+
response: this.success({
|
|
1339
|
+
status: 'ok',
|
|
1340
|
+
timestamp: new Date().toISOString(),
|
|
1341
|
+
version: '1.0.0',
|
|
1342
|
+
uptime: typeof process !== 'undefined' ? process.uptime() : undefined,
|
|
1343
|
+
}),
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1210
1347
|
// 1. System Protocols (Prefix-based)
|
|
1211
1348
|
if (cleanPath.startsWith('/auth')) {
|
|
1212
1349
|
return this.handleAuth(cleanPath.substring(5), method, body, context);
|
|
@@ -1249,6 +1386,11 @@ export class HttpDispatcher {
|
|
|
1249
1386
|
return this.handleI18n(cleanPath.substring(5), method, query, context);
|
|
1250
1387
|
}
|
|
1251
1388
|
|
|
1389
|
+
// AI Service — delegate to the registered AI route handlers
|
|
1390
|
+
if (cleanPath.startsWith('/ai')) {
|
|
1391
|
+
return this.handleAI(cleanPath, method, body, query, context);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1252
1394
|
// OpenAPI Specification
|
|
1253
1395
|
if (cleanPath === '/openapi.json' && method === 'GET') {
|
|
1254
1396
|
const broker = this.ensureBroker();
|
|
@@ -1265,8 +1407,11 @@ export class HttpDispatcher {
|
|
|
1265
1407
|
const result = await this.handleApiEndpoint(cleanPath, method, body, query, context);
|
|
1266
1408
|
if (result.handled) return result;
|
|
1267
1409
|
|
|
1268
|
-
// 3. Fallback
|
|
1269
|
-
return {
|
|
1410
|
+
// 3. Fallback — return semantic 404 with diagnostic info
|
|
1411
|
+
return {
|
|
1412
|
+
handled: true,
|
|
1413
|
+
response: this.routeNotFound(cleanPath),
|
|
1414
|
+
};
|
|
1270
1415
|
}
|
|
1271
1416
|
|
|
1272
1417
|
/**
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { defineConfig } from 'vitest/config';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
resolve: {
|
|
8
|
+
alias: {
|
|
9
|
+
'@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'),
|
|
10
|
+
'@objectstack/rest': path.resolve(__dirname, '../rest/src/index.ts'),
|
|
11
|
+
'@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'),
|
|
12
|
+
'@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'),
|
|
13
|
+
'@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'),
|
|
14
|
+
'@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'),
|
|
15
|
+
'@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'),
|
|
16
|
+
'@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'),
|
|
17
|
+
'@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'),
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
test: {
|
|
21
|
+
globals: true,
|
|
22
|
+
environment: 'node',
|
|
23
|
+
include: ['src/**/*.test.ts'],
|
|
24
|
+
},
|
|
25
|
+
});
|