From 27a131a0ce500cf467e2e1568fc0572ef2769957 Mon Sep 17 00:00:00 2001 From: froQ Date: Tue, 27 Oct 2026 16:58:02 +0000 Subject: [PATCH] fix(runtime): run page middleware around the final HTTP response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #564 ## Summary 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. ## Changes - 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. ## Testing - `packages/void` unit: `define-handler.test.ts`, `pages-protocol.test.ts` - `packages/void` typecheck (incl. middleware + `withValidator`) ## Alternatives 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` 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 --- packages/void/src/pages/protocol.ts | 410 +++++++++++------- packages/void/src/runtime/handler.ts | 153 ++++++- packages/void/src/runtime/routing.ts | 13 +- .../void/test/unit/define-handler.test.ts | 50 +++ .../void/test/unit/pages-protocol.test.ts | 152 ++++++- .../unit/route-type-extraction.typecheck.ts | 17 + 6 files changed, 609 insertions(+), 186 deletions(-) diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 2d82c2405..50af335e5 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -1,4 +1,10 @@ -import type { CloudBindings, CloudContext } from '../runtime/handler.ts'; +import { + executeComposedHandler, + getHandlerComposition, + isComposedHandler, + type CloudBindings, + type CloudContext, +} from '../runtime/handler.ts'; import { isConvertedResponse } from '../runtime/response.ts'; import { hasActionKey, searchWithoutActionKey } from './action-key.ts'; import { DEFERRED, separateDeferred, type ResolvedDeferredGroup } from './defer.ts'; @@ -283,11 +289,9 @@ async function extractProps(result: unknown): Promise> { * 200s (file downloads, streams). JSON 200s are still extracted as props for * backward compat with loaders using c.json(). */ -async function runLoader( - handler: NonNullable, - c: CloudContext, +async function loaderRawToLoaded( + result: unknown, ): Promise> { - const result = await handler(c); if (result instanceof Response && !isConvertedResponse(result)) { const contentType = result.headers.get('content-type') || ''; if (result.status >= 300 || !contentType.includes('application/json')) { @@ -297,6 +301,161 @@ async function runLoader( return extractProps(result); } +async function runLoader( + handler: NonNullable, + c: CloudContext, +): Promise> { + const composition = getHandlerComposition(handler); + const result = composition ? await composition.leaf(c) : await handler(c); + return loaderRawToLoaded(result); +} + +type PageGetRenderArgs = { + headFn: PageHeadHandler; + c: CloudContext; + componentId: string; + renderFn: RenderPage; + getAssetTags: GetAssetTags; + configHead?: HeadConfig; + options?: PageRenderOptions; + renderMode: PageRenderMode; + shared: Record; +}; + +async function renderPageGetFromProps( + props: Record, + errors: Record | null, + args: PageGetRenderArgs, +): Promise { + const { headFn, c, componentId, renderFn, getAssetTags, configHead, options, renderMode, shared } = + args; + + const pageHead = headFn ? headFn(c, props) : undefined; + const middlewareHead = c.get('headDefaults') as HeadDescriptor | undefined; + const head = resolveHead(configHead, middlewareHead, pageHead); + + const { immediateProps, deferredMap } = separateDeferred(props); + const hasDeferred = Object.keys(deferredMap).length > 0; + + const deferredKeys: Record = {}; + for (const [group, { keys }] of Object.entries(deferredMap)) { + for (const key of keys) { + deferredKeys[key] = group; + } + } + + const pageObj: PageObject = { + component: componentId, + props: immediateProps, + params: requestParams(c), + shared, + url: requestPathWithSearch(c), + errors, + ...(head && { head }), + ...(hasDeferred && { + deferred: Object.keys(deferredMap), + deferredKeys, + }), + }; + + if (renderMode !== 'island' && isPagesRequest(c)) { + const isPrefetch = c.req.header('purpose') === 'prefetch'; + if (isPrefetch && hasDeferred) { + const resolvedProps: Record = { ...immediateProps }; + let deferredErrors: Record | undefined; + await Promise.all( + Object.entries(deferredMap).map(async ([group, { resolver, keys }]) => { + try { + const data = await resolver(c); + if (keys.length === 1) { + resolvedProps[keys[0]] = data ?? null; + } else { + const obj = + typeof data === 'object' && data !== null ? (data as Record) : {}; + for (const key of keys) { + resolvedProps[key] = obj[key]; + } + } + } catch (err) { + (deferredErrors ??= {})[group] = deferredErrorMessage(err, options?.dev === true); + for (const key of keys) { + delete resolvedProps[key]; + } + } + }), + ); + return pageJsonResponse(c, { + ...pageObj, + props: resolvedProps, + deferred: undefined, + ...(deferredErrors && { deferredErrors }), + }); + } + + if (!hasDeferred) { + return pageJsonResponse(c, pageObj); + } + c.header('X-VoidPages', 'true'); + const headers = new Headers(c.res.headers); + appendVary(headers, 'X-VoidPages'); + c.header('Vary', headers.get('Vary')!); + return streamDeferredResponse( + JSON.stringify(pageObj) + '\n', + deferredMap, + c, + 'application/x-ndjson', + options?.dev === true, + (group, data) => JSON.stringify({ __deferred: group, data: data ?? null }) + '\n', + (group, error) => JSON.stringify({ __deferred: group, error }) + '\n', + ); + } + + const { html, assetTags } = await renderPageHtml( + c, + pageObj, + componentId, + renderMode, + renderFn, + getAssetTags, + ); + + if (!hasDeferred) { + return renderedResponse(c, html, 'text/html; charset=utf-8', assetTags); + } + + return streamDeferredResponse( + html, + deferredMap, + c, + 'text/html; charset=utf-8', + options?.dev === true, + (group, data) => { + const g = safeJsonStringify(group); + return `\n`; + }, + (group, error) => { + const g = safeJsonStringify(group); + return `\n`; + }, + assetTags, + ); +} + +async function runComposedPageLoader( + handler: NonNullable, + c: CloudContext, + render: (props: Record) => Promise, +): Promise { + const composition = getHandlerComposition(handler)!; + return executeComposedHandler(c, composition.middlewares, async () => { + const loaded = await loaderRawToLoaded(await composition.leaf(c)); + if (loaded instanceof Response) { + return loaded; + } + return render(loaded); + }); +} + /** Flatten withValidator's { issues: [{ slot, issues: [{ message, path }] }] } into { field: message } */ export function flattenValidationErrors(body: unknown): Record { const errors: Record = {}; @@ -433,131 +592,31 @@ export async function handlePageGet( ): Promise { const renderMode = options?.renderMode ?? 'server'; const shared = (c.get('shared') as Record | undefined) ?? {}; - - // Run loader and check for redirect/error responses before extracting props - let props: Record = {}; - if (handler) { - const loaded = await runLoader(handler, c); - if (loaded instanceof Response) { - return loaded; - } - props = loaded; - } - - const pageHead = headFn ? headFn(c, props) : undefined; - const middlewareHead = c.get('headDefaults') as HeadDescriptor | undefined; - const head = resolveHead(configHead, middlewareHead, pageHead); - - const { immediateProps, deferredMap } = separateDeferred(props); - const hasDeferred = Object.keys(deferredMap).length > 0; - - // Build key-to-group mapping for the client - const deferredKeys: Record = {}; - for (const [group, { keys }] of Object.entries(deferredMap)) { - for (const key of keys) { - deferredKeys[key] = group; - } - } - - const pageObj: PageObject = { - component: componentId, - props: immediateProps, - params: requestParams(c), - shared, - url: requestPathWithSearch(c), - errors: null, - ...(head && { head }), - ...(hasDeferred && { - deferred: Object.keys(deferredMap), - deferredKeys, - }), - }; - - // Island pages always render HTML — no Inertia JSON navigation - if (renderMode !== 'island' && isPagesRequest(c)) { - // Prefetch requests: resolve all deferred props inline (plain JSON, no streaming) - const isPrefetch = c.req.header('purpose') === 'prefetch'; - if (isPrefetch && hasDeferred) { - const resolvedProps: Record = { ...immediateProps }; - let deferredErrors: Record | undefined; - await Promise.all( - Object.entries(deferredMap).map(async ([group, { resolver, keys }]) => { - try { - const data = await resolver(c); - if (keys.length === 1) { - resolvedProps[keys[0]] = data ?? null; - } else { - const obj = - typeof data === 'object' && data !== null ? (data as Record) : {}; - for (const key of keys) { - resolvedProps[key] = obj[key]; - } - } - } catch (err) { - (deferredErrors ??= {})[group] = deferredErrorMessage(err, options?.dev === true); - for (const key of keys) { - delete resolvedProps[key]; - } - } - }), - ); - return pageJsonResponse(c, { - ...pageObj, - props: resolvedProps, - deferred: undefined, - ...(deferredErrors && { deferredErrors }), - }); - } - - if (!hasDeferred) { - return pageJsonResponse(c, pageObj); - } - // Stream NDJSON: first line is PageObject, subsequent lines are deferred groups - c.header('X-VoidPages', 'true'); - const headers = new Headers(c.res.headers); - appendVary(headers, 'X-VoidPages'); - c.header('Vary', headers.get('Vary')!); - return streamDeferredResponse( - JSON.stringify(pageObj) + '\n', - deferredMap, - c, - 'application/x-ndjson', - options?.dev === true, - (group, data) => JSON.stringify({ __deferred: group, data: data ?? null }) + '\n', - (group, error) => JSON.stringify({ __deferred: group, error }) + '\n', - ); - } - - const { html, assetTags } = await renderPageHtml( + const renderArgs: PageGetRenderArgs = { + headFn, c, - pageObj, componentId, - renderMode, renderFn, getAssetTags, - ); + configHead, + options, + renderMode, + shared, + }; - if (!hasDeferred) { - return renderedResponse(c, html, 'text/html; charset=utf-8', assetTags); + if (!handler) { + return renderPageGetFromProps({}, null, renderArgs); } - // Stream HTML followed by deferred `; - }, - (group, error) => { - const g = safeJsonStringify(group); - return `\n`; - }, - assetTags, - ); + if (isComposedHandler(handler)) { + return runComposedPageLoader(handler, c, (props) => renderPageGetFromProps(props, null, renderArgs)); + } + + const loaded = await runLoader(handler, c); + if (loaded instanceof Response) { + return loaded; + } + return renderPageGetFromProps(loaded, null, renderArgs); } function isSameOriginAction(request: Request): boolean { @@ -620,13 +679,11 @@ export async function handlePageMutation( }; } - /** Re-run the GET loader and respond with the page, optionally carrying validation errors. */ - async function respondWithPage(errors: Record | null): Promise { - const loaded = getHandler ? await runLoader(getHandler, c) : {}; - if (loaded instanceof Response) { - return loaded; - } - const pageObj = buildMutationPageObj(stripDeferred(loaded), errors); + async function renderMutationPage( + props: Record, + errors: Record | null, + ): Promise { + const pageObj = buildMutationPageObj(stripDeferred(props), errors); if (isPagesRequest(c)) { return pageJsonResponse(c, pageObj); } @@ -641,6 +698,21 @@ export async function handlePageMutation( return renderedResponse(c, html, 'text/html; charset=utf-8', assetTags); } + /** Re-run the GET loader and respond with the page, optionally carrying validation errors. */ + async function respondWithPage(errors: Record | null): Promise { + if (!getHandler) { + return renderMutationPage({}, errors); + } + if (isComposedHandler(getHandler)) { + return runComposedPageLoader(getHandler, c, (props) => renderMutationPage(props, errors)); + } + const loaded = await runLoader(getHandler, c); + if (loaded instanceof Response) { + return loaded; + } + return renderMutationPage(loaded, errors); + } + function respondWithErrors(errors: Record): Promise { if (renderMode === 'island') { return Promise.resolve(Response.json({ errors }, { status: 422 })); @@ -648,43 +720,59 @@ export async function handlePageMutation( return respondWithPage(errors); } - let result: unknown; - try { - result = mutationHandler ? await mutationHandler(c) : undefined; - } catch (e) { - if (e instanceof ValidationError) { - return respondWithErrors(e.errors); + async function finishMutation(result: unknown): Promise { + if (result instanceof Response) { + const status = result.status; + if (status >= 300 && status < 400) { + return result; + } + if (status === 400) { + const body: unknown = await result + .clone() + .json() + .catch(() => null); + if (Array.isArray((body as ValidationErrorBody | null)?.issues)) { + return respondWithErrors(flattenValidationErrors(body)); + } + return result; + } + if (status !== 204) { + return result; + } } - throw e; - } - // Handle Response results (from withValidator or explicit redirect) - if (result instanceof Response) { - const status = result.status; - // Redirect - if (status >= 300 && status < 400) { - return result; + if (renderMode === 'island') { + return Response.json({ success: true }); } - // Only a withValidator issues body becomes field errors; other 400s pass through - if (status === 400) { - const body: unknown = await result - .clone() - .json() - .catch(() => null); - if (Array.isArray((body as ValidationErrorBody | null)?.issues)) { - return respondWithErrors(flattenValidationErrors(body)); + return respondWithPage(null); + } + + async function runMutationLeaf(leaf: (c: CloudContext) => unknown | Promise) { + try { + return await finishMutation(await leaf(c)); + } catch (e) { + if (e instanceof ValidationError) { + return respondWithErrors(e.errors); } - return result; - } - // 204 (void from withValidator) — fall through to re-run GET - if (status !== 204) { - return result; + throw e; } } - // void — re-run GET with fresh props - if (renderMode === 'island') { - return Response.json({ success: true }); + if (mutationHandler && isComposedHandler(mutationHandler)) { + const composition = getHandlerComposition(mutationHandler)!; + return executeComposedHandler(c, composition.middlewares, () => runMutationLeaf(composition.leaf)); + } + + if (!mutationHandler) { + return finishMutation(undefined); + } + + try { + return await finishMutation(await mutationHandler(c)); + } catch (e) { + if (e instanceof ValidationError) { + return respondWithErrors(e.errors); + } + throw e; } - return respondWithPage(null); } diff --git a/packages/void/src/runtime/handler.ts b/packages/void/src/runtime/handler.ts index 01006ffc7..6a9d192d6 100644 --- a/packages/void/src/runtime/handler.ts +++ b/packages/void/src/runtime/handler.ts @@ -136,6 +136,96 @@ type LoaderOutput = Exclude>, Respons type MiddlewareFn = MiddlewareHandler; type BasicAuthOptionValue = string | (() => string); +/** Attached to handlers created by `defineHandler(middleware..., leaf)`. */ +export type HandlerComposition = { + middlewares: MiddlewareFn[]; + leaf: HandlerFn; +}; + +const handlerCompositionKey = '__voidHandlerComposition' as const; + +type HandlerWithComposition = HandlerFn & { + [handlerCompositionKey]?: HandlerComposition; +}; + +export function getHandlerComposition(handler: HandlerFn): HandlerComposition | undefined { + return (handler as HandlerWithComposition)[handlerCompositionKey]; +} + +export function isComposedHandler(handler: HandlerFn | undefined): handler is HandlerWithComposition { + return handler !== undefined && getHandlerComposition(handler) !== undefined; +} + +/** + * Run per-route middleware on the current Hono context, then build the endpoint + * response. Middleware after `next()` sees and may mutate `c.res`. + */ +export async function executeComposedHandler( + c: CloudContext, + middlewares: MiddlewareFn[], + endpoint: () => Promise, +): Promise { + let index = 0; + let shortCircuit: Response | undefined; + + const dispatch = async (): Promise => { + if (shortCircuit) { + return; + } + if (index < middlewares.length) { + const middleware = middlewares[index++]; + const middlewareResponse = await middleware(c, dispatch); + if (middlewareResponse instanceof Response) { + shortCircuit = middlewareResponse; + c.res = middlewareResponse; + } + return; + } + c.res = await endpoint(); + }; + + await dispatch(); + + if (shortCircuit) { + return shortCircuit; + } + return c.res; +} + +function flattenHandlerComposition( + args: Array, +): { middlewares: MiddlewareFn[]; leaf: HandlerFn } { + const middlewares: MiddlewareFn[] = []; + for (let i = 0; i < args.length - 1; i++) { + middlewares.push(args[i] as MiddlewareFn); + } + let leaf = args[args.length - 1] as HandlerFn; + while (isComposedHandler(leaf)) { + const nested = getHandlerComposition(leaf)!; + middlewares.push(...nested.middlewares); + leaf = nested.leaf; + } + return { middlewares, leaf }; +} + +function attachHandlerComposition( + fn: HandlerFn, + composition: HandlerComposition, + meta: Pick, +): TypedHandler { + const composed = fn as TypedHandler & HandlerWithComposition; + composed[handlerCompositionKey] = composition; + Object.defineProperty(composed, '__validators', { + value: meta.__validators, + enumerable: true, + }); + Object.defineProperty(composed, '__output', { + value: meta.__output, + enumerable: true, + }); + return composed; +} + export interface BasicAuthOptions { /** Username required by the Basic auth challenge. */ username: BasicAuthOptionValue; @@ -218,6 +308,10 @@ export type TypedHandler = { export function defineHandler( handler: LoaderFn, ): TypedHandler<{}, Exclude, Response>>; +export function defineHandler( + firstMiddleware: MiddlewareFn, + handler: TypedHandler, +): TypedHandler; export function defineHandler( firstMiddleware: MiddlewareFn, handler: LoaderFn, @@ -226,6 +320,11 @@ export function defineHandler( firstMiddleware: MiddlewareFn, handler: F, ): TypedHandler<{}, LoaderOutput>; +export function defineHandler( + firstMiddleware: MiddlewareFn, + secondMiddleware: MiddlewareFn, + handler: TypedHandler, +): TypedHandler; export function defineHandler( firstMiddleware: MiddlewareFn, secondMiddleware: MiddlewareFn, @@ -236,6 +335,12 @@ export function defineHandler( secondMiddleware: MiddlewareFn, handler: F, ): TypedHandler<{}, LoaderOutput>; +export function defineHandler( + firstMiddleware: MiddlewareFn, + secondMiddleware: MiddlewareFn, + thirdMiddleware: MiddlewareFn, + handler: TypedHandler, +): TypedHandler; export function defineHandler( firstMiddleware: MiddlewareFn, secondMiddleware: MiddlewareFn, @@ -248,6 +353,13 @@ export function defineHandler( thirdMiddleware: MiddlewareFn, handler: F, ): TypedHandler<{}, LoaderOutput>; +export function defineHandler( + firstMiddleware: MiddlewareFn, + secondMiddleware: MiddlewareFn, + thirdMiddleware: MiddlewareFn, + fourthMiddleware: MiddlewareFn, + handler: TypedHandler, +): TypedHandler; export function defineHandler( firstMiddleware: MiddlewareFn, secondMiddleware: MiddlewareFn, @@ -262,6 +374,14 @@ export function defineHandler( fourthMiddleware: MiddlewareFn, handler: F, ): TypedHandler<{}, LoaderOutput>; +export function defineHandler( + firstMiddleware: MiddlewareFn, + secondMiddleware: MiddlewareFn, + thirdMiddleware: MiddlewareFn, + fourthMiddleware: MiddlewareFn, + fifthMiddleware: MiddlewareFn, + handler: TypedHandler, +): TypedHandler; export function defineHandler( firstMiddleware: MiddlewareFn, secondMiddleware: MiddlewareFn, @@ -282,25 +402,20 @@ export function defineHandler(...args: Array): TypedHa if (args.length === 1) { return args[0] as TypedHandler; } - const middlewares = args.slice(0, -1) as Array; - const handler = args[args.length - 1] as HandlerFn; - - return (async (c: CloudContext) => { - let index = 0; - const dispatch = async (): Promise => { - if (index < middlewares.length) { - const middleware = middlewares[index++]; - const middlewareResponse = await middleware(c, dispatch); - if (middlewareResponse instanceof Response) { - c.res = middlewareResponse; - } - } else { - c.res = convertReturnValue(await handler(c)); - } - }; - await dispatch(); - return c.res; - }) as TypedHandler; + const { middlewares, leaf } = flattenHandlerComposition(args); + const leafMeta = leaf as TypedHandler; + const composition: HandlerComposition = { middlewares, leaf }; + + const fn = async (c: CloudContext) => { + return executeComposedHandler(c, middlewares, async () => { + return convertReturnValue(await leaf(c)); + }); + }; + + return attachHandlerComposition(fn, composition, { + __validators: leafMeta.__validators ?? {}, + __output: leafMeta.__output, + }); } /** diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 46c143bab..4d2fd0b91 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -14,7 +14,7 @@ import { markActionKey, searchWithoutActionKey, } from '../pages/action-key.ts'; -import { VoidAssetRewriteError } from './handler.ts'; +import { isComposedHandler, VoidAssetRewriteError, type CloudContext } from './handler.ts'; import { convertReturnValue } from './response.ts'; import { ASSET_EXTENSIONS } from './routing-assets.ts'; import { markRouteHandler } from './routing-pages.ts'; @@ -347,9 +347,14 @@ export function createHttpRouteHandler( load: () => Promise unknown>>, method: string, ): (c: Context) => Promise { - return markRouteHandler(async (c: Context) => - convertReturnValue(await (await load())[method](c)), - ); + return markRouteHandler(async (c: Context) => { + const handler = (await load())[method] as (c: CloudContext) => unknown; + const result = await handler(c as CloudContext); + if (isComposedHandler(handler)) { + return result as Response; + } + return convertReturnValue(result); + }); } export { diff --git a/packages/void/test/unit/define-handler.test.ts b/packages/void/test/unit/define-handler.test.ts index fc6f63e5e..736509b37 100644 --- a/packages/void/test/unit/define-handler.test.ts +++ b/packages/void/test/unit/define-handler.test.ts @@ -7,7 +7,9 @@ import { type CloudContext, type TypedHandler, } from '../../src/runtime/handler.ts'; +import { createHttpRouteHandler } from '../../src/runtime/routing.ts'; import { convertReturnValue } from '../../src/runtime/response.ts'; +import { etag } from 'hono/etag'; import type { StandardSchemaV1 } from '../../src/standard-schema.ts'; type ValidationIssueBody = { @@ -150,6 +152,54 @@ describe('defineHandler', () => { expect(order).toEqual(['raw-before', 'handler', 'raw-after']); }); + it('createHttpRouteHandler runs composed handlers with post-next header mutation', async () => { + const addHeader = defineMiddleware(async (c, next) => { + await next(); + c.res.headers.set('X-Route-Mw', 'yes'); + }); + + const handler = defineHandler(addHeader, () => ({ ok: true })); + const app = new Hono(); + app.get( + '/test', + createHttpRouteHandler(async () => ({ GET: handler }), 'GET'), + ); + + const res = await app.request(req()); + expect(res.status).toBe(200); + expect(res.headers.get('X-Route-Mw')).toBe('yes'); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('createHttpRouteHandler etag hashes the final response body', async () => { + const addHeader = defineMiddleware(async (c, next) => { + await next(); + c.res.headers.set('X-Seen', '1'); + }); + + const handler = defineHandler(etag(), addHeader, () => ({ value: 'stable' })); + const app = new Hono(); + app.get( + '/test', + createHttpRouteHandler(async () => ({ GET: handler }), 'GET'), + ); + + const first = await app.request(req()); + const etagValue = first.headers.get('etag'); + expect(etagValue).toBeTruthy(); + expect(first.headers.get('X-Seen')).toBe('1'); + const firstBody = await first.text(); + + const second = await app.request( + new Request('http://localhost/test', { + headers: { 'if-none-match': etagValue! }, + }), + ); + expect(second.status).toBe(304); + expect(await second.text()).toBe(''); + expect(firstBody).toContain('stable'); + }); + it('middleware error propagates', async () => { const failing = defineMiddleware(async () => { throw new Error('middleware boom'); diff --git a/packages/void/test/unit/pages-protocol.test.ts b/packages/void/test/unit/pages-protocol.test.ts index 0c3d5ae78..6eca34a97 100644 --- a/packages/void/test/unit/pages-protocol.test.ts +++ b/packages/void/test/unit/pages-protocol.test.ts @@ -8,6 +8,8 @@ import { flattenValidationErrors, ValidationError, } from '../../src/pages/protocol.ts'; +import { defineHandler, defineMiddleware } from '../../src/runtime/handler.ts'; +import type { StandardSchemaV1 } from '../../src/standard-schema.ts'; type TestContext = Context & { set(key: string, value: unknown): void }; type RenderPage = { component: string }; @@ -32,6 +34,7 @@ function createTestContext( path: string, options: { headers?: Record; + body?: string; routePattern?: string; shared?: Record; } = {}, @@ -55,8 +58,12 @@ function createTestContext( }); const url = `http://localhost${path}`; - const req = new Request(url, { method, headers: options.headers }); - app.fetch(req); + const init: RequestInit = { method, headers: options.headers }; + if (options.body !== undefined) { + init.body = options.body; + } + const req = new Request(url, init); + void app.fetch(req); return contextPromise; } @@ -1273,6 +1280,147 @@ describe('handlePageGet with deferred props', () => { }); }); +function passSchema(): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate(value: unknown) { + return { value }; + }, + }, + }; +} + +function failSchema(message: string): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate() { + return { issues: [{ message }] }; + }, + }, + }; +} + +describe('defineHandler middleware composition (pages)', () => { + const passThrough = defineMiddleware(async (_c, next) => { + await next(); + }); + + it('loader with pass-through middleware returns page props', async () => { + const c = await createTestContext('GET', '/db', { headers: { 'x-voidpages': 'true' } }); + const loader = defineHandler(passThrough, async () => ({ notes: ['a'] })); + + const response = await handlePageGet( + loader, + undefined, + c, + 'db', + mockRenderFn, + mockGetAssetTags, + ); + + const body = await json(response); + expect(response.status).toBe(200); + expect(body.props).toEqual({ notes: ['a'] }); + }); + + it('loader middleware after-next can mutate the final page response', async () => { + const addHeader = defineMiddleware(async (c, next) => { + await next(); + c.res.headers.set('X-Loader-Mw', 'yes'); + }); + const c = await createTestContext('GET', '/db', { headers: { 'x-voidpages': 'true' } }); + const loader = defineHandler(addHeader, async () => ({ ok: true })); + + const response = await handlePageGet( + loader, + undefined, + c, + 'db', + mockRenderFn, + mockGetAssetTags, + ); + + expect(response.headers.get('X-Loader-Mw')).toBe('yes'); + expect((await json(response)).props).toEqual({ ok: true }); + }); + + it('action with middleware re-renders the page after a successful mutation', async () => { + const c = await createTestContext('POST', '/db?create', { + headers: { + 'x-voidpages': 'true', + origin: 'http://localhost', + 'content-type': 'application/json', + }, + body: JSON.stringify({ title: 'a' }), + }); + + const getHandler = defineHandler(async () => ({ notes: ['fresh'] })); + const mutationHandler = defineHandler( + passThrough, + defineHandler.withValidator({ + body: passSchema(), + })(async (_c, { body }) => { + void body; + }), + ); + + const response = await handlePageMutation( + mutationHandler, + getHandler, + undefined, + c, + 'db', + mockRenderFn, + mockGetAssetTags, + ); + + const body = await json(response); + expect(response.status).toBe(200); + expect(body.props).toEqual({ notes: ['fresh'] }); + expect(body.errors).toBeNull(); + }); + + it('action with middleware maps validation failures to page field errors', async () => { + const c = await createTestContext('POST', '/db?create', { + headers: { + 'x-voidpages': 'true', + origin: 'http://localhost', + 'content-type': 'application/json', + }, + body: JSON.stringify({ title: '' }), + }); + + const getHandler = defineHandler(async () => ({ notes: [] })); + const mutationHandler = defineHandler( + passThrough, + defineHandler.withValidator({ + body: failSchema('title is required'), + })(async (_c, { body }) => { + void body; + }), + ); + + const response = await handlePageMutation( + mutationHandler, + getHandler, + undefined, + c, + 'db', + mockRenderFn, + mockGetAssetTags, + ); + + const body = await json(response); + expect(response.status).toBe(200); + expect(body.errors).toEqual({ body: 'title is required' }); + expect(body.props).toEqual({ notes: [] }); + }); +}); + describe('flattenValidationErrors', () => { it('flattens nested issues format into flat map', () => { const body = { diff --git a/packages/void/test/unit/route-type-extraction.typecheck.ts b/packages/void/test/unit/route-type-extraction.typecheck.ts index 787dfae51..65797e764 100644 --- a/packages/void/test/unit/route-type-extraction.typecheck.ts +++ b/packages/void/test/unit/route-type-extraction.typecheck.ts @@ -107,6 +107,23 @@ describe('ExtractInput', () => { }>(); }); + it('preserves validators when middleware wraps withValidator handler', () => { + const middleware = async (_c: unknown, next: () => Promise) => { + await next(); + }; + const handler = defineHandler( + middleware as never, + defineHandler.withValidator({ + body: z.object({ title: z.string() }), + })((_c, { body }) => { + void body; + }), + ); + + type Input = ExtractInput<(typeof handler)['__validators']>; + expectTypeOf().toEqualTypeOf<{ body: { title: string } }>(); + }); + it('extracts all three slots combined', () => { const handler = defineHandler.withValidator({ params: z.object({ id: z.string() }),