diff --git a/CHANGELOG.md b/CHANGELOG.md index 294a26dc20..9e987d18d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **i18n service registration & state inconsistency** — Discovery API (`getDiscoveryInfo`) now uses + the same async `resolveService()` fallback chain that request handlers (`handleI18n`) use, ensuring + the reported service status is always consistent with actual runtime availability. +- Discovery `locale` field is now populated from the actual i18n service (`getDefaultLocale`, + `getLocales`) instead of being hardcoded, so clients get accurate locale information. +- Updated all framework adapters (Hono, Express, Fastify, Next.js, NestJS, Nuxt, SvelteKit), + the dispatcher plugin, and the MSW plugin to `await` the now-async `getDiscoveryInfo()`. + +### Added +- **AppPlugin i18n auto-loading** — `AppPlugin` now automatically loads translation bundles from + app configs (`translations` array) into the kernel's i18n service during the `start` phase, + coordinating i18n data loading across server/dev/mock environments. +- i18n service registration guide in `content/docs/guides/kernel-services.mdx` documenting + service registration patterns, discovery consistency, and AppPlugin auto-loading behavior. + ### Changed - Updated ROADMAP.md for v3.0 release preparation with full codebase scan results - Audited all @deprecated items: 14 in spec, 9 in runtime packages (23 total) diff --git a/content/docs/guides/kernel-services.mdx b/content/docs/guides/kernel-services.mdx index 90293236b2..e3380a96ce 100644 --- a/content/docs/guides/kernel-services.mdx +++ b/content/docs/guides/kernel-services.mdx @@ -246,6 +246,68 @@ Trigger engine, event triggers from ObjectQL hooks, flow executor, scheduled tri ### 11. i18n — 3 methods `getLocales`, `getTranslations`, `getFieldLabels` +**Service Name**: `i18n` · **Criticality**: `optional` +**Implementations**: `@objectstack/service-i18n` (production — file-based) · Dev Plugin (in-memory stub) +**Route Mount**: `/api/v1/i18n` +**Contract**: `II18nService` in `@objectstack/spec/contracts` + +#### Service Registration + +The i18n service is registered by a **plugin** during the `init` phase: + +| Environment | Provider | Registration | +|:------------|:---------|:-------------| +| **Production** | `I18nServicePlugin` | File-based `FileI18nAdapter` loads JSON locale files from disk | +| **Development** | `DevPlugin` | In-memory Map-backed stub, supports `loadTranslations()` | +| **Mock / MSW** | `MswPlugin` | Routes via `HttpDispatcher.dispatch()` catch-all — requires one of the above | + +```typescript +// Production +kernel.use(new I18nServicePlugin({ defaultLocale: 'en', localesDir: './i18n' })); + +// Development (automatic — DevPlugin registers i18n stub for all 17 core services) +kernel.use(new DevPlugin()); +``` + +#### Discovery & Handler Consistency + +The Discovery API (`/api/v1` or `/.well-known/objectstack`) and the i18n route handler (`/api/v1/i18n/*`) both use the **same async resolution chain** to detect i18n availability: + +``` +getServiceAsync() → getService() → context.getService() → services Map +``` + +This ensures that `discovery.services.i18n.status` always matches the actual runtime behavior — a service registered via any mechanism (sync Map, async factory, or context) will be reported correctly in both places. + +The `locale` field in the discovery response is populated from the actual i18n service: +- `locale.default` — from `i18nService.getDefaultLocale()` (falls back to `'en'`) +- `locale.supported` — from `i18nService.getLocales()` (falls back to `[default]`) + +#### AppPlugin Auto-Loading + +When an app bundle includes an `i18n` config and `translations` array, `AppPlugin` automatically loads the translation data into the i18n service during the `start` phase: + +```typescript +export default defineStack({ + manifest: { id: 'com.example.crm', namespace: 'crm' }, + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + translations: [CrmTranslations], // TranslationBundle[] +}); +``` + +AppPlugin will: +1. Set the default locale via `i18nService.setDefaultLocale()` +2. Call `i18nService.loadTranslations(locale, data)` for each locale in every bundle +3. Skip gracefully if no i18n service is registered (no errors, just a debug log) + +#### REST API Endpoints + +| Method | Path | Description | +|:-------|:-----|:------------| +| `GET` | `/api/v1/i18n/locales` | List available locales | +| `GET` | `/api/v1/i18n/translations/:locale` | Get all translations for a locale | +| `GET` | `/api/v1/i18n/labels/:object/:locale` | Get translated field labels for an object | + --- ## 12–17. Infrastructure Services ❌ Plugin Required diff --git a/packages/adapters/express/src/index.ts b/packages/adapters/express/src/index.ts index d37bd92722..010a96ac00 100644 --- a/packages/adapters/express/src/index.ts +++ b/packages/adapters/express/src/index.ts @@ -71,8 +71,8 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router { }; // --- Discovery --- - router.get('/', (_req: Request, res: Response) => { - res.json({ data: dispatcher.getDiscoveryInfo(prefix) }); + router.get('/', async (_req: Request, res: Response) => { + res.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); // --- Auth --- diff --git a/packages/adapters/fastify/src/index.ts b/packages/adapters/fastify/src/index.ts index c11fc89874..84188db7b9 100644 --- a/packages/adapters/fastify/src/index.ts +++ b/packages/adapters/fastify/src/index.ts @@ -70,7 +70,7 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti // --- Discovery --- fastify.get(`${prefix}`, async (_request: FastifyRequest, reply: FastifyReply) => { - return reply.send({ data: dispatcher.getDiscoveryInfo(prefix) }); + return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); // --- .well-known --- diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index a8234fc57c..7fbe74301c 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -72,8 +72,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { }; // --- Discovery --- - app.get(`${prefix}`, (c) => { - return c.json({ data: dispatcher.getDiscoveryInfo(prefix) }); + app.get(`${prefix}`, async (c) => { + return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); // --- .well-known --- diff --git a/packages/adapters/nestjs/src/__mocks__/runtime.ts b/packages/adapters/nestjs/src/__mocks__/runtime.ts index ba700a6693..aa91068dcf 100644 --- a/packages/adapters/nestjs/src/__mocks__/runtime.ts +++ b/packages/adapters/nestjs/src/__mocks__/runtime.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; export class HttpDispatcher { - getDiscoveryInfo = vi.fn().mockReturnValue({ version: '1.0' }); + getDiscoveryInfo = vi.fn().mockResolvedValue({ version: '1.0' }); handleGraphQL = vi.fn().mockResolvedValue({ data: {} }); handleAuth = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: { ok: true } } }); handleMetadata = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: [] } }); diff --git a/packages/adapters/nestjs/src/index.ts b/packages/adapters/nestjs/src/index.ts index df920f39d7..0e05dd2091 100644 --- a/packages/adapters/nestjs/src/index.ts +++ b/packages/adapters/nestjs/src/index.ts @@ -95,8 +95,8 @@ export class ObjectStackController { // --- Discovery Endpoint --- @Get() - discovery() { - return { data: this.service.dispatcher.getDiscoveryInfo('/api') }; + async discovery() { + return { data: await this.service.dispatcher.getDiscoveryInfo('/api') }; } @Post('graphql') diff --git a/packages/adapters/nestjs/src/nestjs.test.ts b/packages/adapters/nestjs/src/nestjs.test.ts index a83a3dec95..6cbe8794a9 100644 --- a/packages/adapters/nestjs/src/nestjs.test.ts +++ b/packages/adapters/nestjs/src/nestjs.test.ts @@ -136,8 +136,8 @@ describe('ObjectStackController', () => { }); describe('discovery()', () => { - it('returns discovery info from the dispatcher', () => { - const result = controller.discovery(); + it('returns discovery info from the dispatcher', async () => { + const result = await controller.discovery(); expect(result).toEqual({ data: { version: '1.0' } }); expect(service.dispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api'); }); diff --git a/packages/adapters/nextjs/src/index.ts b/packages/adapters/nextjs/src/index.ts index 21d01e8b97..8d4b2f90ef 100644 --- a/packages/adapters/nextjs/src/index.ts +++ b/packages/adapters/nextjs/src/index.ts @@ -60,7 +60,7 @@ export function createRouteHandler(options: NextAdapterOptions) { // --- 0. Discovery Endpoint --- if (segments.length === 0 && method === 'GET') { - return NextResponse.json({ data: dispatcher.getDiscoveryInfo(options.prefix || '/api') }); + return NextResponse.json({ data: await dispatcher.getDiscoveryInfo(options.prefix || '/api') }); } try { diff --git a/packages/adapters/nuxt/src/index.ts b/packages/adapters/nuxt/src/index.ts index 2c0985525f..14a3f4733b 100644 --- a/packages/adapters/nuxt/src/index.ts +++ b/packages/adapters/nuxt/src/index.ts @@ -82,8 +82,8 @@ export function createH3Router(options: NuxtAdapterOptions): Router { // --- Discovery --- router.get( `${prefix}`, - defineEventHandler(() => { - return { data: dispatcher.getDiscoveryInfo(prefix) }; + defineEventHandler(async () => { + return { data: await dispatcher.getDiscoveryInfo(prefix) }; }), ); diff --git a/packages/adapters/sveltekit/src/index.ts b/packages/adapters/sveltekit/src/index.ts index baa70f10f1..ab5817a4e0 100644 --- a/packages/adapters/sveltekit/src/index.ts +++ b/packages/adapters/sveltekit/src/index.ts @@ -97,7 +97,7 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { // --- Discovery --- if (segments.length === 0 && method === 'GET') { - return new Response(JSON.stringify({ data: dispatcher.getDiscoveryInfo(prefix) }), { + return new Response(JSON.stringify({ data: await dispatcher.getDiscoveryInfo(prefix) }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); diff --git a/packages/plugins/plugin-msw/src/msw-plugin.ts b/packages/plugins/plugin-msw/src/msw-plugin.ts index 0834328658..3393e43c92 100644 --- a/packages/plugins/plugin-msw/src/msw-plugin.ts +++ b/packages/plugins/plugin-msw/src/msw-plugin.ts @@ -204,10 +204,10 @@ export class MSWPlugin implements Plugin { // Discovery Endpoint this.handlers.push( - http.get('*/.well-known/objectstack', () => { + http.get('*/.well-known/objectstack', async () => { if (this.dispatcher) { return HttpResponse.json({ - data: this.dispatcher.getDiscoveryInfo(baseUrl) + data: await this.dispatcher.getDiscoveryInfo(baseUrl) }); } return HttpResponse.json({ diff --git a/packages/runtime/src/app-plugin.test.ts b/packages/runtime/src/app-plugin.test.ts index 2a43f98dd0..1c93064774 100644 --- a/packages/runtime/src/app-plugin.test.ts +++ b/packages/runtime/src/app-plugin.test.ts @@ -99,4 +99,137 @@ describe('AppPlugin', () => { expect.any(Object) ); }); + + // ═══════════════════════════════════════════════════════════════ + // i18n translation auto-loading + // ═══════════════════════════════════════════════════════════════ + + describe('i18n translation loading', () => { + let mockI18n: any; + let mockQL: any; + + beforeEach(() => { + mockI18n = { + loadTranslations: vi.fn(), + setDefaultLocale: vi.fn(), + getLocales: vi.fn().mockReturnValue([]), + getDefaultLocale: vi.fn().mockReturnValue('en'), + }; + mockQL = { registry: {} }; + + vi.mocked(mockContext.getService).mockImplementation((name: string) => { + if (name === 'objectql') return mockQL; + if (name === 'i18n') return mockI18n; + return undefined; + }); + }); + + it('should auto-load translations from bundle into i18n service', async () => { + const bundle = { + id: 'com.test.i18n', + translations: [ + { + en: { objects: { task: { label: 'Task' } } }, + 'zh-CN': { objects: { task: { label: '任务' } } }, + }, + ], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { objects: { task: { label: 'Task' } } }); + expect(mockI18n.loadTranslations).toHaveBeenCalledWith('zh-CN', { objects: { task: { label: '任务' } } }); + }); + + it('should set default locale from i18n config', async () => { + const bundle = { + id: 'com.test.locale', + i18n: { defaultLocale: 'zh-CN', supportedLocales: ['en', 'zh-CN'] }, + translations: [{ en: { messages: { hello: 'Hello' } } }], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN'); + }); + + it('should skip translation loading when i18n service is not registered', async () => { + vi.mocked(mockContext.getService).mockImplementation((name: string) => { + if (name === 'objectql') return mockQL; + return undefined; // No i18n service + }); + + const bundle = { + id: 'com.test.noi18n', + translations: [{ en: { messages: { hello: 'Hello' } } }], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + // Should log debug but not throw + expect(mockContext.logger.debug).toHaveBeenCalledWith( + expect.stringContaining('No i18n service registered'), + expect.any(Object) + ); + }); + + it('should handle bundle with no translations gracefully', async () => { + const bundle = { id: 'com.test.notrans' }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.loadTranslations).not.toHaveBeenCalled(); + }); + + it('should load translations from nested manifest.translations', async () => { + const bundle = { + manifest: { + id: 'com.test.nested', + translations: [ + { en: { messages: { save: 'Save' } } }, + ], + }, + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } }); + }); + + it('should load multiple translation bundles', async () => { + const bundle = { + id: 'com.test.multi', + translations: [ + { en: { objects: { task: { label: 'Task' } } } }, + { en: { objects: { contact: { label: 'Contact' } } }, 'ja-JP': { objects: { contact: { label: '連絡先' } } } }, + ], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.loadTranslations).toHaveBeenCalledTimes(3); + }); + + it('should handle errors in loadTranslations gracefully', async () => { + mockI18n.loadTranslations.mockImplementation((locale: string) => { + if (locale === 'zh-CN') throw new Error('Disk read failed'); + }); + + const bundle = { + id: 'com.test.error', + translations: [ + { en: { messages: { save: 'Save' } }, 'zh-CN': { messages: { save: '保存' } } }, + ], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + // en should still be loaded despite zh-CN failure + expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } }); + expect(mockContext.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to load translations'), + expect.objectContaining({ locale: 'zh-CN' }) + ); + }); + }); }); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 7f8a8bd493..c373fbe686 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -2,7 +2,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import { SeedLoaderService } from './seed-loader.js'; -import type { IMetadataService } from '@objectstack/spec/contracts'; +import type { IMetadataService, II18nService } from '@objectstack/spec/contracts'; /** * AppPlugin @@ -12,6 +12,7 @@ import type { IMetadataService } from '@objectstack/spec/contracts'; * Responsibilities: * 1. Register App Manifest as a service (for ObjectQL discovery) * 2. Execute Runtime `onEnable` hook (for code logic) + * 3. Auto-load i18n translation bundles into the kernel's i18n service */ export class AppPlugin implements Plugin { name: string; @@ -102,6 +103,11 @@ export class AppPlugin implements Plugin { ctx.logger.debug('No runtime.onEnable function found', { appId }); } + // ── i18n Translation Loading ───────────────────────────────────── + // Auto-load translation bundles from the app config into the + // kernel's i18n service, so discovery and handlers stay consistent. + this.loadTranslations(ctx, appId); + // Data Seeding // Collect seed data from multiple locations (top-level `data` preferred, `manifest.data` for backward compat) const seedDatasets: any[] = []; @@ -185,4 +191,58 @@ export class AppPlugin implements Plugin { } } } + + /** + * Auto-load i18n translation bundles from the app config into the + * kernel's i18n service. Handles both `translations` (array of + * TranslationBundle) and `i18n` config (default locale, etc.). + * + * Gracefully skips when the i18n service is not registered — + * this keeps AppPlugin resilient across server/dev/mock environments. + */ + private loadTranslations(ctx: PluginContext, appId: string): void { + const i18nService = ctx.getService('i18n') as II18nService | undefined; + if (!i18nService) { + ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId }); + return; + } + + // Apply i18n config (default locale, etc.) + const i18nConfig = this.bundle.i18n || (this.bundle.manifest || this.bundle)?.i18n; + if (i18nConfig?.defaultLocale && typeof i18nService.setDefaultLocale === 'function') { + i18nService.setDefaultLocale(i18nConfig.defaultLocale); + ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale }); + } + + // Collect translation bundles from top-level and legacy locations + const bundles: Array> = []; + if (Array.isArray(this.bundle.translations)) { + bundles.push(...this.bundle.translations); + } + const manifest = this.bundle.manifest || this.bundle; + if (manifest && Array.isArray(manifest.translations) && manifest.translations !== this.bundle.translations) { + bundles.push(...manifest.translations); + } + + if (bundles.length === 0) { + return; + } + + let loadedLocales = 0; + for (const bundle of bundles) { + // Each bundle is a TranslationBundle: Record + for (const [locale, data] of Object.entries(bundle)) { + if (data && typeof data === 'object') { + try { + i18nService.loadTranslations(locale, data as Record); + loadedLocales++; + } catch (err: any) { + ctx.logger.warn('[i18n] Failed to load translations', { appId, locale, error: err.message }); + } + } + } + } + + ctx.logger.info('[i18n] Loaded translation bundles', { appId, bundles: bundles.length, locales: loadedLocales }); + } } diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 2efc74b354..c23b7eecec 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -88,12 +88,12 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // ── Discovery (.well-known) ───────────────────────────────── server.get('/.well-known/objectstack', async (_req: any, res: any) => { - res.json({ data: dispatcher.getDiscoveryInfo(prefix) }); + res.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); // ── Discovery (versioned API path) ────────────────────────── server.get(`${prefix}/discovery`, async (_req: any, res: any) => { - res.json({ data: dispatcher.getDiscoveryInfo(prefix) }); + res.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); // ── Auth ──────────────────────────────────────────────────── diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 3e24667570..0bea9bc5b1 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -954,4 +954,227 @@ describe('HttpDispatcher', () => { expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']); }); }); + + // ═══════════════════════════════════════════════════════════════ + // Discovery ↔ Handler i18n consistency + // ═══════════════════════════════════════════════════════════════ + + describe('discovery-handler i18n consistency', () => { + it('should report i18n as available in discovery when service is registered', async () => { + const mockI18nService = { + getLocales: vi.fn().mockReturnValue(['en', 'zh-CN', 'ja']), + getTranslations: vi.fn().mockReturnValue({}), + getDefaultLocale: vi.fn().mockReturnValue('en'), + }; + + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'i18n') return mockI18nService; + return null; + }); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.i18n.enabled).toBe(true); + expect(info.services.i18n.status).toBe('available'); + expect(info.routes.i18n).toBe('/api/v1/i18n'); + expect(info.features.i18n).toBe(true); + }); + + it('should report i18n as unavailable in discovery when service is not registered', async () => { + (kernel as any).getService = vi.fn().mockResolvedValue(null); + (kernel as any).services = new Map(); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.i18n.enabled).toBe(false); + expect(info.services.i18n.status).toBe('unavailable'); + expect(info.routes.i18n).toBeUndefined(); + expect(info.features.i18n).toBe(false); + }); + + it('should detect i18n via getServiceAsync (async factory) in discovery', async () => { + const mockI18nService = { + getLocales: vi.fn().mockReturnValue(['en', 'fr']), + getTranslations: vi.fn().mockReturnValue({}), + getDefaultLocale: vi.fn().mockReturnValue('fr'), + }; + + // Service NOT in sync map, only accessible via async factory + (kernel as any).services = new Map(); + (kernel as any).getServiceAsync = vi.fn().mockImplementation(async (name: string) => { + if (name === 'i18n') return mockI18nService; + return null; + }); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.i18n.enabled).toBe(true); + expect(info.services.i18n.status).toBe('available'); + + // Handler should also find it + const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.locales).toEqual(['en', 'fr']); + }); + + it('should populate locale from actual i18n service', async () => { + const mockI18nService = { + getLocales: vi.fn().mockReturnValue(['en', 'zh-CN', 'ja']), + getTranslations: vi.fn().mockReturnValue({}), + getDefaultLocale: vi.fn().mockReturnValue('zh-CN'), + }; + + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'i18n') return mockI18nService; + return null; + }); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.locale.default).toBe('zh-CN'); + expect(info.locale.supported).toEqual(['en', 'zh-CN', 'ja']); + }); + + it('should use default locale when i18n service is not available', async () => { + (kernel as any).getService = vi.fn().mockResolvedValue(null); + (kernel as any).services = new Map(); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.locale.default).toBe('en'); + expect(info.locale.supported).toEqual(['en']); + expect(info.locale.timezone).toBe('UTC'); + }); + + it('should ensure discovery and dispatch are consistent for root path', async () => { + const mockI18nService = { + getLocales: vi.fn().mockReturnValue(['en']), + getTranslations: vi.fn().mockReturnValue({}), + getDefaultLocale: vi.fn().mockReturnValue('en'), + }; + + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'i18n') return mockI18nService; + return null; + }); + + // Dispatch to root should return the same discovery data + const result = await dispatcher.dispatch('GET', '', undefined, {}, { request: {} }); + expect(result.handled).toBe(true); + const data = result.response?.body?.data; + expect(data.services.i18n.enabled).toBe(true); + expect(data.locale.default).toBe('en'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // i18n across server/dev/mock environments + // ═══════════════════════════════════════════════════════════════ + + describe('i18n environment consistency', () => { + it('should work with dev stub i18n service (in-memory translations)', async () => { + // Simulate dev plugin i18n stub — Map-backed, all sync + const translations = new Map>(); + let defaultLocale = 'en'; + const devI18nStub = { + t: (key: string, locale: string) => { + const t = translations.get(locale); + return (t?.[key] as string) ?? key; + }, + getTranslations: (locale: string) => translations.get(locale) ?? {}, + loadTranslations: (locale: string, data: Record) => { + translations.set(locale, { ...translations.get(locale), ...data }); + }, + getLocales: () => [...translations.keys()], + getDefaultLocale: () => defaultLocale, + setDefaultLocale: (locale: string) => { defaultLocale = locale; }, + }; + + // Load data like AppPlugin would + devI18nStub.loadTranslations('en', { 'o.task.label': 'Task' }); + devI18nStub.loadTranslations('zh-CN', { 'o.task.label': '任务' }); + + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'i18n') return devI18nStub; + return null; + }); + + // Discovery should reflect loaded locales + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.i18n.enabled).toBe(true); + expect(info.locale.supported).toEqual(['en', 'zh-CN']); + + // Handler should serve translations + const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, { request: {} }); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.translations['o.task.label']).toBe('任务'); + }); + + it('should handle MSW catch-all dispatch pattern for i18n', async () => { + // MSW routes all requests through dispatcher.dispatch() + const mockI18nService = { + getLocales: vi.fn().mockReturnValue(['en', 'de']), + getTranslations: vi.fn().mockReturnValue({ 'o.account.label': 'Konto' }), + getDefaultLocale: vi.fn().mockReturnValue('de'), + }; + + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'i18n') return mockI18nService; + return null; + }); + + // MSW-style dispatch: full path stripped to relative + const localesResult = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} }); + expect(localesResult.handled).toBe(true); + expect(localesResult.response?.body?.data?.locales).toEqual(['en', 'de']); + + const translationsResult = await dispatcher.dispatch('GET', '/i18n/translations/de', undefined, {}, { request: {} }); + expect(translationsResult.handled).toBe(true); + expect(translationsResult.response?.body?.data?.translations['o.account.label']).toBe('Konto'); + + // Discovery and handler agree + const discovery = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(discovery.services.i18n.enabled).toBe(true); + expect(discovery.locale.default).toBe('de'); + }); + + it('should return 501 consistently when i18n is unavailable in both discovery and handler', async () => { + (kernel as any).getService = vi.fn().mockResolvedValue(null); + (kernel as any).services = new Map(); + + // Discovery: unavailable + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.i18n.enabled).toBe(false); + expect(info.services.i18n.status).toBe('unavailable'); + + // Handler: 501 + const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + expect(result.response?.status).toBe(501); + + // Dispatch: also 501 + const dispatchResult = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} }); + expect(dispatchResult.response?.status).toBe(501); + }); + + it('should handle context-based service resolution (mock kernel)', async () => { + // Simulate a kernel that only provides i18n through context.getService + const mockI18n = { + getLocales: vi.fn().mockReturnValue(['en']), + getTranslations: vi.fn().mockReturnValue({}), + getDefaultLocale: vi.fn().mockReturnValue('en'), + }; + + (kernel as any).services = new Map(); + (kernel as any).getService = undefined; + (kernel as any).getServiceAsync = undefined; + (kernel as any).context = { + getService: vi.fn().mockImplementation((name: string) => { + if (name === 'i18n') return mockI18n; + return null; + }), + }; + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.i18n.enabled).toBe(true); + + const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + expect(result.response?.status).toBe(200); + }); + }); }); \ No newline at end of file diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 81c3db46ab..0f05a2a696 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -67,27 +67,52 @@ export class HttpDispatcher { } /** - * Generates the discovery JSON response for the API root + * Generates the discovery JSON response for the API root. + * + * Uses the same async `resolveService()` fallback chain that request + * handlers use, so the reported service status is always consistent + * with the actual runtime availability. */ - getDiscoveryInfo(prefix: string) { - const services = this.getServicesMap(); - - // All services are plugin-provided — check if a plugin has registered them - const hasAuth = !!services[CoreServiceName.enum.auth]; - const hasGraphQL = !!(services[CoreServiceName.enum.graphql] || this.kernel.graphql); - const hasSearch = !!services[CoreServiceName.enum.search]; - const hasWebSockets = !!services[CoreServiceName.enum.realtime]; - const hasFiles = !!(services[CoreServiceName.enum['file-storage']] || services['storage']?.supportsFiles); - const hasAnalytics = !!services[CoreServiceName.enum.analytics]; - const hasWorkflow = !!services[CoreServiceName.enum.workflow]; - const hasAi = !!services[CoreServiceName.enum.ai]; - const hasNotification = !!services[CoreServiceName.enum.notification]; - const hasI18n = !!services[CoreServiceName.enum.i18n]; - const hasUi = !!services[CoreServiceName.enum.ui]; - const hasAutomation = !!services[CoreServiceName.enum.automation]; - const hasCache = !!services[CoreServiceName.enum.cache]; - const hasQueue = !!services[CoreServiceName.enum.queue]; - const hasJob = !!services[CoreServiceName.enum.job]; + async getDiscoveryInfo(prefix: string) { + // Resolve all services through the same async fallback chain + // that request handlers (handleI18n, handleAuth, …) use. + const [ + authSvc, graphqlSvc, searchSvc, realtimeSvc, filesSvc, + analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc, + uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc, + ] = await Promise.all([ + this.resolveService(CoreServiceName.enum.auth), + this.resolveService(CoreServiceName.enum.graphql), + this.resolveService(CoreServiceName.enum.search), + this.resolveService(CoreServiceName.enum.realtime), + this.resolveService(CoreServiceName.enum['file-storage']), + this.resolveService(CoreServiceName.enum.analytics), + this.resolveService(CoreServiceName.enum.workflow), + this.resolveService(CoreServiceName.enum.ai), + this.resolveService(CoreServiceName.enum.notification), + this.resolveService(CoreServiceName.enum.i18n), + this.resolveService(CoreServiceName.enum.ui), + this.resolveService(CoreServiceName.enum.automation), + this.resolveService(CoreServiceName.enum.cache), + this.resolveService(CoreServiceName.enum.queue), + this.resolveService(CoreServiceName.enum.job), + ]); + + const hasAuth = !!authSvc; + const hasGraphQL = !!(graphqlSvc || this.kernel.graphql); + const hasSearch = !!searchSvc; + const hasWebSockets = !!realtimeSvc; + const hasFiles = !!filesSvc; + const hasAnalytics = !!analyticsSvc; + const hasWorkflow = !!workflowSvc; + const hasAi = !!aiSvc; + const hasNotification = !!notificationSvc; + const hasI18n = !!i18nSvc; + const hasUi = !!uiSvc; + const hasAutomation = !!automationSvc; + const hasCache = !!cacheSvc; + const hasQueue = !!queueSvc; + const hasJob = !!jobSvc; // Routes are only exposed when a plugin provides the service const routes = { @@ -116,6 +141,20 @@ export class HttpDispatcher { message: `Install a ${name} plugin to enable`, }); + // Derive locale info from actual i18n service when available + let locale = { default: 'en', supported: ['en'], timezone: 'UTC' }; + if (hasI18n && i18nSvc) { + const defaultLocale = typeof i18nSvc.getDefaultLocale === 'function' + ? i18nSvc.getDefaultLocale() : 'en'; + const locales = typeof i18nSvc.getLocales === 'function' + ? i18nSvc.getLocales() : []; + locale = { + default: defaultLocale, + supported: locales.length > 0 ? locales : [defaultLocale], + timezone: 'UTC', + }; + } + return { name: 'ObjectOS', version: '1.0.0', @@ -154,11 +193,7 @@ export class HttpDispatcher { 'file-storage': hasFiles ? svcAvailable(routes.storage) : svcUnavailable('file-storage'), search: hasSearch ? svcAvailable() : svcUnavailable('search'), }, - locale: { - default: 'en', - supported: ['en', 'zh-CN'], - timezone: 'UTC' - } + locale, }; } @@ -1056,7 +1091,7 @@ export class HttpDispatcher { // Handles request to base URL (e.g. /api/v1) which MSW strips to empty string if (cleanPath === '' && method === 'GET') { // We use '' as prefix since we are internal dispatcher - const info = this.getDiscoveryInfo(''); + const info = await this.getDiscoveryInfo(''); return { handled: true, response: this.success(info)