@trifrost/core
Version:
Blazingly fast, runtime-agnostic server framework for modern edge and node environments
276 lines (275 loc) • 10 kB
JavaScript
import { LRU } from '@valkyriestudios/utils/caching';
import { Sym_TriFrostMiddlewareCors } from '../middleware/Cors';
import { HttpMethods, HttpMethodsSet } from '../types/constants';
function isValidPath(val) {
return typeof val === 'string' && val.length > 0 && val[0] === '/';
}
/**
* Factory for a blank trie node
*/
function blankTrieNode() {
return {
children: Object.create(null),
param_child: null,
wildcard_child: null,
param_name: null,
methods: Object.create(null),
};
}
/**
* Factory for an options route
*
* @param {string} path - Path the options route is for
* @param {TriFrostRoute[]} routes - Routes array for this path
*/
function createOptionsRoute(path, routes) {
const methods = [HttpMethods.OPTIONS];
let cors_mware = null;
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
/* Push into allowed methods array */
if (route.method !== HttpMethods.OPTIONS)
methods.push(route.method);
/* We extract the cors middleware from the chains of the routes on the same path */
if (cors_mware)
continue;
for (let y = 0; y < route.middleware.length; y++) {
const el = route.middleware[y];
if (el.fingerprint === Sym_TriFrostMiddlewareCors)
cors_mware = el;
}
}
return {
method: HttpMethods.OPTIONS,
kind: 'options',
path,
fn: (ctx) => {
ctx.setHeaders({ allow: methods.join(', '), vary: 'Origin' });
ctx.status(204);
},
middleware: cors_mware ? [cors_mware] : [],
timeout: null,
bodyParser: null,
name: `OPTIONS_${path}`,
description: 'Auto-generated OPTIONS handler',
meta: null,
};
}
/**
* Trie-based route tree: Builds a prefix tree of path segments.
*/
class TrieRouteTree {
root = blankTrieNode();
/**
* Adds a route into the trie, segment by segment.
*
* @param {TriFrostRoute<Env>} route - Route to add
* @param {boolean} no_options - Set to true to not add options route
*/
add(route, no_options = false) {
let node = this.root;
const segments = route.path.split('/');
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (segment) {
if (segment.startsWith(':')) {
if (!node.param_child) {
node.param_child = blankTrieNode();
node.param_child.param_name = segment.slice(1);
}
node = node.param_child;
}
else if (segment === '*') {
if (!node.wildcard_child) {
node.wildcard_child = blankTrieNode();
}
node = node.wildcard_child;
}
else {
if (!node.children[segment])
node.children[segment] = blankTrieNode();
node = node.children[segment];
}
}
}
node.methods[route.method] = route;
/* Only add options method if no_options is false */
if (!no_options) {
node.methods.OPTIONS = createOptionsRoute(route.path, Object.values(node.methods));
}
}
/**
* Matches a path and method against the trie, collecting parameters.
*
* @param {HttpMethod} method - HTTP method to match
* @param {string} path - Path to find the match for
*/
match(method, path) {
const params = {};
const matched = this.search(this.root, path.split('/'), 0, params, method);
return matched ? { route: matched, path: matched.path, params } : null;
}
/**
* Recursively collects all registered routes
* @note This is used for debugging/tests and as such does not need to be highly optimized
*/
get stack() {
const collected = [];
const recurse = (node, pathSegments) => {
for (const method in node.methods) {
collected.push(node.methods[method]);
}
for (const seg in node.children) {
recurse(node.children[seg], [...pathSegments, seg]);
}
if (node.param_child) {
recurse(node.param_child, [...pathSegments, `:${node.param_child.param_name}`]);
}
if (node.wildcard_child) {
recurse(node.wildcard_child, [...pathSegments, '*']);
}
};
recurse(this.root, []);
return collected;
}
/**
* Recursively searches the trie for a matching route and method
*
* @param {TrieNode<Env>} node - Node of the tree we're currently on
* @param {string[]} segments - Segments of the path we're searching
* @param {number} segment_idx - Index of the current segment we're on
* @param {Record<string, string>} params_acc - Parameter accumulator (built up as we go)
* @param {HttpMethod} method - HTTP method being matched
*/
search(node, segments, segment_idx, params_acc, method) {
if (segment_idx === segments.length) {
return node.methods[method] || null;
}
const segment = segments[segment_idx];
if (!segment)
return this.search(node, segments, segment_idx + 1, params_acc, method);
/* Exact match */
if (node.children[segment]) {
const found = this.search(node.children[segment], segments, segment_idx + 1, params_acc, method);
if (found)
return found;
}
/* Param match */
if (node.param_child) {
params_acc[node.param_child.param_name] = segment;
const found = this.search(node.param_child, segments, segment_idx + 1, params_acc, method);
if (found)
return found;
delete params_acc[node.param_child.param_name];
}
/* Wildcard match (wildcard eats the rest) */
if (node.wildcard_child) {
const remaining = segments.slice(segment_idx).join('/');
params_acc['*'] = remaining;
return node.wildcard_child.methods[method] || null;
}
return null;
}
}
export class RouteTree {
static = Object.create(null);
dynamic = { lru: new LRU({ max_size: 250 }), tree: new TrieRouteTree() };
notfound = { lru: new LRU({ max_size: 250 }), tree: new TrieRouteTree() };
error = { lru: new LRU({ max_size: 250 }), tree: new TrieRouteTree() };
/**
* Stack getter used for testing/debugging
*/
get stack() {
return [
...Object.values(this.static).flatMap(obj => Object.values(obj)),
...this.dynamic.tree.stack,
...this.notfound.tree.stack,
...this.error.tree.stack,
];
}
/**
* Clears all stored routes
*/
reset() {
this.static = Object.create(null);
this.dynamic = { lru: new LRU({ max_size: 250 }), tree: new TrieRouteTree() };
this.notfound = { lru: new LRU({ max_size: 250 }), tree: new TrieRouteTree() };
this.error = { lru: new LRU({ max_size: 250 }), tree: new TrieRouteTree() };
}
/**
* MARK: Standard
*/
/**
* Adds a route to the tree
*
* @param {MethodRouteDefinition<Env>} route - Route to add
*/
add(route) {
if (!isValidPath(route?.path))
throw new Error('RouteTree@add: invalid path');
if (typeof route.fn !== 'function')
throw new Error('RouteTree@add: route.fn must be a function');
if (!HttpMethodsSet.has(route.method))
throw new Error('RouteTree@add: method is not valid');
if (route.path.indexOf(':') < 0 && route.path.indexOf('*') < 0) {
if (!this.static[route.path])
this.static[route.path] = Object.create(null);
this.static[route.path][route.method] = route;
this.static[route.path].OPTIONS = createOptionsRoute(route.path, Object.values(this.static[route.path]));
}
else {
this.dynamic.tree.add(route);
}
}
/**
* Attempts to match a route by method and path
*
* @param {HttpMethod} method - HTTP Verb (GET, DELETE, POST, ...)
* @param {string} path - Path to look for
*/
match(method, path) {
const normalized = path === '' ? '/' : path;
/* Check static routes */
const hit_static = this.static[normalized]?.[method];
if (hit_static)
return { route: hit_static, path: hit_static.path, params: {} };
const cached = this.dynamic.lru.get(normalized);
if (cached)
return cached.v;
const matched = this.dynamic.tree.match(method, normalized);
this.dynamic.lru.set(normalized, { v: matched });
return matched;
}
/**
* MARK: Not Found
*/
addNotFound(route) {
if (!isValidPath(route?.path))
throw new Error('RouteTree@addNotFound: invalid path');
this.notfound.tree.add({ ...route, method: 'GET' }, true);
}
matchNotFound(path) {
const cached = this.notfound.lru.get(path);
if (cached)
return cached.v;
const matched = this.notfound.tree.match('GET', path);
this.notfound.lru.set(path, { v: matched });
return matched;
}
/**
* MARK: Error
*/
addError(route) {
if (!isValidPath(route?.path))
throw new Error('RouteTree@addError: invalid path');
this.error.tree.add({ ...route, method: 'GET' }, true);
}
matchError(path) {
const cached = this.error.lru.get(path);
if (cached)
return cached.v;
const matched = this.error.tree.match('GET', path);
this.error.lru.set(path, { v: matched });
return matched;
}
}