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
14 changes: 14 additions & 0 deletions docs/guide/server-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 9 additions & 8 deletions packages/void/src/pages/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { routeSegmentKind } from '../router/route-order.ts';
import { resolveLayoutIds, toLayoutId } from './layout-resolution.ts';
import type { PageScanResult } from './types.ts';

Expand Down Expand Up @@ -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. */
Expand Down
14 changes: 2 additions & 12 deletions packages/void/src/pages/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions packages/void/src/router/route-order.ts
Original file line number Diff line number Diff line change
@@ -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<RouteSegmentKind, number> = { 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;
}
22 changes: 1 addition & 21 deletions packages/void/src/router/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -47,27 +48,6 @@ function parseMiddlewareDefinitions(files: Array<string>): Array<MiddlewareDefin
.sort((a, b) => 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,
Expand Down
8 changes: 2 additions & 6 deletions packages/void/src/router/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('|')})$`,
Expand Down Expand Up @@ -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<string, Array<WebSocketRouteDefinition>>();
for (const route of routes) {
const previous = byClass.get(route.className) ?? [];
Expand Down
33 changes: 33 additions & 0 deletions packages/void/test/unit/pages-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div />; }');
const result = await scanPages(TMP);
Expand Down
87 changes: 87 additions & 0 deletions packages/void/test/unit/route-order.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
});
});
13 changes: 13 additions & 0 deletions packages/void/test/unit/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {}');
Expand Down
10 changes: 10 additions & 0 deletions packages/void/test/unit/websocket-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
});
Loading