@objectstack/runtime 4.0.1 → 4.0.3
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 +19 -0
- package/dist/index.cjs +281 -10
- 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 +281 -10
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/app-plugin.test.ts +8 -3
- package/src/app-plugin.ts +4 -8
- package/src/dispatcher-plugin.ts +141 -2
- package/src/http-dispatcher.root.test.ts +5 -2
- package/src/http-dispatcher.ts +240 -19
- package/vitest.config.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/runtime",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.3",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "ObjectStack Core Runtime & Query Engine",
|
|
6
6
|
"type": "module",
|
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"zod": "^4.3.6",
|
|
18
|
-
"@objectstack/core": "4.0.
|
|
19
|
-
"@objectstack/
|
|
20
|
-
"@objectstack/spec": "4.0.
|
|
21
|
-
"@objectstack/
|
|
18
|
+
"@objectstack/core": "4.0.3",
|
|
19
|
+
"@objectstack/types": "4.0.3",
|
|
20
|
+
"@objectstack/spec": "4.0.3",
|
|
21
|
+
"@objectstack/rest": "4.0.3"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"typescript": "^6.0.2",
|
|
25
|
-
"vitest": "^4.1.
|
|
25
|
+
"vitest": "^4.1.4"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsup --config ../../tsup.config.ts",
|
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,88 @@ 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
|
+
* Register a single RouteDefinition on the HTTP server.
|
|
27
|
+
* Returns true if the route was successfully registered.
|
|
28
|
+
*/
|
|
29
|
+
function mountRouteOnServer(route: RouteDefinition, server: IHttpServer, routePath: string): boolean {
|
|
30
|
+
const handler = async (req: any, res: any) => {
|
|
31
|
+
try {
|
|
32
|
+
const result = await route.handler({
|
|
33
|
+
body: req.body,
|
|
34
|
+
params: req.params,
|
|
35
|
+
query: req.query,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (result.stream && result.events) {
|
|
39
|
+
// SSE streaming response
|
|
40
|
+
res.status(result.status);
|
|
41
|
+
|
|
42
|
+
// Apply headers from the route result if available
|
|
43
|
+
if (result.headers) {
|
|
44
|
+
for (const [k, v] of Object.entries(result.headers)) {
|
|
45
|
+
res.header(k, String(v));
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
res.header('Content-Type', 'text/event-stream');
|
|
49
|
+
res.header('Cache-Control', 'no-cache');
|
|
50
|
+
res.header('Connection', 'keep-alive');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Write the stream — events are pre-encoded SSE strings
|
|
54
|
+
if (typeof res.write === 'function' && typeof res.end === 'function') {
|
|
55
|
+
for await (const event of result.events) {
|
|
56
|
+
res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
|
|
57
|
+
}
|
|
58
|
+
res.end();
|
|
59
|
+
} else {
|
|
60
|
+
// Fallback: collect events into array
|
|
61
|
+
const events = [];
|
|
62
|
+
for await (const event of result.events) {
|
|
63
|
+
events.push(event);
|
|
64
|
+
}
|
|
65
|
+
res.json({ events });
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
res.status(result.status);
|
|
69
|
+
if (result.body !== undefined) {
|
|
70
|
+
res.json(result.body);
|
|
71
|
+
} else {
|
|
72
|
+
res.end();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
errorResponse(err, res);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const m = route.method.toLowerCase();
|
|
81
|
+
if (m === 'get' && typeof server.get === 'function') {
|
|
82
|
+
server.get(routePath, handler);
|
|
83
|
+
return true;
|
|
84
|
+
} else if (m === 'post' && typeof server.post === 'function') {
|
|
85
|
+
server.post(routePath, handler);
|
|
86
|
+
return true;
|
|
87
|
+
} else if (m === 'delete' && typeof server.delete === 'function') {
|
|
88
|
+
server.delete(routePath, handler);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Send an HttpDispatcherResult through IHttpResponse.
|
|
96
|
+
* Differentiates between handled, unhandled (404), and special results.
|
|
16
97
|
*/
|
|
17
98
|
function sendResult(result: HttpDispatcherResult, res: any): void {
|
|
18
99
|
if (result.handled) {
|
|
@@ -32,7 +113,16 @@ function sendResult(result: HttpDispatcherResult, res: any): void {
|
|
|
32
113
|
return;
|
|
33
114
|
}
|
|
34
115
|
}
|
|
35
|
-
|
|
116
|
+
// Semantic 404: no route matched — include diagnostic info
|
|
117
|
+
res.status(404).json({
|
|
118
|
+
success: false,
|
|
119
|
+
error: {
|
|
120
|
+
message: 'Not Found',
|
|
121
|
+
code: 404,
|
|
122
|
+
type: 'ROUTE_NOT_FOUND',
|
|
123
|
+
hint: 'No handler matched this request. Check the API discovery endpoint for available routes.',
|
|
124
|
+
},
|
|
125
|
+
});
|
|
36
126
|
}
|
|
37
127
|
|
|
38
128
|
function errorResponse(err: any, res: any): void {
|
|
@@ -96,6 +186,16 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
|
|
|
96
186
|
res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
|
|
97
187
|
});
|
|
98
188
|
|
|
189
|
+
// ── Health ──────────────────────────────────────────────────
|
|
190
|
+
server.get(`${prefix}/health`, async (_req: any, res: any) => {
|
|
191
|
+
try {
|
|
192
|
+
const result = await dispatcher.dispatch('GET', '/health', undefined, {}, { request: _req });
|
|
193
|
+
sendResult(result, res);
|
|
194
|
+
} catch (err: any) {
|
|
195
|
+
errorResponse(err, res);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
99
199
|
// ── Auth ────────────────────────────────────────────────────
|
|
100
200
|
server.post(`${prefix}/auth/login`, async (req: any, res: any) => {
|
|
101
201
|
try {
|
|
@@ -359,6 +459,45 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
|
|
|
359
459
|
});
|
|
360
460
|
|
|
361
461
|
ctx.logger.info('Dispatcher bridge routes registered', { prefix });
|
|
462
|
+
|
|
463
|
+
// ── Dynamic service routes (AI, etc.) ───────────────────
|
|
464
|
+
// Listen for route definitions emitted by service plugins.
|
|
465
|
+
// The AIServicePlugin emits 'ai:routes' with RouteDefinition[].
|
|
466
|
+
ctx.hook('ai:routes', async (routes: RouteDefinition[]) => {
|
|
467
|
+
if (!server) return;
|
|
468
|
+
for (const route of routes) {
|
|
469
|
+
// Strip the /api/v1 prefix if present (it's already in the path)
|
|
470
|
+
// and register on the HTTP server with the configured prefix.
|
|
471
|
+
const routePath = route.path.startsWith('/api/v1')
|
|
472
|
+
? route.path
|
|
473
|
+
: `${prefix}${route.path}`;
|
|
474
|
+
mountRouteOnServer(route, server, routePath);
|
|
475
|
+
}
|
|
476
|
+
ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ── Fallback: recover routes cached before hook was registered ──
|
|
480
|
+
// If AIServicePlugin.start() ran before DispatcherPlugin.start()
|
|
481
|
+
// (possible when plugin start order differs from registration order),
|
|
482
|
+
// the 'ai:routes' trigger fires with no listener. The AIServicePlugin
|
|
483
|
+
// caches the routes on the kernel as __aiRoutes (see AIServicePlugin.start())
|
|
484
|
+
// as an internal cross-plugin protocol so we can recover them here.
|
|
485
|
+
// TODO: replace with a formal kernel.getCachedRoutes('ai') API in a future release.
|
|
486
|
+
const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined;
|
|
487
|
+
if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) {
|
|
488
|
+
let registered = 0;
|
|
489
|
+
for (const route of cachedRoutes) {
|
|
490
|
+
const routePath = route.path.startsWith('/api/v1')
|
|
491
|
+
? route.path
|
|
492
|
+
: `${prefix}${route.path}`;
|
|
493
|
+
if (mountRouteOnServer(route, server, routePath)) {
|
|
494
|
+
registered++;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (registered > 0) {
|
|
498
|
+
ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
362
501
|
},
|
|
363
502
|
};
|
|
364
503
|
}
|
|
@@ -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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core';
|
|
4
4
|
import { CoreServiceName } from '@objectstack/spec/system';
|
|
5
|
+
import { pluralToSingular } from '@objectstack/spec/shared';
|
|
5
6
|
|
|
6
7
|
/** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
|
|
7
8
|
function randomUUID(): string {
|
|
@@ -59,6 +60,25 @@ export class HttpDispatcher {
|
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 404 Route Not Found — no route is registered for this path.
|
|
65
|
+
*/
|
|
66
|
+
private routeNotFound(route: string) {
|
|
67
|
+
return {
|
|
68
|
+
status: 404,
|
|
69
|
+
body: {
|
|
70
|
+
success: false,
|
|
71
|
+
error: {
|
|
72
|
+
code: 404,
|
|
73
|
+
message: `Route Not Found: ${route}`,
|
|
74
|
+
type: 'ROUTE_NOT_FOUND' as const,
|
|
75
|
+
route,
|
|
76
|
+
hint: 'No route is registered for this path. Check the API discovery endpoint for available routes.',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
private ensureBroker() {
|
|
63
83
|
if (!this.kernel.broker) {
|
|
64
84
|
throw { statusCode: 500, message: 'Kernel Broker not available' };
|
|
@@ -133,11 +153,14 @@ export class HttpDispatcher {
|
|
|
133
153
|
};
|
|
134
154
|
|
|
135
155
|
// Build per-service status map
|
|
156
|
+
// handlerReady: true means the dispatcher has a real, bound handler for this route.
|
|
157
|
+
// handlerReady: false means the route is present in the discovery table but may not
|
|
158
|
+
// yet have a concrete implementation or may be served by a stub.
|
|
136
159
|
const svcAvailable = (route?: string, provider?: string) => ({
|
|
137
|
-
enabled: true, status: 'available' as const, route, provider,
|
|
160
|
+
enabled: true, status: 'available' as const, handlerReady: true, route, provider,
|
|
138
161
|
});
|
|
139
162
|
const svcUnavailable = (name: string) => ({
|
|
140
|
-
enabled: false, status: 'unavailable' as const,
|
|
163
|
+
enabled: false, status: 'unavailable' as const, handlerReady: false,
|
|
141
164
|
message: `Install a ${name} plugin to enable`,
|
|
142
165
|
});
|
|
143
166
|
|
|
@@ -174,7 +197,7 @@ export class HttpDispatcher {
|
|
|
174
197
|
},
|
|
175
198
|
services: {
|
|
176
199
|
// 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' },
|
|
200
|
+
metadata: { enabled: true, status: 'degraded' as const, handlerReady: true, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
|
|
178
201
|
data: svcAvailable(routes.data, 'kernel'),
|
|
179
202
|
// Plugin-provided — only available when a plugin registers the service
|
|
180
203
|
auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable('auth'),
|
|
@@ -319,22 +342,82 @@ export class HttpDispatcher {
|
|
|
319
342
|
|
|
320
343
|
// GET /metadata/types
|
|
321
344
|
if (parts[0] === 'types') {
|
|
322
|
-
// Try
|
|
345
|
+
// PRIORITY 1: Try MetadataService directly (includes both typeRegistry with agent/tool AND runtime-registered types)
|
|
346
|
+
console.log('[HttpDispatcher] Attempting to resolve MetadataService...');
|
|
347
|
+
console.log('[HttpDispatcher] Available kernel methods:', {
|
|
348
|
+
hasGetServiceAsync: typeof this.kernel.getServiceAsync === 'function',
|
|
349
|
+
hasGetService: typeof this.kernel.getService === 'function',
|
|
350
|
+
hasContext: !!this.kernel.context,
|
|
351
|
+
hasContextGetService: typeof this.kernel.context?.getService === 'function',
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Try all service resolution paths with detailed logging
|
|
355
|
+
let metadataService: any = null;
|
|
356
|
+
|
|
357
|
+
// Path 1: kernel.getServiceAsync
|
|
358
|
+
if (typeof this.kernel.getServiceAsync === 'function') {
|
|
359
|
+
try {
|
|
360
|
+
metadataService = await this.kernel.getServiceAsync('metadata');
|
|
361
|
+
console.log('[HttpDispatcher] kernel.getServiceAsync("metadata") returned:', !!metadataService);
|
|
362
|
+
} catch (e: any) {
|
|
363
|
+
console.log('[HttpDispatcher] kernel.getServiceAsync("metadata") failed:', e.message);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Path 2: kernel.getService (if not found via async)
|
|
368
|
+
if (!metadataService && typeof this.kernel.getService === 'function') {
|
|
369
|
+
try {
|
|
370
|
+
metadataService = await this.kernel.getService('metadata');
|
|
371
|
+
console.log('[HttpDispatcher] kernel.getService("metadata") returned:', !!metadataService);
|
|
372
|
+
} catch (e: any) {
|
|
373
|
+
console.log('[HttpDispatcher] kernel.getService("metadata") failed:', e.message);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Path 3: kernel.context.getService (if not found)
|
|
378
|
+
if (!metadataService && this.kernel.context?.getService) {
|
|
379
|
+
try {
|
|
380
|
+
metadataService = await this.kernel.context.getService('metadata');
|
|
381
|
+
console.log('[HttpDispatcher] kernel.context.getService("metadata") returned:', !!metadataService);
|
|
382
|
+
} catch (e: any) {
|
|
383
|
+
console.log('[HttpDispatcher] kernel.context.getService("metadata") failed:', e.message);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
console.log('[HttpDispatcher] Final metadataService:', !!metadataService, 'has getRegisteredTypes:', typeof (metadataService as any)?.getRegisteredTypes);
|
|
388
|
+
|
|
389
|
+
if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') {
|
|
390
|
+
try {
|
|
391
|
+
const types = await (metadataService as any).getRegisteredTypes();
|
|
392
|
+
console.log('[HttpDispatcher] MetadataService.getRegisteredTypes() returned:', types);
|
|
393
|
+
return { handled: true, response: this.success({ types }) };
|
|
394
|
+
} catch (e: any) {
|
|
395
|
+
// Log error but continue to fallbacks
|
|
396
|
+
console.warn('[HttpDispatcher] MetadataService.getRegisteredTypes() failed:', e.message, e.stack);
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
console.log('[HttpDispatcher] MetadataService not available or missing getRegisteredTypes, falling back to protocol service');
|
|
400
|
+
}
|
|
401
|
+
// PRIORITY 2: Try protocol service (returns SchemaRegistry types only - missing agent/tool)
|
|
323
402
|
const protocol = await this.resolveService('protocol');
|
|
324
403
|
if (protocol && typeof protocol.getMetaTypes === 'function') {
|
|
325
404
|
const result = await protocol.getMetaTypes({});
|
|
405
|
+
console.log('[HttpDispatcher] Protocol service returned types:', result);
|
|
326
406
|
return { handled: true, response: this.success(result) };
|
|
327
407
|
}
|
|
328
|
-
//
|
|
408
|
+
// PRIORITY 3: ask broker for registered types
|
|
329
409
|
if (broker) {
|
|
330
410
|
try {
|
|
331
411
|
const data = await broker.call('metadata.types', {}, { request: context.request });
|
|
412
|
+
console.log('[HttpDispatcher] Broker returned types:', data);
|
|
332
413
|
return { handled: true, response: this.success(data) };
|
|
333
|
-
} catch {
|
|
414
|
+
} catch (e) {
|
|
415
|
+
console.log('[HttpDispatcher] Broker call failed:', e);
|
|
334
416
|
// fall through to hardcoded defaults
|
|
335
417
|
}
|
|
336
418
|
}
|
|
337
419
|
// Last resort: hardcoded defaults
|
|
420
|
+
console.warn('[HttpDispatcher] Falling back to hardcoded defaults for metadata types');
|
|
338
421
|
return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
|
|
339
422
|
}
|
|
340
423
|
|
|
@@ -362,12 +445,14 @@ export class HttpDispatcher {
|
|
|
362
445
|
// /metadata/:type/:name
|
|
363
446
|
if (parts.length === 2) {
|
|
364
447
|
const [type, name] = parts;
|
|
448
|
+
// Extract optional package filter from query string
|
|
449
|
+
const packageId = query?.package || undefined;
|
|
365
450
|
|
|
366
451
|
// PUT /metadata/:type/:name (Save)
|
|
367
452
|
if (method === 'PUT' && body) {
|
|
368
453
|
// Try to get the protocol service directly
|
|
369
454
|
const protocol = await this.resolveService('protocol');
|
|
370
|
-
|
|
455
|
+
|
|
371
456
|
if (protocol && typeof protocol.saveMetaItem === 'function') {
|
|
372
457
|
try {
|
|
373
458
|
const result = await protocol.saveMetaItem({ type, name, item: body });
|
|
@@ -376,7 +461,7 @@ export class HttpDispatcher {
|
|
|
376
461
|
return { handled: true, response: this.error(e.message, 400) };
|
|
377
462
|
}
|
|
378
463
|
}
|
|
379
|
-
|
|
464
|
+
|
|
380
465
|
// Fallback to broker if protocol not available (legacy)
|
|
381
466
|
if (broker) {
|
|
382
467
|
try {
|
|
@@ -405,15 +490,14 @@ export class HttpDispatcher {
|
|
|
405
490
|
return { handled: true, response: this.error('Not found', 404) };
|
|
406
491
|
}
|
|
407
492
|
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
493
|
+
// Normalize plural URL paths to singular registry type names
|
|
494
|
+
const singularType = pluralToSingular(type);
|
|
495
|
+
|
|
412
496
|
// Try Protocol Service First (Preferred)
|
|
413
497
|
const protocol = await this.resolveService('protocol');
|
|
414
498
|
if (protocol && typeof protocol.getMetaItem === 'function') {
|
|
415
499
|
try {
|
|
416
|
-
const data = await protocol.getMetaItem({ type: singularType, name });
|
|
500
|
+
const data = await protocol.getMetaItem({ type: singularType, name, packageId });
|
|
417
501
|
return { handled: true, response: this.success(data) };
|
|
418
502
|
} catch (e: any) {
|
|
419
503
|
// Protocol might throw if not found or not supported
|
|
@@ -440,7 +524,7 @@ export class HttpDispatcher {
|
|
|
440
524
|
const typeOrName = parts[0];
|
|
441
525
|
// Extract optional package filter from query string
|
|
442
526
|
const packageId = query?.package || undefined;
|
|
443
|
-
|
|
527
|
+
|
|
444
528
|
// Try protocol service first for any type
|
|
445
529
|
const protocol = await this.resolveService('protocol');
|
|
446
530
|
if (protocol && typeof protocol.getMetaItems === 'function') {
|
|
@@ -455,6 +539,22 @@ export class HttpDispatcher {
|
|
|
455
539
|
}
|
|
456
540
|
}
|
|
457
541
|
|
|
542
|
+
// Try MetadataService directly for runtime-registered metadata (agents, tools, etc.)
|
|
543
|
+
const metadataService = await this.getService(CoreServiceName.enum.metadata);
|
|
544
|
+
if (metadataService && typeof (metadataService as any).list === 'function') {
|
|
545
|
+
try {
|
|
546
|
+
const items = await (metadataService as any).list(typeOrName);
|
|
547
|
+
if (items && items.length > 0) {
|
|
548
|
+
return { handled: true, response: this.success({ type: typeOrName, items }) };
|
|
549
|
+
}
|
|
550
|
+
} catch (e: any) {
|
|
551
|
+
// MetadataService doesn't know this type or failed, continue to other fallbacks
|
|
552
|
+
// Sanitize typeOrName to prevent log injection (CodeQL warning)
|
|
553
|
+
const sanitizedType = String(typeOrName).replace(/[\r\n\t]/g, '');
|
|
554
|
+
console.debug(`[HttpDispatcher] MetadataService.list() failed for type:`, sanitizedType, 'error:', e.message);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
458
558
|
// Try broker for the type
|
|
459
559
|
if (broker) {
|
|
460
560
|
try {
|
|
@@ -1188,25 +1288,138 @@ export class HttpDispatcher {
|
|
|
1188
1288
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
1189
1289
|
}
|
|
1190
1290
|
|
|
1291
|
+
/**
|
|
1292
|
+
* Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.)
|
|
1293
|
+
* Resolves the AI service and its built-in route handlers, then dispatches.
|
|
1294
|
+
*/
|
|
1295
|
+
async handleAI(subPath: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
1296
|
+
let aiService: any;
|
|
1297
|
+
try {
|
|
1298
|
+
aiService = await this.resolveService('ai');
|
|
1299
|
+
} catch {
|
|
1300
|
+
// AI service not registered
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (!aiService) {
|
|
1304
|
+
return {
|
|
1305
|
+
handled: true,
|
|
1306
|
+
response: {
|
|
1307
|
+
status: 404,
|
|
1308
|
+
body: { success: false, error: { message: 'AI service is not configured', code: 404 } },
|
|
1309
|
+
},
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// The AI service exposes route definitions via buildAIRoutes.
|
|
1314
|
+
// We match the request path against known AI route patterns.
|
|
1315
|
+
const fullPath = `/api/v1${subPath}`;
|
|
1316
|
+
|
|
1317
|
+
// Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id
|
|
1318
|
+
const matchRoute = (pattern: string, path: string): Record<string, string> | null => {
|
|
1319
|
+
const patternParts = pattern.split('/');
|
|
1320
|
+
const pathParts = path.split('/');
|
|
1321
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
1322
|
+
const params: Record<string, string> = {};
|
|
1323
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
1324
|
+
if (patternParts[i].startsWith(':')) {
|
|
1325
|
+
params[patternParts[i].substring(1)] = pathParts[i];
|
|
1326
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return params;
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
// Try to get route definitions from the AI service's cached routes
|
|
1334
|
+
const routes = (this.kernel as any).__aiRoutes as Array<{
|
|
1335
|
+
method: string; path: string; handler: (req: any) => Promise<any>;
|
|
1336
|
+
}> | undefined;
|
|
1337
|
+
|
|
1338
|
+
if (!routes) {
|
|
1339
|
+
return {
|
|
1340
|
+
handled: true,
|
|
1341
|
+
response: {
|
|
1342
|
+
status: 503,
|
|
1343
|
+
body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } },
|
|
1344
|
+
},
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
for (const route of routes) {
|
|
1349
|
+
if (route.method !== method) continue;
|
|
1350
|
+
const params = matchRoute(route.path, fullPath);
|
|
1351
|
+
if (params === null) continue;
|
|
1352
|
+
|
|
1353
|
+
const result = await route.handler({ body, params, query });
|
|
1354
|
+
|
|
1355
|
+
if (result.stream && result.events) {
|
|
1356
|
+
// Return a streaming result for the adapter to handle
|
|
1357
|
+
return {
|
|
1358
|
+
handled: true,
|
|
1359
|
+
result: {
|
|
1360
|
+
type: 'stream',
|
|
1361
|
+
contentType: result.vercelDataStream
|
|
1362
|
+
? 'text/plain; charset=utf-8'
|
|
1363
|
+
: 'text/event-stream',
|
|
1364
|
+
events: result.events,
|
|
1365
|
+
vercelDataStream: result.vercelDataStream,
|
|
1366
|
+
headers: {
|
|
1367
|
+
'Content-Type': result.vercelDataStream
|
|
1368
|
+
? 'text/plain; charset=utf-8'
|
|
1369
|
+
: 'text/event-stream',
|
|
1370
|
+
'Cache-Control': 'no-cache',
|
|
1371
|
+
'Connection': 'keep-alive',
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
return {
|
|
1378
|
+
handled: true,
|
|
1379
|
+
response: {
|
|
1380
|
+
status: result.status,
|
|
1381
|
+
body: result.body,
|
|
1382
|
+
},
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return {
|
|
1387
|
+
handled: true,
|
|
1388
|
+
response: this.routeNotFound(subPath),
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1191
1392
|
/**
|
|
1192
1393
|
* Main Dispatcher Entry Point
|
|
1193
1394
|
* Routes the request to the appropriate handler based on path and precedence
|
|
1194
1395
|
*/
|
|
1195
|
-
async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
1396
|
+
async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise<HttpDispatcherResult> {
|
|
1196
1397
|
const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
|
|
1197
1398
|
|
|
1198
1399
|
// 0. Discovery Endpoint (GET /discovery or GET /)
|
|
1199
1400
|
// Standard route: /discovery (protocol-compliant)
|
|
1200
1401
|
// Legacy route: / (empty path, for backward compatibility — MSW strips base URL)
|
|
1201
1402
|
if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') {
|
|
1202
|
-
|
|
1203
|
-
const info = await this.getDiscoveryInfo('');
|
|
1403
|
+
const info = await this.getDiscoveryInfo(prefix ?? '');
|
|
1204
1404
|
return {
|
|
1205
1405
|
handled: true,
|
|
1206
1406
|
response: this.success(info)
|
|
1207
1407
|
};
|
|
1208
1408
|
}
|
|
1209
1409
|
|
|
1410
|
+
// 0b. Health Endpoint (GET /health)
|
|
1411
|
+
if (cleanPath === '/health' && method === 'GET') {
|
|
1412
|
+
return {
|
|
1413
|
+
handled: true,
|
|
1414
|
+
response: this.success({
|
|
1415
|
+
status: 'ok',
|
|
1416
|
+
timestamp: new Date().toISOString(),
|
|
1417
|
+
version: '1.0.0',
|
|
1418
|
+
uptime: typeof process !== 'undefined' ? process.uptime() : undefined,
|
|
1419
|
+
}),
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1210
1423
|
// 1. System Protocols (Prefix-based)
|
|
1211
1424
|
if (cleanPath.startsWith('/auth')) {
|
|
1212
1425
|
return this.handleAuth(cleanPath.substring(5), method, body, context);
|
|
@@ -1249,6 +1462,11 @@ export class HttpDispatcher {
|
|
|
1249
1462
|
return this.handleI18n(cleanPath.substring(5), method, query, context);
|
|
1250
1463
|
}
|
|
1251
1464
|
|
|
1465
|
+
// AI Service — delegate to the registered AI route handlers
|
|
1466
|
+
if (cleanPath.startsWith('/ai')) {
|
|
1467
|
+
return this.handleAI(cleanPath, method, body, query, context);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1252
1470
|
// OpenAPI Specification
|
|
1253
1471
|
if (cleanPath === '/openapi.json' && method === 'GET') {
|
|
1254
1472
|
const broker = this.ensureBroker();
|
|
@@ -1265,8 +1483,11 @@ export class HttpDispatcher {
|
|
|
1265
1483
|
const result = await this.handleApiEndpoint(cleanPath, method, body, query, context);
|
|
1266
1484
|
if (result.handled) return result;
|
|
1267
1485
|
|
|
1268
|
-
// 3. Fallback
|
|
1269
|
-
return {
|
|
1486
|
+
// 3. Fallback — return semantic 404 with diagnostic info
|
|
1487
|
+
return {
|
|
1488
|
+
handled: true,
|
|
1489
|
+
response: this.routeNotFound(cleanPath),
|
|
1490
|
+
};
|
|
1270
1491
|
}
|
|
1271
1492
|
|
|
1272
1493
|
/**
|