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: feat(reg-exp-router): Introduced PreparedRegExpRouter #1796

Open
wants to merge 4 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
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({
routes
})

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
@@ -1 +1,2 @@
export { RegExpRouter } from './router'
export { PreparedRegExpRouter, buildInitParams, serializeInitParams } from './prepared-router'
32 changes: 32 additions & 0 deletions src/router/reg-exp-router/matcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Router, Result, ParamIndexMap } 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>>

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]()

this.match = (method, path) => {
const matcher = matchers[method]

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)
}
144 changes: 144 additions & 0 deletions src/router/reg-exp-router/prepared-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { Result, Router, ParamIndexMap } from '../../router'
import { METHOD_NAME_ALL } from '../../router'
import type { Matcher, MatcherMap } 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]
this.#matchers[method] ||= [
all[0],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
all[1].map((list) => Array.isArray(list) ? list.slice() : 0) as any,
Object.keys(all[2]).reduce((obj, key) => {
obj[key] = [all[2][key][0].slice(), emptyParam]
return obj
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}, {} as any),
]

if (path === '/*' || path === '*') {
const defaultHandlerData: [T, ParamIndexMap] = [handler, {}]
;(method === METHOD_NAME_ALL ? Object.keys(this.#matchers) : [method]).forEach((m) => {
this.#matchers[m][1].forEach((list) => list && list.push(defaultHandlerData))
Object.values(this.#matchers[m][2]).forEach((list) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
list[0].push(defaultHandlerData as any)
)
})
return
}

const data = this.#relocateMap[path]
if (!data) {
return
}
for (const [indexes, map] of data) {
;(method === METHOD_NAME_ALL ? Object.keys(this.#matchers) : [method]).forEach((m) => {
if (!map) {
// assumed to be a static route
this.#matchers[m][2][path][0].push([handler, {}])
} else {
indexes.forEach((index) => {
if (typeof index === 'number') {
this.#matchers[m][1][index].push([handler, map])
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.#matchers[m][2][index || path][0].push([handler, map as any])
}
})
}
})
}
}

[buildAllMatchersKey](): Record<string, Matcher<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) {
if (path === '/*' || path === '*') {
continue
}
router.add(METHOD_NAME_ALL, path, path)
}

const matchers = router[buildAllMatchersKey]()
const all = matchers[METHOD_NAME_ALL]
Object.keys(matchers).forEach((method) => {
if (method !== METHOD_NAME_ALL) {
delete matchers[method]
}
})

