@thi.ng/router
Version:
Generic trie-based router with support for wildcards, route param validation/coercion, auth
234 lines (233 loc) • 6.66 kB
JavaScript
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
};