Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a7f8a88
feat(reg-exp-router): Introduced PreparedRegExpRouter
usualoma Dec 8, 2023
befcce4
fix: explicitly specify a type of `match` method.
usualoma Dec 9, 2023
e8dbcd2
test: refactor PreparedRegExpRouter test
usualoma Dec 9, 2023
54b82ca
refactor: simplify PreparedRegExpRouter API.
usualoma Dec 10, 2023
e98a119
Merge branch 'main' into feat/prepared-reg-exp-router
usualoma Oct 10, 2025
f9eef28
fix(reg-exp-router): run `bun run format:fix`
usualoma Oct 10, 2025
66146af
fix: applying updates from the main branch
usualoma Oct 10, 2025
3634ad2
fix: merge relocateMap if path is already in the map
usualoma Oct 11, 2025
db5eb2a
fix: should not skip wildcard path
usualoma Oct 11, 2025
48a4446
test(reg-exp-router): fix test for PreparedRegExpRouter
usualoma Oct 11, 2025
2ebcf28
refactor(reg-exp-router): fix import statement order
usualoma Oct 11, 2025
68e2279
refactor(reg-exp-router): in the first call, call the method directly…
usualoma Oct 11, 2025
5353aaf
refactor(reg-exp-router): use for...in instead of Object.keys
usualoma Oct 11, 2025
8b358c3
docs(reg-exp-router): add comments
usualoma Oct 11, 2025
8c4e605
refactor(reg-exp-router): use a more explicit type instead of `as any`.
usualoma Oct 11, 2025
2e32491
refactor(reg-exp-router): improve static route handling
usualoma Oct 11, 2025
b6d9068
refactor(reg-exp-router): throws an Error when an unregistered path i…
usualoma Oct 11, 2025
55d147e
test(reg-exp-router): add test for PreparedRegExpRouter
usualoma Oct 11, 2025
359780e
refactor(reg-exp-router): remove unnecessary guards as only METHOD_NA…
usualoma Oct 11, 2025
1bbe9e0
ci: apply automated fixes
autofix-ci[bot] Oct 11, 2025
4909093
fix(reg-exp-router): fix typo in test comment
usualoma Oct 11, 2025
5965b7f
test(reg-exp-router): remove unnecessary type annotation in test
usualoma Oct 11, 2025
1f9f94c
chore(benchmarks): update buildInitParams to use paths (spec change)
usualoma Oct 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion benchmarks/routers/src/bench-includes-init.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ import KoaRouter from 'koa-tree-router'
import { run, bench, group } from 'mitata'
import TrekRouter from 'trek-router'
import { LinearRouter } from '../../../src/router/linear-router/index.ts'
import { RegExpRouter } from '../../../src/router/reg-exp-router/index.ts'
import {
RegExpRouter,
PreparedRegExpRouter,
buildInitParams,
} from '../../../src/router/reg-exp-router/index.ts'
import { TrieRouter } from '../../../src/router/trie-router/index.ts'
import type { Route } from './tool.mts'
import { routes } from './tool.mts'

const preparedParams = buildInitParams({
paths: routes.map((r) => r.path),
})

