From 3bd91f436bce846350b00ac8c5741f339841e99b Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Mon, 24 Aug 2026 12:03:23 +0200 Subject: [PATCH 1/7] perf(router): index static route lookups --- src/router/store.ts | 211 +++++++++++++++++++++++++++- tests/router/router.spec.ts | 25 ++++ tests/router/store.spec.ts | 269 ++++++++++++++++++++++++++++++++++++ 3 files changed, 499 insertions(+), 6 deletions(-) diff --git a/src/router/store.ts b/src/router/store.ts index 75af0d10..9c209c2c 100644 --- a/src/router/store.ts +++ b/src/router/store.ts @@ -22,6 +22,20 @@ import type { import debug from '../debug.ts' import { parseRoute } from '../helpers.ts' +type IndexedStaticRoute = { + route: RouteJSON + precedingDynamicRoutesCount: number +} + +type MethodNodeIndex = { + staticRoutes: Map + dynamicRoutes: MatchItRouteToken[][] +} + +type ParsedRouteToken = MatchItRouteToken & { + matcher?: RegExp +} + /** * Store class is used to store a list of routes, along side with their tokens * to match the URLs. @@ -44,6 +58,12 @@ import { parseRoute } from '../helpers.ts' * ``` */ export class RoutesStore { + /** + * Lookup indexes are kept outside the public routes tree to avoid changing + * its observable shape. + */ + #methodNodeIndexes = new WeakMap() + /** * A flag to know if routes for explicit domains * have been registered @@ -79,6 +99,142 @@ export class RoutesStore { return domainNode[method] } + /** + * Returns an index key for a static route. Empty token lists are excluded, + * since matchit does not treat them as static routes. + */ + #getStaticRouteKey(tokens: MatchItRouteToken[]): string | null { + if (!tokens.length || tokens.some((token) => token.type !== 0)) { + return null + } + + return tokens.length === 1 && tokens[0].val === '/' + ? 'root' + : `segments:${tokens.map((token) => token.val).join('/')}` + } + + /** + * Removes a single leading and trailing slash, just like matchit. + */ + #stripRoutePath(pathname: string): string { + if (pathname === '/') { + return pathname + } + + if (pathname.charCodeAt(0) === 47) { + pathname = pathname.substring(1) + } + + const lastIndex = pathname.length - 1 + if (pathname.charCodeAt(lastIndex) === 47) { + pathname = pathname.substring(0, lastIndex) + } + + return pathname + } + + /** + * Returns the static index key for a request path. + */ + #getStaticRequestKey(pathname: string): string { + pathname = this.#stripRoutePath(pathname) + return pathname === '/' ? 'root' : `segments:${pathname}` + } + + /** + * Matches a prefix of routes using matchit's matching semantics. Limiting the + * scan prevents routes registered after a static candidate from producing + * observable matcher side effects or errors. + */ + #matchRoutesBefore( + pathname: string, + routes: MatchItRouteToken[][], + routesCount: number + ): MatchItRouteToken[] { + if (!routesCount) { + return [] + } + + pathname = this.#stripRoutePath(pathname) + const segments = pathname === '/' ? ['/'] : pathname.split('/') + const segmentsCount = segments.length + + for (let routeIndex = 0; routeIndex < routesCount; routeIndex++) { + const tokens = routes[routeIndex] + const tokensCount = tokens.length + + if ( + tokensCount !== segmentsCount && + !(tokensCount < segmentsCount && tokens[tokensCount - 1].type === 2) && + !(tokensCount > segmentsCount && tokens[tokensCount - 1].type === 3) + ) { + continue + } + + let matches = true + for (let tokenIndex = 0; tokenIndex < tokensCount; tokenIndex++) { + const token = tokens[tokenIndex] as ParsedRouteToken + const segment = segments[tokenIndex] + + if (token.val === segment && token.type === 0) { + continue + } + if (segment === '/') { + matches = token.type > 1 + } else if (token.type === 0) { + matches = false + } else if (segment === '') { + matches = token.end === '' && (token.matcher ? token.matcher.test(segment) : true) + } else if (!segment) { + matches = token.end === '' + } else { + matches = + segment.endsWith(token.end) && (token.matcher ? token.matcher.test(segment) : true) + } + + if (!matches) { + break + } + } + + if (matches) { + return tokens + } + } + + return [] + } + + /** + * Creates the public match result for a route and its collected params. + */ + #createMatchedRoute( + route: RouteJSON, + methodNode: StoreMethodNode, + params: Record, + domain?: { tokens: MatchItRouteToken[]; hostname: string } + ): MatchedRoute { + return { + route, + routeKey: methodNode.routeKeys[route.pattern], + params, + subdomains: domain?.hostname ? matchit.exec(domain.hostname, domain.tokens) : {}, + } + } + + /** + * Creates the static index lazily. Routes registered before the first static + * route are copied to the dynamic fallback in their original order. + */ + #createMethodNodeIndex(methodNode: StoreMethodNode): MethodNodeIndex { + const index: MethodNodeIndex = { + staticRoutes: new Map(), + dynamicRoutes: methodNode.tokens.slice(), + } + this.#methodNodeIndexes.set(methodNode, index) + return index + } + /** * Collects route params */ @@ -121,6 +277,21 @@ export class RoutesStore { debug('route middleware %O', route.middleware.all().entries()) } + const staticRouteKey = this.#getStaticRouteKey(tokens) + let methodNodeIndex = this.#methodNodeIndexes.get(methodRoutes) + + if (staticRouteKey !== null) { + methodNodeIndex ||= this.#createMethodNodeIndex(methodRoutes) + if (!methodNodeIndex.staticRoutes.has(staticRouteKey)) { + methodNodeIndex.staticRoutes.set(staticRouteKey, { + route, + precedingDynamicRoutesCount: methodNodeIndex.dynamicRoutes.length, + }) + } + } else if (methodNodeIndex) { + methodNodeIndex.dynamicRoutes.push(tokens) + } + methodRoutes.tokens.push(tokens) methodRoutes.routes[route.pattern] = route methodRoutes.routeKeys[route.pattern] = @@ -208,6 +379,34 @@ export class RoutesStore { return null } + const methodNodeIndex = this.#methodNodeIndexes.get(matchedMethod) + if (methodNodeIndex) { + const staticRoute = methodNodeIndex.staticRoutes.get(this.#getStaticRequestKey(url)) + + const dynamicRoute = staticRoute + ? this.#matchRoutesBefore( + url, + methodNodeIndex.dynamicRoutes, + staticRoute.precedingDynamicRoutesCount + ) + : matchit.match(url, methodNodeIndex.dynamicRoutes) + if (!dynamicRoute.length) { + if (!staticRoute) { + return null + } + + return this.#createMatchedRoute(staticRoute.route, matchedMethod, {}, domain) + } + + const route = matchedMethod.routes[dynamicRoute[0].old] + return this.#createMatchedRoute( + route, + matchedMethod, + matchit.exec(url, dynamicRoute, shouldDecodeParam), + domain + ) + } + /* * Next, match route for the given url inside the tokens list for the * matchedMethod @@ -218,12 +417,12 @@ export class RoutesStore { } const route = matchedMethod.routes[matchedRoute[0].old] - return { - route: route, - routeKey: matchedMethod.routeKeys[route.pattern], - params: matchit.exec(url, matchedRoute, shouldDecodeParam), - subdomains: domain?.hostname ? matchit.exec(domain.hostname, domain.tokens) : {}, - } + return this.#createMatchedRoute( + route, + matchedMethod, + matchit.exec(url, matchedRoute, shouldDecodeParam), + domain + ) } /** diff --git a/tests/router/router.spec.ts b/tests/router/router.spec.ts index 3f577d82..3493eee1 100644 --- a/tests/router/router.spec.ts +++ b/tests/router/router.spec.ts @@ -558,6 +558,31 @@ test.group('Router | commit', () => { }) test.group('Router | match', () => { + test('do not let a malformed repeated-separator route shadow the root route', ({ assert }) => { + async function repeatedSeparatorHandler() {} + async function rootHandler() {} + + const router = new RouterFactory().create() + router.get('////', repeatedSeparatorHandler) + router.get('/', rootHandler) + router.commit() + + assert.strictEqual(router.match('/', 'GET', false)?.route.handler, rootHandler) + }) + + test('normalize an empty route pattern to root without matching an empty request path', ({ + assert, + }) => { + async function handler() {} + + const router = new RouterFactory().create() + router.get('', handler) + router.commit() + + assert.strictEqual(router.match('/', 'GET', false)?.route.handler, handler) + assert.isNull(router.match('', 'GET', false)) + }) + test('match route using URL', ({ assert }) => { const router = new RouterFactory().create() diff --git a/tests/router/store.spec.ts b/tests/router/store.spec.ts index 9ddc222f..078d348c 100644 --- a/tests/router/store.spec.ts +++ b/tests/router/store.spec.ts @@ -9,11 +9,33 @@ import { test } from '@japa/runner' import Middleware from '@poppinss/middleware' +// @ts-expect-error +import matchit from '@poppinss/matchit' +import type { MatchedRoute, RouteJSON } from '../../src/types/route.ts' import { parseRoute } from '../../src/helpers.ts' import { execute } from '../../src/router/executor.ts' import { RoutesStore } from '../../src/router/store.ts' +function addRoute( + store: RoutesStore, + pattern: string, + options: Partial> = {} +) { + const matchers = options.matchers ?? {} + store.add({ + pattern, + tokens: options.tokens ?? parseRoute(pattern, matchers), + handler: options.handler ?? async function handler() {}, + matchers, + meta: {}, + execute, + middleware: new Middleware(), + methods: options.methods ?? ['GET'], + domain: options.domain ?? 'root', + }) +} + test.group('Store | add', () => { test('add route without explicit domain', ({ assert }) => { async function handler() {} @@ -556,6 +578,253 @@ test.group('Store | add', () => { }) test.group('Store | match', () => { + test('preserve registration order for equivalent static routes with repeated trailing separators', ({ + assert, + }) => { + async function repeatedSeparatorHandler() {} + async function canonicalHandler() {} + + const store = new RoutesStore() + for (const [pattern, handler] of [ + ['/users//', repeatedSeparatorHandler], + ['/users', canonicalHandler], + ] as const) { + addRoute(store, pattern, { handler }) + } + + assert.strictEqual(store.match('/users', 'GET', false)?.route.handler, repeatedSeparatorHandler) + }) + + test('preserve registration order across static, parameter, optional, and wildcard routes', ({ + assert, + }) => { + const cases = [ + { patterns: ['/:value', '/users'], pathname: '/users', expected: '/:value' }, + { patterns: ['/users', '/:value'], pathname: '/users', expected: '/users' }, + { patterns: ['/:value?', '/'], pathname: '/', expected: '/:value?' }, + { patterns: ['/', '/:value?'], pathname: '/', expected: '/' }, + { patterns: ['/*', '/users'], pathname: '/users', expected: '/*' }, + { patterns: ['/users', '/*'], pathname: '/users', expected: '/users' }, + ] + + for (const { patterns, pathname, expected } of cases) { + const store = new RoutesStore() + for (const pattern of patterns) { + addRoute(store, pattern) + } + + assert.equal(store.match(pathname, 'GET', false)?.route.pattern, expected) + } + }) + + test('preserve matchit separator semantics for static routes', ({ assert }) => { + const cases = [ + { + patterns: ['/users', 'users/'], + pathnames: ['/users', '/users/', 'users', 'users/'], + expected: '/users', + }, + { patterns: ['//users', '/users'], pathnames: ['/users'], expected: '/users' }, + { + patterns: ['/teams//users', '/teams/users'], + pathnames: ['/teams//users'], + expected: '/teams//users', + }, + { + patterns: ['/teams//users', '/teams/users'], + pathnames: ['/teams/users'], + expected: '/teams/users', + }, + ] + + for (const { patterns, pathnames, expected } of cases) { + const store = new RoutesStore() + for (const pattern of patterns) { + addRoute(store, pattern) + } + + for (const pathname of pathnames) { + assert.equal(store.match(pathname, 'GET', false)?.route.pattern, expected) + } + } + }) + + test('return the same route object for repeated matches and routes with multiple methods', ({ + assert, + }) => { + const store = new RoutesStore() + addRoute(store, '/users', { methods: ['GET', 'POST'] }) + + const firstGetMatch = store.match('/users', 'GET', false)! + const secondGetMatch = store.match('/users/', 'GET', false)! + const postMatch = store.match('/users', 'POST', false)! + + assert.strictEqual(firstGetMatch.route, secondGetMatch.route) + assert.strictEqual(firstGetMatch.route, postMatch.route) + assert.equal(firstGetMatch.routeKey, 'GET-/users') + assert.equal(postMatch.routeKey, 'POST-/users') + }) + + test('decode parameter and wildcard values only when requested', ({ assert }) => { + const store = new RoutesStore() + for (const pattern of ['/users/:name', '/files/*']) { + addRoute(store, pattern) + } + + assert.deepEqual(store.match('/users/Romain%20Lanz', 'GET', false)?.params, { + name: 'Romain%20Lanz', + }) + assert.deepEqual(store.match('/users/Romain%20Lanz', 'GET', true)?.params, { + name: 'Romain Lanz', + }) + assert.deepEqual(store.match('/files/folder%20one/file%20two', 'GET', false)?.params, { + '*': ['folder%20one', 'file%20two'], + }) + assert.deepEqual(store.match('/files/folder%20one/file%20two', 'GET', true)?.params, { + '*': ['folder one', 'file two'], + }) + }) + + test('extract subdomains when matching an indexed static route on an explicit domain', ({ + assert, + }) => { + const store = new RoutesStore() + addRoute(store, '/dashboard', { domain: ':tenant.adonisjs.com' }) + + const domainTokens = store.matchDomain('news.adonisjs.com') + assert.containSubset( + store.match('/dashboard', 'GET', false, { + tokens: domainTokens, + hostname: 'news.adonisjs.com', + }), + { + route: { pattern: '/dashboard' }, + routeKey: ':tenant.adonisjs.com-GET-/dashboard', + params: {}, + subdomains: { tenant: 'news' }, + } + ) + }) + + test('apply matchers and casts on a dynamic route before an indexed static route', ({ + assert, + }) => { + const store = new RoutesStore() + const matchers = { id: { match: /^\d+$/, cast: Number } } + addRoute(store, '/:id', { matchers }) + addRoute(store, '/users') + + assert.deepEqual(store.match('/42', 'GET', false)?.params, { id: 42 }) + assert.equal(store.match('/users', 'GET', false)?.route.pattern, '/users') + }) + + test('match the same route as matchit across generated route orders and path spellings', ({ + assert, + }) => { + const routeDefinitions = [ + { pattern: '/' }, + { pattern: '' }, + { pattern: '//' }, + { pattern: '///' }, + { pattern: '////' }, + { pattern: '/users' }, + { pattern: 'users/' }, + { pattern: '/users//' }, + { pattern: '/users///' }, + { pattern: '//users' }, + { pattern: '/teams//users' }, + { pattern: '/:value' }, + { pattern: '/:value?' }, + { pattern: '/*' }, + { pattern: '/teams/:id', matchers: { id: { match: /^\d+$/, cast: Number } } }, + { pattern: '/teams/:id?' }, + { pattern: '/teams/*' }, + ] + const pathnames = [ + '', + '/', + '//', + '///', + '////', + 'users', + '/users', + '/users/', + '/users//', + '//users', + '/teams/users', + '/teams//users', + '/teams/42', + '/teams/Romain%20Lanz', + '/teams/42/members', + '/missing', + ] + + let seed = 42 + function random() { + seed = (seed * 1_664_525 + 1_013_904_223) >>> 0 + return seed / 2 ** 32 + } + + function captureMatch(callback: () => null | MatchedRoute) { + try { + const match = callback() + return match + ? { + status: 'matched', + routePattern: match.route.pattern, + routeKey: match.routeKey, + params: match.params, + } + : { status: 'missing' } + } catch (error) { + return { + status: 'threw', + errorName: (error as Error).constructor.name, + errorMessage: (error as Error).message, + } + } + } + + for (let iteration = 0; iteration < 250; iteration++) { + const shuffled = routeDefinitions.slice() + for (let index = shuffled.length - 1; index > 0; index--) { + const swapIndex = Math.floor(random() * (index + 1)) + ;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]] + } + + const definitions = shuffled.slice(0, 1 + Math.floor(random() * 12)) + const tokenLists = definitions.map(({ pattern, matchers }) => parseRoute(pattern, matchers)) + const store = new RoutesStore() + definitions.forEach(({ pattern, matchers = {} }, index) => { + addRoute(store, pattern, { tokens: tokenLists[index], matchers }) + }) + + for (const [pathnameIndex, pathname] of pathnames.entries()) { + const shouldDecodeParam = pathnameIndex % 2 === 0 + const expected = captureMatch(() => { + const matchedTokens = matchit.match(pathname, tokenLists) + if (!matchedTokens.length) { + return null + } + + const pattern = matchedTokens[0].old + return { + route: { pattern }, + routeKey: `GET-${pattern}`, + params: matchit.exec(pathname, matchedTokens, shouldDecodeParam), + } as MatchedRoute + }) + const actual = captureMatch(() => store.match(pathname, 'GET', shouldDecodeParam)) + + assert.deepEqual( + actual, + expected, + JSON.stringify({ definitions: definitions.map(({ pattern }) => pattern), pathname }) + ) + } + } + }) + test('find route for a given url', ({ assert }) => { async function handler() {} From ffd39140550c98d75a7895f5869f808eab157488 Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Mon, 24 Aug 2026 13:53:11 +0200 Subject: [PATCH 2/7] perf(router): replace matchit with ordered route table --- package-lock.json | 4 +- package.json | 2 +- src/helpers.ts | 19 +- src/router/route_parser.ts | 113 +++++++++ src/router/route_table.ts | 405 ++++++++++++++++++++++++++++++ src/router/store.ts | 209 ++------------- src/types/route.ts | 2 +- tests/router/route_parser.spec.ts | 23 ++ tests/router/route_table.spec.ts | 145 +++++++++++ tests/router/store.spec.ts | 2 +- 10 files changed, 714 insertions(+), 210 deletions(-) create mode 100644 src/router/route_parser.ts create mode 100644 src/router/route_table.ts create mode 100644 tests/router/route_table.spec.ts diff --git a/package-lock.json b/package-lock.json index b3540094..916af690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@poppinss/macroable": "^1.1.2", - "@poppinss/matchit": "^3.2.0", "@poppinss/middleware": "^3.2.7", "@poppinss/qs": "^6.15.0", "@poppinss/types": "^1.2.1", @@ -44,6 +43,7 @@ "@japa/file-system": "^3.0.0", "@japa/runner": "^5.3.0", "@japa/snapshot": "^2.0.10", + "@poppinss/matchit": "^3.2.0", "@poppinss/ts-exec": "^1.4.4", "@release-it/conventional-changelog": "^11.0.0", "@types/accepts": "^1.3.7", @@ -269,6 +269,7 @@ }, "node_modules/@arr/every": { "version": "1.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -1698,6 +1699,7 @@ }, "node_modules/@poppinss/matchit": { "version": "3.2.0", + "dev": true, "license": "MIT", "dependencies": { "@arr/every": "^1.0.0" diff --git a/package.json b/package.json index 228e0fca..c219bbee 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@japa/file-system": "^3.0.0", "@japa/runner": "^5.3.0", "@japa/snapshot": "^2.0.10", + "@poppinss/matchit": "^3.2.0", "@poppinss/ts-exec": "^1.4.4", "@release-it/conventional-changelog": "^11.0.0", "@types/accepts": "^1.3.7", @@ -96,7 +97,6 @@ }, "dependencies": { "@poppinss/macroable": "^1.1.2", - "@poppinss/matchit": "^3.2.0", "@poppinss/middleware": "^3.2.7", "@poppinss/qs": "^6.15.0", "@poppinss/types": "^1.2.1", diff --git a/src/helpers.ts b/src/helpers.ts index bdc3dabc..e368c27a 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -8,8 +8,6 @@ */ import { serialize } from 'cookie-es' -// @ts-expect-error -import matchit from '@poppinss/matchit' import string from '@poppinss/utils/string' import { type Encryption } from '@boringnode/encryption' import { parseBindingReference } from '@adonisjs/fold' @@ -21,6 +19,8 @@ import { createURL } from './client/helpers.ts' import { type CookieOptions } from './types/response.ts' import { type SignedURLOptions } from './types/url_builder.ts' import type { RouteMatchers, RouteJSON, MatchItRouteToken } from './types/route.ts' +import { matchRouteTokens } from './router/route_table.ts' +import { parseRoutePattern } from './router/route_parser.ts' import { type MiddlewareFn, type RouteHandlerInfo, @@ -159,8 +159,7 @@ export { default as mime } from 'mime-types' * @returns {MatchItRouteToken[]} Array of parsed route tokens */ export function parseRoute(pattern: string, matchers?: RouteMatchers): MatchItRouteToken[] { - const tokens = matchit.parse(pattern, matchers) - return tokens + return parseRoutePattern(pattern, matchers) } /** @@ -215,13 +214,11 @@ export function createSignedURL( * @returns {null | Record} Extracted parameters or null if no match */ export function matchRoute(url: string, patterns: string[]): null | Record { - const tokensBucket = patterns.map((pattern) => parseRoute(pattern)) - const match = matchit.match(url, tokensBucket) - if (!match.length) { - return null - } - - return matchit.exec(url, match) + return matchRouteTokens( + url, + patterns.map((pattern) => parseRoute(pattern)), + false + ) } /** diff --git a/src/router/route_parser.ts b/src/router/route_parser.ts new file mode 100644 index 00000000..63f85fcd --- /dev/null +++ b/src/router/route_parser.ts @@ -0,0 +1,113 @@ +/* + * @adonisjs/http-server + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { MatchItRouteToken, RouteMatchers } from '../types/route.ts' + +export type ParsedRouteToken = MatchItRouteToken & { + matcher?: RegExp +} + +export function stripRouteSeparators(value: string): string { + if (value === '/') { + return value + } + if (value.charCodeAt(0) === 47) { + value = value.substring(1) + } + + const lastIndex = value.length - 1 + return value.charCodeAt(lastIndex) === 47 ? value.substring(0, lastIndex) : value +} + +/** + * Parses a route pattern into the token format shared by route matching and + * URL generation. Single leading/trailing separator stripping is preserved + * for backwards compatibility. + */ +export function parseRoutePattern( + pattern: string, + matchers: RouteMatchers = {} +): MatchItRouteToken[] { + if (pattern === '/') { + return [{ old: pattern, type: 0, val: pattern, end: '' }] + } + + if (typeof matchers !== 'object') { + matchers = {} + } + + let remaining = stripRouteSeparators(pattern) + let index = -1 + let parameterNameEnd = 0 + let segmentStart = 0 + let remainingLength = remaining.length + const tokens: MatchItRouteToken[] = [] + + while (++index < remainingLength) { + let character = remaining.charCodeAt(index) + + if (character === 58) { + segmentStart = index + 1 + let type: 1 | 3 = 1 + parameterNameEnd = 0 + let suffix = '' + + while (index < remainingLength && remaining.charCodeAt(index) !== 47) { + character = remaining.charCodeAt(index) + if (character === 63) { + parameterNameEnd = index + type = 3 + } else if (character === 46 && suffix.length === 0) { + parameterNameEnd = index + suffix = remaining.substring(index) + } + index++ + } + + const value = remaining.substring(segmentStart, parameterNameEnd || index) + const matcher = matchers[value] + tokens.push({ + old: pattern, + type, + val: value, + end: suffix, + matcher: matcher?.match, + cast: matcher?.cast, + } as ParsedRouteToken) + + remaining = remaining.substring(index) + remainingLength -= index + index = 0 + continue + } + + if (character === 42) { + tokens.push({ + old: pattern, + type: 2, + val: remaining.substring(index), + end: '', + }) + continue + } + + segmentStart = index + while (index < remainingLength && remaining.charCodeAt(index) !== 47) { + index++ + } + + const value = remaining.substring(segmentStart, index) + tokens.push({ old: pattern, type: 0, val: value, end: '' }) + remaining = remaining.substring(index) + remainingLength -= index + index = segmentStart = 0 + } + + return tokens +} diff --git a/src/router/route_table.ts b/src/router/route_table.ts new file mode 100644 index 00000000..511d9b3f --- /dev/null +++ b/src/router/route_table.ts @@ -0,0 +1,405 @@ +/* + * @adonisjs/http-server + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { MatchItRouteToken } from '../types/route.ts' +import { stripRouteSeparators, type ParsedRouteToken } from './route_parser.ts' + +type IndexedRoute = { + additionalMatcherChecks?: { index: number; matcher: RegExp }[] + isStructurallyMatched?: boolean + matcher?: RegExp + matcherSegmentIndex?: number + order: number + tokens: MatchItRouteToken[] + value: T +} + +type RouteNode = { + literals?: Map> + minimumOrder: number + optionals?: Map> + parameters?: Map> + terminals?: IndexedRoute[] + wildcards?: IndexedRoute[] +} + +function createNode(): RouteNode { + return { + minimumOrder: Number.POSITIVE_INFINITY, + } +} + +function splitRoutePath(pathname: string): string[] { + pathname = stripRouteSeparators(pathname) + return pathname === '/' ? ['/'] : pathname.split('/') +} + +function getStaticRouteKey(tokens: MatchItRouteToken[]): string | null { + if (!tokens.length || tokens.some((token) => token.type !== 0)) { + return null + } + + return tokens.length === 1 && tokens[0].val === '/' + ? 'root' + : `segments:${tokens.map((token) => token.val).join('/')}` +} + +function getStaticRequestKey(pathname: string): string { + pathname = stripRouteSeparators(pathname) + return pathname === '/' ? 'root' : `segments:${pathname}` +} + +function getOrCreateChild(children: Map>, key: string): RouteNode { + let child = children.get(key) + if (!child) { + child = createNode() + children.set(key, child) + } + return child +} + +function matchesRoute(tokens: MatchItRouteToken[], segments: string[]): boolean { + if ( + tokens.length !== segments.length && + !(tokens.length < segments.length && tokens[tokens.length - 1].type === 2) && + !(tokens.length > segments.length && tokens[tokens.length - 1].type === 3) + ) { + return false + } + + let index = 0 + while (index < tokens.length) { + const rawToken = tokens[index] + const token = rawToken as ParsedRouteToken + const segment = segments[index] + + if (token.val === segment && token.type === 0) { + index++ + continue + } + if (segment === '/') { + if (token.type > 1) { + index++ + continue + } + return false + } + if (token.type === 0) { + return false + } + if (segment === '') { + if (token.end === '' && (token.matcher ? token.matcher.test(segment) : true)) { + index++ + continue + } + return false + } + if (!segment) { + if (token.end === '') { + index++ + continue + } + return false + } + if (segment.endsWith(token.end) && (token.matcher ? token.matcher.test(segment) : true)) { + index++ + continue + } + return false + } + + return true +} + +function matchesIndexedRoute(route: IndexedRoute, segments: string[]): boolean { + if (!route.isStructurallyMatched) { + return matchesRoute(route.tokens, segments) + } + + const matcher = route.matcher + if (matcher) { + const segment = segments[route.matcherSegmentIndex!] + if (segment !== undefined && segment !== '/' && !matcher.test(segment)) { + return false + } + } + + for (const { index, matcher: additionalMatcher } of route.additionalMatcherChecks ?? []) { + const segment = segments[index] + if (segment !== undefined && segment !== '/' && !additionalMatcher.test(segment)) { + return false + } + } + return true +} + +export function extractRouteParams( + tokens: MatchItRouteToken[], + pathname: string, + shouldDecodeParams: boolean +) { + const segments = splitRoutePath(pathname) + const params: Record = {} + let index = 0 + while (index < tokens.length) { + const token = tokens[index] + const segment = segments[index] + + if (segment === '/') { + index++ + continue + } + + if (token.val === '*') { + params[token.val] = segments.slice(index).map((value) => { + if (!shouldDecodeParams) { + return value + } + try { + return decodeURIComponent(value) + } catch { + return value + } + }) + break + } + + if (segment === undefined || token.type === 0) { + index++ + continue + } + + let value = segment.replace(token.end, '') + if (shouldDecodeParams) { + try { + value = decodeURIComponent(value) + } catch {} + } + params[token.val] = token.cast ? token.cast(value) : value + index++ + } + return params +} + +/** + * Matches a transient list of tokenized routes without building an index. + */ +export function matchRouteTokens( + pathname: string, + routes: MatchItRouteToken[][], + shouldDecodeParams: boolean +): Record | null { + const segments = splitRoutePath(pathname) + for (const tokens of routes) { + if (matchesRoute(tokens, segments)) { + return extractRouteParams(tokens, pathname, shouldDecodeParams) + } + } + return null +} + +/** + * Registration-ordered route matcher. The structural index only discards + * impossible routes; the lowest registration order always selects the winner. + */ +export class RouteTable { + #nextOrder = 0 + #root = createNode() + #staticRoutes = new Map>() + #unindexedRoutes: IndexedRoute[] = [] + + add(tokens: MatchItRouteToken[], value: T): this { + const indexedRoute: IndexedRoute = { order: this.#nextOrder++, tokens, value } + const staticKey = getStaticRouteKey(tokens) + + if (staticKey !== null) { + if (!this.#staticRoutes.has(staticKey)) { + this.#staticRoutes.set(staticKey, indexedRoute) + } + return this + } + + const hasStatefulMatcher = tokens.some((rawToken, index) => { + const matcher = (rawToken as ParsedRouteToken).matcher + const isStateful = + matcher && + (matcher.global || + matcher.sticky || + matcher.exec !== RegExp.prototype.exec || + matcher.test !== RegExp.prototype.test) + if (isStateful) { + return true + } + if (matcher) { + if (!indexedRoute.matcher) { + indexedRoute.matcher = matcher + indexedRoute.matcherSegmentIndex = index + } else { + indexedRoute.additionalMatcherChecks ||= [] + indexedRoute.additionalMatcherChecks.push({ index, matcher }) + } + } + return false + }) + if (!tokens.length || hasStatefulMatcher) { + this.#unindexedRoutes.push(indexedRoute) + return this + } + + let node = this.#root + node.minimumOrder = Math.min(node.minimumOrder, indexedRoute.order) + for (const token of tokens) { + if (token.type === 0) { + node.literals ||= new Map() + node = getOrCreateChild(node.literals, token.val) + } else if (token.type === 1) { + node.parameters ||= new Map() + node = getOrCreateChild(node.parameters, token.end) + } else if (token.type === 3) { + node.optionals ||= new Map() + node = getOrCreateChild(node.optionals, token.end) + } else { + node.wildcards ||= [] + node.wildcards.push(indexedRoute) + return this + } + node.minimumOrder = Math.min(node.minimumOrder, indexedRoute.order) + } + node.terminals ||= [] + indexedRoute.isStructurallyMatched = true + node.terminals.push(indexedRoute) + return this + } + + match( + pathname: string, + shouldDecodeParams: boolean + ): { params: Record; value: T } | null { + const staticRoute = this.#staticRoutes.get(getStaticRequestKey(pathname)) + const cutoff = staticRoute?.order ?? Number.POSITIVE_INFINITY + const firstUnindexedRoute = this.#unindexedRoutes[0] + if ( + staticRoute && + this.#root.minimumOrder >= cutoff && + (!firstUnindexedRoute || firstUnindexedRoute.order >= cutoff) + ) { + return { value: staticRoute.value, params: {} } + } + + const segments = splitRoutePath(pathname) + const candidateLists: IndexedRoute[][] = [] + if (firstUnindexedRoute && firstUnindexedRoute.order < cutoff) { + candidateLists.push(this.#unindexedRoutes) + } + this.#collectCandidates(this.#root, segments, 0, cutoff, candidateLists) + + if (candidateLists.length === 1) { + const candidates = candidateLists[0] + let candidateIndex = 0 + while (candidateIndex < candidates.length) { + const candidate = candidates[candidateIndex++] + if (candidate.order >= cutoff) { + break + } + if (matchesIndexedRoute(candidate, segments)) { + return { + value: candidate.value, + params: extractRouteParams(candidate.tokens, pathname, shouldDecodeParams), + } + } + } + + return staticRoute ? { value: staticRoute.value, params: {} } : null + } + + const positions = new Uint32Array(candidateLists.length) + while (true) { + let selectedList = -1 + let selectedRoute: IndexedRoute | undefined + for (const [listIndex, candidates] of candidateLists.entries()) { + const candidate = candidates[positions[listIndex]] + if (candidate && (!selectedRoute || candidate.order < selectedRoute.order)) { + selectedList = listIndex + selectedRoute = candidate + } + } + + if (!selectedRoute || selectedRoute.order >= cutoff) { + break + } + positions[selectedList]++ + + if (matchesIndexedRoute(selectedRoute, segments)) { + return { + value: selectedRoute.value, + params: extractRouteParams(selectedRoute.tokens, pathname, shouldDecodeParams), + } + } + } + + return staticRoute ? { value: staticRoute.value, params: {} } : null + } + + #collectCandidates( + node: RouteNode, + segments: string[], + segmentIndex: number, + cutoff: number, + candidateLists: IndexedRoute[][] + ) { + if (node.minimumOrder >= cutoff) { + return + } + + const wildcards = node.wildcards + if (wildcards?.length && wildcards[0].order < cutoff && segmentIndex < segments.length) { + candidateLists.push(wildcards) + } + + if (segmentIndex === segments.length) { + const terminals = node.terminals + if (terminals?.length && terminals[0].order < cutoff) { + candidateLists.push(terminals) + } + for (const optionalChild of node.optionals?.values() ?? []) { + this.#collectCandidates(optionalChild, segments, segmentIndex, cutoff, candidateLists) + } + for (const [suffix, parameterChild] of node.parameters ?? []) { + if (suffix === '') { + this.#collectCandidates(parameterChild, segments, segmentIndex, cutoff, candidateLists) + } + } + return + } + + const segment = segments[segmentIndex] + const literalChild = node.literals?.get(segment) + if (literalChild) { + this.#collectCandidates(literalChild, segments, segmentIndex + 1, cutoff, candidateLists) + } + if (segment !== '/') { + for (const [suffix, parameterChild] of node.parameters ?? []) { + if (segment.endsWith(suffix)) { + this.#collectCandidates( + parameterChild, + segments, + segmentIndex + 1, + cutoff, + candidateLists + ) + } + } + } + for (const [suffix, optionalChild] of node.optionals ?? []) { + if (segment === '/' || segment.endsWith(suffix)) { + this.#collectCandidates(optionalChild, segments, segmentIndex + 1, cutoff, candidateLists) + } + } + } +} diff --git a/src/router/store.ts b/src/router/store.ts index 9c209c2c..8866da3a 100644 --- a/src/router/store.ts +++ b/src/router/store.ts @@ -7,8 +7,6 @@ * file that was distributed with this source code. */ -// @ts-expect-error -import matchit from '@poppinss/matchit' import { RuntimeException } from '@poppinss/utils/exception' import type { @@ -21,20 +19,7 @@ import type { } from '../types/route.ts' import debug from '../debug.ts' import { parseRoute } from '../helpers.ts' - -type IndexedStaticRoute = { - route: RouteJSON - precedingDynamicRoutesCount: number -} - -type MethodNodeIndex = { - staticRoutes: Map - dynamicRoutes: MatchItRouteToken[][] -} - -type ParsedRouteToken = MatchItRouteToken & { - matcher?: RegExp -} +import { RouteTable, extractRouteParams } from './route_table.ts' /** * Store class is used to store a list of routes, along side with their tokens @@ -62,7 +47,8 @@ export class RoutesStore { * Lookup indexes are kept outside the public routes tree to avoid changing * its observable shape. */ - #methodNodeIndexes = new WeakMap() + #methodRouteTables = new WeakMap>() + #domainRouteTable = new RouteTable() /** * A flag to know if routes for explicit domains @@ -71,7 +57,7 @@ export class RoutesStore { usingDomains: boolean = false /** - * Tree of registered routes and their matchit tokens + * Tree of registered routes and their parsed tokens */ tree: StoreRoutesTree = { tokens: [], domains: {} } @@ -80,7 +66,9 @@ export class RoutesStore { */ #getDomainNode(domain: string): StoreDomainNode { if (!this.tree.domains[domain]) { - this.tree.tokens.push(parseRoute(domain)) + const tokens = parseRoute(domain) + this.tree.tokens.push(tokens) + this.#domainRouteTable.add(tokens, tokens) this.tree.domains[domain] = {} } @@ -94,117 +82,12 @@ export class RoutesStore { const domainNode = this.#getDomainNode(domain) if (!domainNode[method]) { domainNode[method] = { tokens: [], routes: {}, routeKeys: {} } + this.#methodRouteTables.set(domainNode[method], new RouteTable()) } return domainNode[method] } - /** - * Returns an index key for a static route. Empty token lists are excluded, - * since matchit does not treat them as static routes. - */ - #getStaticRouteKey(tokens: MatchItRouteToken[]): string | null { - if (!tokens.length || tokens.some((token) => token.type !== 0)) { - return null - } - - return tokens.length === 1 && tokens[0].val === '/' - ? 'root' - : `segments:${tokens.map((token) => token.val).join('/')}` - } - - /** - * Removes a single leading and trailing slash, just like matchit. - */ - #stripRoutePath(pathname: string): string { - if (pathname === '/') { - return pathname - } - - if (pathname.charCodeAt(0) === 47) { - pathname = pathname.substring(1) - } - - const lastIndex = pathname.length - 1 - if (pathname.charCodeAt(lastIndex) === 47) { - pathname = pathname.substring(0, lastIndex) - } - - return pathname - } - - /** - * Returns the static index key for a request path. - */ - #getStaticRequestKey(pathname: string): string { - pathname = this.#stripRoutePath(pathname) - return pathname === '/' ? 'root' : `segments:${pathname}` - } - - /** - * Matches a prefix of routes using matchit's matching semantics. Limiting the - * scan prevents routes registered after a static candidate from producing - * observable matcher side effects or errors. - */ - #matchRoutesBefore( - pathname: string, - routes: MatchItRouteToken[][], - routesCount: number - ): MatchItRouteToken[] { - if (!routesCount) { - return [] - } - - pathname = this.#stripRoutePath(pathname) - const segments = pathname === '/' ? ['/'] : pathname.split('/') - const segmentsCount = segments.length - - for (let routeIndex = 0; routeIndex < routesCount; routeIndex++) { - const tokens = routes[routeIndex] - const tokensCount = tokens.length - - if ( - tokensCount !== segmentsCount && - !(tokensCount < segmentsCount && tokens[tokensCount - 1].type === 2) && - !(tokensCount > segmentsCount && tokens[tokensCount - 1].type === 3) - ) { - continue - } - - let matches = true - for (let tokenIndex = 0; tokenIndex < tokensCount; tokenIndex++) { - const token = tokens[tokenIndex] as ParsedRouteToken - const segment = segments[tokenIndex] - - if (token.val === segment && token.type === 0) { - continue - } - if (segment === '/') { - matches = token.type > 1 - } else if (token.type === 0) { - matches = false - } else if (segment === '') { - matches = token.end === '' && (token.matcher ? token.matcher.test(segment) : true) - } else if (!segment) { - matches = token.end === '' - } else { - matches = - segment.endsWith(token.end) && (token.matcher ? token.matcher.test(segment) : true) - } - - if (!matches) { - break - } - } - - if (matches) { - return tokens - } - } - - return [] - } - /** * Creates the public match result for a route and its collected params. */ @@ -218,21 +101,8 @@ export class RoutesStore { route, routeKey: methodNode.routeKeys[route.pattern], params, - subdomains: domain?.hostname ? matchit.exec(domain.hostname, domain.tokens) : {}, - } - } - - /** - * Creates the static index lazily. Routes registered before the first static - * route are copied to the dynamic fallback in their original order. - */ - #createMethodNodeIndex(methodNode: StoreMethodNode): MethodNodeIndex { - const index: MethodNodeIndex = { - staticRoutes: new Map(), - dynamicRoutes: methodNode.tokens.slice(), + subdomains: domain?.hostname ? extractRouteParams(domain.tokens, domain.hostname, false) : {}, } - this.#methodNodeIndexes.set(methodNode, index) - return index } /** @@ -277,20 +147,7 @@ export class RoutesStore { debug('route middleware %O', route.middleware.all().entries()) } - const staticRouteKey = this.#getStaticRouteKey(tokens) - let methodNodeIndex = this.#methodNodeIndexes.get(methodRoutes) - - if (staticRouteKey !== null) { - methodNodeIndex ||= this.#createMethodNodeIndex(methodRoutes) - if (!methodNodeIndex.staticRoutes.has(staticRouteKey)) { - methodNodeIndex.staticRoutes.set(staticRouteKey, { - route, - precedingDynamicRoutesCount: methodNodeIndex.dynamicRoutes.length, - }) - } - } else if (methodNodeIndex) { - methodNodeIndex.dynamicRoutes.push(tokens) - } + this.#methodRouteTables.get(methodRoutes)!.add(tokens, route) methodRoutes.tokens.push(tokens) methodRoutes.routes[route.pattern] = route @@ -379,50 +236,12 @@ export class RoutesStore { return null } - const methodNodeIndex = this.#methodNodeIndexes.get(matchedMethod) - if (methodNodeIndex) { - const staticRoute = methodNodeIndex.staticRoutes.get(this.#getStaticRequestKey(url)) - - const dynamicRoute = staticRoute - ? this.#matchRoutesBefore( - url, - methodNodeIndex.dynamicRoutes, - staticRoute.precedingDynamicRoutesCount - ) - : matchit.match(url, methodNodeIndex.dynamicRoutes) - if (!dynamicRoute.length) { - if (!staticRoute) { - return null - } - - return this.#createMatchedRoute(staticRoute.route, matchedMethod, {}, domain) - } - - const route = matchedMethod.routes[dynamicRoute[0].old] - return this.#createMatchedRoute( - route, - matchedMethod, - matchit.exec(url, dynamicRoute, shouldDecodeParam), - domain - ) - } - - /* - * Next, match route for the given url inside the tokens list for the - * matchedMethod - */ - const matchedRoute = matchit.match(url, matchedMethod.tokens) - if (!matchedRoute.length) { + const matchedRoute = this.#methodRouteTables.get(matchedMethod)!.match(url, shouldDecodeParam) + if (!matchedRoute) { return null } - const route = matchedMethod.routes[matchedRoute[0].old] - return this.#createMatchedRoute( - route, - matchedMethod, - matchit.exec(url, matchedRoute, shouldDecodeParam), - domain - ) + return this.#createMatchedRoute(matchedRoute.value, matchedMethod, matchedRoute.params, domain) } /** @@ -435,6 +254,6 @@ export class RoutesStore { return [] } - return matchit.match(hostname, this.tree.tokens) + return this.#domainRouteTable.match(hostname, false)?.value ?? [] } } diff --git a/src/types/route.ts b/src/types/route.ts index 976b5d5f..a9e7bbfc 100644 --- a/src/types/route.ts +++ b/src/types/route.ts @@ -27,7 +27,7 @@ export type RouteMatcher = { } /** - * Route token structure used internally by the matchit routing library + * Route token structure used internally by the router */ export type MatchItRouteToken = RouteMatcher & ClientRouteMatchItTokens diff --git a/tests/router/route_parser.spec.ts b/tests/router/route_parser.spec.ts index ec6606df..c86b67bd 100644 --- a/tests/router/route_parser.spec.ts +++ b/tests/router/route_parser.spec.ts @@ -13,6 +13,29 @@ import { test } from '@japa/runner' import { parseRoute } from '../../src/helpers.ts' test.group('Route parser', () => { + test('ignore non-object matcher collections like matchit', ({ assert }) => { + assert.deepEqual(parseRoute('/:0', 'x' as never), matchit.parse('/:0', 'x')) + }) + + test('parse the same tokens as matchit across generated pattern strings', ({ assert }) => { + const alphabet = ['/', ':', '*', '?', '.', 'a', 'Z', '0', '-', '_', 'é', '😀'] + let seed = 73 + function random() { + seed = (seed * 1_664_525 + 1_013_904_223) >>> 0 + return seed / 2 ** 32 + } + + for (let iteration = 0; iteration < 10_000; iteration++) { + const length = Math.floor(random() * 30) + let pattern = '' + for (let index = 0; index < length; index++) { + pattern += alphabet[Math.floor(random() * alphabet.length)] + } + + assert.deepEqual(parseRoute(pattern), matchit.parse(pattern), pattern) + } + }) + test('parse route with params', ({ assert }) => { const tokens = parseRoute('/posts/:id') assert.deepEqual(tokens, [ diff --git a/tests/router/route_table.spec.ts b/tests/router/route_table.spec.ts new file mode 100644 index 00000000..618e2672 --- /dev/null +++ b/tests/router/route_table.spec.ts @@ -0,0 +1,145 @@ +/* + * @adonisjs/http-server + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { parseRoute } from '../../src/helpers.ts' +import { RouteTable } from '../../src/router/route_table.ts' + +test.group('Route table', () => { + test('return the first registered matching route regardless of its shape', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const dynamicRoute = { pattern: '/:value' } + const staticRoute = { pattern: '/users' } + + table.add(parseRoute(dynamicRoute.pattern), dynamicRoute) + table.add(parseRoute(staticRoute.pattern), staticRoute) + + const match = table.match('/users', false) + assert.strictEqual(match?.value, dynamicRoute) + assert.deepEqual(match, { + value: dynamicRoute, + params: { value: 'users' }, + }) + }) + + test('match and decode wildcard parameters', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const route = { pattern: '/files/*' } + + table.add(parseRoute(route.pattern), route) + + assert.deepEqual(table.match('/files/folder%20one/file%20two', true), { + value: route, + params: { '*': ['folder one', 'file two'] }, + }) + }) + + test('match optional parameters with and without a value', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const route = { pattern: '/archive/:year?' } + + table.add(parseRoute(route.pattern), route) + + assert.deepEqual(table.match('/archive', false), { value: route, params: {} }) + assert.deepEqual(table.match('/archive/2026', false), { + value: route, + params: { year: '2026' }, + }) + + const rootTable = new RouteTable<{ pattern: string }>() + const rootRoute = { pattern: '/:value?' } + rootTable.add(parseRoute(rootRoute.pattern), rootRoute) + assert.deepEqual(rootTable.match('/', false), { value: rootRoute, params: {} }) + }) + + test('preserve stateful matcher evaluation order', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const matcher = /^[a-z]+$/g + const dynamicRoute = { pattern: '/:value/foo' } + const staticRoute = { pattern: '/users/bar' } + + table.add(parseRoute(dynamicRoute.pattern, { value: { match: matcher } }), dynamicRoute) + table.add(parseRoute(staticRoute.pattern), staticRoute) + + assert.deepEqual(table.match('/users/bar', false), { value: staticRoute, params: {} }) + assert.isNull(table.match('/abc/foo', false)) + }) + + test('preserve missing parameter semantics before a trailing optional', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const route = { pattern: '/archive/:year/:month?' } + + table.add(parseRoute(route.pattern), route) + + assert.deepEqual(table.match('/archive', false), { value: route, params: {} }) + }) + + test('preserve parameter extraction for non-canonical wildcard patterns', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const route = { pattern: '/*/:id/*' } + + table.add(parseRoute(route.pattern), route) + + assert.deepEqual(table.match('////a////', false), { + value: route, + params: { + '*/:id/*': '', + 'id': '', + '*': ['a', '', '', ''], + }, + }) + }) + + test('preserve custom regular expression evaluation order', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const matcher = /^[a-z]+$/ + let matcherCalls = 0 + matcher.exec = function exec(value: string) { + matcherCalls++ + return RegExp.prototype.exec.call(this, value) + } + + const dynamicRoute = { pattern: '/:value/foo' } + const staticRoute = { pattern: '/users/bar' } + table.add(parseRoute(dynamicRoute.pattern, { value: { match: matcher } }), dynamicRoute) + table.add(parseRoute(staticRoute.pattern), staticRoute) + + assert.deepEqual(table.match('/users/bar', false), { value: staticRoute, params: {} }) + assert.equal(matcherCalls, 1) + }) + + test('evaluate every matcher on a structurally matched route', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const constrainedRoute = { pattern: '/:section/:id?' } + const fallbackRoute = { pattern: '/:type/:value' } + + table.add( + parseRoute(constrainedRoute.pattern, { + section: { match: /^users$/ }, + id: { match: /^\d+$/ }, + }), + constrainedRoute + ) + table.add(parseRoute(fallbackRoute.pattern), fallbackRoute) + + assert.deepEqual(table.match('/users/42', false), { + value: constrainedRoute, + params: { section: 'users', id: '42' }, + }) + assert.deepEqual(table.match('/users', false), { + value: constrainedRoute, + params: { section: 'users' }, + }) + assert.deepEqual(table.match('/users/not-a-number', false), { + value: fallbackRoute, + params: { type: 'users', value: 'not-a-number' }, + }) + }) +}) diff --git a/tests/router/store.spec.ts b/tests/router/store.spec.ts index 078d348c..d7ae2998 100644 --- a/tests/router/store.spec.ts +++ b/tests/router/store.spec.ts @@ -785,7 +785,7 @@ test.group('Store | match', () => { } } - for (let iteration = 0; iteration < 250; iteration++) { + for (let iteration = 0; iteration < 1_000; iteration++) { const shuffled = routeDefinitions.slice() for (let index = shuffled.length - 1; index > 0; index--) { const swapIndex = Math.floor(random() * (index + 1)) From 7749a9145afeb78512f1536ec6877ecdc1261d23 Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Wed, 26 Aug 2026 06:54:02 +0000 Subject: [PATCH 3/7] fix(router): reject missing required route params --- src/router/route_table.ts | 20 +++++++++++++++----- tests/router/router.spec.ts | 27 +++++++++++++++++++++++++++ tests/router/store.spec.ts | 4 ++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/router/route_table.ts b/src/router/route_table.ts index 511d9b3f..6f3b7739 100644 --- a/src/router/route_table.ts +++ b/src/router/route_table.ts @@ -351,7 +351,8 @@ export class RouteTable { segments: string[], segmentIndex: number, cutoff: number, - candidateLists: IndexedRoute[][] + candidateLists: IndexedRoute[][], + canMatchTerminal: boolean = true ) { if (node.minimumOrder >= cutoff) { return @@ -364,15 +365,24 @@ export class RouteTable { if (segmentIndex === segments.length) { const terminals = node.terminals - if (terminals?.length && terminals[0].order < cutoff) { + if (canMatchTerminal && terminals?.length && terminals[0].order < cutoff) { candidateLists.push(terminals) } - for (const optionalChild of node.optionals?.values() ?? []) { - this.#collectCandidates(optionalChild, segments, segmentIndex, cutoff, candidateLists) + for (const [suffix, optionalChild] of node.optionals ?? []) { + if (suffix === '') { + this.#collectCandidates(optionalChild, segments, segmentIndex, cutoff, candidateLists) + } } for (const [suffix, parameterChild] of node.parameters ?? []) { if (suffix === '') { - this.#collectCandidates(parameterChild, segments, segmentIndex, cutoff, candidateLists) + this.#collectCandidates( + parameterChild, + segments, + segmentIndex, + cutoff, + candidateLists, + false + ) } } return diff --git a/tests/router/router.spec.ts b/tests/router/router.spec.ts index 3493eee1..e8473f51 100644 --- a/tests/router/router.spec.ts +++ b/tests/router/router.spec.ts @@ -558,6 +558,33 @@ test.group('Router | commit', () => { }) test.group('Router | match', () => { + test('do not match "/users" against "/users/:id"', ({ assert }) => { + const router = new RouterFactory().create() + router.get('/users/:id', async () => {}) + router.commit() + + assert.isNull(router.match('/users', 'GET', false)) + }) + + test('match "/users" route registered after "/users/:id"', ({ assert }) => { + async function indexHandler() {} + + const router = new RouterFactory().create() + router.get('/users/:id', async () => {}) + router.get('/users', indexHandler) + router.commit() + + assert.strictEqual(router.match('/users', 'GET', false)?.route.handler, indexHandler) + }) + + test('do not match "/posts" against "/posts/:slug?.json"', ({ assert }) => { + const router = new RouterFactory().create() + router.get('/posts/:slug?.json', async () => {}) + router.commit() + + assert.isNull(router.match('/posts', 'GET', false)) + }) + test('do not let a malformed repeated-separator route shadow the root route', ({ assert }) => { async function repeatedSeparatorHandler() {} async function rootHandler() {} diff --git a/tests/router/store.spec.ts b/tests/router/store.spec.ts index d7ae2998..5a8ffd9f 100644 --- a/tests/router/store.spec.ts +++ b/tests/router/store.spec.ts @@ -739,6 +739,7 @@ test.group('Store | match', () => { { pattern: '/teams/:id', matchers: { id: { match: /^\d+$/, cast: Number } } }, { pattern: '/teams/:id?' }, { pattern: '/teams/*' }, + { pattern: '/posts/:slug?.json' }, ] const pathnames = [ '', @@ -753,9 +754,12 @@ test.group('Store | match', () => { '//users', '/teams/users', '/teams//users', + '/teams', '/teams/42', '/teams/Romain%20Lanz', '/teams/42/members', + '/posts', + '/posts/article.json', '/missing', ] From 1ba09ef08494167d0a0c3662c596c4792221299f Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Wed, 26 Aug 2026 07:24:14 +0000 Subject: [PATCH 4/7] fix(router): preserve empty wildcard matches --- src/router/route_table.ts | 2 +- tests/router/route_table.spec.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/router/route_table.ts b/src/router/route_table.ts index 6f3b7739..fb6bb4a2 100644 --- a/src/router/route_table.ts +++ b/src/router/route_table.ts @@ -359,7 +359,7 @@ export class RouteTable { } const wildcards = node.wildcards - if (wildcards?.length && wildcards[0].order < cutoff && segmentIndex < segments.length) { + if (wildcards?.length && wildcards[0].order < cutoff) { candidateLists.push(wildcards) } diff --git a/tests/router/route_table.spec.ts b/tests/router/route_table.spec.ts index 618e2672..50b405cc 100644 --- a/tests/router/route_table.spec.ts +++ b/tests/router/route_table.spec.ts @@ -97,6 +97,18 @@ test.group('Route table', () => { }) }) + test('preserve empty path matches with a wildcard before a trailing optional', ({ assert }) => { + const table = new RouteTable<{ pattern: string }>() + const route = { pattern: '/:value/*:optional?' } + + table.add(parseRoute(route.pattern), route) + + assert.deepEqual(table.match('', false), { + value: route, + params: { value: '' }, + }) + }) + test('preserve custom regular expression evaluation order', ({ assert }) => { const table = new RouteTable<{ pattern: string }>() const matcher = /^[a-z]+$/ From eecc1c9e2e17e8cf9a9b9d68d380f6d42792112b Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Wed, 26 Aug 2026 09:29:42 +0000 Subject: [PATCH 5/7] test(router): cover upstream matchit behavior --- tests/router/matchit_compatibility.spec.ts | 337 +++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 tests/router/matchit_compatibility.spec.ts diff --git a/tests/router/matchit_compatibility.spec.ts b/tests/router/matchit_compatibility.spec.ts new file mode 100644 index 00000000..167c6104 --- /dev/null +++ b/tests/router/matchit_compatibility.spec.ts @@ -0,0 +1,337 @@ +/* + * @adonisjs/http-server + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// @ts-expect-error +import matchit from '@poppinss/matchit' +import { test } from '@japa/runner' + +import { parseRoute } from '../../src/helpers.ts' +import { RouteTable, extractRouteParams } from '../../src/router/route_table.ts' +import type { RouteMatchers } from '../../src/types/route.ts' + +type RouteDefinition = { + matchers?: RouteMatchers + pattern: string +} + +/** + * Compatibility vectors copied from the complete upstream test suites: + * https://github.com/lukeed/matchit/blob/master/test/index.js + * https://github.com/poppinss/matchit/blob/master/test/index.js + */ +function matchWithMatchit( + pathname: string, + definitions: RouteDefinition[], + shouldDecodeParams: boolean = false +) { + const tokenLists = definitions.map(({ pattern, matchers }) => matchit.parse(pattern, matchers)) + const matchedTokens = matchit.match(pathname, tokenLists) + if (!matchedTokens.length) { + return null + } + + return { + params: matchit.exec(pathname, matchedTokens, shouldDecodeParams), + pattern: matchedTokens[0].old, + } +} + +function matchWithRouteTable( + pathname: string, + definitions: RouteDefinition[], + shouldDecodeParams: boolean = false +) { + const table = new RouteTable() + for (const { pattern, matchers } of definitions) { + table.add(parseRoute(pattern, matchers), pattern) + } + + const match = table.match(pathname, shouldDecodeParams) + return match ? { params: match.params, pattern: match.value } : null +} + +test.group('Route table | matchit upstream compatibility', () => { + test('parse every pattern from the upstream suites', ({ assert }) => { + const patterns = [ + '', + '/', + '/about', + 'contact', + '/foobar', + '/:foo', + 'books/:title', + '/foo/:bar', + '/:foo.bar', + 'books/:title.jpg', + '/foo/:bar.html', + '/foo/:bar/:baz', + '/foo/bar/:baz', + '/foo/bar/:baz/:bat', + '/:foo?', + 'foo/:bar?', + '/foo/:bar?/:baz?', + '*', + '/*', + 'foo/*', + 'foo/bar/*', + ] + + for (const pattern of patterns) { + assert.deepEqual(parseRoute(pattern), matchit.parse(pattern), pattern) + } + }) + + test('match every pathname from the inherited lukeed suite', ({ assert }) => { + const definitions = [ + '/', + '/about', + 'contact', + '/books', + '/books/:title', + '/foo/*', + 'bar/:baz/:bat?', + '/videos/:title.mp4', + ].map((pattern) => ({ pattern })) + const cases = [ + { pathname: '/', expected: '/' }, + { pathname: '/about', expected: '/about' }, + { pathname: 'contact', expected: 'contact' }, + { pathname: 'about', expected: '/about' }, + { pathname: '/contact', expected: 'contact' }, + { pathname: '/books/', expected: '/books' }, + { pathname: '/books/foobar', expected: '/books/:title' }, + { pathname: '/books/foo/bar', expected: null }, + { pathname: '/hello/world', expected: null }, + { pathname: '/videos/buckbunny.mp4', expected: '/videos/:title.mp4' }, + { pathname: '/videos/buckbunny', expected: null }, + { pathname: '/bar/hello', expected: 'bar/:baz/:bat?' }, + { pathname: '/bar/hello/world', expected: 'bar/:baz/:bat?' }, + { pathname: '/books/narnia?author=lukeed', expected: '/books/:title' }, + { pathname: '/foo/bar', expected: '/foo/*' }, + { pathname: '/foo/bar/baz', expected: '/foo/*' }, + ] + + for (const { pathname, expected } of cases) { + const oracle = matchWithMatchit(pathname, definitions) + const actual = matchWithRouteTable(pathname, definitions) + assert.equal(actual?.pattern ?? null, expected, pathname) + assert.deepEqual(actual, oracle, pathname) + } + }) + + test('preserve every root and segment cardinality case', ({ assert }) => { + const cases = [ + { patterns: ['/'], pathname: '/', expected: '/' }, + { patterns: ['/:title'], pathname: '/', expected: null }, + { patterns: ['/:title'], pathname: '/narnia', expected: '/:title' }, + { patterns: ['/:title?'], pathname: '/', expected: '/:title?' }, + { patterns: ['*'], pathname: '/', expected: '*' }, + { patterns: ['/x', '*'], pathname: '/', expected: '*' }, + { patterns: ['*', '/x'], pathname: '/', expected: '*' }, + { patterns: ['/books/:title'], pathname: '/books', expected: null }, + { patterns: ['/books'], pathname: '/books/123', expected: null }, + ] + + for (const { patterns, pathname, expected } of cases) { + const definitions = patterns.map((pattern) => ({ pattern })) + const oracle = matchWithMatchit(pathname, definitions) + const actual = matchWithRouteTable(pathname, definitions) + assert.equal(actual?.pattern ?? null, expected, JSON.stringify({ patterns, pathname })) + assert.deepEqual(actual, oracle, JSON.stringify({ patterns, pathname })) + } + }) + + test('extract params for every inherited exec case', ({ assert }) => { + const cases = [ + { pattern: '/', pathname: '/', expected: {} }, + { pattern: '/:type?', pathname: '/', expected: {} }, + { pattern: '/:type?', pathname: '/news', expected: { type: 'news' } }, + { pattern: '/about', pathname: '/about', expected: {} }, + { pattern: 'contact', pathname: '/contact', expected: {} }, + { pattern: '/books/:title', pathname: '/books/foo', expected: { title: 'foo' } }, + { + pattern: '/videos/:title.mp4', + pathname: '/videos/foo.mp4', + expected: { title: 'foo' }, + }, + { + pattern: '/foo/:bar/:baz', + pathname: '/foo/hello/world', + expected: { bar: 'hello', baz: 'world' }, + }, + { + pattern: 'bar/:baz/:bat?', + pathname: '/bar/hello', + expected: { baz: 'hello' }, + }, + { + pattern: 'bar/:baz/:bat?', + pathname: '/bar/hello/world', + expected: { baz: 'hello', bat: 'world' }, + }, + { + pattern: '/books/:title', + pathname: '/books/foo?author=lukeed', + expected: { title: 'foo?author=lukeed' }, + }, + { pattern: '/', pathname: 'foo', expected: {} }, + ] + + for (const { pattern, pathname, expected } of cases) { + const oracleTokens = matchit.parse(pattern) + const actual = extractRouteParams(parseRoute(pattern), pathname, false) + assert.deepEqual(actual, expected, JSON.stringify({ pattern, pathname })) + assert.deepEqual(actual, matchit.exec(pathname, oracleTokens), pattern) + } + }) + + test('preserve every poppinss matcher case', ({ assert }) => { + const alphaMatcher = { bar: { match: /[a-z]+/ } } + const optionalNumberMatcher = { bar: { match: /^[0-9]+$/ } } + const cases: { + definitions: RouteDefinition[] + expected: string | null + pathname: string + }[] = [ + { + definitions: [{ pattern: '/foo/:bar', matchers: alphaMatcher }], + pathname: '/foo/1', + expected: null, + }, + { + definitions: [{ pattern: '/foo/:bar', matchers: alphaMatcher }], + pathname: '/foo/bar', + expected: '/foo/:bar', + }, + { + definitions: [{ pattern: '/foo/:bar', matchers: alphaMatcher }], + pathname: '/foo/', + expected: null, + }, + { + definitions: [{ pattern: '/foo/:bar', matchers: alphaMatcher }], + pathname: '/foo', + expected: null, + }, + { + definitions: [{ pattern: '/foo/:bar?', matchers: optionalNumberMatcher }], + pathname: '/foo', + expected: '/foo/:bar?', + }, + { + definitions: [{ pattern: '/foo/:bar?', matchers: optionalNumberMatcher }], + pathname: '/foo/1', + expected: '/foo/:bar?', + }, + { + definitions: [{ pattern: '/foo/:bar?', matchers: optionalNumberMatcher }], + pathname: '/foo/', + expected: '/foo/:bar?', + }, + { + definitions: [ + { pattern: '/foo/:bar?', matchers: alphaMatcher }, + { pattern: '/foo/:id?', matchers: optionalNumberMatcher }, + ], + pathname: '/foo/1', + expected: '/foo/:id?', + }, + { + definitions: [{ pattern: '/foo/:bar/baz', matchers: alphaMatcher }], + pathname: '/foo/bar/baz', + expected: '/foo/:bar/baz', + }, + { + definitions: [{ pattern: '/foo/:bar/baz', matchers: alphaMatcher }], + pathname: '/foo//baz', + expected: null, + }, + { + definitions: [{ pattern: '/foo/:bar/baz' }], + pathname: '/foo/bar/baz', + expected: '/foo/:bar/baz', + }, + { + definitions: [{ pattern: '/foo/:bar/baz' }], + pathname: '/foo//baz', + expected: '/foo/:bar/baz', + }, + ] + + for (const { definitions, pathname, expected } of cases) { + const oracle = matchWithMatchit(pathname, definitions) + const actual = matchWithRouteTable(pathname, definitions) + assert.equal(actual?.pattern ?? null, expected, JSON.stringify({ definitions, pathname })) + assert.deepEqual(actual, oracle, JSON.stringify({ definitions, pathname })) + } + }) + + test('preserve poppinss wildcard, cast, and decoding extensions', ({ assert }) => { + const cases: { + decode?: boolean + expected: Record + matchers?: RouteMatchers + pathname: string + pattern: string + }[] = [ + { + pattern: '/foo/*', + pathname: '/foo/bar/baz', + expected: { '*': ['bar', 'baz'] }, + }, + { + pattern: '/foo/:bar', + pathname: '/foo/1', + matchers: { bar: { match: /^[0-9]+$/, cast: Number } }, + expected: { bar: 1 }, + }, + { + pattern: '/foo/:bar?', + pathname: '/foo', + matchers: { bar: { match: /^[0-9]+$/, cast: Number } }, + expected: {}, + }, + { + pattern: '/foo/:bar/:baz', + pathname: '/foo/1/hello', + matchers: { + bar: { match: /^[0-9]+$/, cast: Number }, + baz: { cast: (value) => value.toUpperCase() }, + }, + expected: { bar: 1, baz: 'HELLO' }, + }, + { + pattern: '/foo/:bar', + pathname: '/foo/fran%C3%A7ais', + decode: true, + expected: { bar: 'français' }, + }, + { + pattern: '/foo/:bar?', + pathname: '/foo/fran%C3%A7ais', + decode: true, + expected: { bar: 'français' }, + }, + { + pattern: '/foo/*', + pathname: '/foo/fran%C3%A7ais', + decode: true, + expected: { '*': ['français'] }, + }, + ] + + for (const { pattern, pathname, matchers, decode = false, expected } of cases) { + const definitions = [{ pattern, matchers }] + const oracle = matchWithMatchit(pathname, definitions, decode) + const actual = matchWithRouteTable(pathname, definitions, decode) + assert.deepEqual(actual?.params, expected, JSON.stringify({ pattern, pathname })) + assert.deepEqual(actual, oracle, JSON.stringify({ pattern, pathname })) + } + }) +}) From c703bb09d7b6a98de2cb13240389e60d539c0e3f Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Wed, 2 Sep 2026 20:15:47 +0000 Subject: [PATCH 6/7] refactor(router): use route matcher package --- package-lock.json | 169 +++++++-- package.json | 2 +- src/client/helpers.ts | 4 +- src/client/types.ts | 9 +- src/client/url_builder.ts | 3 +- src/helpers.ts | 16 +- src/router/route.ts | 8 +- src/router/route_parser.ts | 113 ------ src/router/route_table.ts | 415 --------------------- src/router/store.ts | 15 +- src/types/route.ts | 42 +-- tests/router/matchit_compatibility.spec.ts | 337 ----------------- tests/router/route_parser.spec.ts | 34 -- tests/router/route_table.spec.ts | 157 -------- tests/router/store.spec.ts | 117 +----- 15 files changed, 185 insertions(+), 1256 deletions(-) delete mode 100644 src/router/route_parser.ts delete mode 100644 src/router/route_table.ts delete mode 100644 tests/router/matchit_compatibility.spec.ts delete mode 100644 tests/router/route_table.spec.ts diff --git a/package-lock.json b/package-lock.json index 916af690..7a1f16ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "9.2.0", "license": "MIT", "dependencies": { + "@boringnode/route-matcher": "^0.1.1", "@poppinss/macroable": "^1.1.2", "@poppinss/middleware": "^3.2.7", "@poppinss/qs": "^6.15.0", @@ -43,7 +44,6 @@ "@japa/file-system": "^3.0.0", "@japa/runner": "^5.3.0", "@japa/snapshot": "^2.0.10", - "@poppinss/matchit": "^3.2.0", "@poppinss/ts-exec": "^1.4.4", "@release-it/conventional-changelog": "^11.0.0", "@types/accepts": "^1.3.7", @@ -109,7 +109,6 @@ "integrity": "sha512-iQpq/JRJsnrqOMHfu72CYjmlkH5FwT28DhUKEOjktccmFh8OLdVZ2Sieb8b2/qNv4c+w8Yo7keOGEzOYUrU+kA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@poppinss/hooks": "^7.3.0", "@poppinss/macroable": "^1.1.0", @@ -220,7 +219,6 @@ "integrity": "sha512-RnmDPWz2imVp/B74xitxCPqTdoP07bZvfJe1bh9CD9Rmia4jjDvehZF67KFyGNMZ24MuKasqs3jOcM1vGJp0GA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@poppinss/utils": "^7.0.0", "parse-imports": "^3.0.0" @@ -267,14 +265,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@arr/every": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@assemblyscript/loader": { "version": "0.19.23", "dev": true, @@ -390,6 +380,15 @@ "node": ">=20.6" } }, + "node_modules/@boringnode/route-matcher": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@boringnode/route-matcher/-/route-matcher-0.1.1.tgz", + "integrity": "sha512-Iim6TZdwX3BuQ+m4wg5wIEviIoi0Xy6j6cpw4B2o1wppByUxafbLMgMIFJ6yS/l1XKXifEGcds9M1Gw7Fk8DUg==", + "license": "MIT", + "engines": { + "node": ">=20.6" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.0.3", "dev": true, @@ -460,13 +459,39 @@ } } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1158,7 +1183,6 @@ "version": "4.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@poppinss/macroable": "^1.1.0", "@types/chai": "^5.2.3", @@ -1240,7 +1264,6 @@ "integrity": "sha512-WCnTd1q2EpbKKa96NzL16kVxJXVLRj1VqbswNAn17hYSuMlNKKhPGNbAosB32QZVFcoe9fv4Ebh1HtjyAT/viw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@japa/core": "^10.4.0", "@japa/errors-printer": "^4.1.4", @@ -1450,7 +1473,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1697,14 +1719,6 @@ "integrity": "sha512-FAVBRzzWhYP5mA3lCwLH1A0fKBqq5anyjGet90Z81aRK5c/+LTGUE1zJhZrErjaenBSOOI9BVUs3WVmotneFQA==", "license": "MIT" }, - "node_modules/@poppinss/matchit": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@arr/every": "^1.0.0" - } - }, "node_modules/@poppinss/middleware": { "version": "3.2.7", "license": "MIT" @@ -2029,6 +2043,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", @@ -2811,7 +2859,6 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -3086,7 +3133,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3438,7 +3484,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4684,7 +4729,6 @@ "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -4741,7 +4785,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6267,7 +6310,6 @@ "version": "2.6.1", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -7369,7 +7411,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7585,8 +7626,7 @@ "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-0.0.1.tgz", "integrity": "sha512-fBWNLTBkxkLAhe1AzF1hyXEvuA+N+vV1WMP2D6iiMUblvmOt8Pp5t8zUcgvz7aYA1ldUdxDlgUse15dmcKjkNg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/rc9": { "version": "2.1.2", @@ -7814,7 +7854,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@inquirer/prompts": "8.4.2", "@octokit/rest": "22.0.1", @@ -7941,7 +7980,6 @@ "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" @@ -8838,7 +8876,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8955,6 +8992,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "rolldown": "1.0.0-rc.17" }, @@ -8976,6 +9014,43 @@ } } }, + "node_modules/unrun/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/unrun/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/unrun/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/unrun/node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -8983,6 +9058,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -9000,6 +9076,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9017,6 +9094,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9034,6 +9112,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9051,6 +9130,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9068,6 +9148,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9085,6 +9166,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9102,6 +9184,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9119,6 +9202,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9136,6 +9220,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9153,6 +9238,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9170,6 +9256,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9187,6 +9274,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9201,6 +9289,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", @@ -9223,6 +9312,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9240,6 +9330,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9250,7 +9341,8 @@ "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/unrun/node_modules/rolldown": { "version": "1.0.0-rc.17", @@ -9259,6 +9351,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" diff --git a/package.json b/package.json index c219bbee..67e77154 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "@japa/file-system": "^3.0.0", "@japa/runner": "^5.3.0", "@japa/snapshot": "^2.0.10", - "@poppinss/matchit": "^3.2.0", "@poppinss/ts-exec": "^1.4.4", "@release-it/conventional-changelog": "^11.0.0", "@types/accepts": "^1.3.7", @@ -96,6 +95,7 @@ "youch": "^4.1.1" }, "dependencies": { + "@boringnode/route-matcher": "^0.1.1", "@poppinss/macroable": "^1.1.2", "@poppinss/middleware": "^3.2.7", "@poppinss/qs": "^6.15.0", diff --git a/src/client/helpers.ts b/src/client/helpers.ts index 84715691..f02b28fd 100644 --- a/src/client/helpers.ts +++ b/src/client/helpers.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import { type ClientRouteMatchItTokens, type ClientRouteJSON, type URLOptions } from './types.ts' +import { type ClientRouteToken, type ClientRouteJSON, type URLOptions } from './types.ts' /** * Finds a route by its identifier across domains. @@ -99,7 +99,7 @@ export function findRoute( */ export function createURL( pattern: string, - tokens: Pick[], + tokens: Pick[], searchParamsStringifier: (qs: Record) => string, params?: any[] | { [param: string]: any }, options?: URLOptions diff --git a/src/client/types.ts b/src/client/types.ts index 45901fe7..35daf8fc 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -11,7 +11,7 @@ * Types shared with the client. These should never import other types */ -export type ClientRouteMatchItTokens = { +export type ClientRouteToken = { /** Original token string */ old: string /** Token type identifier (0=static, 1=param, 2=wildcard, 3=optional) */ @@ -22,6 +22,11 @@ export type ClientRouteMatchItTokens = { end: string } +/** + * @deprecated Use `ClientRouteToken` instead. + */ +export type ClientRouteMatchItTokens = ClientRouteToken + /** * Complete route definition with all metadata, handlers, and execution context */ @@ -44,7 +49,7 @@ export type ClientRouteJSON = { /** * Tokens to be used to construct the route URL */ - tokens: ClientRouteMatchItTokens[] + tokens: ClientRouteToken[] /** * HTTP methods, the route responds to. diff --git a/src/client/url_builder.ts b/src/client/url_builder.ts index 5224bc89..8fa2e54d 100644 --- a/src/client/url_builder.ts +++ b/src/client/url_builder.ts @@ -21,7 +21,8 @@ export { createURL, findRoute } */ export function createUrlBuilder( routesLoader: - { [domain: string]: ClientRouteJSON[] } | (() => { [domain: string]: ClientRouteJSON[] }), + | { [domain: string]: ClientRouteJSON[] } + | (() => { [domain: string]: ClientRouteJSON[] }), searchParamsStringifier: (qs: Record) => string, defaultOptions?: URLOptions ): UrlFor { diff --git a/src/helpers.ts b/src/helpers.ts index e368c27a..d32fc21a 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -11,6 +11,12 @@ import { serialize } from 'cookie-es' import string from '@poppinss/utils/string' import { type Encryption } from '@boringnode/encryption' import { parseBindingReference } from '@adonisjs/fold' +import { + parseRoute as parseRoutePattern, + matchRouteTokens, + type RouteToken, + type RouteMatchers, +} from '@boringnode/route-matcher' import { type Qs } from './qs.ts' import { safeDecodeURI } from './utils.ts' @@ -18,9 +24,7 @@ import type { HttpRequest } from './request.ts' import { createURL } from './client/helpers.ts' import { type CookieOptions } from './types/response.ts' import { type SignedURLOptions } from './types/url_builder.ts' -import type { RouteMatchers, RouteJSON, MatchItRouteToken } from './types/route.ts' -import { matchRouteTokens } from './router/route_table.ts' -import { parseRoutePattern } from './router/route_parser.ts' +import type { RouteJSON } from './types/route.ts' import { type MiddlewareFn, type RouteHandlerInfo, @@ -156,9 +160,9 @@ export { default as mime } from 'mime-types' * * @param pattern - The route pattern to parse * @param matchers - Optional route matchers - * @returns {MatchItRouteToken[]} Array of parsed route tokens + * @returns {RouteToken[]} Array of parsed route tokens */ -export function parseRoute(pattern: string, matchers?: RouteMatchers): MatchItRouteToken[] { +export function parseRoute(pattern: string, matchers?: RouteMatchers): RouteToken[] { return parseRoutePattern(pattern, matchers) } @@ -176,7 +180,7 @@ export function parseRoute(pattern: string, matchers?: RouteMatchers): MatchItRo */ export function createSignedURL( identifier: string, - tokens: MatchItRouteToken[], + tokens: RouteToken[], searchParamsStringifier: (qs: Record) => string, encryption: Encryption, params?: any[] | { [param: string]: any }, diff --git a/src/router/route.ts b/src/router/route.ts index d993cb7d..5c108be4 100644 --- a/src/router/route.ts +++ b/src/router/route.ts @@ -144,7 +144,9 @@ export class Route = any> extends Macroable pattern: string methods: string[] handler: - RouteFn | string | [LazyImport | Controller, GetControllerHandlers?] + | RouteFn + | string + | [LazyImport | Controller, GetControllerHandlers?] globalMatchers: RouteMatchers } ) { @@ -172,7 +174,9 @@ export class Route = any> extends Macroable */ #resolveRouteHandle( handler: - RouteFn | string | [LazyImport | Controller, GetControllerHandlers?] + | RouteFn + | string + | [LazyImport | Controller, GetControllerHandlers?] ) { /** * Convert magic string to handle method call diff --git a/src/router/route_parser.ts b/src/router/route_parser.ts deleted file mode 100644 index 63f85fcd..00000000 --- a/src/router/route_parser.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * @adonisjs/http-server - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { MatchItRouteToken, RouteMatchers } from '../types/route.ts' - -export type ParsedRouteToken = MatchItRouteToken & { - matcher?: RegExp -} - -export function stripRouteSeparators(value: string): string { - if (value === '/') { - return value - } - if (value.charCodeAt(0) === 47) { - value = value.substring(1) - } - - const lastIndex = value.length - 1 - return value.charCodeAt(lastIndex) === 47 ? value.substring(0, lastIndex) : value -} - -/** - * Parses a route pattern into the token format shared by route matching and - * URL generation. Single leading/trailing separator stripping is preserved - * for backwards compatibility. - */ -export function parseRoutePattern( - pattern: string, - matchers: RouteMatchers = {} -): MatchItRouteToken[] { - if (pattern === '/') { - return [{ old: pattern, type: 0, val: pattern, end: '' }] - } - - if (typeof matchers !== 'object') { - matchers = {} - } - - let remaining = stripRouteSeparators(pattern) - let index = -1 - let parameterNameEnd = 0 - let segmentStart = 0 - let remainingLength = remaining.length - const tokens: MatchItRouteToken[] = [] - - while (++index < remainingLength) { - let character = remaining.charCodeAt(index) - - if (character === 58) { - segmentStart = index + 1 - let type: 1 | 3 = 1 - parameterNameEnd = 0 - let suffix = '' - - while (index < remainingLength && remaining.charCodeAt(index) !== 47) { - character = remaining.charCodeAt(index) - if (character === 63) { - parameterNameEnd = index - type = 3 - } else if (character === 46 && suffix.length === 0) { - parameterNameEnd = index - suffix = remaining.substring(index) - } - index++ - } - - const value = remaining.substring(segmentStart, parameterNameEnd || index) - const matcher = matchers[value] - tokens.push({ - old: pattern, - type, - val: value, - end: suffix, - matcher: matcher?.match, - cast: matcher?.cast, - } as ParsedRouteToken) - - remaining = remaining.substring(index) - remainingLength -= index - index = 0 - continue - } - - if (character === 42) { - tokens.push({ - old: pattern, - type: 2, - val: remaining.substring(index), - end: '', - }) - continue - } - - segmentStart = index - while (index < remainingLength && remaining.charCodeAt(index) !== 47) { - index++ - } - - const value = remaining.substring(segmentStart, index) - tokens.push({ old: pattern, type: 0, val: value, end: '' }) - remaining = remaining.substring(index) - remainingLength -= index - index = segmentStart = 0 - } - - return tokens -} diff --git a/src/router/route_table.ts b/src/router/route_table.ts deleted file mode 100644 index fb6bb4a2..00000000 --- a/src/router/route_table.ts +++ /dev/null @@ -1,415 +0,0 @@ -/* - * @adonisjs/http-server - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { MatchItRouteToken } from '../types/route.ts' -import { stripRouteSeparators, type ParsedRouteToken } from './route_parser.ts' - -type IndexedRoute = { - additionalMatcherChecks?: { index: number; matcher: RegExp }[] - isStructurallyMatched?: boolean - matcher?: RegExp - matcherSegmentIndex?: number - order: number - tokens: MatchItRouteToken[] - value: T -} - -type RouteNode = { - literals?: Map> - minimumOrder: number - optionals?: Map> - parameters?: Map> - terminals?: IndexedRoute[] - wildcards?: IndexedRoute[] -} - -function createNode(): RouteNode { - return { - minimumOrder: Number.POSITIVE_INFINITY, - } -} - -function splitRoutePath(pathname: string): string[] { - pathname = stripRouteSeparators(pathname) - return pathname === '/' ? ['/'] : pathname.split('/') -} - -function getStaticRouteKey(tokens: MatchItRouteToken[]): string | null { - if (!tokens.length || tokens.some((token) => token.type !== 0)) { - return null - } - - return tokens.length === 1 && tokens[0].val === '/' - ? 'root' - : `segments:${tokens.map((token) => token.val).join('/')}` -} - -function getStaticRequestKey(pathname: string): string { - pathname = stripRouteSeparators(pathname) - return pathname === '/' ? 'root' : `segments:${pathname}` -} - -function getOrCreateChild(children: Map>, key: string): RouteNode { - let child = children.get(key) - if (!child) { - child = createNode() - children.set(key, child) - } - return child -} - -function matchesRoute(tokens: MatchItRouteToken[], segments: string[]): boolean { - if ( - tokens.length !== segments.length && - !(tokens.length < segments.length && tokens[tokens.length - 1].type === 2) && - !(tokens.length > segments.length && tokens[tokens.length - 1].type === 3) - ) { - return false - } - - let index = 0 - while (index < tokens.length) { - const rawToken = tokens[index] - const token = rawToken as ParsedRouteToken - const segment = segments[index] - - if (token.val === segment && token.type === 0) { - index++ - continue - } - if (segment === '/') { - if (token.type > 1) { - index++ - continue - } - return false - } - if (token.type === 0) { - return false - } - if (segment === '') { - if (token.end === '' && (token.matcher ? token.matcher.test(segment) : true)) { - index++ - continue - } - return false - } - if (!segment) { - if (token.end === '') { - index++ - continue - } - return false - } - if (segment.endsWith(token.end) && (token.matcher ? token.matcher.test(segment) : true)) { - index++ - continue - } - return false - } - - return true -} - -function matchesIndexedRoute(route: IndexedRoute, segments: string[]): boolean { - if (!route.isStructurallyMatched) { - return matchesRoute(route.tokens, segments) - } - - const matcher = route.matcher - if (matcher) { - const segment = segments[route.matcherSegmentIndex!] - if (segment !== undefined && segment !== '/' && !matcher.test(segment)) { - return false - } - } - - for (const { index, matcher: additionalMatcher } of route.additionalMatcherChecks ?? []) { - const segment = segments[index] - if (segment !== undefined && segment !== '/' && !additionalMatcher.test(segment)) { - return false - } - } - return true -} - -export function extractRouteParams( - tokens: MatchItRouteToken[], - pathname: string, - shouldDecodeParams: boolean -) { - const segments = splitRoutePath(pathname) - const params: Record = {} - let index = 0 - while (index < tokens.length) { - const token = tokens[index] - const segment = segments[index] - - if (segment === '/') { - index++ - continue - } - - if (token.val === '*') { - params[token.val] = segments.slice(index).map((value) => { - if (!shouldDecodeParams) { - return value - } - try { - return decodeURIComponent(value) - } catch { - return value - } - }) - break - } - - if (segment === undefined || token.type === 0) { - index++ - continue - } - - let value = segment.replace(token.end, '') - if (shouldDecodeParams) { - try { - value = decodeURIComponent(value) - } catch {} - } - params[token.val] = token.cast ? token.cast(value) : value - index++ - } - return params -} - -/** - * Matches a transient list of tokenized routes without building an index. - */ -export function matchRouteTokens( - pathname: string, - routes: MatchItRouteToken[][], - shouldDecodeParams: boolean -): Record | null { - const segments = splitRoutePath(pathname) - for (const tokens of routes) { - if (matchesRoute(tokens, segments)) { - return extractRouteParams(tokens, pathname, shouldDecodeParams) - } - } - return null -} - -/** - * Registration-ordered route matcher. The structural index only discards - * impossible routes; the lowest registration order always selects the winner. - */ -export class RouteTable { - #nextOrder = 0 - #root = createNode() - #staticRoutes = new Map>() - #unindexedRoutes: IndexedRoute[] = [] - - add(tokens: MatchItRouteToken[], value: T): this { - const indexedRoute: IndexedRoute = { order: this.#nextOrder++, tokens, value } - const staticKey = getStaticRouteKey(tokens) - - if (staticKey !== null) { - if (!this.#staticRoutes.has(staticKey)) { - this.#staticRoutes.set(staticKey, indexedRoute) - } - return this - } - - const hasStatefulMatcher = tokens.some((rawToken, index) => { - const matcher = (rawToken as ParsedRouteToken).matcher - const isStateful = - matcher && - (matcher.global || - matcher.sticky || - matcher.exec !== RegExp.prototype.exec || - matcher.test !== RegExp.prototype.test) - if (isStateful) { - return true - } - if (matcher) { - if (!indexedRoute.matcher) { - indexedRoute.matcher = matcher - indexedRoute.matcherSegmentIndex = index - } else { - indexedRoute.additionalMatcherChecks ||= [] - indexedRoute.additionalMatcherChecks.push({ index, matcher }) - } - } - return false - }) - if (!tokens.length || hasStatefulMatcher) { - this.#unindexedRoutes.push(indexedRoute) - return this - } - - let node = this.#root - node.minimumOrder = Math.min(node.minimumOrder, indexedRoute.order) - for (const token of tokens) { - if (token.type === 0) { - node.literals ||= new Map() - node = getOrCreateChild(node.literals, token.val) - } else if (token.type === 1) { - node.parameters ||= new Map() - node = getOrCreateChild(node.parameters, token.end) - } else if (token.type === 3) { - node.optionals ||= new Map() - node = getOrCreateChild(node.optionals, token.end) - } else { - node.wildcards ||= [] - node.wildcards.push(indexedRoute) - return this - } - node.minimumOrder = Math.min(node.minimumOrder, indexedRoute.order) - } - node.terminals ||= [] - indexedRoute.isStructurallyMatched = true - node.terminals.push(indexedRoute) - return this - } - - match( - pathname: string, - shouldDecodeParams: boolean - ): { params: Record; value: T } | null { - const staticRoute = this.#staticRoutes.get(getStaticRequestKey(pathname)) - const cutoff = staticRoute?.order ?? Number.POSITIVE_INFINITY - const firstUnindexedRoute = this.#unindexedRoutes[0] - if ( - staticRoute && - this.#root.minimumOrder >= cutoff && - (!firstUnindexedRoute || firstUnindexedRoute.order >= cutoff) - ) { - return { value: staticRoute.value, params: {} } - } - - const segments = splitRoutePath(pathname) - const candidateLists: IndexedRoute[][] = [] - if (firstUnindexedRoute && firstUnindexedRoute.order < cutoff) { - candidateLists.push(this.#unindexedRoutes) - } - this.#collectCandidates(this.#root, segments, 0, cutoff, candidateLists) - - if (candidateLists.length === 1) { - const candidates = candidateLists[0] - let candidateIndex = 0 - while (candidateIndex < candidates.length) { - const candidate = candidates[candidateIndex++] - if (candidate.order >= cutoff) { - break - } - if (matchesIndexedRoute(candidate, segments)) { - return { - value: candidate.value, - params: extractRouteParams(candidate.tokens, pathname, shouldDecodeParams), - } - } - } - - return staticRoute ? { value: staticRoute.value, params: {} } : null - } - - const positions = new Uint32Array(candidateLists.length) - while (true) { - let selectedList = -1 - let selectedRoute: IndexedRoute | undefined - for (const [listIndex, candidates] of candidateLists.entries()) { - const candidate = candidates[positions[listIndex]] - if (candidate && (!selectedRoute || candidate.order < selectedRoute.order)) { - selectedList = listIndex - selectedRoute = candidate - } - } - - if (!selectedRoute || selectedRoute.order >= cutoff) { - break - } - positions[selectedList]++ - - if (matchesIndexedRoute(selectedRoute, segments)) { - return { - value: selectedRoute.value, - params: extractRouteParams(selectedRoute.tokens, pathname, shouldDecodeParams), - } - } - } - - return staticRoute ? { value: staticRoute.value, params: {} } : null - } - - #collectCandidates( - node: RouteNode, - segments: string[], - segmentIndex: number, - cutoff: number, - candidateLists: IndexedRoute[][], - canMatchTerminal: boolean = true - ) { - if (node.minimumOrder >= cutoff) { - return - } - - const wildcards = node.wildcards - if (wildcards?.length && wildcards[0].order < cutoff) { - candidateLists.push(wildcards) - } - - if (segmentIndex === segments.length) { - const terminals = node.terminals - if (canMatchTerminal && terminals?.length && terminals[0].order < cutoff) { - candidateLists.push(terminals) - } - for (const [suffix, optionalChild] of node.optionals ?? []) { - if (suffix === '') { - this.#collectCandidates(optionalChild, segments, segmentIndex, cutoff, candidateLists) - } - } - for (const [suffix, parameterChild] of node.parameters ?? []) { - if (suffix === '') { - this.#collectCandidates( - parameterChild, - segments, - segmentIndex, - cutoff, - candidateLists, - false - ) - } - } - return - } - - const segment = segments[segmentIndex] - const literalChild = node.literals?.get(segment) - if (literalChild) { - this.#collectCandidates(literalChild, segments, segmentIndex + 1, cutoff, candidateLists) - } - if (segment !== '/') { - for (const [suffix, parameterChild] of node.parameters ?? []) { - if (segment.endsWith(suffix)) { - this.#collectCandidates( - parameterChild, - segments, - segmentIndex + 1, - cutoff, - candidateLists - ) - } - } - } - for (const [suffix, optionalChild] of node.optionals ?? []) { - if (segment === '/' || segment.endsWith(suffix)) { - this.#collectCandidates(optionalChild, segments, segmentIndex + 1, cutoff, candidateLists) - } - } - } -} diff --git a/src/router/store.ts b/src/router/store.ts index 8866da3a..62d708a2 100644 --- a/src/router/store.ts +++ b/src/router/store.ts @@ -8,6 +8,7 @@ */ import { RuntimeException } from '@poppinss/utils/exception' +import { RouteTable, extractRouteParams, type RouteToken } from '@boringnode/route-matcher' import type { RouteJSON, @@ -15,11 +16,9 @@ import type { StoreDomainNode, StoreMethodNode, StoreRoutesTree, - MatchItRouteToken, } from '../types/route.ts' import debug from '../debug.ts' import { parseRoute } from '../helpers.ts' -import { RouteTable, extractRouteParams } from './route_table.ts' /** * Store class is used to store a list of routes, along side with their tokens @@ -48,7 +47,7 @@ export class RoutesStore { * its observable shape. */ #methodRouteTables = new WeakMap>() - #domainRouteTable = new RouteTable() + #domainRouteTable = new RouteTable() /** * A flag to know if routes for explicit domains @@ -95,7 +94,7 @@ export class RoutesStore { route: RouteJSON, methodNode: StoreMethodNode, params: Record, - domain?: { tokens: MatchItRouteToken[]; hostname: string } + domain?: { tokens: RouteToken[]; hostname: string } ): MatchedRoute { return { route, @@ -108,7 +107,7 @@ export class RoutesStore { /** * Collects route params */ - #collectRouteParams(route: RouteJSON, tokens: MatchItRouteToken[]) { + #collectRouteParams(route: RouteJSON, tokens: RouteToken[]) { const collectedParams: Set = new Set() for (let token of tokens) { @@ -130,7 +129,7 @@ export class RoutesStore { /** * Register route for a given domain and method */ - #registerRoute(domain: string, method: string, tokens: MatchItRouteToken[], route: RouteJSON) { + #registerRoute(domain: string, method: string, tokens: RouteToken[], route: RouteJSON) { const methodRoutes = this.#getMethodNode(domain, method) /* @@ -217,7 +216,7 @@ export class RoutesStore { url: string, method: string, shouldDecodeParam: boolean, - domain?: { tokens: MatchItRouteToken[]; hostname: string } + domain?: { tokens: RouteToken[]; hostname: string } ): null | MatchedRoute { const domainName = domain?.tokens[0]?.old || 'root' @@ -249,7 +248,7 @@ export class RoutesStore { * @param hostname - The hostname to match * @returns Array of matched domain tokens */ - matchDomain(hostname?: string | null): MatchItRouteToken[] { + matchDomain(hostname?: string | null): RouteToken[] { if (!hostname || !this.usingDomains) { return [] } diff --git a/src/types/route.ts b/src/types/route.ts index a9e7bbfc..725f05cd 100644 --- a/src/types/route.ts +++ b/src/types/route.ts @@ -10,26 +10,19 @@ import type Middleware from '@poppinss/middleware' import type { ContainerResolver } from '@adonisjs/fold' import type { Constructor, LazyImport } from '@poppinss/utils/types' +import type { RouteMatchers, RouteToken } from '@boringnode/route-matcher' import type { ServerErrorHandler } from './server.ts' import type { HttpContext } from '../http_context/main.ts' import type { MiddlewareFn, ParsedGlobalMiddleware } from './middleware.ts' -import { type ClientRouteJSON, type ClientRouteMatchItTokens } from '../client/types.ts' +import { type ClientRouteJSON } from '../client/types.ts' -/** - * Configuration for matching and casting route parameters - */ -export type RouteMatcher = { - /** Regular expression to match parameter values */ - match?: RegExp - /** Function to cast string parameter values to specific types */ - cast?: (value: string) => any -} +export type { RouteMatcher, RouteMatchers, RouteToken } from '@boringnode/route-matcher' /** - * Route token structure used internally by the router + * @deprecated Use `RouteToken` instead. */ -export type MatchItRouteToken = RouteMatcher & ClientRouteMatchItTokens +export type MatchItRouteToken = RouteToken /** * Extracts method names from a controller class that accept HttpContext as first parameter @@ -73,14 +66,15 @@ export type StoreRouteHandler = * Middleware representation stored with route information */ export type StoreRouteMiddleware = - MiddlewareFn | ({ name?: string; args?: any[] } & ParsedGlobalMiddleware) + | MiddlewareFn + | ({ name?: string; args?: any[] } & ParsedGlobalMiddleware) /** * Route storage structure for a specific HTTP method containing tokens and route mappings */ export type StoreMethodNode = { /** Array of route tokens for pattern matching */ - tokens: MatchItRouteToken[][] + tokens: RouteToken[][] /** Mapping from route patterns to unique route keys */ routeKeys: { [pattern: string]: string @@ -104,7 +98,7 @@ export type StoreDomainNode = { */ export type StoreRoutesTree = { /** Global route tokens for pattern matching */ - tokens: MatchItRouteToken[][] + tokens: RouteToken[][] /** Domain-based route organization */ domains: { [domain: string]: StoreDomainNode @@ -134,14 +128,6 @@ export type MatchedRoute = { subdomains: Record } -/** - * Collection of parameter matchers indexed by parameter name - */ -export type RouteMatchers = { - /** Parameter name to matcher mapping */ - [param: string]: RouteMatcher -} - /** * Complete route definition with all metadata, handlers, and execution context */ @@ -175,7 +161,7 @@ export type RouteJSON = Pick matchit.parse(pattern, matchers)) - const matchedTokens = matchit.match(pathname, tokenLists) - if (!matchedTokens.length) { - return null - } - - return { - params: matchit.exec(pathname, matchedTokens, shouldDecodeParams), - pattern: matchedTokens[0].old, - } -} - -function matchWithRouteTable( - pathname: string, - definitions: RouteDefinition[], - shouldDecodeParams: boolean = false -) { - const table = new RouteTable() - for (const { pattern, matchers } of definitions) { - table.add(parseRoute(pattern, matchers), pattern) - } - - const match = table.match(pathname, shouldDecodeParams) - return match ? { params: match.params, pattern: match.value } : null -} - -test.group('Route table | matchit upstream compatibility', () => { - test('parse every pattern from the upstream suites', ({ assert }) => { - const patterns = [ - '', - '/', - '/about', - 'contact', - '/foobar', - '/:foo', - 'books/:title', - '/foo/:bar', - '/:foo.bar', - 'books/:title.jpg', - '/foo/:bar.html', - '/foo/:bar/:baz', - '/foo/bar/:baz', - '/foo/bar/:baz/:bat', - '/:foo?', - 'foo/:bar?', - '/foo/:bar?/:baz?', - '*', - '/*', - 'foo/*', - 'foo/bar/*', - ] - - for (const pattern of patterns) { - assert.deepEqual(parseRoute(pattern), matchit.parse(pattern), pattern) - } - }) - - test('match every pathname from the inherited lukeed suite', ({ assert }) => { - const definitions = [ - '/', - '/about', - 'contact', - '/books', - '/books/:title', - '/foo/*', - 'bar/:baz/:bat?', - '/videos/:title.mp4', - ].map((pattern) => ({ pattern })) - const cases = [ - { pathname: '/', expected: '/' }, - { pathname: '/about', expected: '/about' }, - { pathname: 'contact', expected: 'contact' }, - { pathname: 'about', expected: '/about' }, - { pathname: '/contact', expected: 'contact' }, - { pathname: '/books/', expected: '/books' }, - { pathname: '/books/foobar', expected: '/books/:title' }, - { pathname: '/books/foo/bar', expected: null }, - { pathname: '/hello/world', expected: null }, - { pathname: '/videos/buckbunny.mp4', expected: '/videos/:title.mp4' }, - { pathname: '/videos/buckbunny', expected: null }, - { pathname: '/bar/hello', expected: 'bar/:baz/:bat?' }, - { pathname: '/bar/hello/world', expected: 'bar/:baz/:bat?' }, - { pathname: '/books/narnia?author=lukeed', expected: '/books/:title' }, - { pathname: '/foo/bar', expected: '/foo/*' }, - { pathname: '/foo/bar/baz', expected: '/foo/*' }, - ] - - for (const { pathname, expected } of cases) { - const oracle = matchWithMatchit(pathname, definitions) - const actual = matchWithRouteTable(pathname, definitions) - assert.equal(actual?.pattern ?? null, expected, pathname) - assert.deepEqual(actual, oracle, pathname) - } - }) - - test('preserve every root and segment cardinality case', ({ assert }) => { - const cases = [ - { patterns: ['/'], pathname: '/', expected: '/' }, - { patterns: ['/:title'], pathname: '/', expected: null }, - { patterns: ['/:title'], pathname: '/narnia', expected: '/:title' }, - { patterns: ['/:title?'], pathname: '/', expected: '/:title?' }, - { patterns: ['*'], pathname: '/', expected: '*' }, - { patterns: ['/x', '*'], pathname: '/', expected: '*' }, - { patterns: ['*', '/x'], pathname: '/', expected: '*' }, - { patterns: ['/books/:title'], pathname: '/books', expected: null }, - { patterns: ['/books'], pathname: '/books/123', expected: null }, - ] - - for (const { patterns, pathname, expected } of cases) { - const definitions = patterns.map((pattern) => ({ pattern })) - const oracle = matchWithMatchit(pathname, definitions) - const actual = matchWithRouteTable(pathname, definitions) - assert.equal(actual?.pattern ?? null, expected, JSON.stringify({ patterns, pathname })) - assert.deepEqual(actual, oracle, JSON.stringify({ patterns, pathname })) - } - }) - - test('extract params for every inherited exec case', ({ assert }) => { - const cases = [ - { pattern: '/', pathname: '/', expected: {} }, - { pattern: '/:type?', pathname: '/', expected: {} }, - { pattern: '/:type?', pathname: '/news', expected: { type: 'news' } }, - { pattern: '/about', pathname: '/about', expected: {} }, - { pattern: 'contact', pathname: '/contact', expected: {} }, - { pattern: '/books/:title', pathname: '/books/foo', expected: { title: 'foo' } }, - { - pattern: '/videos/:title.mp4', - pathname: '/videos/foo.mp4', - expected: { title: 'foo' }, - }, - { - pattern: '/foo/:bar/:baz', - pathname: '/foo/hello/world', - expected: { bar: 'hello', baz: 'world' }, - }, - { - pattern: 'bar/:baz/:bat?', - pathname: '/bar/hello', - expected: { baz: 'hello' }, - }, - { - pattern: 'bar/:baz/:bat?', - pathname: '/bar/hello/world', - expected: { baz: 'hello', bat: 'world' }, - }, - { - pattern: '/books/:title', - pathname: '/books/foo?author=lukeed', - expected: { title: 'foo?author=lukeed' }, - }, - { pattern: '/', pathname: 'foo', expected: {} }, - ] - - for (const { pattern, pathname, expected } of cases) { - const oracleTokens = matchit.parse(pattern) - const actual = extractRouteParams(parseRoute(pattern), pathname, false) - assert.deepEqual(actual, expected, JSON.stringify({ pattern, pathname })) - assert.deepEqual(actual, matchit.exec(pathname, oracleTokens), pattern) - } - }) - - test('preserve every poppinss matcher case', ({ assert }) => { - const alphaMatcher = { bar: { match: /[a-z]+/ } } - const optionalNumberMatcher = { bar: { match: /^[0-9]+$/ } } - const cases: { - definitions: RouteDefinition[] - expected: string | null - pathname: string - }[] = [ - { - definitions: [{ pattern: '/foo/:bar', matchers: alphaMatcher }], - pathname: '/foo/1', - expected: null, - }, - { - definitions: [{ pattern: '/foo/:bar', matchers: alphaMatcher }], - pathname: '/foo/bar', - expected: '/foo/:bar', - }, - { - definitions: [{ pattern: '/foo/:bar', matchers: alphaMatcher }], - pathname: '/foo/', - expected: null, - }, - { - definitions: [{ pattern: '/foo/:bar', matchers: alphaMatcher }], - pathname: '/foo', - expected: null, - }, - { - definitions: [{ pattern: '/foo/:bar?', matchers: optionalNumberMatcher }], - pathname: '/foo', - expected: '/foo/:bar?', - }, - { - definitions: [{ pattern: '/foo/:bar?', matchers: optionalNumberMatcher }], - pathname: '/foo/1', - expected: '/foo/:bar?', - }, - { - definitions: [{ pattern: '/foo/:bar?', matchers: optionalNumberMatcher }], - pathname: '/foo/', - expected: '/foo/:bar?', - }, - { - definitions: [ - { pattern: '/foo/:bar?', matchers: alphaMatcher }, - { pattern: '/foo/:id?', matchers: optionalNumberMatcher }, - ], - pathname: '/foo/1', - expected: '/foo/:id?', - }, - { - definitions: [{ pattern: '/foo/:bar/baz', matchers: alphaMatcher }], - pathname: '/foo/bar/baz', - expected: '/foo/:bar/baz', - }, - { - definitions: [{ pattern: '/foo/:bar/baz', matchers: alphaMatcher }], - pathname: '/foo//baz', - expected: null, - }, - { - definitions: [{ pattern: '/foo/:bar/baz' }], - pathname: '/foo/bar/baz', - expected: '/foo/:bar/baz', - }, - { - definitions: [{ pattern: '/foo/:bar/baz' }], - pathname: '/foo//baz', - expected: '/foo/:bar/baz', - }, - ] - - for (const { definitions, pathname, expected } of cases) { - const oracle = matchWithMatchit(pathname, definitions) - const actual = matchWithRouteTable(pathname, definitions) - assert.equal(actual?.pattern ?? null, expected, JSON.stringify({ definitions, pathname })) - assert.deepEqual(actual, oracle, JSON.stringify({ definitions, pathname })) - } - }) - - test('preserve poppinss wildcard, cast, and decoding extensions', ({ assert }) => { - const cases: { - decode?: boolean - expected: Record - matchers?: RouteMatchers - pathname: string - pattern: string - }[] = [ - { - pattern: '/foo/*', - pathname: '/foo/bar/baz', - expected: { '*': ['bar', 'baz'] }, - }, - { - pattern: '/foo/:bar', - pathname: '/foo/1', - matchers: { bar: { match: /^[0-9]+$/, cast: Number } }, - expected: { bar: 1 }, - }, - { - pattern: '/foo/:bar?', - pathname: '/foo', - matchers: { bar: { match: /^[0-9]+$/, cast: Number } }, - expected: {}, - }, - { - pattern: '/foo/:bar/:baz', - pathname: '/foo/1/hello', - matchers: { - bar: { match: /^[0-9]+$/, cast: Number }, - baz: { cast: (value) => value.toUpperCase() }, - }, - expected: { bar: 1, baz: 'HELLO' }, - }, - { - pattern: '/foo/:bar', - pathname: '/foo/fran%C3%A7ais', - decode: true, - expected: { bar: 'français' }, - }, - { - pattern: '/foo/:bar?', - pathname: '/foo/fran%C3%A7ais', - decode: true, - expected: { bar: 'français' }, - }, - { - pattern: '/foo/*', - pathname: '/foo/fran%C3%A7ais', - decode: true, - expected: { '*': ['français'] }, - }, - ] - - for (const { pattern, pathname, matchers, decode = false, expected } of cases) { - const definitions = [{ pattern, matchers }] - const oracle = matchWithMatchit(pathname, definitions, decode) - const actual = matchWithRouteTable(pathname, definitions, decode) - assert.deepEqual(actual?.params, expected, JSON.stringify({ pattern, pathname })) - assert.deepEqual(actual, oracle, JSON.stringify({ pattern, pathname })) - } - }) -}) diff --git a/tests/router/route_parser.spec.ts b/tests/router/route_parser.spec.ts index c86b67bd..d8087c00 100644 --- a/tests/router/route_parser.spec.ts +++ b/tests/router/route_parser.spec.ts @@ -7,35 +7,10 @@ * file that was distributed with this source code. */ -// @ts-expect-error -import matchit from '@poppinss/matchit' import { test } from '@japa/runner' import { parseRoute } from '../../src/helpers.ts' test.group('Route parser', () => { - test('ignore non-object matcher collections like matchit', ({ assert }) => { - assert.deepEqual(parseRoute('/:0', 'x' as never), matchit.parse('/:0', 'x')) - }) - - test('parse the same tokens as matchit across generated pattern strings', ({ assert }) => { - const alphabet = ['/', ':', '*', '?', '.', 'a', 'Z', '0', '-', '_', 'é', '😀'] - let seed = 73 - function random() { - seed = (seed * 1_664_525 + 1_013_904_223) >>> 0 - return seed / 2 ** 32 - } - - for (let iteration = 0; iteration < 10_000; iteration++) { - const length = Math.floor(random() * 30) - let pattern = '' - for (let index = 0; index < length; index++) { - pattern += alphabet[Math.floor(random() * alphabet.length)] - } - - assert.deepEqual(parseRoute(pattern), matchit.parse(pattern), pattern) - } - }) - test('parse route with params', ({ assert }) => { const tokens = parseRoute('/posts/:id') assert.deepEqual(tokens, [ @@ -54,8 +29,6 @@ test.group('Route parser', () => { val: 'id', }, ]) - - assert.deepEqual(matchit.exec('/posts/10', tokens), { id: '10' }) }) test('parse route params with extensions', ({ assert }) => { @@ -76,8 +49,6 @@ test.group('Route parser', () => { val: 'id', }, ]) - - assert.deepEqual(matchit.exec('/posts/10.json', tokens), { id: '10' }) }) test('do not allow extensions with optional params', ({ assert }) => { @@ -98,9 +69,6 @@ test.group('Route parser', () => { val: 'id?', // This is invalid }, ]) - - assert.deepEqual(matchit.exec('/posts/10.json', tokens), { 'id?': '10' }) - assert.deepEqual(matchit.exec('/posts', tokens), {}) }) test('parse route params wildcard', ({ assert }) => { @@ -119,7 +87,5 @@ test.group('Route parser', () => { val: '*', }, ]) - - assert.deepEqual(matchit.exec('/posts/10/hello-world', tokens), { '*': ['10', 'hello-world'] }) }) }) diff --git a/tests/router/route_table.spec.ts b/tests/router/route_table.spec.ts deleted file mode 100644 index 50b405cc..00000000 --- a/tests/router/route_table.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* - * @adonisjs/http-server - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' - -import { parseRoute } from '../../src/helpers.ts' -import { RouteTable } from '../../src/router/route_table.ts' - -test.group('Route table', () => { - test('return the first registered matching route regardless of its shape', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const dynamicRoute = { pattern: '/:value' } - const staticRoute = { pattern: '/users' } - - table.add(parseRoute(dynamicRoute.pattern), dynamicRoute) - table.add(parseRoute(staticRoute.pattern), staticRoute) - - const match = table.match('/users', false) - assert.strictEqual(match?.value, dynamicRoute) - assert.deepEqual(match, { - value: dynamicRoute, - params: { value: 'users' }, - }) - }) - - test('match and decode wildcard parameters', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const route = { pattern: '/files/*' } - - table.add(parseRoute(route.pattern), route) - - assert.deepEqual(table.match('/files/folder%20one/file%20two', true), { - value: route, - params: { '*': ['folder one', 'file two'] }, - }) - }) - - test('match optional parameters with and without a value', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const route = { pattern: '/archive/:year?' } - - table.add(parseRoute(route.pattern), route) - - assert.deepEqual(table.match('/archive', false), { value: route, params: {} }) - assert.deepEqual(table.match('/archive/2026', false), { - value: route, - params: { year: '2026' }, - }) - - const rootTable = new RouteTable<{ pattern: string }>() - const rootRoute = { pattern: '/:value?' } - rootTable.add(parseRoute(rootRoute.pattern), rootRoute) - assert.deepEqual(rootTable.match('/', false), { value: rootRoute, params: {} }) - }) - - test('preserve stateful matcher evaluation order', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const matcher = /^[a-z]+$/g - const dynamicRoute = { pattern: '/:value/foo' } - const staticRoute = { pattern: '/users/bar' } - - table.add(parseRoute(dynamicRoute.pattern, { value: { match: matcher } }), dynamicRoute) - table.add(parseRoute(staticRoute.pattern), staticRoute) - - assert.deepEqual(table.match('/users/bar', false), { value: staticRoute, params: {} }) - assert.isNull(table.match('/abc/foo', false)) - }) - - test('preserve missing parameter semantics before a trailing optional', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const route = { pattern: '/archive/:year/:month?' } - - table.add(parseRoute(route.pattern), route) - - assert.deepEqual(table.match('/archive', false), { value: route, params: {} }) - }) - - test('preserve parameter extraction for non-canonical wildcard patterns', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const route = { pattern: '/*/:id/*' } - - table.add(parseRoute(route.pattern), route) - - assert.deepEqual(table.match('////a////', false), { - value: route, - params: { - '*/:id/*': '', - 'id': '', - '*': ['a', '', '', ''], - }, - }) - }) - - test('preserve empty path matches with a wildcard before a trailing optional', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const route = { pattern: '/:value/*:optional?' } - - table.add(parseRoute(route.pattern), route) - - assert.deepEqual(table.match('', false), { - value: route, - params: { value: '' }, - }) - }) - - test('preserve custom regular expression evaluation order', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const matcher = /^[a-z]+$/ - let matcherCalls = 0 - matcher.exec = function exec(value: string) { - matcherCalls++ - return RegExp.prototype.exec.call(this, value) - } - - const dynamicRoute = { pattern: '/:value/foo' } - const staticRoute = { pattern: '/users/bar' } - table.add(parseRoute(dynamicRoute.pattern, { value: { match: matcher } }), dynamicRoute) - table.add(parseRoute(staticRoute.pattern), staticRoute) - - assert.deepEqual(table.match('/users/bar', false), { value: staticRoute, params: {} }) - assert.equal(matcherCalls, 1) - }) - - test('evaluate every matcher on a structurally matched route', ({ assert }) => { - const table = new RouteTable<{ pattern: string }>() - const constrainedRoute = { pattern: '/:section/:id?' } - const fallbackRoute = { pattern: '/:type/:value' } - - table.add( - parseRoute(constrainedRoute.pattern, { - section: { match: /^users$/ }, - id: { match: /^\d+$/ }, - }), - constrainedRoute - ) - table.add(parseRoute(fallbackRoute.pattern), fallbackRoute) - - assert.deepEqual(table.match('/users/42', false), { - value: constrainedRoute, - params: { section: 'users', id: '42' }, - }) - assert.deepEqual(table.match('/users', false), { - value: constrainedRoute, - params: { section: 'users' }, - }) - assert.deepEqual(table.match('/users/not-a-number', false), { - value: fallbackRoute, - params: { type: 'users', value: 'not-a-number' }, - }) - }) -}) diff --git a/tests/router/store.spec.ts b/tests/router/store.spec.ts index 5a8ffd9f..42b1fb88 100644 --- a/tests/router/store.spec.ts +++ b/tests/router/store.spec.ts @@ -9,10 +9,8 @@ import { test } from '@japa/runner' import Middleware from '@poppinss/middleware' -// @ts-expect-error -import matchit from '@poppinss/matchit' -import type { MatchedRoute, RouteJSON } from '../../src/types/route.ts' +import type { RouteJSON } from '../../src/types/route.ts' import { parseRoute } from '../../src/helpers.ts' import { execute } from '../../src/router/executor.ts' import { RoutesStore } from '../../src/router/store.ts' @@ -617,7 +615,7 @@ test.group('Store | match', () => { } }) - test('preserve matchit separator semantics for static routes', ({ assert }) => { + test('preserve separator semantics for static routes', ({ assert }) => { const cases = [ { patterns: ['/users', 'users/'], @@ -718,117 +716,6 @@ test.group('Store | match', () => { assert.equal(store.match('/users', 'GET', false)?.route.pattern, '/users') }) - test('match the same route as matchit across generated route orders and path spellings', ({ - assert, - }) => { - const routeDefinitions = [ - { pattern: '/' }, - { pattern: '' }, - { pattern: '//' }, - { pattern: '///' }, - { pattern: '////' }, - { pattern: '/users' }, - { pattern: 'users/' }, - { pattern: '/users//' }, - { pattern: '/users///' }, - { pattern: '//users' }, - { pattern: '/teams//users' }, - { pattern: '/:value' }, - { pattern: '/:value?' }, - { pattern: '/*' }, - { pattern: '/teams/:id', matchers: { id: { match: /^\d+$/, cast: Number } } }, - { pattern: '/teams/:id?' }, - { pattern: '/teams/*' }, - { pattern: '/posts/:slug?.json' }, - ] - const pathnames = [ - '', - '/', - '//', - '///', - '////', - 'users', - '/users', - '/users/', - '/users//', - '//users', - '/teams/users', - '/teams//users', - '/teams', - '/teams/42', - '/teams/Romain%20Lanz', - '/teams/42/members', - '/posts', - '/posts/article.json', - '/missing', - ] - - let seed = 42 - function random() { - seed = (seed * 1_664_525 + 1_013_904_223) >>> 0 - return seed / 2 ** 32 - } - - function captureMatch(callback: () => null | MatchedRoute) { - try { - const match = callback() - return match - ? { - status: 'matched', - routePattern: match.route.pattern, - routeKey: match.routeKey, - params: match.params, - } - : { status: 'missing' } - } catch (error) { - return { - status: 'threw', - errorName: (error as Error).constructor.name, - errorMessage: (error as Error).message, - } - } - } - - for (let iteration = 0; iteration < 1_000; iteration++) { - const shuffled = routeDefinitions.slice() - for (let index = shuffled.length - 1; index > 0; index--) { - const swapIndex = Math.floor(random() * (index + 1)) - ;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]] - } - - const definitions = shuffled.slice(0, 1 + Math.floor(random() * 12)) - const tokenLists = definitions.map(({ pattern, matchers }) => parseRoute(pattern, matchers)) - const store = new RoutesStore() - definitions.forEach(({ pattern, matchers = {} }, index) => { - addRoute(store, pattern, { tokens: tokenLists[index], matchers }) - }) - - for (const [pathnameIndex, pathname] of pathnames.entries()) { - const shouldDecodeParam = pathnameIndex % 2 === 0 - const expected = captureMatch(() => { - const matchedTokens = matchit.match(pathname, tokenLists) - if (!matchedTokens.length) { - return null - } - - const pattern = matchedTokens[0].old - return { - route: { pattern }, - routeKey: `GET-${pattern}`, - params: matchit.exec(pathname, matchedTokens, shouldDecodeParam), - } as MatchedRoute - }) - const actual = captureMatch(() => store.match(pathname, 'GET', shouldDecodeParam)) - - assert.deepEqual( - actual, - expected, - JSON.stringify({ definitions: definitions.map(({ pattern }) => pattern), pathname }) - ) - } - } - }) - test('find route for a given url', ({ assert }) => { async function handler() {} From dac70e1618b17ff939b6708242ce9cf02fc51573 Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Wed, 2 Sep 2026 21:36:14 +0000 Subject: [PATCH 7/7] style(router): format with latest prettier --- src/client/url_builder.ts | 3 +-- src/router/route.ts | 8 ++------ src/types/route.ts | 11 ++--------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/client/url_builder.ts b/src/client/url_builder.ts index 8fa2e54d..5224bc89 100644 --- a/src/client/url_builder.ts +++ b/src/client/url_builder.ts @@ -21,8 +21,7 @@ export { createURL, findRoute } */ export function createUrlBuilder( routesLoader: - | { [domain: string]: ClientRouteJSON[] } - | (() => { [domain: string]: ClientRouteJSON[] }), + { [domain: string]: ClientRouteJSON[] } | (() => { [domain: string]: ClientRouteJSON[] }), searchParamsStringifier: (qs: Record) => string, defaultOptions?: URLOptions ): UrlFor { diff --git a/src/router/route.ts b/src/router/route.ts index 5c108be4..d993cb7d 100644 --- a/src/router/route.ts +++ b/src/router/route.ts @@ -144,9 +144,7 @@ export class Route = any> extends Macroable pattern: string methods: string[] handler: - | RouteFn - | string - | [LazyImport | Controller, GetControllerHandlers?] + RouteFn | string | [LazyImport | Controller, GetControllerHandlers?] globalMatchers: RouteMatchers } ) { @@ -174,9 +172,7 @@ export class Route = any> extends Macroable */ #resolveRouteHandle( handler: - | RouteFn - | string - | [LazyImport | Controller, GetControllerHandlers?] + RouteFn | string | [LazyImport | Controller, GetControllerHandlers?] ) { /** * Convert magic string to handle method call diff --git a/src/types/route.ts b/src/types/route.ts index 725f05cd..a630a411 100644 --- a/src/types/route.ts +++ b/src/types/route.ts @@ -66,8 +66,7 @@ export type StoreRouteHandler = * Middleware representation stored with route information */ export type StoreRouteMiddleware = - | MiddlewareFn - | ({ name?: string; args?: any[] } & ParsedGlobalMiddleware) + MiddlewareFn | ({ name?: string; args?: any[] } & ParsedGlobalMiddleware) /** * Route storage structure for a specific HTTP method containing tokens and route mappings @@ -173,13 +172,7 @@ export type RouteJSON = Pick