diff --git a/benchmarks/routers/src/bench-includes-init.mts b/benchmarks/routers/src/bench-includes-init.mts index 31b5f6757..0d876f3a2 100644 --- a/benchmarks/routers/src/bench-includes-init.mts +++ b/benchmarks/routers/src/bench-includes-init.mts @@ -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' @@ -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) { diff --git a/benchmarks/routers/src/bench.mts b/benchmarks/routers/src/bench.mts index c0cf69301..85bc76202 100644 --- a/benchmarks/routers/src/bench.mts +++ b/benchmarks/routers/src/bench.mts @@ -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' @@ -13,6 +13,7 @@ import { trekRouter } from './trek-router.mts' const routers: RouterInterface[] = [ regExpRouter, trieRouter, + filePatternRouter, medleyRouter, findMyWayRouter, koaTreeRouter, diff --git a/benchmarks/routers/src/hono.mts b/benchmarks/routers/src/hono.mts index 6c72cad4f..a2804045a 100644 --- a/benchmarks/routers/src/hono.mts +++ b/benchmarks/routers/src/hono.mts @@ -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' @@ -18,3 +19,4 @@ const createHonoRouter = (name: string, router: Router): RouterInterfac export const regExpRouter = createHonoRouter('RegExpRouter', new RegExpRouter()) export const trieRouter = createHonoRouter('TrieRouter', new TrieRouter()) +export const filePatternRouter = createHonoRouter('FilePatternRouter', new FilePatternRouter()) diff --git a/deno_dist/router/file-pattern-router/index.ts b/deno_dist/router/file-pattern-router/index.ts new file mode 100644 index 000000000..cb3c04c04 --- /dev/null +++ b/deno_dist/router/file-pattern-router/index.ts @@ -0,0 +1 @@ +export { FilePatternRouter } from './router.ts' diff --git a/deno_dist/router/file-pattern-router/router.ts b/deno_dist/router/file-pattern-router/router.ts new file mode 100644 index 000000000..99f79efe7 --- /dev/null +++ b/deno_dist/router/file-pattern-router/router.ts @@ -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, ParamIndexMap][] +type StaticMap = Record> +type MatcherWithHint = [ + string | RegExp, + HandlerData[], + StaticMap, + number, + Record +] +type Matcher = [RegExp, HandlerData[], StaticMap] + +type Route = [number, number, string, boolean, string, string[], T] // [sortScore, index, method, isMiddleware, regexpStr, params, handler] + +function addMatchers( + matchersWithHint: Record>, + method: string, + [, index, , isMiddleware, regexpStr, params, handler]: Route +) { + 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>((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>((map, param) => { + map[param] = ++matcher[3] + return map + }, {}), + ] + matcher[1][(matcher[4][regexpStr] = ++matcher[3])] = handlerData as HandlerData + 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>((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 implements Router { + name: string = 'FilePatternRouter' + #routes: Route[] = [] + + 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>, + method: string + ): MatcherWithHint { + 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 to Matcher + 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 { + const matchers: Record> = {} + const match = (method: string, path: string): Result => { + const matcher = (matchers[method] || + this.buildMatcher(matchers, method)) as unknown as Matcher + + 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) + } +} diff --git a/src/router/file-pattern-router/index.ts b/src/router/file-pattern-router/index.ts new file mode 100644 index 000000000..2985ce8f4 --- /dev/null +++ b/src/router/file-pattern-router/index.ts @@ -0,0 +1 @@ +export { FilePatternRouter } from './router' diff --git a/src/router/file-pattern-router/router.test.ts b/src/router/file-pattern-router/router.test.ts new file mode 100644 index 000000000..4b756c724 --- /dev/null +++ b/src/router/file-pattern-router/router.test.ts @@ -0,0 +1,658 @@ +import { UnsupportedPathError } from '../../router' +import { FilePatternRouter } from './router' + +describe('Basic Usage', () => { + const router = new FilePatternRouter() + + router.add('GET', '/hello', 'get hello') + router.add('POST', '/hello', 'post hello') + router.add('PURGE', '/hello', 'purge hello') + + it('get, post hello', async () => { + let [res] = router.match('GET', '/hello') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('get hello') + ;[res] = router.match('POST', '/hello') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('post hello') + ;[res] = router.match('PURGE', '/hello') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('purge hello') + ;[res] = router.match('PUT', '/hello') + expect(res.length).toBe(0) + ;[res] = router.match('GET', '/') + expect(res.length).toBe(0) + }) +}) + +describe('Complex', () => { + let router: FilePatternRouter + beforeEach(() => { + router = new FilePatternRouter() + }) + + it('Named Param', async () => { + router.add('GET', '/entry/:id', 'get entry') + const [res, stash] = router.match('GET', '/entry/123') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('get entry') + expect(stash?.[res[0][1]['id'] as number]).toBe('123') + }) + + it.skip('Wildcard', async () => { + router.add('GET', '/wild/*/card', 'get wildcard') + const [res] = router.match('GET', '/wild/xxx/card') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('get wildcard') + }) + + it('Default', async () => { + router.add('GET', '/api/abc', 'get api') + router.add('GET', '/api/*', 'fallback') + let [res] = router.match('GET', '/api/abc') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('get api') + expect(res[1][0]).toEqual('fallback') + ;[res] = router.match('GET', '/api/def') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('fallback') + }) + + it.skip('Regexp', async () => { + router.add('GET', '/post/:date{[0-9]+}/:title{[a-z]+}', 'get post') + let [res, stash] = router.match('GET', '/post/20210101/hello') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('get post') + expect(stash?.[res[0][1]['date'] as number]).toBe('20210101') + expect(stash?.[res[0][1]['title'] as number]).toBe('hello') + ;[res] = router.match('GET', '/post/onetwothree') + expect(res.length).toBe(0) + ;[res] = router.match('GET', '/post/123/123') + expect(res.length).toBe(0) + }) + + it('/*', async () => { + router.add('GET', '/api/*', 'auth middleware') + router.add('GET', '/api', 'top') + router.add('GET', '/api/posts', 'posts') + router.add('GET', '/api/*', 'fallback') + + let [res] = router.match('GET', '/api') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('auth middleware') + expect(res[1][0]).toEqual('top') + expect(res[2][0]).toEqual('fallback') + ;[res] = router.match('GET', '/api/posts') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('auth middleware') + expect(res[1][0]).toEqual('posts') + expect(res[2][0]).toEqual('fallback') + }) +}) + +describe('Registration order', () => { + let router: FilePatternRouter + beforeEach(() => { + router = new FilePatternRouter() + }) + + it('middleware -> handler', async () => { + router.add('GET', '*', 'bar') + router.add('GET', '/:type/:action', 'foo') + const [res] = router.match('GET', '/posts/123') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('bar') + expect(res[1][0]).toEqual('foo') + }) + + it('handler -> fallback', async () => { + router.add('GET', '/:type/:action', 'foo') + router.add('GET', '*', 'fallback') + const [res] = router.match('GET', '/posts/123') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('foo') + expect(res[1][0]).toEqual('fallback') + }) +}) + +describe('Multi match', () => { + describe('Blog', () => { + const router = new FilePatternRouter() + + router.add('ALL', '*', 'middleware a') + router.add('GET', '*', 'middleware b') + router.add('GET', '/entry', 'get entries') + router.add('POST', '/entry/*', 'middleware c') + router.add('POST', '/entry', 'post entry') + router.add('GET', '/entry/:id', 'get entry') + router.add('GET', '/entry/:id/comment/:comment_id', 'get comment') + it('GET /', async () => { + const [res] = router.match('GET', '/') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('middleware a') + expect(res[1][0]).toEqual('middleware b') + }) + it('GET /entry/123', async () => { + const [res, stash] = router.match('GET', '/entry/123') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('middleware a') + expect(stash?.[res[0][1]['id'] as number]).toBe(undefined) + expect(res[1][0]).toEqual('middleware b') + expect(stash?.[res[1][1]['id'] as number]).toBe(undefined) + expect(res[2][0]).toEqual('get entry') + expect(stash?.[res[2][1]['id'] as number]).toBe('123') + }) + it('GET /entry/123/comment/456', async () => { + const [res, stash] = router.match('GET', '/entry/123/comment/456') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('middleware a') + expect(stash?.[res[0][1]['id'] as number]).toBe(undefined) + expect(stash?.[res[0][1]['comment_id'] as number]).toBe(undefined) + expect(res[1][0]).toEqual('middleware b') + expect(stash?.[res[1][1]['id'] as number]).toBe(undefined) + expect(stash?.[res[1][1]['comment_id'] as number]).toBe(undefined) + expect(res[2][0]).toEqual('get comment') + expect(stash?.[res[2][1]['id'] as number]).toBe('123') + expect(stash?.[res[2][1]['comment_id'] as number]).toBe('456') + }) + it('POST /entry', async () => { + const [res] = router.match('POST', '/entry') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('middleware a') + expect(res[1][0]).toEqual('middleware c') + expect(res[2][0]).toEqual('post entry') + }) + it('DELETE /entry', async () => { + const [res] = router.match('DELETE', '/entry') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('middleware a') + }) + }) + + describe('`params` per a handler', () => { + const router = new FilePatternRouter() + + router.add('ALL', '*', 'middleware a') + router.add('GET', '/entry/:id/*', 'middleware b') + router.add('GET', '/entry/:id/:action', 'action') + + it('GET /entry/123/show', async () => { + const [res, stash] = router.match('GET', '/entry/123/show') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('middleware a') + expect(stash?.[res[0][1]['id'] as number]).toBe(undefined) + expect(stash?.[res[0][1]['action'] as number]).toBe(undefined) + expect(res[1][0]).toEqual('middleware b') + expect(stash?.[res[1][1]['id'] as number]).toBe('123') + expect(stash?.[res[1][1]['comment_id'] as number]).toBe(undefined) + expect(res[2][0]).toEqual('action') + expect(stash?.[res[2][1]['id'] as number]).toBe('123') + expect(stash?.[res[2][1]['action'] as number]).toBe('show') + }) + }) + + it('hierarchy', () => { + const router = new FilePatternRouter() + router.add('GET', '/posts/:id/comments/:comment_id', 'foo') + router.add('GET', '/posts/:id', 'bar') + expect(() => { + router.match('GET', '/') + }).not.toThrow() + }) +}) + +describe.skip('Check for duplicate parameter names', () => { + it('self', () => { + const router = new FilePatternRouter() + 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') + }) +}) + +describe.skip('UnsupportedPathError', () => { + describe('Ambiguous', () => { + const router = new FilePatternRouter() + + router.add('GET', '/:user/entries', 'get user entries') + router.add('GET', '/entry/:name', 'get entry') + router.add('POST', '/entry', 'create entry') + + it('GET /entry/entries', async () => { + expect(() => { + router.match('GET', '/entry/entries') + }).toThrowError(UnsupportedPathError) + }) + }) + + describe('Multiple handlers with different label', () => { + const router = new FilePatternRouter() + + router.add('GET', '/:type/:id', ':type') + router.add('GET', '/:class/:id', ':class') + router.add('GET', '/:model/:id', ':model') + + it('GET /entry/123', async () => { + expect(() => { + router.match('GET', '/entry/123') + }).toThrowError(UnsupportedPathError) + }) + }) + + it('parent', () => { + const router = new FilePatternRouter() + router.add('GET', '/:id/:action', 'foo') + router.add('GET', '/posts/:id', 'bar') + expect(() => { + router.match('GET', '/') + }).toThrowError(UnsupportedPathError) + }) + + it('child', () => { + const router = new FilePatternRouter() + router.add('GET', '/posts/:id', 'foo') + router.add('GET', '/:id/:action', 'bar') + + expect(() => { + router.match('GET', '/') + }).toThrowError(UnsupportedPathError) + }) + + describe('static and dynamic', () => { + it('static first', () => { + const router = new FilePatternRouter() + router.add('GET', '/reg-exp/router', 'foo') + router.add('GET', '/reg-exp/:id', 'bar') + + expect(() => { + router.match('GET', '/') + }).toThrowError(UnsupportedPathError) + }) + + it('long label', () => { + const router = new FilePatternRouter() + router.add('GET', '/reg-exp/router', 'foo') + router.add('GET', '/reg-exp/:service', 'bar') + + expect(() => { + router.match('GET', '/') + }).toThrowError(UnsupportedPathError) + }) + + it('dynamic first', () => { + const router = new FilePatternRouter() + router.add('GET', '/reg-exp/:id', 'bar') + router.add('GET', '/reg-exp/router', 'foo') + + expect(() => { + router.match('GET', '/') + }).toThrowError(UnsupportedPathError) + }) + }) + + it('different regular expression', () => { + const router = new FilePatternRouter() + router.add('GET', '/:id/:action{create|update}', 'foo') + router.add('GET', '/:id/:action{delete}', 'bar') + expect(() => { + router.match('GET', '/') + }).toThrowError(UnsupportedPathError) + }) +}) + +describe('star', () => { + const router = new FilePatternRouter() + + router.add('GET', '/', '/') + router.add('GET', '/*', '/*') + router.add('GET', '*', '*') + + router.add('GET', '/x', '/x') + router.add('GET', '/x/*', '/x/*') + + it('top', async () => { + const [res] = router.match('GET', '/') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('/') + expect(res[1][0]).toEqual('/*') + expect(res[2][0]).toEqual('*') + }) + + it('Under a certain path', async () => { + const [res] = router.match('GET', '/x') + expect(res.length).toBe(4) + expect(res[0][0]).toEqual('/*') + expect(res[1][0]).toEqual('*') + expect(res[2][0]).toEqual('/x') + expect(res[3][0]).toEqual('/x/*') + }) +}) + +describe.skip('Optional route', () => { + const router = new FilePatternRouter() + router.add('GET', '/api/animals/:type?', 'animals') + it('GET /api/animals/dog', async () => { + const [res, stash] = router.match('GET', '/api/animals/dog') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('animals') + expect(stash?.[res[0][1]['type'] as number]).toBe('dog') + }) + it('GET /api/animals', async () => { + const [res, stash] = router.match('GET', '/api/animals') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('animals') + expect(stash?.[res[0][1]['type'] as number]).toBeUndefined() + }) +}) + +describe('All', () => { + const router = new FilePatternRouter() + + router.add('GET', '/hello', 'get hello') + router.add('ALL', '/all', 'get all') + + it('get, all hello', async () => { + const [res] = router.match('GET', '/all') + expect(res.length).toBe(1) + }) +}) + +describe('long prefix, then star', () => { + describe('GET only', () => { + const router = new FilePatternRouter() + + router.add('GET', '/long/prefix/*', 'long-prefix') + router.add('GET', '/long/*', 'long') + router.add('GET', '*', 'star1') + router.add('GET', '*', 'star2') + + it('get /', () => { + const [res] = router.match('GET', '/') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('star1') + expect(res[1][0]).toEqual('star2') + }) + + it('get /long/prefix', () => { + const [res] = router.match('GET', '/long/prefix') + expect(res.length).toBe(4) + expect(res[0][0]).toEqual('long-prefix') + expect(res[1][0]).toEqual('long') + expect(res[2][0]).toEqual('star1') + expect(res[3][0]).toEqual('star2') + }) + + it('get /long/prefix/test', () => { + const [res] = router.match('GET', '/long/prefix/test') + expect(res.length).toBe(4) + expect(res[0][0]).toEqual('long-prefix') + expect(res[1][0]).toEqual('long') + expect(res[2][0]).toEqual('star1') + expect(res[3][0]).toEqual('star2') + }) + }) + + describe('ALL and GET', () => { + const router = new FilePatternRouter() + + router.add('ALL', '/long/prefix/*', 'long-prefix') + router.add('ALL', '/long/*', 'long') + router.add('GET', '*', 'star1') + router.add('GET', '*', 'star2') + + it('get /', () => { + const [res] = router.match('GET', '/') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('star1') + expect(res[1][0]).toEqual('star2') + }) + + it('get /long/prefix', () => { + const [res] = router.match('GET', '/long/prefix') + expect(res.length).toBe(4) + expect(res[0][0]).toEqual('long-prefix') + expect(res[1][0]).toEqual('long') + expect(res[2][0]).toEqual('star1') + expect(res[3][0]).toEqual('star2') + }) + + it('get /long/prefix/test', () => { + const [res] = router.match('GET', '/long/prefix/test') + expect(res.length).toBe(4) + expect(res[0][0]).toEqual('long-prefix') + expect(res[1][0]).toEqual('long') + expect(res[2][0]).toEqual('star1') + expect(res[3][0]).toEqual('star2') + }) + }) + + describe.skip('Including slashes', () => { + const router = new FilePatternRouter() + + router.add('GET', '/js/:filename{[a-z0-9/]+.js}', 'any file') + + // XXX This route can not be added with `:label` to FilePatternRouter. This is ambiguous. + // router.add('GET', '/js/main.js', 'main.js') + // it('get /js/main.js', () => { + // const [res] = router.match('GET', '/js/main.js') + // expect(res.length).toBe(1) + // expect(res[0][0]).toEqual('any file', 'main.js') + // expect(stash?.[res[0][1]['filename'] as number]).toEqual('main.js') + // }) + + it('get /js/chunk/123.js', () => { + const [res, stash] = router.match('GET', '/js/chunk/123.js') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('any file') + expect(stash?.[res[0][1]['filename'] as number]).toEqual('chunk/123.js') + }) + + it('get /js/chunk/nest/123.js', () => { + const [res, stash] = router.match('GET', '/js/chunk/nest/123.js') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('any file') + expect(stash?.[res[0][1]['filename'] as number]).toEqual('chunk/nest/123.js') + }) + }) + + describe.skip('REST API', () => { + const router = new FilePatternRouter() + + router.add('GET', '/users/:username{[a-z]+}', 'profile') + router.add('GET', '/users/:username{[a-z]+}/posts', 'posts') + + it('get /users/hono', () => { + const [res] = router.match('GET', '/users/hono') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('profile') + }) + + it('get /users/hono/posts', () => { + const [res] = router.match('GET', '/users/hono/posts') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('posts') + }) + }) +}) + +describe('static routes of ALL and GET', () => { + const router = new FilePatternRouter() + + router.add('ALL', '/foo', 'foo') + router.add('GET', '/bar', 'bar') + + it('get /foo', () => { + const [res] = router.match('GET', '/foo') + expect(res[0][0]).toEqual('foo') + }) +}) + +describe('ALL and Star', () => { + const router = new FilePatternRouter() + + router.add('ALL', '/x', '/x') + router.add('GET', '*', 'star') + + it('Should return /x and star', async () => { + const [res] = router.match('GET', '/x') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('/x') + expect(res[1][0]).toEqual('star') + }) +}) + +describe('GET star, ALL static, GET star...', () => { + const router = new FilePatternRouter() + + router.add('GET', '*', 'star1') + router.add('ALL', '/x', '/x') + router.add('GET', '*', 'star2') + router.add('GET', '*', 'star3') + + it('Should return /x and star', async () => { + const [res] = router.match('GET', '/x') + expect(res.length).toBe(4) + expect(res[0][0]).toEqual('star1') + expect(res[1][0]).toEqual('/x') + expect(res[2][0]).toEqual('star2') + expect(res[3][0]).toEqual('star3') + }) +}) + +// https://github.com/honojs/hono/issues/699 +describe('GET star, GET static, ALL star...', () => { + const router = new FilePatternRouter() + + router.add('GET', '/y/*', 'star1') + router.add('GET', '/y/a', 'a') + router.add('ALL', '/y/b/*', 'star2') + router.add('GET', '/y/b/bar', 'bar') + + it('Should return star1, star2, and bar', async () => { + const [res] = router.match('GET', '/y/b/bar') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('star1') + expect(res[1][0]).toEqual('star2') + expect(res[2][0]).toEqual('bar') + }) +}) + +describe('ALL star, ALL star, GET static, ALL star...', () => { + const router = new FilePatternRouter() + + router.add('ALL', '*', 'wildcard') + router.add('ALL', '/a/*', 'star1') + router.add('GET', '/a/foo', 'foo') + router.add('ALL', '/b/*', 'star2') + router.add('GET', '/b/bar', 'bar') + + it('Should return wildcard, star2 and bar', async () => { + const [res] = router.match('GET', '/b/bar') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('wildcard') + expect(res[1][0]).toEqual('star2') + expect(res[2][0]).toEqual('bar') + }) +}) + +describe('Routing with a hostname', () => { + const router = new FilePatternRouter() + 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') + 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') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('www2') + }) + it('GET /hello', () => { + const [res] = router.match('get', '/hello') + expect(res.length).toBe(0) + }) +}) + +describe.skip('Capture Group', () => { + describe('Simple capturing group', () => { + const router = new FilePatternRouter() + router.add('get', '/foo/:capture{(?:bar|baz)}', 'ok') + it('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') + 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') + expect(res.length).toBe(0) + }) + }) + + describe('Non-capturing group', () => { + const router = new FilePatternRouter() + router.add('get', '/foo/:capture{(?:bar|baz)}', 'ok') + it('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') + 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') + expect(res.length).toBe(0) + }) + }) + + describe('Non-capturing group with prefix', () => { + const router = new FilePatternRouter() + router.add('get', '/foo/:capture{ba(?:r|z)}', 'ok') + it('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') + 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') + expect(res.length).toBe(0) + }) + }) + + describe('Complex capturing group', () => { + const router = new FilePatternRouter() + router.add('get', '/foo/:capture{ba(r|z)}', 'ok') + it('GET request', () => { + expect(() => { + router.match('GET', '/foo/bar') + }).toThrowError(UnsupportedPathError) + }) + }) +}) diff --git a/src/router/file-pattern-router/router.ts b/src/router/file-pattern-router/router.ts new file mode 100644 index 000000000..00417ce34 --- /dev/null +++ b/src/router/file-pattern-router/router.ts @@ -0,0 +1,214 @@ +import type { Result, Router, ParamIndexMap } from '../../router' +import { METHOD_NAME_ALL } from '../../router' + +const MAX_PATH_LENGTH = 99 +const STATIC_SORT_SCORE = MAX_PATH_LENGTH + 1 +const emptyParam: string[] = [] +const emptyParamIndexMap = {} + +type HandlerData = [T, ParamIndexMap][] +type StaticMap = Record> +type MatcherWithHint = [ + string | RegExp, + HandlerData[], + StaticMap, + number, + Record +] +type Matcher = [RegExp, HandlerData[], StaticMap] + +type Route = [number, number, string, boolean, string, string[], T] // [sortScore, index, method, isMiddleware, regexpStr, params, handler] + +function addMatchers( + matchersWithHint: Record>, + method: string, + [, index, , isMiddleware, regexpStr, params, handler]: Route +) { + 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>((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>((map, param) => { + map[param] = ++matcher[3] + return map + }, {}), + ] + matcher[1][(matcher[4][regexpStr] = ++matcher[3])] = handlerData as HandlerData + 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>((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 implements Router { + name: string = 'FilePatternRouter' + #routes: Route[] = [] + + 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>, + method: string + ): MatcherWithHint { + 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 to Matcher + 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 { + const matchers: Record> = {} + const match = (method: string, path: string): Result => { + const matcher = (matchers[method] || + this.buildMatcher(matchers, method)) as unknown as Matcher + + 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) + } +}