From e69dc23636871b5fb2546df4e97a09de3655606f Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sat, 7 Nov 2026 07:04:39 +0800 Subject: [PATCH 1/6] feat(pages): check c.redirect() locations when typedRoutes is on Redirects take RouteUrlInput, so an in-app path is checked against the app's routes while an external URL still typechecks. Co-authored-by: Cursor --- docs/guide/pages-routing/actions-and-forms.md | 8 +-- docs/guide/type-safety.md | 46 ++++++++------- docs/reference/api/rewrites.md | 9 ++- docs/reference/config.md | 2 +- packages/void/src/pages/protocol.ts | 56 ++++++++++++++++++- packages/void/src/runtime/handler.ts | 36 ++++++++++-- packages/void/src/runtime/routing.ts | 6 +- .../integration/route-url-builder.test.ts | 36 ++++++++++-- .../void/test/unit/pages-protocol.test.ts | 22 ++++---- .../void/test/unit/routing-runtime.test.ts | 45 ++++++++------- .../test/unit/typed-navigation.typecheck.ts | 31 ++++++---- 11 files changed, 210 insertions(+), 87 deletions(-) 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 561daab29..0bef61812 100644 --- a/docs/guide/type-safety.md +++ b/docs/guide/type-safety.md @@ -257,7 +257,7 @@ await revalidate({ paths: ['/', '/blog/hello-world'] satisfies RouteUrl[] }); ### Checked URL strings -[`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. 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()`, and `c.redirect()`. Apps created with `void init` have it on. To turn it on in an existing app: ```ts // void.config.ts @@ -324,20 +324,26 @@ import { routeUrl } from 'void/routes'; Links can also point to the current page (`?page=2`, `#comments`) and to `http://`, `https://`, `mailto:`, and `tel:` URLs. Relative paths such as `../settings` are rejected; use the absolute path. A fixed part with spaces or non-ASCII characters can be written as is (`/café menu`) or percent-encoded in uppercase, as `routeUrl()` returns it (`/caf%C3%A9%20menu`). A dynamic segment matches any text, including none, so `/users/:id` also accepts `/users/` and `/users/42/edit`. With a root dynamic page such as `pages/[slug].tsx`, `.vue`, or `.svelte`, a protocol-relative URL like `//example.com` passes too. -`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 that check. 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); ``` `void/routes` exports two types for these strings: -- `RouteUrl`: the URL of one of your routes. `routeUrl()` returns it, and `c.rewrite()` accepts it. Annotate a `c.redirect()` path or a prerender or revalidate path 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()` accepts it. Annotate a prerender or revalidate path 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/`: @@ -395,20 +401,20 @@ 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, and `c.rewrite()` | -| `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` | `paths`, and `routing.prerender`, 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, and `c.rewrite()` | +| `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` | `paths`, and `routing.prerender`, 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..a735c13cc 100644 --- a/docs/reference/api/rewrites.md +++ b/docs/reference/api/rewrites.md @@ -22,14 +22,17 @@ 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 with `as Href`: ```ts -import { routeUrl, type RouteUrl } from 'void/routes'; +import { routeUrl, type Href } from 'void/routes'; + +declare const nextUrl: 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(nextUrl as Href); ``` `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/config.md b/docs/reference/config.md index d16bee826..ce2c7c18e 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()`, and `c.rewrite()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `c.redirect()`, `revalidate({ paths })`, 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()`, and `c.redirect()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` 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`. `revalidate({ paths })` and `routing.prerender` stay plain strings until 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..a3ca2b228 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( +export 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, @@ -614,7 +642,31 @@ function isSameOriginAction(request: Request): boolean { } /** Handle POST/PUT/PATCH/DELETE page request — run mutation, then re-run GET or redirect */ -export async function handlePageMutation( +export 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..97bfdc588 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,16 @@ 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; +// One property, not a second overload. A module augmentation would leave +// Hono's `(location: string | URL)` signature in place. +export interface CloudContext extends Context { + redirect: ( + location: RouteUrlInput, + status?: T, + ) => Response & TypedResponse; +} type HandlerFn = (c: CloudContext) => unknown | Promise; // `R` is the loader's data, so `defineHandler(async (c) => ...)` works. @@ -289,7 +298,21 @@ export function defineHandler( fifthMiddleware: MiddlewareFn, handler: F, ): TypedHandler<{}, LoaderOutput>; -export function defineHandler(...args: Array): TypedHandler { +// Hono middleware such as `etag()` is not a CloudContext callback. These +// overloads come after the CloudContext ones, so an inline callback is still +// checked against `RouteUrlInput`. +export function defineHandler( + ...args: [...Array, TypedHandler] +): TypedHandler; +export function defineHandler( + ...args: [...Array, LoaderFn] +): TypedHandler<{}, Exclude, Response>>; +export function defineHandler( + ...args: [...Array, F] +): TypedHandler<{}, LoaderOutput>; +export function defineHandler( + ...args: Array +): TypedHandler { if (args.length === 1) { return args[0] as TypedHandler; } @@ -341,7 +364,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 7edc46d4a..8776afb96 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -26,7 +26,7 @@ afterEach(() => { const typedCheck = `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'; declare const id: number; declare const dynamic: string; const user: RouteUrl = routeUrl('/users/:id', { params: { id } }); @@ -82,8 +82,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'); @@ -91,7 +90,28 @@ 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'); +}); // Config rewrites keep placeholders and static files. defineConfig({ routing: { @@ -251,17 +271,21 @@ 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]; -const context: CloudContext = honoContext; +declare const context: CloudContext; 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, }; 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 4bc97e65b..3ad6c2caa 100644 --- a/packages/void/test/unit/typed-navigation.typecheck.ts +++ b/packages/void/test/unit/typed-navigation.typecheck.ts @@ -1,4 +1,3 @@ -import type { Context } from 'hono'; import { expectTypeOf } from 'vite-plus/test'; import { routeUrl, @@ -8,12 +7,7 @@ import { type RouteUrlInput, } from 'void/routes'; import type { VoidRouter } from '../../src/pages/client.ts'; -import { - defineHandler, - defineMiddleware, - type CloudContext, - type CloudEnv, -} from '../../src/runtime/handler.ts'; +import { defineHandler, defineMiddleware, type CloudContext } from '../../src/runtime/handler.ts'; declare module 'void/routes' { interface RoutePathMap { @@ -22,9 +16,9 @@ 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 checkTypedServerDestinations(c: CloudContext): void { const target = routeUrl('/typed-navigation/:id', { params: { id: 123 }, query: { tab: 'activity' }, @@ -32,6 +26,7 @@ export function checkTypedServerDestinations(c: Context): void { }); const response: Response & { _status: 307 } = c.redirect(target, 307); void response; + expectTypeOf[0]>().toEqualTypeOf(); void c.rewrite({ pathname: '/typed-navigation/:id', params: { id: 123 }, @@ -39,14 +34,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 { From f672e0ad53f1aa1b611f06e059e10300909b1643 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sat, 7 Nov 2026 09:43:16 +0800 Subject: [PATCH 2/6] fix(pages): keep CloudContext assignable to Hono's Context Narrowing `redirect` made CloudContext an interface compared member by member, so Hono helpers such as `getCookie(c)` and functions typed with `Context` rejected it. `redirect` and `set` now start with an uncallable `this: never` signature that Hono's Context accepts, while calls still resolve to the typed signature. This drops the defineHandler overloads that worked around the same problem for `etag()`. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/runtime/handler.ts | 41 ++++++++++--------- .../integration/route-url-builder.test.ts | 14 ++++++- .../test/unit/typed-navigation.typecheck.ts | 5 +++ 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/void/src/runtime/handler.ts b/packages/void/src/runtime/handler.ts index 97bfdc588..73cdeeca6 100644 --- a/packages/void/src/runtime/handler.ts +++ b/packages/void/src/runtime/handler.ts @@ -135,13 +135,28 @@ declare module 'hono' { * created with {@link defineHandler} and {@link defineMiddleware}. * `redirect` accepts a {@link RouteUrlInput}. */ -// One property, not a second overload. A module augmentation would leave -// Hono's `(location: string | URL)` signature in place. +// `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. export interface CloudContext extends Context { - redirect: ( - location: RouteUrlInput, + redirect: (( + this: never, + location: string | URL, status?: T, - ) => Response & TypedResponse; + ) => Response & TypedResponse) & + (( + location: RouteUrlInput, + status?: T, + ) => Response & TypedResponse); + set: ((this: never, key: PropertyKey, value: unknown) => void) & Context['set']; } type HandlerFn = (c: CloudContext) => unknown | Promise; @@ -298,21 +313,7 @@ export function defineHandler( fifthMiddleware: MiddlewareFn, handler: F, ): TypedHandler<{}, LoaderOutput>; -// Hono middleware such as `etag()` is not a CloudContext callback. These -// overloads come after the CloudContext ones, so an inline callback is still -// checked against `RouteUrlInput`. -export function defineHandler( - ...args: [...Array, TypedHandler] -): TypedHandler; -export function defineHandler( - ...args: [...Array, LoaderFn] -): TypedHandler<{}, Exclude, Response>>; -export function defineHandler( - ...args: [...Array, F] -): TypedHandler<{}, LoaderOutput>; -export function defineHandler( - ...args: Array -): TypedHandler { +export function defineHandler(...args: Array): TypedHandler { if (args.length === 1) { return args[0] as TypedHandler; } diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index 8776afb96..8783d2271 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -23,7 +23,10 @@ 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 { defineHandler, defineMiddleware, type CloudContext } from 'void/handler'; @@ -112,6 +115,14 @@ defineMiddleware(async (c) => { // @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(); +}); // Config rewrites keep placeholders and static files. defineConfig({ routing: { @@ -295,6 +306,7 @@ defineMiddleware(async c => c.redirect(destination)); } 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/typed-navigation.typecheck.ts b/packages/void/test/unit/typed-navigation.typecheck.ts index 3ad6c2caa..c1a904506 100644 --- a/packages/void/test/unit/typed-navigation.typecheck.ts +++ b/packages/void/test/unit/typed-navigation.typecheck.ts @@ -1,3 +1,5 @@ +import type { Context } from 'hono'; +import { getCookie } from 'hono/cookie'; import { expectTypeOf } from 'vite-plus/test'; import { routeUrl, @@ -27,6 +29,9 @@ export function checkTypedServerDestinations(c: CloudContext): 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 }, From 42dbc0c33612f60ffb60ab79fde1bb423c960e85 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sat, 7 Nov 2026 10:05:54 +0800 Subject: [PATCH 3/6] refactor(pages): mark exported page handlers async handlePageGet and handlePageMutation were async before the CloudContext wrappers were added. Keeping them async means any failure reaches callers as a rejected promise. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/pages/protocol.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index a3ca2b228..4a405ebf4 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -462,7 +462,7 @@ function asCloudContext(c: PageRequestContext): CloudContext { } /** Handle GET page request — run handler, return page object or SSR HTML */ -export function handlePageGet( +export async function handlePageGet( handler: PageHandler, headFn: PageHeadHandler, c: PageRequestContext, @@ -642,7 +642,7 @@ function isSameOriginAction(request: Request): boolean { } /** Handle POST/PUT/PATCH/DELETE page request — run mutation, then re-run GET or redirect */ -export function handlePageMutation( +export async function handlePageMutation( mutationHandler: PageHandler, getHandler: PageHandler, headFn: PageHeadHandler, From 81a50851a42fb7fcd4151debf0c24e5e95cbd5f3 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sat, 7 Nov 2026 10:18:29 +0800 Subject: [PATCH 4/6] docs: explain what an `as Href` redirect cast skips The cast turns the typedRoutes check off and nothing validates the location at runtime. Show how to check a `?redirectTo=` value before redirecting to it, and list `c.redirect()` as a `RouteUrlInput` consumer. Co-Authored-By: Claude Opus 5.5 --- docs/guide/type-safety.md | 4 +++- docs/reference/api/rewrites.md | 13 ++++++++++--- docs/reference/api/types.md | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/guide/type-safety.md b/docs/guide/type-safety.md index 0bef61812..8ce7cc117 100644 --- a/docs/guide/type-safety.md +++ b/docs/guide/type-safety.md @@ -340,12 +340,14 @@ 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()` accepts it. Annotate a prerender or revalidate path 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/`: +Cast a string that TypeScript can't check, such as a path from your CMS or a file in `public/`. The cast skips the check, so a wrong path still compiles: ::: code-group diff --git a/docs/reference/api/rewrites.md b/docs/reference/api/rewrites.md index a735c13cc..ed05fec80 100644 --- a/docs/reference/api/rewrites.md +++ b/docs/reference/api/rewrites.md @@ -22,17 +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)``. -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 with `as Href`: +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 Href } from 'void/routes'; -declare const nextUrl: string; +declare const checkoutUrl: string; return c.redirect(routeUrl('/users/:id', { params: { id: 42 } })); return c.redirect({ pathname: '/users/:id', params: { id: 42 } }); return c.redirect('https://example.com/login'); -return c.redirect(nextUrl as Href); +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`. | From 095d654a845676e8fe7ebd4c265a9d1a99dc44a7 Mon Sep 17 00:00:00 2001 From: Christoph Nakazawa Date: Sat, 7 Nov 2026 19:07:12 +0900 Subject: [PATCH 5/6] Preserve Hono context compatibility. --- packages/void/src/runtime/handler.ts | 19 +++++++++----- .../integration/route-url-builder.test.ts | 20 +++++++++++--- .../test/unit/typed-navigation.typecheck.ts | 26 +++++++++++++++++-- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/void/src/runtime/handler.ts b/packages/void/src/runtime/handler.ts index 73cdeeca6..15103a9da 100644 --- a/packages/void/src/runtime/handler.ts +++ b/packages/void/src/runtime/handler.ts @@ -146,17 +146,22 @@ declare module 'hono' { // 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: (( + redirect( this: never, location: string | URL, status?: T, - ) => Response & TypedResponse) & - (( - location: RouteUrlInput, - status?: T, - ) => Response & TypedResponse); - set: ((this: never, key: PropertyKey, value: unknown) => void) & Context['set']; + ): 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; diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index 52dcc721a..84fac9f99 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -272,14 +272,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); @@ -311,7 +313,19 @@ void router.prefetch(destination); router.flush(destination); // @ts-expect-error destinations require path params void router.visit({ pathname: '/users/:id' }); -declare const context: CloudContext; +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); diff --git a/packages/void/test/unit/typed-navigation.typecheck.ts b/packages/void/test/unit/typed-navigation.typecheck.ts index c1a904506..56871a1be 100644 --- a/packages/void/test/unit/typed-navigation.typecheck.ts +++ b/packages/void/test/unit/typed-navigation.typecheck.ts @@ -1,4 +1,4 @@ -import type { Context } from 'hono'; +import type { Context, MiddlewareHandler } from 'hono'; import { getCookie } from 'hono/cookie'; import { expectTypeOf } from 'vite-plus/test'; import { @@ -9,7 +9,12 @@ import { type RouteUrlInput, } from 'void/routes'; import type { VoidRouter } from '../../src/pages/client.ts'; -import { defineHandler, defineMiddleware, type CloudContext } from '../../src/runtime/handler.ts'; +import { + defineHandler, + defineMiddleware, + type CloudContext, + type CloudEnv, +} from '../../src/runtime/handler.ts'; declare module 'void/routes' { interface RoutePathMap { @@ -20,6 +25,23 @@ declare module 'void/routes' { // 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 }, From 7a0b2197c98f5e45f0ef6cb1f6093cd052485aaf Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sat, 7 Nov 2026 18:10:50 +0800 Subject: [PATCH 6/6] docs: restore the SSE and live stream URL paragraph The main merge kept only this branch's side of the type-safety guide, which dropped the stream client paragraph that #741 added. Co-Authored-By: Claude Opus 5.5 --- docs/guide/type-safety.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/type-safety.md b/docs/guide/type-safety.md index 4b9e0c29e..3358b5c95 100644 --- a/docs/guide/type-safety.md +++ b/docs/guide/type-safety.md @@ -324,7 +324,9 @@ import { routeUrl } from 'void/routes'; Links can also point to the current page (`?page=2`, `#comments`) and to `http://`, `https://`, `mailto:`, and `tel:` URLs. Relative paths such as `../settings` are rejected; use the absolute path. A fixed part with spaces or non-ASCII characters can be written as is (`/café menu`) or percent-encoded in uppercase, as `routeUrl()` returns it (`/caf%C3%A9%20menu`). A dynamic segment matches any text, including none, so `/users/:id` also accepts `/users/` and `/users/42/edit`. With a root dynamic page such as `pages/[slug].tsx`, `.vue`, or `.svelte`, a protocol-relative URL like `//example.com` passes too. -`c.redirect()` uses that check. 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`: +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()` 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 Href } from 'void/routes';