const benchRoutes: (Route & { name: string })[] = [
{
name: 'short static',
Expand Down Expand Up @@ -57,6 +65,13 @@ for (const benchRoute of benchRoutes) {
}
router.match(benchRoute.method, benchRoute.path)
})
bench('PreparedRegExpRouter', () => {
const router = new PreparedRegExpRouter(preparedParams[0], preparedParams[1])
for (const route of routes) {
router.add(route.method, route.path, () => {})
}
router.match(benchRoute.method, benchRoute.path)
})
bench('TrieRouter', () => {
const router = new TrieRouter()
for (const route of routes) {
Expand Down
1 change: 1 addition & 0 deletions src/router/reg-exp-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
*/

export { RegExpRouter } from './router'
export { PreparedRegExpRouter, buildInitParams, serializeInitParams } from './prepared-router'
34 changes: 34 additions & 0 deletions src/router/reg-exp-router/matcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ParamIndexMap, Result, Router } from '../../router'
import { METHOD_NAME_ALL } from '../../router'

export type HandlerData<T> = [T, ParamIndexMap][]
export type StaticMap<T> = Record<string, Result<T>>
export type Matcher<T> = [RegExp, HandlerData<T>[], StaticMap<T>]
export type MatcherMap<T> = Record<string, Matcher<T> | null>

export const emptyParam: string[] = []
export const buildAllMatchersKey = Symbol('buildAllMatchers')
export function match<R extends Router<T>, T>(this: R, method: string, path: string): Result<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const matchers: MatcherMap<T> = (this as any)[buildAllMatchersKey]()

const match = ((method, path) => {
const matcher = (matchers[method] || matchers[METHOD_NAME_ALL]) as Matcher<T>

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

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

const index = match.indexOf('', 1)
return [matcher[1][index], match]
}) as Router<T>['match']

this.match = match
return match(method, path)
}
70 changes: 70 additions & 0 deletions src/router/reg-exp-router/prepared-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { runTest } from '../common.case.test'
import { buildInitParams, serializeInitParams, PreparedRegExpRouter } from './prepared-router'

describe('PreparedRegExpRouter', async () => {
runTest({
skip: [
{
reason: 'UnsupportedPath',
tests: [
'Duplicate param name > parent',
'Duplicate param name > child',
'Capture Group > Complex capturing group > GET request',
'Capture complex multiple directories > GET /part1/middle-b/latest',
'Capture complex multiple directories > GET /part1/middle-b/end-c/latest',
'Complex > Parameter with {.*} regexp',
],
},
{
reason:
'This route can not be added with `:label` to PreparedRegExpRouter. This is ambiguous',
tests: ['Including slashes > GET /js/main.js'],
},
],
newRouter: <T>() => {
let router: PreparedRegExpRouter<T>
const routes: [string, string, T][] = []
return {
name: 'PreparedRegExpRouterBuilder',
add: (method: string, path: string, handler: T) => {
routes.push([method, path, handler])
},
match: (method: string, path: string) => {
if (!router) {
const serialized = serializeInitParams(
buildInitParams({
paths: routes.map((r) => r[1]),
})
)
const params = eval(serialized) as ConstructorParameters<typeof PreparedRegExpRouter<T>>
router = new PreparedRegExpRouter<T>(...params)

for (const route of routes) {
router.add(...route)
}
}
return router.match(method, path)
},
}
},
})

describe('add()', () => {
it('should add a route', () => {
const params = buildInitParams({
paths: ['/hello'],
})
const router = new PreparedRegExpRouter(...params)
router.add('GET', '/hello', 'get hello')
expect(router.match('GET', '/hello')).toEqual([[['get hello', {}]], []])
})

it('should throw an error if the path is not pre-registered', () => {
const params = buildInitParams({
paths: ['/hello'],
})
const router = new PreparedRegExpRouter(...params)
expect(() => router.add('GET', '/unknown', 'get hello')).toThrowError()
})
})
})
141 changes: 141 additions & 0 deletions src/router/reg-exp-router/prepared-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { ParamIndexMap, Result, Router } from '../../router'
import { METHOD_NAME_ALL } from '../../router'
import type { HandlerData, Matcher, MatcherMap, StaticMap } from './matcher'
import { match, buildAllMatchersKey, emptyParam } from './matcher'
import { RegExpRouter } from './router'

type RelocateMap = Record<string, [(number | string)[], ParamIndexMap | undefined][]>

export class PreparedRegExpRouter<T> implements Router<T> {
name: string = 'PreparedRegExpRouter'
#matchers: MatcherMap<T>
#relocateMap: RelocateMap

constructor(matchers: MatcherMap<T>, relocateMap: RelocateMap) {
this.#matchers = matchers
this.#relocateMap = relocateMap
}

add(method: string, path: string, handler: T) {
const all = this.#matchers[METHOD_NAME_ALL] as Matcher<T>
this.#matchers[method] ||= [
all[0],
all[1].map((list) => (Array.isArray(list) ? list.slice() : 0)) as HandlerData<T>[],
Object.keys(all[2]).reduce((obj, key) => {
obj[key] = [all[2][key][0].slice(), emptyParam] as Result<T>
return obj
}, {} as StaticMap<T>),
]

if (path === '/*' || path === '*') {
const defaultHandlerData: [T, ParamIndexMap] = [handler, {}]
;(method === METHOD_NAME_ALL ? Object.keys(this.#matchers) : [method]).forEach((m) => {
const matcher = this.#matchers[m] as Matcher<T>
matcher[1].forEach((list) => list && list.push(defaultHandlerData))
Object.values(matcher[2]).forEach((list) =>
(list[0] as [T, ParamIndexMap][]).push(defaultHandlerData)
)
})
return
}

const data = this.#relocateMap[path]
if (!data) {
throw new Error(`Path ${path} is not registered`)
}
for (const [indexes, map] of data) {
;(method === METHOD_NAME_ALL ? Object.keys(this.#matchers) : [method]).forEach((m) => {
const matcher = this.#matchers[m] as Matcher<T>
if (!map) {
// assumed to be a static route
matcher[2][path][0].push([handler, {}])
} else {
indexes.forEach((index) => {
if (typeof index === 'number') {
matcher[1][index].push([handler, map])
} else {
;(matcher[2][index || path][0] as [T, ParamIndexMap][]).push([handler, map])
}
})
}
})
}
}

[buildAllMatchersKey](): MatcherMap<T> {
return this.#matchers
}

match: typeof match<Router<T>, T> = match
}

