Skip to content
Open
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
20 changes: 20 additions & 0 deletions docs/guide/pages-routing/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ In middleware, `c.notFound()` shows the root `_not-found` without running a page

Action errors that reach a client boundary keep their HTTP status and public message. Redirects, authentication responses, and API errors keep their usual behavior.

## HTTP exceptions

Throw an `HTTPException` with a 4xx/5xx status and a public message to show a deliberate HTTP error inside your layouts:

```ts
// pages/account.server.ts
import { HTTPException } from 'hono/http-exception';
import { defineHandler } from 'void/handler';

export const loader = defineHandler(() => {
throw new HTTPException(403, { message: 'Your account is suspended' });
});
```

The nearest `_error` receives `{ status: 403, message: 'Your account is suspended' }`. The HTML document or client-navigation response uses HTTP 403. For exceptions thrown in application middleware before the loader, Void uses the root boundary without running the loader. A 404 selects `_not-found`. If no matching boundary exists, the exception's response is returned.

This applies to page GET and HEAD requests. Redirects, API routes, actions, returned responses, and exceptions with an explicit `res` keep their existing behavior. Only include messages safe to show to users. Checks that determine the HTTP status must finish before deferred streaming starts.

Use [`c.status()`](./loaders.md#response-status) instead when you want an error status while rendering the requested page with its normal props.

## Nested errors

Put an error component in a subdirectory to customize that section. Void uses the closest one and keeps its surrounding layouts. If a layout fails, an error component outside that layout handles it. If an error component fails, Void tries an outer one, then its built-in error page.
Expand Down
17 changes: 17 additions & 0 deletions docs/guide/pages-routing/loaders.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ Return data directly to preserve values such as `Date`, `Map`, `Set`, `bigint`,

Returning `Response.json()` uses JSON conversion instead. Functions and custom class instances can't be sent as page data.

## Response Status

Use `c.status()` to render the current page with an HTTP error status, such as `403` or `503`. Return the page's props as usual:

```ts
// pages/maintenance.server.ts
import { defineHandler } from 'void/handler';

export const loader = defineHandler((c) => {
c.status(503);
c.header('Retry-After', '120');
return { message: 'Down for maintenance' };
});
```

The status and headers apply to both the HTML document and client-side navigation responses. Client visits still display the page with its props and layouts. Middleware can also set the status before `await next()`. For pages with deferred props, set the status in the loader before streaming starts.

## Using the Data in Page Components

The page component receives the loader's result as props. Export the inferred type from the server file so the component stays in sync:
Expand Down
2 changes: 2 additions & 0 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Pages and island actions opt in to `X-VoidPages-Redirect: manual`. The routing m

Pages `_not-found`/`_error` recovery uses finite render plans that retain logical layout slots. Never serialize original exceptions through framework hydration helpers, rerun a failed loader/action, or cache an error document. Native client boundaries reset on every visit/refresh and preserve healthy ancestor layouts.

Recover response-less 4xx/5xx Hono `HTTPException`s only for page GET/HEAD requests: nearest boundary for loaders/handler middleware, root boundary for application middleware. Preserve redirects, explicit responses, API routes, actions, and existing 404 recovery. Middleware recovery must identify the actual matched endpoint without loading its module or replaying request code.

**Hono** is used as the runtime framework. The generated virtual module creates a Hono app with file-based routes and middleware. Route handlers receive a Hono `Context` object (`c`) and can use the full Hono API (`c.json()`, `c.text()`, `c.req.param()`, etc.) plus the Hono middleware ecosystem.

- `defineHandler(fn)` — wraps a route handler `(c: CloudContext) => R`, returns `TypedHandler<{}, R>`
Expand Down
69 changes: 63 additions & 6 deletions packages/void/src/pages/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PAGES_PROTOCOL_VERSION } from '@void/edge';
import type { Context } from 'hono';
import type { HTTPException } from 'hono/http-exception';
import { executeComposedHandler, getHandlerComposition } from '../runtime/handler-composition.ts';
import type { CloudBindings, CloudContext } from '../runtime/handler.ts';
import { isConvertedResponse } from '../runtime/response.ts';
Expand Down Expand Up @@ -103,6 +104,7 @@ function legacyPageResponse(
c: CloudContext,
errors: Record<string, string> | null = null,
): Response {
c.header('Cache-Control', 'no-store');
const headers = pageResponseHeaders(c, {
'Content-Type': errors ? 'application/json' : 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
Expand Down Expand Up @@ -290,6 +292,11 @@ async function renderPageHtml(

function pageJsonResponse(c: CloudContext, pageObj: PageObject): Response {
return new Response(serializePageData(pageObj), {
// Hono's lazy c.res getter ignores the private status set by c.status().
// Use its response factory to read that status without changing our header merging.
// https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L403-L407
// https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L652-L659
status: c.newResponse(null).status,
headers: pageResponseHeaders(c, {
'X-VoidPages': 'true',
'X-VoidPages-Version': PAGES_PROTOCOL_VERSION,
Expand Down Expand Up @@ -397,6 +404,38 @@ async function runLoader(
});
}

function isRecoverableHttpException(error: unknown): error is HTTPException {
// Application middleware may use a different installed copy of Hono.
const exception = error as HTTPException | null | undefined;
return (
!!exception &&
typeof exception.getResponse === 'function' &&
!exception.res &&
typeof exception.status === 'number' &&
exception.status >= 400 &&
exception.status < 600
);
}

/** Recover HTTP failures outside the loader leaf without executing application code again. */
export async function recoverPageHttpException(
error: unknown,
c: PageRequestContext,
renderFn: RenderPage,
getAssetTags: GetAssetTags,
configHead?: HeadConfig,
options?: PageRenderOptions,
): Promise<Response | undefined> {
if (!isRecoverableHttpException(error) || c.finalized) {
return undefined;
}
const context = asCloudContext(c);
if (options?.renderMode !== 'island' && needsPageReload(context)) {
return legacyPageResponse(context);
}
return renderPageFailure(context, error, undefined, renderFn, getAssetTags, configHead, options);
}

/** Recover one completed GET operation without replaying its middleware or loader. */
async function renderPageFailure(
c: CloudContext,
Expand All @@ -413,7 +452,11 @@ async function renderPageFailure(
'getResponse' in error &&
typeof error.getResponse === 'function';
const explicit = httpError ? (error as { getResponse(): Response }).getResponse() : original;
if (explicit && (explicit.status !== 404 || explicit.headers.has('Content-Disposition'))) {
const recoverHttpError = isRecoverableHttpException(error);
if (
explicit &&
((!recoverHttpError && explicit.status !== 404) || explicit.headers.has('Content-Disposition'))
) {
return undefined;
}
const notFound = explicit?.status === 404;
Expand All @@ -426,19 +469,22 @@ async function renderPageFailure(
if (!candidates?.[0]) {
return undefined;
}
c.header('Cache-Control', 'no-store');
if (options?.renderMode !== 'island') {
varyPagesResponse(c);
}
let status = notFound ? 404 : 500;
let status = recoverHttpError ? error.status : notFound ? 404 : 500;
let message = notFound
? httpError && error instanceof Error
? error.message || 'Not Found'
: 'Not Found'
: 'Internal Server Error';
if (!notFound && options?.dev && error instanceof Error) {
: recoverHttpError
? error.message || explicit?.statusText || 'HTTP Error'
: 'Internal Server Error';
if (!notFound && !recoverHttpError && options?.dev && error instanceof Error) {
message = error.message;
}
if (error && !notFound) {
if (error && !notFound && !recoverHttpError) {
console.error(error);
}
const shared = (c.get('shared') as Record<string, unknown> | undefined) ?? {};
Expand All @@ -465,7 +511,13 @@ async function renderPageFailure(
errors: null,
head: {
...head,
title: head?.title ?? (status === 404 ? 'Not Found' : 'Internal Server Error'),
title:
head?.title ??
(status === 404
? 'Not Found'
: recoverHttpError && status !== 500
? message
: 'Internal Server Error'),
},
};
try {
Expand Down Expand Up @@ -599,6 +651,11 @@ function renderedResponse(
assetTags?: AssetTags,
): Response {
return new Response(result, {
// Hono's lazy c.res getter ignores the private status set by c.status().
// Use its response factory to read that status without changing our header merging.
// https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L403-L407
// https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L652-L659
status: c.newResponse(null).status,
headers: adjustDevCssCsp(
pageResponseHeaders(c, {
'content-type': contentType,
Expand Down
16 changes: 12 additions & 4 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function pushDefaultErrorPageHelpers(lines: Array<string>, options: CompileOptio
lines.push(
`const __errorRenderer = __createErrorRenderer(${JSON.stringify({ dev: options.dev === true, projectRoot: options.projectRoot, homeDir: options.homeDir })});`,
);
lines.push('app.onError(__errorRenderer.onError);');
lines.push('app.onError(__createPageErrorHandler(__matchedRoutes, __errorRenderer.onError));');
}

export type AuthCompileOptions = {
Expand Down Expand Up @@ -196,6 +196,11 @@ export function compileRouteTable(
lines.push(
`import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createStaticNotFound as __createStaticNotFound, createPageMutationHandler as __createPageMutationHandler, createNamedActionMarker as __createNamedActionMarker, markRouteHandler as __markRouteHandler, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound, buildPrerenderPath as __buildPrerenderPath } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`,
);
if (pages) {
lines.push(
`import { createPageErrorHandler as __createPageErrorHandler } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`,
);
}
if (options.nodeTarget) {
lines.push(`const withRuntimeEnv = (_env, fn) => fn();`);
} else {
Expand Down Expand Up @@ -306,11 +311,13 @@ export function compileRouteTable(

// After routing rules, which ignore the action key as platform dispatch does,
// and before auth and user middleware, so their c.redirect() drops it too.
if (pages && scan.pages.pages.some((page) => page.methods.includes('actions'))) {
if (pages) {
lines.push(
`import { matchedRoutes as __matchedRoutes } from ${JSON.stringify(options.honoRoutePath)};`,
);
lines.push('app.use(__createNamedActionMarker(__matchedRoutes));');
if (scan.pages.pages.some((page) => page.methods.includes('actions'))) {
lines.push('app.use(__createNamedActionMarker(__matchedRoutes));');
}
}

const headerRules = emitEdgeRules ? options.headerRules : undefined;
Expand Down Expand Up @@ -1144,7 +1151,8 @@ function compilePageRoutes(
const trailingCatchAll = options.trailingSlash && page.catchAll;
const pattern = trailingCatchAll ? page.pattern.slice(0, -1) : page.pattern;
const catchAllParam = page.pattern.match(/:([^/{}]+)\{\.\+\}\/$/)?.[1];
const routeOptions = `{ ${serverImportPath ? 'load: _pload_' + safeId + ', ' : ''}${mdHeadRef ? 'head: ' + mdHeadRef + ', ' : ''}${trailingCatchAll ? `trailingSlash: true, catchAllParam: ${JSON.stringify(catchAllParam)}, ` : ''}componentId: ${JSON.stringify(page.componentId)}, render: ${renderer}, assets: ${assetTags}, headConfig: __voidHeadConfig, renderOptions: ${JSON.stringify({ renderMode: page.renderMode, ...(options.dev === true ? { dev: true } : {}), ...(boundaryManifest.pages[page.componentId] && { boundaries: boundaryManifest.pages[page.componentId] }) })} }`;
const middlewareBoundaries = `middlewareBoundaries: ${JSON.stringify({ notFound: boundaryManifest.root, errors: (boundaryManifest.pages[page.componentId]?.errors ?? []).filter((id) => !boundaryManifest.plans[id].directory) })}, `;
const routeOptions = `{ ${serverImportPath ? 'load: _pload_' + safeId + ', ' : ''}${mdHeadRef ? 'head: ' + mdHeadRef + ', ' : ''}${trailingCatchAll ? `trailingSlash: true, catchAllParam: ${JSON.stringify(catchAllParam)}, ` : ''}${middlewareBoundaries}componentId: ${JSON.stringify(page.componentId)}, render: ${renderer}, assets: ${assetTags}, headConfig: __voidHeadConfig, renderOptions: ${JSON.stringify({ renderMode: page.renderMode, ...(options.dev === true ? { dev: true } : {}), ...(boundaryManifest.pages[page.componentId] && { boundaries: boundaryManifest.pages[page.componentId] }) })} }`;
lines.push(`app.get(${JSON.stringify(pattern)}, __createPageGetHandler(${routeOptions}));`);
if (page.methods.includes('action') || page.methods.includes('actions')) {
lines.push(
Expand Down
92 changes: 80 additions & 12 deletions packages/void/src/runtime/routing-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
searchWithoutPreservedQuery,
} from '../pages/action-key.ts';
import { handlesPageLoaderNotFound } from '../pages/not-found-context.ts';
import { handlePageGet, handlePageMutation } from '../pages/protocol.ts';
import { handlePageGet, handlePageMutation, recoverPageHttpException } from '../pages/protocol.ts';
import { decodePagePath } from '../pages/url.ts';
import type { ClientAssetTags } from './routing-assets.ts';
import type { ErrorRenderer } from './routing-errors.ts';
Expand All @@ -28,6 +28,7 @@ export type PageRouteOptions = {
assets: PageGetArguments[5];
headConfig: PageGetArguments[6] | null;
renderOptions: PageGetArguments[7];
middlewareBoundaries?: NonNullable<PageGetArguments[7]>['boundaries'];
namedActions?: boolean;
trailingSlash?: boolean;
catchAllParam?: string;
Expand Down Expand Up @@ -59,11 +60,61 @@ function actionNameOf(request: Request): string | undefined {
}
const routeHandlers = new WeakSet<Function>();
const namedActionHandlers = new WeakSet<Function>();
const pageGetOptions = new WeakMap<Function, PageRouteOptions>();
/** Records a handler that answers a route, so the marker can tell it from middleware. */
export function markRouteHandler<T extends Function>(handler: T): T {
routeHandlers.add(handler);
return handler;
}

/** Hono catches middleware throws itself, before the page endpoint can run. */
export function createPageErrorHandler(
matchedRoutes: (c: Context) => Array<{ handler: Function }>,
fallback: ErrorRenderer['onError'],
): ErrorRenderer['onError'] {
const recovering = new WeakSet<Context>();
return async (error, c) => {
if ((c.req.method !== 'GET' && c.req.method !== 'HEAD') || recovering.has(c)) {
return fallback(error, c);
}
// Use the first endpoint, not a broad Pages pattern that follows an API route.
const routes = matchedRoutes(c);
const endpointIndex = routes.findIndex((route) => routeHandlers.has(route.handler));
const options =
endpointIndex < 0 ? undefined : pageGetOptions.get(routes[endpointIndex].handler);
// The endpoint already tried its nearest boundary; do not retry it as a middleware failure.
if (c.req.routeIndex >= endpointIndex) {
return fallback(error, c);
}
if (!options || (options.trailingSlash && !c.req.path.endsWith('/'))) {
return fallback(error, c);
}
recovering.add(c);
const routeIndex = c.req.routeIndex;
try {
// Params are scoped to Hono's current route, which is still the failed middleware.
c.req.routeIndex = endpointIndex;
const response = await recoverPageHttpException(
error,
c,
options.render,
options.assets,
options.headConfig ?? undefined,
{ ...options.renderOptions, boundaries: options.middlewareBoundaries ?? { errors: [] } },
);
return response ?? fallback(error, c);
} catch (failure) {
return fallback(
failure instanceof Error
? failure
: new Error('Unable to render Pages error boundary', { cause: failure }),
c,
);
} finally {
c.req.routeIndex = routeIndex;
}
};
}
/**
* Runs before auth and user middleware, so a c.redirect() there leaves the
* action key out too. It marks the request only when a page with named actions
Expand All @@ -86,7 +137,7 @@ export function createNamedActionMarker(
};
}
export function createPageGetHandler(options: PageRouteOptions): (c: Context) => Promise<Response> {
return markRouteHandler(async (context: Context) => {
const handler = markRouteHandler(async (context: Context) => {
if (!preparePageRequest(context, options)) {
return context.notFound();
}
Expand All @@ -100,17 +151,34 @@ export function createPageGetHandler(options: PageRouteOptions): (c: Context) =>
},
};
}
return handlePageGet(
mod.loader,
mod.head,
context as PageGetArguments[2],
options.componentId,
options.render,
options.assets,
options.headConfig ?? undefined,
options.renderOptions,
);
try {
return await handlePageGet(
mod.loader,
mod.head,
context as PageGetArguments[2],
options.componentId,
options.render,
options.assets,
options.headConfig ?? undefined,
options.renderOptions,
);
} catch (error) {
const response = await recoverPageHttpException(
error,
context,
options.render,
options.assets,
options.headConfig ?? undefined,
options.renderOptions,
);
if (response) {
return response;
}
throw error;
}
});
pageGetOptions.set(handler, options);
return handler;
}

/** Build-only rendering bypasses application middleware and serializes no request data. */
Expand Down
1 change: 1 addition & 0 deletions packages/void/src/runtime/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ export function createHttpRouteHandler(

export {
createPageGetHandler,
createPageErrorHandler,
createStaticNotFound,
createPageMutationHandler,
createPagesFallback,
Expand Down
Loading
Loading