diff --git a/package-lock.json b/package-lock.json index b354009..7a1f16e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "9.2.0", "license": "MIT", "dependencies": { + "@boringnode/route-matcher": "^0.1.1", "@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", @@ -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,13 +265,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@arr/every": { - "version": "1.0.1", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@assemblyscript/loader": { "version": "0.19.23", "dev": true, @@ -389,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, @@ -459,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" } @@ -1157,7 +1183,6 @@ "version": "4.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@poppinss/macroable": "^1.1.0", "@types/chai": "^5.2.3", @@ -1239,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", @@ -1449,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", @@ -1696,13 +1719,6 @@ "integrity": "sha512-FAVBRzzWhYP5mA3lCwLH1A0fKBqq5anyjGet90Z81aRK5c/+LTGUE1zJhZrErjaenBSOOI9BVUs3WVmotneFQA==", "license": "MIT" }, - "node_modules/@poppinss/matchit": { - "version": "3.2.0", - "license": "MIT", - "dependencies": { - "@arr/every": "^1.0.0" - } - }, "node_modules/@poppinss/middleware": { "version": "3.2.7", "license": "MIT" @@ -2027,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", @@ -2809,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", @@ -3084,7 +3133,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3436,7 +3484,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4682,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", @@ -4739,7 +4785,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6265,7 +6310,6 @@ "version": "2.6.1", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -7367,7 +7411,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7583,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", @@ -7812,7 +7854,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@inquirer/prompts": "8.4.2", "@octokit/rest": "22.0.1", @@ -7939,7 +7980,6 @@ "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" @@ -8836,7 +8876,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8953,6 +8992,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "rolldown": "1.0.0-rc.17" }, @@ -8974,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", @@ -8981,6 +9058,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -8998,6 +9076,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9015,6 +9094,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9032,6 +9112,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9049,6 +9130,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9066,6 +9148,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9083,6 +9166,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9100,6 +9184,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9117,6 +9202,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9134,6 +9220,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9151,6 +9238,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9168,6 +9256,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9185,6 +9274,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9199,6 +9289,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", @@ -9221,6 +9312,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9238,6 +9330,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9248,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", @@ -9257,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 228e0fc..67e7715 100644 --- a/package.json +++ b/package.json @@ -95,8 +95,8 @@ "youch": "^4.1.1" }, "dependencies": { + "@boringnode/route-matcher": "^0.1.1", "@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/client/helpers.ts b/src/client/helpers.ts index 8471569..f02b28f 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 45901fe..35daf8f 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/helpers.ts b/src/helpers.ts index bdc3dab..d32fc21 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -8,11 +8,15 @@ */ 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' +import { + parseRoute as parseRoutePattern, + matchRouteTokens, + type RouteToken, + type RouteMatchers, +} from '@boringnode/route-matcher' import { type Qs } from './qs.ts' import { safeDecodeURI } from './utils.ts' @@ -20,7 +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 type { RouteJSON } from './types/route.ts' import { type MiddlewareFn, type RouteHandlerInfo, @@ -156,11 +160,10 @@ 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[] { - const tokens = matchit.parse(pattern, matchers) - return tokens +export function parseRoute(pattern: string, matchers?: RouteMatchers): RouteToken[] { + return parseRoutePattern(pattern, matchers) } /** @@ -177,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 }, @@ -215,13 +218,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/store.ts b/src/router/store.ts index 75af0d1..62d708a 100644 --- a/src/router/store.ts +++ b/src/router/store.ts @@ -7,9 +7,8 @@ * file that was distributed with this source code. */ -// @ts-expect-error -import matchit from '@poppinss/matchit' import { RuntimeException } from '@poppinss/utils/exception' +import { RouteTable, extractRouteParams, type RouteToken } from '@boringnode/route-matcher' import type { RouteJSON, @@ -17,7 +16,6 @@ import type { StoreDomainNode, StoreMethodNode, StoreRoutesTree, - MatchItRouteToken, } from '../types/route.ts' import debug from '../debug.ts' import { parseRoute } from '../helpers.ts' @@ -44,6 +42,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 +56,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 +65,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,15 +81,33 @@ 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: RouteToken[]; hostname: string } + ): MatchedRoute { + return { + route, + routeKey: methodNode.routeKeys[route.pattern], + params, + subdomains: domain?.hostname ? extractRouteParams(domain.tokens, domain.hostname, false) : {}, + } + } + /** * Collects route params */ - #collectRouteParams(route: RouteJSON, tokens: MatchItRouteToken[]) { + #collectRouteParams(route: RouteJSON, tokens: RouteToken[]) { const collectedParams: Set = new Set() for (let token of tokens) { @@ -104,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) /* @@ -121,6 +146,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] = @@ -189,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' @@ -208,22 +235,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) } /** @@ -231,11 +248,11 @@ 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 [] } - 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..a630a41 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 matchit routing library + * @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 @@ -80,7 +73,7 @@ export type StoreRouteMiddleware = */ 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 +97,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 +127,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 +160,7 @@ export type RouteJSON = Pick { val: 'id', }, ]) - - assert.deepEqual(matchit.exec('/posts/10', tokens), { id: '10' }) }) test('parse route params with extensions', ({ assert }) => { @@ -53,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 }) => { @@ -75,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 }) => { @@ -96,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/router.spec.ts b/tests/router/router.spec.ts index 3f577d8..e8473f5 100644 --- a/tests/router/router.spec.ts +++ b/tests/router/router.spec.ts @@ -558,6 +558,58 @@ 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() {} + + 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..42b1fb8 100644 --- a/tests/router/store.spec.ts +++ b/tests/router/store.spec.ts @@ -10,10 +10,30 @@ import { test } from '@japa/runner' import Middleware from '@poppinss/middleware' +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' +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 +576,146 @@ 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 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('find route for a given url', ({ assert }) => { async function handler() {}