export const buildInitParams: (params: {
paths: string[]
}) => ConstructorParameters<typeof PreparedRegExpRouter> = ({ paths }) => {
const router = new RegExpRouter<string>()
for (const path of paths) {
router.add(METHOD_NAME_ALL, path, path)
}

const matchers = router[buildAllMatchersKey]()
const all = matchers[METHOD_NAME_ALL] as Matcher<string>

const relocateMap: RelocateMap = {}
for (const path of paths) {
all[1].forEach((list, i) => {
list.forEach(([p, map]) => {
if (p === path) {
if (relocateMap[path]) {
relocateMap[path][0][1] = {
...relocateMap[path][0][1],
...map,
}
} else {
relocateMap[path] = [[[], map]]
}
if (relocateMap[path][0][0].findIndex((j) => j === i) === -1) {
relocateMap[path][0][0].push(i)
}
}
})
})
for (const path2 in all[2]) {
all[2][path2][0].forEach(([p]) => {
if (p === path) {
relocateMap[path] ||= [[[], undefined]]
const value = path2 === path ? '' : path2
if (relocateMap[path][0][0].findIndex((v) => v === value) === -1) {
relocateMap[path][0][0].push(value)
}
}
})
}
}

for (let i = 0, len = all[1].length; i < len; i++) {
all[1][i] = all[1][i] ? [] : (0 as unknown as HandlerData<string>)
}
for (const path in all[2]) {
all[2][path][0] = []
}

return [matchers, relocateMap]
}

export const serializeInitParams: (
params: ConstructorParameters<typeof PreparedRegExpRouter>
) => string = ([matchers, relocateMap]) => {
for (const method in matchers) {
const matcher = matchers[method] as Matcher<string>
// escape the regular expression to serialize it with `JSON.stringify`
;(matcher[0] as RegExp & { toJSON: () => string }).toJSON = function () {
return `@${this.toString()}@`
}
}
// unescape the regular expression so that it can be deserialized with `eval`.
const matchersStr = JSON.stringify(matchers).replace(/"@(.+?)@"/g, (_, str) =>
str.replace(/\\\\/g, '\\')
)
const relocateMapStr = JSON.stringify(relocateMap)
return `[${matchersStr},${relocateMapStr}]`
}
38 changes: 7 additions & 31 deletions src/router/reg-exp-router/router.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import type { ParamIndexMap, Result, Router } from '../../router'
import type { ParamIndexMap, Router } from '../../router'
import {
MESSAGE_MATCHER_IS_ALREADY_BUILT,
METHOD_NAME_ALL,
UnsupportedPathError,
} from '../../router'
import { checkOptionalParameter } from '../../utils/url'
import type { HandlerData, StaticMap, Matcher, MatcherMap } from './matcher'
import { match, emptyParam, buildAllMatchersKey } from './matcher'
import { PATH_ERROR } from './node'
import type { ParamAssocArray } from './node'
import { Trie } from './trie'

type HandlerData<T> = [T, ParamIndexMap][]
type StaticMap<T> = Record<string, Result<T>>
type Matcher<T> = [RegExp, HandlerData<T>[], StaticMap<T>]
type HandlerWithMetadata<T> = [T, number] // [handler, paramCount]

const emptyParam: string[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nullMatcher: Matcher<any> = [/^$/, [], Object.create(null)]

Expand Down Expand Up @@ -205,33 +203,10 @@ export class RegExpRouter<T> implements Router<T> {
}
}

match(method: string, path: string): Result<T> {
clearWildcardRegExpCache() // no longer used.
match: typeof match<Router<T>, T> = match;

const matchers = this.#buildAllMatchers()

this.match = (method, path) => {
const matcher = (matchers[method] || matchers[METHOD_NAME_ALL]) as Matcher<T>

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

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

const index = match.indexOf('', 1)
return [matcher[1][index], match]
}

return this.match(method, path)
}

#buildAllMatchers(): Record<string, Matcher<T> | null> {
const matchers: Record<string, Matcher<T> | null> = Object.create(null)
[buildAllMatchersKey](): MatcherMap<T> {
const matchers: MatcherMap<T> = Object.create(null)

Object.keys(this.#routes!)
.concat(Object.keys(this.#middleware!))
Expand All @@ -241,6 +216,7 @@ export class RegExpRouter<T> implements Router<T> {

// Release cache
this.#middleware = this.#routes = undefined
clearWildcardRegExpCache()

return matchers
}
Expand Down
Loading