@objectstack/runtime 3.2.0 → 3.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +156 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +156 -26
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/http-dispatcher.test.ts +273 -3
- package/src/http-dispatcher.ts +193 -32
package/src/http-dispatcher.ts
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
import { ObjectKernel, getEnv } from '@objectstack/core';
|
|
4
4
|
import { CoreServiceName } from '@objectstack/spec/system';
|
|
5
5
|
|
|
6
|
+
/** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
|
|
7
|
+
function randomUUID(): string {
|
|
8
|
+
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
|
|
9
|
+
return globalThis.crypto.randomUUID();
|
|
10
|
+
}
|
|
11
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
12
|
+
const r = (Math.random() * 16) | 0;
|
|
13
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
14
|
+
return v.toString(16);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export interface HttpProtocolContext {
|
|
7
19
|
request: any;
|
|
8
20
|
response?: any;
|
|
@@ -179,12 +191,80 @@ export class HttpDispatcher {
|
|
|
179
191
|
return { handled: true, result: response };
|
|
180
192
|
}
|
|
181
193
|
|
|
182
|
-
// 2. Legacy Login
|
|
194
|
+
// 2. Legacy Login via broker
|
|
183
195
|
const normalizedPath = path.replace(/^\/+/, '');
|
|
184
196
|
if (normalizedPath === 'login' && method.toUpperCase() === 'POST') {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
try {
|
|
198
|
+
const broker = this.ensureBroker();
|
|
199
|
+
const data = await broker.call('auth.login', body, { request: context.request });
|
|
200
|
+
return { handled: true, response: { status: 200, body: data } };
|
|
201
|
+
} catch (error: any) {
|
|
202
|
+
// Only fall through to mock when the broker is truly unavailable
|
|
203
|
+
// (ensureBroker throws statusCode 500 when kernel.broker is null)
|
|
204
|
+
const statusCode = error?.statusCode ?? error?.status;
|
|
205
|
+
if (statusCode !== 500 || !error?.message?.includes('Broker not available')) {
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 3. Mock fallback for MSW/test environments when no auth service is registered
|
|
212
|
+
return this.mockAuthFallback(normalizedPath, method, body);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Provides mock auth responses for core better-auth endpoints when
|
|
217
|
+
* AuthPlugin is not loaded (e.g. MSW/browser-only environments).
|
|
218
|
+
* This ensures registration/sign-in flows do not 404 in mock mode.
|
|
219
|
+
*/
|
|
220
|
+
private mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
|
|
221
|
+
const m = method.toUpperCase();
|
|
222
|
+
const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours
|
|
223
|
+
|
|
224
|
+
// POST sign-up/email
|
|
225
|
+
if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
|
|
226
|
+
const id = `mock_${randomUUID()}`;
|
|
227
|
+
return {
|
|
228
|
+
handled: true,
|
|
229
|
+
response: {
|
|
230
|
+
status: 200,
|
|
231
|
+
body: {
|
|
232
|
+
user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
|
233
|
+
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// POST sign-in/email or login
|
|
240
|
+
if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
|
|
241
|
+
const id = `mock_${randomUUID()}`;
|
|
242
|
+
return {
|
|
243
|
+
handled: true,
|
|
244
|
+
response: {
|
|
245
|
+
status: 200,
|
|
246
|
+
body: {
|
|
247
|
+
user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
|
248
|
+
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// GET get-session
|
|
255
|
+
if (path === 'get-session' && m === 'GET') {
|
|
256
|
+
return {
|
|
257
|
+
handled: true,
|
|
258
|
+
response: { status: 200, body: { session: null, user: null } },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// POST sign-out
|
|
263
|
+
if (path === 'sign-out' && m === 'POST') {
|
|
264
|
+
return {
|
|
265
|
+
handled: true,
|
|
266
|
+
response: { status: 200, body: { success: true } },
|
|
267
|
+
};
|
|
188
268
|
}
|
|
189
269
|
|
|
190
270
|
return { handled: false };
|
|
@@ -202,7 +282,7 @@ export class HttpDispatcher {
|
|
|
202
282
|
// GET /metadata/types
|
|
203
283
|
if (parts[0] === 'types') {
|
|
204
284
|
// Try protocol service for dynamic types
|
|
205
|
-
const protocol =
|
|
285
|
+
const protocol = await this.resolveService('protocol');
|
|
206
286
|
if (protocol && typeof protocol.getMetaTypes === 'function') {
|
|
207
287
|
const result = await protocol.getMetaTypes({});
|
|
208
288
|
return { handled: true, response: this.success(result) };
|
|
@@ -242,7 +322,7 @@ export class HttpDispatcher {
|
|
|
242
322
|
// PUT /metadata/:type/:name (Save)
|
|
243
323
|
if (method === 'PUT' && body) {
|
|
244
324
|
// Try to get the protocol service directly
|
|
245
|
-
const protocol =
|
|
325
|
+
const protocol = await this.resolveService('protocol');
|
|
246
326
|
|
|
247
327
|
if (protocol && typeof protocol.saveMetaItem === 'function') {
|
|
248
328
|
try {
|
|
@@ -275,7 +355,7 @@ export class HttpDispatcher {
|
|
|
275
355
|
const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
|
|
276
356
|
|
|
277
357
|
// Try Protocol Service First (Preferred)
|
|
278
|
-
const protocol =
|
|
358
|
+
const protocol = await this.resolveService('protocol');
|
|
279
359
|
if (protocol && typeof protocol.getMetaItem === 'function') {
|
|
280
360
|
try {
|
|
281
361
|
const data = await protocol.getMetaItem({ type: singularType, name });
|
|
@@ -304,7 +384,7 @@ export class HttpDispatcher {
|
|
|
304
384
|
const packageId = query?.package || undefined;
|
|
305
385
|
|
|
306
386
|
// Try protocol service first for any type
|
|
307
|
-
const protocol =
|
|
387
|
+
const protocol = await this.resolveService('protocol');
|
|
308
388
|
if (protocol && typeof protocol.getMetaItems === 'function') {
|
|
309
389
|
try {
|
|
310
390
|
const data = await protocol.getMetaItems({ type: typeOrName, packageId });
|
|
@@ -343,7 +423,7 @@ export class HttpDispatcher {
|
|
|
343
423
|
// GET /metadata — return available metadata types
|
|
344
424
|
if (parts.length === 0) {
|
|
345
425
|
// Try protocol service for dynamic types
|
|
346
|
-
const protocol =
|
|
426
|
+
const protocol = await this.resolveService('protocol');
|
|
347
427
|
if (protocol && typeof protocol.getMetaTypes === 'function') {
|
|
348
428
|
const result = await protocol.getMetaTypes({});
|
|
349
429
|
return { handled: true, response: this.success(result) };
|
|
@@ -446,7 +526,7 @@ export class HttpDispatcher {
|
|
|
446
526
|
* Handles Analytics requests
|
|
447
527
|
* path: sub-path after /analytics/
|
|
448
528
|
*/
|
|
449
|
-
async handleAnalytics(path: string, method: string, body: any,
|
|
529
|
+
async handleAnalytics(path: string, method: string, body: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
450
530
|
const analyticsService = await this.getService(CoreServiceName.enum.analytics);
|
|
451
531
|
if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
|
|
452
532
|
|
|
@@ -455,26 +535,84 @@ export class HttpDispatcher {
|
|
|
455
535
|
|
|
456
536
|
// POST /analytics/query
|
|
457
537
|
if (subPath === 'query' && m === 'POST') {
|
|
458
|
-
const result = await analyticsService.query(body
|
|
538
|
+
const result = await analyticsService.query(body);
|
|
459
539
|
return { handled: true, response: this.success(result) };
|
|
460
540
|
}
|
|
461
541
|
|
|
462
542
|
// GET /analytics/meta
|
|
463
543
|
if (subPath === 'meta' && m === 'GET') {
|
|
464
|
-
const result = await analyticsService.
|
|
544
|
+
const result = await analyticsService.getMeta();
|
|
465
545
|
return { handled: true, response: this.success(result) };
|
|
466
546
|
}
|
|
467
547
|
|
|
468
548
|
// POST /analytics/sql (Dry-run or debug)
|
|
469
549
|
if (subPath === 'sql' && m === 'POST') {
|
|
470
550
|
// Assuming service has generateSql method
|
|
471
|
-
const result = await analyticsService.generateSql(body
|
|
551
|
+
const result = await analyticsService.generateSql(body);
|
|
472
552
|
return { handled: true, response: this.success(result) };
|
|
473
553
|
}
|
|
474
554
|
|
|
475
555
|
return { handled: false };
|
|
476
556
|
}
|
|
477
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Handles i18n requests
|
|
560
|
+
* path: sub-path after /i18n/
|
|
561
|
+
*
|
|
562
|
+
* Routes:
|
|
563
|
+
* GET /locales → getLocales
|
|
564
|
+
* GET /translations/:locale → getTranslations (locale from path)
|
|
565
|
+
* GET /translations?locale=xx → getTranslations (locale from query)
|
|
566
|
+
* GET /labels/:object/:locale → getFieldLabels (both from path)
|
|
567
|
+
* GET /labels/:object?locale=xx → getFieldLabels (locale from query)
|
|
568
|
+
*/
|
|
569
|
+
async handleI18n(path: string, method: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
570
|
+
const i18nService = await this.getService(CoreServiceName.enum.i18n);
|
|
571
|
+
if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) };
|
|
572
|
+
|
|
573
|
+
const m = method.toUpperCase();
|
|
574
|
+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
575
|
+
|
|
576
|
+
if (m !== 'GET') return { handled: false };
|
|
577
|
+
|
|
578
|
+
// GET /i18n/locales
|
|
579
|
+
if (parts[0] === 'locales' && parts.length === 1) {
|
|
580
|
+
const locales = i18nService.getLocales();
|
|
581
|
+
return { handled: true, response: this.success({ locales }) };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// GET /i18n/translations/:locale OR /i18n/translations?locale=xx
|
|
585
|
+
if (parts[0] === 'translations') {
|
|
586
|
+
const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale;
|
|
587
|
+
if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
|
|
588
|
+
const translations = i18nService.getTranslations(locale);
|
|
589
|
+
return { handled: true, response: this.success({ locale, translations }) };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx
|
|
593
|
+
if (parts[0] === 'labels' && parts.length >= 2) {
|
|
594
|
+
const objectName = decodeURIComponent(parts[1]);
|
|
595
|
+
const locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
|
|
596
|
+
if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
|
|
597
|
+
if (typeof i18nService.getFieldLabels === 'function') {
|
|
598
|
+
const labels = i18nService.getFieldLabels(objectName, locale);
|
|
599
|
+
return { handled: true, response: this.success({ object: objectName, locale, labels }) };
|
|
600
|
+
}
|
|
601
|
+
// Fallback: derive field labels from full translation bundle
|
|
602
|
+
const translations = i18nService.getTranslations(locale);
|
|
603
|
+
const prefix = `o.${objectName}.fields.`;
|
|
604
|
+
const labels: Record<string, string> = {};
|
|
605
|
+
for (const [key, value] of Object.entries(translations)) {
|
|
606
|
+
if (key.startsWith(prefix)) {
|
|
607
|
+
labels[key.substring(prefix.length)] = value as string;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return { handled: true, response: this.success({ object: objectName, locale, labels }) };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return { handled: false };
|
|
614
|
+
}
|
|
615
|
+
|
|
478
616
|
/**
|
|
479
617
|
* Handles Package Management requests
|
|
480
618
|
*
|
|
@@ -708,7 +846,7 @@ export class HttpDispatcher {
|
|
|
708
846
|
// Support both path param /view/obj/list AND query param /view/obj?type=list
|
|
709
847
|
const type = parts[2] || query?.type || 'list';
|
|
710
848
|
|
|
711
|
-
const protocol =
|
|
849
|
+
const protocol = await this.resolveService('protocol');
|
|
712
850
|
|
|
713
851
|
if (protocol && typeof protocol.getUiView === 'function') {
|
|
714
852
|
try {
|
|
@@ -852,35 +990,54 @@ export class HttpDispatcher {
|
|
|
852
990
|
}
|
|
853
991
|
|
|
854
992
|
private async getService(name: CoreServiceName) {
|
|
855
|
-
|
|
856
|
-
return await this.kernel.getService(name);
|
|
857
|
-
}
|
|
858
|
-
const services = this.getServicesMap();
|
|
859
|
-
return services[name];
|
|
993
|
+
return this.resolveService(name);
|
|
860
994
|
}
|
|
861
995
|
|
|
862
996
|
/**
|
|
863
|
-
*
|
|
864
|
-
*
|
|
997
|
+
* Resolve any service by name, supporting async factories.
|
|
998
|
+
* Fallback chain: getServiceAsync → getService (sync) → context.getService → services map.
|
|
999
|
+
* Only returns when a non-null service is found; otherwise falls through to the next step.
|
|
865
1000
|
*/
|
|
866
|
-
private async
|
|
867
|
-
//
|
|
1001
|
+
private async resolveService(name: string) {
|
|
1002
|
+
// Prefer async resolution to support factory-based services (e.g. auth, analytics, protocol)
|
|
1003
|
+
if (typeof this.kernel.getServiceAsync === 'function') {
|
|
1004
|
+
try {
|
|
1005
|
+
const svc = await this.kernel.getServiceAsync(name);
|
|
1006
|
+
if (svc != null) return svc;
|
|
1007
|
+
} catch {
|
|
1008
|
+
// Service not registered or async resolution failed — fall through
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
868
1011
|
if (typeof this.kernel.getService === 'function') {
|
|
869
1012
|
try {
|
|
870
|
-
const svc = await this.kernel.getService(
|
|
871
|
-
if (svc
|
|
872
|
-
} catch {
|
|
1013
|
+
const svc = await this.kernel.getService(name);
|
|
1014
|
+
if (svc != null) return svc;
|
|
1015
|
+
} catch {
|
|
1016
|
+
// Service not registered or sync resolution threw "is async" — fall through
|
|
1017
|
+
}
|
|
873
1018
|
}
|
|
874
|
-
// 2. Try via kernel context
|
|
875
1019
|
if (this.kernel?.context?.getService) {
|
|
876
1020
|
try {
|
|
877
|
-
const svc = await this.kernel.context.getService(
|
|
878
|
-
if (svc
|
|
879
|
-
} catch {
|
|
1021
|
+
const svc = await this.kernel.context.getService(name);
|
|
1022
|
+
if (svc != null) return svc;
|
|
1023
|
+
} catch {
|
|
1024
|
+
// Service not registered — fall through
|
|
1025
|
+
}
|
|
880
1026
|
}
|
|
881
|
-
// 3. Try via services map
|
|
882
1027
|
const services = this.getServicesMap();
|
|
883
|
-
|
|
1028
|
+
return services[name];
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Get the ObjectQL service which provides access to SchemaRegistry.
|
|
1033
|
+
* Tries multiple access patterns since kernel structure varies.
|
|
1034
|
+
*/
|
|
1035
|
+
private async getObjectQLService(): Promise<any> {
|
|
1036
|
+
// 1. Try via resolveService (handles async factories, sync, context, and map)
|
|
1037
|
+
try {
|
|
1038
|
+
const svc = await this.resolveService('objectql');
|
|
1039
|
+
if (svc?.registry) return svc;
|
|
1040
|
+
} catch { /* service not available */ }
|
|
884
1041
|
return null;
|
|
885
1042
|
}
|
|
886
1043
|
|
|
@@ -944,6 +1101,10 @@ export class HttpDispatcher {
|
|
|
944
1101
|
return this.handlePackages(cleanPath.substring(9), method, body, query, context);
|
|
945
1102
|
}
|
|
946
1103
|
|
|
1104
|
+
if (cleanPath.startsWith('/i18n')) {
|
|
1105
|
+
return this.handleI18n(cleanPath.substring(5), method, query, context);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
947
1108
|
// OpenAPI Specification
|
|
948
1109
|
if (cleanPath === '/openapi.json' && method === 'GET') {
|
|
949
1110
|
const broker = this.ensureBroker();
|