Files
WRNexusJS/packages/router/src/match.ts
T
2026-07-22 17:29:08 +05:30

180 lines
5.2 KiB
TypeScript

/**
* Route compilation + matching.
*
* Supported segments:
* [id] required parameter
* [id?] optional parameter
* [[id]] optional parameter (directory-friendly form)
* [...slug] required catch-all
* [[...slug]] optional catch-all
*/
export interface RouteParam {
name: string;
optional: boolean;
catchAll: boolean;
}
export interface Route {
/** The human-readable route pattern, e.g. `/users/[id]`. */
raw: string;
/** Absolute path to the module that handles this route. */
file: string;
/** Compiled matcher. */
regex: RegExp;
/** Ordered names of dynamic params captured by `regex`. */
paramNames: string[];
/** Rich parameter metadata. Optional for compatibility with old manifests. */
paramMeta?: RouteParam[];
}
export interface RouteMatch {
route: Route;
params: Record<string, string>;
}
const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
function parseParamSegment(segment: string): RouteParam | null {
let inner: string | null = null;
let optional = false;
if (segment.startsWith("[[") && segment.endsWith("]]")) {
inner = segment.slice(2, -2);
optional = true;
} else if (segment.startsWith("[") && segment.endsWith("]")) {
inner = segment.slice(1, -1);
if (inner.endsWith("?")) {
optional = true;
inner = inner.slice(0, -1);
}
}
if (inner === null) return null;
const catchAll = inner.startsWith("...");
const name = catchAll ? inner.slice(3) : inner;
if (!/^[A-Za-z_$][\w$-]*$/.test(name)) {
throw new Error(`WRN-ROUTE-PARAM: Invalid route parameter '${segment}'.`);
}
return { name, optional, catchAll };
}
/** Return parameter metadata without requiring callers to inspect the regex. */
export function getRouteParams(raw: string): RouteParam[] {
return raw
.split("/")
.filter(Boolean)
.map(parseParamSegment)
.filter((value): value is RouteParam => value !== null);
}
/** Compile a WRNexus route pattern into a RegExp + parameter metadata. */
export function compileRoutePattern(
raw: string,
): Pick<Route, "regex" | "paramNames" | "paramMeta"> {
if (raw === "/") {
return { regex: /^\/$/, paramNames: [], paramMeta: [] };
}
const paramMeta: RouteParam[] = [];
let source = "^";
const segments = raw.split("/").filter(Boolean);
for (const segment of segments) {
const param = parseParamSegment(segment);
if (!param) {
source += `/${segment.replace(ESCAPE_RE, "\\$&")}`;
continue;
}
if (paramMeta.some((existing) => existing.name === param.name)) {
throw new Error(
`WRN-ROUTE-DUPLICATE-PARAM: Parameter '${param.name}' appears more than once in '${raw}'.`,
);
}
paramMeta.push(param);
const capture = param.catchAll ? "(.+?)" : "([^/]+)";
source += param.optional ? `(?:/${capture})?` : `/${capture}`;
}
source += "/?$";
return {
regex: new RegExp(source),
paramNames: paramMeta.map((param) => param.name),
paramMeta,
};
}
function routeSpecificity(route: Route): number[] {
const segments = route.raw.split("/").filter(Boolean);
let staticCount = 0;
let requiredCount = 0;
let optionalCount = 0;
let catchAllCount = 0;
for (const segment of segments) {
const param = parseParamSegment(segment);
if (!param) staticCount++;
else if (param.catchAll) catchAllCount++;
else if (param.optional) optionalCount++;
else requiredCount++;
}
return [
staticCount,
requiredCount,
-optionalCount,
-catchAllCount,
segments.length,
route.raw.length,
];
}
/**
* Order routes so static and constrained routes win over optional/catch-all
* routes. The ordering remains deterministic for identical specificity.
*/
export function sortRoutes(routes: Route[]): Route[] {
return [...routes].sort((a, b) => {
const as = routeSpecificity(a);
const bs = routeSpecificity(b);
for (let i = 0; i < as.length; i++) {
if (as[i] !== bs[i]) return bs[i]! - as[i]!;
}
return a.raw.localeCompare(b.raw) || a.file.localeCompare(b.file);
});
}
/** Find duplicate URL patterns before request handling starts. */
export function findRouteConflicts(routes: Route[]): Array<{ raw: string; files: string[] }> {
const grouped = new Map<string, string[]>();
for (const route of routes) {
const files = grouped.get(route.raw) ?? [];
files.push(route.file);
grouped.set(route.raw, files);
}
return [...grouped]
.filter(([, files]) => files.length > 1)
.map(([raw, files]) => ({ raw, files }));
}
/** Find the first route whose pattern matches `pathname`. */
export function matchRoute(routes: Route[], pathname: string): RouteMatch | null {
for (const route of routes) {
const match = route.regex.exec(pathname);
if (!match) continue;
const params: Record<string, string> = {};
try {
route.paramNames.forEach((name, index) => {
const value = match[index + 1];
if (value !== undefined) params[name] = decodeURIComponent(value);
});
} catch {
// A malformed percent-encoded path is not a valid route match. Treat it
// as a 404 instead of allowing decodeURIComponent to become a 500.
return null;
}
return { route, params };
}
return null;
}