@objectstack/runtime 4.0.3 → 4.0.5
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/dist/index.cjs +36768 -1106
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +718 -15
- package/dist/index.d.ts +718 -15
- package/dist/index.js +36780 -1099
- package/dist/index.js.map +1 -1
- package/package.json +42 -9
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -753
- package/src/app-plugin.test.ts +0 -274
- package/src/app-plugin.ts +0 -276
- package/src/dispatcher-plugin.ts +0 -503
- package/src/driver-plugin.ts +0 -76
- package/src/http-dispatcher.root.test.ts +0 -76
- package/src/http-dispatcher.test.ts +0 -1312
- package/src/http-dispatcher.ts +0 -1563
- package/src/http-server.ts +0 -142
- package/src/index.ts +0 -39
- package/src/middleware.ts +0 -222
- package/src/runtime.test.ts +0 -65
- package/src/runtime.ts +0 -69
- package/src/seed-loader.test.ts +0 -1123
- package/src/seed-loader.ts +0 -713
- package/tsconfig.json +0 -10
- package/vitest.config.ts +0 -26
package/src/dispatcher-plugin.ts
DELETED
|
@@ -1,503 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
|
|
4
|
-
import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js';
|
|
5
|
-
|
|
6
|
-
export interface DispatcherPluginConfig {
|
|
7
|
-
/**
|
|
8
|
-
* API path prefix for all endpoints.
|
|
9
|
-
* @default '/api/v1'
|
|
10
|
-
*/
|
|
11
|
-
prefix?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
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.
|
|
97
|
-
*/
|
|
98
|
-
function sendResult(result: HttpDispatcherResult, res: any): void {
|
|
99
|
-
if (result.handled) {
|
|
100
|
-
if (result.response) {
|
|
101
|
-
res.status(result.response.status);
|
|
102
|
-
if (result.response.headers) {
|
|
103
|
-
for (const [k, v] of Object.entries(result.response.headers)) {
|
|
104
|
-
res.header(k, v);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
res.json(result.response.body);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (result.result) {
|
|
111
|
-
// Special results (redirect, stream) — pass through as JSON for now
|
|
112
|
-
res.status(200).json(result.result);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
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
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function errorResponse(err: any, res: any): void {
|
|
129
|
-
const code = err.statusCode || 500;
|
|
130
|
-
res.status(code).json({
|
|
131
|
-
success: false,
|
|
132
|
-
error: { message: err.message || 'Internal Server Error', code },
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Dispatcher Plugin
|
|
138
|
-
*
|
|
139
|
-
* Bridges legacy HttpDispatcher handlers to the IHttpServer route-registration model.
|
|
140
|
-
* Registers routes for domains NOT covered by @objectstack/rest:
|
|
141
|
-
* - /.well-known/objectstack (discovery)
|
|
142
|
-
* - /auth (authentication)
|
|
143
|
-
* - /graphql (GraphQL)
|
|
144
|
-
* - /analytics (BI queries)
|
|
145
|
-
* - /packages (package management)
|
|
146
|
-
* - /i18n (internationalization — locales, translations, field labels)
|
|
147
|
-
* - /storage (file storage)
|
|
148
|
-
* - /automation (CRUD + triggers + runs)
|
|
149
|
-
*
|
|
150
|
-
* Usage:
|
|
151
|
-
* ```ts
|
|
152
|
-
* import { createDispatcherPlugin } from '@objectstack/runtime';
|
|
153
|
-
* runtime.use(createDispatcherPlugin({ prefix: '/api/v1' }));
|
|
154
|
-
* ```
|
|
155
|
-
*/
|
|
156
|
-
export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plugin {
|
|
157
|
-
return {
|
|
158
|
-
name: 'com.objectstack.runtime.dispatcher',
|
|
159
|
-
version: '1.0.0',
|
|
160
|
-
|
|
161
|
-
init: async (_ctx: PluginContext) => {
|
|
162
|
-
// Consumer-only plugin — no services registered
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
start: async (ctx: PluginContext) => {
|
|
166
|
-
let server: IHttpServer | undefined;
|
|
167
|
-
try {
|
|
168
|
-
server = ctx.getService<IHttpServer>('http.server');
|
|
169
|
-
} catch {
|
|
170
|
-
// No HTTP server available — skip silently
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
if (!server) return;
|
|
174
|
-
|
|
175
|
-
const kernel = ctx.getKernel();
|
|
176
|
-
const dispatcher = new HttpDispatcher(kernel);
|
|
177
|
-
const prefix = config.prefix || '/api/v1';
|
|
178
|
-
|
|
179
|
-
// ── Discovery (.well-known) ─────────────────────────────────
|
|
180
|
-
server.get('/.well-known/objectstack', async (_req: any, res: any) => {
|
|
181
|
-
res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
// ── Discovery (versioned API path) ──────────────────────────
|
|
185
|
-
server.get(`${prefix}/discovery`, async (_req: any, res: any) => {
|
|
186
|
-
res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
|
|
187
|
-
});
|
|
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
|
-
|
|
199
|
-
// ── Auth ────────────────────────────────────────────────────
|
|
200
|
-
server.post(`${prefix}/auth/login`, async (req: any, res: any) => {
|
|
201
|
-
try {
|
|
202
|
-
const result = await dispatcher.handleAuth('login', 'POST', req.body, { request: req });
|
|
203
|
-
sendResult(result, res);
|
|
204
|
-
} catch (err: any) {
|
|
205
|
-
errorResponse(err, res);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// ── GraphQL ─────────────────────────────────────────────────
|
|
210
|
-
server.post(`${prefix}/graphql`, async (req: any, res: any) => {
|
|
211
|
-
try {
|
|
212
|
-
const result = await dispatcher.handleGraphQL(req.body, { request: req });
|
|
213
|
-
res.json(result);
|
|
214
|
-
} catch (err: any) {
|
|
215
|
-
errorResponse(err, res);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// ── Analytics ───────────────────────────────────────────────
|
|
220
|
-
server.post(`${prefix}/analytics/query`, async (req: any, res: any) => {
|
|
221
|
-
try {
|
|
222
|
-
const result = await dispatcher.handleAnalytics('query', 'POST', req.body, { request: req });
|
|
223
|
-
sendResult(result, res);
|
|
224
|
-
} catch (err: any) {
|
|
225
|
-
errorResponse(err, res);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
server.get(`${prefix}/analytics/meta`, async (req: any, res: any) => {
|
|
230
|
-
try {
|
|
231
|
-
const result = await dispatcher.handleAnalytics('meta', 'GET', {}, { request: req });
|
|
232
|
-
sendResult(result, res);
|
|
233
|
-
} catch (err: any) {
|
|
234
|
-
errorResponse(err, res);
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
server.post(`${prefix}/analytics/sql`, async (req: any, res: any) => {
|
|
239
|
-
try {
|
|
240
|
-
const result = await dispatcher.handleAnalytics('sql', 'POST', req.body, { request: req });
|
|
241
|
-
sendResult(result, res);
|
|
242
|
-
} catch (err: any) {
|
|
243
|
-
errorResponse(err, res);
|
|
244
|
-
}
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// ── Packages ────────────────────────────────────────────────
|
|
248
|
-
server.get(`${prefix}/packages`, async (req: any, res: any) => {
|
|
249
|
-
try {
|
|
250
|
-
const result = await dispatcher.handlePackages('', 'GET', {}, req.query, { request: req });
|
|
251
|
-
sendResult(result, res);
|
|
252
|
-
} catch (err: any) {
|
|
253
|
-
errorResponse(err, res);
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
server.post(`${prefix}/packages`, async (req: any, res: any) => {
|
|
258
|
-
try {
|
|
259
|
-
const result = await dispatcher.handlePackages('', 'POST', req.body, {}, { request: req });
|
|
260
|
-
sendResult(result, res);
|
|
261
|
-
} catch (err: any) {
|
|
262
|
-
errorResponse(err, res);
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
server.get(`${prefix}/packages/:id`, async (req: any, res: any) => {
|
|
267
|
-
try {
|
|
268
|
-
const result = await dispatcher.handlePackages(`/${req.params.id}`, 'GET', {}, req.query, { request: req });
|
|
269
|
-
sendResult(result, res);
|
|
270
|
-
} catch (err: any) {
|
|
271
|
-
errorResponse(err, res);
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
server.delete(`${prefix}/packages/:id`, async (req: any, res: any) => {
|
|
276
|
-
try {
|
|
277
|
-
const result = await dispatcher.handlePackages(`/${req.params.id}`, 'DELETE', {}, {}, { request: req });
|
|
278
|
-
sendResult(result, res);
|
|
279
|
-
} catch (err: any) {
|
|
280
|
-
errorResponse(err, res);
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
server.patch(`${prefix}/packages/:id/enable`, async (req: any, res: any) => {
|
|
285
|
-
try {
|
|
286
|
-
const result = await dispatcher.handlePackages(`/${req.params.id}/enable`, 'PATCH', {}, {}, { request: req });
|
|
287
|
-
sendResult(result, res);
|
|
288
|
-
} catch (err: any) {
|
|
289
|
-
errorResponse(err, res);
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
server.patch(`${prefix}/packages/:id/disable`, async (req: any, res: any) => {
|
|
294
|
-
try {
|
|
295
|
-
const result = await dispatcher.handlePackages(`/${req.params.id}/disable`, 'PATCH', {}, {}, { request: req });
|
|
296
|
-
sendResult(result, res);
|
|
297
|
-
} catch (err: any) {
|
|
298
|
-
errorResponse(err, res);
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
server.post(`${prefix}/packages/:id/publish`, async (req: any, res: any) => {
|
|
303
|
-
try {
|
|
304
|
-
const result = await dispatcher.handlePackages(`/${req.params.id}/publish`, 'POST', req.body, {}, { request: req });
|
|
305
|
-
sendResult(result, res);
|
|
306
|
-
} catch (err: any) {
|
|
307
|
-
errorResponse(err, res);
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
server.post(`${prefix}/packages/:id/revert`, async (req: any, res: any) => {
|
|
312
|
-
try {
|
|
313
|
-
const result = await dispatcher.handlePackages(`/${req.params.id}/revert`, 'POST', req.body, {}, { request: req });
|
|
314
|
-
sendResult(result, res);
|
|
315
|
-
} catch (err: any) {
|
|
316
|
-
errorResponse(err, res);
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
// ── Storage ─────────────────────────────────────────────────
|
|
321
|
-
server.post(`${prefix}/storage/upload`, async (req: any, res: any) => {
|
|
322
|
-
try {
|
|
323
|
-
// For file uploads the body *is* the file (parsed by adapter)
|
|
324
|
-
const result = await dispatcher.handleStorage('upload', 'POST', req.body, { request: req });
|
|
325
|
-
sendResult(result, res);
|
|
326
|
-
} catch (err: any) {
|
|
327
|
-
errorResponse(err, res);
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
server.get(`${prefix}/storage/file/:id`, async (req: any, res: any) => {
|
|
332
|
-
try {
|
|
333
|
-
const result = await dispatcher.handleStorage(`file/${req.params.id}`, 'GET', undefined, { request: req });
|
|
334
|
-
sendResult(result, res);
|
|
335
|
-
} catch (err: any) {
|
|
336
|
-
errorResponse(err, res);
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
// ── i18n ────────────────────────────────────────────────────
|
|
341
|
-
// Bridges to HttpDispatcher.handleI18n() which resolves the i18n
|
|
342
|
-
// service from the kernel (either I18nServicePlugin or memory fallback).
|
|
343
|
-
server.get(`${prefix}/i18n/locales`, async (req: any, res: any) => {
|
|
344
|
-
try {
|
|
345
|
-
const result = await dispatcher.handleI18n('/locales', 'GET', req.query, { request: req });
|
|
346
|
-
sendResult(result, res);
|
|
347
|
-
} catch (err: any) {
|
|
348
|
-
errorResponse(err, res);
|
|
349
|
-
}
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
server.get(`${prefix}/i18n/translations/:locale`, async (req: any, res: any) => {
|
|
353
|
-
try {
|
|
354
|
-
const result = await dispatcher.handleI18n(`/translations/${req.params.locale}`, 'GET', req.query, { request: req });
|
|
355
|
-
sendResult(result, res);
|
|
356
|
-
} catch (err: any) {
|
|
357
|
-
errorResponse(err, res);
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
server.get(`${prefix}/i18n/labels/:object/:locale`, async (req: any, res: any) => {
|
|
362
|
-
try {
|
|
363
|
-
const result = await dispatcher.handleI18n(`/labels/${req.params.object}/${req.params.locale}`, 'GET', req.query, { request: req });
|
|
364
|
-
sendResult(result, res);
|
|
365
|
-
} catch (err: any) {
|
|
366
|
-
errorResponse(err, res);
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
// ── Automation ──────────────────────────────────────────────
|
|
371
|
-
server.get(`${prefix}/automation`, async (req: any, res: any) => {
|
|
372
|
-
try {
|
|
373
|
-
const result = await dispatcher.handleAutomation('', 'GET', {}, { request: req });
|
|
374
|
-
sendResult(result, res);
|
|
375
|
-
} catch (err: any) {
|
|
376
|
-
errorResponse(err, res);
|
|
377
|
-
}
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
server.post(`${prefix}/automation`, async (req: any, res: any) => {
|
|
381
|
-
try {
|
|
382
|
-
const result = await dispatcher.handleAutomation('', 'POST', req.body, { request: req });
|
|
383
|
-
sendResult(result, res);
|
|
384
|
-
} catch (err: any) {
|
|
385
|
-
errorResponse(err, res);
|
|
386
|
-
}
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
server.get(`${prefix}/automation/:name`, async (req: any, res: any) => {
|
|
390
|
-
try {
|
|
391
|
-
const result = await dispatcher.handleAutomation(`${req.params.name}`, 'GET', {}, { request: req });
|
|
392
|
-
sendResult(result, res);
|
|
393
|
-
} catch (err: any) {
|
|
394
|
-
errorResponse(err, res);
|
|
395
|
-
}
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
server.put(`${prefix}/automation/:name`, async (req: any, res: any) => {
|
|
399
|
-
try {
|
|
400
|
-
const result = await dispatcher.handleAutomation(`${req.params.name}`, 'PUT', req.body, { request: req });
|
|
401
|
-
sendResult(result, res);
|
|
402
|
-
} catch (err: any) {
|
|
403
|
-
errorResponse(err, res);
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
server.delete(`${prefix}/automation/:name`, async (req: any, res: any) => {
|
|
408
|
-
try {
|
|
409
|
-
const result = await dispatcher.handleAutomation(`${req.params.name}`, 'DELETE', {}, { request: req });
|
|
410
|
-
sendResult(result, res);
|
|
411
|
-
} catch (err: any) {
|
|
412
|
-
errorResponse(err, res);
|
|
413
|
-
}
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
server.post(`${prefix}/automation/trigger/:name`, async (req: any, res: any) => {
|
|
417
|
-
try {
|
|
418
|
-
const result = await dispatcher.handleAutomation(`trigger/${req.params.name}`, 'POST', req.body, { request: req });
|
|
419
|
-
sendResult(result, res);
|
|
420
|
-
} catch (err: any) {
|
|
421
|
-
errorResponse(err, res);
|
|
422
|
-
}
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
server.post(`${prefix}/automation/:name/trigger`, async (req: any, res: any) => {
|
|
426
|
-
try {
|
|
427
|
-
const result = await dispatcher.handleAutomation(`${req.params.name}/trigger`, 'POST', req.body, { request: req });
|
|
428
|
-
sendResult(result, res);
|
|
429
|
-
} catch (err: any) {
|
|
430
|
-
errorResponse(err, res);
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
server.post(`${prefix}/automation/:name/toggle`, async (req: any, res: any) => {
|
|
435
|
-
try {
|
|
436
|
-
const result = await dispatcher.handleAutomation(`${req.params.name}/toggle`, 'POST', req.body, { request: req });
|
|
437
|
-
sendResult(result, res);
|
|
438
|
-
} catch (err: any) {
|
|
439
|
-
errorResponse(err, res);
|
|
440
|
-
}
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
server.get(`${prefix}/automation/:name/runs`, async (req: any, res: any) => {
|
|
444
|
-
try {
|
|
445
|
-
const result = await dispatcher.handleAutomation(`${req.params.name}/runs`, 'GET', {}, { request: req }, req.query);
|
|
446
|
-
sendResult(result, res);
|
|
447
|
-
} catch (err: any) {
|
|
448
|
-
errorResponse(err, res);
|
|
449
|
-
}
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
server.get(`${prefix}/automation/:name/runs/:runId`, async (req: any, res: any) => {
|
|
453
|
-
try {
|
|
454
|
-
const result = await dispatcher.handleAutomation(`${req.params.name}/runs/${req.params.runId}`, 'GET', {}, { request: req });
|
|
455
|
-
sendResult(result, res);
|
|
456
|
-
} catch (err: any) {
|
|
457
|
-
errorResponse(err, res);
|
|
458
|
-
}
|
|
459
|
-
});
|
|
460
|
-
|
|
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
|
-
}
|
|
501
|
-
},
|
|
502
|
-
};
|
|
503
|
-
}
|
package/src/driver-plugin.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Driver Plugin
|
|
7
|
-
*
|
|
8
|
-
* Generic plugin wrapper for ObjectQL drivers.
|
|
9
|
-
* Registers a driver with the ObjectQL engine.
|
|
10
|
-
*
|
|
11
|
-
* Dependencies: None (Registers service for ObjectQL to discover)
|
|
12
|
-
* Services: driver.{name}
|
|
13
|
-
*
|
|
14
|
-
* @example
|
|
15
|
-
* const memoryDriver = new InMemoryDriver();
|
|
16
|
-
* const driverPlugin = new DriverPlugin(memoryDriver, 'memory');
|
|
17
|
-
* kernel.use(driverPlugin);
|
|
18
|
-
*/
|
|
19
|
-
export class DriverPlugin implements Plugin {
|
|
20
|
-
name: string;
|
|
21
|
-
type = 'driver';
|
|
22
|
-
version = '1.0.0';
|
|
23
|
-
// dependencies = ['com.objectstack.engine.objectql']; // Removed: Driver is a producer, not strictly a consumer during init
|
|
24
|
-
|
|
25
|
-
private driver: any;
|
|
26
|
-
|
|
27
|
-
constructor(driver: any, driverName?: string) {
|
|
28
|
-
this.driver = driver;
|
|
29
|
-
this.name = `com.objectstack.driver.${driverName || driver.name || 'unknown'}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
init = async (ctx: PluginContext) => {
|
|
33
|
-
// Register driver as a service instead of directly to objectql
|
|
34
|
-
const serviceName = `driver.${this.driver.name || 'unknown'}`;
|
|
35
|
-
ctx.registerService(serviceName, this.driver);
|
|
36
|
-
ctx.logger.info('Driver service registered', {
|
|
37
|
-
serviceName,
|
|
38
|
-
driverName: this.driver.name,
|
|
39
|
-
driverVersion: this.driver.version
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
start = async (ctx: PluginContext) => {
|
|
44
|
-
// Drivers don't need start phase, initialization happens in init
|
|
45
|
-
// Auto-configure alias for shorter access if it follows reverse domain standard
|
|
46
|
-
if (this.name.startsWith('com.objectstack.driver.')) {
|
|
47
|
-
// const shortName = this.name.split('.').pop();
|
|
48
|
-
// Optional: ctx.registerService(`driver.${shortName}`, this.driver);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Auto-configure 'default' datasource if none exists
|
|
52
|
-
// We do this in 'start' phase to ensure metadata service is likely available
|
|
53
|
-
try {
|
|
54
|
-
const metadata = ctx.getService<any>('metadata');
|
|
55
|
-
if (metadata && metadata.addDatasource) {
|
|
56
|
-
// Check if default datasource exists
|
|
57
|
-
const datasources = metadata.getDatasources ? metadata.getDatasources() : [];
|
|
58
|
-
const hasDefault = datasources.some((ds: any) => ds.name === 'default');
|
|
59
|
-
|
|
60
|
-
if (!hasDefault) {
|
|
61
|
-
ctx.logger.info(`[DriverPlugin] No 'default' datasource found. Auto-configuring '${this.driver.name}' as default.`);
|
|
62
|
-
await metadata.addDatasource({
|
|
63
|
-
name: 'default',
|
|
64
|
-
driver: this.driver.name, // The driver's internal name (e.g. com.objectstack.driver.memory)
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
} catch (e) {
|
|
69
|
-
// Metadata service might not be ready or available, which is fine
|
|
70
|
-
// We just skip auto-configuration
|
|
71
|
-
ctx.logger.debug('[DriverPlugin] Failed to auto-configure default datasource (Metadata service missing?)', { error: e });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
ctx.logger.debug('Driver plugin started', { driverName: this.driver.name || 'unknown' });
|
|
75
|
-
}
|
|
76
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
-
import { HttpDispatcher } from './http-dispatcher';
|
|
4
|
-
import { ObjectKernel } from '@objectstack/core';
|
|
5
|
-
|
|
6
|
-
describe('HttpDispatcher Root Handling', () => {
|
|
7
|
-
let kernel: ObjectKernel;
|
|
8
|
-
let dispatcher: HttpDispatcher;
|
|
9
|
-
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
// Mock minimal Kernel structure
|
|
12
|
-
kernel = {
|
|
13
|
-
services: {},
|
|
14
|
-
broker: {
|
|
15
|
-
call: vi.fn(),
|
|
16
|
-
},
|
|
17
|
-
context: {
|
|
18
|
-
getService: vi.fn(),
|
|
19
|
-
}
|
|
20
|
-
} as any;
|
|
21
|
-
|
|
22
|
-
dispatcher = new HttpDispatcher(kernel);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('should handled GET request to root path ("") correctly', async () => {
|
|
26
|
-
const context = { request: {} };
|
|
27
|
-
const method = 'GET';
|
|
28
|
-
// MSW passes empty string when stripping base URL
|
|
29
|
-
const path = '';
|
|
30
|
-
const body = undefined;
|
|
31
|
-
const query = {};
|
|
32
|
-
|
|
33
|
-
const result = await dispatcher.dispatch(method, path, body, query, context);
|
|
34
|
-
|
|
35
|
-
expect(result.handled).toBe(true);
|
|
36
|
-
expect(result.response).toBeDefined();
|
|
37
|
-
expect(result.response?.status).toBe(200);
|
|
38
|
-
|
|
39
|
-
const data = result.response?.body?.data;
|
|
40
|
-
expect(data).toBeDefined();
|
|
41
|
-
// getDiscoveryInfo returns 'name' not 'apiName'
|
|
42
|
-
expect(data.name).toBe('ObjectOS');
|
|
43
|
-
expect(data.version).toBe('1.0.0');
|
|
44
|
-
expect(data.routes).toBeDefined();
|
|
45
|
-
// Since we passed empty prefix in dispatch code (hardcoded), routes should be relative
|
|
46
|
-
expect(data.routes.metadata).toBe('/meta');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('should handle GET /discovery (protocol-standard route)', async () => {
|
|
50
|
-
const context = { request: {} };
|
|
51
|
-
const result = await dispatcher.dispatch('GET', '/discovery', undefined, {}, context);
|
|
52
|
-
|
|
53
|
-
expect(result.handled).toBe(true);
|
|
54
|
-
expect(result.response).toBeDefined();
|
|
55
|
-
expect(result.response?.status).toBe(200);
|
|
56
|
-
|
|
57
|
-
const data = result.response?.body?.data;
|
|
58
|
-
expect(data).toBeDefined();
|
|
59
|
-
expect(data.name).toBe('ObjectOS');
|
|
60
|
-
expect(data.version).toBe('1.0.0');
|
|
61
|
-
expect(data.routes).toBeDefined();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('should return semantic 404 for POST request to root path ("")', async () => {
|
|
65
|
-
const context = { request: {} };
|
|
66
|
-
const method = 'POST';
|
|
67
|
-
const path = '';
|
|
68
|
-
|
|
69
|
-
const result = await dispatcher.dispatch(method, path, {}, {}, context);
|
|
70
|
-
|
|
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');
|
|
75
|
-
});
|
|
76
|
-
});
|