diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md
index 906876701..6de8cba6c 100644
--- a/docs/guide/pages-routing/actions-and-forms.md
+++ b/docs/guide/pages-routing/actions-and-forms.md
@@ -24,10 +24,10 @@ export const action = defineHandler.withValidator({
});
```
-| Action returns | Behavior |
-| ----------------- | ------------------------------------------------------------------------------------------------------------ |
-| Nothing (void) | Re-runs the loader, page re-renders with fresh props. |
-| `c.redirect(url)` | Navigates to another page. Build `url` with `routeUrl()`, or annotate a full path with `satisfies RouteUrl`. |
+| Action returns | Behavior |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Nothing (void) | Re-runs the loader, page re-renders with fresh props. |
+| `c.redirect(url)` | Navigates to another page. With [`routing.typedRoutes`](../type-safety.md#checked-url-strings), the location is checked the same way as a link. Cast a string TypeScript cannot check with `as Href`. |
Middleware does not run again after the action. To change [shared data](./layouts.md#shared-data), update it in the action or redirect.
diff --git a/docs/guide/type-safety.md b/docs/guide/type-safety.md
index 8ef61aaae..b54de0bd0 100644
--- a/docs/guide/type-safety.md
+++ b/docs/guide/type-safety.md
@@ -261,7 +261,7 @@ export default defineConfig({
### Checked URL strings
-[`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, `c.rewrite()`, `revalidate({ paths })`, `connectEventStream()`, `connectLiveStream()`, and `useIslandForm()`'s `post()`, `put()`, `patch()`, `delete()`, and `submit()`. Apps created with `void init` have it on. To turn it on in an existing app:
+[`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, `c.rewrite()`, `c.redirect()`, `revalidate({ paths })`, `connectEventStream()`, `connectLiveStream()`, and `useIslandForm()`'s `post()`, `put()`, `patch()`, `delete()`, and `submit()`. Apps created with `void init` have it on. To turn it on in an existing app:
```ts
// void.config.ts
@@ -330,20 +330,28 @@ Links can also point to the current page (`?page=2`, `#comments`) and to `http:/
The [SSE](./sse.md#client) and [live stream](./live.md#subscribe-from-the-browser) clients, `connectEventStream()` and `connectLiveStream()`, take one of your routes or an `http://` or `https://` URL for a stream on another origin. They reject same-page, `mailto:`, `tel:`, and `ws://` URLs. Cast a stream URL that TypeScript can't check with `as RouteUrl`; the cast turns off the check for that URL.
-`c.redirect()` is not part of that check. It still accepts any string. Build an internal path with `routeUrl()`, or annotate a full path with `satisfies RouteUrl`. With `routing.typedRoutes` on, a path that matches no route is a type error. An external URL stays a plain string:
+`c.redirect()` uses the same check as links. With `routing.typedRoutes` on, the location is a route URL, a same-page query or hash (`?page=2`, `#comments`), or an `http://`, `https://`, `mailto:`, or `tel:` URL. A path that matches no route is a type error. An external `https://` literal needs no cast. A `URL` or a destination object is accepted too. Cast a string TypeScript cannot check, such as a URL from a provider SDK, your CMS, or a file in `public/`, with `as Href`:
```ts
-import { routeUrl, type RouteUrl } from 'void/routes';
+import { routeUrl, type Href } from 'void/routes';
+declare const id: string;
+declare const dynamic: string;
+
+c.redirect('/about');
c.redirect(routeUrl('/users/:id', { params: { id } }));
-c.redirect('/users/42' satisfies RouteUrl);
+c.redirect({ pathname: '/users/:id', params: { id } });
c.redirect('https://example.com/login');
+c.redirect(new URL('https://example.com/login'));
+c.redirect(dynamic as Href);
```
+The cast turns the check off, so cast only URLs you trust. Check a URL from the request, such as a `?redirectTo=` parameter, before redirecting to it. See [`c.redirect()`](../reference/api/rewrites.md#c-rewrite-destination).
+
`void/routes` exports two types for these strings:
-- `RouteUrl`: the URL of one of your routes. `routeUrl()` returns it, and `c.rewrite()`, `useIslandForm()`, and `revalidate({ paths })` accept it. Annotate a `c.redirect()` path or a `routing.prerender` list with it.
-- `Href`: anything a link can point to: a `RouteUrl`, a same-page query or hash, or an external URL.
+- `RouteUrl`: the URL of one of your routes. `routeUrl()` returns it, and `c.rewrite()`, `useIslandForm()`, and `revalidate({ paths })` accept it. Annotate a `routing.prerender` list with it.
+- `Href`: anything a link or `c.redirect()` can point to: a `RouteUrl`, a same-page query or hash, or an external URL.
Cast a string that TypeScript can't check, such as a path from your CMS or a file in `public/`. The cast turns the check off for that string:
@@ -401,21 +409,21 @@ Route types update with the dev server. For CI or standalone typechecking, run `
## What Gets Checked
-| Layer | What's type-checked |
-| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
-| Database | Table columns, value types, insert/update shapes from Drizzle schema |
-| Handlers | `c.env` bindings, validator input, return type |
-| Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type |
-| Navigation | Route patterns and required parameter names and values in `routeUrl()` and destination objects |
-| Links | With `routing.typedRoutes`, URL strings in `Link`, router methods, `c.rewrite()`, `useIslandForm()`, and SSE and live stream clients |
-| `c.redirect` | An internal path built with `routeUrl()`, or a full path annotated with `satisfies RouteUrl` |
-| `useParams` | Page patterns and the names of the params they return |
-| `c.req.param` | Page param names, when the result is annotated with `PageParams` |
-| `getPrerenderPaths` | Page param names, when the return type is annotated with `PageParams` |
-| `revalidate` | With `routing.typedRoutes`, URL strings in `paths` |
-| `routing.prerender` | Paths, when the array is annotated with `satisfies RouteUrl[]` |
-| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` |
-| `action()` | Action URLs, `data` payload, `params` |
+| Layer | What's type-checked |
+| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Database | Table columns, value types, insert/update shapes from Drizzle schema |
+| Handlers | `c.env` bindings, validator input, return type |
+| Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type |
+| Navigation | Route patterns and required parameter names and values in `routeUrl()` and destination objects |
+| Links | With `routing.typedRoutes`, URL strings in `Link`, router methods, `c.rewrite()`, `useIslandForm()`, and SSE and live stream clients |
+| `c.redirect` | With `routing.typedRoutes`, route URLs, same-page queries and hashes, destination objects, `URL` values, and `http://`, `https://`, `mailto:`, and `tel:` literals. A string TypeScript cannot check needs `as Href`. |
+| `useParams` | Page patterns and the names of the params they return |
+| `c.req.param` | Page param names, when the result is annotated with `PageParams` |
+| `getPrerenderPaths` | Page param names, when the return type is annotated with `PageParams` |
+| `revalidate` | With `routing.typedRoutes`, URL strings in `paths` |
+| `routing.prerender` | Paths, when the array is annotated with `satisfies RouteUrl[]` |
+| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` |
+| `action()` | Action URLs, `data` payload, `params` |
## Serialization
diff --git a/docs/reference/api/rewrites.md b/docs/reference/api/rewrites.md
index 6065fdf72..ed05fec80 100644
--- a/docs/reference/api/rewrites.md
+++ b/docs/reference/api/rewrites.md
@@ -22,14 +22,24 @@ return c.rewrite({
With [`routing.typedRoutes`](../config.md#routing-typedroutes), a string destination must be a [`RouteUrl`](./types.md#route-types): a URL that matches one of your routes. Cast a path built at runtime, such as ``c.rewrite(`/${locale}${path}` as RouteUrl)``.
-For a typed redirect, build the URL with [`routeUrl()`](./types.md#routeurl-path-options). `c.redirect()` still accepts any string, so a full path is checked only when you annotate it with [`RouteUrl`](./types.md#route-types). That check applies when [`routing.typedRoutes`](../config.md#routing-typedroutes) is on. An external URL stays a plain string:
+With [`routing.typedRoutes`](../config.md#routing-typedroutes), `c.redirect()` checks its location the same way as a link. A string must be a route URL, a same-page query or hash, or an `http://`, `https://`, `mailto:`, or `tel:` URL. An external `https://` literal needs no cast. A `URL`, a [`routeUrl()`](./types.md#routeurl-path-options) string, or a destination object is accepted. Cast a string TypeScript cannot check, such as a checkout URL from a payment provider's SDK, with `as Href`:
```ts
-import { routeUrl, type RouteUrl } from 'void/routes';
+import { routeUrl, type Href } from 'void/routes';
+
+declare const checkoutUrl: string;
return c.redirect(routeUrl('/users/:id', { params: { id: 42 } }));
-return c.redirect('/users/42' satisfies RouteUrl);
+return c.redirect({ pathname: '/users/:id', params: { id: 42 } });
return c.redirect('https://example.com/login');
+return c.redirect(checkoutUrl as Href);
+```
+
+The cast turns the check off for that value, and Void does not check the location at runtime, so cast only URLs you trust. A URL from the request, such as a `?redirectTo=` parameter, can point to another site. Resolve it against the request URL and redirect only when the origin matches:
+
+```ts
+const requested = new URL(c.req.query('redirectTo') ?? '/', c.req.url);
+return c.redirect(requested.origin === new URL(c.req.url).origin ? requested : '/');
```
`c.redirect()` sends the location it is given. The request query is not added to an internal path, a protocol-relative URL, or an absolute URL, so `c.redirect('/notes')` from `/sign-in?redirectTo=/notes` goes to `/notes`. [`routing.redirects`](../../guide/edge/redirects.md) still merge the request query into the destination.
diff --git a/docs/reference/api/types.md b/docs/reference/api/types.md
index 86cc9afb2..a4241e86a 100644
--- a/docs/reference/api/types.md
+++ b/docs/reference/api/types.md
@@ -134,7 +134,7 @@ Import these types from `void/routes`:
| `RouteDestination
` | `{ pathname: P }` with `RouteOptions
`. Defaults to all known routes when `P` is omitted. |
| `RouteUrl` | The URL of one of your routes, returned by `routeUrl()`. `string` unless `typedRoutes` is on. |
| `Href` | A `RouteUrl`, a same-page query or hash, or an external URL. `string` unless `typedRoutes` is on. |
-| `RouteUrlInput` | `Href`, `URL`, or `RouteDestination`; accepted by router navigation methods. |
+| `RouteUrlInput` | `Href`, `URL`, or `RouteDestination`; accepted by router navigation methods and `c.redirect()`. |
| `RouteQuery` | Query keys mapped to `RouteQueryValue` or readonly arrays of those values. |
| `RouteQueryValue` | `string`, `number`, `boolean`, `null`, or `undefined`. |
diff --git a/docs/reference/config.md b/docs/reference/config.md
index 5bea9c943..6bf4d390f 100644
--- a/docs/reference/config.md
+++ b/docs/reference/config.md
@@ -378,7 +378,7 @@ routing: {
#### `routing.typedRoutes`
-Type-check URL strings against your app's routes in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, `c.rewrite()`, `useIslandForm()`, `revalidate({ paths })`, `connectEventStream()`, and `connectLiveStream()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Link and router strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `useIslandForm()` and `revalidate({ paths })` take route URLs; `connectEventStream()` and `connectLiveStream()` also take `http://` and `https://` URLs. `c.redirect()` and `routing.prerender` stay plain strings until you annotate them. See [Checked URL strings](../guide/type-safety#checked-url-strings).
+Type-check URL strings against your app's routes in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, `c.rewrite()`, `c.redirect()`, `useIslandForm()`, `revalidate({ paths })`, `connectEventStream()`, and `connectLiveStream()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Link and router strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `useIslandForm()` and `revalidate({ paths })` take route URLs; `connectEventStream()` and `connectLiveStream()` also take `http://` and `https://` URLs. `c.redirect()` accepts those strings, a `URL`, or a destination object. An external `https://` literal needs no cast; cast a string TypeScript cannot check with `as Href`. `routing.prerender` takes plain strings unless you annotate them. See [Checked URL strings](../guide/type-safety#checked-url-strings).
```ts
routing: {
diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts
index ed12f69be..4a405ebf4 100644
--- a/packages/void/src/pages/protocol.ts
+++ b/packages/void/src/pages/protocol.ts
@@ -1,3 +1,4 @@
+import type { Context } from 'hono';
import { executeComposedHandler, getHandlerComposition } from '../runtime/handler-composition.ts';
import type { CloudBindings, CloudContext } from '../runtime/handler.ts';
import { isConvertedResponse } from '../runtime/response.ts';
@@ -60,6 +61,7 @@ type PageHandler = ((c: CloudContext) => unknown | Promise) | undefined
type PageHeadHandler =
| ((c: CloudContext, props: Record) => HeadDescriptor | undefined)
| undefined;
+type PageRequestContext = Context | CloudContext;
type PageRenderOptions = {
renderMode?: PageRenderMode;
/** Dev server build: rejected deferred props keep their raw error message. */
@@ -455,8 +457,34 @@ function streamDeferredResponse(
return renderedResponse(c, readable, contentType, assetTags);
}
+function asCloudContext(c: PageRequestContext): CloudContext {
+ return c as unknown as CloudContext;
+}
+
/** Handle GET page request — run handler, return page object or SSR HTML */
export async function handlePageGet(
+ handler: PageHandler,
+ headFn: PageHeadHandler,
+ c: PageRequestContext,
+ componentId: string,
+ renderFn: RenderPage,
+ getAssetTags: GetAssetTags,
+ configHead?: HeadConfig,
+ options?: PageRenderOptions,
+): Promise {
+ return runPageGet(
+ handler,
+ headFn,
+ asCloudContext(c),
+ componentId,
+ renderFn,
+ getAssetTags,
+ configHead,
+ options,
+ );
+}
+
+async function runPageGet(
handler: PageHandler,
headFn: PageHeadHandler,
c: CloudContext,
@@ -615,6 +643,30 @@ function isSameOriginAction(request: Request): boolean {
/** Handle POST/PUT/PATCH/DELETE page request — run mutation, then re-run GET or redirect */
export async function handlePageMutation(
+ mutationHandler: PageHandler,
+ getHandler: PageHandler,
+ headFn: PageHeadHandler,
+ c: PageRequestContext,
+ componentId: string,
+ renderFn: RenderPage,
+ getAssetTags: GetAssetTags,
+ configHead?: HeadConfig,
+ options?: PageRenderOptions,
+): Promise {
+ return runPageMutation(
+ mutationHandler,
+ getHandler,
+ headFn,
+ asCloudContext(c),
+ componentId,
+ renderFn,
+ getAssetTags,
+ configHead,
+ options,
+ );
+}
+
+async function runPageMutation(
mutationHandler: PageHandler,
getHandler: PageHandler,
headFn: PageHeadHandler,
diff --git a/packages/void/src/runtime/handler.ts b/packages/void/src/runtime/handler.ts
index b44b5902d..15103a9da 100644
--- a/packages/void/src/runtime/handler.ts
+++ b/packages/void/src/runtime/handler.ts
@@ -1,5 +1,6 @@
-import type { Context, Env, MiddlewareHandler, Next } from 'hono';
-import type { RewriteDestination, RouteDestination, RouteUrl } from 'void/routes';
+import type { Context, Env, MiddlewareHandler, Next, TypedResponse } from 'hono';
+import type { RedirectStatusCode } from 'hono/utils/http-status';
+import type { RewriteDestination, RouteDestination, RouteUrl, RouteUrlInput } from 'void/routes';
import type { HeadDescriptor } from '../pages/head.ts';
import type { AuthSession, AuthUser } from './auth.ts';
import { base64ToBytes } from './base64.ts';
@@ -132,8 +133,36 @@ declare module 'hono' {
*
* This is the type of the `c` parameter in route handlers and middleware
* created with {@link defineHandler} and {@link defineMiddleware}.
+ * `redirect` accepts a {@link RouteUrlInput}.
*/
-export type CloudContext = Context;
+// `c` must still be assignable to Hono's `Context`, as in `getCookie(c)` and
+// `etag()`. An interface with its own members is compared member by member,
+// and two of them are not assignable on their own:
+// - `redirect`: a narrower `location` rejects some of the strings Hono's
+// accepts.
+// - `set`: TypeScript compares `Set` by its type argument.
+// Each member begins with a signature that Hono's `Context` accepts. `this:
+// never` makes that signature uncallable, so `c.redirect()` and `c.set()`
+// resolve to the typed signature, and its error is the one reported. A
+// module augmentation cannot narrow `redirect`: it would keep Hono's
+// `(location: string | URL)` signature callable.
+// Keep method variance on both overloads so an unchecked Hono context is
+// assignable back to CloudContext, including in raw Hono middleware.
+export interface CloudContext extends Context {
+ redirect(
+ this: never,
+ location: string | URL,
+ status?: T,
+ ): Response & TypedResponse;
+ redirect(
+ location: RouteUrlInput,
+ status?: T,
+ ): Response & TypedResponse;
+ set: {
+ set(this: never, key: PropertyKey, value: unknown): void;
+ }['set'] &
+ Context['set'];
+}
type HandlerFn = (c: CloudContext) => unknown | Promise;
// `R` is the loader's data, so `defineHandler(async (c) => ...)` works.
@@ -341,7 +370,10 @@ export namespace defineHandler {
* At runtime it simply returns the function passed to it.
*/
export function defineMiddleware(handler: MiddlewareFn): MiddlewareHandler {
- return handler;
+ // The callback's context accepts destination objects. Hono's handler type
+ // does not, so the same function is not assignable without a cast. The
+ // routing runtime replaces `redirect` before this runs.
+ return handler as MiddlewareHandler;
}
const basicAuthEncoder = new TextEncoder();
diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts
index 4c965d4d0..df524e204 100644
--- a/packages/void/src/runtime/routing.ts
+++ b/packages/void/src/runtime/routing.ts
@@ -17,7 +17,7 @@ import {
searchWithoutActionKey,
} from '../pages/action-key.ts';
import { SHARED_CACHE_HEADERS, authDependentRequests } from './auth-response.ts';
-import { VoidAssetRewriteError } from './handler.ts';
+import { VoidAssetRewriteError, type CloudContext } from './handler.ts';
import { convertReturnValue } from './response.ts';
import { toUrlString } from './route-url.ts';
import { ASSET_EXTENSIONS } from './routing-assets.ts';
@@ -409,11 +409,11 @@ export function markNoMatch(request: Request, response: Response): Response {
}
export function createHttpRouteHandler(
- load: () => Promise unknown>>,
+ load: () => Promise unknown>>,
method: string,
): (c: Context) => Promise {
return markRouteHandler(async (c: Context) =>
- convertReturnValue(await (await load())[method](c)),
+ convertReturnValue(await (await load())[method](c as unknown as CloudContext)),
);
}
diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts
index 315fd1435..f55a40595 100644
--- a/packages/void/test/integration/route-url-builder.test.ts
+++ b/packages/void/test/integration/route-url-builder.test.ts
@@ -23,10 +23,13 @@ afterEach(() => {
});
// Checked against a fixture generated with routing.typedRoutes on.
-const typedCheck = `import { routeUrl, type Href, type RouteUrl } from 'void/routes';
+const typedCheck = `import type { Context } from 'hono';
+import { getCookie } from 'hono/cookie';
+import { etag } from 'hono/etag';
+import { routeUrl, type Href, type RouteUrl } from 'void/routes';
import { defineConfig } from 'void/config';
import type { VoidRouter } from 'void/pages-client';
-import { defineMiddleware, type CloudContext } from 'void/handler';
+import { defineHandler, defineMiddleware, type CloudContext } from 'void/handler';
import { revalidate } from 'void/isr';
import { connectLiveStream } from 'void/live/client';
import { connectEventStream } from 'void/sse/client';
@@ -86,8 +89,7 @@ router.flush('#details');
void router.visit('/unknown');
// @ts-expect-error router prefetches are checked
void router.prefetch('../relative');
-declare const honoContext: Parameters>[0];
-const context: CloudContext = honoContext;
+declare const context: CloudContext;
void context.rewrite('/users/42');
void context.rewrite(user);
void context.rewrite('/api/legacy');
@@ -95,7 +97,36 @@ void context.rewrite('/api/legacy');
void context.rewrite('https://example.com');
// @ts-expect-error rewrites do not take same-page hashes
void context.rewrite('#details');
+context.redirect('/about');
+context.redirect(user);
+context.redirect(routeUrl('/users/:id', { params: { id } }));
+context.redirect({ pathname: '/users/:id', params: { id } });
context.redirect('https://example.com/elsewhere');
+context.redirect(new URL('https://example.com/login'));
+context.redirect(dynamic as Href);
+// @ts-expect-error unknown routes are rejected
+context.redirect('/unknown');
+// @ts-expect-error unchecked strings are rejected
+context.redirect(dynamic);
+defineHandler((c) => {
+ c.redirect('/about');
+ c.redirect({ pathname: '/users/:id', params: { id } });
+ // @ts-expect-error unknown routes are rejected
+ return c.redirect('/unknown');
+});
+defineMiddleware(async (c) => {
+ c.redirect('https://example.com/login');
+ // @ts-expect-error unknown routes are rejected
+ return c.redirect('/unknown');
+});
+// Checked redirects keep the context usable with Hono helpers and middleware.
+const honoContext: Context = context;
+void [honoContext, getCookie(context, 'session')];
+defineHandler(etag(), (c) => c.redirect('/about'));
+defineMiddleware(async (c, next) => {
+ if (!getCookie(c, 'session')) return c.redirect('/about');
+ await next();
+});
void revalidate({ paths: ['/', '/about', \`/users/\${id}\`, user] });
// @ts-expect-error revalidated paths are checked
void revalidate({ paths: ['/unknown'] });
@@ -275,14 +306,16 @@ async function createFixture({
'pages/users/[id].tsx': 'export default function Page() { return null; }',
'pages/files/[...path].tsx': 'export default function Page() { return null; }',
'pages/wild/[...].tsx': 'export default function Page() { return null; }',
- 'check.ts': `import {
+ 'check.ts': `import type { Context, MiddlewareHandler } from 'hono';
+import { getCookie } from 'hono/cookie';
+import {
routeUrl,
type RouteDestination,
type RouteOptions,
type RoutePath,
} from 'void/routes';
import type { VoidRouter } from 'void/pages-client';
-import { defineHandler, defineMiddleware, type CloudContext } from 'void/handler';
+import { defineHandler, defineMiddleware, type CloudContext, type CloudEnv } from 'void/handler';
const path: RoutePath = '/users/:id';
const options: RouteOptions = { params: { id: 123 }, query: { tab: 'activity' }, hash: 'details' };
const url: string = routeUrl(path, options);
@@ -314,17 +347,33 @@ void router.prefetch(destination);
router.flush(destination);
// @ts-expect-error destinations require path params
void router.visit({ pathname: '/users/:id' });
-declare const honoContext: Parameters>[0];
+declare const honoContext: Context;
const context: CloudContext = honoContext;
+const nativeContext: Context = context;
+const readUser = (c: CloudContext) => c.get('user');
+const middleware: MiddlewareHandler = async (c, next) => {
+ readUser(c);
+ await next();
+};
+defineHandler(middleware, c => c.redirect('/unknown'));
+context.set('user', null);
+// @ts-expect-error compatibility overloads must not bypass context variable types
+context.set('user', 'not a user');
+void [nativeContext, getCookie(context, 'session')];
const response: Response & { _status: 307 } = context.redirect(url, 307);
void response;
void context.rewrite(destination);
-// @ts-expect-error Hono redirects require a string or URL
context.redirect(destination);
+context.redirect('/unknown');
+context.redirect('https://example.com/elsewhere');
+context.redirect(new URL('https://example.com/login'));
// @ts-expect-error typed server rewrites reject unknown patterns
void context.rewrite({ pathname: '/unknown' });
defineHandler(c => c.redirect(url));
+defineHandler(c => c.redirect(destination));
+defineHandler(c => c.redirect('/unknown'));
defineMiddleware(async c => c.redirect(url));
+defineMiddleware(async c => c.redirect(destination));
`,
...extraFiles,
};
@@ -334,6 +383,7 @@ defineMiddleware(async c => c.redirect(url));
}
mkdirSync(join(root, 'node_modules'));
symlinkSync(packageRoot, join(root, 'node_modules/void'), 'junction');
+ symlinkSync(join(packageRoot, 'node_modules/hono'), join(root, 'node_modules/hono'), 'junction');
const { scan } = await analyzeApplication(root, {
config: readConfig(root),
});
diff --git a/packages/void/test/unit/pages-protocol.test.ts b/packages/void/test/unit/pages-protocol.test.ts
index 0295f3542..ba4ecff3d 100644
--- a/packages/void/test/unit/pages-protocol.test.ts
+++ b/packages/void/test/unit/pages-protocol.test.ts
@@ -1,4 +1,4 @@
-import { Hono, type Context } from 'hono';
+import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { describe, expect, it, vi } from 'vite-plus/test';
import { defer } from '../../src/pages/defer.ts';
@@ -52,7 +52,7 @@ function createTestContext(
});
app.all(options.routePattern ?? '*', (c) => {
- resolveContext!(c);
+ resolveContext!(c as unknown as CloudContext);
return c.text('ok');
});
@@ -179,7 +179,7 @@ describe('handlePageGet', () => {
headers: { 'x-voidpages': 'true' },
});
- const handler = vi.fn((ctx: Context) => {
+ const handler = vi.fn((ctx: CloudContext) => {
ctx.header('Set-Cookie', 'void_auth_nonce=abc; Path=/auth; HttpOnly');
return { users: [] };
});
@@ -258,7 +258,7 @@ describe('handlePageGet', () => {
it('preserves headers set by loaders on rendered HTML responses', async () => {
const c = await createTestContext('GET', '/login');
- const handler = vi.fn((ctx: Context) => {
+ const handler = vi.fn((ctx: CloudContext) => {
ctx.header('Set-Cookie', 'void_auth_nonce=abc; Path=/auth; HttpOnly');
return { githubUrl: '/auth/github' };
});
@@ -341,7 +341,7 @@ describe('handlePageGet', () => {
it('adds a quoted dev CSS nonce source to strict style CSP responses', async () => {
const c = await createTestContext('GET', '/styled');
const response = await handlePageGet(
- (ctx: Context) => {
+ (ctx: CloudContext) => {
ctx.header('Content-Security-Policy', "default-src 'self'; style-src 'self'");
return {};
},
@@ -365,7 +365,7 @@ describe('handlePageGet', () => {
it('adds style-src with a quoted nonce when only default-src is present', async () => {
const c = await createTestContext('GET', '/styled');
const response = await handlePageGet(
- (ctx: Context) => {
+ (ctx: CloudContext) => {
ctx.header('Content-Security-Policy', "default-src 'self'");
return {};
},
@@ -389,7 +389,7 @@ describe('handlePageGet', () => {
it('adds the generated dev CSS nonce when CSP already has another nonce', async () => {
const c = await createTestContext('GET', '/styled');
const response = await handlePageGet(
- (ctx: Context) => {
+ (ctx: CloudContext) => {
ctx.header('Content-Security-Policy', "style-src 'self' 'nonce-existing'");
return {};
},
@@ -954,21 +954,21 @@ describe('handlePageMutation', () => {
name: string;
headers: Record;
action: () => undefined;
- loader: (c: Context) => Response | Promise;
+ loader: (c: CloudContext) => Response | Promise;
status: number;
}>([
{
name: 'void action passes through a loader redirect',
headers: { 'x-voidpages': 'true' },
action: () => undefined,
- loader: (c: Context) => c.redirect('/posts'),
+ loader: (c: CloudContext) => c.redirect('/posts'),
status: 302,
},
{
name: 'void action passes through a loader 404',
headers: {},
action: () => undefined,
- loader: (c: Context) => c.notFound(),
+ loader: (c: CloudContext) => c.notFound(),
status: 404,
},
{
@@ -977,7 +977,7 @@ describe('handlePageMutation', () => {
action: () => {
throw new ValidationError({ title: 'required' });
},
- loader: (c: Context) => c.redirect('/posts'),
+ loader: (c: CloudContext) => c.redirect('/posts'),
status: 302,
},
])('$name', async ({ headers, action, loader, status }) => {
diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts
index ae4dddac3..2ca90d084 100644
--- a/packages/void/test/unit/routing-runtime.test.ts
+++ b/packages/void/test/unit/routing-runtime.test.ts
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vite-plus/test';
import { routeUrl } from 'void/routes';
import { defer } from '../../src/pages/defer.ts';
import { ValidationError } from '../../src/pages/protocol.ts';
-import { defineHandler, defineMiddleware } from '../../src/runtime/handler.ts';
+import { defineHandler, defineMiddleware, type CloudContext } from '../../src/runtime/handler.ts';
import { createClientAssetTags, createManifestLoader } from '../../src/runtime/routing-assets.ts';
import { createErrorRenderer } from '../../src/runtime/routing-errors.ts';
import {
@@ -203,7 +203,7 @@ describe('typed HTTP execution', () => {
assets: async () => ({ css: '', body: '', preloads: '' }),
headConfig: undefined,
renderOptions: { renderMode: 'server' },
- load: async () => ({ loader: (c: Context) => c.redirect('/notes') }),
+ load: async () => ({ loader: (c: CloudContext) => c.redirect('/notes') }),
}),
);
});
@@ -222,7 +222,7 @@ describe('typed HTTP execution', () => {
assets: async () => ({ css: '', body: '', preloads: '' }),
headConfig: undefined,
renderOptions: { renderMode: 'server' },
- load: async () => ({ action: (c: Context) => c.redirect('/done') }),
+ load: async () => ({ action: (c: CloudContext) => c.redirect('/done') }),
}),
);
});
@@ -809,7 +809,7 @@ describe('typed Pages and SSR execution', () => {
const app = new Hono();
const runtime = createRoutingRuntime(app, { nodeTarget: true });
app.use(runtime.contextMiddleware);
- const redirectTo = (location: string) => (c: Context) => c.redirect(location);
+ const redirectTo = (location: string) => (c: CloudContext) => c.redirect(location);
app.post(
'/named',
createPageMutationHandler({
@@ -830,7 +830,10 @@ describe('typed Pages and SSR execution', () => {
app.use(async (c, next) => (c.req.header('x-anon') ? c.redirect('/login') : next()));
app.post(
'/api/x',
- createHttpRouteHandler(async () => ({ POST: (c: Context) => c.redirect('/done') }), 'POST'),
+ createHttpRouteHandler(
+ async () => ({ POST: (c: CloudContext) => c.redirect('/done') }),
+ 'POST',
+ ),
);
app.on(
['POST', 'PUT'],
@@ -839,19 +842,19 @@ describe('typed Pages and SSR execution', () => {
...pageOptions,
load: async () => ({
actions: {
- delete: (c: Context) => c.redirect('/named'),
- update: (c: Context) => c.redirect('/named', 303),
- move: (c: Context) => c.redirect('/named-v2', 307),
- moveTab: (c: Context) => c.redirect('/named-v2?tab=details', 307),
- moveHash: (c: Context) => c.redirect('/named-v2?tab=details#top', 307),
- moveReset: (c: Context) => c.redirect('/named-v2?', 307),
- replace: (c: Context) => c.redirect('/named-v2?tab=details', 302),
- moveKey: (c: Context) => c.redirect('/named-v2?tab=1&moveKey=x', 307),
- moveSame: (c: Context) => c.redirect('/named-v2?moveSame', 307),
- toArchive: (c: Context) => c.redirect('/named?archive', 307),
- toArchiveHere: (c: Context) => c.redirect('/named?', 307),
- go: (c: Context) => c.rewrite('/api/x'),
- goWithQuery: (c: Context) => c.rewrite('/api/x?q=1'),
+ delete: (c: CloudContext) => c.redirect('/named'),
+ update: (c: CloudContext) => c.redirect('/named', 303),
+ move: (c: CloudContext) => c.redirect('/named-v2', 307),
+ moveTab: (c: CloudContext) => c.redirect('/named-v2?tab=details', 307),
+ moveHash: (c: CloudContext) => c.redirect('/named-v2?tab=details#top', 307),
+ moveReset: (c: CloudContext) => c.redirect('/named-v2?', 307),
+ replace: (c: CloudContext) => c.redirect('/named-v2?tab=details', 302),
+ moveKey: (c: CloudContext) => c.redirect('/named-v2?tab=1&moveKey=x', 307),
+ moveSame: (c: CloudContext) => c.redirect('/named-v2?moveSame', 307),
+ toArchive: (c: CloudContext) => c.redirect('/named?archive', 307),
+ toArchiveHere: (c: CloudContext) => c.redirect('/named?', 307),
+ go: (c: CloudContext) => c.rewrite('/api/x'),
+ goWithQuery: (c: CloudContext) => c.rewrite('/api/x?q=1'),
save: () => undefined,
},
}),
@@ -1557,10 +1560,10 @@ describe('page loaders and actions composed with defineHandler middleware', () =
await next();
};
const sharedOf = async (response: Response) => ((await response.json()) as PageJson).shared;
- const setCount = (n: number) => (c: Context) => {
+ const setCount = (n: number) => (c: CloudContext) => {
c.set('shared', { ...(c.get('shared') as object), ...count(n) });
};
- const assignCount = (n: number) => (c: Context) => {
+ const assignCount = (n: number) => (c: CloudContext) => {
Object.assign(c.get('shared') as object, count(n));
};
const loader = () => ({});
@@ -1583,7 +1586,7 @@ describe('page loaders and actions composed with defineHandler middleware', () =
let loaderSaw: unknown;
const app = createPageApp(
{
- loader: (c: Context) => ((loaderSaw = c.get('shared')), {}),
+ loader: (c: CloudContext) => ((loaderSaw = c.get('shared')), {}),
actions: { create: defineHandler(passThrough, async (c) => update(c)) },
},
appShared,
diff --git a/packages/void/test/unit/typed-navigation.typecheck.ts b/packages/void/test/unit/typed-navigation.typecheck.ts
index ef41f83bb..d651e4522 100644
--- a/packages/void/test/unit/typed-navigation.typecheck.ts
+++ b/packages/void/test/unit/typed-navigation.typecheck.ts
@@ -1,4 +1,5 @@
-import type { Context } from 'hono';
+import type { Context, MiddlewareHandler } from 'hono';
+import { getCookie } from 'hono/cookie';
import { expectTypeOf } from 'vite-plus/test';
import {
routeUrl,
@@ -25,9 +26,26 @@ declare module 'void/routes' {
}
}
-export function checkTypedServerDestinations(c: Context): void {
- const cloudContext: CloudContext = c;
- void cloudContext;
+// This package typecheck leaves `routing.typedRoutes` off, so a string is a route URL.
+// The route-url builder fixture covers the checked case.
+export function checkUntypedHonoCompatibility(c: Context): void {
+ const cloud: CloudContext = c;
+ const hono: Context = cloud;
+ const readUser = (context: CloudContext) => context.get('user');
+ const middleware: MiddlewareHandler = async (context, next) => {
+ readUser(context);
+ await next();
+ };
+ defineHandler(middleware, (context) => context.text('ok'));
+ cloud.set('user', null);
+ // @ts-expect-error compatibility overloads must not bypass context variable types
+ cloud.set('user', 'not a user');
+ // @ts-expect-error compatibility overloads must not accept arbitrary symbol keys
+ cloud.set(Symbol('user'), null);
+ void [hono, getCookie(cloud, 'session')];
+}
+
+export function checkTypedServerDestinations(c: CloudContext): void {
const target = routeUrl('/typed-navigation/:id', {
params: { id: 123 },
query: { tab: 'activity' },
@@ -35,6 +53,10 @@ export function checkTypedServerDestinations(c: Context): void {
});
const response: Response & { _status: 307 } = c.redirect(target, 307);
void response;
+ expectTypeOf[0]>().toEqualTypeOf();
+ // Hono helpers and functions typed with Hono's `Context` still take `c`.
+ const honoContext: Context = c;
+ void [honoContext, getCookie(c, 'session')];
void c.rewrite({
pathname: '/typed-navigation/:id',
params: { id: 123 },
@@ -42,14 +64,28 @@ export function checkTypedServerDestinations(c: Context): void {
hash: 'details',
});
c.redirect('/unknown');
- c.redirect(new URL('https://example.com'));
+ c.redirect('https://example.com/login');
+ c.redirect(new URL('https://example.com/login'));
+ c.redirect({
+ pathname: '/typed-navigation/:id',
+ params: { id: 123 },
+ query: { tab: 'activity' },
+ hash: 'details',
+ });
+ const dynamic: string = 'https://example.com/callback';
+ c.redirect(dynamic as Href);
void c.rewrite('/unknown');
// @ts-expect-error typed rewrite destinations require params
void c.rewrite({ pathname: '/typed-navigation/:id' });
// @ts-expect-error typed rewrite parameter names are checked
void c.rewrite({ pathname: '/typed-navigation/:id', params: { wrong: '123' } });
defineHandler((context) => context.redirect(target));
+ defineHandler((context) =>
+ context.redirect({ pathname: '/typed-navigation/:id', params: { id: '123' } }),
+ );
+ defineHandler((context) => context.redirect('/unknown'));
defineMiddleware(async (context) => context.redirect(target, 303));
+ defineMiddleware(async (context) => context.redirect(dynamic));
}
export function checkTypedNavigation(router: VoidRouter): void {