UNPKG

@thi.ng/router

Version:

Generic trie-based router with support for wildcards, route param validation/coercion, auth

234 lines (233 loc) 6.66 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __decorateClass = (decorators, target, key, kind) => { var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target; for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result; if (kind && result) __defProp(target, key, result); return result; }; import { INotifyMixin } from "@thi.ng/api/mixins/inotify"; import { isString } from "@thi.ng/checks/is-string"; import { equiv } from "@thi.ng/equiv"; import { assert } from "@thi.ng/errors/assert"; import { illegalArgs } from "@thi.ng/errors/illegal-arguments"; import { illegalArity } from "@thi.ng/errors/illegal-arity"; import { illegalState } from "@thi.ng/errors/illegal-state"; import { EVENT_ROUTE_CHANGED, EVENT_ROUTE_FAILED } from "./api.js"; import { Trie } from "./trie.js"; let Router = class { opts; current; index = {}; routes = new Trie(); constructor(config) { this.opts = { authenticator: (match) => match, prefix: "/", separator: "/", trim: true, ...config }; this.addRoutes(this.opts.routes); assert( this.routeForID(this.opts.default) !== void 0, `missing config for default route: '${this.opts.default}'` ); if (config.initial) { const route = this.routeForID(config.initial); assert( route !== void 0, `missing config for initial route: ${this.opts.initial}` ); assert(!route.params, "initial route MUST not be parametric"); } } // @ts-ignore: arguments // prettier-ignore addListener(id, fn, scope) { } // @ts-ignore: arguments // prettier-ignore removeListener(id, fn, scope) { } // @ts-ignore: arguments notify(event) { } start() { if (this.opts.initial) { const route = this.routeForID(this.opts.initial); this.current = { id: route.id, params: {} }; this.notify({ id: EVENT_ROUTE_CHANGED, value: this.current }); } } addRoutes(routes) { for (let r of routes) { try { const route = this.augmentRoute(r); this.routes.set(route.match, route); this.index[route.id] = route; } catch (e) { illegalArgs( `error in route "${r.id}": ${e.origMessage}` ); } } } /** * Main router function. Attempts to match given input string against all * configured routes. Before returning, triggers {@link EVENT_ROUTE_CHANGED} * with return value as well. If none of the routes matches, emits * {@link EVENT_ROUTE_FAILED} and then falls back to configured default * route. * * @remarks * See {@link RouteAuthenticator} for details about `ctx` handling. * * @param src - route path to match * @param ctx - arbitrary user context */ route(src, ctx) { if (this.opts.trim && src.charAt(src.length - 1) === this.opts.separator) { src = src.substring(0, src.length - 1); } src = src.substring(this.opts.prefix.length); let match = this.matchRoutes(src, ctx); if (!match) { this.notify({ id: EVENT_ROUTE_FAILED, value: src }); if (!this.handleRouteFailure()) { return; } const route = this.routeForID(this.opts.default); match = { id: route.id, redirect: true }; } if (!equiv(match, this.current)) { this.current = match; this.notify({ id: EVENT_ROUTE_CHANGED, value: match }); } return match; } format(...args) { let [id, params, rest] = args; let match; switch (args.length) { case 3: match = { id, params, rest }; break; case 2: match = { id, params }; break; case 1: match = isString(id) ? { id } : id; break; default: illegalArity(args.length); } const route = this.routeForID(match.id); if (route) { const params2 = match.params; let parts = route.match.map((x) => { if (__isRouteParam(x)) { const id2 = x.substring(1); const p = params2?.[id2]; if (p == null) { illegalArgs(`missing value for param '${id2}'`); } return p; } return x; }); if (route.rest >= 0) parts = parts.slice(0, route.rest).concat(match.rest || []); return this.opts.prefix + parts.join(this.opts.separator); } else { illegalArgs(`invalid route ID: ${match.id}`); } } routeForID(id) { return this.index[id]; } augmentRoute(route) { const match = isString(route.match) ? route.match.split(this.opts.separator).filter((x) => !!x) : route.match; const existing = this.routes.get(match); if (existing) { illegalArgs( `duplicate route: ${match} (id: ${route.id}, conflicts with: ${existing.id})` ); } let hasParams = false; const params = match.reduce((acc, x, i) => { if (__isRouteParam(x)) { hasParams = true; acc[i] = x.substring(1); } return acc; }, {}); return { spec: route, id: route.id, match, params: hasParams ? params : void 0, rest: match.indexOf("+") }; } matchRoutes(src, ctx) { const curr = src.split(this.opts.separator); const route = this.routes.get(curr); if (!route) return; let params; if (route.params) { params = Object.entries(route.params).reduce( (acc, [i, k]) => (acc[k] = curr[+i], acc), {} ); } const validator = route.spec.validate; if (validator && !this.validateRouteParams(params, validator)) { return; } const rest = route.rest >= 0 ? curr.slice(route.rest) : void 0; let match = { id: route.id, params, rest }; if (route.spec.auth) { match = this.opts.authenticator(match, route, ctx); if (match && !this.index[match.id]) { illegalState( "auth handler returned invalid route ID: " + match.id ); } } return match; } validateRouteParams(params, validators) { for (let id in validators) { if (params[id] !== void 0) { const val = validators[id]; if (val.coerce) { params[id] = val.coerce(params[id]); } if (val.check && !val.check(params[id])) { return false; } } } return true; } handleRouteFailure() { return true; } }; Router = __decorateClass([ INotifyMixin ], Router); const __isRouteParam = (x) => x[0] === "?"; export { Router };