src/features/router.ts
Rama main · réplica de lectura del repositorio original.
/**
* Declarative router with pattern matching and typed route params
*/
import { reduce } from "#fp";
import type { PathMethodRoute } from "#routes/types.ts";
import type { ResponseHandler } from "#shared/response-steps.ts";
import {
compileRoutePathPattern,
type RouteParamNames,
} from "#shared/route-pattern.ts";
// =============================================================================
// Type-level route param inference
// =============================================================================
/** Extract path part from "METHOD /path" pattern */
type ExtractPath<S extends string> = S extends `${string} ${infer Path}`
? Path
: S;
/** Infer runtime type from param name (mirrors isNumericParam convention) */
type InferParamType<Name extends string> = Name extends `${string}Id`
? number
: Name extends "id"
? number
: string;
/** Build typed params object from a route pattern string */
export type RouteParamsFor<Pattern extends string> = {
[K in RouteParamNames<ExtractPath<Pattern>>]: InferParamType<K>;
};
/** Route handler with params inferred from the route pattern */
export type TypedRouteHandler<Pattern extends string> = ResponseHandler<
[request: Request, params: RouteParamsFor<Pattern>]
>;
// =============================================================================
// Runtime types
// =============================================================================
/** Route parameters extracted from URL patterns (ID params are auto-parsed to numbers) */
export type RouteParams = Record<string, string | number | undefined>;
/** Route handler function signature (used internally by createRouter) */
export type RouteHandlerFn = ResponseHandler<
[request: Request, params: RouteParams]
>;
/** Compiled route with regex */
type CompiledRoute = {
regex: RegExp;
paramNames: string[];
numericParams: Set<string>;
handler: RouteHandlerFn;
};
/** A matched route: its handler plus the params pulled from the path. */
type RouteMatch = { handler: RouteHandlerFn; params: RouteParams };
/**
* Parse route pattern "METHOD /path" into method and path parts
*/
const parseRoutePattern = (
pattern: string,
): { method: string; path: string } => {
const spaceIndex = pattern.indexOf(" ");
return {
method: pattern.slice(0, spaceIndex),
path: pattern.slice(spaceIndex + 1),
};
};
/**
* Compile all routes for efficient matching.
*
* Routes are sorted so more-specific patterns (fewer params, longer literals)
* are tried first. This means literal routes like "/join/complete" beat
* "/join/:code" regardless of insertion order, so route definition objects
* can be sorted alphabetically by tooling without changing match behaviour.
*/
const routeSpecificity = (path: string): [number, number] => {
const paramCount = (path.match(/:\w+/g) ?? []).length;
const literalLength = path.replace(/:\w+/g, "").length;
return [paramCount, -literalLength];
};
const compareSpecificity = (a: string, b: string): number => {
const [aParams, aLit] = routeSpecificity(a);
const [bParams, bLit] = routeSpecificity(b);
return aParams - bParams || aLit - bLit;
};
const compileRoutes = (
routes: Record<string, RouteHandlerFn>,
): Map<string, CompiledRoute[]> => {
const sortedEntries = Object.entries(routes).sort(([a], [b]) =>
compareSpecificity(parseRoutePattern(a).path, parseRoutePattern(b).path),
);
return reduce(
(
compiled: Map<string, CompiledRoute[]>,
[pattern, handler]: [string, RouteHandlerFn],
) => {
const { method, path } = parseRoutePattern(pattern);
const { regex, paramNames, numericParams } =
compileRoutePathPattern(path);
const methodRoutes = compiled.get(method) ?? [];
methodRoutes.push({ handler, numericParams, paramNames, regex });
compiled.set(method, methodRoutes);
return compiled;
},
new Map(),
)(sortedEntries);
};
/**
* Extract params from regex match using param names
*/
const extractParams = (
paramNames: string[],
numericParams: Set<string>,
match: RegExpMatchArray,
): RouteParams => {
const params: RouteParams = {};
for (const [i, name] of paramNames.entries()) {
const value = match[i + 1];
if (value !== undefined) {
params[name] = numericParams.has(name) ? Number(value) : value;
}
}
return params;
};
/**
* Try to match a single route against a path
*/
const tryMatchRoute = (
route: CompiledRoute,
path: string,
): RouteMatch | null => {
const match = path.match(route.regex);
if (!match) return null;
return {
handler: route.handler,
params: extractParams(route.paramNames, route.numericParams, match),
};
};
/**
* Match a request against compiled routes
*/
const matchRequest = (
compiledRoutes: Map<string, CompiledRoute[]>,
method: string,
path: string,
): RouteMatch | null => {
const methodRoutes = compiledRoutes.get(method);
if (!methodRoutes) return null;
for (const route of methodRoutes) {
const result = tryMatchRoute(route, path);
if (result) return result;
}
return null;
};
/**
* Create a router function from route definitions
*/
export const createRouter = (
routes: Record<string, RouteHandlerFn>,
): PathMethodRoute => {
const compiled = compileRoutes(routes);
return (request, path, method) => {
const match = matchRequest(compiled, method, path);
if (!match) return Promise.resolve(null);
return Promise.resolve(match.handler(request, match.params));
};
};
/** The typed call signature of a route-table factory: each handler's params
* are checked against the literal route key that binds it. */
export type RouteTableFactory = <T extends string>(
routes: { [K in T]: TypedRouteHandler<K> },
) => Record<string, RouteHandlerFn>;
/**
* Define routes with typed params inferred from route pattern strings.
* Params ending in "Id" or named "id" are typed as number; all others as string.
*
* The call signature enforces per-route param types at the call site; the
* implementation accepts the general RouteHandlerFn map that createRouter
* expects. The implementation's parameter is wider than the call signature's,
* which is why the cast stays out of sight behind the named type.
*/
export const defineRoutes: RouteTableFactory = (
routes: Record<string, (...args: never) => unknown>,
): Record<string, RouteHandlerFn> => routes as Record<string, RouteHandlerFn>;