diff --git a/docs/guide/server-routing.md b/docs/guide/server-routing.md index 27995c454..221fe0e98 100644 --- a/docs/guide/server-routing.md +++ b/docs/guide/server-routing.md @@ -14,6 +14,20 @@ Create route handlers in `routes/` using `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, o Dynamic segments use brackets: `[id]` becomes a route parameter and `[...slug]` becomes a catch-all. Prefix files or directories with `_` or `.` to exclude them from routing; `.well-known` remains available for standard discovery endpoints. Directories wrapped in parentheses like `(admin)` are route groups. They help organize files without changing the URL. +### Overlapping routes + +When several routes match a URL, the most specific one handles it. Void compares routes segment by segment, from the left, and the first segment that differs decides: a static segment beats `[param]`, and `[param]` beats `[...slug]`. Catch-all routes rank after all other routes. + +``` +routes/ + users/[id].ts → handles /users/about (users beats [lang]) + [lang]/about.ts → handles /fr/about (about beats [slug]) + [lang]/[slug].ts → handles /fr/contact + [...path].ts → handles URLs no other route matches +``` + +Parameter names don't affect the order. Routes that differ only in their parameter names, such as `[id].ts` and `[slug].ts`, match the same URLs, so keep only one of them. + ### Environment-only routes Add a `.dev` or `.prod` suffix before the extension to include a route in only one environment: diff --git a/packages/void/src/pages/manifest.ts b/packages/void/src/pages/manifest.ts index b0757b123..0a4546585 100644 --- a/packages/void/src/pages/manifest.ts +++ b/packages/void/src/pages/manifest.ts @@ -1,3 +1,4 @@ +import { routeSegmentKind } from '../router/route-order.ts'; import { resolveLayoutIds, toLayoutId } from './layout-resolution.ts'; import type { PageScanResult } from './types.ts'; @@ -106,19 +107,19 @@ const REGEX_SYNTAX = '.*+?^${}()|[]\\'; /** The capture group for `*`, `:name{.+}` and `:name`; other segments match as written. */ function captureGroup(segment: string): string | undefined { - if (segment === '*') { - return '(.*)'; + switch (routeSegmentKind(segment)) { + case 'rest': + return segment === '*' ? '(.*)' : '(.+)'; + case 'param': + return '([^\\/]+)'; + case 'static': + return undefined; } - if (segment.length < 2 || segment[0] !== ':') { - return undefined; - } - const name = segment.endsWith('{.+}') ? segment.slice(1, -4) : ''; - return name && !name.includes('{') && !name.includes('}') ? '(.+)' : '([^\\/]+)'; } /** Whether `patternToRegex` matches only the pattern's own text. */ function isStaticPattern(pattern: string): boolean { - return pattern.split('/').every((segment) => captureGroup(segment) === undefined); + return pattern.split('/').every((segment) => routeSegmentKind(segment) === 'static'); } /** The RegExp literal a page pattern matches with, the same on the client and the server. */ diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index db80be4c8..aa3e71b30 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -9,6 +9,7 @@ import { parseRouteFilename, type RouteEnvironment, } from '../router/parse-filename.ts'; +import { compareRouteSpecificity } from '../router/route-order.ts'; import { bindingNames } from '../shared/ast-bindings.ts'; import { isIdentifier, @@ -407,18 +408,7 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis }); } - // Sort: static before dynamic, catch-all last - pages.sort((a, b) => { - if (a.catchAll !== b.catchAll) { - return a.catchAll ? 1 : -1; - } - const aDynamic = a.pattern.includes(':'); - const bDynamic = b.pattern.includes(':'); - if (aDynamic !== bDynamic) { - return aDynamic ? 1 : -1; - } - return a.pattern.localeCompare(b.pattern); - }); + pages.sort(compareRouteSpecificity); if (options?.checkRoutes !== false) { // At most one page per route in each environment. A scan for every diff --git a/packages/void/src/router/route-order.ts b/packages/void/src/router/route-order.ts new file mode 100644 index 000000000..f8b527082 --- /dev/null +++ b/packages/void/src/router/route-order.ts @@ -0,0 +1,64 @@ +/** How a pattern segment matches: as written, one URL segment, or the rest of the path. */ +export type RouteSegmentKind = 'static' | 'param' | 'rest'; + +/** + * Classify one segment of a route pattern. `*` and `:name{.+}` match the rest of the path, + * `:name` matches one segment, and anything else matches as written. Pages match with the + * same rules on the client (see `patternToRegex`), so ranking and matching agree. + */ +export function routeSegmentKind(segment: string): RouteSegmentKind { + if (segment === '*') { + return 'rest'; + } + if (segment.length < 2 || segment[0] !== ':') { + return 'static'; + } + const name = segment.endsWith('{.+}') ? segment.slice(1, -4) : ''; + return name && !name.includes('{') && !name.includes('}') ? 'rest' : 'param'; +} + +const SEGMENT_RANK: Record = { static: 0, param: 1, rest: 2 }; + +/** + * Order routes so that, of any two that match the same URL, the more specific one comes first. + * Hono and the Pages client matcher both use the first match. + * + * Catch-all routes come last and fully static routes first. Otherwise the first segment that + * differs decides: static before `:param` before catch-all. Parameter names never decide, so + * `/:lang/about` ranks ahead of `/:category/:item`. Two different static segments can't match + * the same URL, so their text only keeps the order stable. + */ +export function compareRouteSpecificity( + a: { pattern: string; catchAll: boolean }, + b: { pattern: string; catchAll: boolean }, +): number { + if (a.catchAll !== b.catchAll) { + return a.catchAll ? 1 : -1; + } + const aSegments = a.pattern.split('/'); + const bSegments = b.pattern.split('/'); + const aKinds = aSegments.map(routeSegmentKind); + const bKinds = bSegments.map(routeSegmentKind); + // A static route never shares a URL with a more specific one, and Pages looks + // up the leading run of static routes exactly. + const aStatic = aKinds.every((kind) => kind === 'static'); + const bStatic = bKinds.every((kind) => kind === 'static'); + if (aStatic !== bStatic) { + return aStatic ? -1 : 1; + } + const shared = Math.min(aSegments.length, bSegments.length); + for (let i = 0; i < shared; i++) { + const rank = SEGMENT_RANK[aKinds[i]] - SEGMENT_RANK[bKinds[i]]; + if (rank !== 0) { + return rank; + } + if (aKinds[i] === 'static' && aSegments[i] !== bSegments[i]) { + return compareText(aSegments[i], bSegments[i]); + } + } + return aSegments.length - bSegments.length || compareText(a.pattern, b.pattern); +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts index 9de82760b..9c2bfa8e7 100644 --- a/packages/void/src/router/scan.ts +++ b/packages/void/src/router/scan.ts @@ -14,6 +14,7 @@ import { type RouteDefinition, type RouteEnvironment, } from './parse-filename.ts'; +import { compareRouteSpecificity } from './route-order.ts'; import { isWebSocketPatternFile, type WebSocketRouteDefinition } from './websocket.ts'; export type ScanResult = { @@ -47,27 +48,6 @@ function parseMiddlewareDefinitions(files: Array): Array a.order - b.order); } -// Order by specificity (static < dynamic param < catch-all, depth-first); Hono resolves overlap by registration order. -function compareRouteSpecificity(a: RouteDefinition, b: RouteDefinition): number { - if (a.catchAll !== b.catchAll) { - return a.catchAll ? 1 : -1; - } - const aSegments = a.pattern.split('/'); - const bSegments = b.pattern.split('/'); - const shared = Math.min(aSegments.length, bSegments.length); - for (let i = 0; i < shared; i++) { - const aDynamic = aSegments[i].startsWith(':') || aSegments[i] === '*'; - const bDynamic = bSegments[i].startsWith(':') || bSegments[i] === '*'; - if (aDynamic !== bDynamic) { - return aDynamic ? 1 : -1; - } - if (aSegments[i] !== bSegments[i]) { - return aSegments[i].localeCompare(bSegments[i]); - } - } - return aSegments.length - bSegments.length; -} - /** Add pages and HTTP methods to the inventories already resolved for this application phase. */ export async function scanRoutes( root: string, diff --git a/packages/void/src/router/websocket.ts b/packages/void/src/router/websocket.ts index b0a5bdd67..9c8cb8038 100644 --- a/packages/void/src/router/websocket.ts +++ b/packages/void/src/router/websocket.ts @@ -18,6 +18,7 @@ import { type RouteDefinition, type RouteEnvironment, } from './parse-filename.ts'; +import { compareRouteSpecificity } from './route-order.ts'; const WEBSOCKET_ROUTE_RE = new RegExp( `(?:\\.(?:dev|prod))?\\.ws\\.(?:${ROUTE_EXTENSIONS.join('|')})$`, @@ -148,12 +149,7 @@ function parseWebSocketFiles( isRouteEnabledForEnvironment(parseWebSocketFilename(filePath), environment), ) .map((filePath) => buildWebSocketRoute(root, filePath, paths)) - .sort((a, b) => { - if (a.catchAll !== b.catchAll) { - return a.catchAll ? 1 : -1; - } - return a.pattern.localeCompare(b.pattern); - }); + .sort(compareRouteSpecificity); const byClass = new Map>(); for (const route of routes) { const previous = byClass.get(route.className) ?? []; diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index e4f1a1d66..43a2beac3 100644 --- a/packages/void/test/unit/pages-scan.test.ts +++ b/packages/void/test/unit/pages-scan.test.ts @@ -364,6 +364,39 @@ export { handler as action, type actions };`, expect(patterns.indexOf('/:id')).toBeLessThan(patterns.indexOf('/:slug{.+}')); }); + it('matches overlapping dynamic pages by their first differing segment', async () => { + for (const file of [ + '[lang]/about.tsx', + '[lang]/[slug].tsx', + 'users/[id].tsx', + 'users/[id]/edit.tsx', + 'users/new/[tab].tsx', + 'docs/[...path].tsx', + '[lang]/[...path].tsx', + ]) { + writeFile(file, 'export default function Page() { return null; }'); + } + + const scan = await scanPages(TMP); + // The server registers pages in this order, and the client matcher keeps it. + const { matchRoute } = new Function( + `${generateComponentManifest(scan, '/project/pages')}\nreturn { matchRoute };`, + )() as { matchRoute: (path: string) => string | null }; + expect( + Object.fromEntries( + ['/users/about', '/users/new/edit', '/fr/about', '/docs/a/b', '/fr/team/jobs'].map( + (url) => [url, matchRoute(url)], + ), + ), + ).toEqual({ + '/users/about': 'users/[id]', + '/users/new/edit': 'users/new/[tab]', + '/fr/about': '[lang]/about', + '/docs/a/b': 'docs/[...path]', + '/fr/team/jobs': '[lang]/[...path]', + }); + }); + it('handles .tsx components', async () => { writeFile('dashboard.tsx', 'export default function Dashboard() { return
; }'); const result = await scanPages(TMP); diff --git a/packages/void/test/unit/route-order.test.ts b/packages/void/test/unit/route-order.test.ts new file mode 100644 index 000000000..b35d78f4a --- /dev/null +++ b/packages/void/test/unit/route-order.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { patternToRegex } from '../../src/pages/manifest.ts'; +import { compareRouteSpecificity, routeSegmentKind } from '../../src/router/route-order.ts'; + +function route(pattern: string) { + return { + pattern, + catchAll: pattern.split('/').some((segment) => routeSegmentKind(segment) === 'rest'), + }; +} + +function matches(pattern: string, url: string): boolean { + return new RegExp(patternToRegex(pattern).slice(1, -1)).test(url); +} + +describe('compareRouteSpecificity', () => { + it.each([ + // A static segment beats a dynamic one, wherever it is + ['/users/:id', '/:lang/about', '/users/about'], + ['/users/new/:tab', '/users/:id/edit', '/users/new/edit'], + ['/:lang/about', '/:lang/:slug', '/en/about'], + ['/about', '/:slug', '/about'], + // Parameter names never decide + ['/:lang/about', '/:category/:item', '/fr/about'], + ['/:z/about', '/:a/:b', '/fr/about'], + // A plain parameter beats a catch-all, and catch-alls come last + ['/docs/:path{.+}', '/:slug{.+}', '/docs/intro'], + ['/:lang/:path{.+}', '/:slug{.+}', '/fr/team/jobs'], + ['/:lang/:slug', '/docs/:path{.+}', '/docs/intro'], + ['/files/:name', '/files/*', '/files/a'], + ])('ranks %s ahead of %s, which both match %s', (specific, general, url) => { + expect(matches(specific, url)).toBe(true); + expect(matches(general, url)).toBe(true); + for (const routes of [ + [route(specific), route(general)], + [route(general), route(specific)], + ]) { + expect(routes.sort(compareRouteSpecificity).map((r) => r.pattern)).toEqual([ + specific, + general, + ]); + } + }); + + it('gives the same order for any input order', () => { + const patterns = [ + '/', + '/zzz', + '/files/a*b', + '/:lang', + '/:lang/about', + '/:lang/:slug', + '/:category/:item', + '/users/:id', + '/users/:id/edit', + '/users/new/:tab', + '/docs/:path{.+}', + '/:lang/:path{.+}', + '/:slug{.+}', + '/*', + ]; + const expected = [ + // Fully static routes first, so Pages can look them up exactly + '/', + '/files/a*b', + '/zzz', + '/users/new/:tab', + '/users/:id', + '/users/:id/edit', + '/:lang', + '/:lang/about', + '/:category/:item', + '/:lang/:slug', + // Catch-all routes last + '/docs/:path{.+}', + '/:lang/:path{.+}', + '/*', + '/:slug{.+}', + ]; + for (let shift = 0; shift < patterns.length; shift++) { + const rotated = [...patterns.slice(shift), ...patterns.slice(0, shift)]; + expect(rotated.reverse().map(route).sort(compareRouteSpecificity)).toEqual( + expected.map(route), + ); + } + }); +}); diff --git a/packages/void/test/unit/scan.test.ts b/packages/void/test/unit/scan.test.ts index a79cb4fa8..297ccc556 100644 --- a/packages/void/test/unit/scan.test.ts +++ b/packages/void/test/unit/scan.test.ts @@ -250,6 +250,19 @@ describe('scanRoutes', () => { ]); }); + it('ranks routes by segment kind, not parameter names', async () => { + createFile('routes/[category]/[item].ts'); + createFile('routes/[lang]/about.ts'); + createFile('routes/[lang]/[slug].ts'); + + const result = await scanRoutes(TMP); + expect(result.routes.map((route) => route.pattern)).toEqual([ + '/:lang/about', + '/:category/:item', + '/:lang/:slug', + ]); + }); + it('discovers cron jobs from crons directory', async () => { createFile('routes/api/hello.ts'); createFile('crons/sync.ts', 'export const cron = "0 * * * *"\nexport default () => {}'); diff --git a/packages/void/test/unit/websocket-scan.test.ts b/packages/void/test/unit/websocket-scan.test.ts index f9ffd4f1a..7a1940636 100644 --- a/packages/void/test/unit/websocket-scan.test.ts +++ b/packages/void/test/unit/websocket-scan.test.ts @@ -229,4 +229,14 @@ describe('WebSocket resource names', () => { expect(() => scanWebSocketRoutesSync(root)).toThrow('duplicate generated name'); expect(scanWebSocketRoutesSync(root, undefined, 'prod')).toHaveLength(1); }); + + it('orders a static WebSocket route ahead of a dynamic sibling', () => { + const root = createRoot(); + createRoute(root, 'chat/[room].ws.ts', 'export default defineRoom({})'); + createRoute(root, 'chat/lobby.ws.ts', 'export default defineRoom({})'); + expect(scanWebSocketRoutesSync(root).map((route) => route.pattern)).toEqual([ + '/chat/lobby', + '/chat/:room', + ]); + }); });