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..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. 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 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/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/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 fefb15827..9edb14652 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, 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();`); @@ -261,6 +263,15 @@ 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 && scan.pages.pages.some((page) => page.methods.includes('actions'))) { + lines.push( + `import { matchedRoutes as __matchedRoutes } from ${JSON.stringify(options.honoRoutePath)};`, + ); + lines.push('app.use(__createNamedActionMarker(__matchedRoutes));'); + } + if (auth) { lines.push('app.use(async (c, next) => {'); lines.push(' return runVoidAuthMiddleware(c, next, __voidAuthOptions, __voidAuthDatabase);'); @@ -296,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(''); } @@ -390,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) { @@ -491,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(''); } @@ -559,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(''); } @@ -705,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(''); @@ -733,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-pages.ts b/packages/void/src/runtime/routing-pages.ts index 180dae577..124981c50 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,14 +23,39 @@ 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; +} +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 + * 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 function createNamedActionMarker( + matchedRoutes: (c: Context) => Array<{ handler: Function }>, +): MiddlewareHandler { + return async (c, next) => { + const answer = matchedRoutes(c) + .slice(c.req.routeIndex + 1) + .find((route) => routeHandlers.has(route.handler)); + 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) => { + return markRouteHandler(async (context: Context) => { const mod = options.load ? await options.load() : { head: options.head }; return handlePageGet( mod.loader, @@ -41,16 +67,14 @@ export function createPageGetHandler(options: PageRouteOptions): (c: Context) => options.headConfig ?? undefined, options.renderOptions, ); - }; + }); } 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 - ? 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 +91,7 @@ export function createPageMutationHandler( }); } if (actionName) { - actionQueryRequests.add(context.req.raw); + markActionKey(context.req.raw); } return handlePageMutation( handler, @@ -81,6 +105,10 @@ export function createPageMutationHandler( options.renderOptions, ); }; + if (options.namedActions) { + namedActionHandlers.add(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 a717bfcde..740c8bcb3 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -8,10 +8,11 @@ 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'; +import { markRouteHandler } from './routing-pages.ts'; export { createErrorRenderer } from './routing-errors.ts'; export type RoutingRule = { @@ -119,6 +120,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 +171,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 +179,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 +252,22 @@ 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)) { - 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++; + // 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. */ +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 { @@ -290,13 +294,17 @@ 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 { createPageGetHandler, createPageMutationHandler, createPagesFallback, + createNamedActionMarker, + markRouteHandler, } 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..83126adae 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', @@ -64,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 65423e76b..98e424cc7 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', @@ -171,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/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-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..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', @@ -61,6 +62,42 @@ 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 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.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);')); + } + }, + ); 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/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. */ diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index d7f04a46f..d4d243527 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,7 +8,9 @@ import { createPageGetHandler, createPageMutationHandler, createPagesFallback, + createNamedActionMarker, createSsrFallback, + markRouteHandler, } from '../../src/runtime/routing-pages.ts'; import { applyAssetCachePolicy, @@ -487,6 +490,105 @@ 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(pagePattern = '/named') { + 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.post( + '/api/x', + createHttpRouteHandler(async () => ({ POST: (c: Context) => c.redirect('/done') }), 'POST'), + ); + 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'), + save: () => undefined, + }, + }), + namedActions: true, + }), + ); + return app; + } + + it.each([ + ['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, + headers: through === 'middleware' ? { 'x-anon': '1' } : {}, + }), + ); + 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' }), + ); + 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', { + 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( 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',