goodrouter
Version:
a good router
469 lines (463 loc) • 17.2 kB
JavaScript
/**
* @description
* Default options to be passed to the router
*/
const defaultRouterOptions = {
/**
* @description
* Default encoding function to use, this is the encodeUriComponent function by default
*
* @param decodedValue value to be encoded
* @returns encoded value
*/
parameterValueEncoder: (decodedValue) => encodeURIComponent(decodedValue),
/**
* @description
* Default decoding function to use, this is the decodeURIComponent function by default
*
* @param encodedValue value to be decoded
* @returns decoded value
*/
parameterValueDecoder: (encodedValue) => decodeURIComponent(encodedValue),
/**
* Use `{` and `}` as a default for matching placeholders in the route templates.
*/
parameterPlaceholderRE: /\{(.*?)\}/gu,
/**
* Assume a maximum parameter value length of 50
*/
maximumParameterValueLength: 50,
};
function findCommonPrefixLength(stringLeft, stringRight) {
const length = Math.min(stringLeft.length, stringRight.length);
let index;
for (index = 0; index < length; index++) {
const charLeft = stringLeft.charAt(index);
const charRight = stringRight.charAt(index);
if (charLeft !== charRight) {
break;
}
}
return index;
}
/**
* @description
* This interface represents a node in the tree structure that holds all the node
* for the routes
*/
class RouteNode {
anchor;
hasParameter;
routeKey;
children;
constructor(
/**
* @description
* suffix that comes after the parameter value (if any!) of the path
*/
anchor = "",
/**
* @description
* does this node have a parameter value
*/
hasParameter = false,
/**
* @description
* key that identifies the route, if this is a leaf node for the route
*/
routeKey = null,
/**
* @description
* children that represent the rest of the path that needs to be matched
*/
children = new Array()) {
this.anchor = anchor;
this.hasParameter = hasParameter;
this.routeKey = routeKey;
this.children = children;
}
addChild(childNode) {
this.children.push(childNode);
}
removeChild(childNode) {
const childIndex = this.children.indexOf(childNode);
this.children.splice(childIndex, 1);
}
countChildren() {
return this.children.length;
}
insert(routeKey, templatePairs) {
const routeParameterNames = templatePairs
.map(([, parameterName]) => parameterName)
.filter((parameterName) => parameterName);
// eslint-disable-next-line @typescript-eslint/no-this-alias
let currentNode = this;
for (let index = 0; index < templatePairs.length; index++) {
const [anchor, parameterName] = templatePairs[index];
const hasParameter = parameterName != null;
const [commonPrefixLength, childNode] = currentNode.findSimilarChild(anchor, hasParameter);
currentNode = currentNode.merge(childNode, anchor, hasParameter, index === templatePairs.length - 1 ? routeKey : null, routeParameterNames, commonPrefixLength);
}
return currentNode;
}
parse(path, maximumParameterValueLength) {
const parameterValues = new Array();
if (this.hasParameter) {
// we are matching a parameter value! If the path's length is 0, there is no match, because a parameter value should have at least length 1
if (path.length === 0) {
return [null, []];
}
// look for the anchor in the path (note: indexOf is probably the most expensive operation!) If the anchor is empty, match the remainder of the path
const index = this.anchor.length === 0
? path.length
: path
.substring(0, maximumParameterValueLength + this.anchor.length)
.indexOf(this.anchor);
if (index < 0) {
return [null, []];
}
// get the parameter value
const value = path.substring(0, index);
// remove the matches part from the path
path = path.substring(index + this.anchor.length);
// add value to parameters
parameterValues.push(value);
}
else {
// if this node does not represent a parameter we expect the path to start with the `anchor`
if (!path.startsWith(this.anchor)) {
// this node does not match the path
return [null, []];
}
// we successfully matches the node to the path, now remove the matched part from the path
path = path.substring(this.anchor.length);
}
for (const childNode of this.children) {
// find a route in every child node
const [childRouteKey, childParameterValues] = childNode.parse(path, maximumParameterValueLength);
// if a child node is matched, return that node instead of the current! So child nodes are matched first!
if (childRouteKey != null) {
return [childRouteKey, [...parameterValues, ...childParameterValues]];
}
}
// if the node had a route name and there is no path left to match against then we found a route
if (this.routeKey != null && path.length === 0) {
return [this.routeKey, parameterValues];
}
// we did not found a route :-(
return [null, []];
}
merge(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength) {
if (childNode == null) {
return this.mergeNew(anchor, hasParameter, routeKey);
}
const commonPrefix = childNode.anchor.substring(0, commonPrefixLength);
if (childNode.anchor === anchor) {
return this.mergeJoin(childNode, routeKey);
}
else if (childNode.anchor === commonPrefix) {
return this.mergeAddToChild(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength);
}
else if (anchor === commonPrefix) {
return this.mergeAddToNew(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength);
}
else {
return this.mergeIntermediate(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength);
}
}
mergeNew(anchor, hasParameter, routeKey) {
const newNode = new RouteNode(anchor, hasParameter, routeKey);
this.addChild(newNode);
this.children.sort((a, b) => a.compare(b));
return newNode;
}
mergeJoin(childNode, routeKey) {
if (childNode.routeKey != null && routeKey != null) {
throw new Error("ambiguous route");
}
if (childNode.routeKey == null) {
childNode.routeKey = routeKey;
}
return childNode;
}
mergeIntermediate(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength) {
this.removeChild(childNode);
const newNode = new RouteNode(anchor.substring(commonPrefixLength), false, routeKey);
childNode.anchor = childNode.anchor.substring(commonPrefixLength);
childNode.hasParameter = false;
const intermediateNode = new RouteNode(anchor.substring(0, commonPrefixLength), hasParameter);
intermediateNode.addChild(childNode);
intermediateNode.addChild(newNode);
this.addChild(intermediateNode);
this.children.sort((a, b) => a.compare(b));
intermediateNode.children.sort((a, b) => a.compare(b));
return newNode;
}
mergeAddToChild(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength) {
anchor = anchor.substring(commonPrefixLength);
hasParameter = false;
const [commonPrefixLength2, childNode2] = childNode.findSimilarChild(anchor, hasParameter);
return childNode.merge(childNode2, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength2);
}
mergeAddToNew(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength) {
const newNode = new RouteNode(anchor, hasParameter, routeKey);
this.addChild(newNode);
this.removeChild(childNode);
childNode.anchor = childNode.anchor.substring(commonPrefixLength);
childNode.hasParameter = false;
newNode.addChild(childNode);
this.children.sort((a, b) => a.compare(b));
newNode.children.sort((a, b) => a.compare(b));
return newNode;
}
findSimilarChild(anchor, hasParameter) {
for (const childNode of this.children) {
if (childNode.hasParameter !== hasParameter) {
continue;
}
const commonPrefixLength = findCommonPrefixLength(anchor, childNode.anchor);
if (commonPrefixLength === 0) {
continue;
}
return [commonPrefixLength, childNode];
}
return [0, null];
}
compare(other) {
if (!this.hasParameter && other.hasParameter)
return -1;
if (this.hasParameter && !other.hasParameter)
return 1;
if (this.anchor.length < other.anchor.length)
return 1;
if (this.anchor.length > other.anchor.length)
return -1;
if (this.anchor < other.anchor)
return -1;
if (this.anchor > other.anchor)
return 1;
return 0;
}
toJSON() {
const json = {
anchor: this.anchor,
hasParameter: this.hasParameter,
routeKey: this.routeKey,
children: this.children.map((child) => child.toJSON()),
};
return json;
}
static fromJSON(json) {
const node = new RouteNode(json.anchor, json.hasParameter, json.routeKey, json.children.map((child) => RouteNode.fromJSON(child)));
return node;
}
}
/**
* Take a route template and chops is in pieces! The first piece is a literal part of
* the template. Then the name of a placeholder. Then a literal parts of the template again.
* The first and the last elements are always literal strings taken from the template,
* therefore the number of elements in the resulting iterable is always uneven!
*
* @param routeTemplate template to chop up
* @param parameterPlaceholderRE regular expression to use when searching for parameter placeholders
* @returns Iterable of strings, always an uneven number of elements.
*/
function* parseTemplateParts(routeTemplate, parameterPlaceholderRE) {
if (!parameterPlaceholderRE.global) {
throw new Error("regular expression needs to be global");
}
let match;
let offsetIndex = 0;
while ((match = parameterPlaceholderRE.exec(routeTemplate)) != null) {
yield routeTemplate.substring(offsetIndex, parameterPlaceholderRE.lastIndex - match[0].length);
yield match[1];
offsetIndex = parameterPlaceholderRE.lastIndex;
}
yield routeTemplate.substring(offsetIndex);
}
function* parseTemplatePairs(routeTemplate, parameterPlaceholderRE) {
const parts = parseTemplateParts(routeTemplate, parameterPlaceholderRE);
let index = 0;
let parameter = null;
for (const part of parts) {
if (index % 2 === 0) {
yield [part, parameter];
}
else {
parameter = part;
}
index++;
}
}
var RouterMode;
(function (RouterMode) {
RouterMode[RouterMode["Client"] = 2] = "Client";
RouterMode[RouterMode["Server"] = 4] = "Server";
RouterMode[RouterMode["Bidirectional"] = 6] = "Bidirectional";
})(RouterMode || (RouterMode = {}));
/**
* @description
* This is the actual router that contains all routes and does the actual routing
*
* @example
* ```typescript
* const router = new Router<string>();
*
* router.insertRoute("all-products", "/product/all");
* router.insertRoute("product-detail", "/product/{id}");
*
* // And now we can parse routes!
*
* {
* const [routeKey, routeParameters] = router.parseRoute("/not-found");
* assert.equal(routeKey, null);
* assert.deepEqual(routeParameters, {});
* }
*
* {
* const [routeKey, routeParameters] = router.parseRoute("/product/all");
* assert.equal(routeKey, "all-products");
* assert.deepEqual(routeParameters, {});
* }
*
* {
* const [routeKey, routeParameters] = router.parseRoute("/product/1");
* assert.equal(routeKey, "product-detail");
* assert.deepEqual(routeParameters, { id: "1" });
* }
*
* // And we can stringify routes
*
* {
* const path = router.stringifyRoute(
* "all-products",
* });
* assert.equal(path, "/product/all");
* }
*
* {
* const path = router.stringifyRoute(
* "product-detail",
* { id: "2" },
* );
* assert.equal(path, "/product/2");
* }
* ```
*/
class Router {
mode;
constructor(options = {}, mode = RouterMode.Bidirectional) {
this.mode = mode;
this.options = {
...defaultRouterOptions,
...options,
};
}
options;
rootNode = new RouteNode();
templatePairs = new Map();
/**
* @description
* Adds a new route
*
* @param routeKey name of the route
* @param routeTemplate template for the route, als defines parameters
*/
insertRoute(routeKey, routeTemplate) {
const templatePairs = [
...parseTemplatePairs(routeTemplate, this.options.parameterPlaceholderRE),
];
if ((this.mode & RouterMode.Client) > 0) {
this.templatePairs.set(routeKey, templatePairs);
}
if ((this.mode & RouterMode.Server) > 0) {
this.rootNode.insert(routeKey, templatePairs);
}
return this;
}
/**
* @description
* Match the path against a known routes and parse the parameters in it
*
* @param path path to match
* @returns tuple with the route name or null if no route found. Then the parameters
*/
parseRoute(path) {
if ((this.mode & RouterMode.Server) === 0) {
throw new TypeError("Router needs to be in server mode to parse");
}
const parameters = {};
const [routeKey, parameterValues] = this.rootNode.parse(path, this.options.maximumParameterValueLength);
if (routeKey == null) {
return [null, {}];
}
const templatePairs = this.templatePairs.get(routeKey);
if (templatePairs == null) {
// this never happens
return [null, {}];
}
for (let index = 0; index < parameterValues.length; index++) {
const [, parameterName] = templatePairs[index + 1];
if (parameterName == null) {
// this never happens
return [null, {}];
}
const parameterValue = parameterValues[index];
parameters[parameterName] = this.options.parameterValueDecoder(parameterValue);
}
return [routeKey, parameters];
}
/**
* @description
* Convert a route to a path string.
*
* @param routeKey route to stringify
* @param routeParameters parameters to include in the path
* @returns string representing the route or null if the route is not found by name
*/
stringifyRoute(routeKey, routeParameters = {}) {
if ((this.mode & RouterMode.Client) === 0) {
throw new TypeError("Router needs to be in client mode to stringify");
}
let result = "";
const templatePairs = this.templatePairs.get(routeKey);
if (templatePairs == null) {
return null;
}
for (let index = 0; index < templatePairs.length; index++) {
const [parameterAnchor, parameterName] = templatePairs[index];
if (parameterName != null) {
const parameterValue = routeParameters[parameterName];
result += this.options.parameterValueEncoder(parameterValue);
}
result += parameterAnchor;
}
return result;
}
saveToJson(mode = this.mode) {
const rootNode = (this.mode & mode & RouterMode.Server) > 0 ? this.rootNode.toJSON() : undefined;
const templatePairs = (this.mode & mode & RouterMode.Client) > 0 ? [...this.templatePairs] : undefined;
return {
rootNode,
templatePairs,
};
}
loadFromJson(json) {
this.mode = RouterMode.Bidirectional;
if (json.rootNode == null) {
this.mode &= ~RouterMode.Server;
}
else {
this.rootNode = RouteNode.fromJSON(json.rootNode);
}
if (json.templatePairs == null) {
this.mode &= ~RouterMode.Client;
}
else {
this.templatePairs = new Map(json.templatePairs);
}
return this;
}
}
export { Router, RouterMode, defaultRouterOptions };
//# sourceMappingURL=main.js.map