const relocateMap: RelocateMap = {}
for (const path of paths) {
all[1].forEach((list, i) => {
list.forEach(([p, map]) => {
if (p === path) {
relocateMap[path] ||= [[[], map]]
if (relocateMap[path][0][0].findIndex((j) => j === i) === -1) {
relocateMap[path][0][0].push(i)
}
}
})
})
for (const path2 of Object.keys(all[2])) {
all[2][path2][0].forEach(([p, map]) => {
if (p === path) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
relocateMap[path] ||= [[[], map as any]]
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++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
all[1][i] = all[1][i] ? [] : (0 as any)
}
Object.keys(all[2]).forEach((path) => {
all[2][path][0] = []
})

return [matchers, relocateMap]
}

export const serializeInitParams: (
params: ConstructorParameters<typeof PreparedRegExpRouter>
) => string = ([matchers, relocateMap]) => {
for (const method of Object.keys(matchers)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(matchers[method][0] as any).toJSON = function () {
return `@${this.toString()}@`
}
}
const matchersStr = JSON.stringify(matchers).replace(/"@(.+?)@"/g, (_, str) =>
str.replace(/\\\\/g, '\\')
)
const relocateMapStr = JSON.stringify(relocateMap)
return `[${matchersStr},${relocateMapStr}]`
}
76 changes: 57 additions & 19 deletions src/router/reg-exp-router/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { UnsupportedPathError } from '../../router'
import { PreparedRegExpRouter, buildInitParams, serializeInitParams } from './prepared-router'
import { RegExpRouter } from './router'

describe('Basic Usage', () => {
Expand Down Expand Up @@ -204,7 +207,7 @@ describe('Multi match', () => {
describe('Check for duplicate parameter names', () => {
it('self', () => {
const router = new RegExpRouter<string>()
router.add('GET', '/:id/:id', 'get')
router.add('GET', '/:id/:id', 'GET')
const [res, stash] = router.match('GET', '/123/456')
expect(res.length).toBe(1)
expect(stash?.[res[0][1]['id'] as number]).toBe('123')
Expand Down Expand Up @@ -558,101 +561,136 @@ describe('ALL star, ALL star, GET static, ALL star...', () => {

describe('Routing with a hostname', () => {
const router = new RegExpRouter<string>()
router.add('get', 'www1.example.com/hello', 'www1')
router.add('get', 'www2.example.com/hello', 'www2')
router.add('GET', 'www1.example.com/hello', 'www1')
router.add('GET', 'www2.example.com/hello', 'www2')
it('GET www1.example.com/hello', () => {
const [res] = router.match('get', 'www1.example.com/hello')
const [res] = router.match('GET', 'www1.example.com/hello')
expect(res.length).toBe(1)
expect(res[0][0]).toEqual('www1')
})
it('GET www2.example.com/hello', () => {
const [res] = router.match('get', 'www2.example.com/hello')
const [res] = router.match('GET', 'www2.example.com/hello')
expect(res.length).toBe(1)
expect(res[0][0]).toEqual('www2')
})
it('GET /hello', () => {
const [res] = router.match('get', '/hello')
const [res] = router.match('GET', '/hello')
expect(res.length).toBe(0)
})
})

describe('Capture Group', () => {
describe('Simple capturing group', () => {
const router = new RegExpRouter<string>()
router.add('get', '/foo/:capture{(?:bar|baz)}', 'ok')
router.add('GET', '/foo/:capture{(?:bar|baz)}', 'ok')
it('GET /foo/bar', () => {
const [res, stash] = router.match('get', '/foo/bar')
const [res, stash] = router.match('GET', '/foo/bar')
expect(res.length).toBe(1)
expect(res[0][0]).toBe('ok')
expect(stash?.[res[0][1]['capture'] as number]).toBe('bar')
})

it('GET /foo/baz', () => {
const [res, stash] = router.match('get', '/foo/baz')
const [res, stash] = router.match('GET', '/foo/baz')
expect(res.length).toBe(1)
expect(res[0][0]).toBe('ok')
expect(stash?.[res[0][1]['capture'] as number]).toBe('baz')
})

it('GET /foo/qux', () => {
const [res] = router.match('get', '/foo/qux')
const [res] = router.match('GET', '/foo/qux')
expect(res.length).toBe(0)
})
})

describe('Non-capturing group', () => {
const router = new RegExpRouter<string>()
router.add('get', '/foo/:capture{(?:bar|baz)}', 'ok')
router.add('GET', '/foo/:capture{(?:bar|baz)}', 'ok')
it('GET /foo/bar', () => {
const [res, stash] = router.match('get', '/foo/bar')
const [res, stash] = router.match('GET', '/foo/bar')
expect(res.length).toBe(1)
expect(res[0][0]).toBe('ok')
expect(stash?.[res[0][1]['capture'] as number]).toBe('bar')
})

it('GET /foo/baz', () => {
const [res, stash] = router.match('get', '/foo/baz')
const [res, stash] = router.match('GET', '/foo/baz')
expect(res.length).toBe(1)
expect(res[0][0]).toBe('ok')
expect(stash?.[res[0][1]['capture'] as number]).toBe('baz')
})

it('GET /foo/qux', () => {
const [res] = router.match('get', '/foo/qux')
const [res] = router.match('GET', '/foo/qux')
expect(res.length).toBe(0)
})
})

describe('Non-capturing group with prefix', () => {
const router = new RegExpRouter<string>()
router.add('get', '/foo/:capture{ba(?:r|z)}', 'ok')
router.add('GET', '/foo/:capture{ba(?:r|z)}', 'ok')
it('GET /foo/bar', () => {
const [res, stash] = router.match('get', '/foo/bar')
const [res, stash] = router.match('GET', '/foo/bar')
expect(res.length).toBe(1)
expect(res[0][0]).toBe('ok')
expect(stash?.[res[0][1]['capture'] as number]).toBe('bar')
})

it('GET /foo/baz', () => {
const [res, stash] = router.match('get', '/foo/baz')
const [res, stash] = router.match('GET', '/foo/baz')
expect(res.length).toBe(1)
expect(res[0][0]).toBe('ok')
expect(stash?.[res[0][1]['capture'] as number]).toBe('baz')
})

it('GET /foo/qux', () => {
const [res] = router.match('get', '/foo/qux')
const [res] = router.match('GET', '/foo/qux')
expect(res.length).toBe(0)
})
})

describe('Complex capturing group', () => {
const router = new RegExpRouter<string>()
router.add('get', '/foo/:capture{ba(r|z)}', 'ok')
router.add('GET', '/foo/:capture{ba(r|z)}', 'ok')
it('GET request', () => {
expect(() => {
router.match('GET', '/foo/bar')
}).toThrowError(UnsupportedPathError)
})
})
})

describe('PreparedRegExpRouter', async () => {
const serialized = serializeInitParams(
buildInitParams({
paths: ['*', '/static', '/posts/:id/*', '/posts/:id', '/posts/:id/comments', '/posts'],
})
)

const path = `${tmpdir()}/params.ts`
await writeFile(path, `const params = ${serialized}; export default params`)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const params: ConstructorParameters<typeof PreparedRegExpRouter<string>> = (await import(path)).default
const router = new PreparedRegExpRouter<string>(...params)

router.add('ALL', '*', 'wildcard')
router.add('GET', '*', 'star1')
router.add('GET', '/static', 'static')
router.add('ALL', '/posts/:id/*', 'all star2')
router.add('GET', '/posts/:id/*', 'star2')
router.add('GET', '/posts/:id', 'post')
router.add('GET', '/posts/:id/comments', 'comments')
router.add('POST', '/posts', 'create')
router.add('PUT', '/posts/:id', 'update')

it('GET /posts/123/comments', async () => {
const [res] = router.match('GET', '/posts/123/comments')
expect(res.length).toBe(5)
expect(res[0][0]).toEqual('wildcard')
expect(res[1][0]).toEqual('star1')
expect(res[2][0]).toEqual('all star2')
expect(res[3][0]).toEqual('star2')
expect(res[4][0]).toEqual('comments')
})
})