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 {