UNPKG

memoirist

Version:

Elysia's Radix Tree router for fast matching dynamic parameters

436 lines (351 loc) 9.54 kB
export interface FindResult<T> { store: T params: Record<string, any> } export interface ParamNode<T> { name: string store: T | null inert: Node<T> | null } export interface Node<T> { part: string store: T | null inert: Record<number, Node<T>> | null params: ParamNode<T> | null wildcardStore: T | null } const createNode = <T>(part: string, inert?: Node<T>[]): Node<T> => { const inertMap: Record<number, Node<T>> | null = inert?.length ? {} : null if (inertMap) for (const child of inert!) inertMap[child.part.charCodeAt(0)] = child return { part, store: null, inert: inertMap, params: null, wildcardStore: null } } const cloneNode = <T>(node: Node<T>, part: string) => ({ ...node, part }) const createParamNode = <T>(name: string): ParamNode<T> => ({ name, store: null, inert: null }) type MaybeArray<T> = T | T[] type ProcessParam = (value: string, key: string) => unknown export interface Config { /** * lazily create nodes * * @default undefined * @since 0.3.0 */ lazy?: boolean /** * process dynamic parameter */ onParam?: MaybeArray<ProcessParam> } export class Memoirist<T> { root: Record<string, Node<T>> = {} history: [string, string, T][] = [] deferred: [string, string, T][] = [] constructor(public config: Config = {}) { if (config.lazy) // @ts-expect-error this.find = this.lazyFind if (config.onParam && !Array.isArray(config.onParam)) this.config.onParam = [ this.config.onParam as (param: string) => unknown ] } private static regex = { static: /:.+?(?=\/|$)/, params: /:.+?(?=\/|$)/g, optionalParams: /(\/:\w+\?)/g } private lazyFind = (method: string, url: string) => { if (!this.config.lazy) return this.find this.build() return this.find(method, url) } build() { if (!this.config.lazy) return for (const [method, path, store] of this.deferred) this.add(method, path, store, { lazy: false, ignoreHistory: true }) this.deferred = [] this.find = (method: string, url: string): FindResult<T> | null => { const root = this.root[method] if (!root) return null return matchRoute( url, url.length, root, 0, this.config.onParam as ProcessParam[] ) } } add( method: string, path: string, store: T, { ignoreError = false, ignoreHistory = false, lazy = this.config.lazy }: { ignoreError?: boolean ignoreHistory?: boolean lazy?: boolean } = {} ): FindResult<T>['store'] { if (lazy) { // @ts-expect-error this.find = this.lazyFind this.deferred.push([method, path, store]) return store } if (typeof path !== 'string') throw new TypeError('Route path must be a string') if (path === '') path = '/' else if (path[0] !== '/') path = `/${path}` const isWildcard = path[path.length - 1] === '*' // End with ? and is param const optionalParams = path.match(Memoirist.regex.optionalParams) if (optionalParams) { const originalPath = path.replaceAll('?', '') this.add(method, originalPath, store, { ignoreError, ignoreHistory, lazy }) for (let i = 0; i < optionalParams.length; i++) { let newPath = path.replace(optionalParams[i], '') this.add(method, newPath, store, { ignoreError: true, ignoreHistory, lazy }) } return store } if (optionalParams) path = path.replaceAll('?', '') if (this.history.find(([m, p, s]) => m === method && p === path)) return store if ( isWildcard || (optionalParams && path.charCodeAt(path.length - 1) === 63) ) // Slice off trailing '*' path = path.slice(0, -1) if (!ignoreHistory) this.history.push([method, path, store]) const inertParts = path.split(Memoirist.regex.static) const paramParts = path.match(Memoirist.regex.params) || [] if (inertParts[inertParts.length - 1] === '') inertParts.pop() let node: Node<T> if (!this.root[method]) node = this.root[method] = createNode<T>('/') else node = this.root[method] let paramPartsIndex = 0 for (let i = 0; i < inertParts.length; ++i) { let part = inertParts[i] if (i > 0) { // Set param on the node const param = paramParts[paramPartsIndex++].slice(1) if (node.params === null) node.params = createParamNode(param) else if (node.params.name !== param) { if (ignoreError) return store else throw new Error( `Cannot create route "${path}" with parameter "${param}" ` + 'because a route already exists with a different parameter name ' + `("${node.params.name}") in the same location` ) } const params = node.params if (params.inert === null) { node = params.inert = createNode(part) continue } node = params.inert } for (let j = 0; ; ) { if (j === part.length) { if (j < node.part.length) { // Move the current node down const childNode = cloneNode(node, node.part.slice(j)) Object.assign(node, createNode(part, [childNode])) } break } if (j === node.part.length) { // Add static child if (node.inert === null) node.inert = {} const inert = node.inert[part.charCodeAt(j)] if (inert) { // Re-run loop with existing static node node = inert part = part.slice(j) j = 0 continue } // Create new node const childNode = createNode<T>(part.slice(j)) node.inert[part.charCodeAt(j)] = childNode node = childNode break } if (part[j] !== node.part[j]) { // Split the node const existingChild = cloneNode(node, node.part.slice(j)) const newChild = createNode<T>(part.slice(j)) Object.assign( node, createNode(node.part.slice(0, j), [ existingChild, newChild ]) ) node = newChild break } ++j } } if (paramPartsIndex < paramParts.length) { // The final part is a parameter const param = paramParts[paramPartsIndex] const name = param.slice(1) if (node.params === null) node.params = createParamNode(name) else if (node.params.name !== name) { if (ignoreError) return store else throw new Error( `Cannot create route "${path}" with parameter "${name}" ` + 'because a route already exists with a different parameter name ' + `("${node.params.name}") in the same location` ) } if (node.params.store === null) node.params.store = store return node.params.store! } if (isWildcard) { // The final part is a wildcard if (node.wildcardStore === null) node.wildcardStore = store return node.wildcardStore! } // The final part is static if (node.store === null) node.store = store return node.store! } find(method: string, url: string): FindResult<T> | null { const root = this.root[method] if (!root) return null return matchRoute( url, url.length, root, 0, this.config.onParam as ProcessParam[] ) } } const matchRoute = <T>( url: string, urlLength: number, node: Node<T>, startIndex: number, onParam?: ProcessParam[] ): FindResult<T> | null => { const part = node.part const length = part.length const endIndex = startIndex + length // Only check the pathPart if its length is > 1 since the parent has // already checked that the url matches the first character if (length > 1) { if (endIndex > urlLength) return null // Using a loop is faster for short strings if (length < 15) { for (let i = 1, j = startIndex + 1; i < length; ++i, ++j) if (part.charCodeAt(i) !== url.charCodeAt(j)) return null } else if (url.slice(startIndex, endIndex) !== part) return null } // Reached the end of the URL if (endIndex === urlLength) { if (node.store !== null) return { store: node.store, params: {} } if (node.wildcardStore !== null) return { store: node.wildcardStore, params: { '*': '' } } return null } // Check for a static leaf if (node.inert !== null) { const inert = node.inert[url.charCodeAt(endIndex)] if (inert !== undefined) { const route = matchRoute(url, urlLength, inert, endIndex, onParam) if (route !== null) return route } } // Check for dynamic leaf if (node.params !== null) { const { store, name, inert } = node.params const slashIndex = url.indexOf('/', endIndex) if (slashIndex !== endIndex) { // Params cannot be empty if (slashIndex === -1 || slashIndex >= urlLength) { if (store !== null) { // This is much faster than using a computed property const params: Record<string, string> = {} params[name] = url.substring(endIndex, urlLength) if (onParam) for (let i = 0; i < onParam.length; i++) { let temp = onParam[i](params[name], name) if (temp !== undefined) params[name] = temp as any } return { store, params } } } else if (inert !== null) { const route = matchRoute( url, urlLength, inert, slashIndex, onParam ) if (route !== null) { route.params[name] = url.substring(endIndex, slashIndex) if (onParam) for (let i = 0; i < onParam.length; i++) { let temp = onParam[i](route.params[name], name) if (temp !== undefined) route.params[name] = temp as any } return route } } } } // Check for wildcard leaf if (node.wildcardStore !== null) return { store: node.wildcardStore, params: { '*': url.substring(endIndex, urlLength) } } return null } export default Memoirist