UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

194 lines (162 loc) 5.34 kB
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; }; }