Skip to content

feat: asena console request reporting, non-Error throws, core 0.13 - #38

Open
LibirSoft wants to merge 10 commits into
masterfrom
feat/exception-handler
Open

LibirSoft wants to merge 10 commits into
masterfrom
feat/exception-handler

Conversation

@LibirSoft

Copy link
Copy Markdown
Member

Summary

Three changes for the core 0.13 release. Values thrown that are not Error instances now reach the application's error handler (@Catch(), ConfigService.onError) instead of resetting the connection. The adapter implements core's optional attachDevtools(bus) hook, so every HTTP request shows up in the asena console request inspector. And the core peer range accepts 0.13.

When the console is not attached, start() hands app.fetch itself to Bun.serve; nothing is recorded and no envelope is added.

Changes

  • Non-Error throws (72fa1e7): Hono only passes Error instances to onError and rethrows anything else out of fetch, so throw 'text' or throw { code: 1 } skipped @Catch(), onError and the default 500, and Bun reset the connection. Every handler the adapter registers (route handler, Asena and override middlewares, both validator paths, the contract validator, the static endpoint) now wraps such a value in an internal carrier Error; onError unwraps it, so the application's handler receives the original value by identity, as on ergenecore. With no handler, or one that declines, the adapter answers its default 500 JSON body and logs the value.
  • Peer range (8c13111): @asenajs/asena peer ^0.12.0 -> ^0.12.0 || ^0.13.0. The devDependency stays on the published ^0.12.0.
  • attachDevtools(bus) (16cb897, d3de7d2): when attached, start() serves a fetch that opens one record per request and runs dispatch inside the bus, so core's steps (middleware, controller, @Catch, onError, @NotFound) land in it, including override: true middlewares. Adapter-only steps: adapter-default (named HonoAdapter) for the built-in 404 and the default error response, and a validator step around each legacy validator: middleware. The bus type is a local structural interface (HonoDevtoolsBus). WebSocket upgrades are not recorded.
  • Validation attribution (bfe9bcb, 014f201): a legacy validation failure that no @Catch / onError answered is credited to the failing validator step, not adapter-default. The step is named <ValidatorClass>.<target> from the route's validatorName (the same field ergenecore reads, passed by core 0.13); without it the step is the bare target.

Release

  • @asenajs/hono-adapter 5.2.0 (minor devtools-envelope, patch non-error-throw, which also carries the peer widening).
  • Peer: @asenajs/asena ^0.12.0 || ^0.13.0, hono >=4.12.9.
  • Publish core 0.13.0 first. After it is on npm, bump the @asenajs/asena devDependency to ^0.13.0 and run bun update.

Testing

bun install --frozen-lockfile
bun run check && bun run typecheck && bun run build
bun test   # 1251 pass, 19 skip (pre-existing), 0 fail (1270 tests, 53 files)
  • test/NonErrorThrow.test.ts (43 tests): string and plain-object throws from every registration site x handler answers / declines / absent, plus a circular value. All 36 cases of the first matrix failed with ECONNRESET before the fix.
  • test/Devtools.test.ts (34 tests) against a fake AsyncLocalStorage-backed bus: not-attached identity, 404 and error defaults, concurrent requests, override middlewares, and the validator attribution matrix.
  • Perf: the route-handler guard adds one promise per request; wrk over a socket stayed within run-to-run noise.

Known follow-ups

  • An unanswered Context.define contract failure is still recorded as adapter-default (ergenecore credits the contract).
  • A middleware that calls next() and then returns false gets a 403 whose answeredBy names the downstream step.
  • For streaming responses the record closes when the Response is returned, so the duration excludes the body.

Hono's compose only hands `err instanceof Error` to `onError`, and
hono-base rethrows anything else out of `fetch`. A `throw 'text'` or
`throw { code: 1 }` from a route handler, a middleware or a validator
therefore skipped the application's error handler (the `@Catch()`
catch-all and `ConfigService.onError`), the adapter's default 500 and its
log line; Bun reset the connection instead.

Every handler the adapter registers now converts a non-Error throw into
an internal carrier Error at its own boundary, so Hono treats it exactly
like an Error thrown at the same place (same dispatch level, same
middleware unwinding). `onError` unwraps the carrier before the
application's handler sees it, so the handler receives the original
value unchanged - the contract ergenecore already keeps. With no handler,
or one that declines, the adapter answers its default 500 JSON envelope
and logs the value JSON-serialised (falling back to Object#toString for
a value JSON cannot serialise), without a stack.

