Skip to content

Commit 12298c7

Browse files
test(http-conformance): setFallbackHandler 的四条契约保证跨适配器落锁 —— 参考适配器先补实现 (#6143) (#6851)
* feat(http-conformance): NodeHttpServer 实现 IHttpServer.setFallbackHandler(#6143) * test(http-conformance): setFallbackHandler 四条契约保证的跨适配器共享用例(#6143) * chore(changeset): http-conformance minor — fallback seam capability + cross-adapter cases (#6143) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47a4e67 commit 12298c7

3 files changed

Lines changed: 452 additions & 22 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/http-conformance": minor
3+
---
4+
5+
test(http-conformance): the `setFallbackHandler` seam's four guarantees are asserted cross-adapter — and the reference adapter implements them
6+
7+
`IHttpServer.setFallbackHandler` (`packages/spec/src/contracts/http-server.ts`,
8+
#5040 §1-C) declares four testable guarantees, and `@objectstack/http-conformance`
9+
— the cross-adapter guard those semantics name by hand — asserted **none** of
10+
them. Since the #5111 flip this seam is the ONLY entry path for declarative
11+
`apis:` endpoints, so a second adapter diverging here does not mean cosmetic
12+
drift, it means "declarative endpoints behave unpredictably on that adapter".
13+
14+
Two changes, in the only order that works:
15+
16+
1. **`NodeHttpServer` gains the member.** `node:http` ships no not-found hook to
17+
map onto, so this adapter builds the equivalent out of its own router: the
18+
handler is a FIELD consulted in the route-miss branch, never a
19+
`${prefix}/*` catch-all route (which would be decided by first-match-wins
20+
registration order — the ADR-0076 D11 hazard). 405 + `Allow` keeps
21+
precedence over the fallback, and a fallback that writes nothing falls
22+
through to the adapter's own unmatched answer unchanged.
23+
2. **`fallback-seam.conformance.test.ts` transcribes the four guarantees** and
24+
runs them against BOTH adapters over a real socket — `NodeHttpServer` and
25+
`HonoHttpServer`, same cases, no adapter-conditional branches. Nine cases
26+
per adapter.
27+
28+
**Why `minor`, and why only this package.** The bump is a new capability on a
29+
published-nothing QA harness: `NodeHttpServer` grew a contract member it did not
30+
have, which is additive API surface on this package, so `minor` rather than the
31+
`patch` the test file alone would earn. No other package is named because none
32+
changed — `packages/spec`'s contract is untouched (the four guarantees were
33+
already declared; this asserts them), and `HonoHttpServer` needed no change to
34+
pass all nine, which is itself the finding: Hono violates none of the four.
35+
36+
**Observation-class, not a live defect.** Only `HonoHttpServer` implements the
37+
member today, and the reference adapter's previous non-implementation was
38+
*compliant* — the member is optional on the contract. This closes a latent gap
39+
before a second implementor exists to fall through it, which is the only moment
40+
the coverage is cheap.

‎packages/qa/http-conformance/src/adapter.ts‎

Lines changed: 130 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ import {
2727
* - **`getPort()`**: used by boot code/tests to discover the OS-assigned
2828
* port after `listen(0)`.
2929
*
30+
* Since #6143 it also implements the contract's optional
31+
* {@link NodeHttpServer.setFallbackHandler} — see the CONTRACT there. That is
32+
* NOT a third soft extension: the member is part of `IHttpServer` and carries
33+
* four testable guarantees, and this adapter has to satisfy them before the
34+
* conformance suite can assert them CROSS-adapter. Asserting them against a
35+
* single implementor would prove nothing about the port.
36+
*
3037
* Deliberately NOT implemented (each one is a known escape hatch whose
3138
* consumers feature-detect and degrade):
3239
* - `getRawApp()` — Hono-specific; metadata HMR, cloud-connection routes and
@@ -103,6 +110,14 @@ export class NodeHttpServer implements IHttpServer {
103110
private middlewares: Middleware[] = [];
104111
private server: Server | undefined;
105112
private listeningPort: number | undefined;
113+
/**
114+
* The LAST-RESORT handler installed by {@link setFallbackHandler}, or
115+
* `undefined` when no consumer installed one. Exactly ONE — installing
116+
* again replaces it, per the contract. A field read per request (rather
117+
* than a route pushed onto {@link routes}) is what makes the replacement
118+
* semantics and the zero registration-order dependency structural here.
119+
*/
120+
private fallbackHandler: RouteHandler | undefined;
106121

107122
constructor(
108123
private port: number = 3000,
@@ -150,6 +165,79 @@ export class NodeHttpServer implements IHttpServer {
150165
return Array.from(methods).sort();
151166
}
152167

168+
/**
169+
* Install the LAST-RESORT handler — see the CONTRACT on
170+
* `IHttpServer.setFallbackHandler` in `@objectstack/spec/contracts`
171+
* (#5040 §1-C). `node:http` ships no not-found hook to map onto, so this
172+
* adapter builds the equivalent out of its own router (#6143). The four
173+
* guarantees, and how each is honoured HERE:
174+
*
175+
* 1. **Only after every registered route has missed.** The handler is a
176+
* FIELD consulted in {@link handleRequest}'s route-miss branch — never
177+
* a route pushed onto {@link routes}. A `${prefix}/*` catch-all route
178+
* would be decided by this adapter's first-match-wins matching, i.e.
179+
* by registration order across plugin `start()` — the ADR-0076 D11
180+
* hazard "one route, one owner" exists to prevent. As a field it is
181+
* structurally incapable of shadowing a registered route, whenever it
182+
* was installed. A METHOD mismatch on an existing path is also a miss,
183+
* so the fallback sees those too (the primary adapter routes them into
184+
* the same `notFound` sink) — and declining leaves the 405 intact,
185+
* guarantee 4.
186+
* 2. **`req.body` IS readable.** Nothing consumed the request stream — no
187+
* route handler ran — so the fallback branch parses it by content-type
188+
* through the SAME code a matched route goes through, rather than a
189+
* second request-builder that could drift from it. That is the whole
190+
* reason this seam exists and `use()` cannot serve: the middleware
191+
* contract explicitly does NOT populate `body`.
192+
* 3. **Replacement, not a chain.** One field, assigned. Installing again
193+
* overwrites; nothing accumulates and nothing stacks.
194+
* 4. **A handler that writes nothing leaves the standard answer.** The
195+
* branch falls through to {@link writeUnmatchedResponse} — the 404, or
196+
* the 405 + `Allow`, unchanged. Writing nothing is the documented way
197+
* to say "not mine".
198+
*
199+
* Locked cross-adapter by this package's own
200+
* `fallback-seam.conformance.test.ts`, which runs the same cases against
201+
* `HonoHttpServer`.
202+
*/
203+
setFallbackHandler(handler: RouteHandler): void {
204+
this.fallbackHandler = handler;
205+
}
206+
207+
/**
208+
* This adapter's standard answer for a request that matched no route — the
209+
* `IHttpServer` unmatched-request CONTRACT (#3607 / ADR-0076 OQ#10): 405 +
210+
* an accurate `Allow` when the path exists under another verb, otherwise
211+
* the shared 404 body.
212+
*
213+
* Extracted from {@link handleRequest} by #6143 because it gained a second
214+
* call site: the fall-through after an installed fallback declined to
215+
* answer. Both paths must produce the byte-identical answer — a fallback
216+
* that writes nothing may not cost a caller the `Allow` header.
217+
*/
218+
private writeUnmatchedResponse(nodeRes: ServerResponse, method: string, path: string) {
219+
// Distinguish "path exists under another verb" (405 + Allow) from a
220+
// genuine 404 — same semantics as the primary adapter's notFound.
221+
const allowed = this.allowedMethodsForPath(path);
222+
if (allowed.length > 0 && !allowed.includes(method)) {
223+
nodeRes.statusCode = 405;
224+
nodeRes.setHeader('Allow', allowed.join(', '));
225+
nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8');
226+
nodeRes.end(JSON.stringify({
227+
error: 'Method Not Allowed',
228+
code: 'METHOD_NOT_ALLOWED',
229+
message: `${method} is not supported for ${path}. Allowed: ${allowed.join(', ')}.`,
230+
method,
231+
path,
232+
allowed,
233+
}));
234+
return;
235+
}
236+
nodeRes.statusCode = 404;
237+
nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8');
238+
nodeRes.end(JSON.stringify({ error: 'Not found' }));
239+
}
240+
153241
private match(method: string, path: string): { route: CompiledRoute; params: Record<string, string> } | undefined {
154242
const normalized = normalize(path);
155243
// HEAD is answered by GET handlers (body suppressed by node core for
@@ -177,27 +265,14 @@ export class NodeHttpServer implements IHttpServer {
177265
const path = url.pathname;
178266

179267
const matched = this.match(method, path);
180-
if (!matched) {
181-
// Distinguish "path exists under another verb" (405 + Allow) from a
182-
// genuine 404 — same semantics as the primary adapter's notFound.
183-
const allowed = this.allowedMethodsForPath(path);
184-
if (allowed.length > 0 && !allowed.includes(method)) {
185-
nodeRes.statusCode = 405;
186-
nodeRes.setHeader('Allow', allowed.join(', '));
187-
nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8');
188-
nodeRes.end(JSON.stringify({
189-
error: 'Method Not Allowed',
190-
code: 'METHOD_NOT_ALLOWED',
191-
message: `${method} is not supported for ${path}. Allowed: ${allowed.join(', ')}.`,
192-
method,
193-
path,
194-
allowed,
195-
}));
196-
return;
197-
}
198-
nodeRes.statusCode = 404;
199-
nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8');
200-
nodeRes.end(JSON.stringify({ error: 'Not found' }));
268+
// The LAST-RESORT seam (#6143): consulted ONLY here, i.e. only once
269+
// every explicitly registered route has missed — see the CONTRACT on
270+
// {@link setFallbackHandler}. Resolved BEFORE the request body is read
271+
// so an unmatched request on a server with NO fallback installed still
272+
// costs exactly what it cost before this seam existed: nothing.
273+
const fallback = matched ? undefined : this.fallbackHandler;
274+
if (!matched && !fallback) {
275+
this.writeUnmatchedResponse(nodeRes, method, path);
201276
return;
202277
}
203278

@@ -243,7 +318,9 @@ export class NodeHttpServer implements IHttpServer {
243318
// included — no backfill needed (the Fetch-API Host backfill in the
244319
// Hono adapter is adapter-local, not a port requirement).
245320
const req: IHttpRequest = {
246-
params: matched.params,
321+
// No matched route means no path params — `{}`, exactly what the
322+
// primary adapter hands its fallback (its `readRouteParams` guard).
323+
params: matched?.params ?? {},
247324
query,
248325
body,
249326
headers: nodeReq.headers as Record<string, string | string[]>,
@@ -285,6 +362,37 @@ export class NodeHttpServer implements IHttpServer {
285362
},
286363
};
287364

365+
if (!matched) {
366+
// ── The fallback seam ───────────────────────────────────────────
367+
// Guaranteed installed: the no-fallback case returned above.
368+
// Middlewares deliberately do NOT run here — they do not run for
369+
// an unmatched request on this adapter today either, and changing
370+
// that is a separate decision from installing this seam.
371+
const handler = fallback as RouteHandler;
372+
try {
373+
await handler(req, res as IHttpResponse);
374+
} catch {
375+
// Prefer failing to falling back: a fallback that THREW is a
376+
// broken consumer, and reporting its failure as this adapter's
377+
// ordinary 404 would hide it behind the most unremarkable
378+
// status on the wire. Same body as the primary adapter.
379+
if (!nodeRes.writableEnded) {
380+
if (!nodeRes.headersSent) {
381+
nodeRes.statusCode = 500;
382+
nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8');
383+
}
384+
nodeRes.end(JSON.stringify({ error: 'Fallback handler failed' }));
385+
}
386+
return;
387+
}
388+
// Answered (buffered or streamed) — that response stands.
389+
if (nodeRes.writableEnded || streaming) return;
390+
// Wrote nothing — the documented way to say "not mine". The
391+
// adapter's own unmatched answer stands, unchanged (guarantee 4).
392+
this.writeUnmatchedResponse(nodeRes, method, path);
393+
return;
394+
}
395+
288396
try {
289397
for (const mw of this.middlewares) {
290398
let advanced = false;

0 commit comments

Comments
 (0)