UNPKG

@trifrost/core

Version:

Blazingly fast, runtime-agnostic server framework for modern edge and node environments

280 lines (279 loc) 10.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RouteTree = void 0; const caching_1 = require("@valkyriestudios/utils/caching"); const Cors_1 = require("../middleware/Cors"); const constants_1 = require("../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 = [constants_1.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 !== constants_1.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 === Cors_1.Sym_TriFrostMiddlewareCors) cors_mware = el; } } return { method: constants_1.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; } } class RouteTree { static = Object.create(null); dynamic = { lru: new caching_1.LRU({ max_size: 250 }), tree: new TrieRouteTree() }; notfound = { lru: new caching_1.LRU({ max_size: 250 }), tree: new TrieRouteTree() }; error = { lru: new caching_1.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 caching_1.LRU({ max_size: 250 }), tree: new TrieRouteTree() }; this.notfound = { lru: new caching_1.LRU({ max_size: 250 }), tree: new TrieRouteTree() }; this.error = { lru: new caching_1.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 (!constants_1.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; } } exports.RouteTree = RouteTree;