@wroud/navigation
Version:
A flexible, pattern-matching navigation system for JavaScript applications with built-in routing, browser integration, and navigation state management
444 lines (388 loc) • 11.9 kB
text/typescript
import { TrieNode } from "./TrieNode.js";
import { matchSegments } from "./matcher.js";
import {
extractParamName,
extractParamType,
isParameterSegment,
isWildcardSegment,
joinPath,
splitPath,
splitPathAndQuery,
parseQueryPatternDefs,
type QueryParamDef,
} from "./path-utils.js";
import {
buildQueryString,
buildUrlSegments,
matchQueryParams,
validateParameters,
} from "./parameter-utils.js";
import type {
TypedPatternMatcher,
ExtractRouteParams,
IPatternRouteState,
} from "./types.js";
import type { IRouteMatcher, RouteParams } from "../IRouteMatcher.js";
export interface ITriePatternMatchingOptions {
trailingSlash?: boolean;
base?: string;
}
/**
* Trie-based implementation for URL pattern matching.
* Supports static segments, parameter segments (:param), and wildcard segments (:param*).
*/
export class TriePatternMatching implements TypedPatternMatcher, IRouteMatcher {
private root: TrieNode;
private patterns: Set<string>;
// Direct mapping from patterns to nodes for fast access
private patternToNode: Map<string, TrieNode>;
// Query parameter definitions per pattern
private patternQueryDefs: Map<string, QueryParamDef[]>;
// Cache for frequently decoded URLs
private decodeCache: Map<string, Map<string, RouteParams | null>>;
// Cache for frequently encoded patterns
private encodeCache: Map<string, Map<string, string>>;
// Maximum cache size to prevent memory leaks
private readonly MAX_CACHE_SIZE = 1000;
private readonly options: ITriePatternMatchingOptions;
constructor(options: ITriePatternMatchingOptions = {}) {
this.root = new TrieNode();
this.patterns = new Set();
this.patternToNode = new Map();
this.patternQueryDefs = new Map();
this.decodeCache = new Map();
this.encodeCache = new Map();
this.options = {
trailingSlash: true,
...options,
};
}
/**
* Add a pattern to the trie (e.g., "/user/:id<number>")
*
* Parameter nodes store declared type information which
* is later used to convert matched values.
*/
addPattern(pattern: string): void {
if (this.patterns.has(pattern)) return;
this.patterns.add(pattern);
// Separate path and query parts of the pattern
const { path: pathPattern, query: queryPattern } =
splitPathAndQuery(pattern);
const queryDefs = parseQueryPatternDefs(queryPattern);
if (queryDefs.length > 0) {
this.patternQueryDefs.set(pattern, queryDefs);
}
const segments = splitPath(pathPattern);
let current = this.root;
for (const segment of segments) {
if (!segment) {
// Handle root path
if (pattern === "/" && current === this.root) {
current = current.addStaticChild("");
}
continue;
}
if (isParameterSegment(segment)) {
const paramName = extractParamName(segment);
const paramType = extractParamType(segment);
current = isWildcardSegment(segment)
? current.getOrCreateWildcardChild(paramName, paramType)
: current.getOrCreateParamChild(paramName, paramType);
} else {
current = current.addStaticChild(segment);
}
}
current.markAsPatternEnd(pattern);
// Store direct reference to the node
this.patternToNode.set(pattern, current);
this.clearCaches();
}
/**
* Type guard to check if a route state matches a specific route ID
*/
isRoute<Pattern extends string>(
state: IPatternRouteState<any> | null,
id: Pattern,
): state is IPatternRouteState<Pattern> {
if (!state) return false;
return state.id === id;
}
/**
* Match a URL against the patterns in the trie
*/
match<Pattern extends string>(
url: string,
): IPatternRouteState<Pattern> | null {
const { path, query } = splitPathAndQuery(this.removeBaseFromUrl(url));
const segments = splitPath(path);
const { matched, pattern, params } = matchSegments(
this.root,
segments,
0,
{},
);
if (!matched || !pattern) return null;
// Extract query parameters if the pattern defines them
const queryDefs = this.patternQueryDefs.get(pattern);
if (queryDefs && !matchQueryParams(queryDefs, query, params)) {
return null;
}
return {
id: pattern as Pattern,
params: params as ExtractRouteParams<Pattern>,
};
}
/**
* Decode URL parameters based on a pattern
*/
decode<Pattern extends string>(
pattern: Pattern,
url: string,
): ExtractRouteParams<Pattern> | null {
url = this.addBaseToUrl(url);
// Check cache first
const cached = this.decodeCache.get(pattern)?.get(url) as
| ExtractRouteParams<Pattern>
| null
| undefined;
if (cached !== undefined) return cached;
// Register pattern if needed
if (!this.patterns.has(pattern)) {
this.addPattern(pattern);
}
// Match URL against pattern
const matchResult = this.match<Pattern>(url);
const result =
!matchResult || matchResult.id !== pattern ? null : matchResult.params;
// Update cache
this.updateCache(this.decodeCache, pattern, url, result);
return result;
}
/**
* Encode parameters into a URL based on a pattern
*/
encode<Pattern extends string>(
pattern: Pattern,
params: ExtractRouteParams<Pattern>,
): string {
// Special case for root path
if (pattern === "/") {
return "/";
}
const paramsKey = JSON.stringify(params);
// Check cache
const patternCache = this.encodeCache.get(pattern);
const cached = patternCache?.get(paramsKey);
// Only proceed with encoding if the cached value is undefined
// (not null or empty string, which are valid cached results)
if (cached !== undefined) return cached;
const { path: pathPattern } = splitPathAndQuery(pattern);
const segments = splitPath(pathPattern);
const paramTypes = this.extractParameterTypes(pattern, segments);
const queryDefs = this.patternQueryDefs.get(pattern);
validateParameters(
pattern,
segments,
params as RouteParams,
paramTypes,
queryDefs,
);
const resultSegments = buildUrlSegments(
segments,
params as RouteParams,
paramTypes,
);
let result = this.addBaseToUrl(joinPath(resultSegments));
if (!this.options.trailingSlash) {
result = result.replace(/\/$/, "");
} else if (!result.endsWith("/")) {
result += "/";
}
// Append query parameters
if (queryDefs && queryDefs.length > 0) {
result += buildQueryString(queryDefs, params as RouteParams);
}
// Update cache
this.updateCache(this.encodeCache, pattern, paramsKey, result);
return result;
}
/**
* Explicitly clear all caches
*/
clearCaches(): void {
this.decodeCache.clear();
this.encodeCache.clear();
}
/**
* Get all registered patterns
*/
getPatterns(): string[] {
return [...this.patterns];
}
/**
* Get potential parent patterns for a given pattern
*/
getPatternAncestors(pattern: string): string[] {
if (pattern === "/") {
return []; // Root has no parents
}
// Get the node for this pattern - direct lookup from map
const node = this.patternToNode.get(pattern);
if (!node) return [];
// Use the node's findAncestorPatterns method to get ancestors
return node.findAncestorPatterns();
}
/**
* Find all patterns that could be considered child patterns of the given pattern
*/
getPatternDescendants(pattern: string): string[] {
// Get the node for this pattern - direct lookup from map
const node = this.patternToNode.get(pattern);
if (!node) return [];
// Collect all patterns that end in nodes below this one
return this.collectDescendantPatterns(node);
}
/**
* Collect all patterns that end in nodes below a given node
*/
private collectDescendantPatterns(node: TrieNode): string[] {
const patterns: string[] = [];
const traverse = (current: TrieNode) => {
// If this node has a pattern and it's not the starting node's pattern,
// add it to the results
if (current !== node && current.isEndOfPattern && current.pattern) {
patterns.push(current.pattern);
}
// Traverse all children
for (const [_, child] of current.children) {
traverse(child);
}
// Check parameter children
if (current.paramChild) {
traverse(current.paramChild);
}
// Check wildcard children
if (current.wildcardChild) {
traverse(current.wildcardChild);
}
};
// Start DFS traversal from the node
traverse(node);
return patterns;
}
/**
* Update a cache with a new result
*/
private updateCache<K, V>(
cache: Map<string, Map<K, V>>,
pattern: string,
key: K,
value: V,
): void {
const cacheMap = this.ensureCacheMap(cache, pattern);
this.evictCacheIfNeeded(cacheMap);
cacheMap.set(key, value);
}
/**
* Ensure a cache map exists for the given key, evicting the oldest entry if needed
*/
private ensureCacheMap<K, V>(
cache: Map<string, Map<K, V>>,
key: string,
): Map<K, V> {
if (!cache.has(key)) {
// Check if we need to evict the oldest entry due to cache size
this.evictCacheIfNeeded(cache);
cache.set(key, new Map());
}
return cache.get(key)!;
}
/**
* Extract parameter types from a pattern by analyzing its segments
*/
private extractParameterTypes(
pattern: string,
segments: string[],
): Record<string, string> {
const paramTypes: Record<string, string> = {};
segments.forEach((segment) => {
if (isParameterSegment(segment)) {
const paramName = extractParamName(segment);
const paramType = extractParamType(segment);
paramTypes[paramName] = paramType;
}
});
return paramTypes;
}
/**
* Helper method to evict oldest cache entry if size limit is reached
*/
private evictCacheIfNeeded<K, V>(cache: Map<K, V>): void {
if (cache.size >= this.MAX_CACHE_SIZE) {
const iterator = cache.keys().next();
if (iterator.value !== undefined) {
cache.delete(iterator.value);
}
}
}
/**
* Remove a pattern from the trie
*/
removePattern(pattern: string): void {
if (!this.patterns.has(pattern)) {
return;
}
// Get the node for this pattern
const node = this.patternToNode.get(pattern);
if (node) {
// Just unmark it as a pattern end
node.isEndOfPattern = false;
node.pattern = null;
}
// Remove from maps and sets
this.patterns.delete(pattern);
this.patternToNode.delete(pattern);
this.patternQueryDefs.delete(pattern);
this.clearCaches();
}
/**
* Convert a URL to a route state
*/
urlToState<Pattern extends string>(
url: string,
): IPatternRouteState<Pattern> | null {
return this.match(url);
}
/**
* Convert a route state to a URL
*/
stateToUrl<Pattern extends string>(
state: IPatternRouteState<Pattern>,
): string | null {
if (!this.patterns.has(state.id)) return null;
// Simplified - encode() already handles validation and will return proper results
return this.encode(state.id, state.params);
}
/**
* Remove base path from URL if present
*/
private removeBaseFromUrl(url: string): string {
if (this.options.base && url.startsWith(this.options.base)) {
return url.replace(this.options.base, "");
}
return url;
}
/**
* Add base path to URL if configured
*/
private addBaseToUrl(url: string): string {
if (this.options.base && url.startsWith("/")) {
if (this.options.base.endsWith("/")) {
return this.options.base + url.replace(/^\//, "");
}
return this.options.base + url;
}
return url;
}
}