import type { RoutePattern } from '../route-pattern.ts' import { decodeHostname } from './decode.ts' import { generateVariants, type Param } from './variant.ts' import { unreachable } from '../unreachable.ts' import type { Match, MatchParamMeta } from './types.ts' export class Trie { readonly ignoreCase: boolean #root: ProtocolNode constructor(options?: { ignoreCase?: boolean }) { this.ignoreCase = options?.ignoreCase ?? false this.#root = { http: createHostnameNode(), https: createHostnameNode(), } } insert(pattern: RoutePattern, data: data): void { for (let variant of generateVariants(pattern)) { let hostnameNode = this.#root[variant.protocol] let portNode: PortNode if (variant.hostname.type === 'any') { portNode = hostnameNode.any } else if (variant.hostname.type === 'static') { let key = variant.hostname.value.toLowerCase() let existing = hostnameNode.static.get(key) if (existing === undefined) { existing = new Map() hostnameNode.static.set(key, existing) } portNode = existing } else { portNode = new Map() hostnameNode.dynamic.push({ regexp: variant.hostname.regexp, params: variant.hostname.params, portNode, }) } let pathnameRoot = portNode.get(variant.port) if (pathnameRoot === undefined) { pathnameRoot = createPathnameNode() portNode.set(variant.port, pathnameRoot) } let pathnameNode = pathnameRoot for (let segment of variant.pathname) { if (segment.type === 'static') { let key = this.ignoreCase ? segment.key.toLowerCase() : segment.key let next = pathnameNode.static.get(key) if (next === undefined) { next = createPathnameNode() pathnameNode.static.set(key, next) } pathnameNode = next continue } if (segment.type === 'variable') { let next = pathnameNode.variable.get(segment.key) if (next === undefined) { next = { regexp: segment.regexp, pathnameNode: createPathnameNode() } pathnameNode.variable.set(segment.key, next) } pathnameNode = next.pathnameNode continue } if (segment.type === 'wildcard') { let next = pathnameNode.wildcard.get(segment.key) if (next === undefined) { next = { regexp: segment.regexp, pathnameNode: createPathnameNode() } pathnameNode.wildcard.set(segment.key, next) } pathnameNode = next.pathnameNode continue } unreachable(segment) } let requiredParams: Array = [] for (let segment of variant.pathname) { if (segment.type === 'variable' || segment.type === 'wildcard') { for (let param of segment.params) requiredParams.push(param) } } let undefinedParams: Array = [] for (let param of pattern.pathname.tokens) { if (param.type !== ':' && param.type !== '*') continue if (requiredParams.some((p) => p.name === param.name)) continue if (undefinedParams.some((p) => p.name === param.name)) continue undefinedParams.push(param) } pathnameNode.values.push({ pattern, data, requiredParams, undefinedParams }) } } search(url: URL): Array> { let protocol = url.protocol.slice(0, -1) if (protocol !== 'http' && protocol !== 'https') return [] let hostnameNode = this.#root[protocol] let decodedHostname = decodeHostname(url.hostname) let origins: Array<{ hostnameMatch: Array; pathnameNode: PathnameNode }> = [] // any hostname (no port allowed -- empty-port key) let anyPathname = hostnameNode.any.get('') if (anyPathname) { origins.push({ hostnameMatch: [ { type: '*', name: '*', value: decodedHostname, begin: 0, end: decodedHostname.length }, ], pathnameNode: anyPathname, }) } // static hostname let staticPort = hostnameNode.static.get(decodedHostname.toLowerCase()) if (staticPort) { let next = staticPort.get(url.port) if (next) origins.push({ hostnameMatch: [], pathnameNode: next }) } // dynamic hostnames for (let entry of hostnameNode.dynamic) { let m = entry.regexp.exec(decodedHostname) if (!m) continue let next = entry.portNode.get(url.port) if (!next) continue let hostnameMatch: Array = [] for (let i = 0; i < entry.params.length; i++) { let param = entry.params[i] let span = m.indices?.[i + 1] if (span === undefined) continue hostnameMatch.push({ type: param.type, name: param.name, value: m[i + 1], begin: span[0], end: span[1], }) } origins.push({ hostnameMatch, pathnameNode: next }) } let results: Array> = [] let urlSegments = normalizePathname(url.pathname) if (urlSegments === null) return results for (let origin of origins) { let stack: Array<{ segmentIndex: number pathnameNode: PathnameNode charOffset: number captures: Array<{ value: string; begin: number; end: number }> }> = [{ segmentIndex: 0, pathnameNode: origin.pathnameNode, charOffset: 0, captures: [] }] while (stack.length > 0) { let current = stack.pop()! if (current.segmentIndex === urlSegments.length) { for (let value of current.pathnameNode.values) { if (!matchSearch(url.searchParams, value.pattern.search)) continue let pathnameMatch: Array = [] for (let i = 0; i < value.requiredParams.length; i++) { let param = value.requiredParams[i] let cap = current.captures[i] pathnameMatch.push({ type: param.type, name: param.name, value: fastDecodeURIComponent(cap.value), begin: cap.begin, end: cap.end, }) } let params: Record = {} for (let token of value.pattern.hostname?.tokens ?? []) { if ((token.type === ':' || token.type === '*') && token.name !== '*') { params[token.name] = undefined } } for (let token of value.pattern.pathname.tokens) { if ((token.type === ':' || token.type === '*') && token.name !== '*') { params[token.name] = undefined } } for (let p of origin.hostnameMatch) { if (p.name === '*') continue params[p.name] = p.value } for (let p of pathnameMatch) { if (p.name === '*') continue params[p.name] = p.value } results.push({ url, pattern: value.pattern, data: value.data, params, paramsMeta: { hostname: origin.hostnameMatch, pathname: pathnameMatch }, }) } continue } let urlSegment = urlSegments[current.segmentIndex] let staticKey = this.ignoreCase ? urlSegment.toLowerCase() : urlSegment let nextStatic = current.pathnameNode.static.get(staticKey) if (nextStatic) { stack.push({ segmentIndex: current.segmentIndex + 1, pathnameNode: nextStatic, charOffset: current.charOffset + urlSegment.length + 1, captures: current.captures, }) } for (let { regexp, pathnameNode } of current.pathnameNode.variable.values()) { let m = regexp.exec(urlSegment) if (!m) continue let captures = current.captures.slice() for (let i = 1; i < m.indices!.length; i++) { let span = m.indices![i] if (span === undefined) unreachable() captures.push({ value: m[i], begin: current.charOffset + span[0], end: current.charOffset + span[1], }) } stack.push({ segmentIndex: current.segmentIndex + 1, pathnameNode, charOffset: current.charOffset + m.index + m[0].length + 1, captures, }) } for (let { regexp, pathnameNode } of current.pathnameNode.wildcard.values()) { let remaining = urlSegments.slice(current.segmentIndex).join('/') let m = regexp.exec(remaining) if (!m) continue let captures = current.captures.slice() for (let i = 1; i < m.indices!.length; i++) { let span = m.indices![i] if (span === undefined) continue captures.push({ value: m[i], begin: current.charOffset + span[0], end: current.charOffset + span[1], }) } stack.push({ segmentIndex: urlSegments.length, pathnameNode, charOffset: current.charOffset + remaining.length, captures, }) } } } return results } } // Pathname codec ---------------------------------------------------------------------------------- // Pathname matching uses canonical percent-encoded text. URL pathnames are split on structural // "/" before normalization so encoded slashes like "%2F" remain data within a segment instead of // becoming separators. Pattern static text is encoded the same way when variants are generated. function normalizePathname(pathname: string): string[] | null { let segments: string[] = [] for (let segment of pathname.slice(1).split('/')) { let normalized = normalizePathnameText(segment) if (normalized === null) return null segments.push(normalized) } return segments } function normalizePathnameText(text: string): string | null { let decoded = safeDecodeURIComponent(text) return decoded === null ? null : encodeURIComponent(decoded) } function fastDecodeURIComponent(text: string): string { return text.includes('%') ? decodeURIComponent(text) : text } function safeDecodeURIComponent(text: string): string | null { try { return fastDecodeURIComponent(text) } catch (error) { if (error instanceof URIError) return null throw error } } // Search ------------------------------------------------------------------------------------------ function matchSearch( params: URLSearchParams, constraints: ReadonlyMap>, ): boolean { for (let [name, requiredValues] of constraints) { if (requiredValues.size === 0) { if (!params.has(name)) return false continue } let values = params.getAll(name) for (let requiredValue of requiredValues) { if (!values.includes(requiredValue)) return false } } return true } // Trie nodes -------------------------------------------------------------------------------------- type ProtocolNode = { http: HostnameNode https: HostnameNode } type HostnameNode = { static: Map> dynamic: Array<{ regexp: RegExp params: ReadonlyArray portNode: PortNode }> any: PortNode } type PortNode = Map> type PathnameNode = { static: Map> variable: Map }> wildcard: Map }> values: Array<{ pattern: RoutePattern data: data requiredParams: Array undefinedParams: Array }> } function createHostnameNode(): HostnameNode { return { static: new Map(), dynamic: [], any: new Map() } } function createPathnameNode(): PathnameNode { return { static: new Map(), variable: new Map(), wildcard: new Map(), values: [] } }