Skip to content

fix: move i18n routes from RestServer to service-i18n self-registration + dispatcher bridge - #911

Merged
hotlong merged 4 commits into
mainfrom
copilot/fix-i18n-route-registration
Mar 11, 2026
Merged

hotlong merged 4 commits into
mainfrom
copilot/fix-i18n-route-registration

Conversation

Copilot AI commented Mar 11, 2026 •

Copy link
Copy Markdown
Contributor

RestServer was registering /api/v1/i18n/* routes directly, breaking the architectural invariant that service plugins self-register their own HTTP routes. Additionally, createDispatcherPlugin() bridged every service domain (auth, analytics, packages, storage, automation) except i18n — so when the kernel uses the memory fallback (no explicit I18nServicePlugin), i18n endpoints returned 404.

RestServer — removed i18n responsibility

  • Remove enableI18n flag from RestApiConfigSchema
  • Remove registerI18nEndpoints() and all i18n config from NormalizedRestServerConfig
  • RestServer now strictly handles protocol-level endpoints only (discovery, metadata, CRUD, batch, UI)

I18nServicePlugin — self-registers routes

  • Add registerRoutes (default true) and basePath (default /api/v1/i18n) options
  • Self-register 3 GET routes via kernel:ready hook, same pattern as AuthPlugin
  • Graceful no-op when HTTP server is absent (MSW/mock environments)

Dispatcher plugin — i18n bridge (fixes the 404)

  • Add i18n route bridges to createDispatcherPlugin(), delegating to HttpDispatcher.handleI18n()
  • Ensures i18n endpoints work even when only the kernel's memory fallback i18n is active
// dispatcher-plugin.ts — new i18n bridges
server.get(`${prefix}/i18n/locales`, async (req, res) => {
    const result = await dispatcher.handleI18n('/locales', 'GET', req.query, { request: req });
    sendResult(result, res);
});
server.get(`${prefix}/i18n/translations/:locale`, async (req, res) => {
    const result = await dispatcher.handleI18n(`/translations/${req.params.locale}`, 'GET', req.query, { request: req });
    sendResult(result, res);
});
server.get(`${prefix}/i18n/labels/:object/:locale`, async (req, res) => {
    const result = await dispatcher.handleI18n(`/labels/${req.params.object}/${req.params.locale}`, 'GET', req.query, { request: req });
    sendResult(result, res);
});

Tests

  • 14 new tests for I18nServicePlugin route self-registration and handler behavior
  • Updated rest.test.ts to assert RestServer no longer registers i18n routes
  • All 6,743 tests pass across rest, service-i18n, and spec packages
Original prompt

This section details on the original issue you should resolve

<issue_title>i18n 路由应由 service-i18n 自注册,RestServer 应移除统一注册职责,保持架构一致性</issue_title>
<issue_description>## 问题描述
当前 RestServer 插件自动注册 /api/v1/i18n/* 路由,实际职责和架构不一致:

  • RestServer 设计为协议驱动网关(所有 REST 路由通过 ObjectStackProtocol 实现方法注册),但 i18n 并未通过 Protocol 实现。
  • i18n 路由应由 @objectstack/service-i18n 插件(或者内核 fallback)向 http-server 自注册,而不是由 RestServer 统一注册。
  • 当前 i18n 路由既未完全 Protocol 化,又与其它服务(如 auth、workflow、realtime)的插件自治路由模式不一致,导致架构混乱。

架构分析

  • auth、workflow、realtime 等服务都是各自插件注册自己的 REST 路由,互不依赖 RestServer。
  • RestServer 应只暴露 protocol 层方法(metadata、data、batch、discovery 等)作为 REST endpoint。
  • i18n 服务已通过 kernel 注册(包括自动 fallback),但相关 REST endpoint 不属于 protocol 范畴,应该由 service-i18n 模���(或 fallback 适配器)自行注册。
  • 当前 RestServer/registerI18nEndpoints 造成了「半自动」路由,既不可用,也破坏长期架构一致性。

解决建议

  1. 移除 RestServer 的 registerI18nEndpoints 方法及 enableI18n 配置项。
  2. 改为 service-i18n(或内置 fallback)在 start 阶段主动向 http.server 注册 /api/v1/i18n/* 路由,类似于 AuthPlugin 的路由注册方案。
  3. 明确文档/代码注释,RestServer 只暴露 protocol 实现方法的 endpoint,不涉及任何单独服务。
  4. 移除或重构 ObjectStackProtocolImplementation 的 i18n 方法桥接逻辑(如果有)。

验收标准

  • 所有核心服务(auth、i18n、realtime 等)都通过各自插件注册 REST 路由,无需任何 RestServer 配置。
  • RestServer 仅负责 protocol 方法路由注册。
  • i18n endpoint 能在无 RestServer 的情况下依然正常工作(如 MSW 单测或 core mock server)。
  • 文档、CHANGELOG 及 ROADMAP 有清晰架构说明。

此修正将使平台 REST 路由体系与所有核心服务自治模式以及 Protocol 网关模式一致。
</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@vercel

vercel Bot commented Mar 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-play Ready Ready Preview, Comment Mar 11, 2026 9:47am
spec Ready Ready Preview, Comment Mar 11, 2026 9:47am

Request Review

…Plugin

- Remove `enableI18n` from RestApiConfigSchema (rest-server.zod.ts)
- Remove `registerI18nEndpoints()` method from RestServer
- Remove `enableI18n` from NormalizedRestServerConfig
- Add self-registration of i18n REST routes in I18nServicePlugin
  following the AuthPlugin kernel:ready hook pattern
- Update rest.test.ts to verify RestServer no longer registers i18n routes
- Add comprehensive tests for I18nServicePlugin route self-registration

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
… I18nServicePlugin

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix i18n route registration in RestServer refactor: move i18n route registration from RestServer to I18nServicePlugin Mar 11, 2026
The createDispatcherPlugin() bridges all service domains (auth, analytics,
packages, storage, automation) from HttpDispatcher to the HTTP server, but
i18n was missing. When no explicit I18nServicePlugin is loaded (kernel uses
memory fallback i18n), no i18n HTTP routes were registered, causing 404.

Add 3 i18n route bridges consistent with all other service domains:
- GET /api/v1/i18n/locales
- GET /api/v1/i18n/translations/:locale
- GET /api/v1/i18n/labels/:object/:locale

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title refactor: move i18n route registration from RestServer to I18nServicePlugin fix: move i18n routes from RestServer to service-i18n self-registration + dispatcher bridge Mar 11, 2026
@hotlong
hotlong marked this pull request as ready for review March 11, 2026 09:52
Copilot AI review requested due to automatic review settings March 11, 2026 09:52
@hotlong
hotlong merged commit 5a71ba1 into main Mar 11, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Moves i18n HTTP route ownership out of the protocol-level RestServer and into the i18n service layer, while also adding dispatcher bridge routes so i18n endpoints continue working when only the kernel’s fallback i18n service is present.

Changes:

  • Removed enableI18n from RestApiConfigSchema and deleted RestServer’s i18n endpoint registration.
  • Added self-registering i18n routes to I18nServicePlugin (with registerRoutes + basePath options) and a new test suite.
  • Added i18n bridge routes to createDispatcherPlugin() and updated RestServer tests + docs (ROADMAP/CHANGELOG).

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/spec/src/api/rest-server.zod.ts Removes enableI18n flag from REST API config schema.
packages/services/service-i18n/src/i18n-service-plugin.ts Adds kernel:ready-based self-registration for i18n HTTP routes.
packages/services/service-i18n/src/i18n-service-plugin.test.ts Adds tests for i18n route self-registration and handler behavior.
packages/runtime/src/dispatcher-plugin.ts Adds i18n HTTP route bridges delegating to HttpDispatcher.handleI18n().
packages/rest/src/rest.test.ts Updates expectations: RestServer no longer registers i18n routes.
packages/rest/src/rest-server.ts Removes i18n config and endpoint registration from RestServer.
ROADMAP.md Notes i18n service now self-registers REST routes.
CHANGELOG.md Documents architectural move + dispatcher bridge addition.

Comment on lines +163 to +171
// GET /i18n/labels/:object/:locale
httpServer.get(`${basePath}/labels/:object/:locale`, async (req: IHttpRequest, res: IHttpResponse) => {
try {
const objectName = req.params.object;
const locale = req.params.locale;
if (!objectName || !locale) {
res.status(400).json({ error: 'Missing object or locale parameter' });
return;
}

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I18nServicePlugin only registers GET ${basePath}/labels/:object/:locale, but HttpDispatcher.handleI18n (and ObjectStackClient.i18n.getFieldLabels) also supports/uses GET /labels/:object?locale=.... As-is, client requests will 404. Consider registering an additional GET ${basePath}/labels/:object route that reads req.query.locale, or otherwise support both route shapes.

Copilot uses AI. Check for mistakes.
Comment on lines +252 to +259
server.get(`${prefix}/i18n/translations/:locale`, async (req: any, res: any) => {
try {
const result = await dispatcher.handleI18n(`/translations/${req.params.locale}`, 'GET', req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dispatcher i18n bridges only expose /i18n/translations/:locale and /i18n/labels/:object/:locale. HttpDispatcher.handleI18n explicitly supports (and the TS client uses) /i18n/translations?locale=... and /i18n/labels/:object?locale=..., so those client calls will still 404 even after this change. Add bridge routes for the query-param forms (and keep behavior consistent across both).

Copilot uses AI. Check for mistakes.

beforeEach(() => {
httpServer = createMockHttpServer();
ctx = createMockPluginContext({ 'http-server': httpServer });

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test setup registers the HTTP server under the 'http-server' name, but production code commonly registers it as 'http.server' (with 'http-server' only as an alias in some server plugins). To catch real regressions, include 'http.server' in the mocked services (and update the plugin to prefer it).

Suggested change
ctx = createMockPluginContext({ 'http-server': httpServer });
ctx = createMockPluginContext({ 'http-server': httpServer, 'http.server': httpServer });

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +203
expect(res._data).toEqual({
data: {
locales: [
{ code: 'en', label: 'en', isDefault: true },
{ code: 'zh-CN', label: 'zh-CN', isDefault: false },
],
},
});
});

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions lock in a non-standard response shape ({ data: ... }). The repo's JS client unwraps responses based on a { success, data } envelope, so once the plugin is aligned with the standard envelope, these tests should assert the enveloped shape (and error shape) to prevent future incompatibilities.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +102
httpServer = ctx.getService<IHttpServer>('http-server');
} catch {
// Service not found — expected in MSW/mock mode
}

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I18nServicePlugin looks up the HTTP server using service name 'http-server', but the canonical registration is 'http.server' (and external RuntimeConfig.server only registers 'http.server'). This can prevent route self-registration in real runtimes where the alias isn't present. Prefer resolving 'http.server' first (and optionally falling back to 'http-server' for backwards compatibility).

Suggested change
httpServer = ctx.getService<IHttpServer>('http-server');
} catch {
// Service not found — expected in MSW/mock mode
}
// Prefer canonical service name used by RuntimeConfig.server
httpServer = ctx.getService<IHttpServer>('http.server');
} catch {
// Service not found under canonical name — expected in MSW/mock mode or legacy setups
}
if (!httpServer) {
try {
// Backwards compatibility: fall back to legacy alias
httpServer = ctx.getService<IHttpServer>('http-server');
} catch {
// Service not found under legacy alias either
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +145
res.json({
data: {
locales: locales.map((code) => ({
code,
label: code,
isDefault: code === defaultLocale,
})),
},
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These route handlers return { data: ... } / { error: ... }, which does not match the standard REST envelope used by ObjectStackClient.unwrapResponse ({ success: boolean, data, meta? } / { success:false, error:{message,code} }). This will break client.i18n calls when served by I18nServicePlugin directly. Align the response shape with the standard envelope (and keep it consistent with HttpDispatcher.handleI18n / dispatcher-plugin).

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +157
// GET /i18n/translations/:locale
httpServer.get(`${basePath}/translations/:locale`, async (req: IHttpRequest, res: IHttpResponse) => {
try {
const locale = req.params.locale;
if (!locale) {
res.status(400).json({ error: 'Missing locale parameter' });
return;
}
const translations = i18n.getTranslations(locale);
res.json({ data: { locale, translations } });

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I18nServicePlugin only registers GET ${basePath}/translations/:locale, but HttpDispatcher.handleI18n (and ObjectStackClient.i18n.getTranslations) also supports/uses the query form GET /translations?locale=.... With the current routing, client requests to /translations?locale=... will 404. Consider registering an additional GET ${basePath}/translations route (reading req.query.locale) or supporting both patterns.

Copilot uses AI. Check for mistakes.

This branch was successfully deployed

2 active deployments
Preview – spec — 6ea9935f Deployed Mar 11, 2026 by vercel[bot]
Preview – objectstack-play — 6ea9935f Deployed Mar 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n 路由应由 service-i18n 自注册,RestServer 应移除统一注册职责,保持架构一致性

3 participants