From 1611a44624382861fa025538ed7f8daeee7e70d9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:13:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20thin=20domain-handler=20regist?= =?UTF-8?q?ry=20seam=20in=20the=20HTTP=20dispatcher=20=E2=80=94=20ADR-0076?= =?UTF-8?q?=20D11=20step=20=E2=91=A2=20PR-1=20(#2462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch() routed every domain through one hand-written startsWith if-chain and every handler lived on the dispatcher class — the "god implementation on a clean port" shape D11 calls out. This is the decomposition seam, cut deliberately small: - DomainHandlerRegistry: first-match {prefix, match, methods, handler} table, no wildcards/params/middleware — only "which domain owns this path". Matching is FAITHFUL to the legacy chain, rough edges included (bare startsWith also matching '/i18nxx' is preserved, not fixed). - dispatch() consults the registry before the legacy chain; the four seeded prefixes are disjoint from every remaining branch, so registry-first is order-equivalent. - Seeded four builtin domains demonstrating the three handler shapes: /health + /ready (no service dependency), /analytics (service bridge; fallback-vs-replace semantics stay in the service layer per D10/D12), /i18n (optional service, in-handler 501). - registerDomainHandler() is the public seam follow-up domain PRs use to move handler bodies + registration ownership into owning service packages (service-absence semantics decided per-domain there, with D12 discovery honesty). Verified: 11 new seam tests; runtime 610 tests green; http-conformance 41 cross-adapter assertions green; 25-package dependent closure builds with DTS. Co-Authored-By: Claude Fable 5 --- .changeset/runtime-domain-handler-registry.md | 19 +++ .../src/domain-handler-registry.test.ts | 154 ++++++++++++++++++ .../runtime/src/domain-handler-registry.ts | 97 +++++++++++ packages/runtime/src/http-dispatcher.ts | 131 ++++++++++----- packages/runtime/src/index.ts | 4 + 5 files changed, 367 insertions(+), 38 deletions(-) create mode 100644 .changeset/runtime-domain-handler-registry.md create mode 100644 packages/runtime/src/domain-handler-registry.test.ts create mode 100644 packages/runtime/src/domain-handler-registry.ts diff --git a/.changeset/runtime-domain-handler-registry.md b/.changeset/runtime-domain-handler-registry.md new file mode 100644 index 000000000..a677bde46 --- /dev/null +++ b/.changeset/runtime-domain-handler-registry.md @@ -0,0 +1,19 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): thin domain-handler registry seam in the HTTP dispatcher — ADR-0076 D11 step ③, PR-1 (#2462) + +`dispatch()` routed every domain through one hand-written +`if (cleanPath.startsWith('/xxx'))` chain — the "god implementation on a clean +port" shape ADR-0076 D11 calls out. This lands the decomposition seam: a +first-match `DomainHandlerRegistry` consulted before the legacy chain, plus a +public `HttpDispatcher.registerDomainHandler()` so follow-up PRs can hand each +domain's normalized handler to its owning service package. + +Migration discipline is "registry first, code moves later, ownership last": +this PR only wraps four existing branches (`/health`, `/ready`, `/analytics`, +`/i18n` — three shapes: no-service probe, service bridge, optional-service +501) into registry entries with faithful legacy matching semantics. Zero +behavior change, locked by the 41-assertion http-conformance cross-adapter +suite and 11 new seam tests. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts new file mode 100644 index 000000000..f716abbb5 --- /dev/null +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0076 D11 step ③ (#2462) — the thin domain-handler registry seam. + * + * Two layers under test: + * 1. `DomainHandlerRegistry` matching semantics (first-match, exact vs + * prefix, method restriction) — deliberately faithful to the legacy + * if-chain, rough edges included. + * 2. `HttpDispatcher` integration: the four seeded builtin domains + * (/health /ready /analytics /i18n) behave exactly as their legacy + * if-chain branches did, and `registerDomainHandler` is the public + * seam follow-up domain PRs will use. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { DomainHandlerRegistry } from './domain-handler-registry.js'; +import type { DomainHandler } from './domain-handler-registry.js'; + +const okHandler = (tag: string): DomainHandler => + async () => ({ handled: true, response: { status: 200, body: { tag } } }); + +/** Minimal kernel: objectql + optional extra services. */ +function makeKernel(services: Record = {}, state = 'running') { + const objectql = { + find: vi.fn().mockResolvedValue([]), + getObjects: vi.fn().mockReturnValue({}), + registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) }, + }; + const all: Record = { objectql, ...services }; + const kernel: any = { + getState: () => state, + getService: (name: string) => all[name] ?? null, + getServiceAsync: async (name: string) => all[name] ?? null, + context: { getService: (name: string) => all[name] ?? null }, + }; + return kernel; +} + +function makeDispatcher(services: Record = {}, state = 'running') { + return new HttpDispatcher(makeKernel(services, state), undefined, { + enforceProjectMembership: false, + }); +} + +// --------------------------------------------------------------------------- +// DomainHandlerRegistry matching semantics +// --------------------------------------------------------------------------- + +describe('DomainHandlerRegistry', () => { + it('resolves in registration order, first match wins', () => { + const registry = new DomainHandlerRegistry(); + const first = okHandler('first'); + const second = okHandler('second'); + registry.register({ prefix: '/a', handler: first }); + registry.register({ prefix: '/a', handler: second }); + expect(registry.resolve('/a/x', 'GET')?.handler).toBe(first); + }); + + it("match: 'exact' does not claim sub-paths; default prefix match does (legacy startsWith, rough edges included)", () => { + const registry = new DomainHandlerRegistry(); + registry.register({ prefix: '/health', match: 'exact', handler: okHandler('h') }); + registry.register({ prefix: '/i18n', handler: okHandler('i') }); + expect(registry.resolve('/health', 'GET')).toBeDefined(); + expect(registry.resolve('/health/deep', 'GET')).toBeUndefined(); + expect(registry.resolve('/i18n/locales', 'GET')).toBeDefined(); + // Faithful legacy semantics: bare startsWith also matches '/i18nxx'. + expect(registry.resolve('/i18nxx', 'GET')).toBeDefined(); + }); + + it('restricts by method when `methods` is set (case-insensitive on input)', () => { + const registry = new DomainHandlerRegistry(); + registry.register({ prefix: '/health', match: 'exact', methods: ['GET'], handler: okHandler('h') }); + expect(registry.resolve('/health', 'get')).toBeDefined(); + expect(registry.resolve('/health', 'POST')).toBeUndefined(); + }); + + it('rejects a prefix that does not start with a slash', () => { + const registry = new DomainHandlerRegistry(); + expect(() => registry.register({ prefix: 'health', handler: okHandler('h') })).toThrow(/prefix/); + }); +}); + +// --------------------------------------------------------------------------- +// Dispatcher integration — seeded builtin domains keep legacy behavior +// --------------------------------------------------------------------------- + +describe('HttpDispatcher domain registry (D11 step ③)', () => { + it('GET /health serves the liveness payload', async () => { + const result = await makeDispatcher().dispatch('GET', '/health', undefined, {}, {} as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.status).toBe('ok'); + }); + + it('GET /ready reflects kernel state: running → 200, booting → 503', async () => { + const ready = await makeDispatcher({}, 'running').dispatch('GET', '/ready', undefined, {}, {} as any); + expect(ready.response?.status).toBe(200); + expect(ready.response?.body?.data?.state).toBe('running'); + + const booting = await makeDispatcher({}, 'initializing').dispatch('GET', '/ready', undefined, {}, {} as any); + expect(booting.response?.status).toBe(503); + expect(booting.response?.body?.error?.details?.state).toBe('initializing'); + }); + + it('non-GET /health is NOT claimed by the health domain (falls through the legacy chain)', async () => { + const result = await makeDispatcher().dispatch('POST', '/health', undefined, {}, {} as any); + // Same as before the registry: no branch claims POST /health. + expect(result.response?.status ?? 404).not.toBe(200); + }); + + it('/i18n keeps its in-handler 501 when the i18n service is absent', async () => { + const result = await makeDispatcher().dispatch('GET', '/i18n/locales', undefined, {}, {} as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); + }); + + it('/i18n/locales serves from the i18n service when present', async () => { + const i18n = { getLocales: vi.fn().mockReturnValue(['en', 'zh-CN']), getTranslations: vi.fn().mockReturnValue({}) }; + const result = await makeDispatcher({ i18n }).dispatch('GET', '/i18n/locales', undefined, {}, {} as any); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN']); + }); + + it('/analytics bridges to the analytics service exactly as the legacy branch did', async () => { + const analytics = { + query: vi.fn().mockResolvedValue({ rows: [{ n: 1 }] }), + analyticsQuery: vi.fn().mockResolvedValue({ rows: [{ n: 1 }] }), + }; + const result = await makeDispatcher({ analytics }).dispatch( + 'POST', '/analytics/query', { metric: 'count' }, {}, {} as any, + ); + expect(result.handled).toBe(true); + // The bridge consulted the service (whichever entry point it uses). + expect( + analytics.query.mock.calls.length + analytics.analyticsQuery.mock.calls.length, + ).toBeGreaterThan(0); + }); + + it('registerDomainHandler is the public seam: a service-owned domain resolves before the legacy chain', async () => { + const dispatcher = makeDispatcher(); + const handler = vi.fn(async (req: any) => ({ + handled: true as const, + response: { status: 200, body: { echo: req.path } }, + })); + dispatcher.registerDomainHandler({ prefix: '/custom-domain', handler }); + + const result = await dispatcher.dispatch('GET', '/custom-domain/thing', undefined, { q: '1' }, {} as any); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toMatchObject({ path: '/custom-domain/thing', method: 'GET' }); + expect(result.response?.body?.echo).toBe('/custom-domain/thing'); + }); +}); diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts new file mode 100644 index 000000000..fa476cd78 --- /dev/null +++ b/packages/runtime/src/domain-handler-registry.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Domain handler registry — the thin routing seam of ADR-0076 D11 step ③ + * (#2462). + * + * The HTTP dispatcher historically routed every domain through one hand-written + * `if (cleanPath.startsWith('/xxx'))` chain inside `dispatch()`, and every + * domain's handler lived as a method on the dispatcher class — the "god + * implementation on a clean port" shape ADR-0076 D11 calls out. This registry + * is the decomposition seam: `dispatch()` consults it FIRST, and domains are + * migrated out of the if-chain one PR at a time. + * + * Migration discipline (registry first, code moves later, ownership last): + * 1. This PR: the dispatcher wraps its existing `handleXxx` methods into + * registry entries at construction time — same matching semantics, same + * handler bodies, zero behavior change (locked by the http-conformance + * cross-adapter suite). + * 2. Follow-up PRs: a domain's handler body moves into its owning service + * package, which registers the entry itself (via + * {@link HttpDispatcher.registerDomainHandler}). Service-absence + * semantics (today's in-handler 501 vs route-not-mounted 404) are decided + * per-domain at THAT point, together with the D12 honest-capabilities + * discovery contract. + * + * Matching semantics are deliberately faithful to the legacy if-chain, + * INCLUDING its rough edges (`match: 'prefix'` on `/i18n` also matches + * `/i18nxx`, exactly as `startsWith` did) — fixing those edges is explicitly + * not this seam's job; behavior preservation is. + */ + +import type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js'; + +/** + * The normalized request slice a domain handler receives. `path` is the + * dispatcher's `cleanPath` — API prefix and `/environments/:id` scope already + * stripped, NO domain-prefix stripping (each handler keeps its historical + * substring convention until its domain PR normalizes it). + */ +export interface DomainRequest { + path: string; + method: string; + body: any; + query: any; +} + +/** Normalized per-domain handler — the dispatcher-independent handler shape. */ +export type DomainHandler = ( + req: DomainRequest, + context: HttpProtocolContext, +) => Promise; + +export interface DomainRoute { + /** Path prefix the domain claims, e.g. `'/i18n'`. */ + prefix: string; + /** + * `'prefix'` — legacy `startsWith(prefix)` semantics (default). + * `'exact'` — the path must equal the prefix exactly. + */ + match?: 'prefix' | 'exact'; + /** Restrict to these UPPERCASE HTTP methods. Omit = all methods. */ + methods?: string[]; + handler: DomainHandler; +} + +/** + * First-match-wins routing table, in registration order. Kept deliberately + * minimal — no wildcards, no params, no middleware: those belong to the real + * HTTP adapters. This seam only decides "which domain owns this path". + */ +export class DomainHandlerRegistry { + private readonly routes: DomainRoute[] = []; + + register(route: DomainRoute): void { + if (!route.prefix.startsWith('/')) { + throw new Error(`DomainHandlerRegistry: prefix must start with '/', got '${route.prefix}'`); + } + this.routes.push(route); + } + + /** Resolve the first route claiming `path` (+`method`), else undefined. */ + resolve(path: string, method: string): DomainRoute | undefined { + const m = method.toUpperCase(); + for (const route of this.routes) { + if (route.methods && !route.methods.includes(m)) continue; + if (route.match === 'exact' ? path === route.prefix : path.startsWith(route.prefix)) { + return route; + } + } + return undefined; + } + + /** Registered routes, in match order (introspection / tests). */ + list(): readonly DomainRoute[] { + return this.routes; + } +} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5c93a37b4..65a586708 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -14,6 +14,7 @@ import { validateActionParams, type ResolvedActionParam } from '@objectstack/spe import type { ExecutionContext } from '@objectstack/spec/kernel'; import { setPackageDisabled } from './package-state-store.js'; import { checkApiExposure } from './api-exposure.js'; +import { DomainHandlerRegistry, type DomainRoute } from './domain-handler-registry.js'; /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ interface EnvironmentScopeManager { @@ -168,6 +169,11 @@ export class HttpDispatcher { private defaultProject?: { environmentId: string; orgId?: string }; private kernelResolver?: KernelResolver; private scopeManager?: EnvironmentScopeManager; + /** + * ADR-0076 D11 step ③ decomposition seam — consulted by `dispatch()` + * before the legacy if-chain. See {@link DomainHandlerRegistry}. + */ + private readonly domainRegistry = new DomainHandlerRegistry(); /** * When `true`, scoped data-plane routes enforce a * `sys_environment_member` lookup and return 403 for non-members. @@ -217,6 +223,75 @@ export class HttpDispatcher { // Single-project default is resolved lazily on first request — the // plugin that registers it (`createSingleEnvironmentPlugin`) may run // its `init()` after the HttpDispatcher is constructed. + this.registerBuiltinDomains(); + } + + /** + * ADR-0076 D11 step ③ — seed the domain registry with the first domains + * lifted out of the `dispatch()` if-chain. Handler BODIES stay as + * dispatcher methods in this PR (registry first, code moves later, + * ownership last — see {@link DomainHandlerRegistry}); each entry + * faithfully reproduces its legacy branch's matching + argument + * convention, so behavior is unchanged. + */ + private registerBuiltinDomains(): void { + // GET /health — liveness probe (was branch "0b"). + this.domainRegistry.register({ + prefix: '/health', match: 'exact', methods: ['GET'], + handler: async () => ({ + handled: true, + response: this.success({ + status: 'ok', + timestamp: new Date().toISOString(), + version: '1.0.0', + uptime: typeof process !== 'undefined' ? process.uptime() : undefined, + }), + }), + }); + // GET /ready — k8s / load-balancer readiness probe (was branch "0b2"). + // 200 only when the kernel is fully running; 503 while booting + // (idle/initializing) or shutting down (stopping/stopped) so a load + // balancer stops routing to this replica BEFORE in-flight requests + // are drained and the server closes (graceful rolling restart). + this.domainRegistry.register({ + prefix: '/ready', match: 'exact', methods: ['GET'], + handler: async () => { + const state: string = typeof (this.kernel as any)?.getState === 'function' + ? (this.kernel as any).getState() + : 'running'; + return state === 'running' + ? { handled: true, response: this.success({ status: 'ready', state }) } + : { handled: true, response: this.error('Service not ready', 503, { state }) }; + }, + }); + // /analytics — bridge to the `analytics` service. NOTE: the fallback + // vs service-replace semantics live in the SERVICE layer + // (ObjectQLPlugin fallback + service-analytics `replaceService`, see + // ADR-0076 D10/D12) — this route entry must keep working whether the + // real engine or the degraded fallback is registered, which is why + // its handler registration stays dispatcher-owned for now. + this.domainRegistry.register({ + prefix: '/analytics', + handler: (req, context) => + this.handleAnalytics(req.path.substring(10), req.method, req.body, context), + }); + // /i18n — translations / locales / labels, served by the `i18n` + // service (501 inside the handler when the service is absent). + this.domainRegistry.register({ + prefix: '/i18n', + handler: (req, context) => + this.handleI18n(req.path.substring(5), req.method, req.query, context), + }); + } + + /** + * Public registration seam for follow-up D11 domain PRs: an owning + * service package registers its normalized handler here instead of the + * dispatcher hard-coding another if-branch. Entries are consulted before + * the legacy if-chain, first match wins. + */ + registerDomainHandler(route: DomainRoute): void { + this.domainRegistry.register(route); } private resolveDefaultProject(): { environmentId: string; orgId?: string } | undefined { @@ -4525,45 +4600,29 @@ export class HttpDispatcher { cleanPath = scopedMatch[1] ?? ''; } + try { + // ── Domain registry (ADR-0076 D11 step ③) ── + // Domains lifted out of the if-chain below resolve here first; + // unmigrated domains fall through to the legacy branches. The four + // seeded prefixes (/health /ready /analytics /i18n) are disjoint from + // every remaining branch prefix, so consulting the registry first is + // order-equivalent to their original chain positions. + const domainRoute = this.domainRegistry.resolve(cleanPath, method); + if (domainRoute) { + return domainRoute.handler({ path: cleanPath, method, body, query }, context); + } + // 0. Discovery Endpoint (GET /discovery or GET /) // Standard route: /discovery (protocol-compliant) // Legacy route: / (empty path, for backward compatibility — MSW strips base URL) - try { if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') { const info = await this.getDiscoveryInfo(prefix ?? ''); - return { - handled: true, - response: this.success(info) + return { + handled: true, + response: this.success(info) }; } - // 0b. Health Endpoint (GET /health) - if (cleanPath === '/health' && method === 'GET') { - return { - handled: true, - response: this.success({ - status: 'ok', - timestamp: new Date().toISOString(), - version: '1.0.0', - uptime: typeof process !== 'undefined' ? process.uptime() : undefined, - }), - }; - } - - // 0b2. Readiness Endpoint (GET /ready) — k8s / load-balancer readiness probe. - // 200 only when the kernel is fully running; 503 while booting - // (idle/initializing) or shutting down (stopping/stopped) so a load - // balancer stops routing to this replica BEFORE in-flight requests are - // drained and the server closes (graceful rolling restart). - if (cleanPath === '/ready' && method === 'GET') { - const state: string = typeof (this.kernel as any)?.getState === 'function' - ? (this.kernel as any).getState() - : 'running'; - return state === 'running' - ? { handled: true, response: this.success({ status: 'ready', state }) } - : { handled: true, response: this.error('Service not ready', 503, { state }) }; - } - // 0c. Plan-A diagnostics removed; the seed-replay and oauth2/callback // probes were temporary debugging tools used during the SSO rollout. @@ -4616,10 +4675,8 @@ export class HttpDispatcher { if (cleanPath.startsWith('/actions')) { return this.handleActions(cleanPath.substring(8), method, body, context); } - - if (cleanPath.startsWith('/analytics')) { - return this.handleAnalytics(cleanPath.substring(10), method, body, context); - } + + // /analytics moved to the domain registry (D11 step ③). // In-app notifications (ADR-0030) — inbox list + receipt mark-read, // backed by the messaging service registered under the `notification` slot. @@ -4637,9 +4694,7 @@ export class HttpDispatcher { return this.handlePackages(cleanPath.substring(9), method, body, query, context); } - if (cleanPath.startsWith('/i18n')) { - return this.handleI18n(cleanPath.substring(5), method, query, context); - } + // /i18n moved to the domain registry (D11 step ③). // AI Service — delegate to the registered AI route handlers if (cleanPath.startsWith('/ai')) { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 458bc1aef..71f2dddc1 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -45,6 +45,10 @@ export type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatche // ADR-0006 generic kernel-resolution seam (retained framework contract; the // multi-tenant implementation lives in cloud `@objectstack/objectos-runtime`). export type { KernelResolver } from './http-dispatcher.js'; +// ADR-0076 D11 step ③ — thin domain-handler registry seam; owning service +// packages register normalized handlers via HttpDispatcher.registerDomainHandler. +export { DomainHandlerRegistry } from './domain-handler-registry.js'; +export type { DomainRoute, DomainHandler, DomainRequest } from './domain-handler-registry.js'; export { MiddlewareManager } from './middleware.js'; // ── Security primitives ───────────────────────────────────────────────