Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/raw-mount-declared-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/plugin-hono-server': patch
---

**`getRawApp()` mounts now answer an escaped throw with the declared ADR-0112 envelope.** A route mounted on the Hono handle funnels through neither the adapter's `wrap()` nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler — `500 text/plain "Internal Server Error"`, no `success` flag, no `code`, and the thrown value's own declared `status` / `code` discarded. A transport error seam on the raw handle now renders the same throw-to-envelope rule a direct-mount route already used, so both doors answer one shape: a throw declaring `503` / `SERVICE_UNAVAILABLE` answers `503 application/json` with `{"success":false,"error":{"code":"SERVICE_UNAVAILABLE",…}}`, and a throw declaring no envelope still answers `500` with no cause in the body.

The escape hatch is unchanged: consumers still mount framework-natively, still stay outside `getMountedRoutes()`, and still need no adapter verb. A thrown value carrying its own `Response` (Hono's `HTTPException`) keeps the response it declared. A consumer that installs its own `getRawApp().onError(...)` replaces the seam.

Also fixed alongside it: `afterResponse` observers — and therefore `http_requests_total{status}` — reported a hard-coded `500` for any request that ended in a throw, which stops being the status actually sent once a declared envelope is rendered.
160 changes: 157 additions & 3 deletions packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ export class HonoHttpServer implements IHttpServer {
private drainTimeoutMs: number = 10_000,
) {
this.app = new Hono();
this.installErrorEnvelopeSeam();
}

// internal helper to convert standard handler to Hono handler
Expand Down Expand Up @@ -770,6 +771,148 @@ export class HonoHttpServer implements IHttpServer {
}
}

/**
* Report a throw that escaped to the TRANSPORT seam — the diagnostic exit
* for the population {@link reportHandlerFailure} cannot see.
*
* Two reporters, DISJOINT populations: `wrap()` catches everything a
* {@link RouteHandler} throws and reports it there, so nothing reaching
* this method has been reported already and nothing it reports will be
* reported again. Hono's default handler wrote these to `console.error`;
* routing them through the host logger is what puts a raw-mount failure in
* the same stream as every other one.
*
* `error`, not `warn`, for the reason {@link reportHandlerFailure} states:
* an unhandled throw out of a handler is a server-side defect, and the
* AGENTS.md "handed to the CALLER" exemption does not apply to a throw
* nobody caught. Method and path only — never the body.
*/
private reportTransportEscape(
c: any,
thrown: unknown,
rendered?: { status: number; code: unknown },
): void {
try {
const method = typeof c?.req?.method === 'string' ? c.req.method : undefined;
const path = typeof c?.req?.path === 'string' ? c.req.path : undefined;
this.logger.error(
rendered
? '[hono] a throw escaped to the transport — request answered with the throw\'s declared ADR-0112 envelope'
: '[hono] a throw escaped to the transport — request answered 500 INTERNAL_ERROR with no cause in the body',
toLoggableError(thrown),
rendered
? { method, path, status: rendered.status, code: rendered.code }
: { method, path },
);
} catch {
// Same discipline as {@link reportHandlerFailure}: a host logger
// that throws would otherwise reject the error seam itself and
// hand the caller Hono's opaque page — precisely the answer this
// seam exists to remove.
}
}

