@objectstack/runtime 4.0.4 → 4.1.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.
@@ -1,1483 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core';
4
- import { CoreServiceName } from '@objectstack/spec/system';
5
- import { pluralToSingular } from '@objectstack/spec/shared';
6
-
7
- /** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
8
- function randomUUID(): string {
9
- if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
10
- return globalThis.crypto.randomUUID();
11
- }
12
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
13
- const r = (Math.random() * 16) | 0;
14
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
15
- return v.toString(16);
16
- });
17
- }
18
-
19
- export interface HttpProtocolContext {
20
- request: any;
21
- response?: any;
22
- }
23
-
24
- export interface HttpDispatcherResult {
25
- handled: boolean;
26
- response?: {
27
- status: number;
28
- body?: any;
29
- headers?: Record<string, string>;
30
- };
31
- result?: any; // For flexible return types or direct response objects (Response/NextResponse)
32
- }
33
-
34
- /**
35
- * @deprecated Use `createDispatcherPlugin()` from `@objectstack/runtime` instead.
36
- * This class will be removed in v2. Prefer the plugin-based approach:
37
- * ```ts
38
- * import { createDispatcherPlugin } from '@objectstack/runtime';
39
- * kernel.use(createDispatcherPlugin({ prefix: '/api/v1' }));
40
- * ```
41
- */
42
- export class HttpDispatcher {
43
- private kernel: any; // Casting to any to access dynamic props like services, graphql
44
-
45
- constructor(kernel: ObjectKernel) {
46
- this.kernel = kernel;
47
- }
48
-
49
- private success(data: any, meta?: any) {
50
- return {
51
- status: 200,
52
- body: { success: true, data, meta }
53
- };
54
- }
55
-
56
- private error(message: string, code: number = 500, details?: any) {
57
- return {
58
- status: code,
59
- body: { success: false, error: { message, code, details } }
60
- };
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
-
82
- /**
83
- * Direct data service dispatch — replaces broker.call('data.*').
84
- * Tries protocol service first (supports expand/populate), falls back to ObjectQL.
85
- */
86
- private async callData(action: string, params: any): Promise<any> {
87
- const protocol = await this.resolveService('protocol');
88
- const qlService = await this.getObjectQLService();
89
- const ql = qlService ?? await this.resolveService('objectql');
90
-
91
- if (action === 'create') {
92
- if (ql) {
93
- const res = await ql.insert(params.object, params.data);
94
- const record = { ...params.data, ...res };
95
- return { object: params.object, id: record.id, record };
96
- }
97
- throw { statusCode: 503, message: 'Data service not available' };
98
- }
99
-
100
- if (action === 'get') {
101
- if (protocol && typeof protocol.getData === 'function') {
102
- return await protocol.getData({ object: params.object, id: params.id, expand: params.expand, select: params.select });
103
- }
104
- if (ql) {
105
- let all = await ql.find(params.object);
106
- if (!all) all = [];
107
- const match = all.find((i: any) => i.id === params.id);
108
- return match ? { object: params.object, id: params.id, record: match } : null;
109
- }
110
- throw { statusCode: 503, message: 'Data service not available' };
111
- }
112
-
113
- if (action === 'update') {
114
- if (ql && params.id) {
115
- let all = await ql.find(params.object);
116
- if (all && (all as any).value) all = (all as any).value;
117
- if (!all) all = [];
118
- const existing = all.find((i: any) => i.id === params.id);
119
- if (!existing) throw new Error('[ObjectStack] Not Found');
120
- await ql.update(params.object, params.data, { where: { id: params.id } });
121
- return { object: params.object, id: params.id, record: { ...existing, ...params.data } };
122
- }
123
- throw { statusCode: 503, message: 'Data service not available' };
124
- }
125
-
126
- if (action === 'delete') {
127
- if (ql) {
128
- await ql.delete(params.object, { where: { id: params.id } });
129
- return { object: params.object, id: params.id, deleted: true };
130
- }
131
- throw { statusCode: 503, message: 'Data service not available' };
132
- }
133
-
134
- if (action === 'query' || action === 'find') {
135
- if (protocol && typeof protocol.findData === 'function') {
136
- // Build query: use explicit params.query if provided, otherwise extract query fields from params
137
- const query = params.query || (() => {
138
- const { object, ...rest } = params;
139
- return rest;
140
- })();
141
- return await protocol.findData({ object: params.object, query });
142
- }
143
- if (ql) {
144
- let all = await ql.find(params.object);
145
- if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value;
146
- if (!all) all = [];
147
- return { object: params.object, records: all, total: all.length };
148
- }
149
- throw { statusCode: 503, message: 'Data service not available' };
150
- }
151
-
152
- if (action === 'batch') {
153
- // Batch operations — not yet supported via direct service dispatch
154
- return { object: params.object, results: [] };
155
- }
156
-
157
- throw { statusCode: 400, message: `Unknown data action: ${action}` };
158
- }
159
-
160
- /**
161
- * Generates the discovery JSON response for the API root.
162
- *
163
- * Uses the same async `resolveService()` fallback chain that request
164
- * handlers use, so the reported service status is always consistent
165
- * with the actual runtime availability.
166
- */
167
- async getDiscoveryInfo(prefix: string) {
168
- // Resolve all services through the same async fallback chain
169
- // that request handlers (handleI18n, handleAuth, …) use.
170
- const [
171
- authSvc, graphqlSvc, searchSvc, realtimeSvc, filesSvc,
172
- analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc,
173
- uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc,
174
- ] = await Promise.all([
175
- this.resolveService(CoreServiceName.enum.auth),
176
- this.resolveService(CoreServiceName.enum.graphql),
177
- this.resolveService(CoreServiceName.enum.search),
178
- this.resolveService(CoreServiceName.enum.realtime),
179
- this.resolveService(CoreServiceName.enum['file-storage']),
180
- this.resolveService(CoreServiceName.enum.analytics),
181
- this.resolveService(CoreServiceName.enum.workflow),
182
- this.resolveService(CoreServiceName.enum.ai),
183
- this.resolveService(CoreServiceName.enum.notification),
184
- this.resolveService(CoreServiceName.enum.i18n),
185
- this.resolveService(CoreServiceName.enum.ui),
186
- this.resolveService(CoreServiceName.enum.automation),
187
- this.resolveService(CoreServiceName.enum.cache),
188
- this.resolveService(CoreServiceName.enum.queue),
189
- this.resolveService(CoreServiceName.enum.job),
190
- ]);
191
-
192
- const hasAuth = !!authSvc;
193
- const hasGraphQL = !!(graphqlSvc || this.kernel.graphql);
194
- const hasSearch = !!searchSvc;
195
- const hasWebSockets = !!realtimeSvc;
196
- const hasFiles = !!filesSvc;
197
- const hasAnalytics = !!analyticsSvc;
198
- const hasWorkflow = !!workflowSvc;
199
- const hasAi = !!aiSvc;
200
- const hasNotification = !!notificationSvc;
201
- const hasI18n = !!i18nSvc;
202
- const hasUi = !!uiSvc;
203
- const hasAutomation = !!automationSvc;
204
- const hasCache = !!cacheSvc;
205
- const hasQueue = !!queueSvc;
206
- const hasJob = !!jobSvc;
207
-
208
- // Routes are only exposed when a plugin provides the service
209
- const routes = {
210
- data: `${prefix}/data`,
211
- metadata: `${prefix}/meta`,
212
- packages: `${prefix}/packages`,
213
- auth: hasAuth ? `${prefix}/auth` : undefined,
214
- ui: hasUi ? `${prefix}/ui` : undefined,
215
- graphql: hasGraphQL ? `${prefix}/graphql` : undefined,
216
- storage: hasFiles ? `${prefix}/storage` : undefined,
217
- analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
218
- automation: hasAutomation ? `${prefix}/automation` : undefined,
219
- workflow: hasWorkflow ? `${prefix}/workflow` : undefined,
220
- realtime: hasWebSockets ? `${prefix}/realtime` : undefined,
221
- notifications: hasNotification ? `${prefix}/notifications` : undefined,
222
- ai: hasAi ? `${prefix}/ai` : undefined,
223
- i18n: hasI18n ? `${prefix}/i18n` : undefined,
224
- };
225
-
226
- // Build per-service status map
227
- // handlerReady: true means the dispatcher has a real, bound handler for this route.
228
- // handlerReady: false means the route is present in the discovery table but may not
229
- // yet have a concrete implementation or may be served by a stub.
230
- const svcAvailable = (route?: string, provider?: string) => ({
231
- enabled: true, status: 'available' as const, handlerReady: true, route, provider,
232
- });
233
- const svcUnavailable = (name: string) => ({
234
- enabled: false, status: 'unavailable' as const, handlerReady: false,
235
- message: `Install a ${name} plugin to enable`,
236
- });
237
-
238
- // Derive locale info from actual i18n service when available
239
- let locale = { default: 'en', supported: ['en'], timezone: 'UTC' };
240
- if (hasI18n && i18nSvc) {
241
- const defaultLocale = typeof i18nSvc.getDefaultLocale === 'function'
242
- ? i18nSvc.getDefaultLocale() : 'en';
243
- const locales = typeof i18nSvc.getLocales === 'function'
244
- ? i18nSvc.getLocales() : [];
245
- locale = {
246
- default: defaultLocale,
247
- supported: locales.length > 0 ? locales : [defaultLocale],
248
- timezone: 'UTC',
249
- };
250
- }
251
-
252
- return {
253
- name: 'ObjectOS',
254
- version: '1.0.0',
255
- environment: getEnv('NODE_ENV', 'development'),
256
- routes,
257
- endpoints: routes, // Alias for backward compatibility with some clients
258
- features: {
259
- graphql: hasGraphQL,
260
- search: hasSearch,
261
- websockets: hasWebSockets,
262
- files: hasFiles,
263
- analytics: hasAnalytics,
264
- ai: hasAi,
265
- workflow: hasWorkflow,
266
- notifications: hasNotification,
267
- i18n: hasI18n,
268
- },
269
- services: {
270
- // Kernel-provided (always available via protocol implementation)
271
- metadata: { enabled: true, status: 'degraded' as const, handlerReady: true, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
272
- data: svcAvailable(routes.data, 'kernel'),
273
- // Plugin-provided — only available when a plugin registers the service
274
- auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable('auth'),
275
- automation: hasAutomation ? svcAvailable(routes.automation) : svcUnavailable('automation'),
276
- analytics: hasAnalytics ? svcAvailable(routes.analytics) : svcUnavailable('analytics'),
277
- cache: hasCache ? svcAvailable() : svcUnavailable('cache'),
278
- queue: hasQueue ? svcAvailable() : svcUnavailable('queue'),
279
- job: hasJob ? svcAvailable() : svcUnavailable('job'),
280
- ui: hasUi ? svcAvailable(routes.ui) : svcUnavailable('ui'),
281
- workflow: hasWorkflow ? svcAvailable(routes.workflow) : svcUnavailable('workflow'),
282
- realtime: hasWebSockets ? svcAvailable(routes.realtime) : svcUnavailable('realtime'),
283
- notification: hasNotification ? svcAvailable(routes.notifications) : svcUnavailable('notification'),
284
- ai: hasAi ? svcAvailable(routes.ai) : svcUnavailable('ai'),
285
- i18n: hasI18n ? svcAvailable(routes.i18n) : svcUnavailable('i18n'),
286
- graphql: hasGraphQL ? svcAvailable(routes.graphql) : svcUnavailable('graphql'),
287
- 'file-storage': hasFiles ? svcAvailable(routes.storage) : svcUnavailable('file-storage'),
288
- search: hasSearch ? svcAvailable() : svcUnavailable('search'),
289
- },
290
- locale,
291
- };
292
- }
293
-
294
- /**
295
- * Handles GraphQL requests
296
- */
297
- async handleGraphQL(body: { query: string; variables?: any }, context: HttpProtocolContext) {
298
- if (!body || !body.query) {
299
- throw { statusCode: 400, message: 'Missing query in request body' };
300
- }
301
-
302
- if (typeof this.kernel.graphql !== 'function') {
303
- throw { statusCode: 501, message: 'GraphQL service not available' };
304
- }
305
-
306
- return this.kernel.graphql(body.query, body.variables, {
307
- request: context.request
308
- });
309
- }
310
-
311
- /**
312
- * Handles Auth requests
313
- * path: sub-path after /auth/
314
- */
315
- async handleAuth(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
316
- // 1. Try generic Auth Service
317
- const authService = await this.getService(CoreServiceName.enum.auth);
318
- if (authService && typeof authService.handler === 'function') {
319
- const response = await authService.handler(context.request, context.response);
320
- return { handled: true, result: response };
321
- }
322
-
323
- // 2. Mock fallback for MSW/test environments when no auth service is registered
324
- const normalizedPath = path.replace(/^\/+/, '');
325
- return this.mockAuthFallback(normalizedPath, method, body);
326
- }
327
-
328
- /**
329
- * Provides mock auth responses for core better-auth endpoints when
330
- * AuthPlugin is not loaded (e.g. MSW/browser-only environments).
331
- * This ensures registration/sign-in flows do not 404 in mock mode.
332
- */
333
- private mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
334
- const m = method.toUpperCase();
335
- const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours
336
-
337
- // POST sign-up/email
338
- if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
339
- const id = `mock_${randomUUID()}`;
340
- return {
341
- handled: true,
342
- response: {
343
- status: 200,
344
- body: {
345
- user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
346
- session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
347
- },
348
- },
349
- };
350
- }
351
-
352
- // POST sign-in/email or login
353
- if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
354
- const id = `mock_${randomUUID()}`;
355
- return {
356
- handled: true,
357
- response: {
358
- status: 200,
359
- body: {
360
- user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
361
- session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
362
- },
363
- },
364
- };
365
- }
366
-
367
- // GET get-session
368
- if (path === 'get-session' && m === 'GET') {
369
- return {
370
- handled: true,
371
- response: { status: 200, body: { session: null, user: null } },
372
- };
373
- }
374
-
375
- // POST sign-out
376
- if (path === 'sign-out' && m === 'POST') {
377
- return {
378
- handled: true,
379
- response: { status: 200, body: { success: true } },
380
- };
381
- }
382
-
383
- return { handled: false };
384
- }
385
-
386
- /**
387
- * Handles Metadata requests
388
- * Standard: /metadata/:type/:name
389
- * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
390
- */
391
- async handleMetadata(path: string, _context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise<HttpDispatcherResult> {
392
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
393
-
394
- // GET /metadata/types
395
- if (parts[0] === 'types') {
396
- // PRIORITY 1: Try MetadataService directly (includes both typeRegistry with agent/tool AND runtime-registered types)
397
- const metadataService = await this.resolveService('metadata');
398
-
399
- if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') {
400
- try {
401
- const types = await (metadataService as any).getRegisteredTypes();
402
- return { handled: true, response: this.success({ types }) };
403
- } catch (e: any) {
404
- // Log error but continue to fallbacks
405
- console.warn('[HttpDispatcher] MetadataService.getRegisteredTypes() failed:', e.message);
406
- }
407
- }
408
- // PRIORITY 2: Try protocol service (returns SchemaRegistry types only - missing agent/tool)
409
- const protocol = await this.resolveService('protocol');
410
- if (protocol && typeof protocol.getMetaTypes === 'function') {
411
- const result = await protocol.getMetaTypes({});
412
- return { handled: true, response: this.success(result) };
413
- }
414
- // Last resort: hardcoded defaults
415
- return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
416
- }
417
-
418
- // GET /metadata/:type/:name/published → get published version
419
- if (parts.length === 3 && parts[2] === 'published' && (!method || method === 'GET')) {
420
- const [type, name] = parts;
421
- const metadataService = await this.getService(CoreServiceName.enum.metadata);
422
- if (metadataService && typeof (metadataService as any).getPublished === 'function') {
423
- const data = await (metadataService as any).getPublished(type, name);
424
- if (data === undefined) return { handled: true, response: this.error('Not found', 404) };
425
- return { handled: true, response: this.success(data) };
426
- }
427
- // Fallback — try MetadataService via resolveService
428
- const metaSvc = await this.resolveService('metadata');
429
- if (metaSvc && typeof (metaSvc as any).getPublished === 'function') {
430
- try {
431
- const fallbackData = await (metaSvc as any).getPublished(type, name);
432
- if (fallbackData !== undefined) return { handled: true, response: this.success(fallbackData) };
433
- } catch { /* fall through */ }
434
- }
435
- return { handled: true, response: this.error('Not found', 404) };
436
- }
437
-
438
- // /metadata/:type/:name
439
- if (parts.length === 2) {
440
- const [type, name] = parts;
441
- // Extract optional package filter from query string
442
- const packageId = query?.package || undefined;
443
-
444
- // PUT /metadata/:type/:name (Save)
445
- if (method === 'PUT' && body) {
446
- // Try to get the protocol service directly
447
- const protocol = await this.resolveService('protocol');
448
-
449
- if (protocol && typeof protocol.saveMetaItem === 'function') {
450
- try {
451
- const result = await protocol.saveMetaItem({ type, name, item: body });
452
- return { handled: true, response: this.success(result) };
453
- } catch (e: any) {
454
- return { handled: true, response: this.error(e.message, 400) };
455
- }
456
- }
457
-
458
- // Fallback: try MetadataService directly
459
- const metaSvc = await this.resolveService('metadata');
460
- if (metaSvc && typeof (metaSvc as any).saveItem === 'function') {
461
- try {
462
- const data = await (metaSvc as any).saveItem(type, name, body);
463
- return { handled: true, response: this.success(data) };
464
- } catch (e: any) {
465
- return { handled: true, response: this.error(e.message || 'Save not supported', 501) };
466
- }
467
- }
468
- return { handled: true, response: this.error('Save not supported', 501) };
469
- }
470
-
471
- try {
472
- // Try specific calls based on type
473
- if (type === 'objects' || type === 'object') {
474
- // Try ObjectQL service directly
475
- const qlService = await this.getObjectQLService();
476
- if (qlService?.registry) {
477
- const data = qlService.registry.getObject(name);
478
- if (data) return { handled: true, response: this.success(data) };
479
- }
480
- return { handled: true, response: this.error('Not found', 404) };
481
- }
482
-
483
- // Normalize plural URL paths to singular registry type names
484
- const singularType = pluralToSingular(type);
485
-
486
- // Try Protocol Service First (Preferred)
487
- const protocol = await this.resolveService('protocol');
488
- if (protocol && typeof protocol.getMetaItem === 'function') {
489
- try {
490
- const data = await protocol.getMetaItem({ type: singularType, name, packageId });
491
- return { handled: true, response: this.success(data) };
492
- } catch (e: any) {
493
- // Protocol might throw if not found or not supported
494
- }
495
- }
496
-
497
- // Try MetadataService for runtime-registered types
498
- const metaSvc = await this.resolveService('metadata');
499
- if (metaSvc && typeof (metaSvc as any).getItem === 'function') {
500
- try {
501
- const data = await (metaSvc as any).getItem(singularType, name);
502
- if (data) return { handled: true, response: this.success(data) };
503
- } catch { /* not found */ }
504
- }
505
- return { handled: true, response: this.error('Not found', 404) };
506
- } catch (e: any) {
507
- // Fallback: treat first part as object name if only 1 part (handled below)
508
- // But here we are deep in 2 parts. Must be an error.
509
- return { handled: true, response: this.error(e.message, 404) };
510
- }
511
- }
512
-
513
- // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy)
514
- if (parts.length === 1) {
515
- const typeOrName = parts[0];
516
- // Extract optional package filter from query string
517
- const packageId = query?.package || undefined;
518
-
519
- // Try protocol service first for any type
520
- const protocol = await this.resolveService('protocol');
521
- if (protocol && typeof protocol.getMetaItems === 'function') {
522
- try {
523
- const data = await protocol.getMetaItems({ type: typeOrName, packageId });
524
- // Return any valid response from protocol (including empty items arrays)
525
- if (data && (data.items !== undefined || Array.isArray(data))) {
526
- return { handled: true, response: this.success(data) };
527
- }
528
- } catch {
529
- // Protocol doesn't know this type, fall through
530
- }
531
- }
532
-
533
- // Try MetadataService directly for runtime-registered metadata (agents, tools, etc.)
534
- const metadataService = await this.getService(CoreServiceName.enum.metadata);
535
- if (metadataService && typeof (metadataService as any).list === 'function') {
536
- try {
537
- let items = await (metadataService as any).list(typeOrName);
538
- // Respect package filter: MetadataService.list() returns ALL items,
539
- // so filter by _packageId when a specific package is requested.
540
- if (packageId && items && items.length > 0) {
541
- items = items.filter((item: any) => item?._packageId === packageId);
542
- }
543
- if (items && items.length > 0) {
544
- return { handled: true, response: this.success({ type: typeOrName, items }) };
545
- }
546
- } catch (e: any) {
547
- // MetadataService doesn't know this type or failed, continue to other fallbacks
548
- // Sanitize typeOrName to prevent log injection (CodeQL warning)
549
- const sanitizedType = String(typeOrName).replace(/[\r\n\t]/g, '');
550
- console.debug(`[HttpDispatcher] MetadataService.list() failed for type:`, sanitizedType, 'error:', e.message);
551
- }
552
- }
553
-
554
- // Try ObjectQL registry directly for object/type lookups
555
- const qlService = await this.getObjectQLService();
556
- if (qlService?.registry) {
557
- if (typeOrName === 'objects') {
558
- const objs = qlService.registry.getAllObjects(packageId);
559
- return { handled: true, response: this.success({ type: 'object', items: objs }) };
560
- }
561
- // Try listing items of the given type
562
- const items = qlService.registry.listItems?.(typeOrName, packageId);
563
- if (items && items.length > 0) {
564
- return { handled: true, response: this.success({ type: typeOrName, items }) };
565
- }
566
- // Legacy: treat as object name
567
- const obj = qlService.registry.getObject(typeOrName);
568
- if (obj) return { handled: true, response: this.success(obj) };
569
- }
570
- return { handled: true, response: this.error('Not found', 404) };
571
- }
572
-
573
- // GET /metadata — return available metadata types
574
- if (parts.length === 0) {
575
- // Try MetadataService for registered types
576
- const metadataService = await this.resolveService('metadata');
577
- if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') {
578
- try {
579
- const types = await (metadataService as any).getRegisteredTypes();
580
- return { handled: true, response: this.success({ types }) };
581
- } catch { /* fall through */ }
582
- }
583
- // Try protocol service for dynamic types
584
- const protocol = await this.resolveService('protocol');
585
- if (protocol && typeof protocol.getMetaTypes === 'function') {
586
- const result = await protocol.getMetaTypes({});
587
- return { handled: true, response: this.success(result) };
588
- }
589
- return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
590
- }
591
-
592
- return { handled: false };
593
- }
594
-
595
- /**
596
- * Handles Data requests
597
- * path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query")
598
- */
599
- async handleData(path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
600
- const parts = path.replace(/^\/+/, '').split('/');
601
- const objectName = parts[0];
602
-
603
- if (!objectName) {
604
- return { handled: true, response: this.error('Object name required', 400) };
605
- }
606
-
607
- const m = method.toUpperCase();
608
-
609
- // 1. Custom Actions (query, batch)
610
- if (parts.length > 1) {
611
- const action = parts[1];
612
-
613
- // POST /data/:object/query
614
- if (action === 'query' && m === 'POST') {
615
- // Spec: returns FindDataResponse = { object, records, total?, hasMore? }
616
- const result = await this.callData('query', { object: objectName, ...body });
617
- return { handled: true, response: this.success(result) };
618
- }
619
-
620
- // POST /data/:object/batch
621
- if (action === 'batch' && m === 'POST') {
622
- const result = await this.callData('batch', { object: objectName, ...body });
623
- return { handled: true, response: this.success(result) };
624
- }
625
-
626
- // GET /data/:object/:id
627
- if (parts.length === 2 && m === 'GET') {
628
- const id = parts[1];
629
- // Spec: Only select/expand are allowlisted query params for GET by ID.
630
- // All other query parameters are discarded to prevent parameter pollution.
631
- const { select, expand } = query || {};
632
- const allowedParams: Record<string, unknown> = {};
633
- if (select != null) allowedParams.select = select;
634
- if (expand != null) allowedParams.expand = expand;
635
- // Spec: returns GetDataResponse = { object, id, record }
636
- const result = await this.callData('get', { object: objectName, id, ...allowedParams });
637
- return { handled: true, response: this.success(result) };
638
- }
639
-
640
- // PATCH /data/:object/:id
641
- if (parts.length === 2 && m === 'PATCH') {
642
- const id = parts[1];
643
- // Spec: returns UpdateDataResponse = { object, id, record }
644
- const result = await this.callData('update', { object: objectName, id, data: body });
645
- return { handled: true, response: this.success(result) };
646
- }
647
-
648
- // DELETE /data/:object/:id
649
- if (parts.length === 2 && m === 'DELETE') {
650
- const id = parts[1];
651
- // Spec: returns DeleteDataResponse = { object, id, deleted }
652
- const result = await this.callData('delete', { object: objectName, id });
653
- return { handled: true, response: this.success(result) };
654
- }
655
- } else {
656
- // GET /data/:object (List)
657
- if (m === 'GET') {
658
- // ── Normalize HTTP transport params → Spec canonical (QueryAST) ──
659
- // HTTP GET query params use transport-level names (filter, sort, top,
660
- // skip, select, expand) which are normalized here to canonical
661
- // QueryAST field names (where, orderBy, limit, offset, fields,
662
- // expand) before forwarding to the data service layer.
663
- // The protocol.ts findData() method performs a deeper normalization
664
- // pass, but pre-normalizing here ensures the data service always receives
665
- // Spec-canonical keys.
666
- const normalized: Record<string, unknown> = { ...query };
667
-
668
- // filter/filters → where
669
- // Note: `filter` is the canonical HTTP *transport* parameter name
670
- // (see HttpFindQueryParamsSchema). It is normalized here to the
671
- // canonical *QueryAST* field name `where` before data dispatch.
672
- // `filters` (plural) is a deprecated alias for `filter`.
673
- if (normalized.filter != null || normalized.filters != null) {
674
- normalized.where = normalized.where ?? normalized.filter ?? normalized.filters;
675
- delete normalized.filter;
676
- delete normalized.filters;
677
- }
678
- // select → fields
679
- if (normalized.select != null && normalized.fields == null) {
680
- normalized.fields = normalized.select;
681
- delete normalized.select;
682
- }
683
- // sort → orderBy
684
- if (normalized.sort != null && normalized.orderBy == null) {
685
- normalized.orderBy = normalized.sort;
686
- delete normalized.sort;
687
- }
688
- // top → limit
689
- if (normalized.top != null && normalized.limit == null) {
690
- normalized.limit = normalized.top;
691
- delete normalized.top;
692
- }
693
- // skip → offset
694
- if (normalized.skip != null && normalized.offset == null) {
695
- normalized.offset = normalized.skip;
696
- delete normalized.skip;
697
- }
698
-
699
- // Spec: returns FindDataResponse = { object, records, total?, hasMore? }
700
- const result = await this.callData('query', { object: objectName, query: normalized });
701
- return { handled: true, response: this.success(result) };
702
- }
703
-
704
- // POST /data/:object (Create)
705
- if (m === 'POST') {
706
- // Spec: returns CreateDataResponse = { object, id, record }
707
- const result = await this.callData('create', { object: objectName, data: body });
708
- const res = this.success(result);
709
- res.status = 201;
710
- return { handled: true, response: res };
711
- }
712
- }
713
-
714
- return { handled: false };
715
- }
716
-
717
- /**
718
- * Handles Analytics requests
719
- * path: sub-path after /analytics/
720
- */
721
- async handleAnalytics(path: string, method: string, body: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
722
- const analyticsService = await this.getService(CoreServiceName.enum.analytics);
723
- if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
724
-
725
- const m = method.toUpperCase();
726
- const subPath = path.replace(/^\/+/, '');
727
-
728
- // POST /analytics/query
729
- if (subPath === 'query' && m === 'POST') {
730
- const result = await analyticsService.query(body);
731
- return { handled: true, response: this.success(result) };
732
- }
733
-
734
- // GET /analytics/meta
735
- if (subPath === 'meta' && m === 'GET') {
736
- const result = await analyticsService.getMeta();
737
- return { handled: true, response: this.success(result) };
738
- }
739
-
740
- // POST /analytics/sql (Dry-run or debug)
741
- if (subPath === 'sql' && m === 'POST') {
742
- // Assuming service has generateSql method
743
- const result = await analyticsService.generateSql(body);
744
- return { handled: true, response: this.success(result) };
745
- }
746
-
747
- return { handled: false };
748
- }
749
-
750
- /**
751
- * Handles i18n requests
752
- * path: sub-path after /i18n/
753
- *
754
- * Routes:
755
- * GET /locales → getLocales
756
- * GET /translations/:locale → getTranslations (locale from path)
757
- * GET /translations?locale=xx → getTranslations (locale from query)
758
- * GET /labels/:object/:locale → getFieldLabels (both from path)
759
- * GET /labels/:object?locale=xx → getFieldLabels (locale from query)
760
- */
761
- async handleI18n(path: string, method: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
762
- const i18nService = await this.getService(CoreServiceName.enum.i18n);
763
- if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) };
764
-
765
- const m = method.toUpperCase();
766
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
767
-
768
- if (m !== 'GET') return { handled: false };
769
-
770
- // GET /i18n/locales
771
- if (parts[0] === 'locales' && parts.length === 1) {
772
- const locales = i18nService.getLocales();
773
- return { handled: true, response: this.success({ locales }) };
774
- }
775
-
776
- // GET /i18n/translations/:locale OR /i18n/translations?locale=xx
777
- if (parts[0] === 'translations') {
778
- const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale;
779
- if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
780
-
781
- let translations = i18nService.getTranslations(locale);
782
-
783
- // Locale fallback: try resolving to an available locale when
784
- // the exact code yields empty translations (e.g. zh → zh-CN).
785
- if (Object.keys(translations).length === 0) {
786
- const availableLocales = typeof i18nService.getLocales === 'function'
787
- ? i18nService.getLocales() : [];
788
- const resolved = resolveLocale(locale, availableLocales);
789
- if (resolved && resolved !== locale) {
790
- translations = i18nService.getTranslations(resolved);
791
- return { handled: true, response: this.success({ locale: resolved, requestedLocale: locale, translations }) };
792
- }
793
- }
794
-
795
- return { handled: true, response: this.success({ locale, translations }) };
796
- }
797
-
798
- // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx
799
- if (parts[0] === 'labels' && parts.length >= 2) {
800
- const objectName = decodeURIComponent(parts[1]);
801
- let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
802
- if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
803
-
804
- // Locale fallback for labels endpoint
805
- const availableLocales = typeof i18nService.getLocales === 'function'
806
- ? i18nService.getLocales() : [];
807
- const resolved = resolveLocale(locale, availableLocales);
808
- if (resolved) locale = resolved;
809
-
810
- if (typeof i18nService.getFieldLabels === 'function') {
811
- const labels = i18nService.getFieldLabels(objectName, locale);
812
- return { handled: true, response: this.success({ object: objectName, locale, labels }) };
813
- }
814
- // Fallback: derive field labels from full translation bundle
815
- const translations = i18nService.getTranslations(locale);
816
- const prefix = `o.${objectName}.fields.`;
817
- const labels: Record<string, string> = {};
818
- for (const [key, value] of Object.entries(translations)) {
819
- if (key.startsWith(prefix)) {
820
- labels[key.substring(prefix.length)] = value as string;
821
- }
822
- }
823
- return { handled: true, response: this.success({ object: objectName, locale, labels }) };
824
- }
825
-
826
- return { handled: false };
827
- }
828
-
829
- /**
830
- * Handles Package Management requests
831
- *
832
- * REST Endpoints:
833
- * - GET /packages → list all installed packages
834
- * - GET /packages/:id → get a specific package
835
- * - POST /packages → install a new package
836
- * - DELETE /packages/:id → uninstall a package
837
- * - PATCH /packages/:id/enable → enable a package
838
- * - PATCH /packages/:id/disable → disable a package
839
- * - POST /packages/:id/publish → publish a package (metadata snapshot)
840
- * - POST /packages/:id/revert → revert a package to last published state
841
- *
842
- * Uses ObjectQL SchemaRegistry directly (via the 'objectql' service).
843
- */
844
- async handlePackages(path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
845
- const m = method.toUpperCase();
846
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
847
-
848
- // Try to get SchemaRegistry from the ObjectQL service
849
- const qlService = await this.getObjectQLService();
850
- const registry = qlService?.registry;
851
-
852
- // If no registry available, return 503
853
- if (!registry) {
854
- return { handled: true, response: this.error('Package service not available', 503) };
855
- }
856
-
857
- try {
858
- // GET /packages → list packages
859
- if (parts.length === 0 && m === 'GET') {
860
- let packages = registry.getAllPackages();
861
- // Apply optional filters
862
- if (query?.status) {
863
- packages = packages.filter((p: any) => p.status === query.status);
864
- }
865
- if (query?.type) {
866
- packages = packages.filter((p: any) => p.manifest?.type === query.type);
867
- }
868
- return { handled: true, response: this.success({ packages, total: packages.length }) };
869
- }
870
-
871
- // POST /packages → install package
872
- if (parts.length === 0 && m === 'POST') {
873
- const pkg = registry.installPackage(body.manifest || body, body.settings);
874
- const res = this.success(pkg);
875
- res.status = 201;
876
- return { handled: true, response: res };
877
- }
878
-
879
- // PATCH /packages/:id/enable
880
- if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') {
881
- const id = decodeURIComponent(parts[0]);
882
- const pkg = registry.enablePackage(id);
883
- if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
884
- return { handled: true, response: this.success(pkg) };
885
- }
886
-
887
- // PATCH /packages/:id/disable
888
- if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') {
889
- const id = decodeURIComponent(parts[0]);
890
- const pkg = registry.disablePackage(id);
891
- if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
892
- return { handled: true, response: this.success(pkg) };
893
- }
894
-
895
- // POST /packages/:id/publish → publish package metadata
896
- if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') {
897
- const id = decodeURIComponent(parts[0]);
898
- const metadataService = await this.getService(CoreServiceName.enum.metadata);
899
- if (metadataService && typeof (metadataService as any).publishPackage === 'function') {
900
- const result = await (metadataService as any).publishPackage(id, body || {});
901
- return { handled: true, response: this.success(result) };
902
- }
903
- return { handled: true, response: this.error('Metadata service not available', 503) };
904
- }
905
-
906
- // POST /packages/:id/revert → revert package to last published state
907
- if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') {
908
- const id = decodeURIComponent(parts[0]);
909
- const metadataService = await this.getService(CoreServiceName.enum.metadata);
910
- if (metadataService && typeof (metadataService as any).revertPackage === 'function') {
911
- await (metadataService as any).revertPackage(id);
912
- return { handled: true, response: this.success({ success: true }) };
913
- }
914
- return { handled: true, response: this.error('Metadata service not available', 503) };
915
- }
916
-
917
- // GET /packages/:id → get package
918
- if (parts.length === 1 && m === 'GET') {
919
- const id = decodeURIComponent(parts[0]);
920
- const pkg = registry.getPackage(id);
921
- if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
922
- return { handled: true, response: this.success(pkg) };
923
- }
924
-
925
- // DELETE /packages/:id → uninstall package
926
- if (parts.length === 1 && m === 'DELETE') {
927
- const id = decodeURIComponent(parts[0]);
928
- const success = registry.uninstallPackage(id);
929
- if (!success) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
930
- return { handled: true, response: this.success({ success: true }) };
931
- }
932
- } catch (e: any) {
933
- return { handled: true, response: this.error(e.message, e.statusCode || 500) };
934
- }
935
-
936
- return { handled: false };
937
- }
938
-
939
-
940
-
941
- /**
942
- * Handles Storage requests
943
- * path: sub-path after /storage/
944
- */
945
- async handleStorage(path: string, method: string, file: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
946
- const storageService = await this.getService(CoreServiceName.enum['file-storage']) || this.kernel.services?.['file-storage'];
947
- if (!storageService) {
948
- return { handled: true, response: this.error('File storage not configured', 501) };
949
- }
950
-
951
- const m = method.toUpperCase();
952
- const parts = path.replace(/^\/+/, '').split('/');
953
-
954
- // POST /storage/upload
955
- if (parts[0] === 'upload' && m === 'POST') {
956
- if (!file) {
957
- return { handled: true, response: this.error('No file provided', 400) };
958
- }
959
- const result = await storageService.upload(file, { request: context.request });
960
- return { handled: true, response: this.success(result) };
961
- }
962
-
963
- // GET /storage/file/:id
964
- if (parts[0] === 'file' && parts[1] && m === 'GET') {
965
- const id = parts[1];
966
- const result = await storageService.download(id, { request: context.request });
967
-
968
- // Result can be URL (redirect), Stream/Blob, or metadata
969
- if (result.url && result.redirect) {
970
- // Must be handled by adapter to do actual redirect
971
- return { handled: true, result: { type: 'redirect', url: result.url } };
972
- }
973
-
974
- if (result.stream) {
975
- // Must be handled by adapter to pipe stream
976
- return {
977
- handled: true,
978
- result: {
979
- type: 'stream',
980
- stream: result.stream,
981
- headers: {
982
- 'Content-Type': result.mimeType || 'application/octet-stream',
983
- 'Content-Length': result.size
984
- }
985
- }
986
- };
987
- }
988
-
989
- return { handled: true, response: this.success(result) };
990
- }
991
-
992
- return { handled: false };
993
- }
994
-
995
- /**
996
- * Handles UI requests
997
- * path: sub-path after /ui/
998
- */
999
- async handleUi(path: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1000
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
1001
-
1002
- // GET /ui/view/:object (with optional type param)
1003
- if (parts[0] === 'view' && parts[1]) {
1004
- const objectName = parts[1];
1005
- // Support both path param /view/obj/list AND query param /view/obj?type=list
1006
- const type = parts[2] || query?.type || 'list';
1007
-
1008
- const protocol = await this.resolveService('protocol');
1009
-
1010
- if (protocol && typeof protocol.getUiView === 'function') {
1011
- try {
1012
- const result = await protocol.getUiView({ object: objectName, type });
1013
- return { handled: true, response: this.success(result) };
1014
- } catch (e: any) {
1015
- return { handled: true, response: this.error(e.message, 500) };
1016
- }
1017
- } else {
1018
- return { handled: true, response: this.error('Protocol service not available', 503) };
1019
- }
1020
- }
1021
-
1022
- return { handled: false };
1023
- }
1024
-
1025
- /**
1026
- * Handles Automation requests
1027
- * path: sub-path after /automation/
1028
- *
1029
- * Routes:
1030
- * GET / → listFlows
1031
- * GET /:name → getFlow
1032
- * POST / → createFlow (registerFlow)
1033
- * PUT /:name → updateFlow
1034
- * DELETE /:name → deleteFlow (unregisterFlow)
1035
- * POST /:name/trigger → execute (legacy: trigger/:name also supported)
1036
- * POST /:name/toggle → toggleFlow
1037
- * GET /:name/runs → listRuns
1038
- * GET /:name/runs/:runId → getRun
1039
- */
1040
- async handleAutomation(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise<HttpDispatcherResult> {
1041
- const automationService = await this.getService(CoreServiceName.enum.automation);
1042
- if (!automationService) return { handled: false };
1043
-
1044
- const m = method.toUpperCase();
1045
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
1046
-
1047
- // Legacy: POST /automation/trigger/:name
1048
- if (parts[0] === 'trigger' && parts[1] && m === 'POST') {
1049
- const triggerName = parts[1];
1050
- if (typeof automationService.trigger === 'function') {
1051
- const result = await automationService.trigger(triggerName, body, { request: context.request });
1052
- return { handled: true, response: this.success(result) };
1053
- }
1054
- // Fallback to execute
1055
- if (typeof automationService.execute === 'function') {
1056
- const result = await automationService.execute(triggerName, body);
1057
- return { handled: true, response: this.success(result) };
1058
- }
1059
- }
1060
-
1061
- // GET / → listFlows
1062
- if (parts.length === 0 && m === 'GET') {
1063
- if (typeof automationService.listFlows === 'function') {
1064
- const names = await automationService.listFlows();
1065
- return { handled: true, response: this.success({ flows: names, total: names.length, hasMore: false }) };
1066
- }
1067
- }
1068
-
1069
- // POST / → createFlow
1070
- if (parts.length === 0 && m === 'POST') {
1071
- if (typeof automationService.registerFlow === 'function') {
1072
- automationService.registerFlow(body?.name, body);
1073
- return { handled: true, response: this.success(body) };
1074
- }
1075
- }
1076
-
1077
- // Routes with :name
1078
- if (parts.length >= 1) {
1079
- const name = parts[0];
1080
-
1081
- // POST /:name/trigger → execute
1082
- if (parts[1] === 'trigger' && m === 'POST') {
1083
- if (typeof automationService.execute === 'function') {
1084
- const result = await automationService.execute(name, body);
1085
- return { handled: true, response: this.success(result) };
1086
- }
1087
- }
1088
-
1089
- // POST /:name/toggle → toggleFlow
1090
- if (parts[1] === 'toggle' && m === 'POST') {
1091
- if (typeof automationService.toggleFlow === 'function') {
1092
- await automationService.toggleFlow(name, body?.enabled ?? true);
1093
- return { handled: true, response: this.success({ name, enabled: body?.enabled ?? true }) };
1094
- }
1095
- }
1096
-
1097
- // GET /:name/runs/:runId → getRun
1098
- if (parts[1] === 'runs' && parts[2] && m === 'GET') {
1099
- if (typeof automationService.getRun === 'function') {
1100
- const run = await automationService.getRun(parts[2]);
1101
- if (!run) return { handled: true, response: this.error('Execution not found', 404) };
1102
- return { handled: true, response: this.success(run) };
1103
- }
1104
- }
1105
-
1106
- // GET /:name/runs → listRuns
1107
- if (parts[1] === 'runs' && !parts[2] && m === 'GET') {
1108
- if (typeof automationService.listRuns === 'function') {
1109
- const options = query ? { limit: query.limit ? Number(query.limit) : undefined, cursor: query.cursor } : undefined;
1110
- const runs = await automationService.listRuns(name, options);
1111
- return { handled: true, response: this.success({ runs, hasMore: false }) };
1112
- }
1113
- }
1114
-
1115
- // GET /:name → getFlow (no sub-path)
1116
- if (parts.length === 1 && m === 'GET') {
1117
- if (typeof automationService.getFlow === 'function') {
1118
- const flow = await automationService.getFlow(name);
1119
- if (!flow) return { handled: true, response: this.error('Flow not found', 404) };
1120
- return { handled: true, response: this.success(flow) };
1121
- }
1122
- }
1123
-
1124
- // PUT /:name → updateFlow
1125
- if (parts.length === 1 && m === 'PUT') {
1126
- if (typeof automationService.registerFlow === 'function') {
1127
- automationService.registerFlow(name, body?.definition ?? body);
1128
- return { handled: true, response: this.success(body?.definition ?? body) };
1129
- }
1130
- }
1131
-
1132
- // DELETE /:name → deleteFlow
1133
- if (parts.length === 1 && m === 'DELETE') {
1134
- if (typeof automationService.unregisterFlow === 'function') {
1135
- automationService.unregisterFlow(name);
1136
- return { handled: true, response: this.success({ name, deleted: true }) };
1137
- }
1138
- }
1139
- }
1140
-
1141
- return { handled: false };
1142
- }
1143
-
1144
- private getServicesMap(): Record<string, any> {
1145
- if (this.kernel.services instanceof Map) {
1146
- return Object.fromEntries(this.kernel.services);
1147
- }
1148
- return this.kernel.services || {};
1149
- }
1150
-
1151
- private async getService(name: CoreServiceName) {
1152
- return this.resolveService(name);
1153
- }
1154
-
1155
- /**
1156
- * Resolve any service by name, supporting async factories.
1157
- * Fallback chain: getServiceAsync → getService (sync) → context.getService → services map.
1158
- * Only returns when a non-null service is found; otherwise falls through to the next step.
1159
- */
1160
- private async resolveService(name: string) {
1161
- // Prefer async resolution to support factory-based services (e.g. auth, analytics, protocol)
1162
- if (typeof this.kernel.getServiceAsync === 'function') {
1163
- try {
1164
- const svc = await this.kernel.getServiceAsync(name);
1165
- if (svc != null) return svc;
1166
- } catch {
1167
- // Service not registered or async resolution failed — fall through
1168
- }
1169
- }
1170
- if (typeof this.kernel.getService === 'function') {
1171
- try {
1172
- const svc = await this.kernel.getService(name);
1173
- if (svc != null) return svc;
1174
- } catch {
1175
- // Service not registered or sync resolution threw "is async" — fall through
1176
- }
1177
- }
1178
- if (this.kernel?.context?.getService) {
1179
- try {
1180
- const svc = await this.kernel.context.getService(name);
1181
- if (svc != null) return svc;
1182
- } catch {
1183
- // Service not registered — fall through
1184
- }
1185
- }
1186
- const services = this.getServicesMap();
1187
- return services[name];
1188
- }
1189
-
1190
- /**
1191
- * Get the ObjectQL service which provides access to SchemaRegistry.
1192
- * Tries multiple access patterns since kernel structure varies.
1193
- */
1194
- private async getObjectQLService(): Promise<any> {
1195
- // 1. Try via resolveService (handles async factories, sync, context, and map)
1196
- try {
1197
- const svc = await this.resolveService('objectql');
1198
- if (svc?.registry) return svc;
1199
- } catch { /* service not available */ }
1200
- return null;
1201
- }
1202
-
1203
- /**
1204
- * Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.)
1205
- * Resolves the AI service and its built-in route handlers, then dispatches.
1206
- */
1207
- async handleAI(subPath: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1208
- let aiService: any;
1209
- try {
1210
- aiService = await this.resolveService('ai');
1211
- } catch {
1212
- // AI service not registered
1213
- }
1214
-
1215
- if (!aiService) {
1216
- return {
1217
- handled: true,
1218
- response: {
1219
- status: 404,
1220
- body: { success: false, error: { message: 'AI service is not configured', code: 404 } },
1221
- },
1222
- };
1223
- }
1224
-
1225
- // The AI service exposes route definitions via buildAIRoutes.
1226
- // We match the request path against known AI route patterns.
1227
- const fullPath = `/api/v1${subPath}`;
1228
-
1229
- // Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id
1230
- const matchRoute = (pattern: string, path: string): Record<string, string> | null => {
1231
- const patternParts = pattern.split('/');
1232
- const pathParts = path.split('/');
1233
- if (patternParts.length !== pathParts.length) return null;
1234
- const params: Record<string, string> = {};
1235
- for (let i = 0; i < patternParts.length; i++) {
1236
- if (patternParts[i].startsWith(':')) {
1237
- params[patternParts[i].substring(1)] = pathParts[i];
1238
- } else if (patternParts[i] !== pathParts[i]) {
1239
- return null;
1240
- }
1241
- }
1242
- return params;
1243
- };
1244
-
1245
- // Try to get route definitions from the AI service's cached routes
1246
- const routes = (this.kernel as any).__aiRoutes as Array<{
1247
- method: string; path: string; handler: (req: any) => Promise<any>;
1248
- }> | undefined;
1249
-
1250
- if (!routes) {
1251
- return {
1252
- handled: true,
1253
- response: {
1254
- status: 503,
1255
- body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } },
1256
- },
1257
- };
1258
- }
1259
-
1260
- for (const route of routes) {
1261
- if (route.method !== method) continue;
1262
- const params = matchRoute(route.path, fullPath);
1263
- if (params === null) continue;
1264
-
1265
- const result = await route.handler({ body, params, query });
1266
-
1267
- if (result.stream && result.events) {
1268
- // Return a streaming result for the adapter to handle
1269
- return {
1270
- handled: true,
1271
- result: {
1272
- type: 'stream',
1273
- contentType: result.vercelDataStream
1274
- ? 'text/plain; charset=utf-8'
1275
- : 'text/event-stream',
1276
- events: result.events,
1277
- vercelDataStream: result.vercelDataStream,
1278
- headers: {
1279
- 'Content-Type': result.vercelDataStream
1280
- ? 'text/plain; charset=utf-8'
1281
- : 'text/event-stream',
1282
- 'Cache-Control': 'no-cache',
1283
- 'Connection': 'keep-alive',
1284
- },
1285
- },
1286
- };
1287
- }
1288
-
1289
- return {
1290
- handled: true,
1291
- response: {
1292
- status: result.status,
1293
- body: result.body,
1294
- },
1295
- };
1296
- }
1297
-
1298
- return {
1299
- handled: true,
1300
- response: this.routeNotFound(subPath),
1301
- };
1302
- }
1303
-
1304
- /**
1305
- * Main Dispatcher Entry Point
1306
- * Routes the request to the appropriate handler based on path and precedence
1307
- */
1308
- async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise<HttpDispatcherResult> {
1309
- const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
1310
-
1311
- // 0. Discovery Endpoint (GET /discovery or GET /)
1312
- // Standard route: /discovery (protocol-compliant)
1313
- // Legacy route: / (empty path, for backward compatibility — MSW strips base URL)
1314
- if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') {
1315
- const info = await this.getDiscoveryInfo(prefix ?? '');
1316
- return {
1317
- handled: true,
1318
- response: this.success(info)
1319
- };
1320
- }
1321
-
1322
- // 0b. Health Endpoint (GET /health)
1323
- if (cleanPath === '/health' && method === 'GET') {
1324
- return {
1325
- handled: true,
1326
- response: this.success({
1327
- status: 'ok',
1328
- timestamp: new Date().toISOString(),
1329
- version: '1.0.0',
1330
- uptime: typeof process !== 'undefined' ? process.uptime() : undefined,
1331
- }),
1332
- };
1333
- }
1334
-
1335
- // 1. System Protocols (Prefix-based)
1336
- if (cleanPath.startsWith('/auth')) {
1337
- return this.handleAuth(cleanPath.substring(5), method, body, context);
1338
- }
1339
-
1340
- if (cleanPath.startsWith('/meta')) {
1341
- return this.handleMetadata(cleanPath.substring(5), context, method, body, query);
1342
- }
1343
-
1344
- if (cleanPath.startsWith('/data')) {
1345
- return this.handleData(cleanPath.substring(5), method, body, query, context);
1346
- }
1347
-
1348
- if (cleanPath.startsWith('/graphql')) {
1349
- if (method === 'POST') return this.handleGraphQL(body, context);
1350
- // GraphQL usually GET for Playground is handled by middleware but we can return 405 or handle it
1351
- }
1352
-
1353
- if (cleanPath.startsWith('/storage')) {
1354
- return this.handleStorage(cleanPath.substring(8), method, body, context); // body here is file/stream for upload
1355
- }
1356
-
1357
- if (cleanPath.startsWith('/ui')) {
1358
- return this.handleUi(cleanPath.substring(3), query, context);
1359
- }
1360
-
1361
- if (cleanPath.startsWith('/automation')) {
1362
- return this.handleAutomation(cleanPath.substring(11), method, body, context, query);
1363
- }
1364
-
1365
- if (cleanPath.startsWith('/analytics')) {
1366
- return this.handleAnalytics(cleanPath.substring(10), method, body, context);
1367
- }
1368
-
1369
- if (cleanPath.startsWith('/packages')) {
1370
- return this.handlePackages(cleanPath.substring(9), method, body, query, context);
1371
- }
1372
-
1373
- if (cleanPath.startsWith('/i18n')) {
1374
- return this.handleI18n(cleanPath.substring(5), method, query, context);
1375
- }
1376
-
1377
- // AI Service — delegate to the registered AI route handlers
1378
- if (cleanPath.startsWith('/ai')) {
1379
- return this.handleAI(cleanPath, method, body, query, context);
1380
- }
1381
-
1382
- // OpenAPI Specification
1383
- if (cleanPath === '/openapi.json' && method === 'GET') {
1384
- try {
1385
- const metaSvc = await this.resolveService('metadata');
1386
- if (metaSvc && typeof (metaSvc as any).generateOpenApi === 'function') {
1387
- const result = await (metaSvc as any).generateOpenApi({});
1388
- return { handled: true, response: this.success(result) };
1389
- }
1390
- } catch (e) {
1391
- // If not implemented, fall through or return 404
1392
- }
1393
- }
1394
-
1395
- // 2. Custom API Endpoints (Registry lookup)
1396
- // Check if there is a custom endpoint defined for this path
1397
- const result = await this.handleApiEndpoint(cleanPath, method, body, query, context);
1398
- if (result.handled) return result;
1399
-
1400
- // 3. Fallback — return semantic 404 with diagnostic info
1401
- return {
1402
- handled: true,
1403
- response: this.routeNotFound(cleanPath),
1404
- };
1405
- }
1406
-
1407
- /**
1408
- * Handles Custom API Endpoints defined in metadata
1409
- */
1410
- async handleApiEndpoint(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1411
- try {
1412
- // Attempt to find a matching endpoint in the registry
1413
- const metaSvc = await this.resolveService('metadata');
1414
- if (!metaSvc || typeof (metaSvc as any).matchEndpoint !== 'function') {
1415
- return { handled: false };
1416
- }
1417
- const endpoint = await (metaSvc as any).matchEndpoint({ path, method });
1418
-
1419
- if (endpoint) {
1420
- // Execute the endpoint target logic
1421
- if (endpoint.type === 'flow') {
1422
- const automationSvc = await this.resolveService('automation');
1423
- if (!automationSvc || typeof (automationSvc as any).runFlow !== 'function') {
1424
- return { handled: true, response: this.error('Automation service not available', 503) };
1425
- }
1426
- const result = await (automationSvc as any).runFlow({
1427
- flowId: endpoint.target,
1428
- inputs: { ...query, ...body, _request: context.request }
1429
- });
1430
- return { handled: true, response: this.success(result) };
1431
- }
1432
-
1433
- if (endpoint.type === 'script') {
1434
- const automationSvc = await this.resolveService('automation');
1435
- if (!automationSvc || typeof (automationSvc as any).runScript !== 'function') {
1436
- return { handled: true, response: this.error('Automation service not available', 503) };
1437
- }
1438
- const result = await (automationSvc as any).runScript({
1439
- scriptName: endpoint.target,
1440
- context: { ...query, ...body, request: context.request }
1441
- });
1442
- return { handled: true, response: this.success(result) };
1443
- }
1444
-
1445
- if (endpoint.type === 'object_operation') {
1446
- // e.g. Proxy to an object action
1447
- if (endpoint.objectParams) {
1448
- const { object, operation } = endpoint.objectParams;
1449
- // Map standard CRUD operations
1450
- if (operation === 'find') {
1451
- const result = await this.callData('query', { object, query });
1452
- // Spec: FindDataResponse = { object, records, total?, hasMore? }
1453
- return { handled: true, response: this.success(result.records, { total: result.total }) };
1454
- }
1455
- if (operation === 'get' && query.id) {
1456
- const result = await this.callData('get', { object, id: query.id });
1457
- return { handled: true, response: this.success(result) };
1458
- }
1459
- if (operation === 'create') {
1460
- const result = await this.callData('create', { object, data: body });
1461
- return { handled: true, response: this.success(result) };
1462
- }
1463
- }
1464
- }
1465
-
1466
- if (endpoint.type === 'proxy') {
1467
- return {
1468
- handled: true,
1469
- response: {
1470
- status: 200,
1471
- body: { proxy: true, target: endpoint.target, note: 'Proxy execution requires http-client service' }
1472
- }
1473
- };
1474
- }
1475
- }
1476
- } catch (e) {
1477
- // If matchEndpoint fails (e.g. not found), we just return not handled
1478
- // so we can fallback to 404 or other handlers
1479
- }
1480
-
1481
- return { handled: false };
1482
- }
1483
- }