436 lines
9.3 KiB
TypeScript
436 lines
9.3 KiB
TypeScript
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
|