@objectstack/runtime 0.9.2 → 1.0.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.
@@ -0,0 +1,600 @@
1
+ import { ObjectKernel } from '@objectstack/core';
2
+ import { CoreServiceName } from '@objectstack/spec/system';
3
+
4
+ export interface HttpProtocolContext {
5
+ request: any;
6
+ response?: any;
7
+ }
8
+
9
+ export interface HttpDispatcherResult {
10
+ handled: boolean;
11
+ response?: {
12
+ status: number;
13
+ body?: any;
14
+ headers?: Record<string, string>;
15
+ };
16
+ result?: any; // For flexible return types or direct response objects (Response/NextResponse)
17
+ }
18
+
19
+ export class HttpDispatcher {
20
+ private kernel: any; // Casting to any to access dynamic props like broker, services, graphql
21
+
22
+ constructor(kernel: ObjectKernel) {
23
+ this.kernel = kernel;
24
+ }
25
+
26
+ private success(data: any, meta?: any) {
27
+ return {
28
+ status: 200,
29
+ body: { success: true, data, meta }
30
+ };
31
+ }
32
+
33
+ private error(message: string, code: number = 500, details?: any) {
34
+ return {
35
+ status: code,
36
+ body: { success: false, error: { message, code, details } }
37
+ };
38
+ }
39
+
40
+ private ensureBroker() {
41
+ if (!this.kernel.broker) {
42
+ throw { statusCode: 500, message: 'Kernel Broker not available' };
43
+ }
44
+ return this.kernel.broker;
45
+ }
46
+
47
+ /**
48
+ * Generates the discovery JSON response for the API root
49
+ */
50
+ getDiscoveryInfo(prefix: string) {
51
+ const services = this.getServicesMap();
52
+
53
+ const hasGraphQL = !!(services[CoreServiceName.enum.graphql] || this.kernel.graphql);
54
+ const hasSearch = !!services[CoreServiceName.enum.search];
55
+ const hasWebSockets = !!services[CoreServiceName.enum.realtime];
56
+ const hasFiles = !!(services[CoreServiceName.enum['file-storage']] || services['storage']?.supportsFiles);
57
+ const hasAnalytics = !!services[CoreServiceName.enum.analytics];
58
+ const hasHub = !!services[CoreServiceName.enum.hub];
59
+
60
+ return {
61
+ name: 'ObjectOS',
62
+ version: '1.0.0',
63
+ environment: process.env.NODE_ENV || 'development',
64
+ routes: {
65
+ data: `${prefix}/data`,
66
+ metadata: `${prefix}/metadata`,
67
+ auth: `${prefix}/auth`,
68
+ graphql: hasGraphQL ? `${prefix}/graphql` : undefined,
69
+ storage: hasFiles ? `${prefix}/storage` : undefined,
70
+ analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
71
+ hub: hasHub ? `${prefix}/hub` : undefined,
72
+ },
73
+ features: {
74
+ graphql: hasGraphQL,
75
+ search: hasSearch,
76
+ websockets: hasWebSockets,
77
+ files: hasFiles,
78
+ analytics: hasAnalytics,
79
+ hub: hasHub,
80
+ },
81
+ locale: {
82
+ default: 'en',
83
+ supported: ['en', 'zh-CN'],
84
+ timezone: 'UTC'
85
+ }
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Handles GraphQL requests
91
+ */
92
+ async handleGraphQL(body: { query: string; variables?: any }, context: HttpProtocolContext) {
93
+ if (!body || !body.query) {
94
+ throw { statusCode: 400, message: 'Missing query in request body' };
95
+ }
96
+
97
+ if (typeof this.kernel.graphql !== 'function') {
98
+ throw { statusCode: 501, message: 'GraphQL service not available' };
99
+ }
100
+
101
+ return this.kernel.graphql(body.query, body.variables, {
102
+ request: context.request
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Handles Auth requests
108
+ * path: sub-path after /auth/
109
+ */
110
+ async handleAuth(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
111
+ // 1. Try generic Auth Service
112
+ const authService = this.getService(CoreServiceName.enum.auth);
113
+ if (authService && typeof authService.handler === 'function') {
114
+ const response = await authService.handler(context.request, context.response);
115
+ return { handled: true, result: response };
116
+ }
117
+
118
+ // 2. Legacy Login
119
+ const normalizedPath = path.replace(/^\/+/, '');
120
+ if (normalizedPath === 'login' && method.toUpperCase() === 'POST') {
121
+ const broker = this.ensureBroker();
122
+ const data = await broker.call('auth.login', body, { request: context.request });
123
+ return { handled: true, response: { status: 200, body: data } };
124
+ }
125
+
126
+ return { handled: false };
127
+ }
128
+
129
+ /**
130
+ * Handles Metadata requests
131
+ * Standard: /metadata/:type/:name
132
+ * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
133
+ */
134
+ async handleMetadata(path: string, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
135
+ const broker = this.ensureBroker();
136
+ const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
137
+
138
+ // GET /metadata/types
139
+ if (parts[0] === 'types') {
140
+ // This would normally come from a registry service
141
+ // For now we mock the types supported by core
142
+ return { handled: true, response: this.success({ types: ['objects', 'apps', 'plugins'] }) };
143
+ }
144
+
145
+ // GET /metadata/:type/:name
146
+ if (parts.length === 2) {
147
+ const [type, name] = parts;
148
+ try {
149
+ // Try specific calls based on type
150
+ if (type === 'objects') {
151
+ const data = await broker.call('metadata.getObject', { objectName: name }, { request: context.request });
152
+ return { handled: true, response: this.success(data) };
153
+ }
154
+ // Generic call for other types if supported
155
+ const data = await broker.call(`metadata.get${this.capitalize(type.slice(0, -1))}`, { name }, { request: context.request });
156
+ return { handled: true, response: this.success(data) };
157
+ } catch (e: any) {
158
+ // Fallback: treat first part as object name if only 1 part (handled below)
159
+ // But here we are deep in 2 parts. Must be an error.
160
+ return { handled: true, response: this.error(e.message, 404) };
161
+ }
162
+ }
163
+
164
+ // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy)
165
+ if (parts.length === 1) {
166
+ const typeOrName = parts[0];
167
+
168
+ // Heuristic: if it maps to a known type, list it. Else treat as object name.
169
+ if (['objects', 'apps', 'plugins'].includes(typeOrName)) {
170
+ if (typeOrName === 'objects') {
171
+ const data = await broker.call('metadata.objects', {}, { request: context.request });
172
+ return { handled: true, response: this.success(data) };
173
+ }
174
+ // Try generic list
175
+ const data = await broker.call(`metadata.${typeOrName}`, {}, { request: context.request });
176
+ return { handled: true, response: this.success(data) };
177
+ }
178
+
179
+ // Legacy: /metadata/:objectName
180
+ const data = await broker.call('metadata.getObject', { objectName: typeOrName }, { request: context.request });
181
+ return { handled: true, response: this.success(data) };
182
+ }
183
+
184
+ // GET /metadata (List Objects - Default)
185
+ if (parts.length === 0) {
186
+ const data = await broker.call('metadata.objects', {}, { request: context.request });
187
+ return { handled: true, response: this.success(data) };
188
+ }
189
+
190
+ return { handled: false };
191
+ }
192
+
193
+ /**
194
+ * Handles Data requests
195
+ * path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query")
196
+ */
197
+ async handleData(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
198
+ const broker = this.ensureBroker();
199
+ const parts = path.replace(/^\/+/, '').split('/');
200
+ const objectName = parts[0];
201
+
202
+ if (!objectName) {
203
+ return { handled: true, response: this.error('Object name required', 400) };
204
+ }
205
+
206
+ const m = method.toUpperCase();
207
+
208
+ // 1. Custom Actions (query, batch)
209
+ if (parts.length > 1) {
210
+ const action = parts[1];
211
+
212
+ // POST /data/:object/query
213
+ if (action === 'query' && m === 'POST') {
214
+ const result = await broker.call('data.query', { object: objectName, ...body }, { request: context.request });
215
+ return { handled: true, response: this.success(result.data, { count: result.count, limit: body.limit, skip: body.skip }) };
216
+ }
217
+
218
+ // POST /data/:object/batch
219
+ if (action === 'batch' && m === 'POST') {
220
+ // Spec complaint: forward the whole body { operation, records, options }
221
+ // Implementation in Kernel should handle the 'operation' field
222
+ const result = await broker.call('data.batch', { object: objectName, ...body }, { request: context.request });
223
+ return { handled: true, response: this.success(result) };
224
+ }
225
+
226
+ // GET /data/:object/:id
227
+ if (parts.length === 2 && m === 'GET') {
228
+ const id = parts[1];
229
+ const data = await broker.call('data.get', { object: objectName, id, ...query }, { request: context.request });
230
+ return { handled: true, response: this.success(data) };
231
+ }
232
+
233
+ // PATCH /data/:object/:id
234
+ if (parts.length === 2 && m === 'PATCH') {
235
+ const id = parts[1];
236
+ const data = await broker.call('data.update', { object: objectName, id, data: body }, { request: context.request });
237
+ return { handled: true, response: this.success(data) };
238
+ }
239
+
240
+ // DELETE /data/:object/:id
241
+ if (parts.length === 2 && m === 'DELETE') {
242
+ const id = parts[1];
243
+ await broker.call('data.delete', { object: objectName, id }, { request: context.request });
244
+ return { handled: true, response: this.success({ id, deleted: true }) };
245
+ }
246
+ } else {
247
+ // GET /data/:object (List)
248
+ if (m === 'GET') {
249
+ const result = await broker.call('data.query', { object: objectName, filters: query }, { request: context.request });
250
+ return { handled: true, response: this.success(result.data, { count: result.count }) };
251
+ }
252
+
253
+ // POST /data/:object (Create)
254
+ if (m === 'POST') {
255
+ const data = await broker.call('data.create', { object: objectName, data: body }, { request: context.request });
256
+ // Note: ideally 201
257
+ const res = this.success(data);
258
+ res.status = 201;
259
+ return { handled: true, response: res };
260
+ }
261
+ }
262
+
263
+ return { handled: false };
264
+ }
265
+
266
+ /**
267
+ * Handles Analytics requests
268
+ * path: sub-path after /analytics/
269
+ */
270
+ async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
271
+ const analyticsService = this.getService(CoreServiceName.enum.analytics);
272
+ if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
273
+
274
+ const m = method.toUpperCase();
275
+ const subPath = path.replace(/^\/+/, '');
276
+
277
+ // POST /analytics/query
278
+ if (subPath === 'query' && m === 'POST') {
279
+ const result = await analyticsService.query(body, { request: context.request });
280
+ return { handled: true, response: this.success(result) };
281
+ }
282
+
283
+ // GET /analytics/meta
284
+ if (subPath === 'meta' && m === 'GET') {
285
+ const result = await analyticsService.getMetadata({ request: context.request });
286
+ return { handled: true, response: this.success(result) };
287
+ }
288
+
289
+ // POST /analytics/sql (Dry-run or debug)
290
+ if (subPath === 'sql' && m === 'POST') {
291
+ // Assuming service has generateSql method
292
+ const result = await analyticsService.generateSql(body, { request: context.request });
293
+ return { handled: true, response: this.success(result) };
294
+ }
295
+
296
+ return { handled: false };
297
+ }
298
+
299
+ /**
300
+ * Handles Hub requests
301
+ * path: sub-path after /hub/
302
+ */
303
+ async handleHub(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
304
+ const hubService = this.getService(CoreServiceName.enum.hub);
305
+ if (!hubService) return { handled: false };
306
+
307
+ const m = method.toUpperCase();
308
+ const parts = path.replace(/^\/+/, '').split('/');
309
+
310
+ // Resource-based routing: /hub/:resource/:id
311
+ if (parts.length > 0) {
312
+ const resource = parts[0]; // spaces, plugins, etc.
313
+
314
+ // Allow mapping "spaces" -> "createSpace", "listSpaces" etc.
315
+ // Convention:
316
+ // GET /spaces -> listSpaces
317
+ // POST /spaces -> createSpace
318
+ // GET /spaces/:id -> getSpace
319
+ // PATCH /spaces/:id -> updateSpace
320
+ // DELETE /spaces/:id -> deleteSpace
321
+
322
+ const actionBase = resource.endsWith('s') ? resource.slice(0, -1) : resource; // space
323
+ const id = parts[1];
324
+
325
+ try {
326
+ if (parts.length === 1) {
327
+ // Collection Operations
328
+ if (m === 'GET') {
329
+ const capitalizedAction = 'list' + this.capitalize(resource); // listSpaces
330
+ if (typeof hubService[capitalizedAction] === 'function') {
331
+ const result = await hubService[capitalizedAction](query, { request: context.request });
332
+ return { handled: true, response: this.success(result) };
333
+ }
334
+ }
335
+ if (m === 'POST') {
336
+ const capitalizedAction = 'create' + this.capitalize(actionBase); // createSpace
337
+ if (typeof hubService[capitalizedAction] === 'function') {
338
+ const result = await hubService[capitalizedAction](body, { request: context.request });
339
+ return { handled: true, response: this.success(result) };
340
+ }
341
+ }
342
+ } else if (parts.length === 2) {
343
+ // Item Operations
344
+ if (m === 'GET') {
345
+ const capitalizedAction = 'get' + this.capitalize(actionBase); // getSpace
346
+ if (typeof hubService[capitalizedAction] === 'function') {
347
+ const result = await hubService[capitalizedAction](id, { request: context.request });
348
+ return { handled: true, response: this.success(result) };
349
+ }
350
+ }
351
+ if (m === 'PATCH' || m === 'PUT') {
352
+ const capitalizedAction = 'update' + this.capitalize(actionBase); // updateSpace
353
+ if (typeof hubService[capitalizedAction] === 'function') {
354
+ const result = await hubService[capitalizedAction](id, body, { request: context.request });
355
+ return { handled: true, response: this.success(result) };
356
+ }
357
+ }
358
+ if (m === 'DELETE') {
359
+ const capitalizedAction = 'delete' + this.capitalize(actionBase); // deleteSpace
360
+ if (typeof hubService[capitalizedAction] === 'function') {
361
+ const result = await hubService[capitalizedAction](id, { request: context.request });
362
+ return { handled: true, response: this.success(result) };
363
+ }
364
+ }
365
+ }
366
+ } catch(e: any) {
367
+ return { handled: true, response: this.error(e.message, 500) };
368
+ }
369
+ }
370
+
371
+ return { handled: false };
372
+ }
373
+
374
+ /**
375
+ * Handles Storage requests
376
+ * path: sub-path after /storage/
377
+ */
378
+ async handleStorage(path: string, method: string, file: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
379
+ const storageService = this.getService(CoreServiceName.enum['file-storage']) || this.kernel.services?.['file-storage'];
380
+ if (!storageService) {
381
+ return { handled: true, response: this.error('File storage not configured', 501) };
382
+ }
383
+
384
+ const m = method.toUpperCase();
385
+ const parts = path.replace(/^\/+/, '').split('/');
386
+
387
+ // POST /storage/upload
388
+ if (parts[0] === 'upload' && m === 'POST') {
389
+ if (!file) {
390
+ return { handled: true, response: this.error('No file provided', 400) };
391
+ }
392
+ const result = await storageService.upload(file, { request: context.request });
393
+ return { handled: true, response: this.success(result) };
394
+ }
395
+
396
+ // GET /storage/file/:id
397
+ if (parts[0] === 'file' && parts[1] && m === 'GET') {
398
+ const id = parts[1];
399
+ const result = await storageService.download(id, { request: context.request });
400
+
401
+ // Result can be URL (redirect), Stream/Blob, or metadata
402
+ if (result.url && result.redirect) {
403
+ // Must be handled by adapter to do actual redirect
404
+ return { handled: true, result: { type: 'redirect', url: result.url } };
405
+ }
406
+
407
+ if (result.stream) {
408
+ // Must be handled by adapter to pipe stream
409
+ return {
410
+ handled: true,
411
+ result: {
412
+ type: 'stream',
413
+ stream: result.stream,
414
+ headers: {
415
+ 'Content-Type': result.mimeType || 'application/octet-stream',
416
+ 'Content-Length': result.size
417
+ }
418
+ }
419
+ };
420
+ }
421
+
422
+ return { handled: true, response: this.success(result) };
423
+ }
424
+
425
+ return { handled: false };
426
+ }
427
+
428
+ /**
429
+ * Handles Automation requests
430
+ * path: sub-path after /automation/
431
+ */
432
+ async handleAutomation(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
433
+ const automationService = this.getService(CoreServiceName.enum.automation);
434
+ if (!automationService) return { handled: false };
435
+
436
+ const m = method.toUpperCase();
437
+ const parts = path.replace(/^\/+/, '').split('/');
438
+
439
+ // POST /automation/trigger/:name
440
+ if (parts[0] === 'trigger' && parts[1] && m === 'POST') {
441
+ const triggerName = parts[1];
442
+ if (typeof automationService.trigger === 'function') {
443
+ const result = await automationService.trigger(triggerName, body, { request: context.request });
444
+ return { handled: true, response: this.success(result) };
445
+ }
446
+ }
447
+
448
+ return { handled: false };
449
+ }
450
+
451
+ private getServicesMap(): Record<string, any> {
452
+ if (this.kernel.services instanceof Map) {
453
+ return Object.fromEntries(this.kernel.services);
454
+ }
455
+ return this.kernel.services || {};
456
+ }
457
+
458
+ private getService(name: CoreServiceName) {
459
+ if (typeof this.kernel.getService === 'function') {
460
+ return this.kernel.getService(name);
461
+ }
462
+ const services = this.getServicesMap();
463
+ return services[name];
464
+ }
465
+
466
+ private capitalize(s: string) {
467
+ return s.charAt(0).toUpperCase() + s.slice(1);
468
+ }
469
+
470
+ /**
471
+ * Main Dispatcher Entry Point
472
+ * Routes the request to the appropriate handler based on path and precedence
473
+ */
474
+ async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
475
+ const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
476
+
477
+ // 1. System Protocols (Prefix-based)
478
+ if (cleanPath.startsWith('/auth')) {
479
+ return this.handleAuth(cleanPath.substring(5), method, body, context);
480
+ }
481
+
482
+ if (cleanPath.startsWith('/metadata')) {
483
+ return this.handleMetadata(cleanPath.substring(9), context);
484
+ }
485
+
486
+ if (cleanPath.startsWith('/data')) {
487
+ return this.handleData(cleanPath.substring(5), method, body, query, context);
488
+ }
489
+
490
+ if (cleanPath.startsWith('/graphql')) {
491
+ if (method === 'POST') return this.handleGraphQL(body, context);
492
+ // GraphQL usually GET for Playground is handled by middleware but we can return 405 or handle it
493
+ }
494
+
495
+ if (cleanPath.startsWith('/storage')) {
496
+ return this.handleStorage(cleanPath.substring(8), method, body, context); // body here is file/stream for upload
497
+ }
498
+
499
+ if (cleanPath.startsWith('/automation')) {
500
+ return this.handleAutomation(cleanPath.substring(11), method, body, context);
501
+ }
502
+
503
+ if (cleanPath.startsWith('/analytics')) {
504
+ return this.handleAnalytics(cleanPath.substring(10), method, body, context);
505
+ }
506
+
507
+ if (cleanPath.startsWith('/hub')) {
508
+ return this.handleHub(cleanPath.substring(4), method, body, query, context);
509
+ }
510
+
511
+ // OpenAPI Specification
512
+ if (cleanPath === '/openapi.json' && method === 'GET') {
513
+ const broker = this.ensureBroker();
514
+ try {
515
+ const result = await broker.call('metadata.generateOpenApi', {}, { request: context.request });
516
+ return { handled: true, response: this.success(result) };
517
+ } catch (e) {
518
+ // If not implemented, fall through or return 404
519
+ }
520
+ }
521
+
522
+ // 2. Custom API Endpoints (Registry lookup)
523
+ // Check if there is a custom endpoint defined for this path
524
+ const result = await this.handleApiEndpoint(cleanPath, method, body, query, context);
525
+ if (result.handled) return result;
526
+
527
+ // 3. Fallback (404)
528
+ return { handled: false };
529
+ }
530
+
531
+ /**
532
+ * Handles Custom API Endpoints defined in metadata
533
+ */
534
+ async handleApiEndpoint(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
535
+ const broker = this.ensureBroker();
536
+ try {
537
+ // Attempt to find a matching endpoint in the registry
538
+ // This assumes a 'metadata.matchEndpoint' action exists in the kernel/registry
539
+ // path should include initial slash e.g. /api/v1/customers
540
+ const endpoint = await broker.call('metadata.matchEndpoint', { path, method });
541
+
542
+ if (endpoint) {
543
+ // Execute the endpoint target logic
544
+ if (endpoint.type === 'flow') {
545
+ const result = await broker.call('automation.runFlow', {
546
+ flowId: endpoint.target,
547
+ inputs: { ...query, ...body, _request: context.request }
548
+ });
549
+ return { handled: true, response: this.success(result) };
550
+ }
551
+
552
+ if (endpoint.type === 'script') {
553
+ const result = await broker.call('automation.runScript', {
554
+ scriptName: endpoint.target,
555
+ context: { ...query, ...body, request: context.request }
556
+ }, { request: context.request });
557
+ return { handled: true, response: this.success(result) };
558
+ }
559
+
560
+ if (endpoint.type === 'object_operation') {
561
+ // e.g. Proxy to an object action
562
+ if (endpoint.objectParams) {
563
+ const { object, operation } = endpoint.objectParams;
564
+ // Map standard CRUD operations
565
+ if (operation === 'find') {
566
+ const result = await broker.call('data.query', { object, filters: query }, { request: context.request });
567
+ return { handled: true, response: this.success(result.data, { count: result.count }) };
568
+ }
569
+ if (operation === 'get' && query.id) {
570
+ const result = await broker.call('data.get', { object, id: query.id }, { request: context.request });
571
+ return { handled: true, response: this.success(result) };
572
+ }
573
+ if (operation === 'create') {
574
+ const result = await broker.call('data.create', { object, data: body }, { request: context.request });
575
+ return { handled: true, response: this.success(result) };
576
+ }
577
+ }
578
+ }
579
+
580
+ if (endpoint.type === 'proxy') {
581
+ // Simple proxy implementation (requires a network call, which usually is done by a service but here we can stub return)
582
+ // In real implementation this might fetch(endpoint.target)
583
+ // For now, return target info
584
+ return {
585
+ handled: true,
586
+ response: {
587
+ status: 200,
588
+ body: { proxy: true, target: endpoint.target, note: 'Proxy execution requires http-client service' }
589
+ }
590
+ };
591
+ }
592
+ }
593
+ } catch (e) {
594
+ // If matchEndpoint fails (e.g. not found), we just return not handled
595
+ // so we can fallback to 404 or other handlers
596
+ }
597
+
598
+ return { handled: false };
599
+ }
600
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  // Export Kernels
2
2
  export { ObjectKernel } from '@objectstack/core';
3
3
 
4
+ // Export Runtime
5
+ export { Runtime } from './runtime.js';
6
+ export type { RuntimeConfig } from './runtime.js';
7
+
4
8
  // Export Plugins
5
9
  export { DriverPlugin } from './driver-plugin.js';
6
10
  export { AppPlugin } from './app-plugin.js';
11
+ export { createApiRegistryPlugin } from './api-registry-plugin.js';
12
+ export type { ApiRegistryConfig } from './api-registry-plugin.js';
7
13
 
8
14
  // Export HTTP Server Components
9
15
  export { HttpServer } from './http-server.js';
16
+ export { HttpDispatcher } from './http-dispatcher.js';
17
+ export type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js';
10
18
  export { RestServer } from './rest-server.js';
11
19
  export { RouteManager, RouteGroupBuilder } from './route-manager.js';
12
20
  export type { RouteEntry } from './route-manager.js';
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Runtime } from './runtime';
3
+ import { IHttpServer, PluginContext } from '@objectstack/core';
4
+
5
+ // Mock ObjectKernel to isolate Runtime logic
6
+ vi.mock('@objectstack/core', async () => {
7
+ const actual = await vi.importActual<any>('@objectstack/core');
8
+ return {
9
+ ...actual,
10
+ ObjectKernel: class {
11
+ use = vi.fn();
12
+ registerService = vi.fn();
13
+ bootstrap = vi.fn().mockResolvedValue(undefined);
14
+ getServices = vi.fn().mockReturnValue(new Map());
15
+ }
16
+ };
17
+ });
18
+
19
+ describe('Runtime', () => {
20
+ it('should initialize successfully', () => {
21
+ const runtime = new Runtime();
22
+ expect(runtime).toBeDefined();
23
+ // Should create a kernel
24
+ expect(runtime.getKernel()).toBeDefined();
25
+ });
26
+
27
+ it('should register api registry plugin by default', () => {
28
+ const runtime = new Runtime();
29
+ const kernel = runtime.getKernel();
30
+ // Check if use was called (at least once for api registry)
31
+ expect(kernel.use).toHaveBeenCalled();
32
+ });
33
+
34
+ it('should register external http server if provided', () => {
35
+ const mockServer: IHttpServer = {
36
+ listen: vi.fn(),
37
+ close: vi.fn(),
38
+ get: vi.fn(),
39
+ post: vi.fn(),
40
+ put: vi.fn(),
41
+ delete: vi.fn(),
42
+ patch: vi.fn(),
43
+ use: vi.fn(),
44
+ };
45
+
46
+ const runtime = new Runtime({ server: mockServer });
47
+ const kernel = runtime.getKernel();
48
+
49
+ expect(kernel.registerService).toHaveBeenCalledWith('http.server', mockServer);
50
+ });
51
+
52
+ it('should delegate use() to kernel', () => {
53
+ const runtime = new Runtime();
54
+ const mockPlugin = { name: 'test', init: vi.fn() };
55
+
56
+ runtime.use(mockPlugin);
57
+ expect(runtime.getKernel().use).toHaveBeenCalledWith(mockPlugin);
58
+ });
59
+
60
+ it('should delegate start() to kernel.bootstrap()', async () => {
61
+ const runtime = new Runtime();
62
+ await runtime.start();
63
+ expect(runtime.getKernel().bootstrap).toHaveBeenCalled();
64
+ });
65
+ });