From 2691379793a2844f85cb0601150d1e61d805ccc7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:43:21 +0800 Subject: [PATCH 1/8] fix(pages): keep the named action key out of middleware redirects, rewrites and the page URL Follow-ups to #522, which dropped the action key only from c.redirect() inside the action: - Middleware: pages with named actions now mark the request before routing rules, auth and user middleware, so an auth redirect on POST /books?delete&page=2 goes to /login?page=2. - Rewrites: a rewrite that keeps the query keeps the mark, so a handler reached from the action drops the key too. - 307 and 308 keep the key: they send the same request again, and the destination needs it to pick the action. - Page URL: when the action renders the page again, page.url leaves the key out, and the client writes that URL to history instead of /books?delete&page=2. - Docs: the actions guide says what happens to the query. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/pages-routing/actions-and-forms.md | 2 +- docs/reference/api/rewrites.md | 2 +- packages/void/src/pages/action-key.ts | 28 ++++++++++ packages/void/src/pages/navigation-engine.ts | 18 +++++++ packages/void/src/pages/protocol.ts | 5 +- packages/void/src/router/compile.ts | 14 ++++- packages/void/src/runtime/routing-pages.ts | 26 +++++---- packages/void/src/runtime/routing.ts | 35 ++++++------ packages/void/test/unit/pages-client.test.ts | 18 +++++++ packages/void/test/unit/pages-compile.test.ts | 25 +++++++++ .../void/test/unit/routing-runtime.test.ts | 53 +++++++++++++++++++ 11 files changed, 196 insertions(+), 30 deletions(-) create mode 100644 packages/void/src/pages/action-key.ts diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 57b945056..620701ce4 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -197,7 +197,7 @@ export const actions = { }; ``` -Named actions are dispatched via a `?actionName` suffix on the URL (e.g. `/users/42?update`). The client primitives `useForm` and `action()` handle this automatically. +Named actions are dispatched via a `?actionName` suffix on the URL (e.g. `/users/42?update`). The client primitives `useForm` and `action()` handle this automatically. The action key stays out of the page URL. When `POST /users/42?update&tab=profile` renders the page again, `useForm` and `action()` leave the address bar at `/users/42?tab=profile`, and `c.redirect('/users/42')` goes to `/users/42?tab=profile`. Use `action` or `actions` in a file, not both. diff --git a/docs/reference/api/rewrites.md b/docs/reference/api/rewrites.md index 4705c0b72..cc5d28ab8 100644 --- a/docs/reference/api/rewrites.md +++ b/docs/reference/api/rewrites.md @@ -10,7 +10,7 @@ URL rewrites re-dispatch a request at a different internal path without changing Re-dispatches the current request at `destination` and returns the resulting `Response`. The browser URL does not change — this is a server-side hop. If `destination` has no query string, the original request query is preserved. If `destination` includes a query string, it replaces the original query. `destination` must start with `/`; known generated routes autocomplete via [`RewriteDestination`](#rewritedestination), while arbitrary strings remain accepted for dynamic paths. -`c.redirect()` also preserves request query params for internal destinations, but it merges rather than replaces: destination params win on collision, and request-only params are appended. Inside a [named action](../../guide/pages-routing/actions-and-forms.md#named-actions), the action key (`?delete` in `POST /users/42?delete`) is not carried over. +`c.redirect()` also preserves request query params for internal destinations, but it merges rather than replaces: destination params win on collision, and request-only params are appended. On a request to a [named action](../../guide/pages-routing/actions-and-forms.md#named-actions), the action key (`?delete` in `POST /users/42?delete`) is not carried over, whether the action or middleware redirects. A `307` or `308` redirect keeps it, because the browser sends the same request again. ```ts import { defineHandler } from 'void'; diff --git a/packages/void/src/pages/action-key.ts b/packages/void/src/pages/action-key.ts new file mode 100644 index 000000000..bdb615b95 --- /dev/null +++ b/packages/void/src/pages/action-key.ts @@ -0,0 +1,28 @@ +// On a page with named actions, the first query key selects the action +// (`/books?delete&page=2`). It addresses the action, not the page, so the page +// URL, history and c.redirect() leave it out. +const actionKeyRequests = new WeakSet(); + +export function markActionKey(request: Request): void { + actionKeyRequests.add(request); +} + +export function hasActionKey(request: Request): boolean { + return actionKeyRequests.has(request); +} + +/** `search` without its first non-empty segment; the rest is kept as sent. */ +export function searchWithoutActionKey(search: string): string { + let start = 1; + while (search[start] === '&') { + start++; + } + let rest = search.indexOf('&', start); + if (rest === -1) { + return ''; + } + while (search[rest] === '&') { + rest++; + } + return rest < search.length ? '?' + search.slice(rest) : ''; +} diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index 75672de65..05ab94680 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -1,5 +1,6 @@ /// +import { searchWithoutActionKey } from './action-key.ts'; import { createActionErrorFromResponse, isAbortError } from './action-result.ts'; import { initClientHead, applyClientHead } from './head-client.ts'; import type { HeadDescriptor } from './head.ts'; @@ -9,6 +10,22 @@ import type { PageObject } from './protocol.ts'; import { createScrollRestoration } from './scroll-restoration.ts'; import { isSameOrigin } from './url.ts'; +/** + * The server leaves a named action's key out of the page URL. When the page URL + * shows that, drop the key from the URL written to history as well. + */ +function withoutActionKey(fetchUrl: string, pageUrl: string): string { + const q = fetchUrl.indexOf('?'); + if (q === -1) { + return fetchUrl; + } + const search = searchWithoutActionKey(fetchUrl.slice(q)); + const pageQ = pageUrl.indexOf('?'); + return search === (pageQ === -1 ? '' : pageUrl.slice(pageQ)) + ? fetchUrl.slice(0, q) + search + : fetchUrl; +} + export type PageComponentModule = { default: unknown; }; @@ -809,6 +826,7 @@ export async function createPageNavigationEngine( if (method !== 'GET') { deferredRegistry.preserveValuesFrom(_committedDeferredRegistry, pageData); + commitTarget.fetchUrl = withoutActionKey(fetchUrl, pageData.url); } const [component, layouts] = await Promise.race([ diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 2ddd4aac3..2d82c2405 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -1,5 +1,6 @@ import type { CloudBindings, CloudContext } from '../runtime/handler.ts'; import { isConvertedResponse } from '../runtime/response.ts'; +import { hasActionKey, searchWithoutActionKey } from './action-key.ts'; import { DEFERRED, separateDeferred, type ResolvedDeferredGroup } from './defer.ts'; import { resolveHead, @@ -611,7 +612,9 @@ export async function handlePageMutation( props, params: requestParams(c), shared, - url: requestPathWithSearch(c), + url: hasActionKey(c.req.raw) + ? c.req.path + searchWithoutActionKey(new URL(c.req.url).search) + : requestPathWithSearch(c), errors, ...(head && { head }), }; diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index fefb15827..2a487ec03 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -154,7 +154,7 @@ export function compileRouteTable( lines.push(`import { AsyncLocalStorage } from "node:async_hooks";`); lines.push(`import { Hono } from ${JSON.stringify(options.honoPath)};`); lines.push( - `import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`, + `import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, markNamedActionRequest as __markNamedActionRequest, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`, ); if (options.nodeTarget) { lines.push(`const withRuntimeEnv = (_env, fn) => fn();`); @@ -243,6 +243,18 @@ export function compileRouteTable( lines.push('app.use(__routingRuntime.requestMiddleware);'); lines.push(''); + // Before routing rules, auth and user middleware, so their redirects and + // rewrites treat a named action's key the same way the action does. + if (pages) { + for (const page of scan.pages.pages) { + if (page.methods.includes('actions')) { + lines.push( + `app.on(["POST", "PUT", "PATCH", "DELETE"], ${JSON.stringify(page.pattern)}, __markNamedActionRequest);`, + ); + } + } + } + // Edge routing middleware. Every target without managed platform dispatch // owns routing end-to-end, including native Cloudflare production builds. // Managed bundles omit it because dispatch applies the same rules first. diff --git a/packages/void/src/runtime/routing-pages.ts b/packages/void/src/runtime/routing-pages.ts index 180dae577..6dbf77cdd 100644 --- a/packages/void/src/runtime/routing-pages.ts +++ b/packages/void/src/runtime/routing-pages.ts @@ -1,4 +1,5 @@ -import type { Context } from 'hono'; +import type { Context, MiddlewareHandler } from 'hono'; +import { markActionKey } from '../pages/action-key.ts'; import { handlePageGet, handlePageMutation } from '../pages/protocol.ts'; import type { ClientAssetTags } from './routing-assets.ts'; import type { ErrorRenderer } from './routing-errors.ts'; @@ -22,12 +23,19 @@ export type PageRouteOptions = { renderOptions: PageGetArguments[7]; namedActions?: boolean; }; -// Requests whose first query key selected a named action. That key addresses -// the action, not the page, so c.redirect() must not carry it over. -const actionQueryRequests = new WeakSet(); -export function hasActionQueryKey(request: Request): boolean { - return actionQueryRequests.has(request); +function actionNameOf(url: string): string | undefined { + return new URL(url).searchParams.keys().next().value; } +/** + * Runs before auth and user middleware on pages with named actions, so a + * c.redirect() there leaves the action key out too. + */ +export const markNamedActionRequest: MiddlewareHandler = async (c, next) => { + if (actionNameOf(c.req.url)) { + markActionKey(c.req.raw); + } + await next(); +}; export function createPageGetHandler(options: PageRouteOptions): (c: Context) => Promise { return async (context) => { const mod = options.load ? await options.load() : { head: options.head }; @@ -48,9 +56,7 @@ export function createPageMutationHandler( ): (c: Context) => Promise { return async (context) => { const mod = options.load ? await options.load() : {}; - const actionName = options.namedActions - ? new URL(context.req.url).searchParams.keys().next().value - : undefined; + const actionName = options.namedActions ? actionNameOf(context.req.url) : undefined; // The name comes from the query string, so only own function properties // count; `?constructor` or `?valueOf` must not reach Object.prototype. const name = actionName || 'default'; @@ -67,7 +73,7 @@ export function createPageMutationHandler( }); } if (actionName) { - actionQueryRequests.add(context.req.raw); + markActionKey(context.req.raw); } return handlePageMutation( handler, diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index a717bfcde..1006a29c2 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -8,10 +8,10 @@ import { } from '@void/edge'; import type { Context, Hono, MiddlewareHandler } from 'hono'; import type { RedirectStatusCode } from 'hono/utils/http-status'; +import { hasActionKey, markActionKey, searchWithoutActionKey } from '../pages/action-key.ts'; import { VoidAssetRewriteError } from './handler.ts'; import { convertReturnValue } from './response.ts'; import { ASSET_EXTENSIONS } from './routing-assets.ts'; -import { hasActionQueryKey } from './routing-pages.ts'; export { createErrorRenderer } from './routing-errors.ts'; export type RoutingRule = { @@ -119,6 +119,7 @@ export function createRoutingRuntime( } const request = new Request(rewriteUrl.toString(), c.req.raw); rememberOriginal(request, c); + keepActionKey(c.req.raw, request, search); if (fallback) { fallbackHandled.add(request); } @@ -169,6 +170,7 @@ export function createRoutingRuntime( } const request = new Request(url.toString(), c.req.raw); rememberOriginal(request, c); + keepActionKey(c.req.raw, request, search); return dispatch(request, c).then((response) => annotate(response, `c.rewrite -> ${path} (middleware)`), ); @@ -176,7 +178,10 @@ export function createRoutingRuntime( const redirect = c.redirect.bind(c); c.redirect = (location, status) => { if (typeof location === 'string' && location.startsWith('/') && !location.startsWith('//')) { - return redirect(mergeQueryIntoLocation(location, redirectSearch(c.req.raw)), status); + return redirect( + mergeQueryIntoLocation(location, redirectSearch(c.req.raw, status)), + status, + ); } return redirect(location, status); }; @@ -246,24 +251,21 @@ export function createRoutingRuntime( }; } -function redirectSearch(request: Request): string { +function redirectSearch(request: Request, status: RedirectStatusCode | undefined): string { const search = new URL(request.url).search; - if (!hasActionQueryKey(request)) { + // 307 and 308 send the same method and body again, so the destination still + // needs the action key to pick the action. + if (!hasActionKey(request) || status === 307 || status === 308) { return search; } - // The action key is the first non-empty segment; keep the rest as sent. - let start = 1; - while (search[start] === '&') { - start++; - } - let rest = search.indexOf('&', start); - if (rest === -1) { - return ''; - } - while (search[rest] === '&') { - rest++; + return searchWithoutActionKey(search); +} + +/** A rewrite that keeps the query keeps the action key in it too. */ +function keepActionKey(from: Request, to: Request, destinationSearch: string): void { + if (!destinationSearch && hasActionKey(from)) { + markActionKey(to); } - return rest < search.length ? '?' + search.slice(rest) : ''; } export function markNoMatch(request: Request, response: Response): Response { @@ -297,6 +299,7 @@ export { createPageGetHandler, createPageMutationHandler, createPagesFallback, + markNamedActionRequest, } from './routing-pages.ts'; export function createModuleLoader(load: () => Promise, cache = true): () => Promise { diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index e06437ee9..e1bc557f9 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -509,6 +509,24 @@ describe('createVoidRouter', () => { ); }); + it.each([ + ['/books?delete&page=2', '/books?page=2', '/books?page=2'], + ['/books?delete', '/books', '/books'], + ['/books?page=2', '/books?page=2', '/books?page=2'], + ['/books?a&b', '/books?a&b', '/books?a&b'], + ])('writes POST %s with page URL %s to history as %s', async (url, pageUrl, historyUrl) => { + stubClientGlobals(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => pageJson({ component: 'home', props: {}, url: pageUrl, errors: null })), + ); + const { router } = await createTestRouter(); + + await router.visit(url, { method: 'POST' }); + + expect(history.pushState).toHaveBeenCalledWith(expect.anything(), '', historyUrl); + }); + it('runs navigation listeners for same-page hash visits', async () => { const documentStub = stubClientGlobals(); window.location.pathname = '/docs'; diff --git a/packages/void/test/unit/pages-compile.test.ts b/packages/void/test/unit/pages-compile.test.ts index a8568fda0..27ee668d1 100644 --- a/packages/void/test/unit/pages-compile.test.ts +++ b/packages/void/test/unit/pages-compile.test.ts @@ -61,6 +61,31 @@ describe('Pages metadata wiring', () => { expect(code).toContain('namedActions: true'); expect(code).not.toContain('handlePageMutation(handler'); }); + it.each([ + [['loader', 'actions'], true], + [['loader', 'action'], false], + ] as const)('marks named action requests before user middleware for %j', (methods, marked) => { + const code = compileRouteTable( + { + ...makeScan({ pages: [{ ...page, methods: [...methods] }], layouts: [], namedLayouts: [] }), + middleware: [{ order: 1, filePath: '01.auth.ts' }], + }, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + pagesOptions, + ); + const marker = code.indexOf( + 'app.on(["POST", "PUT", "PATCH", "DELETE"], "/", __markNamedActionRequest);', + ); + expect(marker !== -1).toBe(marked); + if (marked) { + expect(marker).toBeLessThan(code.indexOf('app.use(middleware0);')); + } + }); it('marks page render options as dev only in dev builds', () => { expect(compile()).toContain('renderOptions: {"renderMode":"server","dev":true} }'); const prod = compile({ ...testOptions, dev: false }); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index d7f04a46f..69e69bbe9 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -8,6 +8,7 @@ import { createPageMutationHandler, createPagesFallback, createSsrFallback, + markNamedActionRequest, } from '../../src/runtime/routing-pages.ts'; import { applyAssetCachePolicy, @@ -487,6 +488,58 @@ describe('typed Pages and SSR execution', () => { const response = await app.fetch(new Request('https://app.test' + path, { method: 'POST' })); expect(response.headers.get('location')).toBe(location); }); + describe('the named action key around the action', () => { + function createNamedActionApp() { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.on(['POST'], '/named', markNamedActionRequest); + app.use(async (c, next) => (c.req.header('x-anon') ? c.redirect('/login') : next())); + app.post('/api/x', (c) => c.redirect('/done')); + app.post( + '/named', + createPageMutationHandler({ + ...pageOptions, + load: async () => ({ + actions: { + delete: (c: Context) => c.redirect('/named'), + move: (c: Context) => c.redirect('/named-v2', 307), + go: (c: Context) => c.rewrite('/api/x'), + goWithQuery: (c: Context) => c.rewrite('/api/x?q=1'), + save: () => undefined, + }, + }), + namedActions: true, + }), + ); + return app; + } + + it.each([ + ['/named?delete&page=2', 'middleware', '/login?page=2'], + ['/named?move&page=2', 'a 307 redirect', '/named-v2?move&page=2'], + ['/named?go&page=2', 'a rewrite', '/done?page=2'], + ['/named?goWithQuery&page=2', 'a rewrite with its own query', '/done?q=1'], + ])('POST %s through %s redirects to %s', async (path, through, location) => { + const response = await createNamedActionApp().fetch( + new Request('https://app.test' + path, { + method: 'POST', + headers: through === 'middleware' ? { 'x-anon': '1' } : {}, + }), + ); + expect(response.headers.get('location')).toBe(location); + }); + + it('leaves the key out of the page URL when the action renders the page again', async () => { + const response = await createNamedActionApp().fetch( + new Request('https://app.test/named?save&page=2', { + method: 'POST', + headers: { 'x-voidpages': 'true' }, + }), + ); + expect(((await response.json()) as { url: string }).url).toBe('/named?page=2'); + }); + }); it('executes the real page loader and protocol', async () => { const app = new Hono(); app.get( From 2609ff5663dfcfd02f40baab2f3fdbf1c7024a10 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:43:27 +0800 Subject: [PATCH 2/8] test(void): stop three unit tests from failing under load - plugin-class-a-wrapper: macOS can report server.mjs, written just before the dev server starts, as changed. The reload gave the second import a new Counter. Turn the watcher off. - plugin-vite-environment (#472): same watcher fix, a lighter schema file without drizzle's sqlite-core, and a 30s limit for the real dev server. - plugin-entry-imports: importing the whole plugin entry takes about 4s alone, so give it a 30s limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/unit/plugin-class-a-wrapper.test.ts | 4 +++- .../test/unit/plugin-entry-imports.test.ts | 3 ++- .../test/unit/plugin-vite-environment.test.ts | 20 +++++++------------ 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index ccf37b590..80a99a70a 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -101,7 +101,9 @@ describe('Class A framework custom entry', () => { configFile: false, logLevel: 'silent', optimizeDeps: { noDiscovery: true }, - server: { middlewareMode: true, ws: false, preTransformRequests: false }, + // No watcher: macOS can report server.mjs, written just before startup, as + // changed, and the reload would give the second import a new Counter. + server: { middlewareMode: true, ws: false, watch: null, preTransformRequests: false }, }); try { const wrapped = await server.ssrLoadModule( diff --git a/packages/void/test/unit/plugin-entry-imports.test.ts b/packages/void/test/unit/plugin-entry-imports.test.ts index ffd15c8b4..27f98f08c 100644 --- a/packages/void/test/unit/plugin-entry-imports.test.ts +++ b/packages/void/test/unit/plugin-entry-imports.test.ts @@ -22,6 +22,7 @@ vi.mock('terminal-link', () => { }); it('keeps auth codegen and CLI output out of the plugin entry', async () => { + // The first import of the whole plugin entry takes a few seconds. await import('../../src/index.ts'); expect(loaded).toEqual([]); -}); +}, 30_000); diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index e3f64d52a..e6420f201 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -11,7 +11,7 @@ import { import module, { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; import { cloudflare } from '@cloudflare/vite-plugin'; import { createBuilder, createServer, normalizePath, resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -955,18 +955,10 @@ describe('voidPlugin Vite environments', () => { 'export * from "../src/resources/projects/projects.schema";\n', ); mkdirSync(join(root, 'src/resources/projects'), { recursive: true }); - const sqliteCore = fileURLToPath(import.meta.resolve('drizzle-orm/sqlite-core')); + // A top-level sql`` call, as in a column default, is enough to need the helper. writeFileSync( join(root, 'src/resources/projects/projects.schema.ts'), - [ - "import { sql } from 'void/db';", - `import { integer, sqliteTable, text } from ${JSON.stringify(normalizePath(sqliteCore))};`, - "export const projects = sqliteTable('projects', {", - " id: integer('id').primaryKey(),", - " createdAt: text('created_at').default(sql`(datetime('now'))`),", - '});', - '', - ].join('\n'), + "import { sql } from 'void/db';\nexport const projects = sql`(datetime('now'))`;\n", ); mkdirSync(join(root, 'routes/api'), { recursive: true }); writeFileSync( @@ -986,7 +978,9 @@ describe('voidPlugin Vite environments', () => { logLevel: 'silent', plugins: [voidPlugin()], optimizeDeps: { noDiscovery: true }, - server: { middlewareMode: true, ws: false, preTransformRequests: false }, + // No watcher: macOS can report the files just written as changed, which + // reloads void/db while the route is still loading it. + server: { middlewareMode: true, ws: false, watch: null, preTransformRequests: false }, }); try { const route = await server.ssrLoadModule('/routes/api/projects.ts'); @@ -996,7 +990,7 @@ describe('voidPlugin Vite environments', () => { } finally { await server.close(); } - }); + }, 30_000); describe('Node target builds with an installed void', () => { /** An API-only Node-target app whose route imports void and an adapter from node_modules. */ From 31f61a5d22d243c70d44ddb13a7f9a528c0fbd68 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:49:40 +0800 Subject: [PATCH 3/8] fix(routing): drop the named action key from redirect rules too The marker runs before routing rules, but a redirect rule merged the full request query into its location before c.redirect() saw it, so POST /books?delete&page=2 with a /books -> /login rule still went to /login?delete&page=2. Use the same filtered query as c.redirect(): drop the key for 301/302/303, keep it for 307/308. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/runtime/routing.ts | 5 ++++- packages/void/test/unit/routing-runtime.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 1006a29c2..bef1f697e 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -130,7 +130,10 @@ export function createRoutingRuntime( `${kind}[${rule.source}] -> ${destination} (${rule.origin || 'unknown'})`, ); } - const location = mergeQueryIntoLocation(destination, url.search); + const location = mergeQueryIntoLocation( + destination, + redirectSearch(c.req.raw, rule.status as RedirectStatusCode), + ); return annotate( c.redirect(location, rule.status as RedirectStatusCode), `redirect[${rule.source}] -> ${destination} ${rule.status} (${rule.origin || 'unknown'})`, diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 69e69bbe9..30ca598e9 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -530,6 +530,23 @@ describe('typed Pages and SSR execution', () => { expect(response.headers.get('location')).toBe(location); }); + it.each([ + [301, ['page']], + [308, ['delete', 'page']], + ] as const)('a %i redirect rule carries the query keys %j', async (status, keys) => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.on(['POST'], '/named', markNamedActionRequest); + app.use(runtime.routingMiddleware([{ source: '/named', destination: '/library', status }])); + const response = await app.fetch( + new Request('https://app.test/named?delete&page=2', { method: 'POST' }), + ); + const location = new URL(response.headers.get('location')!, 'https://app.test'); + expect(location.pathname).toBe('/library'); + expect([...location.searchParams.keys()]).toEqual(keys); + }); + it('leaves the key out of the page URL when the action renders the page again', async () => { const response = await createNamedActionApp().fetch( new Request('https://app.test/named?save&page=2', { From 7615acb309d228e7d465a989774dd4ff4527bd0d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:55:07 +0800 Subject: [PATCH 4/8] fix(routing): leave redirect and rewrite rules out of the action key handling Platform dispatch applies redirect and rewrite rules before the Worker and knows nothing about pages, so on Void Cloud a rule redirect keeps the whole query. Filtering the key in native rules (31f61a5d2) made native and hosted deploys answer the same request differently. Revert that change and run the named-action marker after the routing rules again, so every target applies rules to the query as sent, as docs/guide/edge/redirects.md describes. Middleware, actions and c.rewrite() still drop the key. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/router/compile.ts | 24 ++++----- packages/void/src/runtime/routing.ts | 5 +- packages/void/test/unit/pages-compile.test.ts | 52 +++++++++++-------- .../void/test/unit/routing-runtime.test.ts | 17 ------ 4 files changed, 43 insertions(+), 55 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 2a487ec03..08613ba82 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -243,18 +243,6 @@ export function compileRouteTable( lines.push('app.use(__routingRuntime.requestMiddleware);'); lines.push(''); - // Before routing rules, auth and user middleware, so their redirects and - // rewrites treat a named action's key the same way the action does. - if (pages) { - for (const page of scan.pages.pages) { - if (page.methods.includes('actions')) { - lines.push( - `app.on(["POST", "PUT", "PATCH", "DELETE"], ${JSON.stringify(page.pattern)}, __markNamedActionRequest);`, - ); - } - } - } - // Edge routing middleware. Every target without managed platform dispatch // owns routing end-to-end, including native Cloudflare production builds. // Managed bundles omit it because dispatch applies the same rules first. @@ -273,6 +261,18 @@ export function compileRouteTable( compileHeaderMiddleware(lines, options.headerRules); } + // After routing rules, which ignore the action key as platform dispatch does, + // and before auth and user middleware, so their c.redirect() drops it too. + if (pages) { + for (const page of scan.pages.pages) { + if (page.methods.includes('actions')) { + lines.push( + `app.on(["POST", "PUT", "PATCH", "DELETE"], ${JSON.stringify(page.pattern)}, __markNamedActionRequest);`, + ); + } + } + } + if (auth) { lines.push('app.use(async (c, next) => {'); lines.push(' return runVoidAuthMiddleware(c, next, __voidAuthOptions, __voidAuthDatabase);'); diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index bef1f697e..1006a29c2 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -130,10 +130,7 @@ export function createRoutingRuntime( `${kind}[${rule.source}] -> ${destination} (${rule.origin || 'unknown'})`, ); } - const location = mergeQueryIntoLocation( - destination, - redirectSearch(c.req.raw, rule.status as RedirectStatusCode), - ); + const location = mergeQueryIntoLocation(destination, url.search); return annotate( c.redirect(location, rule.status as RedirectStatusCode), `redirect[${rule.source}] -> ${destination} ${rule.status} (${rule.origin || 'unknown'})`, diff --git a/packages/void/test/unit/pages-compile.test.ts b/packages/void/test/unit/pages-compile.test.ts index 27ee668d1..6eaa8deaf 100644 --- a/packages/void/test/unit/pages-compile.test.ts +++ b/packages/void/test/unit/pages-compile.test.ts @@ -64,28 +64,36 @@ describe('Pages metadata wiring', () => { it.each([ [['loader', 'actions'], true], [['loader', 'action'], false], - ] as const)('marks named action requests before user middleware for %j', (methods, marked) => { - const code = compileRouteTable( - { - ...makeScan({ pages: [{ ...page, methods: [...methods] }], layouts: [], namedLayouts: [] }), - middleware: [{ order: 1, filePath: '01.auth.ts' }], - }, - '/routes', - '/middleware', - '/crons', - '/queues', - null, - testOptions, - pagesOptions, - ); - const marker = code.indexOf( - 'app.on(["POST", "PUT", "PATCH", "DELETE"], "/", __markNamedActionRequest);', - ); - expect(marker !== -1).toBe(marked); - if (marked) { - expect(marker).toBeLessThan(code.indexOf('app.use(middleware0);')); - } - }); + ] as const)( + 'marks named action requests between routing rules and user middleware for %j', + (methods, marked) => { + const code = compileRouteTable( + { + ...makeScan({ + pages: [{ ...page, methods: [...methods] }], + layouts: [], + namedLayouts: [], + }), + middleware: [{ order: 1, filePath: '01.auth.ts' }], + }, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + { ...testOptions, redirectRules: [{ source: '/old', destination: '/', status: 301 }] }, + pagesOptions, + ); + const marker = code.indexOf( + 'app.on(["POST", "PUT", "PATCH", "DELETE"], "/", __markNamedActionRequest);', + ); + expect(marker !== -1).toBe(marked); + if (marked) { + expect(marker).toBeGreaterThan(code.indexOf('__routingRuntime.routingMiddleware(')); + expect(marker).toBeLessThan(code.indexOf('app.use(middleware0);')); + } + }, + ); it('marks page render options as dev only in dev builds', () => { expect(compile()).toContain('renderOptions: {"renderMode":"server","dev":true} }'); const prod = compile({ ...testOptions, dev: false }); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 30ca598e9..69e69bbe9 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -530,23 +530,6 @@ describe('typed Pages and SSR execution', () => { expect(response.headers.get('location')).toBe(location); }); - it.each([ - [301, ['page']], - [308, ['delete', 'page']], - ] as const)('a %i redirect rule carries the query keys %j', async (status, keys) => { - const app = new Hono(); - const runtime = createRoutingRuntime(app, { nodeTarget: true }); - app.use(runtime.contextMiddleware); - app.on(['POST'], '/named', markNamedActionRequest); - app.use(runtime.routingMiddleware([{ source: '/named', destination: '/library', status }])); - const response = await app.fetch( - new Request('https://app.test/named?delete&page=2', { method: 'POST' }), - ); - const location = new URL(response.headers.get('location')!, 'https://app.test'); - expect(location.pathname).toBe('/library'); - expect([...location.searchParams.keys()]).toEqual(keys); - }); - it('leaves the key out of the page URL when the action renders the page again', async () => { const response = await createNamedActionApp().fetch( new Request('https://app.test/named?save&page=2', { From cbdb8ee4630fe2214fcedac15abb540f6cabf0cc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 17:03:53 +0800 Subject: [PATCH 5/8] fix(pages): mark only requests a named-action page answers; keep the key on repeated methods - The marker is now one global middleware. It looks at the routes Hono matched and marks the request only when a page with named actions is the handler that will answer. A catch-all page pattern no longer drops the first query param from an API route's redirect. - The key goes only when the browser follows with a GET: after a 303, or after a 301/302 to a POST. Fetch repeats PUT, PATCH and DELETE on a 301/302, and the repeated request needs the key to pick the action. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/api/rewrites.md | 2 +- packages/void/src/router/compile.ts | 10 ++---- packages/void/src/runtime/routing-pages.ts | 18 ++++++++--- packages/void/src/runtime/routing.ts | 13 ++++---- packages/void/test/unit/pages-compile.test.ts | 4 +-- .../void/test/unit/routing-runtime.test.ts | 31 +++++++++++++------ 6 files changed, 46 insertions(+), 32 deletions(-) diff --git a/docs/reference/api/rewrites.md b/docs/reference/api/rewrites.md index cc5d28ab8..b8b58e381 100644 --- a/docs/reference/api/rewrites.md +++ b/docs/reference/api/rewrites.md @@ -10,7 +10,7 @@ URL rewrites re-dispatch a request at a different internal path without changing Re-dispatches the current request at `destination` and returns the resulting `Response`. The browser URL does not change — this is a server-side hop. If `destination` has no query string, the original request query is preserved. If `destination` includes a query string, it replaces the original query. `destination` must start with `/`; known generated routes autocomplete via [`RewriteDestination`](#rewritedestination), while arbitrary strings remain accepted for dynamic paths. -`c.redirect()` also preserves request query params for internal destinations, but it merges rather than replaces: destination params win on collision, and request-only params are appended. On a request to a [named action](../../guide/pages-routing/actions-and-forms.md#named-actions), the action key (`?delete` in `POST /users/42?delete`) is not carried over, whether the action or middleware redirects. A `307` or `308` redirect keeps it, because the browser sends the same request again. +`c.redirect()` also preserves request query params for internal destinations, but it merges rather than replaces: destination params win on collision, and request-only params are appended. On a request to a [named action](../../guide/pages-routing/actions-and-forms.md#named-actions), the action key (`?delete` in `POST /users/42?delete`) is not carried over, whether the action or middleware redirects. A redirect after which the browser sends the same request again keeps it: `307` and `308`, and `301` or `302` after a `PUT`, `PATCH` or `DELETE`. ```ts import { defineHandler } from 'void'; diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 08613ba82..a4a5c8807 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -263,14 +263,8 @@ export function compileRouteTable( // After routing rules, which ignore the action key as platform dispatch does, // and before auth and user middleware, so their c.redirect() drops it too. - if (pages) { - for (const page of scan.pages.pages) { - if (page.methods.includes('actions')) { - lines.push( - `app.on(["POST", "PUT", "PATCH", "DELETE"], ${JSON.stringify(page.pattern)}, __markNamedActionRequest);`, - ); - } - } + if (pages && scan.pages.pages.some((page) => page.methods.includes('actions'))) { + lines.push('app.use(__markNamedActionRequest);'); } if (auth) { diff --git a/packages/void/src/runtime/routing-pages.ts b/packages/void/src/runtime/routing-pages.ts index 6dbf77cdd..8984e0161 100644 --- a/packages/void/src/runtime/routing-pages.ts +++ b/packages/void/src/runtime/routing-pages.ts @@ -1,4 +1,5 @@ import type { Context, MiddlewareHandler } from 'hono'; +import { matchedRoutes } from 'hono/route'; import { markActionKey } from '../pages/action-key.ts'; import { handlePageGet, handlePageMutation } from '../pages/protocol.ts'; import type { ClientAssetTags } from './routing-assets.ts'; @@ -26,12 +27,17 @@ export type PageRouteOptions = { function actionNameOf(url: string): string | undefined { return new URL(url).searchParams.keys().next().value; } +const namedActionHandlers = new WeakSet(); /** - * Runs before auth and user middleware on pages with named actions, so a - * c.redirect() there leaves the action key out too. + * Runs before auth and user middleware, so a c.redirect() there leaves the + * action key out too. It marks the request only when a page with named actions + * is the route that will answer it, not when a broad page pattern also matches. */ export const markNamedActionRequest: MiddlewareHandler = async (c, next) => { - if (actionNameOf(c.req.url)) { + const routes = matchedRoutes(c); + // Middleware takes (c, next); the first one-argument handler answers. + const answer = routes.slice(c.req.routeIndex + 1).find((route) => route.handler.length < 2); + if (answer && namedActionHandlers.has(answer.handler) && actionNameOf(c.req.url)) { markActionKey(c.req.raw); } await next(); @@ -54,7 +60,7 @@ export function createPageGetHandler(options: PageRouteOptions): (c: Context) => export function createPageMutationHandler( options: PageRouteOptions, ): (c: Context) => Promise { - return async (context) => { + const mutationHandler = async (context: Context) => { const mod = options.load ? await options.load() : {}; const actionName = options.namedActions ? actionNameOf(context.req.url) : undefined; // The name comes from the query string, so only own function properties @@ -87,6 +93,10 @@ export function createPageMutationHandler( options.renderOptions, ); }; + if (options.namedActions) { + namedActionHandlers.add(mutationHandler); + } + return mutationHandler; } export function createPagesFallback(options: { diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 1006a29c2..aa044ff77 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -253,12 +253,13 @@ export function createRoutingRuntime( function redirectSearch(request: Request, status: RedirectStatusCode | undefined): string { const search = new URL(request.url).search; - // 307 and 308 send the same method and body again, so the destination still - // needs the action key to pick the action. - if (!hasActionKey(request) || status === 307 || status === 308) { - return search; - } - return searchWithoutActionKey(search); + // The key goes only when the browser follows with a GET: after a 303, or after + // a 301/302 (the default) to a POST. Other redirects send the same method and + // body again, and the destination needs the key to pick the action. + const followsWithGet = + status === 303 || + ((status === undefined || status === 301 || status === 302) && request.method === 'POST'); + return hasActionKey(request) && followsWithGet ? searchWithoutActionKey(search) : search; } /** A rewrite that keeps the query keeps the action key in it too. */ diff --git a/packages/void/test/unit/pages-compile.test.ts b/packages/void/test/unit/pages-compile.test.ts index 6eaa8deaf..3ada9b372 100644 --- a/packages/void/test/unit/pages-compile.test.ts +++ b/packages/void/test/unit/pages-compile.test.ts @@ -84,9 +84,7 @@ describe('Pages metadata wiring', () => { { ...testOptions, redirectRules: [{ source: '/old', destination: '/', status: 301 }] }, pagesOptions, ); - const marker = code.indexOf( - 'app.on(["POST", "PUT", "PATCH", "DELETE"], "/", __markNamedActionRequest);', - ); + const marker = code.indexOf('app.use(__markNamedActionRequest);'); expect(marker !== -1).toBe(marked); if (marked) { expect(marker).toBeGreaterThan(code.indexOf('__routingRuntime.routingMiddleware(')); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 69e69bbe9..7c24dd3a5 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -489,20 +489,22 @@ describe('typed Pages and SSR execution', () => { expect(response.headers.get('location')).toBe(location); }); describe('the named action key around the action', () => { - function createNamedActionApp() { + function createNamedActionApp(pagePattern = '/named') { const app = new Hono(); const runtime = createRoutingRuntime(app, { nodeTarget: true }); app.use(runtime.contextMiddleware); - app.on(['POST'], '/named', markNamedActionRequest); + app.use(markNamedActionRequest); app.use(async (c, next) => (c.req.header('x-anon') ? c.redirect('/login') : next())); app.post('/api/x', (c) => c.redirect('/done')); - app.post( - '/named', + app.on( + ['POST', 'PUT'], + pagePattern, createPageMutationHandler({ ...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), go: (c: Context) => c.rewrite('/api/x'), goWithQuery: (c: Context) => c.rewrite('/api/x?q=1'), @@ -516,20 +518,29 @@ describe('typed Pages and SSR execution', () => { } it.each([ - ['/named?delete&page=2', 'middleware', '/login?page=2'], - ['/named?move&page=2', 'a 307 redirect', '/named-v2?move&page=2'], - ['/named?go&page=2', 'a rewrite', '/done?page=2'], - ['/named?goWithQuery&page=2', 'a rewrite with its own query', '/done?q=1'], - ])('POST %s through %s redirects to %s', async (path, through, location) => { + ['POST', '/named?delete&page=2', 'middleware', '/login?page=2'], + ['POST', '/named?move&page=2', 'a 307 redirect', '/named-v2?move&page=2'], + ['PUT', '/named?delete&page=2', 'a 302 redirect', '/named?delete&page=2'], + ['PUT', '/named?update&page=2', 'a 303 redirect', '/named?page=2'], + ['POST', '/named?go&page=2', 'a rewrite', '/done?page=2'], + ['POST', '/named?goWithQuery&page=2', 'a rewrite with its own query', '/done?q=1'], + ])('%s %s through %s redirects to %s', async (method, path, through, location) => { const response = await createNamedActionApp().fetch( new Request('https://app.test' + path, { - method: 'POST', + method, headers: through === 'middleware' ? { 'x-anon': '1' } : {}, }), ); expect(response.headers.get('location')).toBe(location); }); + it('leaves the query alone when another route answers a catch-all page pattern', async () => { + const response = await createNamedActionApp('/*').fetch( + new Request('https://app.test/api/x?return=/a&page=2', { method: 'POST' }), + ); + expect(response.headers.get('location')).toBe('/done?return=/a&page=2'); + }); + it('leaves the key out of the page URL when the action renders the page again', async () => { const response = await createNamedActionApp().fetch( new Request('https://app.test/named?save&page=2', { From 6f0b571992ba61992c1dfab24741701a32589633 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 17:19:35 +0800 Subject: [PATCH 6/8] fix(pages): read matched routes from the app's own Hono copy Hono's matchedRoutes() reads the request through a Symbol that each copy of Hono defines for itself. In dev the runtime's bare 'hono/route' import was a different copy from the app's Hono (imported by resolved path), so the marker read undefined and every page request returned 500. The generated app now imports matchedRoutes from the resolved 'hono/route' path, next to Hono, and passes it to createNamedActionMarker(). The runtime no longer imports 'hono/route'. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-routing.ts | 1 + packages/void/src/router/compile.ts | 9 +++++-- packages/void/src/runtime/routing-pages.ts | 27 ++++++++++++------- packages/void/src/runtime/routing.ts | 2 +- .../test/integration/queue-dispatch.test.ts | 1 + .../resource-name-migration.test.ts | 1 + .../test/integration/trigger-auth.test.ts | 1 + .../integration/typed-routing-runtime.test.ts | 1 + .../integration/websocket-middleware.test.ts | 1 + packages/void/test/unit/compile-auth.test.ts | 1 + packages/void/test/unit/compile.test.ts | 1 + .../test/unit/deploy-prerender-skip.test.ts | 1 + packages/void/test/unit/email-inbound.test.ts | 1 + packages/void/test/unit/pages-compile.test.ts | 8 +++++- .../void/test/unit/routing-runtime.test.ts | 5 ++-- .../void/test/unit/scheduled-dispatch.test.ts | 1 + 16 files changed, 46 insertions(+), 16 deletions(-) diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 0bea6d7d5..a4305d29d 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -204,6 +204,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const runtimeEmailPath = resolveFile('#self/email'); const compileOptions: CompileOptions = { honoPath: resolveFile('hono'), + honoRoutePath: resolveFile('hono/route'), runtimeEnvPath: resolveFile('#self/_env'), emailDir, projectRoot: root, diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index a4a5c8807..8db6d18c9 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -33,6 +33,8 @@ export type AuthCompileOptions = { export type CompileOptions = { /** Absolute path to the hono module entry */ honoPath: string; + /** Absolute path to `hono/route` from the same Hono copy as `honoPath` */ + honoRoutePath: string; /** Absolute path to the void/_env module */ runtimeEnvPath: string; /** Absolute path to the project's `email/` directory; enables `__emailHandler`. */ @@ -154,7 +156,7 @@ export function compileRouteTable( lines.push(`import { AsyncLocalStorage } from "node:async_hooks";`); lines.push(`import { Hono } from ${JSON.stringify(options.honoPath)};`); lines.push( - `import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, markNamedActionRequest as __markNamedActionRequest, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`, + `import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, createNamedActionMarker as __createNamedActionMarker, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`, ); if (options.nodeTarget) { lines.push(`const withRuntimeEnv = (_env, fn) => fn();`); @@ -264,7 +266,10 @@ export function compileRouteTable( // After routing rules, which ignore the action key as platform dispatch does, // and before auth and user middleware, so their c.redirect() drops it too. if (pages && scan.pages.pages.some((page) => page.methods.includes('actions'))) { - lines.push('app.use(__markNamedActionRequest);'); + lines.push( + `import { matchedRoutes as __matchedRoutes } from ${JSON.stringify(options.honoRoutePath)};`, + ); + lines.push('app.use(__createNamedActionMarker(__matchedRoutes));'); } if (auth) { diff --git a/packages/void/src/runtime/routing-pages.ts b/packages/void/src/runtime/routing-pages.ts index 8984e0161..f9ac39da5 100644 --- a/packages/void/src/runtime/routing-pages.ts +++ b/packages/void/src/runtime/routing-pages.ts @@ -1,5 +1,4 @@ import type { Context, MiddlewareHandler } from 'hono'; -import { matchedRoutes } from 'hono/route'; import { markActionKey } from '../pages/action-key.ts'; import { handlePageGet, handlePageMutation } from '../pages/protocol.ts'; import type { ClientAssetTags } from './routing-assets.ts'; @@ -32,16 +31,24 @@ const namedActionHandlers = new WeakSet(); * Runs before auth and user middleware, so a c.redirect() there leaves the * action key out too. It marks the request only when a page with named actions * is the route that will answer it, not when a broad page pattern also matches. + * + * `matchedRoutes` comes from 'hono/route' of the same Hono copy as the app: it + * reads the request through a Symbol that each copy defines for itself. */ -export const markNamedActionRequest: MiddlewareHandler = async (c, next) => { - const routes = matchedRoutes(c); - // Middleware takes (c, next); the first one-argument handler answers. - const answer = routes.slice(c.req.routeIndex + 1).find((route) => route.handler.length < 2); - if (answer && namedActionHandlers.has(answer.handler) && actionNameOf(c.req.url)) { - markActionKey(c.req.raw); - } - await next(); -}; +export function createNamedActionMarker( + matchedRoutes: (c: Context) => Array<{ handler: Function }>, +): MiddlewareHandler { + return async (c, next) => { + // Middleware takes (c, next); the first one-argument handler answers. + const answer = matchedRoutes(c) + .slice(c.req.routeIndex + 1) + .find((route) => route.handler.length < 2); + if (answer && namedActionHandlers.has(answer.handler) && actionNameOf(c.req.url)) { + markActionKey(c.req.raw); + } + await next(); + }; +} export function createPageGetHandler(options: PageRouteOptions): (c: Context) => Promise { return async (context) => { const mod = options.load ? await options.load() : { head: options.head }; diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index aa044ff77..a49e96a03 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -300,7 +300,7 @@ export { createPageGetHandler, createPageMutationHandler, createPagesFallback, - markNamedActionRequest, + createNamedActionMarker, } from './routing-pages.ts'; export function createModuleLoader(load: () => Promise, cache = true): () => Promise { diff --git a/packages/void/test/integration/queue-dispatch.test.ts b/packages/void/test/integration/queue-dispatch.test.ts index 5c33b06ef..4d25e2396 100644 --- a/packages/void/test/integration/queue-dispatch.test.ts +++ b/packages/void/test/integration/queue-dispatch.test.ts @@ -90,6 +90,7 @@ describe('generated queue dispatch', () => { routeTablePath, compileRouteTable(scan, temporary, temporary, temporary, queuesDir, null, { honoPath: import.meta.resolve('hono'), + honoRoutePath: import.meta.resolve('hono/route'), runtimeEnvPath, projectRoot: temporary, homeDir: temporary, diff --git a/packages/void/test/integration/resource-name-migration.test.ts b/packages/void/test/integration/resource-name-migration.test.ts index c0724fb53..5b7de2fec 100644 --- a/packages/void/test/integration/resource-name-migration.test.ts +++ b/packages/void/test/integration/resource-name-migration.test.ts @@ -161,6 +161,7 @@ export default defineRoom({ null, { honoPath: import.meta.resolve('hono'), + honoRoutePath: import.meta.resolve('hono/route'), runtimeEnvPath: runtimePath('env.ts'), webSocketRuntimePath: runtimePath('ws-server.ts'), projectRoot: directory, diff --git a/packages/void/test/integration/trigger-auth.test.ts b/packages/void/test/integration/trigger-auth.test.ts index f4e423e5e..3e0f08332 100644 --- a/packages/void/test/integration/trigger-auth.test.ts +++ b/packages/void/test/integration/trigger-auth.test.ts @@ -48,6 +48,7 @@ async function buildWorker(name: string, dev: boolean): Promise { routeTablePath, compileRouteTable(scan, dir, dir, cronsDir, queuesDir, null, { honoPath: import.meta.resolve('hono'), + honoRoutePath: import.meta.resolve('hono/route'), runtimeEnvPath, projectRoot: dir, homeDir: dir, diff --git a/packages/void/test/integration/typed-routing-runtime.test.ts b/packages/void/test/integration/typed-routing-runtime.test.ts index 2582f0aaa..5cbed6e9a 100644 --- a/packages/void/test/integration/typed-routing-runtime.test.ts +++ b/packages/void/test/integration/typed-routing-runtime.test.ts @@ -104,6 +104,7 @@ describe('typed routing in generated Workers', () => { null, { honoPath: fileURLToPath(import.meta.resolve('hono')), + honoRoutePath: fileURLToPath(import.meta.resolve('hono/route')), runtimeEnvPath: source('env.ts'), projectRoot: directory, homeDir: tmpdir(), diff --git a/packages/void/test/integration/websocket-middleware.test.ts b/packages/void/test/integration/websocket-middleware.test.ts index 3cab9c190..861434e3f 100644 --- a/packages/void/test/integration/websocket-middleware.test.ts +++ b/packages/void/test/integration/websocket-middleware.test.ts @@ -170,6 +170,7 @@ export default { __kind: "socket", messages: { client: schema, server: schema } routeTablePath, compileRouteTable(scan, routesDir, middlewareDir, temporary, temporary, null, { honoPath: import.meta.resolve('hono'), + honoRoutePath: import.meta.resolve('hono/route'), runtimeEnvPath, webSocketRuntimePath: join(root, 'packages/void/src/runtime/ws-server.ts'), projectRoot: temporary, diff --git a/packages/void/test/unit/compile-auth.test.ts b/packages/void/test/unit/compile-auth.test.ts index ac4b76364..d7ae8220e 100644 --- a/packages/void/test/unit/compile-auth.test.ts +++ b/packages/void/test/unit/compile-auth.test.ts @@ -12,6 +12,7 @@ const emptyScan: ScanResult = { const baseOptions = { honoPath: '/resolved/hono/index.mjs', + honoRoutePath: '/resolved/hono/route.mjs', runtimeEnvPath: '/resolved/void/runtime/env.mjs', projectRoot: '/project', diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 65423e76b..bb1fd4412 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -29,6 +29,7 @@ import { const testOptions = { honoPath: '/resolved/hono/index.mjs', + honoRoutePath: '/resolved/hono/route.mjs', runtimeEnvPath: '/resolved/void/runtime/env.mjs', projectRoot: '/project', diff --git a/packages/void/test/unit/deploy-prerender-skip.test.ts b/packages/void/test/unit/deploy-prerender-skip.test.ts index baeee8ad7..de5550f15 100644 --- a/packages/void/test/unit/deploy-prerender-skip.test.ts +++ b/packages/void/test/unit/deploy-prerender-skip.test.ts @@ -73,6 +73,7 @@ describe('hasPrerenderSources', () => { describe('__voidCollectPrerenderPaths — sources that can push a path', () => { const testOptions = { honoPath: '/resolved/hono/index.mjs', + honoRoutePath: '/resolved/hono/route.mjs', runtimeEnvPath: '/resolved/void/runtime/env.mjs', projectRoot: '/project', diff --git a/packages/void/test/unit/email-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts index 99941ebf2..efbe55b57 100644 --- a/packages/void/test/unit/email-inbound.test.ts +++ b/packages/void/test/unit/email-inbound.test.ts @@ -283,6 +283,7 @@ describe('parseEmail', () => { const COMPILE_OPTIONS = { honoPath: 'hono', + honoRoutePath: 'hono/route', runtimeEnvPath: 'void/_env', projectRoot: '/project', homeDir: '/home/test', diff --git a/packages/void/test/unit/pages-compile.test.ts b/packages/void/test/unit/pages-compile.test.ts index 3ada9b372..c5234e423 100644 --- a/packages/void/test/unit/pages-compile.test.ts +++ b/packages/void/test/unit/pages-compile.test.ts @@ -5,6 +5,7 @@ import type { ScanResult } from '../../src/router/scan.ts'; const testOptions = { honoPath: '/resolved/hono/index.mjs', + honoRoutePath: '/resolved/hono/route.mjs', runtimeEnvPath: '/resolved/void/runtime/env.mjs', projectRoot: '/project', @@ -84,8 +85,13 @@ describe('Pages metadata wiring', () => { { ...testOptions, redirectRules: [{ source: '/old', destination: '/', status: 301 }] }, pagesOptions, ); - const marker = code.indexOf('app.use(__markNamedActionRequest);'); + const marker = code.indexOf('app.use(__createNamedActionMarker(__matchedRoutes));'); expect(marker !== -1).toBe(marked); + expect( + code.includes( + 'import { matchedRoutes as __matchedRoutes } from "/resolved/hono/route.mjs";', + ), + ).toBe(marked); if (marked) { expect(marker).toBeGreaterThan(code.indexOf('__routingRuntime.routingMiddleware(')); expect(marker).toBeLessThan(code.indexOf('app.use(middleware0);')); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 7c24dd3a5..a98061088 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1,5 +1,6 @@ import { Hono, type Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; +import { matchedRoutes } from 'hono/route'; import { describe, expect, it, vi } from 'vite-plus/test'; import { createClientAssetTags, createManifestLoader } from '../../src/runtime/routing-assets.ts'; import { createErrorRenderer } from '../../src/runtime/routing-errors.ts'; @@ -7,8 +8,8 @@ import { createPageGetHandler, createPageMutationHandler, createPagesFallback, + createNamedActionMarker, createSsrFallback, - markNamedActionRequest, } from '../../src/runtime/routing-pages.ts'; import { applyAssetCachePolicy, @@ -493,7 +494,7 @@ describe('typed Pages and SSR execution', () => { const app = new Hono(); const runtime = createRoutingRuntime(app, { nodeTarget: true }); app.use(runtime.contextMiddleware); - app.use(markNamedActionRequest); + app.use(createNamedActionMarker(matchedRoutes)); app.use(async (c, next) => (c.req.header('x-anon') ? c.redirect('/login') : next())); app.post('/api/x', (c) => c.redirect('/done')); app.on( diff --git a/packages/void/test/unit/scheduled-dispatch.test.ts b/packages/void/test/unit/scheduled-dispatch.test.ts index 7f42c58e2..b37430c48 100644 --- a/packages/void/test/unit/scheduled-dispatch.test.ts +++ b/packages/void/test/unit/scheduled-dispatch.test.ts @@ -40,6 +40,7 @@ function compileNativeScheduled(jobsDir: string, load: LoadJob): Scheduled { null, { honoPath: '/hono', + honoRoutePath: '/hono/route', runtimeEnvPath: '/env', projectRoot: '/project', homeDir: '/home', From d2ce1236c09635e831e46361a8c99a6b3fd0146c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 17:23:10 +0800 Subject: [PATCH 7/8] fix(pages): tell route handlers from middleware by tag, not by arity A one-argument user middleware such as defineMiddleware(c => c.redirect('/maintenance')) looked like the answering route, so the request was left unmarked and the redirect carried the action key. Void now tags the handlers it registers for routes (API, page GET and page mutation handlers), and the marker skips everything else, whatever its arity. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/runtime/routing-pages.ts | 15 ++++++++++----- packages/void/src/runtime/routing.ts | 5 ++++- .../void/test/unit/routing-runtime.test.ts | 18 +++++++++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/void/src/runtime/routing-pages.ts b/packages/void/src/runtime/routing-pages.ts index f9ac39da5..124981c50 100644 --- a/packages/void/src/runtime/routing-pages.ts +++ b/packages/void/src/runtime/routing-pages.ts @@ -26,7 +26,13 @@ export type PageRouteOptions = { function actionNameOf(url: string): string | undefined { return new URL(url).searchParams.keys().next().value; } +const routeHandlers = new WeakSet(); const namedActionHandlers = new WeakSet(); +/** Records a handler that answers a route, so the marker can tell it from middleware. */ +export function markRouteHandler(handler: T): T { + routeHandlers.add(handler); + return handler; +} /** * Runs before auth and user middleware, so a c.redirect() there leaves the * action key out too. It marks the request only when a page with named actions @@ -39,10 +45,9 @@ export function createNamedActionMarker( matchedRoutes: (c: Context) => Array<{ handler: Function }>, ): MiddlewareHandler { return async (c, next) => { - // Middleware takes (c, next); the first one-argument handler answers. const answer = matchedRoutes(c) .slice(c.req.routeIndex + 1) - .find((route) => route.handler.length < 2); + .find((route) => routeHandlers.has(route.handler)); if (answer && namedActionHandlers.has(answer.handler) && actionNameOf(c.req.url)) { markActionKey(c.req.raw); } @@ -50,7 +55,7 @@ export function createNamedActionMarker( }; } export function createPageGetHandler(options: PageRouteOptions): (c: Context) => Promise { - return async (context) => { + return markRouteHandler(async (context: Context) => { const mod = options.load ? await options.load() : { head: options.head }; return handlePageGet( mod.loader, @@ -62,7 +67,7 @@ export function createPageGetHandler(options: PageRouteOptions): (c: Context) => options.headConfig ?? undefined, options.renderOptions, ); - }; + }); } export function createPageMutationHandler( options: PageRouteOptions, @@ -103,7 +108,7 @@ export function createPageMutationHandler( if (options.namedActions) { namedActionHandlers.add(mutationHandler); } - return mutationHandler; + return markRouteHandler(mutationHandler); } export function createPagesFallback(options: { diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index a49e96a03..6bc024995 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -12,6 +12,7 @@ import { hasActionKey, markActionKey, searchWithoutActionKey } from '../pages/ac import { VoidAssetRewriteError } from './handler.ts'; import { convertReturnValue } from './response.ts'; import { ASSET_EXTENSIONS } from './routing-assets.ts'; +import { markRouteHandler } from './routing-pages.ts'; export { createErrorRenderer } from './routing-errors.ts'; export type RoutingRule = { @@ -293,7 +294,9 @@ export function createHttpRouteHandler( load: () => Promise unknown>>, method: string, ): (c: Context) => Promise { - return async (c) => convertReturnValue(await (await load())[method](c)); + return markRouteHandler(async (c: Context) => + convertReturnValue(await (await load())[method](c)), + ); } export { diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index a98061088..ba54a45b9 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -496,7 +496,10 @@ describe('typed Pages and SSR execution', () => { app.use(runtime.contextMiddleware); app.use(createNamedActionMarker(matchedRoutes)); app.use(async (c, next) => (c.req.header('x-anon') ? c.redirect('/login') : next())); - app.post('/api/x', (c) => c.redirect('/done')); + app.post( + '/api/x', + createHttpRouteHandler(async () => ({ POST: (c: Context) => c.redirect('/done') }), 'POST'), + ); app.on( ['POST', 'PUT'], pagePattern, @@ -535,6 +538,19 @@ describe('typed Pages and SSR execution', () => { expect(response.headers.get('location')).toBe(location); }); + it('marks the request for a one-argument middleware that answers first', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use(createNamedActionMarker(matchedRoutes)); + app.use('/named', async (c) => c.redirect('/maintenance')); + app.post('/named', createPageMutationHandler({ ...pageOptions, namedActions: true })); + const response = await app.fetch( + new Request('https://app.test/named?delete&page=2', { method: 'POST' }), + ); + expect(response.headers.get('location')).toBe('/maintenance?page=2'); + }); + it('leaves the query alone when another route answers a catch-all page pattern', async () => { const response = await createNamedActionApp('/*').fetch( new Request('https://app.test/api/x?return=/a&page=2', { method: 'POST' }), From ff46d9f70f5bd9ed800ec033edc65999ad0339eb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 18:10:21 +0800 Subject: [PATCH 8/8] fix(pages): tag Void's auth, internal and WebSocket routes as route handlers With a catch-all page that has named actions, the marker skipped the untagged auth (/api/auth/*), internal (/__void/*) and WebSocket fallback handlers and marked the request for the page, so a global middleware redirect dropped the first query param of an auth callback. Every terminal route the generated app registers is now tagged. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/router/compile.ts | 30 +++++++++---------- packages/void/src/runtime/routing.ts | 1 + packages/void/test/unit/compile-auth.test.ts | 4 +-- packages/void/test/unit/compile.test.ts | 2 +- .../void/test/unit/routing-runtime.test.ts | 21 +++++++++++++ 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 8db6d18c9..9edb14652 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -156,7 +156,7 @@ export function compileRouteTable( lines.push(`import { AsyncLocalStorage } from "node:async_hooks";`); lines.push(`import { Hono } from ${JSON.stringify(options.honoPath)};`); lines.push( - `import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, createNamedActionMarker as __createNamedActionMarker, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`, + `import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, createNamedActionMarker as __createNamedActionMarker, markRouteHandler as __markRouteHandler, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`, ); if (options.nodeTarget) { lines.push(`const withRuntimeEnv = (_env, fn) => fn();`); @@ -307,13 +307,13 @@ export function compileRouteTable( } if (auth) { - lines.push('app.all("/api/auth", async (c) => {'); + lines.push('app.all("/api/auth", __markRouteHandler(async (c) => {'); lines.push(' return handleVoidAuthRequest(c, __voidAuthOptions, __voidAuthDatabase);'); - lines.push('});'); + lines.push('}));'); lines.push(''); - lines.push('app.all("/api/auth/*", async (c) => {'); + lines.push('app.all("/api/auth/*", __markRouteHandler(async (c) => {'); lines.push(' return handleVoidAuthRequest(c, __voidAuthOptions, __voidAuthDatabase);'); - lines.push('});'); + lines.push('}));'); lines.push(''); } @@ -401,7 +401,7 @@ export function compileRouteTable( // Pages handlers win for their methods; upgrades were forwarded above. for (let i = 0; i < webSockets.length; i++) { lines.push( - `app.all(${JSON.stringify(webSockets[i].pattern)}, (c) => __forwardWebSocketRequest(c.req.raw, c.env, __voidWebSocketRoutes[${i}]));`, + `app.all(${JSON.stringify(webSockets[i].pattern)}, __markRouteHandler((c) => __forwardWebSocketRequest(c.req.raw, c.env, __voidWebSocketRoutes[${i}])));`, ); } if (webSockets.length > 0) { @@ -502,7 +502,7 @@ export function compileRouteTable( lines.push(' await scheduled(controller, c.env, c.executionCtx);'); lines.push(' return c.json({ ok: true });'); lines.push('};'); - lines.push('app.post("/__void/scheduled", __scheduledHandler);'); + lines.push('app.post("/__void/scheduled", __markRouteHandler(__scheduledHandler));'); lines.push(''); } @@ -570,7 +570,7 @@ export function compileRouteTable( lines.push(' const result = await __processQueue(payload, c.env);'); lines.push(' return c.json(result);'); lines.push('};'); - lines.push('app.post("/__void/queue", __queueHandler);'); + lines.push('app.post("/__void/queue", __markRouteHandler(__queueHandler));'); lines.push(''); } @@ -716,20 +716,20 @@ export function compileRouteTable( lines.push(' return null;'); lines.push('}'); lines.push( - `app.post("/__void/migrate", (c) => __voidCreateMigrationRequestHandler(${auth ? 'true' : 'false'}, false)(c.req.raw));`, + `app.post("/__void/migrate", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(${auth ? 'true' : 'false'}, false)(c.req.raw)));`, ); lines.push( - `app.get("/__void/migrate/capabilities", (c) => __voidCreateMigrationRequestHandler(${auth ? 'true' : 'false'}, true)(c.req.raw));`, + `app.get("/__void/migrate/capabilities", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(${auth ? 'true' : 'false'}, true)(c.req.raw)));`, ); lines.push( - 'app.post("/__void/migrate/app", (c) => __voidCreateMigrationRequestHandler(false, true)(c.req.raw));', + 'app.post("/__void/migrate/app", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(false, true)(c.req.raw)));', ); lines.push( - 'app.get("/__void/migrate/app/attempt/:attemptId", (c) => __voidCreateMigrationRequestHandler(false, true)(c.req.raw));', + 'app.get("/__void/migrate/app/attempt/:attemptId", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(false, true)(c.req.raw)));', ); if (auth) { lines.push( - 'app.post("/__void/migrate/auth", (c) => __voidCreateMigrationRequestHandler(true, true)(c.req.raw));', + 'app.post("/__void/migrate/auth", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(true, true)(c.req.raw)));', ); } lines.push(''); @@ -744,14 +744,14 @@ export function compileRouteTable( `import { createBindingHandler as __createBindingHandler } from ${JSON.stringify(options.bindingHandlerPath)};`, ); lines.push('let __voidBindingApp;'); - lines.push('app.all("/__void/*", async (c) => {'); + lines.push('app.all("/__void/*", __markRouteHandler(async (c) => {'); lines.push( ' if (!__voidBindingApp) __voidBindingApp = __createBindingHandler(__getRawEnv());', ); lines.push(' const url = new URL(c.req.url);'); lines.push(' url.pathname = url.pathname.slice(7);'); lines.push(' return __voidBindingApp.fetch(new Request(url.toString(), c.req.raw));'); - lines.push('});'); + lines.push('}));'); lines.push(''); } diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 6bc024995..740c8bcb3 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -304,6 +304,7 @@ export { createPageMutationHandler, createPagesFallback, createNamedActionMarker, + markRouteHandler, } from './routing-pages.ts'; export function createModuleLoader(load: () => Promise, cache = true): () => Promise { diff --git a/packages/void/test/unit/compile-auth.test.ts b/packages/void/test/unit/compile-auth.test.ts index d7ae8220e..83126adae 100644 --- a/packages/void/test/unit/compile-auth.test.ts +++ b/packages/void/test/unit/compile-auth.test.ts @@ -65,8 +65,8 @@ describe('compileRouteTable auth integration', () => { expect(code).toContain('handleVoidAuthRequest'); expect(code).toContain('runVoidAuthMiddleware(c, next, __voidAuthOptions, __voidAuthDatabase)'); expect(code).toContain('handleVoidAuthRequest(c, __voidAuthOptions, __voidAuthDatabase)'); - expect(code).toContain('app.all("/api/auth"'); - expect(code).toContain('app.all("/api/auth/*"'); + expect(code).toContain('app.all("/api/auth", __markRouteHandler('); + expect(code).toContain('app.all("/api/auth/*", __markRouteHandler('); expect(code).not.toContain('app.post("/auth/register"'); expect(code).not.toContain('app.post("/auth/login"'); expect(code).not.toContain('app.get("/auth/me"'); diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index bb1fd4412..98e424cc7 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -172,7 +172,7 @@ describe('compileRouteTable', () => { expect(code.indexOf('app.use(middleware0)')).toBeLessThan( code.indexOf('app.all("/chat/:room"'), ); - const fallback = 'app.all("/chat/:room", (c) => __forwardWebSocketRequest'; + const fallback = 'app.all("/chat/:room", __markRouteHandler((c) => __forwardWebSocketRequest'; expect(code.indexOf('app.get("/chat/:room"')).toBeGreaterThan( code.indexOf('app.all("/chat/:room"'), ); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index ba54a45b9..d4d243527 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -10,6 +10,7 @@ import { createPagesFallback, createNamedActionMarker, createSsrFallback, + markRouteHandler, } from '../../src/runtime/routing-pages.ts'; import { applyAssetCachePolicy, @@ -558,6 +559,26 @@ describe('typed Pages and SSR execution', () => { expect(response.headers.get('location')).toBe('/done?return=/a&page=2'); }); + it('leaves the query alone when a tagged Void route answers before a catch-all page', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use(createNamedActionMarker(matchedRoutes)); + app.use(async (c, next) => (c.req.header('x-anon') ? c.redirect('/login') : next())); + app.all( + '/api/auth/*', + markRouteHandler(async () => new Response('auth')), + ); + app.post('/*', createPageMutationHandler({ ...pageOptions, namedActions: true })); + const response = await app.fetch( + new Request('https://app.test/api/auth/callback?code=1&state=2', { + method: 'POST', + headers: { 'x-anon': '1' }, + }), + ); + expect(response.headers.get('location')).toBe('/login?code=1&state=2'); + }); + it('leaves the key out of the page URL when the action renders the page again', async () => { const response = await createNamedActionApp().fetch( new Request('https://app.test/named?save&page=2', {