@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/runtime",
3
- "version": "4.0.1",
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.1",
19
- "@objectstack/rest": "4.0.1",
20
- "@objectstack/spec": "4.0.1",
21
- "@objectstack/types": "4.0.1"
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.2"
25
+ "vitest": "^4.1.4"
26
26
  },
27
27
  "scripts": {
28
28
  "build": "tsup --config ../../tsup.config.ts",
@@ -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.registerService).toHaveBeenCalledWith('app.com.test.simple', bundle);
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 as a service
45
- // ObjectQLPlugin will discover this and call ql.registerApp()
46
- const serviceName = `app.${appId}`;
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.registerService(serviceName, servicePayload);
50
+ ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
55
51
  }
56
52
 
57
53
  start = async (ctx: PluginContext) => {
@@ -12,7 +12,88 @@ export interface DispatcherPluginConfig {
12
12
  }
13
13
 
14
14
  /**
15
- * Send an HttpDispatcherResult through IHttpResponse
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
- res.status(404).json({ success: false, error: { message: 'Not Found', code: 404 } });
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 NOT handle POST request to root path ("")', async () => {
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
- expect(result.handled).toBe(false);
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
  });
@@ -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 protocol service for dynamic types
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
- // Fallback: ask broker for registered types
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
- // If type is singular (e.g. 'app'), use it directly
409
- // If plural (e.g. 'apps'), slice it
410
- const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
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
- // We use '' as prefix since we are internal dispatcher
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 (404)
1269
- return { handled: false };
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
  /**