Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/server-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 41 additions & 5 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
const used = new Set<string>();
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:
Expand Down Expand Up @@ -322,9 +354,10 @@ export function compileRouteTable(

// Register routes with lazy-loaded handlers (shared loader per file)
const seenFiles = new Set<string>();
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);
Expand Down Expand Up @@ -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
Expand All @@ -417,7 +451,7 @@ export function compileRouteTable(
);
}

compilePrerenderCollector(lines, scan, ssr, options);
compilePrerenderCollector(lines, scan, ssr, options, pageNames);

if (ssr) {
lines.push(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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") {`);
Expand Down Expand Up @@ -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";`);
Expand Down Expand Up @@ -971,7 +1007,7 @@ function compilePageRoutes(
// Generate lazy loaders and route registrations for each page
const seenServerFiles = new Set<string>();
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;
Expand Down
14 changes: 10 additions & 4 deletions packages/void/src/router/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
32 changes: 32 additions & 0 deletions packages/void/test/unit/compile.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 });
Expand Down
50 changes: 50 additions & 0 deletions packages/void/test/unit/pages-compile.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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___()');
});
});
8 changes: 8 additions & 0 deletions packages/void/test/unit/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand Down
Loading