diff --git a/README.md b/README.md index 2c9941f..8aa9bcd 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ import { addRoute, findRoute, removeRoute, - matchAllRoutes, + findAllRoutes, } from "rou3"; ``` @@ -63,7 +63,7 @@ const { addRoute, findRoute, removeRoute, - matchAllRoutes, + findAllRoutes, } = require("rou3"); ``` @@ -75,7 +75,7 @@ import { addRoute, findRoute, removeRoute, - matchAllRoutes, + findAllRoutes, } from "https://esm.sh/rou3"; ``` diff --git a/package.json b/package.json index c06b22f..36cad15 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,10 @@ "dist" ], "scripts": { + "bench:bun": "bun ./test/bench", + "bench:node": "node --import jiti/register ./test/bench", "build": "unbuild", "dev": "vitest", - "bench:node": "node --import jiti/register ./test/bench", - "bench:bun": "bun ./test/bench", "lint": "eslint . && prettier -c src test", "lint:fix": "automd && eslint --fix . && prettier -w src test", "release": "pnpm test && pnpm build && changelogen --release && git push --follow-tags && npm publish", diff --git a/src/index.ts b/src/index.ts index 0aee599..8637d45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,4 @@ export type { RouterContext } from "./types"; export { addRoute } from "./operations/add"; export { findRoute } from "./operations/find"; export { removeRoute } from "./operations/remove"; -export { matchAllRoutes } from "./operations/match"; +export { findAllRoutes } from "./operations/find-all"; diff --git a/src/operations/_utils.ts b/src/operations/_utils.ts index 0710d37..c79ecd0 100644 --- a/src/operations/_utils.ts +++ b/src/operations/_utils.ts @@ -1,3 +1,27 @@ +import type { MatchedRoute, ParamsIndexMap } from "../types"; + export function splitPath(path: string) { return path.split("/").filter(Boolean); } + +export function getMatchParams( + segments: string[], + paramsMap: ParamsIndexMap, +): MatchedRoute["params"] { + const params = Object.create(null); + for (const [index, name] of paramsMap) { + const segment = + index < 0 ? segments.slice(-1 * index).join("/") : segments[index]; + if (typeof name === "string") { + params[name] = segment; + } else { + const match = segment.match(name); + if (match) { + for (const key in match.groups) { + params[key] = match.groups[key]; + } + } + } + } + return params; +} diff --git a/src/operations/add.ts b/src/operations/add.ts index fff40de..9d082ad 100644 --- a/src/operations/add.ts +++ b/src/operations/add.ts @@ -1,4 +1,4 @@ -import type { RouterContext, Params } from "../types"; +import type { RouterContext, ParamsIndexMap } from "../types"; import { splitPath } from "./_utils"; /** @@ -16,7 +16,7 @@ export function addRoute( let _unnamedParamIndex = 0; - const params: Params = []; + const paramsMap: ParamsIndexMap = []; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; @@ -27,7 +27,7 @@ export function addRoute( node.wildcard = { key: "**" }; } node = node.wildcard; - params.push([-i, segment.split(":")[1] || "_"]); + paramsMap.push([-i, segment.split(":")[1] || "_"]); break; } @@ -37,7 +37,7 @@ export function addRoute( node.param = { key: "*" }; } node = node.param; - params.push([ + paramsMap.push([ i, segment === "*" ? `_${_unnamedParamIndex++}` @@ -61,11 +61,14 @@ export function addRoute( } // Assign index, params and data to the node - const hasParams = params.length > 0; + const hasParams = paramsMap.length > 0; if (!node.methods) { node.methods = Object.create(null); } - node.methods![method] = [data || (null as T), hasParams ? params : undefined]; + node.methods![method] = { + data: data || (null as T), + paramsMap: hasParams ? paramsMap : undefined, + }; node.index = segments.length - 1; // Static diff --git a/src/operations/find-all.ts b/src/operations/find-all.ts new file mode 100644 index 0000000..246a7f8 --- /dev/null +++ b/src/operations/find-all.ts @@ -0,0 +1,75 @@ +import type { RouterContext, Node, MatchedRoute, MethodData } from "../types"; +import { getMatchParams, splitPath } from "./_utils"; + +/** + * Find all route patterns that match the given path. + */ +export function findAllRoutes( + ctx: RouterContext, + method: string = "", + path: string, + opts?: { params?: boolean }, +): MatchedRoute[] { + if (path[path.length - 1] === "/") { + path = path.slice(0, -1); + } + const segments = splitPath(path); + const _matches = _findAll(ctx, ctx.root, method, segments, 0); + const matches: MatchedRoute[] = []; + for (const match of _matches) { + matches.push({ + data: match.data, + params: + opts?.params && match.paramsMap + ? getMatchParams(segments, match.paramsMap) + : undefined, + }); + } + return matches; +} + +function _findAll( + ctx: RouterContext, + node: Node, + method: string, + segments: string[], + index: number, + matches: MethodData[] = [], +): MethodData[] { + const segment = segments[index]; + + // 1. Wildcard + if (node.wildcard && node.wildcard.methods) { + const match = node.wildcard.methods[method] || node.wildcard.methods[""]; + if (match) { + matches.push(match); + } + } + + // 2. Param + if (node.param) { + _findAll(ctx, node.param, method, segments, index + 1, matches); + if (index === segments.length && node.param.methods) { + const match = node.param.methods[method] || node.param.methods[""]; + if (match) { + matches.push(match); + } + } + } + + // 3. Static + const staticChild = node.static?.[segment]; + if (staticChild) { + _findAll(ctx, staticChild, method, segments, index + 1, matches); + } + + // 4. End of path + if (index === segments.length && node.methods) { + const match = node.methods[method] || node.methods[""]; + if (match) { + matches.push(match); + } + } + + return matches; +} diff --git a/src/operations/find.ts b/src/operations/find.ts index e396c3c..05566a2 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -1,5 +1,5 @@ -import type { RouterContext, MatchedRoute, Node, Params } from "../types"; -import { splitPath } from "./_utils"; +import type { RouterContext, MatchedRoute, Node, MethodData } from "../types"; +import { getMatchParams, splitPath } from "./_utils"; /** * Find a route by path. @@ -8,7 +8,7 @@ export function findRoute( ctx: RouterContext, method: string = "", path: string, - opts?: { ignoreParams?: boolean }, + opts?: { params?: boolean }, ): MatchedRoute | undefined { if (path[path.length - 1] === "/") { path = path.slice(0, -1); @@ -19,38 +19,39 @@ export function findRoute( if (staticNode && staticNode.methods) { const staticMatch = staticNode.methods[method] || staticNode.methods[""]; if (staticMatch !== undefined) { - return { data: staticMatch[0] }; + return staticMatch; } } // Lookup tree const segments = splitPath(path); - const match = _lookupTree(ctx, ctx.root, method, segments, 0); + + const match = _lookupTree(ctx, ctx.root, method, segments, 0); if (match === undefined) { return; } - const [data, paramNames] = match; - if (opts?.ignoreParams || !paramNames) { - return { data }; + if (opts?.params || !match.paramsMap) { + return { + data: match.data, + params: undefined, + }; } - const params = _getParams(segments, paramNames); - return { - data, - params, + data: match.data, + params: getMatchParams(segments, match.paramsMap), }; } function _lookupTree( - ctx: RouterContext, + ctx: RouterContext, node: Node, method: string, segments: string[], index: number, -): [Data: T, Params?: Params] | undefined { - // End of path +): MethodData | undefined { + // 0. End of path if (index === segments.length) { if (node.methods) { const match = node.methods[method] || node.methods[""]; @@ -100,25 +101,3 @@ function _lookupTree( // No match return; } - -function _getParams( - segments: string[], - paramsNames: Params, -): MatchedRoute["params"] { - const params = Object.create(null); - for (const [index, name] of paramsNames) { - const segment = - index < 0 ? segments.slice(-1 * index).join("/") : segments[index]; - if (typeof name === "string") { - params[name] = segment; - } else { - const match = segment.match(name); - if (match) { - for (const key in match.groups) { - params[key] = match.groups[key]; - } - } - } - } - return params; -} diff --git a/src/operations/match.ts b/src/operations/match.ts deleted file mode 100644 index 1d5fd94..0000000 --- a/src/operations/match.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { RouterContext, Node } from "../types"; -import { splitPath } from "./_utils"; - -/** - * Find all route patterns that match the given path. - */ -export function matchAllRoutes( - ctx: RouterContext, - method: string = "", - path: string, -): T[] { - return _matchAll(ctx, ctx.root, method, splitPath(path), 0) as T[]; -} - -function _matchAll( - ctx: RouterContext, - node: Node, - method: string, - segments: string[], - index: number, - matches: T[] = [], -): T[] { - const segment = segments[index]; - - // Wildcard - if (node.wildcard && node.wildcard.methods) { - const match = node.wildcard.methods[method] || node.wildcard.methods[""]; - if (match) { - matches.push(match[0 /* data */]); - } - } - - // Param - if (node.param) { - _matchAll(ctx, node.param, method, segments, index + 1, matches); - if (index === segments.length && node.param.methods) { - const match = node.param.methods[method] || node.param.methods[""]; - if (match) { - matches.push(match[0 /* data */]); - } - } - } - - // Node self data (only if we reached the end of the path) - if (index === segments.length && node.methods) { - const match = node.methods[method] || node.methods[""]; - if (match) { - matches.push(match[0 /* data */]); - } - } - - // Static - const staticChild = node.static?.[segment]; - if (staticChild) { - _matchAll(ctx, staticChild, method, segments, index + 1, matches); - } - - return matches; -} diff --git a/src/types.ts b/src/types.ts index 06c9923..61cacf8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,8 @@ export interface RouterContext { static: Record | undefined>; } -export type Params = Array<[Index: number, name: string | RegExp]>; +export type ParamsIndexMap = Array<[Index: number, name: string | RegExp]>; +export type MethodData = { data: T; paramsMap?: ParamsIndexMap }; export interface Node { key: string; @@ -13,10 +14,10 @@ export interface Node { wildcard?: Node; index?: number; - methods?: Record; + methods?: Record | undefined>; } export type MatchedRoute = { - data?: T | undefined; + data: T; params?: Record; }; diff --git a/test/_utils.ts b/test/_utils.ts index d177430..a9c009b 100644 --- a/test/_utils.ts +++ b/test/_utils.ts @@ -18,7 +18,7 @@ export function createRouter< } export function formatTree( - node: Node, + node: Node<{ path?: string }>, depth = 0, result = [] as string[], prefix = "", @@ -32,7 +32,7 @@ export function formatTree( ...Object.values(node.static || []), node.param, node.wildcard, - ].filter(Boolean) as Node[]; + ].filter(Boolean) as Node<{ path?: string }>[]; for (const [index, child] of childrenArray.entries()) { const lastChild = index === childrenArray.length - 1; formatTree( @@ -47,14 +47,13 @@ export function formatTree( return depth === 0 ? result.join("\n") : result; } -function _formatMethods(node: Node) { +function _formatMethods(node: Node<{ path?: string }>) { if (!node.methods) { return ""; } return ` ┈> ${Object.entries(node.methods) - // @ts-expect-error - .map(([method, [data, _params]]) => { - const val = (data as any)?.path || JSON.stringify(data); + .map(([method, d]) => { + const val = d?.data?.path || JSON.stringify(d?.data); return `[${method || "*"}] ${val}`; }) .join(", ")}`; diff --git a/test/bench/bundle.test.ts b/test/bench/bundle.test.ts index 1e3ed9a..b2b424b 100644 --- a/test/bench/bundle.test.ts +++ b/test/bench/bundle.test.ts @@ -6,11 +6,11 @@ import zlib from "node:zlib"; describe("benchmark", () => { it("bundle size", async () => { const code = /* js */ ` - import { createRouter, addRoute, findRoute, matchAllRoutes } from "../../dist/index.mjs"; - const router = createRouter(); - addRoute(router, "GET", "/hello", { path: "/hello" }); - findRoute(router, "GET", "/hello"); - matchAllRoutes(router, "GET", "/hello"); + import { createRouter, addRoute, findRoute, findAllRoutes } from "../../src"; + createRouter(); + addRoute(); + findRoute(); + findAllRoutes(); `; const { bytes, gzipSize } = await getBundleSize(code); // console.log("bundle size", { bytes, gzipSize }); diff --git a/test/bench/impl.ts b/test/bench/impl.ts index 07210d2..a353220 100644 --- a/test/bench/impl.ts +++ b/test/bench/impl.ts @@ -4,13 +4,18 @@ import { requests, routes } from "./input"; export function createInstances() { return [ - ["rou3-src", createRou3Router(rou3Src)], - ["rou3-release", createRou3Router(rou3Release)], + ["rou3-src", createRouter(rou3Src)], + ["rou3-src-find-all", createRouter(rou3Src, true)], + ["rou3-release", createRouter(rou3Release as unknown as typeof rou3Src)], + // [ + // "rou3-release-find-all", + // createRouter(rou3Release as unknown as typeof rou3Src, true), + // ], ["maximum", createFastestRouter()], ] as const; } -export function createRou3Router(rou3: typeof rou3Release) { +export function createRouter(rou3: typeof rou3Src, withAll: boolean = false) { const router = rou3.createRouter(); for (const route of routes) { rou3.addRoute( @@ -20,6 +25,11 @@ export function createRou3Router(rou3: typeof rou3Release) { `[${route.method}] ${route.path}`, ); } + if (withAll) { + return (method: string, path: string) => { + return rou3.findAllRoutes(router, method, path, { params: true }).pop(); + }; + } return (method: string, path: string) => { return rou3.findRoute(router, method, path); }; diff --git a/test/bench/index.ts b/test/bench/index.ts index a5f1606..23795a1 100644 --- a/test/bench/index.ts +++ b/test/bench/index.ts @@ -5,10 +5,14 @@ import { createInstances } from "./impl"; const instances = createInstances(); const fullTests = process.argv.includes("--full"); +const noMaxTests = process.argv.includes("--no-max"); describe("param routes", () => { const nonStaticRequests = requests.filter((r) => r.data.includes(":")); for (const [name, _find] of instances) { + if (noMaxTests && name === "maximum") { + continue; + } bench(name, () => { for (const request of nonStaticRequests) { _find(request.method, request.path); @@ -20,6 +24,9 @@ describe("param routes", () => { if (fullTests) { describe("param and static routes", () => { for (const [name, _find] of instances) { + if (noMaxTests && name === "maximum") { + continue; + } bench(name, () => { for (const request of requests) { _find(request.method, request.path); @@ -31,6 +38,9 @@ if (fullTests) { for (const request of requests) { describe(`[${request.method}] ${request.path}`, () => { for (const [name, _find] of instances) { + if (noMaxTests && name === "maximum") { + continue; + } bench(name, () => { _find(request.method, request.path); }); diff --git a/test/matcher.test.ts b/test/find-all.test.ts similarity index 69% rename from test/matcher.test.ts rename to test/find-all.test.ts index b9fb9b5..e64e35d 100644 --- a/test/matcher.test.ts +++ b/test/find-all.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect } from "vitest"; import { createRouter, formatTree } from "./_utils"; -import { matchAllRoutes, type RouterContext } from "../src"; +import { findAllRoutes, type RouterContext } from "../src"; // Helper to make snapsots more readable -const _matchAllRoutes = ( - ctx: RouterContext, +const _findAllRoutes = ( + ctx: RouterContext<{ path?: string }>, method: string = "", path: string, -) => matchAllRoutes(ctx, method, path).map((m: any) => m.path); +) => findAllRoutes(ctx, method, path).map((m) => m.data.path); -describe("matcher: basic", () => { +describe("fiind-all: basic", () => { const router = createRouter([ "/foo", "/foo/**", @@ -33,7 +33,7 @@ describe("matcher: basic", () => { }); it("matches /foo/bar/baz pattern", () => { - const matches = _matchAllRoutes(router, "GET", "/foo/bar/baz"); + const matches = _findAllRoutes(router, "GET", "/foo/bar/baz"); expect(matches).to.toMatchInlineSnapshot(` [ "/**", @@ -80,28 +80,26 @@ describe("matcher: complex", () => { }); it("can match routes", () => { - expect(_matchAllRoutes(router, "GET", "/")).to.toMatchInlineSnapshot(` + expect(_findAllRoutes(router, "GET", "/")).to.toMatchInlineSnapshot(` [ "/", ] `); - expect(_matchAllRoutes(router, "GET", "/foo")).to.toMatchInlineSnapshot(` + expect(_findAllRoutes(router, "GET", "/foo")).to.toMatchInlineSnapshot(` [ "/foo/**", "/foo/*", "/foo", ] `); - expect(_matchAllRoutes(router, "GET", "/foo/bar")).to - .toMatchInlineSnapshot(` + expect(_findAllRoutes(router, "GET", "/foo/bar")).to.toMatchInlineSnapshot(` [ "/foo/**", "/foo/*", "/foo/bar", ] `); - expect(_matchAllRoutes(router, "GET", "/foo/baz")).to - .toMatchInlineSnapshot(` + expect(_findAllRoutes(router, "GET", "/foo/baz")).to.toMatchInlineSnapshot(` [ "/foo/**", "/foo/*", @@ -109,15 +107,14 @@ describe("matcher: complex", () => { "/foo/baz", ] `); - expect(_matchAllRoutes(router, "GET", "/foo/123/sub")).to + expect(_findAllRoutes(router, "GET", "/foo/123/sub")).to .toMatchInlineSnapshot(` [ "/foo/**", "/foo/*/sub", ] `); - expect(_matchAllRoutes(router, "GET", "/foo/123")).to - .toMatchInlineSnapshot(` + expect(_findAllRoutes(router, "GET", "/foo/123")).to.toMatchInlineSnapshot(` [ "/foo/**", "/foo/*", @@ -127,44 +124,44 @@ describe("matcher: complex", () => { it("trailing slash", () => { // Defined with trailing slash - expect(_matchAllRoutes(router, "GET", "/with-trailing")).to + expect(_findAllRoutes(router, "GET", "/with-trailing")).to .toMatchInlineSnapshot(` [ "/with-trailing/", ] `); - expect(_matchAllRoutes(router, "GET", "/with-trailing")).toMatchObject( - _matchAllRoutes(router, "GET", "/with-trailing/"), + expect(_findAllRoutes(router, "GET", "/with-trailing")).toMatchObject( + _findAllRoutes(router, "GET", "/with-trailing/"), ); // Defined without trailing slash - expect(_matchAllRoutes(router, "GET", "/without-trailing")).to + expect(_findAllRoutes(router, "GET", "/without-trailing")).to .toMatchInlineSnapshot(` [ "/without-trailing", ] `); - expect(_matchAllRoutes(router, "GET", "/without-trailing")).toMatchObject( - _matchAllRoutes(router, "GET", "/without-trailing/"), + expect(_findAllRoutes(router, "GET", "/without-trailing")).toMatchObject( + _findAllRoutes(router, "GET", "/without-trailing/"), ); }); it("prefix overlap", () => { - expect(_matchAllRoutes(router, "GET", "/c/123")).to.toMatchInlineSnapshot( + expect(_findAllRoutes(router, "GET", "/c/123")).to.toMatchInlineSnapshot( ` [ "/c/**", ] `, ); - expect(_matchAllRoutes(router, "GET", "/c/123")).toMatchObject( - _matchAllRoutes(router, "GET", "/c/123/"), + expect(_findAllRoutes(router, "GET", "/c/123")).toMatchObject( + _findAllRoutes(router, "GET", "/c/123/"), ); - expect(_matchAllRoutes(router, "GET", "/c/123")).toMatchObject( - _matchAllRoutes(router, "GET", "/c"), + expect(_findAllRoutes(router, "GET", "/c/123")).toMatchObject( + _findAllRoutes(router, "GET", "/c"), ); - expect(_matchAllRoutes(router, "GET", "/cart")).to.toMatchInlineSnapshot( + expect(_findAllRoutes(router, "GET", "/cart")).to.toMatchInlineSnapshot( ` [ "/cart", @@ -193,7 +190,7 @@ describe("matcher: order", () => { }); it("/hello", () => { - const matches = _matchAllRoutes(router, "GET", "/hello"); + const matches = _findAllRoutes(router, "GET", "/hello"); expect(matches).to.toMatchInlineSnapshot(` [ "/hello/**", @@ -204,7 +201,7 @@ describe("matcher: order", () => { }); it("/hello/world", () => { - const matches = _matchAllRoutes(router, "GET", "/hello/world"); + const matches = _findAllRoutes(router, "GET", "/hello/world"); expect(matches).to.toMatchInlineSnapshot(` [ "/hello/**", @@ -215,7 +212,7 @@ describe("matcher: order", () => { }); it("/hello/world/foobar", () => { - const matches = _matchAllRoutes(router, "GET", "/hello/world/foobar"); + const matches = _findAllRoutes(router, "GET", "/hello/world/foobar"); expect(matches).to.toMatchInlineSnapshot(` [ "/hello/**", diff --git a/test/router.test.ts b/test/router.test.ts index f6f0855..4da9cce 100644 --- a/test/router.test.ts +++ b/test/router.test.ts @@ -17,10 +17,10 @@ export function createTestRoutes(paths: string[]): Record { function testRouter( routes: string[] | Record, - before?: (router: RouterContext) => void, + before?: (router: RouterContext<{ path?: string }>) => void, tests?: TestRoutes, ) { - const router = createRouter(routes); + const router = createRouter<{ path?: string }>(routes); if (!tests) { tests = Array.isArray(routes)