Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PoC: Introduce FilePatternRouter #1805

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions benchmarks/routers/src/bench-includes-init.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import findMyWay from 'find-my-way'
import KoaRouter from 'koa-tree-router'
import { run, bench, group } from 'mitata'
import TrekRouter from 'trek-router'
import { FilePatternRouter } from '../../../src/router/file-pattern-router/index.ts'
import { LinearRouter } from '../../../src/router/linear-router/index.ts'
import { RegExpRouter } from '../../../src/router/reg-exp-router/index.ts'
import { TrieRouter } from '../../../src/router/trie-router/index.ts'
Expand Down Expand Up @@ -71,6 +72,13 @@ for (const benchRoute of benchRoutes) {
}
router.match(benchRoute.method, benchRoute.path)
})
bench('FilePatternRouter', () => {
const router = new FilePatternRouter()
for (const route of routes) {
router.add(route.method, route.path, () => {})
}
router.match(benchRoute.method, benchRoute.path)
})
bench('MedleyRouter', () => {
const router = new MedleyRouter()
for (const route of routes) {
Expand Down
3 changes: 2 additions & 1 deletion benchmarks/routers/src/bench.mts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { run, bench, group } from 'mitata'
import { expressRouter } from './express.mts'
import { findMyWayRouter } from './find-my-way.mts'
import { regExpRouter, trieRouter } from './hono.mts'
import { regExpRouter, trieRouter, filePatternRouter } from './hono.mts'
import { koaRouter } from './koa-router.mts'
import { koaTreeRouter } from './koa-tree-router.mts'
import { medleyRouter } from './medley-router.mts'
Expand All @@ -13,6 +13,7 @@ import { trekRouter } from './trek-router.mts'
const routers: RouterInterface[] = [
regExpRouter,
trieRouter,
filePatternRouter,
medleyRouter,
findMyWayRouter,
koaTreeRouter,
Expand Down
2 changes: 2 additions & 0 deletions benchmarks/routers/src/hono.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FilePatternRouter } from '../../../src/router/file-pattern-router/index.ts'
import { RegExpRouter } from '../../../src/router/reg-exp-router/index.ts'
import { TrieRouter } from '../../../src/router/trie-router/index.ts'
import type { Router } from '../../../src/router.ts'
Expand All @@ -18,3 +19,4 @@ const createHonoRouter = (name: string, router: Router<unknown>): RouterInterfac

export const regExpRouter = createHonoRouter('RegExpRouter', new RegExpRouter())
export const trieRouter = createHonoRouter('TrieRouter', new TrieRouter())
export const filePatternRouter = createHonoRouter('FilePatternRouter', new FilePatternRouter())
1 change: 1 addition & 0 deletions deno_dist/router/file-pattern-router/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FilePatternRouter } from './router.ts'
214 changes: 214 additions & 0 deletions deno_dist/router/file-pattern-router/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import type { Result, Router, ParamIndexMap } from '../../router.ts'
import { METHOD_NAME_ALL } from '../../router.ts'

const MAX_PATH_LENGTH = 99
const STATIC_SORT_SCORE = MAX_PATH_LENGTH + 1
const emptyParam: string[] = []
const emptyParamIndexMap = {}

type HandlerData<T> = [T, ParamIndexMap][]
type StaticMap<T> = Record<string, HandlerData<T>>
type MatcherWithHint<T> = [
string | RegExp,
HandlerData<T>[],
StaticMap<T>,
number,
Record<string, number>
]
type Matcher<T> = [RegExp, HandlerData<T>[], StaticMap<T>]

type Route<T> = [number, number, string, boolean, string, string[], T] // [sortScore, index, method, isMiddleware, regexpStr, params, handler]

function addMatchers<T>(
matchersWithHint: Record<string, MatcherWithHint<T>>,
method: string,
[, index, , isMiddleware, regexpStr, params, handler]: Route<T>
) {
const skipRegister = method === METHOD_NAME_ALL && matchersWithHint[METHOD_NAME_ALL]

if (!matchersWithHint[method]) {
// new method
if (matchersWithHint[METHOD_NAME_ALL]) {
const template = matchersWithHint[METHOD_NAME_ALL]
matchersWithHint[method] = [
template[0],
[...template[1]],
Object.keys(template[2]).reduce<StaticMap<T>>((map, k) => {
map[k] = template[2][k].slice()
return map
}, {}),
template[3],
{ ...template[4] },
]
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
matchersWithHint[method] = ['', [], {}, 0, {}]
}
}
const matcher = matchersWithHint[method]
if (!skipRegister) {
if (params.length === 0 && !isMiddleware) {
// static routes
const handlers: [T, ParamIndexMap][] = []
handlers[index] = [handler, emptyParamIndexMap]
matcher[2][regexpStr] ||= handlers
return
}

if (matcher[4][regexpStr]) {
// already registered with the same routing
const handlerData = matcher[1][matcher[4][regexpStr]]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handlerData[index] = [handler, handlerData.find((v) => v)?.[1] as any]
} else {
const handlerData = []
handlerData[index] = [
handler,
params.length === 0
? emptyParamIndexMap
: params.reduce<Record<string, number>>((map, param) => {
map[param] = ++matcher[3]
return map
}, {}),
]
matcher[1][(matcher[4][regexpStr] = ++matcher[3])] = handlerData as HandlerData<T>
matcher[0] += `${(matcher[0] as string).length === 0 ? '^' : '|'}${regexpStr}()`
}
}

if (isMiddleware) {
// search for existing handlers with forward matching and add handlers to those that match
Object.keys(matcher[4]).forEach((k) => {
if (k === regexpStr) {
// already added for myself
return
}
if (k.startsWith(regexpStr)) {
const handlerData = matcher[1][matcher[4][k]]
const paramIndexMap =
params.length === 0
? emptyParamIndexMap
: params.reduce<Record<string, number>>((map, param) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map[param] = handlerData.find(Boolean)?.[1][param] as any
return map
}, {})
handlerData[index] = [handler, paramIndexMap]
}
})
Object.keys(matcher[2]).forEach((k) => {
if (k.startsWith(regexpStr)) {
matcher[2][k][index] = [handler, emptyParamIndexMap]
}
})
}
}

export class FilePatternRouter<T> implements Router<T> {
name: string = 'FilePatternRouter'
#routes: Route<T>[] = []

add(method: string, path: string, handler: T) {
const isMiddleware = path[path.length - 1] === '*'
if (isMiddleware) {
path = path.slice(0, -2)
}

if (!isMiddleware && path.indexOf(':') === -1) {
this.#routes.push([
STATIC_SORT_SCORE,
this.#routes.length,
method,
isMiddleware,
path,
emptyParam,
handler,
])
return
}

let sortScore: number = 0
const params: string[] = []
let ratio = 1

const parts = path.split(/(:\w+)/)
for (let i = 0, len = parts.length; i < len; i++) {
if (parts[i].length === 0) {
// skip
} else if (parts[i][0] === ':') {
params.push(parts[i].slice(1))
parts[i] = '([^/]+)'
sortScore += 1 * ratio
} else {
sortScore += parts[i].length
}

ratio /= MAX_PATH_LENGTH + 1
}

const regexpStr = parts.join('')
this.#routes.push([
isMiddleware ? sortScore + 0.01 * ratio : sortScore,
this.#routes.length,
method,
isMiddleware,
isMiddleware ? regexpStr : `${regexpStr}$`,
params,
handler,
])
}

