diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts index 554d7fb92..dfb36532a 100644 --- a/packages/void/src/router/scan.ts +++ b/packages/void/src/router/scan.ts @@ -46,6 +46,27 @@ 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; +} + /** * Scan routes/ and middleware/ directories and return a structured manifest. * Files starting with _ are ignored. @@ -77,13 +98,8 @@ export async function scanRoutes( route.methods = detectHttpExports(join(routesDir, f)); return route; }) - // Sort: static routes before dynamic, catch-all last - .sort((a, b) => { - if (a.catchAll !== b.catchAll) { - return a.catchAll ? 1 : -1; - } - return a.pattern.localeCompare(b.pattern); - }); + // Sort by specificity: static routes before dynamic params, catch-all last. + .sort(compareRouteSpecificity); // Parse middleware, sorted by numeric prefix const middleware = parseMiddlewareDefinitions(middlewareFiles); @@ -120,10 +136,5 @@ export function scanRoutePatternsSync( .filter((f) => !isWebSocketPatternFile(f)) .filter((f) => !f.split('/').some((s) => s.startsWith('_'))) .map((f) => parseRouteFilename(f)) - .sort((a, b) => { - if (a.catchAll !== b.catchAll) { - return a.catchAll ? 1 : -1; - } - return a.pattern.localeCompare(b.pattern); - }); + .sort(compareRouteSpecificity); } diff --git a/packages/void/test/unit/scan.test.ts b/packages/void/test/unit/scan.test.ts index 69190cb34..05b9fb6fb 100644 --- a/packages/void/test/unit/scan.test.ts +++ b/packages/void/test/unit/scan.test.ts @@ -95,6 +95,26 @@ describe('scanRoutes', () => { expect(result.routes[1].pattern).toBe('/*'); }); + it('sorts a static route before a dynamic sibling', async () => { + // Static '/users/new' must register before '/users/:id' (Hono resolves overlap by registration order). + createFile('routes/users/[id].ts', 'export const GET = () => {}'); + createFile('routes/users/new.ts', 'export const GET = () => {}'); + + const result = await scanRoutes(TMP); + expect(result.routes.map((route) => route.pattern)).toEqual(['/users/new', '/users/:id']); + }); + + it('prefers a static segment over a dynamic one at the same depth', async () => { + createFile('routes/posts/[slug]/edit.ts', 'export const GET = () => {}'); + createFile('routes/posts/featured/edit.ts', 'export const GET = () => {}'); + + const result = await scanRoutes(TMP); + expect(result.routes.map((route) => route.pattern)).toEqual([ + '/posts/featured/edit', + '/posts/:slug/edit', + ]); + }); + it('discovers cron jobs from crons directory', async () => { createFile('routes/api/hello.ts'); createFile('crons/sync.ts', 'export const cron = "0 * * * *"\nexport default () => {}');