@@ -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