private buildMatcher(
matchers: Record<string, MatcherWithHint<T>>,
method: string
): MatcherWithHint<T> {
this.#routes
.sort((a, b) => b[0] - a[0])
.forEach((route) => {
if (route[2] === METHOD_NAME_ALL) {
addMatchers(matchers, METHOD_NAME_ALL, route)
addMatchers(matchers, method, route)
} else if (route[2] === method) {
addMatchers(matchers, method, route)
}
})

if (matchers[method]) {
// force convert MatcherWithHint<T> to Matcher<T>
matchers[method][0] = new RegExp(matchers[method][0] || '^$')
matchers[method][1].forEach((v, i) => {
matchers[method][1][i] = v?.filter((v) => v)
})
Object.keys(matchers[method][2]).forEach((k) => {
matchers[method][2][k] = matchers[method][2][k].filter((v) => v)
})
} else {
matchers[method] = matchers[METHOD_NAME_ALL] || [/^$/, 0, {}]
}

return matchers[method]
}

match(method: string, path: string): Result<T> {
const matchers: Record<string, MatcherWithHint<T>> = {}
const match = (method: string, path: string): Result<T> => {
const matcher = (matchers[method] ||
this.buildMatcher(matchers, method)) as unknown as Matcher<T>

const staticMatch = matcher[2][path]
if (staticMatch) {
return [staticMatch, emptyParam]
}

const match = path.match(matcher[0])
if (!match) {
return [[], emptyParam]
}

const index = match.indexOf('', 1)
return [matcher[1][index], match]
}
this.match = match
return match(method, path)
}
}
1 change: 1 addition & 0 deletions src/router/file-pattern-router/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FilePatternRouter } from './router'