@@ -416,6 +416,7 @@ export class HonoHttpServer implements IHttpServer {
416416 private drainTimeoutMs : number = 10_000 ,
417417 ) {
418418 this . app = new Hono ( ) ;
419+ this . installErrorEnvelopeSeam ( ) ;
419420 }
420421
421422 // internal helper to convert standard handler to Hono handler
@@ -770,6 +771,148 @@ export class HonoHttpServer implements IHttpServer {
770771 }
771772 }
772773
774+ /**
775+ * Report a throw that escaped to the TRANSPORT seam — the diagnostic exit
776+ * for the population {@link reportHandlerFailure} cannot see.
777+ *
778+ * Two reporters, DISJOINT populations: `wrap()` catches everything a
779+ * {@link RouteHandler} throws and reports it there, so nothing reaching
780+ * this method has been reported already and nothing it reports will be
781+ * reported again. Hono's default handler wrote these to `console.error`;
782+ * routing them through the host logger is what puts a raw-mount failure in
783+ * the same stream as every other one.
784+ *
785+ * `error`, not `warn`, for the reason {@link reportHandlerFailure} states:
786+ * an unhandled throw out of a handler is a server-side defect, and the
787+ * AGENTS.md "handed to the CALLER" exemption does not apply to a throw
788+ * nobody caught. Method and path only — never the body.
789+ */
790+ private reportTransportEscape (
791+ c : any ,
792+ thrown : unknown ,
793+ rendered ?: { status : number ; code : unknown } ,
794+ ) : void {
795+ try {
796+ const method = typeof c ?. req ?. method === 'string' ? c . req . method : undefined ;
797+ const path = typeof c ?. req ?. path === 'string' ? c . req . path : undefined ;
798+ this . logger . error (
799+ rendered
800+ ? '[hono] a throw escaped to the transport — request answered with the throw\'s declared ADR-0112 envelope'
801+ : '[hono] a throw escaped to the transport — request answered 500 INTERNAL_ERROR with no cause in the body' ,
802+ toLoggableError ( thrown ) ,
803+ rendered
804+ ? { method, path, status : rendered . status , code : rendered . code }
805+ : { method, path } ,
806+ ) ;
807+ } catch {
808+ // Same discipline as {@link reportHandlerFailure }: a host logger
809+ // that throws would otherwise reject the error seam itself and
810+ // hand the caller Hono's opaque page — precisely the answer this
811+ // seam exists to remove.
812+ }
813+ }
814+
815+ /**
816+ * Answer a throw that escaped to the TRANSPORT with the declared ADR-0112
817+ * envelope — the seam a route mounted through {@link getRawApp} funnels
818+ * through (#17411).
819+ *
820+ * ## The door this closes
821+ *
822+ * {@link wrap} catches everything a {@link RouteHandler} throws, so every
823+ * route registered through {@link get} / {@link post } / … already answers
824+ * the declared envelope (#16545). A route mounted on the framework handle
825+ * passes through NEITHER `wrap()` nor any registrar wrapper, so its
826+ * escaped throw reached Hono's own default handler — measured on
827+ * `7d350a46`, one `HonoHttpServer`, the raw pair mounted the way
828+ * `marketplace-install-local-plugin.ts` mounts:
829+ *
830+ * ```
831+ * /raw/envelope -> 500 text/plain; charset=UTF-8 Internal Server Error
832+ * /raw/plain -> 500 text/plain; charset=UTF-8 Internal Server Error
833+ * ```
834+ *
835+ * Byte-identical: a throw that DECLARED `503` / `SERVICE_UNAVAILABLE` and
836+ * a bare driver error answered the same thing, so the transport discarded
837+ * the producer's own declaration — the half of the defect that is
838+ * invisible from the producer's side, which is where anyone would look.
839+ *
840+ * ## Why the transport, and why this does not close the escape hatch
841+ *
842+ * {@link getRawApp}'s exemption is scoped to framework-native MOUNTING and
843+ * to route introspection, never to the wire shape of a refusal. The
844+ * contract says raw-handle mounts are "outside this table by construction
845+ * … this answers 'what routes did I register', not 'what paths might
846+ * respond'" ({@link IHttpServer.getMountedRoutes}), and the same contract
847+ * requires the unmatched answer to carry "the shared not-found error body
848+ * (the `errors.zod` envelope), never an adapter-native error page". An
849+ * error seam on the handle leaves the hatch fully intact: consumers still
850+ * mount natively, still stay outside `getMountedRoutes()`, still need no
851+ * adapter verb. It is the reasoning {@link installHttpMetricsSeam}'s
852+ * seam already rests on (#9650) — the transport is the one layer every
853+ * inbound request converges on, whatever registered the handler.
854+ *
855+ * ## ONE rule, not a second one
856+ *
857+ * The render is {@link declaredEnvelopeForThrow}, the same gate `wrap()`
858+ * opted into, so `/raw/*` and a direct-mount route answer the same shape
859+ * for the same throw — the `ValidationError`-shape-as-declaration limb
860+ * included. The fallback arm is the ADR-0112 `INTERNAL_ERROR` body
861+ * carrying {@link INTERNAL_ERROR_MESSAGE}: a non-envelope throw still
862+ * answers 500 with NO cause in the body, #16545's pinned invariant.
863+ *
864+ * ⛔ It deliberately does NOT copy `wrap()`'s literal `"No response from
865+ * handler"`. That sentence describes a handler that wrote nothing — a
866+ * state this seam never observes, because a Hono handler that returns
867+ * nothing is Hono's own error, not ours. Copying it would put a false
868+ * diagnosis on the wire; the `code` and the `status`, which are what a
869+ * client branches on, agree with `wrap()` exactly.
870+ *
871+ * ## Hono's own declared-`Response` limb is preserved
872+ *
873+ * Hono's default handler honours a thrown value carrying its own
874+ * `Response` (`HTTPException`) before falling back to
875+ * `text('Internal Server Error', 500)`, and that limb is kept verbatim: an
876+ * `HTTPException` is a framework-native refusal the producer DECLARED, and
877+ * overriding it would be this card's own defect with the roles reversed.
878+ * Measured at `7d350a46`: zero `HTTPException` producers anywhere in
879+ * `packages/`, so this preserves behaviour rather than adding any.
880+ *
881+ * ## Installed from the constructor, and overridable on purpose
882+ *
883+ * Once, unconditionally, so a bare `HonoHttpServer` (cloud's serverless
884+ * entrypoints, tests) gets it without wiring — the same reason
885+ * {@link setLogger}'s default is a real logger. A consumer that calls
886+ * `getRawApp().onError(...)` itself replaces it, which is the escape hatch
887+ * working as designed.
888+ */
889+ private installErrorEnvelopeSeam ( ) : void {
890+ this . app . onError ( ( err : Error , c : any ) => {
891+ // Hono's own precedence, unchanged — see the docblock.
892+ if ( err !== null && typeof err === 'object' && 'getResponse' in err ) {
893+ const declared = ( err as unknown as { getResponse ( ) : Response } ) . getResponse ( ) ;
894+ return c . newResponse ( declared . body , declared ) ;
895+ }
896+
897+ const envelope = declaredEnvelopeForThrow ( err ) ;
898+ this . reportTransportEscape (
899+ c ,
900+ err ,
901+ envelope ? { status : envelope . status , code : envelope . body . error . code } : undefined ,
902+ ) ;
903+
904+ return envelope
905+ ? c . json ( envelope . body , envelope . status )
906+ : c . json (
907+ {
908+ success : false ,
909+ error : { code : 'INTERNAL_ERROR' , message : INTERNAL_ERROR_MESSAGE } ,
910+ } ,
911+ 500 ,
912+ ) ;
913+ } ) ;
914+ }
915+
773916 get ( path : string , handler : RouteHandler ) {
774917 this . registeredRoutes . push ( { method : 'GET' , pattern : path } ) ;
775918 this . app . get ( path , this . wrap ( handler ) ) ;
@@ -1311,13 +1454,24 @@ export class HonoHttpServer implements IHttpServer {
13111454 this . app . use ( '*' , async ( c , next ) => {
13121455 if ( this . responseObservers . length === 0 ) return next ( ) ;
13131456 const startedAt = Date . now ( ) ;
1314- // Default 500: if `next()` rejects, Hono's error path renders the
1315- // 500 and `c.res` is not yet set — reading it would synthesize a
1316- // response and change what the caller receives.
1457+ // Default 500: if `next()` rejects, `c.res` is not yet set here —
1458+ // reading it would synthesize a response and change what the
1459+ // caller receives.
13171460 let status = 500 ;
13181461 try {
13191462 await next ( ) ;
13201463 status = c . res . status ;
1464+ } catch ( err ) {
1465+ // … and 500 stopped being the whole answer with #17411: the
1466+ // transport error seam ({@link installErrorEnvelopeSeam}) may
1467+ // render a DECLARED status for this throw, and it runs after
1468+ // this middleware unwinds. `HttpResponseObservation.status` is
1469+ // contracted as "the status of the response as sent", so the
1470+ // observer is owed that status — read off the SAME rule the
1471+ // seam renders from, never a second copy of it. The throw is
1472+ // re-raised untouched: observing is not handling.
1473+ status = declaredEnvelopeForThrow ( err ) ?. status ?? 500 ;
1474+ throw err ;
13211475 } finally {
13221476 // An unrouted request executes only this adapter's own
13231477 // `use('*')` seams, so after `next()` `routePath(c)` reports
0 commit comments