/**
* Answer a throw that escaped to the TRANSPORT with the declared ADR-0112
* envelope — the seam a route mounted through {@link getRawApp} funnels
* through (#17411).
*
* ## The door this closes
*
* {@link wrap} catches everything a {@link RouteHandler} throws, so every
* route registered through {@link get} / {@link post} / … already answers
* the declared envelope (#16545). A route mounted on the framework handle
* passes through NEITHER `wrap()` nor any registrar wrapper, so its
* escaped throw reached Hono's own default handler — measured on
* `7d350a46`, one `HonoHttpServer`, the raw pair mounted the way
* `marketplace-install-local-plugin.ts` mounts:
*
* ```
* /raw/envelope -> 500 text/plain; charset=UTF-8 Internal Server Error
* /raw/plain -> 500 text/plain; charset=UTF-8 Internal Server Error
* ```
*
* Byte-identical: a throw that DECLARED `503` / `SERVICE_UNAVAILABLE` and
* a bare driver error answered the same thing, so the transport discarded
* the producer's own declaration — the half of the defect that is
* invisible from the producer's side, which is where anyone would look.
*
* ## Why the transport, and why this does not close the escape hatch
*
* {@link getRawApp}'s exemption is scoped to framework-native MOUNTING and
* to route introspection, never to the wire shape of a refusal. The
* contract says raw-handle mounts are "outside this table by construction
* … this answers 'what routes did I register', not 'what paths might
* respond'" ({@link IHttpServer.getMountedRoutes}), and the same contract
* requires the unmatched answer to carry "the shared not-found error body
* (the `errors.zod` envelope), never an adapter-native error page". An
* error seam on the handle leaves the hatch fully intact: consumers still
* mount natively, still stay outside `getMountedRoutes()`, still need no
* adapter verb. It is the reasoning {@link installHttpMetricsSeam}'s
* seam already rests on (#9650) — the transport is the one layer every
* inbound request converges on, whatever registered the handler.
*
* ## ONE rule, not a second one
*
* The render is {@link declaredEnvelopeForThrow}, the same gate `wrap()`
* opted into, so `/raw/*` and a direct-mount route answer the same shape
* for the same throw — the `ValidationError`-shape-as-declaration limb
* included. The fallback arm is the ADR-0112 `INTERNAL_ERROR` body
* carrying {@link INTERNAL_ERROR_MESSAGE}: a non-envelope throw still
* answers 500 with NO cause in the body, #16545's pinned invariant.
*
* ⛔ It deliberately does NOT copy `wrap()`'s literal `"No response from
* handler"`. That sentence describes a handler that wrote nothing — a
* state this seam never observes, because a Hono handler that returns
* nothing is Hono's own error, not ours. Copying it would put a false
* diagnosis on the wire; the `code` and the `status`, which are what a
* client branches on, agree with `wrap()` exactly.
*
* ## Hono's own declared-`Response` limb is preserved
*
* Hono's default handler honours a thrown value carrying its own
* `Response` (`HTTPException`) before falling back to
* `text('Internal Server Error', 500)`, and that limb is kept verbatim: an
* `HTTPException` is a framework-native refusal the producer DECLARED, and
* overriding it would be this card's own defect with the roles reversed.
* Measured at `7d350a46`: zero `HTTPException` producers anywhere in
* `packages/`, so this preserves behaviour rather than adding any.
*
* ## Installed from the constructor, and overridable on purpose
*
* Once, unconditionally, so a bare `HonoHttpServer` (cloud's serverless
* entrypoints, tests) gets it without wiring — the same reason
* {@link setLogger}'s default is a real logger. A consumer that calls
* `getRawApp().onError(...)` itself replaces it, which is the escape hatch
* working as designed.
*/
private installErrorEnvelopeSeam(): void {
this.app.onError((err: Error, c: any) => {
// Hono's own precedence, unchanged — see the docblock.
if (err !== null && typeof err === 'object' && 'getResponse' in err) {
const declared = (err as unknown as { getResponse(): Response }).getResponse();
return c.newResponse(declared.body, declared);
}

const envelope = declaredEnvelopeForThrow(err);
this.reportTransportEscape(
c,
err,
envelope ? { status: envelope.status, code: envelope.body.error.code } : undefined,
);

return envelope
? c.json(envelope.body, envelope.status)
: c.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE },
},
500,
);
});
}

get(path: string, handler: RouteHandler) {
this.registeredRoutes.push({ method: 'GET', pattern: path });
this.app.get(path, this.wrap(handler));
Expand Down Expand Up @@ -1311,13 +1454,24 @@ export class HonoHttpServer implements IHttpServer {
this.app.use('*', async (c, next) => {
if (this.responseObservers.length === 0) return next();
const startedAt = Date.now();
// Default 500: if `next()` rejects, Hono's error path renders the
// 500 and `c.res` is not yet set — reading it would synthesize a
// response and change what the caller receives.
// Default 500: if `next()` rejects, `c.res` is not yet set here —
// reading it would synthesize a response and change what the
// caller receives.
let status = 500;
try {
await next();
status = c.res.status;
} catch (err) {
// … and 500 stopped being the whole answer with #17411: the
// transport error seam ({@link installErrorEnvelopeSeam}) may
// render a DECLARED status for this throw, and it runs after
// this middleware unwinds. `HttpResponseObservation.status` is
// contracted as "the status of the response as sent", so the
// observer is owed that status — read off the SAME rule the
// seam renders from, never a second copy of it. The throw is
// re-raised untouched: observing is not handling.
status = declaredEnvelopeForThrow(err)?.status ?? 500;
throw err;
} finally {
// An unrouted request executes only this adapter's own
// `use('*')` seams, so after `next()` `routePath(c)` reports
Expand Down
Loading
Loading