Skip to content

fix(runtime): run page middleware around the final HTTP response - #624

Merged
Brooooooklyn merged 2 commits into
voidzero-dev:mainfrom
0froq:cursor/definehandler-design-a-executor-3fc7
Nov 2, 2026
Merged

Brooooooklyn merged 2 commits into
voidzero-dev:mainfrom
0froq:cursor/definehandler-design-a-executor-3fc7

Conversation

@0froq

@0froq 0froq commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #564

Problem

defineHandler(middleware..., handler) stored the handler's result in c.res, which finalizes the Hono context. A page builds its own response after the loader or action runs, and Hono dropped that response:

Request Without middleware With middleware (main)
GET page 200, page 500 Body has already been used
valid action 200, page with fresh props 204, empty
invalid action 200, page with errors 400, raw issues JSON

The middleware overloads also dropped a withValidator leaf's validator types.

Fix

defineHandler(mw1, mw2, leaf)
  route : mw1 → mw2 → convertReturnValue(leaf)          (unchanged)
  page  : mw1 → mw2 → leaf → render page / re-render     (middleware wraps the final page response)
  • defineHandler records its middleware and leaf (internal module runtime/handler-composition.ts, not exported). Nested compositions are flattened.
  • executeComposedHandler works like Hono's compose: a middleware Response replaces c.res, the endpoint's Response becomes c.res, middleware can edit c.res after next(), and it returns the final c.res.
  • Pages (runLoader / runPageHandler): run the leaf and build the page response inside the chain. Redirects and other Responses from the loader or middleware are returned as before. shared and headDefaults are read at render time, so per-route middleware can set them.
  • Actions: a ValidationError from the leaf, or from middleware before the response exists, becomes field errors. One from the loader re-render is not caught.
  • Types: one overload defineHandler(...middleware, typedHandler) returns the typed handler unchanged, so withValidator validators and output reach void/routes.
  • Plain handlers (no middleware) behave as before.

Tests

packages/void/test/unit/routing-runtime.test.ts runs pages through a real Hono app (every case fails on main or on the first version of this PR):

  • loader with middleware renders the page (JSON and HTML)
  • action with middleware re-renders with fresh props, or with field errors (JSON and HTML form post)
  • middleware headers before/after next() and shared reach the page (GET and action)
  • a short-circuit Response keeps edits that outer middleware makes after next()
  • ValidationError from action middleware → field errors; from the loader re-render → loader runs once; after the response exists → error
  • deferred props stream through loader middleware; etag() tags each page form

Also define-handler.test.ts (route middleware header edits) and route-type-extraction.typecheck.ts (validators and output kept through middleware, including nesting).

Verification

  • vp test run packages/void/test/unit: 362 files, 8246 tests passed
  • vp run test --base origin/main: all affected tests passed
  • vp run --filter ./packages/void typecheck, vp lint, vp fmt: clean

🤖 Generated with Claude Code

0froq and others added 2 commits November 2, 2026 12:39
Fixes voidzero-dev#564

Run per-route `defineHandler` middleware around the final HTTP Response instead of around a temporary leaf body. Pages finish load/render (and action revalidation) inside an endpoint step; routes still go through `convertReturnValue`. Nested compositions flatten; middleware overloads keep leaf `withValidator` phantoms (`__validators` / `__output`).

Most of this patch was written by a coding agent. The change is large on purpose: clearing `finalized` while keeping two exit paths was not a single owner for "handler result → Response".

Happy to change if maintainers prefer one of the alternatives below.

- Add `executeComposedHandler` and composition metadata on composed handlers.
- Page protocol: composed loaders/actions render inside the middleware endpoint; validation `issues` 400s become page field errors there.
- `createHttpRouteHandler`: composed handlers return the middleware Response; plain handlers still use `convertReturnValue`.
- Tests for page middleware cases, route `etag` on the final body, and validator types through middleware.

- `packages/void` unit: `define-handler.test.ts`, `pages-protocol.test.ts`
- `packages/void` typecheck (incl. middleware + `withValidator`)

This PR is the shape we landed on: one executor (`executeComposedHandler`) that runs middleware around the final HTTP Response, with pages doing load/render (and action revalidation) inside the endpoint step.

We also looked at a smaller patch that clears `finalized` and keeps separate exits for routes vs pages. That still left two places that turn a handler result into a Response, so we did not ship it.

Another option is to wrap middleware only around the leaf mutation on POST and re-render the page outside. After-next would then only see JSON/204, not the HTML the client actually gets.

A third option is to keep returning the raw leaf value from `defineHandler` and let every caller adapt it. That matches less with the route path (`convertReturnValue` / final Response) and is easy to misuse from page code.

On how the page Response enters the context: this PR sets `c.res` from a full `Response` so after-next can mutate it. Rebuilding with `c.newResponse()` / `c.body()` would merge headers set before `next()` more like stock Hono, but needs care with deferred / streaming.

For types: this PR uses explicit `TypedHandler<V, R>` overloads per middleware arity. A single `H extends TypedHandler` overload is shorter and may be fine if maintainers prefer that.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
…sions

Keep the approach (per-route middleware wraps the final page response) but
make the change smaller and fix what review found:

- executeComposedHandler returns the final c.res, as Hono's compose does. It
  returned a middleware's short-circuit Response instead, so outer middleware
  edits after next() were lost, and a composed action re-rendering a composed
  loader failed with "body disturbed".
- Move the composition helpers to an internal module so they are not exported
  from void/handler.
- One rest-tuple overload keeps a typed leaf's validators and output, instead
  of one overload per middleware count.
- Pages: runLoader/runPageHandler run the leaf and render inside the chain;
  handlePageGet keeps its body. Drop the unused routing.ts change (Responses
  already pass through convertReturnValue) and the dead runtime metadata.
- A ValidationError thrown by action middleware before the response exists
  becomes field errors again; one from the loader re-render is not caught,
  so the loader no longer runs twice.
- Read shared and headDefaults at render time, so per-route middleware can set
  them on GET and after an action.
- Tests go through a real Hono app (the direct-call tests passed on main):
  loader/action/field errors (JSON and HTML), headers and shared, short-circuit
  with outer edits, ValidationError handling, deferred props, etag. Type test
  uses real middleware and checks the whole handler type, including nesting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn force-pushed the cursor/definehandler-design-a-executor-3fc7 branch from 27a131a to 763ce4b Compare November 2, 2026 05:07
@Brooooooklyn
Brooooooklyn merged commit 8d3a706 into voidzero-dev:main Nov 2, 2026
18 checks passed
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.

defineHandler(middleware, handler) breaks page loaders and actions: 500 on GET, empty 204 after an action, validator types dropped

2 participants