Skip to content

Commit 03b11e8

Browse files
os-zhuangclaude
andauthored
feat(runtime): thin domain-handler registry seam in the HTTP dispatcher — ADR-0076 D11 step ③ PR-1 (#2462) (#3491)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 69f1dfd commit 03b11e8

5 files changed

Lines changed: 367 additions & 38 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): thin domain-handler registry seam in the HTTP dispatcher — ADR-0076 D11 step ③, PR-1 (#2462)
6+
7+
`dispatch()` routed every domain through one hand-written
8+
`if (cleanPath.startsWith('/xxx'))` chain — the "god implementation on a clean
9+
port" shape ADR-0076 D11 calls out. This lands the decomposition seam: a
10+
first-match `DomainHandlerRegistry` consulted before the legacy chain, plus a
11+
public `HttpDispatcher.registerDomainHandler()` so follow-up PRs can hand each
12+
domain's normalized handler to its owning service package.
13+
14+
Migration discipline is "registry first, code moves later, ownership last":
15+
this PR only wraps four existing branches (`/health`, `/ready`, `/analytics`,
16+
`/i18n` — three shapes: no-service probe, service bridge, optional-service
17+
501) into registry entries with faithful legacy matching semantics. Zero
18+
behavior change, locked by the 41-assertion http-conformance cross-adapter
19+
suite and 11 new seam tests.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0076 D11 step ③ (#2462) — the thin domain-handler registry seam.
5+
*
6+
* Two layers under test:
7+
* 1. `DomainHandlerRegistry` matching semantics (first-match, exact vs
8+
* prefix, method restriction) — deliberately faithful to the legacy
9+
* if-chain, rough edges included.
10+
* 2. `HttpDispatcher` integration: the four seeded builtin domains
11+
* (/health /ready /analytics /i18n) behave exactly as their legacy
12+
* if-chain branches did, and `registerDomainHandler` is the public
13+
* seam follow-up domain PRs will use.
14+
*/
15+
16+
import { describe, it, expect, vi } from 'vitest';
17+
import { HttpDispatcher } from './http-dispatcher.js';
18+
import { DomainHandlerRegistry } from './domain-handler-registry.js';
19+
import type { DomainHandler } from './domain-handler-registry.js';
20+
21+
const okHandler = (tag: string): DomainHandler =>
22+
async () => ({ handled: true, response: { status: 200, body: { tag } } });
23+
24+
/** Minimal kernel: objectql + optional extra services. */
25+
function makeKernel(services: Record<string, any> = {}, state = 'running') {
26+
const objectql = {
27+
find: vi.fn().mockResolvedValue([]),
28+
getObjects: vi.fn().mockReturnValue({}),
29+
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
30+
};
31+
const all: Record<string, any> = { objectql, ...services };
32+
const kernel: any = {
33+
getState: () => state,
34+
getService: (name: string) => all[name] ?? null,
35+
getServiceAsync: async (name: string) => all[name] ?? null,
36+
context: { getService: (name: string) => all[name] ?? null },
37+
};
38+
return kernel;
39+
}
40+
41+
function makeDispatcher(services: Record<string, any> = {}, state = 'running') {
42+
return new HttpDispatcher(makeKernel(services, state), undefined, {
43+
enforceProjectMembership: false,
44+
});
45+
}
46+
47+
// ---------------------------------------------------------------------------
48+
// DomainHandlerRegistry matching semantics
49+
// ---------------------------------------------------------------------------
50+
51+
describe('DomainHandlerRegistry', () => {
52+
it('resolves in registration order, first match wins', () => {
53+
const registry = new DomainHandlerRegistry();
54+
const first = okHandler('first');
55+
const second = okHandler('second');
56+
registry.register({ prefix: '/a', handler: first });
57+
registry.register({ prefix: '/a', handler: second });
58+
expect(registry.resolve('/a/x', 'GET')?.handler).toBe(first);
59+
});
60+
61+
it("match: 'exact' does not claim sub-paths; default prefix match does (legacy startsWith, rough edges included)", () => {
62+
const registry = new DomainHandlerRegistry();
63+
registry.register({ prefix: '/health', match: 'exact', handler: okHandler('h') });
64+
registry.register({ prefix: '/i18n', handler: okHandler('i') });
65+
expect(registry.resolve('/health', 'GET')).toBeDefined();
66+
expect(registry.resolve('/health/deep', 'GET')).toBeUndefined();
67+
expect(registry.resolve('/i18n/locales', 'GET')).toBeDefined();
68+
// Faithful legacy semantics: bare startsWith also matches '/i18nxx'.
69+
expect(registry.resolve('/i18nxx', 'GET')).toBeDefined();
70+
});
71+
72+
it('restricts by method when `methods` is set (case-insensitive on input)', () => {
73+
const registry = new DomainHandlerRegistry();
74+
registry.register({ prefix: '/health', match: 'exact', methods: ['GET'], handler: okHandler('h') });
75+
expect(registry.resolve('/health', 'get')).toBeDefined();
76+
expect(registry.resolve('/health', 'POST')).toBeUndefined();
77+
});
78+
79+
it('rejects a prefix that does not start with a slash', () => {
80+
const registry = new DomainHandlerRegistry();
81+
expect(() => registry.register({ prefix: 'health', handler: okHandler('h') })).toThrow(/prefix/);
82+
});
83+
});
84+
85+
// ---------------------------------------------------------------------------
86+
// Dispatcher integration — seeded builtin domains keep legacy behavior
87+
// ---------------------------------------------------------------------------
88+
89+
describe('HttpDispatcher domain registry (D11 step ③)', () => {
90+
it('GET /health serves the liveness payload', async () => {
91+
const result = await makeDispatcher().dispatch('GET', '/health', undefined, {}, {} as any);
92+
expect(result.handled).toBe(true);
93+
expect(result.response?.status).toBe(200);
94+
expect(result.response?.body?.data?.status).toBe('ok');
95+
});
96+
97+
it('GET /ready reflects kernel state: running → 200, booting → 503', async () => {
98+
const ready = await makeDispatcher({}, 'running').dispatch('GET', '/ready', undefined, {}, {} as any);
99+
expect(ready.response?.status).toBe(200);
100+
expect(ready.response?.body?.data?.state).toBe('running');
101+
102+
const booting = await makeDispatcher({}, 'initializing').dispatch('GET', '/ready', undefined, {}, {} as any);
103+
expect(booting.response?.status).toBe(503);
104+
expect(booting.response?.body?.error?.details?.state).toBe('initializing');
105+
});
106+
107+
it('non-GET /health is NOT claimed by the health domain (falls through the legacy chain)', async () => {
108+
const result = await makeDispatcher().dispatch('POST', '/health', undefined, {}, {} as any);
109+
// Same as before the registry: no branch claims POST /health.
110+
expect(result.response?.status ?? 404).not.toBe(200);
111+
});
112+
113+
it('/i18n keeps its in-handler 501 when the i18n service is absent', async () => {
114+
const result = await makeDispatcher().dispatch('GET', '/i18n/locales', undefined, {}, {} as any);
115+
expect(result.handled).toBe(true);
116+
expect(result.response?.status).toBe(501);
117+
});
118+
119+
it('/i18n/locales serves from the i18n service when present', async () => {
120+
const i18n = { getLocales: vi.fn().mockReturnValue(['en', 'zh-CN']), getTranslations: vi.fn().mockReturnValue({}) };
121+
const result = await makeDispatcher({ i18n }).dispatch('GET', '/i18n/locales', undefined, {}, {} as any);
122+
expect(result.response?.status).toBe(200);
123+
expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN']);
124+
});
125+
126+
it('/analytics bridges to the analytics service exactly as the legacy branch did', async () => {
127+
const analytics = {
128+
query: vi.fn().mockResolvedValue({ rows: [{ n: 1 }] }),
129+
analyticsQuery: vi.fn().mockResolvedValue({ rows: [{ n: 1 }] }),
130+
};
131+
const result = await makeDispatcher({ analytics }).dispatch(
132+
'POST', '/analytics/query', { metric: 'count' }, {}, {} as any,
133+
);
134+
expect(result.handled).toBe(true);
135+
// The bridge consulted the service (whichever entry point it uses).
136+
expect(
137+
analytics.query.mock.calls.length + analytics.analyticsQuery.mock.calls.length,
138+
).toBeGreaterThan(0);
139+
});
140+
141+
it('registerDomainHandler is the public seam: a service-owned domain resolves before the legacy chain', async () => {
142+
const dispatcher = makeDispatcher();
143+
const handler = vi.fn(async (req: any) => ({
144+
handled: true as const,
145+
response: { status: 200, body: { echo: req.path } },
146+
}));
147+
dispatcher.registerDomainHandler({ prefix: '/custom-domain', handler });
148+
149+
const result = await dispatcher.dispatch('GET', '/custom-domain/thing', undefined, { q: '1' }, {} as any);
150+
expect(handler).toHaveBeenCalledTimes(1);
151+
expect(handler.mock.calls[0][0]).toMatchObject({ path: '/custom-domain/thing', method: 'GET' });
152+
expect(result.response?.body?.echo).toBe('/custom-domain/thing');
153+
});
154+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Domain handler registry — the thin routing seam of ADR-0076 D11 step ③
5+
* (#2462).
6+
*
7+
* The HTTP dispatcher historically routed every domain through one hand-written
8+
* `if (cleanPath.startsWith('/xxx'))` chain inside `dispatch()`, and every
9+
* domain's handler lived as a method on the dispatcher class — the "god
10+
* implementation on a clean port" shape ADR-0076 D11 calls out. This registry
11+
* is the decomposition seam: `dispatch()` consults it FIRST, and domains are
12+
* migrated out of the if-chain one PR at a time.
13+
*
14+
* Migration discipline (registry first, code moves later, ownership last):
15+
* 1. This PR: the dispatcher wraps its existing `handleXxx` methods into
16+
* registry entries at construction time — same matching semantics, same
17+
* handler bodies, zero behavior change (locked by the http-conformance
18+
* cross-adapter suite).
19+
* 2. Follow-up PRs: a domain's handler body moves into its owning service
20+
* package, which registers the entry itself (via
21+
* {@link HttpDispatcher.registerDomainHandler}). Service-absence
22+
* semantics (today's in-handler 501 vs route-not-mounted 404) are decided
23+
* per-domain at THAT point, together with the D12 honest-capabilities
24+
* discovery contract.
25+
*
26+
* Matching semantics are deliberately faithful to the legacy if-chain,
27+
* INCLUDING its rough edges (`match: 'prefix'` on `/i18n` also matches
28+
* `/i18nxx`, exactly as `startsWith` did) — fixing those edges is explicitly
29+
* not this seam's job; behavior preservation is.
30+
*/
31+
32+
import type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js';
33+
34+
/**
35+
* The normalized request slice a domain handler receives. `path` is the
36+
* dispatcher's `cleanPath` — API prefix and `/environments/:id` scope already
37+
* stripped, NO domain-prefix stripping (each handler keeps its historical
38+
* substring convention until its domain PR normalizes it).
39+
*/
40+
export interface DomainRequest {
41+
path: string;
42+
method: string;
43+
body: any;
44+
query: any;
45+
}
46+
47+
/** Normalized per-domain handler — the dispatcher-independent handler shape. */
48+
export type DomainHandler = (
49+
req: DomainRequest,
50+
context: HttpProtocolContext,
51+
) => Promise<HttpDispatcherResult>;
52+
53+
export interface DomainRoute {
54+
/** Path prefix the domain claims, e.g. `'/i18n'`. */
55+
prefix: string;
56+
/**
57+
* `'prefix'` — legacy `startsWith(prefix)` semantics (default).
58+
* `'exact'` — the path must equal the prefix exactly.
59+
*/
60+
match?: 'prefix' | 'exact';
61+
/** Restrict to these UPPERCASE HTTP methods. Omit = all methods. */
62+
methods?: string[];
63+
handler: DomainHandler;
64+
}
65+
66+
/**
67+
* First-match-wins routing table, in registration order. Kept deliberately
68+
* minimal — no wildcards, no params, no middleware: those belong to the real
69+
* HTTP adapters. This seam only decides "which domain owns this path".
70+
*/
71+
export class DomainHandlerRegistry {
72+
private readonly routes: DomainRoute[] = [];
73+
74+
register(route: DomainRoute): void {
75+
if (!route.prefix.startsWith('/')) {
76+
throw new Error(`DomainHandlerRegistry: prefix must start with '/', got '${route.prefix}'`);
77+
}
78+
this.routes.push(route);
79+
}
80+
81+
/** Resolve the first route claiming `path` (+`method`), else undefined. */
82+
resolve(path: string, method: string): DomainRoute | undefined {
83+
const m = method.toUpperCase();
84+
for (const route of this.routes) {
85+
if (route.methods && !route.methods.includes(m)) continue;
86+
if (route.match === 'exact' ? path === route.prefix : path.startsWith(route.prefix)) {
87+
return route;
88+
}
89+
}
90+
return undefined;
91+
}
92+
93+
/** Registered routes, in match order (introspection / tests). */
94+
list(): readonly DomainRoute[] {
95+
return this.routes;
96+
}
97+
}

0 commit comments

Comments
 (0)