alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
194 lines (162 loc) • 5.34 kB
text/typescript
import { AlephaError } from "alepha";
export abstract class RouterProvider<T extends Route = Route> {
protected routePathRegex: RegExp = /^\/[A-Za-z0-9._~!$&%'()*+,;=:@{}?/-]*$/;
protected tree: Tree<T> = { children: {} };
protected cache = new Map<string, RouteMatch<T>>();
protected maxCacheSize = 10_000;
public match(path: string): RouteMatch<T> {
const pathname = path.split("?", 1)[0];
const hit = this.cache.get(pathname);
if (hit) {
return { route: hit.route, params: { ...hit.params } };
}
const result = this.mapParams(this.createRouteMatch(pathname));
if (this.cache.size >= this.maxCacheSize) this.cache.clear();
this.cache.set(pathname, result);
return { route: result.route, params: { ...result.params } };
}
protected test(path: string): void {
if (!this.routePathRegex.test(path)) {
throw new AlephaError(`Route '${path}' is not valid`);
}
}
protected push(route: T): void {
const path = route.path.replaceAll("//", "/");
this.test(path);
this.cache.clear();
const parts = this.createParts(path);
let cursor = this.tree;
for (let i = 0; i < parts.length; i++) {
const isLast = i === parts.length - 1;
let part = parts[i].toLowerCase(); // url is case-insensitive
if (part === "*" && isLast) {
cursor.wildcard = { route };
break;
}
if (part.includes("*")) {
throw new AlephaError(`Route '${path}' has an invalid wildcard syntax`);
}
if (part.includes("{") || part.includes("}")) {
if (part.startsWith("{") && part.endsWith("}")) {
part = `:${part.slice(1, -1)}`; // convert {param} to :param
} else {
throw new AlephaError(`Route '${path}' has an invalid param syntax`);
}
}
if (part.startsWith(":")) {
const name = parts[i].slice(1).replaceAll("}", "");
if (!name) {
throw new AlephaError(`Route '${path}' has an empty param name`);
}
if (!cursor.param) {
cursor.param = { name, children: {} };
} else if (cursor.param.name !== name) {
// damn, 2 url params with different names
// got this case with /customers/:id and /customers/:userId/payments
route.mapParams ??= {};
route.mapParams[cursor.param.name] = name;
}
if (isLast) {
cursor.param.route = route;
}
cursor = cursor.param;
continue;
}
if (!cursor.children[part]) {
cursor.children[part] = { children: {} };
}
if (isLast) {
cursor.children[part].route = route;
}
cursor = cursor.children[part];
}
}
protected createRouteMatch(path: string): RouteMatch<T> {
if (path[0] !== "/") {
throw new AlephaError(`Path '${path}' must start with "/"`);
}
const parts = this.createParts(path);
let cursor = this.tree;
let wildcard: { route: T } | undefined;
const params: Record<string, string> = {};
for (let i = 0; i < parts.length; i++) {
const part = parts[i].toLowerCase(); // url is case-insensitive
if (cursor.children[part]) {
if (cursor.wildcard) {
wildcard = cursor.wildcard;
}
cursor = cursor.children[part];
} else if (cursor.param) {
if (cursor.wildcard) {
wildcard = cursor.wildcard;
}
params[cursor.param.name] = parts[i];
cursor = cursor.param;
} else if (cursor.wildcard) {
params["*"] = parts.slice(i).join("/");
return { route: cursor.wildcard.route, params };
} else {
return { route: wildcard?.route, params };
}
}
if (!cursor?.route) {
// when "/a/*" - trigger if "/a"
if (cursor.wildcard) {
return { route: cursor.wildcard.route, params };
}
// return deep wildcard or nothing
return { route: wildcard?.route, params };
}
return { route: cursor.route, params };
}
protected mapParams(match: RouteMatch<T>): RouteMatch<T> {
if (match.route?.mapParams && match.params) {
for (const [key, value] of Object.entries(match.route.mapParams)) {
if (match.params[key]) {
match.params[value] = match.params[key];
delete match.params[key];
}
}
}
return match;
}
protected createParts(path: string): string[] {
let pathname = path.split("?")[0].replaceAll("//", "/");
// remove trailing slash
if (pathname.endsWith("/") && pathname.length > 1) {
pathname = pathname.slice(0, -1);
}
return pathname.split("/").slice(1);
}
}
export interface RouteMatch<T extends Route> {
route?: T;
params?: Record<string, string>;
}
export interface Route {
path: string;
/**
* Rename a param in the route.
* This is automatically filled when you have scenarios like:
* `/customers/:id` and `/customers/:userId/payments`
*
* In this case, `:id` will be renamed to `:userId` in the second route.
*/
mapParams?: Record<string, string>;
}
export interface Tree<T extends Route> {
route?: T;
children: {
[key: string]: Tree<T>;
};
param?: {
route?: T;
name: string;
children: {
[key: string]: Tree<T>;
};
};
wildcard?: {
route: T;
};
}