From e84739cbf641aca445d37736cc8a1cd4f2c400d3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 27 Oct 2026 14:20:25 +0800 Subject: [PATCH 1/2] fix(router): reject HEAD exports in route files A route file that exported HEAD made the generated route module call app.head(), which Hono does not have. The module threw on load, so every request to the app failed in dev and in the deployed Worker. Hono already answers HEAD requests with the GET handler, so a HEAD handler could never run anyway. Route scanning now stops with a clear error that names the file and says to remove the HEAD export. The server routing guide notes that GET also answers HEAD. Co-Authored-By: Claude Opus 5.5 --- docs/guide/server-routing.md | 2 ++ packages/void/src/router/scan.ts | 14 ++++++++++---- packages/void/test/unit/scan.test.ts | 8 ++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/guide/server-routing.md b/docs/guide/server-routing.md index 8ee13e8f9..4ecbbe2bc 100644 --- a/docs/guide/server-routing.md +++ b/docs/guide/server-routing.md @@ -61,6 +61,8 @@ export const POST = defineHandler(async (c) => { }); ``` +`GET` also answers `HEAD` requests, so route files can't export `HEAD`. + See [Database](./database.md) for queries with D1, PostgreSQL, or MySQL. ### JSX handlers diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts index bfd07780e..9de82760b 100644 --- a/packages/void/src/router/scan.ts +++ b/packages/void/src/router/scan.ts @@ -88,10 +88,16 @@ export async function scanRoutes( environment: options.environment, }); const routes = inventory.routes - .map((route) => ({ - ...route, - methods: detectHttpExports(join(paths.routesDir, route.filePath)), - })) + .map((route) => { + const methods = detectHttpExports(join(paths.routesDir, route.filePath)); + // Hono runs the GET handler for HEAD requests and has no way to register a HEAD one. + if (methods.includes('HEAD')) { + throw new Error( + `routes: '${route.filePath}' exports HEAD. HEAD requests are answered by the GET handler; remove the HEAD export.`, + ); + } + return { ...route, methods }; + }) .sort(compareRouteSpecificity); return { ...inventory, routes, pages }; } diff --git a/packages/void/test/unit/scan.test.ts b/packages/void/test/unit/scan.test.ts index d6fad45e2..a79cb4fa8 100644 --- a/packages/void/test/unit/scan.test.ts +++ b/packages/void/test/unit/scan.test.ts @@ -66,6 +66,14 @@ describe('scanRoutes', () => { expect(result.jobs).toEqual([]); }); + it('rejects a HEAD export because GET answers HEAD requests', async () => { + createFile('routes/api/ping.ts', 'export const GET = () => {}\nexport const HEAD = () => {}'); + + await expect(scanRoutes(TMP)).rejects.toThrow( + "routes: 'api/ping.ts' exports HEAD. HEAD requests are answered by the GET handler; remove the HEAD export.", + ); + }); + it.each( ['tsx', 'jsx'].flatMap((ext) => ([undefined, 'dev', 'prod'] as const).map((environment) => ({ ext, environment })), From 8665f7799030bf8083593e995028fec220926b91 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 27 Oct 2026 14:54:08 +0800 Subject: [PATCH 2/2] fix(router): give each route and page file its own loader name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated route module named each loader after its file path with every character other than an ASCII letter or digit turned into "_". Two route files such as api/user-list.ts and api/user_list.ts, or two page files whose non-ASCII names have the same length (blog/你好.md and blog/世界.md), produced the same const twice. The module then failed to parse with "Identifier has already been declared", which broke dev and build. Loader and markdown head names now come from one name map per kind. A name that would repeat gets a numeric suffix, and the prerender path collector uses the same names as the page routes. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/router/compile.ts | 46 +++++++++++++++-- packages/void/test/unit/compile.test.ts | 32 ++++++++++++ packages/void/test/unit/pages-compile.test.ts | 50 +++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 891022a68..7b0cd545f 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -123,6 +123,38 @@ export type PagesCompileOptions = { headConfig?: import('../pages/head.ts').HeadConfig; }; +/** + * Returns a lookup that gives each key its own identifier suffix. The suffix + * keeps the key's ASCII letters and digits; keys that clean up to the same + * text (user-list.ts and user_list.ts, or two CJK names of one length) get a + * counter so the generated consts never clash. + */ +function createIdentifierNames(): (key: string) => string { + const byKey = new Map(); + const used = new Set(); + return (key) => { + const known = byKey.get(key); + if (known !== undefined) { + return known; + } + let base = ''; + for (const char of key.split('')) { + const alphanumeric = + (char >= '0' && char <= '9') || + (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z'); + base += alphanumeric ? char : '_'; + } + let name = base; + for (let n = 2; used.has(name); n++) { + name = `${base}_${n}`; + } + used.add(name); + byKey.set(key, name); + return name; + }; +} + /** * Generate the code for the virtual route table module (virtual:void-routes). * This module creates a Hono app with: @@ -322,9 +354,10 @@ export function compileRouteTable( // Register routes with lazy-loaded handlers (shared loader per file) const seenFiles = new Set(); + const routeNames = createIdentifierNames(); for (const route of scan.routes) { const filePath = `${routesDir}/${route.filePath}`; - const loaderName = `_load_${route.filePath.replace(/[^a-zA-Z0-9]/g, '_')}`; + const loaderName = `_load_${routeNames(route.filePath)}`; if (!seenFiles.has(route.filePath)) { seenFiles.add(route.filePath); @@ -396,8 +429,9 @@ export function compileRouteTable( } // Pages mode — generate page route handlers + const pageNames = createIdentifierNames(); if (pages && scan.pages.pages.length > 0) { - compilePageRoutes(lines, scan, pages, options); + compilePageRoutes(lines, scan, pages, options, pageNames); } // Ordinary HTTP hooks are fallbacks for WebSocket paths. Explicit API and @@ -417,7 +451,7 @@ export function compileRouteTable( ); } - compilePrerenderCollector(lines, scan, ssr, options); + compilePrerenderCollector(lines, scan, ssr, options, pageNames); if (ssr) { lines.push( @@ -804,6 +838,7 @@ function compilePrerenderCollector( scan: ScanResult, ssr: SsrCompileOptions | null, options: CompileOptions, + pageNames: (componentId: string) => string, ): void { const staticPaths = scan.pages.pages .filter((page) => page.shouldPrerender && page.params.length === 0 && !page.catchAll) @@ -840,7 +875,7 @@ function compilePrerenderCollector( } for (const page of dynamicPages) { - const loaderName = `_pload_${page.componentId.replace(/[^a-zA-Z0-9]/g, '_')}`; + const loaderName = `_pload_${pageNames(page.componentId)}`; lines.push(` {`); lines.push(` const mod = await ${loaderName}();`); lines.push(` if (typeof mod.getPrerenderPaths === "function") {`); @@ -886,6 +921,7 @@ function compilePageRoutes( scan: ScanResult, pages: PagesCompileOptions, options: CompileOptions, + pageNames: (componentId: string) => string, ): void { // Import protocol helpers and renderer lines.push(`import { renderPage } from "virtual:void-pages-renderer";`); @@ -971,7 +1007,7 @@ function compilePageRoutes( // Generate lazy loaders and route registrations for each page const seenServerFiles = new Set(); for (const page of scan.pages.pages) { - const safeId = page.componentId.replace(/[^a-zA-Z0-9]/g, '_'); + const safeId = pageNames(page.componentId); // Determine server module path let serverImportPath: string | null = null; diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 98e424cc7..bf63d2ab3 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -1,6 +1,7 @@ import { compilePattern as sharedCompilePattern, matchPatternCaptures } from '@void/edge'; /* oxlint-disable typescript-eslint/no-explicit-any -- these tests wire the void context augmentations by hand (`c.rewrite`, `c.originalUrl`, `c.isRewritten`) to exercise generated code paths directly; `as any` is the least noisy way to assign/read those members without re-declaring the full Hono context shape per test. */ import { Hono } from 'hono'; +import { parseSync } from 'vite'; import { describe, expect, it } from 'vite-plus/test'; import { compileRouteTable, @@ -1383,6 +1384,37 @@ describe('typed runtime metadata wiring', () => { expect(dev).not.toContain('function __voidAnnotateRouting'); expect(dev).not.toContain('c.rewrite ='); }); + it('gives route files that differ only in punctuation their own loaders', () => { + const route = (filePath: string, pattern: string) => ({ + pattern, + filePath, + params: [], + catchAll: false, + env: null, + methods: ['GET'], + }); + const code = compile( + { dev: true }, + { + ...emptyScan, + routes: [ + route('api/user-list.ts', '/api/user-list'), + route('api/user_list.ts', '/api/user_list'), + ], + }, + ); + const parsed = parseSync('routes.js', code, { sourceType: 'module', showSemanticErrors: true }); + expect(parsed.errors.map((error) => error.message)).toEqual([]); + expect(code).toContain( + 'const _load_api_user_list_ts = __createModuleLoader(() => import("/routes/api/user-list.ts"), false)', + ); + expect(code).toContain( + 'const _load_api_user_list_ts_2 = __createModuleLoader(() => import("/routes/api/user_list.ts"), false)', + ); + expect(code).toContain( + 'app.get("/api/user_list", __createHttpRouteHandler(_load_api_user_list_ts_2, "GET"))', + ); + }); it('keeps authored rule origins in dev and omits them in production and managed dispatch', () => { const rules = [{ source: '/old', destination: '/new', status: 301, origin: 'config' }]; const dev = compile({ dev: true, redirectRules: rules, fallbackRules: rules }); diff --git a/packages/void/test/unit/pages-compile.test.ts b/packages/void/test/unit/pages-compile.test.ts index c5234e423..9ad59a0a4 100644 --- a/packages/void/test/unit/pages-compile.test.ts +++ b/packages/void/test/unit/pages-compile.test.ts @@ -1,3 +1,4 @@ +import { parseSync } from 'vite'; import { describe, expect, it } from 'vite-plus/test'; import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; import { compileRouteTable, type PagesCompileOptions } from '../../src/router/compile.ts'; @@ -142,4 +143,53 @@ describe('Pages metadata wiring', () => { expect(code).toContain('"extraEntryId":"virtual:md"'); expect(code).toContain('assets: getIslandsAssetTags'); }); + it('gives non-ASCII page names of the same length their own loaders and head functions', () => { + const mdPage = (name: string): PageDefinition => ({ + ...page, + pattern: `/blog/${name}`, + componentPath: `blog/${name}.md`, + componentId: `blog/${name}`, + serverPath: null, + methods: [], + metadata: { title: name }, + }); + const serverPage = (name: string, prerender: boolean): PageDefinition => ({ + ...page, + pattern: `/shop/:id/${name}`, + params: ['id'], + componentPath: `shop/[id]/${name}.vue`, + componentId: `shop/[id]/${name}`, + serverPath: `shop/[id]/${name}.server.ts`, + methods: ['loader'], + shouldPrerender: prerender, + hasGetPrerenderPaths: prerender, + }); + const code = compileRouteTable( + makeScan({ + pages: [mdPage('你好'), mdPage('世界'), serverPage('书', false), serverPage('画', true)], + layouts: [], + namedLayouts: [], + }), + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + pagesOptions, + ); + const parsed = parseSync('routes.js', code, { sourceType: 'module', showSemanticErrors: true }); + expect(parsed.errors.map((error) => error.message)).toEqual([]); + expect(code).toContain('const __mdHead_blog___ = () => ({"title":"你好"});'); + expect(code).toContain('const __mdHead_blog____2 = () => ({"title":"世界"});'); + expect(code).toContain( + 'const _pload_shop__id____2 = __createModuleLoader(() => import("/project/pages/shop/[id]/画.server.ts"));', + ); + expect(code).toContain( + 'app.get("/shop/:id/画", __createPageGetHandler({ load: _pload_shop__id____2, ', + ); + // Only the second page is prerendered; the collector must still use its loader's name. + expect(code).toContain('const mod = await _pload_shop__id____2();'); + expect(code).not.toContain('await _pload_shop__id___()'); + }); });