The guard on the route handler costs one extra promise per request;
measured over a socket with wrk against a bare Hono app the difference
stayed within run-to-run noise (~0.3% mean over three rounds).
Core 0.13 adds the exception handler (@ExceptionHandler / @catch), which
reaches the adapter through the same onError contract 0.12 uses, so the
adapter needs no code change to run under it. The range is widened, not
moved: 0.12 applications keep resolving this release. devDependencies
stay on 0.12 because 0.13.0 is not on npm yet.
One patch changeset for the non-Error throw fix and the widened core
peer range, so both ship in the same hono-adapter patch release.
…hes its bus

Core sees every step of a request except the ones that frame it: when it
started and ended, its final status, and who answered when nothing in
core's chain did. Only the adapter sees those, so core 0.13 hands its
devtools bus to adapter.attachDevtools(bus) in a process asena console
spawned, before any route is registered.

The bus is declared as a local structural type (HonoDevtoolsBus): the
adapter builds against published core, which has no devtools, and must keep
working with a core that never calls the method.

When attached:
- start() serves a fetch that opens a record per request and runs the
  dispatch inside bus.run(), so steps are correlated by async context. That
  is what makes an override: true middleware, which receives hono's raw
  Context rather than the Asena wrapper, land in the right record. The
  record closes with the response status, or as an adapter 500 if fetch
  rejects. Websocket upgrades are not recorded.
- the adapter's own 404 (no onNotFound, or one that declined) steps
  not-found, named after the adapter;
- defaultErrorResponse, reached with no onError, a declining one or a
  throwing one, steps adapter-default;
- a legacy validator: schema, which core never sees run, steps validator
  named after its target (the validator class is not on the route the
  adapter receives), on the cached and the per-request path, before the
  check, so a failure keeps it.

A middleware answering false needs no adapter step: core recorded it when
it was called, and the envelope closes the record with the 403.

When attachDevtools is never called, start() serves app.fetch itself and
the validator middlewares are the ones registered before; a test pins the
fetch identity.
attachDevtools() is new public API and the adapter gains behaviour under
asena console, so this is a minor, not a patch.
The console's request inspector showed `answeredBy: adapter-default` for a
400 from a legacy `validator:` route, and named the validator step after its
target only (`json`). The adapter merely renders the ValidationError's own
response there, so the answer belongs to the validator that failed.

Each validator step now remembers the ValidationError it threw; when that
error reaches the adapter's default error response, the record is closed with
that step as `answeredBy` and no adapter-default step is appended. A @catch or
onError that answers still shows as the answering step after the validator.
Controller throws, including a ValidationError thrown by hand, stay
adapter-default.

The step is named `<ValidatorClass>.<target>` when the route carries a
`validatorName`. Core passes a plain `{ [target]: { handle } }` map with bound
handles, so the class name can only come from core; the field is read
structurally because the published core's RouteParams does not declare it.
Without it the step keeps the target as its name.
The request-inspector changeset now says a validation failure nobody answered is credited to the validator, which is what users of the console will see change.
The legacy validator step was named from a route-level `validatorName`
that core never passes, so in a real app the step stayed `json`. Core
hands the adapter `{ handle, override }` per target with a bound handle,
so the class is not recoverable from the handle itself.

Ergenecore already reads an optional `name` on each ValidatorHandler.
Read the same field here so both adapters follow one contract: core sets
`name` to the validator's component name in PrepareValidatorService, and
the step becomes `<component>.<target>`. Without it the step keeps the
target name, as before. The route-level field is no longer read.
The previous fix moved the step name to an optional `name` on each
ValidatorHandler, while ergenecore, fixing the same finding at the same
time, moved the other way to the route params' `validatorName`. The two
adapters ended up reading different carriers, and core sets neither, so
a real run still named the step `json`.

A bound method does not reveal its class, so the name has to come from
core. Read the route's `validatorName`, structurally because the
published core's RouteParams does not declare it - the field ergenecore
reads at fc271c5 - so core needs one change for both adapters: pass the
validator's component name as `validatorName` in its registerRoute call.

The tests now build each handle as core's PrepareValidatorService does
(a bound method, no name) and put the name on the route; a test pins
that a `name` on the handle is not read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant