diff --git a/package-lock.json b/package-lock.json index b354009..916af69 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 228e0fc..c219bbe 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 bdc3dab..e368c27 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 0000000..63f85fc --- /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 0000000..511d9b3 --- /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 75af0d1..8866da3 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,6 +19,7 @@ import type { } 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 @@ -44,6 +43,13 @@ import { parseRoute } from '../helpers.ts' * ``` */ export class RoutesStore { + /** + * Lookup indexes are kept outside the public routes tree to avoid changing + * its observable shape. + */ + #methodRouteTables = new WeakMap>() + #domainRouteTable = new RouteTable() + /** * A flag to know if routes for explicit domains * have been registered @@ -51,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: {} } @@ -60,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] = {} } @@ -74,11 +82,29 @@ 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] } + /** + * 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 ? extractRouteParams(domain.tokens, domain.hostname, false) : {}, + } + } + /** * Collects route params */ @@ -121,6 +147,8 @@ export class RoutesStore { debug('route middleware %O', route.middleware.all().entries()) } + this.#methodRouteTables.get(methodRoutes)!.add(tokens, route) + methodRoutes.tokens.push(tokens) methodRoutes.routes[route.pattern] = route methodRoutes.routeKeys[route.pattern] = @@ -208,22 +236,12 @@ export class RoutesStore { return null } - /* - * 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 { - 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(matchedRoute.value, matchedMethod, matchedRoute.params, domain) } /** @@ -236,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 976b5d5..a9e7bbf 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 ec6606d..c86b67b 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 0000000..618e267 --- /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/router.spec.ts b/tests/router/router.spec.ts index 3f577d8..3493eee 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 9ddc222..d7ae299 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 < 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() {}