From 7f82d8bc11c4da99097e4c2e0ae9693649a90e02 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 00:18:03 +0200 Subject: [PATCH 01/15] feat: rewrite internal router --- eslint.config.mjs | 13 +- src/router.ts | 334 ++++++++++++++++++++-------------- src/types.ts | 28 +-- tests/_utils.ts | 31 ++++ tests/basic.test.ts | 66 +++++++ tests/router.test.ts | 413 ++++++++++++++++++++++++------------------- 6 files changed, 545 insertions(+), 340 deletions(-) create mode 100644 tests/_utils.ts create mode 100644 tests/basic.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 83d189b..9f96510 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,9 +4,10 @@ import unjs from "eslint-config-unjs"; export default unjs({ ignores: [], rules: { - "unicorn/no-null": 0, - "@typescript-eslint/no-non-null-assertion": 0, - "unicorn/prevent-abbreviations": 0, - "no-unused-expressions": 0 -}, -}); \ No newline at end of file + "unicorn/no-null": 0, + "@typescript-eslint/no-non-null-assertion": 0, + "unicorn/prevent-abbreviations": 0, + "no-unused-expressions": 0, + "unicorn/no-for-loop": 0, + }, +}); diff --git a/src/router.ts b/src/router.ts index 062dae2..413820b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -6,14 +6,13 @@ import type { RadixNodeData, RadixRouterOptions, } from "./types"; -import { NODE_TYPES } from "./types"; export function createRouter( options: RadixRouterOptions = {}, ): RadixRouter { - const ctx: RadixRouterContext = { + const ctx: RadixRouterContext = { options, - rootNode: createRadixNode(), + root: { key: "" }, staticRoutesMap: {}, }; @@ -28,185 +27,246 @@ export function createRouter( return { ctx, - lookup: (path: string) => lookup(ctx, normalizeTrailingSlash(path)), + lookup: (path: string) => + lookup(ctx, normalizeTrailingSlash(path)) as MatchedRoute | undefined, insert: (path: string, data: any) => insert(ctx, normalizeTrailingSlash(path), data), remove: (path: string) => remove(ctx, normalizeTrailingSlash(path)), }; } -function lookup( - ctx: RadixRouterContext, - path: string, -): MatchedRoute | null { - const staticPathNode = ctx.staticRoutesMap[path]; - if (staticPathNode) { - return staticPathNode.data as MatchedRoute; - } +// --- tree operations --- + +// --- Insert --- + +function insert(ctx: RadixRouterContext, path: string, data: any) { + const segments = _splitPath(path); + + let node = ctx.root; - const sections = path.split("/"); + let _unnamedParamIndex = 0; - const params: MatchedRoute["params"] = {}; - let paramsFound = false; - let wildcardNode = null; - let node: RadixNode | null = ctx.rootNode; - let wildCardParam = null; + const nodeParams: RadixNode["paramNames"] = []; - for (let i = 0; i < sections.length; i++) { - const section = sections[i]; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; - if (node.wildcardChildNode !== null) { - wildcardNode = node.wildcardChildNode; - wildCardParam = sections.slice(i).join("/"); + // Wildcard + if (segment.startsWith("**")) { + if (!node.wildcardChild) { + node.wildcardChild = { key: "**" }; + } + node = node.wildcardChild; + nodeParams.push({ + index: i, + name: segment.split(":")[1] || "_", + }); + break; } - // Exact matches take precedence over placeholders - const nextNode = node.children.get(section); - if (nextNode === undefined) { - node = node.placeholderChildNode; - if (node === null) { - break; - } else { - if (node.type === NODE_TYPES.MIXED && node.paramMatcher) { - const matches = section.match(node.paramMatcher); - Object.assign(params, matches?.groups); - } else if (node.paramName) { - params[node.paramName] = section; - } - paramsFound = true; + // Param + if (segment === "*" || segment.includes(":")) { + if (!node.paramChild) { + node.paramChild = { key: "*" }; } + node = node.paramChild; + nodeParams.push({ + index: i, + name: + segment === "*" + ? `_${_unnamedParamIndex++}` + : (_getParamMatcher(segment) as string), + }); + continue; + } + + // Static + const child = node.staticChildren?.get(segment); + if (child) { + node = child; } else { - node = nextNode; + const staticNode = { key: segment }; + if (!node.staticChildren) { + node.staticChildren = new Map(); + } + node.staticChildren.set(segment, staticNode); + node = staticNode; } } - if ((node === null || node.data === null) && wildcardNode !== null) { - node = wildcardNode; - params[node.paramName || "_"] = wildCardParam; - paramsFound = true; + // Assign data and params to the final node + node.index = segments.length - 1; + node.data = data; + if (nodeParams.length > 0) { + node.paramNames = nodeParams; } +} + +// --- Lookup --- - if (!node) { - return null; +function lookup( + ctx: RadixRouterContext, + path: string, +): MatchedRoute | undefined { + const segments = _splitPath(path); + const matchedNode = _lookup(ctx, ctx.root, segments, 0); + if (!matchedNode) { + return; } + const data = matchedNode.data; + if (!matchedNode.paramNames && matchedNode.key !== "**") { + return data; + } + const params = _getParams(segments, matchedNode); + if (matchedNode.key === "**") { + const paramName = + (matchedNode.paramNames?.[matchedNode.paramNames.length - 1] + .name as string) || "_"; + params[paramName] = segments.slice(matchedNode.index).join("/"); + } + return { ...data, params }; +} - if (paramsFound) { - return { - ...node.data, - params: paramsFound ? params : undefined, - } as MatchedRoute; +function _lookup( + ctx: RadixRouterContext, + node: RadixNode, + segments: string[], + index: number, +): RadixNode | undefined { + // End of path + if (index === segments.length) { + return node; } - return node.data as MatchedRoute; -} + const segment = segments[index]; -function insert(ctx: RadixRouterContext, path: string, data: any) { - let isStaticRoute = true; + // 1. Static + const staticChild = node.staticChildren?.get(segment); + if (staticChild) { + const matchedNode = _lookup(ctx, staticChild, segments, index + 1); + if (matchedNode) { + return matchedNode; + } + } - const sections = path.split("/"); + // 2. Param + if (node.paramChild) { + const nextNode = _lookup(ctx, node.paramChild, segments, index + 1); + if (nextNode) { + return nextNode; + } + } - let node = ctx.rootNode; + // 3. Wildcard + if (node.wildcardChild) { + return node.wildcardChild; + } - let _unnamedPlaceholderCtr = 0; + // No match + return; +} - for (const section of sections) { - let childNode: RadixNode | undefined; +// --- Remove --- - if ((childNode = node.children.get(section))) { - node = childNode; - } else { - const type = getNodeType(section); - - // Create new node to represent the next part of the path - childNode = createRadixNode({ type, parent: node }); - - node.children.set(section, childNode); - - if (type === NODE_TYPES.PLACEHOLDER) { - if (section === "*") { - childNode.paramName = `_${_unnamedPlaceholderCtr++}`; - } else { - const PARAMS_RE = /:\w+|[^:]+/g; - const params = [...section.matchAll(PARAMS_RE)].map((i) => i[0]); - if (params.length === 1) { - childNode.paramName = params[0].slice(1); - } else { - childNode.type = NODE_TYPES.MIXED; - const sectionRegexString = section.replace( - /:(\w+)/g, - (_, id) => `(?<${id}>\\w+)`, - ); - childNode.paramMatcher = new RegExp(`^${sectionRegexString}$`); - } - } - node.placeholderChildNode = childNode; - isStaticRoute = false; - } else if (type === NODE_TYPES.WILDCARD) { - node.wildcardChildNode = childNode; - childNode.paramName = section.slice(3 /* "**:" */) || "_"; - isStaticRoute = false; - } +function remove(ctx: RadixRouterContext, path: string) { + const segments = _splitPath(path); + return _remove(ctx.root, segments, 0); +} - node = childNode; +function _remove(node: RadixNode, segments: string[], index: number): boolean { + if (index === segments.length) { + if (node.data === undefined) { + return false; } + node.data = undefined; + return !( + node.staticChildren?.size || + node.paramChild || + node.wildcardChild + ); } - // Store whatever data was provided into the node - node.data = data; + const segment = segments[index]; - // Optimization, if a route is static and does not have any - // variable sections, we can store it into a map for faster retrievals - if (isStaticRoute === true) { - ctx.staticRoutesMap[path] = node; + // Param + if (segment === "*") { + if (!node.paramChild) { + return false; + } + const shouldDelete = _remove(node.paramChild, segments, index + 1); + if (shouldDelete) { + node.paramChild = undefined; + return ( + node.staticChildren?.size === 0 && node.wildcardChild === undefined + ); + } + return false; } - return node; -} - -function remove(ctx: RadixRouterContext, path: string) { - let success = false; - const sections = path.split("/"); - let node: RadixNode | undefined = ctx.rootNode; - - for (const section of sections) { - node = node.children.get(section); - if (!node) { - return success; + // Wildcard + if (segment === "**") { + if (!node.wildcardChild) { + return false; + } + const shouldDelete = _remove(node.wildcardChild, segments, index + 1); + if (shouldDelete) { + node.wildcardChild = undefined; + return node.staticChildren?.size === 0 && node.paramChild === undefined; } + return false; } - if (node.data) { - const lastSection = sections.at(-1) || ""; - node.data = null; - if (node.children.size === 0 && node.parent) { - node.parent.children.delete(lastSection); - node.parent.wildcardChildNode = null; - node.parent.placeholderChildNode = null; - } - success = true; + // Static + const childNode = node.staticChildren?.get(segment); + if (!childNode) { + return false; + } + const shouldDelete = _remove(childNode, segments, index + 1); + if (shouldDelete) { + node.staticChildren?.delete(segment); + return node.staticChildren?.size === 0 && node.data === undefined; } - return success; + return false; } -function createRadixNode(options: Partial = {}): RadixNode { - return { - type: options.type || NODE_TYPES.NORMAL, - parent: options.parent || null, - children: new Map(), - data: options.data || null, - paramName: options.paramName || null, - wildcardChildNode: null, - placeholderChildNode: null, - }; +// --- shared utils --- + +function _splitPath(path: string) { + return path.split("/").filter(Boolean); } -function getNodeType(str: string) { - if (str.startsWith("**")) { - return NODE_TYPES.WILDCARD; +function _getParamMatcher(segment: string): string | RegExp { + const PARAMS_RE = /:\w+|[^:]+/g; + const params = [...segment.matchAll(PARAMS_RE)]; + if (params.length === 1) { + return params[0][0].slice(1); } - if (str.includes(":") || str === "*") { - return NODE_TYPES.PLACEHOLDER; + const sectionRegexString = segment.replace( + /:(\w+)/g, + (_, id) => `(?<${id}>\\w+)`, + ); + console.log(segment); + return new RegExp(`^${sectionRegexString}$`); +} + +function _getParams( + segments: string[], + node: RadixNode, +): Record { + const res = Object.create(null); + for (const param of node.paramNames || []) { + const segment = segments[param.index]; + if (typeof param.name === "string") { + res[param.name] = segment; + } else { + const match = segment.match(param.name); + if (match) { + for (const key in match.groups) { + res[key] = match.groups[key]; + } + } + } } - return NODE_TYPES.NORMAL; + return res; } diff --git a/src/types.ts b/src/types.ts index 17d06ad..5383aa5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,7 @@ export const NODE_TYPES = { - NORMAL: 0 as const, - WILDCARD: 1 as const, - PLACEHOLDER: 2 as const, - MIXED: 3 as const, + STATIC: 0 as const, + PARAM: 1 as const, + WILDCARD: 3 as const, }; type _NODE_TYPES = typeof NODE_TYPES; @@ -18,14 +17,15 @@ export type MatchedRoute = Omit< > & { params?: Record }; export interface RadixNode { - type: NODE_TYPE; - parent: RadixNode | null; - children: Map>; - data: RadixNodeData | null; - paramName: string | null; - paramMatcher?: string | RegExp; - wildcardChildNode: RadixNode | null; - placeholderChildNode: RadixNode | null; + key: string; + + staticChildren?: Map>; + paramChild?: RadixNode; + wildcardChild?: RadixNode; + + index?: number; + data?: T; + paramNames?: Array<{ index: number; name: string | RegExp }>; } export interface RadixRouterOptions { @@ -35,7 +35,7 @@ export interface RadixRouterOptions { export interface RadixRouterContext { options: RadixRouterOptions; - rootNode: RadixNode; + root: RadixNode; staticRoutesMap: Record; } @@ -48,7 +48,7 @@ export interface RadixRouter { * * @returns The data that was originally inserted into the tree */ - lookup(path: string): MatchedRoute | null; + lookup(path: string): MatchedRoute | undefined; /** * Perform an insert into the radix tree diff --git a/tests/_utils.ts b/tests/_utils.ts new file mode 100644 index 0000000..707679b --- /dev/null +++ b/tests/_utils.ts @@ -0,0 +1,31 @@ +import type { RadixNode } from "../src"; + +export function formatTree( + node: RadixNode, + depth = 0, + result = [] as string[], + prefix = "", +) { + result.push( + // prettier-ignore + `${prefix}${depth === 0 ? "" : "├── "}${node.key ? `/${node.key}` : (depth === 0 ? "" : "")}${node.data === undefined ? "" : ` ┈> [${node.data.path}]`}`, + ); + + const childrenArray = [ + ...(node.staticChildren?.values() || []), + node.paramChild, + node.wildcardChild, + ].filter(Boolean) as RadixNode[]; + for (const [index, child] of childrenArray.entries()) { + const lastChild = index === childrenArray.length - 1; + formatTree( + child, + depth + 1, + result, + (depth === 0 ? "" : prefix + (depth > 0 ? "│ " : " ")) + + (lastChild ? " " : " "), + ); + } + + return depth === 0 ? result.join("\n") : result; +} diff --git a/tests/basic.test.ts b/tests/basic.test.ts new file mode 100644 index 0000000..03e76b1 --- /dev/null +++ b/tests/basic.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { createRouter } from "../src"; +import { formatTree } from "./_utils"; + +describe("Basic router", () => { + const router = createRouter({}); + + it("add routes", () => { + for (const path of [ + "/test", + "/test/:id", + "/test/:idYZ/y/z", + "/test/:idY/y", + "/test/foo", + "/test/foo/*", + "/test/foo/**", + "/test/foo/bar/qux", + "/test/foo/baz", + "/test/fooo", + "/another/path", + ]) { + router.insert(path, { path }); + } + + expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(`""`); + }); + + it("lookup works", () => { + // Static + expect(router.lookup("/test")).toEqual({ path: "/test" }); + expect(router.lookup("/test/foo")).toEqual({ path: "/test/foo" }); + expect(router.lookup("/test/fooo")).toEqual({ path: "/test/fooo" }); + expect(router.lookup("/another/path")).toEqual({ path: "/another/path" }); + // Param + expect(router.lookup("/test/123")).toEqual({ + path: "/test/:id", + params: { id: "123" }, + }); + expect(router.lookup("/test/123/y")).toEqual({ + path: "/test/:idY/y", + params: { idY: "123" }, + }); + expect(router.lookup("/test/123/y/z")).toEqual({ + path: "/test/:idYZ/y/z", + params: { idYZ: "123" }, + }); + expect(router.lookup("/test/foo/123")).toEqual({ + path: "/test/foo/*", + params: { _0: "123" }, + }); + // Wildcard + expect(router.lookup("/test/foo/123/456")).toEqual({ + path: "/test/foo/**", + params: { _: "123/456" }, + }); + }); + + it("remove works", () => { + router.remove("/test"); + router.remove("/test/*"); + router.remove("/test/foo/*"); + router.remove("/test/foo/**"); + expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(`""`); + expect(router.lookup("/test")).toBeUndefined(); + }); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts index 6129cac..87b9c24 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -1,19 +1,37 @@ import { describe, it, expect } from "vitest"; -import { createRouter, NODE_TYPES } from "../src"; -import { skip } from "node:test"; +import { createRouter, RadixRouter } from "../src"; +import { formatTree } from "./_utils"; -export function createRoutes(paths) { +type TestRoute = { + path: string; + params?: Record; + skip?: boolean; +}; + +type TestRoutes = Record; + +export function createTestRoutes(paths: string[]): TestRoutes { return Object.fromEntries(paths.map((path) => [path, { path }])); } -function testRouter(paths, tests?) { - const routes = createRoutes(paths); +function testRouter( + paths: string[], + before?: (ctx: { routes: TestRoutes; router: RadixRouter }) => void, + tests?: TestRoutes, +) { + const routes = createTestRoutes(paths); const router = createRouter({ routes }); if (!tests) { tests = routes; } + if (before) { + it("before", () => { + before({ routes, router }); + }); + } + for (const path in tests) { it.skipIf(tests[path]?.skip)( `lookup ${path} should be ${JSON.stringify(tests[path])}`, @@ -25,13 +43,21 @@ function testRouter(paths, tests?) { } describe("Router lookup", function () { - describe("static routes", function () { - testRouter([ - "/", - "/route", - "/another-router", - "/this/is/yet/another/route", - ]); + describe("static routes", () => { + testRouter( + ["/", "/route", "/another-router", "/this/is/yet/another/route"], + (ctx) => + expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + " ┈> [/] + ├── /route ┈> [/route] + ├── /another-router ┈> [/another-router] + ├── /this + │ ├── /is + │ │ ├── /yet + │ │ │ ├── /another + │ │ │ │ ├── /route ┈> [/this/is/yet/another/route]" + `), + ); }); describe("retrieve placeholders", function () { @@ -41,6 +67,19 @@ describe("Router lookup", function () { "carbon/:element/test/:testing", "this/:route/has/:cool/stuff", ], + (ctx) => + expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /carbon + │ ├── /* ┈> [carbon/:element] + │ │ ├── /test + │ │ │ ├── /* ┈> [carbon/:element/test/:testing] + ├── /this + │ ├── /* + │ │ ├── /has + │ │ │ ├── /* + │ │ │ │ ├── /stuff ┈> [this/:route/has/:cool/stuff]" + `), { "carbon/test1": { path: "carbon/:element", @@ -48,8 +87,8 @@ describe("Router lookup", function () { element: "test1", }, }, - "/carbon": null, - "carbon/": null, + "/carbon": undefined, + "carbon/": undefined, "carbon/test2/test/test23": { path: "carbon/:element/test/:testing", params: { @@ -67,43 +106,52 @@ describe("Router lookup", function () { }, ); - testRouter(["/", "/:a", "/:a/:y/:x/:b", "/:a/:x/:b", "/:a/:b"], { - "/": { path: "/" }, - "/a": { - path: "/:a", - params: { - a: "a", + testRouter( + ["/", "/:a", "/:a/:y/:x/:b", "/:a/:x/:b", "/:a/:b"], + (ctx) => + expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot( + ` + " ┈> [/] + ├── /* ┈> [/:a] + │ ├── /* ┈> [/:a/:b] + │ │ ├── /* ┈> [/:a/:x/:b] + │ │ │ ├── /* ┈> [/:a/:y/:x/:b]" + `, + ), + { + "/": { path: "/" }, + "/a": { + path: "/:a", + params: { + a: "a", + }, }, - }, - "/a/b": { - path: "/:a/:b", - params: { - a: "a", - b: "b", + "/a/b": { + path: "/:a/:b", + params: { + a: "a", + b: "b", + }, }, - }, - "/a/x/b": { - path: "/:a/:x/:b", - // TODO: https://github.com/unjs/radix3/pull/96 - skip: true, - params: { - a: "a", - b: "b", - x: "x", + "/a/x/b": { + path: "/:a/:x/:b", + params: { + a: "a", + b: "b", + x: "x", + }, }, - }, - "/a/y/x/b": { - path: "/:a/:y/:x/:b", - // TODO: https://github.com/unjs/radix3/pull/96 - skip: true, - params: { - a: "a", - b: "b", - x: "x", - y: "y", + "/a/y/x/b": { + path: "/:a/:y/:x/:b", + params: { + a: "a", + b: "b", + x: "x", + y: "y", + }, }, }, - }); + ); testRouter( [ @@ -113,10 +161,18 @@ describe("Router lookup", function () { "/:owner/:repo/:packageAndRefOrSha", "/:owner/:repo/:npmOrg/:packageAndRefOrSha", ], + (ctx) => + expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot( + ` + " ┈> [/] + ├── /* ┈> [/:packageAndRefOrSha] + │ ├── /* ┈> [/:owner/:repo/] + │ │ ├── /* ┈> [/:owner/:repo/:packageAndRefOrSha] + │ │ │ ├── /* ┈> [/:owner/:repo/:npmOrg/:packageAndRefOrSha]" + `, + ), { "/tinylibs/tinybench/tiny@232": { - // TODO: https://github.com/unjs/radix3/pull/103 - skip: true, path: "/:owner/:repo/:packageAndRefOrSha", params: { owner: "tinylibs", @@ -137,9 +193,21 @@ describe("Router lookup", function () { ); }); - describe("should be able to perform wildcard lookups", function () { + describe("should be able to perform wildcard lookups", () => { testRouter( ["polymer/**:id", "polymer/another/route", "route/:p1/something/**:rest"], + (ctx) => + expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /polymer + │ ├── /another + │ │ ├── /route ┈> [polymer/another/route] + │ ├── /** ┈> [polymer/**:id] + ├── /route + │ ├── /* + │ │ ├── /something + │ │ │ ├── /** ┈> [route/:p1/something/**:rest]" + `), { "polymer/another/route": { path: "polymer/another/route" }, "polymer/anon": { path: "polymer/**:id", params: { id: "anon" } }, @@ -156,152 +224,128 @@ describe("Router lookup", function () { }); describe("unnamed placeholders", function () { - testRouter(["polymer/**", "polymer/route/*"], { - "polymer/foo/bar": { path: "polymer/**", params: { _: "foo/bar" } }, - "polymer/route/anon": { path: "polymer/route/*", params: { _0: "anon" } }, - "polymer/constructor": { - path: "polymer/**", - params: { _: "constructor" }, + testRouter( + ["polymer/**", "polymer/route/*"], + (ctx) => + expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /polymer + │ ├── /route + │ │ ├── /* ┈> [polymer/route/*] + │ ├── /** ┈> [polymer/**]" + `), + { + "polymer/foo/bar": { path: "polymer/**", params: { _: "foo/bar" } }, + "polymer/route/anon": { + path: "polymer/route/*", + params: { _0: "anon" }, + }, + "polymer/constructor": { + path: "polymer/**", + params: { _: "constructor" }, + }, }, - }); + ); }); describe("mixed params in same segment", function () { const mixedPath = "/files/:category/:id,name=:name.txt"; - testRouter([mixedPath], { - "/files/test/123,name=foobar.txt": { - path: mixedPath, - params: { category: "test", id: "123", name: "foobar" }, + testRouter( + [mixedPath], + (ctx) => + expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /files + │ ├── /* + │ │ ├── /* ┈> [/files/:category/:id,name=:name.txt]" + `), + { + "/files/test/123,name=foobar.txt": { + path: mixedPath, + params: { category: "test", id: "123", name: "foobar" }, + }, }, - }); + ); }); describe("should be able to match routes with trailing slash", function () { - testRouter(["route/without/trailing/slash", "route/with/trailing/slash/"], { - "route/without/trailing/slash": { path: "route/without/trailing/slash" }, - "route/with/trailing/slash/": { path: "route/with/trailing/slash/" }, - "route/without/trailing/slash/": { path: "route/without/trailing/slash" }, - "route/with/trailing/slash": { path: "route/with/trailing/slash/" }, - }); - }); -}); - -describe("Router insert", function () { - it("should be able to insert nodes correctly into the tree", function () { - const router = createRouter(); - router.insert("hello", {}); - router.insert("cool", {}); - router.insert("hi", {}); - router.insert("helium", {}); - router.insert("/choo", {}); - router.insert("//choo", {}); - - const rootNode = router.ctx.rootNode; - const helloNode = rootNode.children.get("hello"); - const coolNode = rootNode.children.get("cool"); - const hiNode = rootNode.children.get("hi"); - const heliumNode = rootNode.children.get("helium"); - const slashNode = rootNode.children.get(""); - - expect(helloNode).to.exist; - expect(coolNode).to.exist; - expect(hiNode).to.exist; - expect(heliumNode).to.exist; - expect(slashNode).to.exist; - - const slashChooNode = slashNode!.children.get("choo"); - const slashSlashChooNode = slashNode!.children - .get("")! - .children.get("choo"); - - expect(slashChooNode).to.exist; - expect(slashSlashChooNode).to.exist; - }); - - it("should insert static routes into the static route map", function () { - const router = createRouter(); - const route = "/api/v2/route"; - router.insert(route, {}); - - expect(router.ctx.staticRoutesMap[route]).to.exist; - }); - it("should not insert variable routes into the static route map", function () { - const router = createRouter(); - const routeA = "/api/v2/**"; - const routeB = "/api/v3/:placeholder"; - router.insert(routeA, {}); - router.insert(routeB, {}); - - expect(router.ctx.staticRoutesMap[routeA]).to.not.exist; - expect(router.ctx.staticRoutesMap[routeB]).to.not.exist; - }); - - it("should insert placeholder and wildcard nodes correctly into the tree", function () { - const router = createRouter(); - router.insert("hello/:placeholder/tree", {}); - router.insert("choot/choo/**", {}); - - const helloNode = router.ctx.rootNode.children.get("hello"); - const helloPlaceholderNode = helloNode!.children.get(":placeholder"); - expect(helloPlaceholderNode!.type).to.equal(NODE_TYPES.PLACEHOLDER); - - const chootNode = router.ctx.rootNode.children.get("choot"); - const chootChooNode = chootNode!.children.get("choo"); - const chootChooWildcardNode = chootChooNode!.children.get("**"); - expect(chootChooWildcardNode!.type).to.equal(NODE_TYPES.WILDCARD); - }); - - it("should be able to initialize routes via the router constructor", function () { - const router = createRouter({ - routes: { - "/api/v1": { value: 1 }, - "/api/v2": { value: 2 }, - "/api/v3": { value: 3 }, + testRouter( + ["route/without/trailing/slash", "route/with/trailing/slash/"], + (ctx) => + expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /route + │ ├── /without + │ │ ├── /trailing + │ │ │ ├── /slash ┈> [route/without/trailing/slash] + │ ├── /with + │ │ ├── /trailing + │ │ │ ├── /slash ┈> [route/with/trailing/slash/]" + `), + { + "route/without/trailing/slash": { + path: "route/without/trailing/slash", + }, + "route/with/trailing/slash/": { path: "route/with/trailing/slash/" }, + "route/without/trailing/slash/": { + path: "route/without/trailing/slash", + }, + "route/with/trailing/slash": { path: "route/with/trailing/slash/" }, }, - }); - - const rootSlashNode = router.ctx.rootNode.children.get(""); - const apiNode = rootSlashNode!.children.get("api"); - const v1Node = apiNode!.children.get("v1"); - const v2Node = apiNode!.children.get("v2"); - const v3Node = apiNode!.children.get("v3"); - - expect(v1Node).to.exist; - expect(v2Node).to.exist; - expect(v3Node).to.exist; - expect(v1Node!.data!.value).to.equal(1); - expect(v2Node!.data!.value).to.equal(2); - expect(v3Node!.data!.value).to.equal(3); + ); }); +}); - it("should allow routes to be overwritten by performing another insert", function () { +describe("Router insert", () => { + it("should be able to insert nodes correctly into the tree", () => { const router = createRouter({ - routes: { "/api/v1": { data: 1 } }, - }); - - let apiRouteData = router.lookup("/api/v1"); - expect(apiRouteData!.data).to.equal(1); - - router.insert("/api/v1", { - path: "/api/v1", - data: 2, - anotherField: 3, - }); - - apiRouteData = router.lookup("/api/v1"); - expect(apiRouteData).deep.equal({ - data: 2, - path: "/api/v1", - anotherField: 3, + routes: createTestRoutes([ + "hello", + "cool", + "hi", + "helium", + "/choo", + "//choo", + "coooool", + "chrome", + "choot", + "choot/:choo", + "ui/**", + "ui/components/**", + "/api/v1", + "/api/v2", + "/api/v3", + ]), }); - expect(apiRouteData!.anotherField).to.equal(3); + router.insert("/api/v3", { path: "/api/v3", overridden: true }); + + expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /hello ┈> [hello] + ├── /cool ┈> [cool] + ├── /hi ┈> [hi] + ├── /helium ┈> [helium] + ├── /choo ┈> [//choo] + ├── /coooool ┈> [coooool] + ├── /chrome ┈> [chrome] + ├── /choot ┈> [choot] + │ ├── /* ┈> [choot/:choo] + ├── /ui + │ ├── /components + │ │ ├── /** ┈> [ui/components/**] + │ ├── /** ┈> [ui/**] + ├── /api + │ ├── /v1 ┈> [/api/v1] + │ ├── /v2 ┈> [/api/v2] + │ ├── /v3 ┈> [/api/v3]" + `); }); }); describe("Router remove", function () { it("should be able to remove nodes", function () { const router = createRouter({ - routes: createRoutes([ + routes: createTestRoutes([ "hello", "cool", "hi", @@ -316,7 +360,7 @@ describe("Router remove", function () { }); router.remove("choot"); - expect(router.lookup("choot")).to.deep.equal(null); + expect(router.lookup("choot")).to.deep.equal(undefined); expect(router.lookup("ui/components/snackbars")).to.deep.equal({ path: "ui/components/**", @@ -332,11 +376,11 @@ describe("Router remove", function () { it("removes data but does not delete a node if it has children", function () { const router = createRouter({ - routes: createRoutes(["a/b", "a/b/:param1"]), + routes: createTestRoutes(["a/b", "a/b/:param1"]), }); router.remove("a/b"); - expect(router.lookup("a/b")).to.deep.equal(null); + expect(router.lookup("a/b")).to.deep.equal(undefined); expect(router.lookup("a/b/c")).to.deep.equal({ params: { param1: "c" }, path: "a/b/:param1", @@ -345,7 +389,10 @@ describe("Router remove", function () { it("should be able to remove placeholder routes", function () { const router = createRouter({ - routes: createRoutes(["placeholder/:choo", "placeholder/:choo/:choo2"]), + routes: createTestRoutes([ + "placeholder/:choo", + "placeholder/:choo/:choo2", + ]), }); expect(router.lookup("placeholder/route")).to.deep.equal({ @@ -356,8 +403,8 @@ describe("Router remove", function () { }); // TODO - // router.remove('placeholder/:choo') - // expect(router.lookup('placeholder/route')).to.deep.equal(null) + // router.remove("placeholder/:choo"); + // expect(router.lookup("placeholder/route")).to.deep.equal(undefined); expect(router.lookup("placeholder/route/route2")).to.deep.equal({ path: "placeholder/:choo/:choo2", @@ -370,7 +417,7 @@ describe("Router remove", function () { it("should be able to remove wildcard routes", function () { const router = createRouter({ - routes: createRoutes(["ui/**", "ui/components/**"]), + routes: createTestRoutes(["ui/**", "ui/components/**"]), }); expect(router.lookup("ui/components/snackbars")).to.deep.equal({ @@ -386,7 +433,7 @@ describe("Router remove", function () { it("should return a result signifying that the remove operation was successful or not", function () { const router = createRouter({ - routes: createRoutes(["/some/route"]), + routes: createTestRoutes(["/some/route"]), }); let removeResult = router.remove("/some/route"); From d9c37d1bd1017659974855e1c4336ba0032b0197 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 01:17:39 +0200 Subject: [PATCH 02/15] migrate matcher to router.matchAll --- src/index.ts | 1 - src/matcher.ts | 162 ------------------------ src/router.ts | 39 ++++++ src/types.ts | 8 ++ tests/_utils.ts | 2 +- tests/matcher.test.ts | 280 ++++++++++++++++++------------------------ 6 files changed, 165 insertions(+), 327 deletions(-) delete mode 100644 src/matcher.ts diff --git a/src/index.ts b/src/index.ts index 3c99956..5dbcb68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ export * from "./router"; -export * from "./matcher"; export * from "./types"; diff --git a/src/matcher.ts b/src/matcher.ts deleted file mode 100644 index c3ec970..0000000 --- a/src/matcher.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { - RadixNode, - RadixRouter, - RadixNodeData, - NODE_TYPES, - MatcherExport, -} from "./types"; - -export interface RouteTable { - static: Map; - wildcard: Map; - dynamic: Map; -} - -export interface RouteMatcher { - ctx: { table: RouteTable }; - matchAll: (path: string) => RadixNodeData[]; -} - -export function toRouteMatcher(router: RadixRouter): RouteMatcher { - const table = _routerNodeToTable("", router.ctx.rootNode); - return _createMatcher(table, router.ctx.options.strictTrailingSlash); -} - -function _createMatcher( - table: RouteTable, - strictTrailingSlash?: boolean, -): RouteMatcher { - return { - ctx: { table }, - matchAll: (path: string) => _matchRoutes(path, table, strictTrailingSlash), - } satisfies RouteMatcher; -} - -function _createRouteTable(): RouteTable { - return { - static: new Map(), - wildcard: new Map(), - dynamic: new Map(), - }; -} - -function _exportMatcherFromTable(table: RouteTable): MatcherExport { - const obj = Object.create(null); - - for (const property in table) { - obj[property] = - property === "dynamic" - ? Object.fromEntries( - [...table[property].entries()].map(([key, value]) => [ - key, - _exportMatcherFromTable(value), - ]), - ) - : Object.fromEntries(table[property as keyof typeof table].entries()); - } - - return obj; -} - -export function exportMatcher(matcher: RouteMatcher): MatcherExport { - return _exportMatcherFromTable(matcher.ctx.table); -} - -function _createTableFromExport(matcherExport: MatcherExport): RouteTable { - const table: Partial = {}; - for (const property in matcherExport) { - table[property as keyof MatcherExport] = - property === "dynamic" - ? new Map( - Object.entries(matcherExport[property]).map(([key, value]) => [ - key, - _createTableFromExport(value as any), - ]), - ) - : new Map( - Object.entries(matcherExport[property as keyof MatcherExport]), - ); - } - return table as RouteTable; -} - -export function createMatcherFromExport( - matcherExport: MatcherExport, -): RouteMatcher { - return _createMatcher(_createTableFromExport(matcherExport)); -} - -function _matchRoutes( - path: string, - table: RouteTable, - strictTrailingSlash?: boolean, -): RadixNodeData[] { - // By default trailing slashes are not strict - if (strictTrailingSlash !== true && path.endsWith("/")) { - path = path.slice(0, -1) || "/"; - } - - // Order should be from less specific to most specific - const matches = []; - - // Wildcard - for (const [key, value] of _sortRoutesMap(table.wildcard)) { - if (path === key || path.startsWith(key + "/")) { - matches.push(value); - } - } - - // Dynamic - for (const [key, value] of _sortRoutesMap(table.dynamic)) { - if (path.startsWith(key + "/")) { - const subPath = - "/" + path.slice(key.length).split("/").splice(2).join("/"); - matches.push(..._matchRoutes(subPath, value)); - } - } - - // Static - const staticMatch = table.static.get(path); - if (staticMatch) { - matches.push(staticMatch); - } - - return matches.filter(Boolean); -} - -function _sortRoutesMap(m: Map) { - return [...m.entries()].sort((a, b) => a[0].length - b[0].length); -} - -function _routerNodeToTable( - initialPath: string, - initialNode: RadixNode, -): RouteTable { - const table: RouteTable = _createRouteTable(); - function _addNode(path: string, node: RadixNode) { - if (path) { - if ( - node.type === NODE_TYPES.NORMAL && - !(path.includes("*") || path.includes(":")) - ) { - if (node.data) { - table.static.set(path, node.data); - } - } else if (node.type === NODE_TYPES.WILDCARD) { - table.wildcard.set(path.replace("/**", ""), node.data); - } else if (node.type === NODE_TYPES.PLACEHOLDER) { - const subTable = _routerNodeToTable("", node); - if (node.data) { - subTable.static.set("/", node.data); - } - table.dynamic.set(path.replace(/\/\*|\/:\w+/, ""), subTable); - return; - } - } - for (const [childPath, child] of node.children.entries()) { - _addNode(`${path}/${childPath}`.replace("//", "/"), child); - } - } - _addNode(initialPath, initialNode); - return table; -} diff --git a/src/router.ts b/src/router.ts index 413820b..0ae1d93 100644 --- a/src/router.ts +++ b/src/router.ts @@ -29,6 +29,8 @@ export function createRouter( ctx, lookup: (path: string) => lookup(ctx, normalizeTrailingSlash(path)) as MatchedRoute | undefined, + matchAll: (path: string) => + _matchAll(ctx, ctx.root, _splitPath(path), 0) as RadixNodeData[], insert: (path: string, data: any) => insert(ctx, normalizeTrailingSlash(path), data), remove: (path: string) => remove(ctx, normalizeTrailingSlash(path)), @@ -166,6 +168,43 @@ function _lookup( return; } +function _matchAll( + ctx: RadixRouterContext, + node: RadixNode, + segments: string[], + index: number, +): RadixNodeData[] { + const matchedNodes: RadixNodeData[] = []; + + const segment = segments[index]; + + // 1. Node self data + if (index === segments.length && node.data !== undefined) { + matchedNodes.unshift(node.data); + } + + // 2. Static + const staticChild = node.staticChildren?.get(segment); + if (staticChild) { + matchedNodes.unshift(..._matchAll(ctx, staticChild, segments, index + 1)); + } + + // 3. Param + if (node.paramChild) { + matchedNodes.unshift( + ..._matchAll(ctx, node.paramChild, segments, index + 1), + ); + } + + // 4. Wildcard + if (node.wildcardChild?.data) { + matchedNodes.unshift(node.wildcardChild.data); + } + + // No match + return matchedNodes; +} + // --- Remove --- function remove(ctx: RadixRouterContext, path: string) { diff --git a/src/types.ts b/src/types.ts index 5383aa5..17e4c45 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,14 @@ export interface RadixRouter { */ lookup(path: string): MatchedRoute | undefined; + /** + * Match all routes that match the given path. + * @param path - the path to search for + * + * @returns The data that was originally inserted into the tree + */ + matchAll(path: string): RadixNodeData[]; + /** * Perform an insert into the radix tree * @param path - the prefix to match diff --git a/tests/_utils.ts b/tests/_utils.ts index 707679b..32dd29b 100644 --- a/tests/_utils.ts +++ b/tests/_utils.ts @@ -8,7 +8,7 @@ export function formatTree( ) { result.push( // prettier-ignore - `${prefix}${depth === 0 ? "" : "├── "}${node.key ? `/${node.key}` : (depth === 0 ? "" : "")}${node.data === undefined ? "" : ` ┈> [${node.data.path}]`}`, + `${prefix}${depth === 0 ? "" : "├── "}${node.key ? `/${node.key}` : (depth === 0 ? "" : "")}${node.data === undefined ? "" : ` ┈> [${node.data.path || JSON.stringify(node.data)}]`}`, ); const childrenArray = [ diff --git a/tests/matcher.test.ts b/tests/matcher.test.ts index 7ced437..0d5cde9 100644 --- a/tests/matcher.test.ts +++ b/tests/matcher.test.ts @@ -1,29 +1,40 @@ -import { describe, it, expect } from "vitest"; -import { createRouter, exportMatcher, toRouteMatcher } from "../src"; +import { describe, it, expect, beforeAll } from "vitest"; +import { createRouter } from "../src"; +import { formatTree } from "./_utils"; export function createRoutes(paths) { - return Object.fromEntries(paths.map((path) => [path, { pattern: path }])); + return Object.fromEntries(paths.map((path) => [path, { path }])); } -describe("Route matcher", function () { - it("readme example works", () => { - const router = createRouter({ - routes: { - "/foo": { m: "foo" }, - "/foo/**": { m: "foo/**", order: "2" }, - "/foo/bar": { m: "foo/bar" }, - "/foo/bar/baz": { m: "foo/bar/baz", order: "4" }, - "/foo/*/baz": { m: "foo/*/baz", order: "3" }, - "/**": { order: "1" }, - }, - }); +it("readme example works", () => { + const router = createRouter({ + routes: { + "/foo": { m: "foo" }, + "/foo/**": { m: "foo/**", order: "2" }, + "/foo/bar": { m: "foo/bar" }, + "/foo/bar/baz": { m: "foo/bar/baz", order: "4" }, + "/foo/*/baz": { m: "foo/*/baz", order: "3" }, + "/**": { m: "/**", order: "1" }, + }, + }); + + expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /foo ┈> [{"m":"foo"}] + │ ├── /bar ┈> [{"m":"foo/bar"}] + │ │ ├── /baz ┈> [{"m":"foo/bar/baz","order":"4"}] + │ ├── /* + │ │ ├── /baz ┈> [{"m":"foo/*/baz","order":"3"}] + │ ├── /** ┈> [{"m":"foo/**","order":"2"}] + ├── /** ┈> [{"m":"/**","order":"1"}]" + `); - const matcher = toRouteMatcher(router); - const matches = matcher.matchAll("/foo/bar/baz"); + const matches = router.matchAll("/foo/bar/baz"); - expect(matches).to.toMatchInlineSnapshot(` + expect(matches).to.toMatchInlineSnapshot(` [ { + "m": "/**", "order": "1", }, { @@ -40,8 +51,9 @@ describe("Route matcher", function () { }, ] `); - }); +}); +describe("route matcher", () => { const routes = createRoutes([ "/", "/foo", @@ -58,195 +70,137 @@ describe("Route matcher", function () { ]); const router = createRouter({ routes }); - const matcher = toRouteMatcher(router); - - const _match = (path) => matcher.matchAll(path).map((r) => r.pattern); - it("can create route table", () => { - expect(matcher.ctx.table).to.toMatchInlineSnapshot(` - { - "dynamic": Map { - "/foo" => { - "dynamic": Map {}, - "static": Map { - "/sub" => { - "pattern": "/foo/*/sub", - }, - "/" => { - "pattern": "/foo/*", - }, - }, - "wildcard": Map {}, - }, - }, - "static": Map { - "/" => { - "pattern": "/", - }, - "/foo" => { - "pattern": "/foo", - }, - "/foo/bar" => { - "pattern": "/foo/bar", - }, - "/foo/baz" => { - "pattern": "/foo/baz", - }, - "/without-trailing" => { - "pattern": "/without-trailing", - }, - "/with-trailing" => { - "pattern": "/with-trailing/", - }, - "/cart" => { - "pattern": "/cart", - }, - }, - "wildcard": Map { - "/foo" => { - "pattern": "/foo/**", - }, - "/foo/baz" => { - "pattern": "/foo/baz/**", - }, - "/c" => { - "pattern": "/c/**", - }, - }, - } + it("snapshot", () => { + expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + " ┈> [/] + ├── /foo ┈> [/foo] + │ ├── /bar ┈> [/foo/bar] + │ ├── /baz ┈> [/foo/baz] + │ │ ├── /** ┈> [/foo/baz/**] + │ ├── /* ┈> [/foo/*] + │ │ ├── /sub ┈> [/foo/*/sub] + │ ├── /** ┈> [/foo/**] + ├── /without-trailing ┈> [/without-trailing] + ├── /with-trailing ┈> [/with-trailing/] + ├── /c + │ ├── /** ┈> [/c/**] + ├── /cart ┈> [/cart]" `); }); it("can match routes", () => { - expect(_match("/")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/")).to.toMatchInlineSnapshot(` [ - "/", + { + "path": "/", + }, ] `); - expect(_match("/foo")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/foo")).to.toMatchInlineSnapshot(` [ - "/foo/**", - "/foo", + { + "path": "/foo/**", + }, + { + "path": "/foo", + }, ] `); - expect(_match("/foo/bar")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/foo/bar")).to.toMatchInlineSnapshot(` [ - "/foo/**", - "/foo/*", - "/foo/bar", + { + "path": "/foo/**", + }, + { + "path": "/foo/*", + }, + { + "path": "/foo/bar", + }, ] `); - expect(_match("/foo/baz")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/foo/baz")).to.toMatchInlineSnapshot(` [ - "/foo/**", - "/foo/baz/**", - "/foo/*", - "/foo/baz", + { + "path": "/foo/**", + }, + { + "path": "/foo/*", + }, + { + "path": "/foo/baz/**", + }, + { + "path": "/foo/baz", + }, ] `); - expect(_match("/foo/123/sub")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/foo/123/sub")).to.toMatchInlineSnapshot(` [ - "/foo/**", - "/foo/*/sub", + { + "path": "/foo/**", + }, + { + "path": "/foo/*/sub", + }, ] `); - expect(_match("/foo/123")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/foo/123")).to.toMatchInlineSnapshot(` [ - "/foo/**", - "/foo/*", + { + "path": "/foo/**", + }, + { + "path": "/foo/*", + }, ] `); }); it("trailing slash", () => { // Defined with trailing slash - expect(_match("/with-trailing")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/with-trailing")).to.toMatchInlineSnapshot(` [ - "/with-trailing/", + { + "path": "/with-trailing/", + }, ] `); - expect(_match("/with-trailing")).toMatchObject(_match("/with-trailing/")); + expect(router.matchAll("/with-trailing")).toMatchObject( + router.matchAll("/with-trailing/"), + ); // Defined without trailing slash - expect(_match("/without-trailing")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/without-trailing")).to.toMatchInlineSnapshot(` [ - "/without-trailing", + { + "path": "/without-trailing", + }, ] `); - expect(_match("/without-trailing")).toMatchObject( - _match("/without-trailing/"), + expect(router.matchAll("/without-trailing")).toMatchObject( + router.matchAll("/without-trailing/"), ); }); it("prefix overlap", () => { - expect(_match("/c/123")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/c/123")).to.toMatchInlineSnapshot(` [ - "/c/**", + { + "path": "/c/**", + }, ] `); - expect(_match("/c/123")).toMatchObject(_match("/c/123/")); - expect(_match("/c/123")).toMatchObject(_match("/c")); + expect(router.matchAll("/c/123")).toMatchObject(router.matchAll("/c/123/")); + expect(router.matchAll("/c/123")).toMatchObject(router.matchAll("/c")); - expect(_match("/cart")).to.toMatchInlineSnapshot(` + expect(router.matchAll("/cart")).to.toMatchInlineSnapshot(` [ - "/cart", + { + "path": "/cart", + }, ] `); }); - - it("can be exported", () => { - const jsonData = exportMatcher(matcher); - expect(jsonData).toMatchInlineSnapshot(` - { - "dynamic": { - "/foo": { - "dynamic": {}, - "static": { - "/": { - "pattern": "/foo/*", - }, - "/sub": { - "pattern": "/foo/*/sub", - }, - }, - "wildcard": {}, - }, - }, - "static": { - "/": { - "pattern": "/", - }, - "/cart": { - "pattern": "/cart", - }, - "/foo": { - "pattern": "/foo", - }, - "/foo/bar": { - "pattern": "/foo/bar", - }, - "/foo/baz": { - "pattern": "/foo/baz", - }, - "/with-trailing": { - "pattern": "/with-trailing/", - }, - "/without-trailing": { - "pattern": "/without-trailing", - }, - }, - "wildcard": { - "/c": { - "pattern": "/c/**", - }, - "/foo": { - "pattern": "/foo/**", - }, - "/foo/baz": { - "pattern": "/foo/baz/**", - }, - }, - } - `); - }); }); From 5b7b1df0cd187742b888495f2af3bdf8cffd3061 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 02:24:29 +0200 Subject: [PATCH 03/15] add bench + use explicit data+params in lookup res --- benchmark/direct.mjs | 5 +-- benchmark/utils.mjs | 56 +++++++++++++++------------- package.json | 5 ++- pnpm-lock.yaml | 3 ++ src/router.ts | 33 ++++++++-------- src/types.ts | 14 +++---- tests/basic.test.ts | 54 +++++++++++++++++++++------ tests/matcher.test.ts | 2 +- tests/router.test.ts | 87 ++++++++++++++++++++++++++----------------- 9 files changed, 159 insertions(+), 100 deletions(-) diff --git a/benchmark/direct.mjs b/benchmark/direct.mjs index c5ec3c5..6786fb7 100644 --- a/benchmark/direct.mjs +++ b/benchmark/direct.mjs @@ -1,10 +1,9 @@ - + import Benchmark from "benchmark"; // https://www.npmjs.com/package/benchmark' import { printEnv, benchSets, printStats, - router, logSection, } from "./utils.mjs"; @@ -17,7 +16,7 @@ async function main() { const stats = {}; suite.add("lookup", () => { for (const req of bench.requests) { - const match = router.lookup(req.path); + const match = bench.router.lookup(req.path); if (!match) { stats[match] = (stats[match] || 0) + 1; } diff --git a/benchmark/utils.mjs b/benchmark/utils.mjs index e5f5fe5..5a0a5d6 100644 --- a/benchmark/utils.mjs +++ b/benchmark/utils.mjs @@ -1,7 +1,8 @@ - + import { readFileSync } from "node:fs"; import os from "node:os"; -import { createRouter } from "radix3"; +import { createRouter } from "../dist/index.mjs"; +import { createRouter as createRouterV1 } from "radix3-v1"; export const logSection = (title) => { console.log(`\n--- ${title} ---\n`); @@ -24,36 +25,41 @@ export function printEnv() { export function printStats(stats) { console.log( "Stats:\n" + - Object.entries(stats) - .map(([path, hits]) => ` - ${path}: ${hits}`) - .join("\n"), + Object.entries(stats) + .map(([path, hits]) => ` - ${path}: ${hits}`) + .join("\n"), ); } -export const router = createRouter({ - routes: Object.fromEntries( - [ - "/hello", - "/cool", - "/hi", - "/helium", - "/coooool", - "/chrome", - "/choot", - "/choot/:choo", - "/ui/**", - "/ui/components/**", - ].map((path) => [path, { path }]), - ), -}); +const routes = Object.fromEntries( + [ + "/hello", + "/cool", + "/hi", + "/helium", + "/coooool", + "/chrome", + "/choot", + "/choot/:choo", + "/ui/**", + "/ui/components/**", + ].map((path) => [path, { path }]) +); + +const requests = [ + // { path: "/hi" }, + { path: "/choot/123" } +] export const benchSets = [ { - title: "static route", - requests: [{ path: "/choot" }], + title: "v2", + router: createRouter({ routes }), + requests, }, { - title: "dynamic route", - requests: [{ path: "/choot/123" }], + title: "v1", + router: createRouterV1({ routes }), + requests, }, ]; diff --git a/package.json b/package.json index f02f953..7908b6d 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "standard-version": "^9.5.0", "typescript": "^5.5.3", "unbuild": "^2.0.0", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "radix3-v1": "npm:radix3@1.1.2" }, "packageManager": "pnpm@9.4.0" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2b1095..c7c8291 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: prettier: specifier: ^3.3.2 version: 3.3.2 + radix3-v1: + specifier: npm:radix3@1.1.2 + version: radix3@1.1.2 standard-version: specifier: ^9.5.0 version: 9.5.0 diff --git a/src/router.ts b/src/router.ts index 0ae1d93..6185957 100644 --- a/src/router.ts +++ b/src/router.ts @@ -112,21 +112,18 @@ function lookup( ): MatchedRoute | undefined { const segments = _splitPath(path); const matchedNode = _lookup(ctx, ctx.root, segments, 0); - if (!matchedNode) { + if (!matchedNode || matchedNode.data === undefined) { return; } const data = matchedNode.data; if (!matchedNode.paramNames && matchedNode.key !== "**") { - return data; + return { data }; } const params = _getParams(segments, matchedNode); - if (matchedNode.key === "**") { - const paramName = - (matchedNode.paramNames?.[matchedNode.paramNames.length - 1] - .name as string) || "_"; - params[paramName] = segments.slice(matchedNode.index).join("/"); - } - return { ...data, params }; + return { + data, + params, + }; } function _lookup( @@ -218,6 +215,8 @@ function _remove(node: RadixNode, segments: string[], index: number): boolean { return false; } node.data = undefined; + node.index = undefined; + node.paramNames = undefined; return !( node.staticChildren?.size || node.paramChild || @@ -285,27 +284,31 @@ function _getParamMatcher(segment: string): string | RegExp { /:(\w+)/g, (_, id) => `(?<${id}>\\w+)`, ); - console.log(segment); return new RegExp(`^${sectionRegexString}$`); } function _getParams( segments: string[], node: RadixNode, -): Record { - const res = Object.create(null); +): MatchedRoute["params"] { + const params = Object.create(null); for (const param of node.paramNames || []) { const segment = segments[param.index]; if (typeof param.name === "string") { - res[param.name] = segment; + params[param.name] = segment; } else { const match = segment.match(param.name); if (match) { for (const key in match.groups) { - res[key] = match.groups[key]; + params[key] = match.groups[key]; } } } } - return res; + if (node.key === "**") { + const paramName = + (node.paramNames?.[node.paramNames.length - 1].name as string) || "_"; + params[paramName] = segments.slice(node.index).join("/"); + } + return params; } diff --git a/src/types.ts b/src/types.ts index 17e4c45..3fe64fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,14 +7,12 @@ export const NODE_TYPES = { type _NODE_TYPES = typeof NODE_TYPES; export type NODE_TYPE = _NODE_TYPES[keyof _NODE_TYPES]; -type _RadixNodeDataObject = { params?: never; [key: string]: any }; -export type RadixNodeData< - T extends _RadixNodeDataObject = _RadixNodeDataObject, -> = T; -export type MatchedRoute = Omit< - T, - "params" -> & { params?: Record }; +export type RadixNodeData> = T; + +export type MatchedRoute = { + data: T; + params?: Record; +}; export interface RadixNode { key: string; diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 03e76b1..89d2c44 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -22,35 +22,54 @@ describe("Basic router", () => { router.insert(path, { path }); } - expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(`""`); + expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /test ┈> [/test] + │ ├── /foo ┈> [/test/foo] + │ │ ├── /bar + │ │ │ ├── /qux ┈> [/test/foo/bar/qux] + │ │ ├── /baz ┈> [/test/foo/baz] + │ │ ├── /* ┈> [/test/foo/*] + │ │ ├── /** ┈> [/test/foo/**] + │ ├── /fooo ┈> [/test/fooo] + │ ├── /* ┈> [/test/:id] + │ │ ├── /y ┈> [/test/:idY/y] + │ │ │ ├── /z ┈> [/test/:idYZ/y/z] + ├── /another + │ ├── /path ┈> [/another/path]" + `); }); it("lookup works", () => { // Static - expect(router.lookup("/test")).toEqual({ path: "/test" }); - expect(router.lookup("/test/foo")).toEqual({ path: "/test/foo" }); - expect(router.lookup("/test/fooo")).toEqual({ path: "/test/fooo" }); - expect(router.lookup("/another/path")).toEqual({ path: "/another/path" }); + expect(router.lookup("/test")).toEqual({ data: { path: "/test" } }); + expect(router.lookup("/test/foo")).toEqual({ data: { path: "/test/foo" } }); + expect(router.lookup("/test/fooo")).toEqual({ + data: { path: "/test/fooo" }, + }); + expect(router.lookup("/another/path")).toEqual({ + data: { path: "/another/path" }, + }); // Param expect(router.lookup("/test/123")).toEqual({ - path: "/test/:id", + data: { path: "/test/:id" }, params: { id: "123" }, }); expect(router.lookup("/test/123/y")).toEqual({ - path: "/test/:idY/y", + data: { path: "/test/:idY/y" }, params: { idY: "123" }, }); expect(router.lookup("/test/123/y/z")).toEqual({ - path: "/test/:idYZ/y/z", + data: { path: "/test/:idYZ/y/z" }, params: { idYZ: "123" }, }); expect(router.lookup("/test/foo/123")).toEqual({ - path: "/test/foo/*", + data: { path: "/test/foo/*" }, params: { _0: "123" }, }); // Wildcard expect(router.lookup("/test/foo/123/456")).toEqual({ - path: "/test/foo/**", + data: { path: "/test/foo/**" }, params: { _: "123/456" }, }); }); @@ -60,7 +79,20 @@ describe("Basic router", () => { router.remove("/test/*"); router.remove("/test/foo/*"); router.remove("/test/foo/**"); - expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(`""`); + expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + " + ├── /test + │ ├── /foo ┈> [/test/foo] + │ │ ├── /bar + │ │ │ ├── /qux ┈> [/test/foo/bar/qux] + │ │ ├── /baz ┈> [/test/foo/baz] + │ ├── /fooo ┈> [/test/fooo] + │ ├── /* + │ │ ├── /y ┈> [/test/:idY/y] + │ │ │ ├── /z ┈> [/test/:idYZ/y/z] + ├── /another + │ ├── /path ┈> [/another/path]" + `); expect(router.lookup("/test")).toBeUndefined(); }); }); diff --git a/tests/matcher.test.ts b/tests/matcher.test.ts index 0d5cde9..7efaf3e 100644 --- a/tests/matcher.test.ts +++ b/tests/matcher.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect } from "vitest"; import { createRouter } from "../src"; import { formatTree } from "./_utils"; diff --git a/tests/router.test.ts b/tests/router.test.ts index 87b9c24..74d07b0 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -3,14 +3,14 @@ import { createRouter, RadixRouter } from "../src"; import { formatTree } from "./_utils"; type TestRoute = { - path: string; + data: { path: string }; params?: Record; skip?: boolean; }; type TestRoutes = Record; -export function createTestRoutes(paths: string[]): TestRoutes { +export function createTestRoutes(paths: string[]): Record { return Object.fromEntries(paths.map((path) => [path, { path }])); } @@ -23,7 +23,14 @@ function testRouter( const router = createRouter({ routes }); if (!tests) { - tests = routes; + tests = Object.fromEntries( + paths.map((path) => [ + path, + { + data: { path }, + }, + ]), + ); } if (before) { @@ -36,7 +43,7 @@ function testRouter( it.skipIf(tests[path]?.skip)( `lookup ${path} should be ${JSON.stringify(tests[path])}`, () => { - expect(router.lookup(path)).to.deep.equal(tests[path]); + expect(router.lookup(path)).to.toMatchObject(tests[path]!); }, ); } @@ -82,7 +89,7 @@ describe("Router lookup", function () { `), { "carbon/test1": { - path: "carbon/:element", + data: { path: "carbon/:element" }, params: { element: "test1", }, @@ -90,14 +97,14 @@ describe("Router lookup", function () { "/carbon": undefined, "carbon/": undefined, "carbon/test2/test/test23": { - path: "carbon/:element/test/:testing", + data: { path: "carbon/:element/test/:testing" }, params: { element: "test2", testing: "test23", }, }, "this/test/has/more/stuff": { - path: "this/:route/has/:cool/stuff", + data: { path: "this/:route/has/:cool/stuff" }, params: { route: "test", cool: "more", @@ -119,22 +126,22 @@ describe("Router lookup", function () { `, ), { - "/": { path: "/" }, + "/": { data: { path: "/" } }, "/a": { - path: "/:a", + data: { path: "/:a" }, params: { a: "a", }, }, "/a/b": { - path: "/:a/:b", + data: { path: "/:a/:b" }, params: { a: "a", b: "b", }, }, "/a/x/b": { - path: "/:a/:x/:b", + data: { path: "/:a/:x/:b" }, params: { a: "a", b: "b", @@ -142,7 +149,7 @@ describe("Router lookup", function () { }, }, "/a/y/x/b": { - path: "/:a/:y/:x/:b", + data: { path: "/:a/:y/:x/:b" }, params: { a: "a", b: "b", @@ -173,7 +180,7 @@ describe("Router lookup", function () { ), { "/tinylibs/tinybench/tiny@232": { - path: "/:owner/:repo/:packageAndRefOrSha", + data: { path: "/:owner/:repo/:packageAndRefOrSha" }, params: { owner: "tinylibs", repo: "tinybench", @@ -181,7 +188,7 @@ describe("Router lookup", function () { }, }, "/tinylibs/tinybench/@tinylibs/tiny@232": { - path: "/:owner/:repo/:npmOrg/:packageAndRefOrSha", + data: { path: "/:owner/:repo/:npmOrg/:packageAndRefOrSha" }, params: { owner: "tinylibs", repo: "tinybench", @@ -209,14 +216,17 @@ describe("Router lookup", function () { │ │ │ ├── /** ┈> [route/:p1/something/**:rest]" `), { - "polymer/another/route": { path: "polymer/another/route" }, - "polymer/anon": { path: "polymer/**:id", params: { id: "anon" } }, + "polymer/another/route": { data: { path: "polymer/another/route" } }, + "polymer/anon": { + data: { path: "polymer/**:id" }, + params: { id: "anon" }, + }, "polymer/foo/bar/baz": { - path: "polymer/**:id", + data: { path: "polymer/**:id" }, params: { id: "foo/bar/baz" }, }, "route/param1/something/c/d": { - path: "route/:p1/something/**:rest", + data: { path: "route/:p1/something/**:rest" }, params: { p1: "param1", rest: "c/d" }, }, }, @@ -235,13 +245,16 @@ describe("Router lookup", function () { │ ├── /** ┈> [polymer/**]" `), { - "polymer/foo/bar": { path: "polymer/**", params: { _: "foo/bar" } }, + "polymer/foo/bar": { + data: { path: "polymer/**" }, + params: { _: "foo/bar" }, + }, "polymer/route/anon": { - path: "polymer/route/*", + data: { path: "polymer/route/*" }, params: { _0: "anon" }, }, "polymer/constructor": { - path: "polymer/**", + data: { path: "polymer/**" }, params: { _: "constructor" }, }, }, @@ -261,7 +274,7 @@ describe("Router lookup", function () { `), { "/files/test/123,name=foobar.txt": { - path: mixedPath, + data: { path: mixedPath }, params: { category: "test", id: "123", name: "foobar" }, }, }, @@ -284,13 +297,17 @@ describe("Router lookup", function () { `), { "route/without/trailing/slash": { - path: "route/without/trailing/slash", + data: { path: "route/without/trailing/slash" }, + }, + "route/with/trailing/slash/": { + data: { path: "route/with/trailing/slash/" }, }, - "route/with/trailing/slash/": { path: "route/with/trailing/slash/" }, "route/without/trailing/slash/": { - path: "route/without/trailing/slash", + data: { path: "route/without/trailing/slash" }, + }, + "route/with/trailing/slash": { + data: { path: "route/with/trailing/slash/" }, }, - "route/with/trailing/slash": { path: "route/with/trailing/slash/" }, }, ); }); @@ -317,7 +334,7 @@ describe("Router insert", () => { "/api/v3", ]), }); - router.insert("/api/v3", { path: "/api/v3", overridden: true }); + router.insert("/api/v3", { data: { path: "/api/v3" }, overridden: true }); expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` " @@ -337,7 +354,7 @@ describe("Router insert", () => { ├── /api │ ├── /v1 ┈> [/api/v1] │ ├── /v2 ┈> [/api/v2] - │ ├── /v3 ┈> [/api/v3]" + │ ├── /v3 ┈> [{"data":{"path":"/api/v3"},"overridden":true}]" `); }); }); @@ -363,13 +380,13 @@ describe("Router remove", function () { expect(router.lookup("choot")).to.deep.equal(undefined); expect(router.lookup("ui/components/snackbars")).to.deep.equal({ - path: "ui/components/**", + data: { path: "ui/components/**" }, params: { _: "snackbars" }, }); router.remove("ui/components/**"); expect(router.lookup("ui/components/snackbars")).to.deep.equal({ - path: "ui/**", + data: { path: "ui/**" }, params: { _: "components/snackbars" }, }); }); @@ -383,7 +400,7 @@ describe("Router remove", function () { expect(router.lookup("a/b")).to.deep.equal(undefined); expect(router.lookup("a/b/c")).to.deep.equal({ params: { param1: "c" }, - path: "a/b/:param1", + data: { path: "a/b/:param1" }, }); }); @@ -396,7 +413,7 @@ describe("Router remove", function () { }); expect(router.lookup("placeholder/route")).to.deep.equal({ - path: "placeholder/:choo", + data: { path: "placeholder/:choo" }, params: { choo: "route", }, @@ -407,7 +424,7 @@ describe("Router remove", function () { // expect(router.lookup("placeholder/route")).to.deep.equal(undefined); expect(router.lookup("placeholder/route/route2")).to.deep.equal({ - path: "placeholder/:choo/:choo2", + data: { path: "placeholder/:choo/:choo2" }, params: { choo: "route", choo2: "route2", @@ -421,12 +438,12 @@ describe("Router remove", function () { }); expect(router.lookup("ui/components/snackbars")).to.deep.equal({ - path: "ui/components/**", + data: { path: "ui/components/**" }, params: { _: "snackbars" }, }); router.remove("ui/components/**"); expect(router.lookup("ui/components/snackbars")).to.deep.equal({ - path: "ui/**", + data: { path: "ui/**" }, params: { _: "components/snackbars" }, }); }); From 9869af89d390ded35e7082c377371e5df32f4125 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 02:32:54 +0200 Subject: [PATCH 04/15] perf: add back staticRoutesMap --- benchmark/utils.mjs | 2 +- src/router.ts | 11 ++++++++++- src/types.ts | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/benchmark/utils.mjs b/benchmark/utils.mjs index 5a0a5d6..8903d66 100644 --- a/benchmark/utils.mjs +++ b/benchmark/utils.mjs @@ -47,7 +47,7 @@ const routes = Object.fromEntries( ); const requests = [ - // { path: "/hi" }, + { path: "/hi" }, { path: "/choot/123" } ] diff --git a/src/router.ts b/src/router.ts index 6185957..766db33 100644 --- a/src/router.ts +++ b/src/router.ts @@ -13,7 +13,7 @@ export function createRouter( const ctx: RadixRouterContext = { options, root: { key: "" }, - staticRoutesMap: {}, + staticRoutesMap: new Map(), }; const normalizeTrailingSlash = (p: string) => @@ -100,7 +100,11 @@ function insert(ctx: RadixRouterContext, path: string, data: any) { node.index = segments.length - 1; node.data = data; if (nodeParams.length > 0) { + // Dynamic route node.paramNames = nodeParams; + } else { + // Static route + ctx.staticRoutesMap.set(path, node); } } @@ -110,6 +114,11 @@ function lookup( ctx: RadixRouterContext, path: string, ): MatchedRoute | undefined { + const staticMatch = ctx.staticRoutesMap.get(path); + if (staticMatch) { + return { data: staticMatch.data }; + } + const segments = _splitPath(path); const matchedNode = _lookup(ctx, ctx.root, segments, 0); if (!matchedNode || matchedNode.data === undefined) { diff --git a/src/types.ts b/src/types.ts index 3fe64fe..19a6520 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,7 +10,7 @@ export type NODE_TYPE = _NODE_TYPES[keyof _NODE_TYPES]; export type RadixNodeData> = T; export type MatchedRoute = { - data: T; + data?: T; params?: Record; }; @@ -34,7 +34,7 @@ export interface RadixRouterOptions { export interface RadixRouterContext { options: RadixRouterOptions; root: RadixNode; - staticRoutesMap: Record; + staticRoutesMap: Map; } export interface RadixRouter { From 66163e1d4604081964bfd5d10980c973bae3ffd7 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 02:37:46 +0200 Subject: [PATCH 05/15] fix condition --- src/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router.ts b/src/router.ts index 766db33..2eab1f9 100644 --- a/src/router.ts +++ b/src/router.ts @@ -115,7 +115,7 @@ function lookup( path: string, ): MatchedRoute | undefined { const staticMatch = ctx.staticRoutesMap.get(path); - if (staticMatch) { + if (staticMatch && staticMatch.data !== undefined) { return { data: staticMatch.data }; } From f9a598a50472a13810cac77d748d9ccf3edc8e49 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 14:16:22 +0200 Subject: [PATCH 06/15] update benchmark script --- benchmark/README.md | 104 ----------------- benchmark/bench.mjs | 70 ++++++++++++ benchmark/direct.mjs | 39 ------- benchmark/http.mjs | 53 --------- benchmark/input.mjs | 59 ++++++++++ benchmark/routers.mjs | 152 +++++++++++++++++++++++++ benchmark/utils.mjs | 65 ----------- package.json | 10 +- pnpm-lock.yaml | 258 ++++++++++++++++++++++++++++++++++++++++-- 9 files changed, 537 insertions(+), 273 deletions(-) delete mode 100644 benchmark/README.md create mode 100644 benchmark/bench.mjs delete mode 100644 benchmark/direct.mjs delete mode 100644 benchmark/http.mjs create mode 100644 benchmark/input.mjs create mode 100644 benchmark/routers.mjs delete mode 100644 benchmark/utils.mjs diff --git a/benchmark/README.md b/benchmark/README.md deleted file mode 100644 index e6100dd..0000000 --- a/benchmark/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# Benchmark Results - -Benchmarks are mainly focusing on benchmarking `lookup` method performance. - -Below results are based on my personal PC using WSL2. You can use provided scripts to test in your own env. - -## Direct benchmark - -Directly benchmarking `lookup` performance using [benchmark](https://www.npmjs.com/package/benchmark) - -Scripts: - -- `pnpm bench` -- `pnpm bench:profile` (using [0x](https://www.npmjs.com/package/0x) to generate flamegraph) - -``` ---- Test environment --- - -Node.js version: 14.18.1 -Radix3 version: 0.1.0 -OS: linux -CPU count: 16 -Current load: [ 0.07, 0.09, 0.16 ] - - ---- static route --- - -lookup x 117,219,957 ops/sec ±0.29% (96 runs sampled) -Stats: - - /choot: 609847174 - ---- dynamic route --- - -lookup x 1,365,609 ops/sec ±0.64% (88 runs sampled) -Stats: - - /choot/123: 7074324 -``` - -## HTTP Benchmark - -Using [`autocannon`](https://github.com/mcollina/autocannon) and a simple http listener using lookup for realworld performance. - -Scripts: - -- `pnpm bench:http` - -``` ---- Test environment --- - -Node.js version: 14.18.1 -Radix3 version: 0.1.0 -OS: linux -CPU count: 16 -Current load: [ 0.43, 0.19, 0.19 ] - - ---- Benchmark: static route --- - -Running 10s test @ http://localhost:3000/ -10 connections - -┌─────────┬──────┬──────┬───────┬──────┬─────────┬────────┬───────┐ -│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ -├─────────┼──────┼──────┼───────┼──────┼─────────┼────────┼───────┤ -│ Latency │ 0 ms │ 0 ms │ 0 ms │ 0 ms │ 0.01 ms │ 0.1 ms │ 10 ms │ -└─────────┴──────┴──────┴───────┴──────┴─────────┴────────┴───────┘ -┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬─────────┬─────────┐ -│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ -├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼─────────┼─────────┤ -│ Req/Sec │ 20783 │ 20783 │ 28191 │ 28335 │ 27356.37 │ 2105.53 │ 20780 │ -├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼─────────┼─────────┤ -│ Bytes/Sec │ 2.91 MB │ 2.91 MB │ 3.95 MB │ 3.96 MB │ 3.83 MB │ 295 kB │ 2.91 MB │ -└───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴─────────┴─────────┘ - -Req/Bytes counts sampled once per second. - -301k requests in 11.01s, 42.1 MB read -Stats: - - /choot: 300910 - ---- Benchmark: dynamic route --- - -Running 10s test @ http://localhost:3000/ -10 connections - -┌─────────┬──────┬──────┬───────┬──────┬─────────┬─────────┬──────┐ -│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ -├─────────┼──────┼──────┼───────┼──────┼─────────┼─────────┼──────┤ -│ Latency │ 0 ms │ 0 ms │ 0 ms │ 1 ms │ 0.02 ms │ 0.12 ms │ 3 ms │ -└─────────┴──────┴──────┴───────┴──────┴─────────┴─────────┴──────┘ -┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬────────┬─────────┐ -│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ -├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼────────┼─────────┤ -│ Req/Sec │ 19023 │ 19023 │ 23311 │ 23439 │ 22883.64 │ 1237.9 │ 19010 │ -├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼────────┼─────────┤ -│ Bytes/Sec │ 3.23 MB │ 3.23 MB │ 3.96 MB │ 3.98 MB │ 3.89 MB │ 211 kB │ 3.23 MB │ -└───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴────────┴─────────┘ - -Req/Bytes counts sampled once per second. - -252k requests in 11s, 42.8 MB read -Stats: - - /choot/123: 251690 -``` diff --git a/benchmark/bench.mjs b/benchmark/bench.mjs new file mode 100644 index 0000000..a78474d --- /dev/null +++ b/benchmark/bench.mjs @@ -0,0 +1,70 @@ +import { bench, group, run } from 'mitata' +import { routers } from './routers.mjs' +import { requests, routes } from './input.mjs' + +// Test all routers +let testsFailed = false +for (const name in routers) { + let routerHasIssues = false + console.log(`\n🧪 Validating ${name}`) + const router = new routers[name](routes) + router.init() + for (const request of requests) { + const title = `${request.name} ([${request.method}] ${request.path})` + const match = router.match(request) + if (!match) { + console.error(`❌ No match for ${title}`) + routerHasIssues = true + testsFailed = true + continue + } + if (typeof match.handler !== 'function') { + console.error(`❌ No handler for ${title}`) + routerHasIssues = true + testsFailed = true + continue + } + if (request.params && JSON.stringify(match.params) !== JSON.stringify(request.params)) { + console.error(`❌ Invalid params for ${title}. Expected %s Got %s`, request.params, match.params) + routerHasIssues = true + testsFailed = true + continue + } + + } + if (!routerHasIssues) { + console.log(`\r✅ Validated ${name}`) + } +} +if (testsFailed) { + console.error('❌ Some routers failed validation') + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1) +} + +// Benchmark all routers +group('All requests (with params)', () => { + for (const name in routers) { + const router = new routers[name](routes, true /* params */) + router.init() + bench(name, () => { + for (const request of requests) { + router.match(request) + } + }) + } +}) + +group('All requests (without params)', () => { + for (const name in routers) { + const router = new routers[name](routes, false /* params */) + router.init() + bench(name, () => { + for (const request of requests) { + router.match(request) + } + }) + } +}) + +run({}) diff --git a/benchmark/direct.mjs b/benchmark/direct.mjs deleted file mode 100644 index 6786fb7..0000000 --- a/benchmark/direct.mjs +++ /dev/null @@ -1,39 +0,0 @@ - -import Benchmark from "benchmark"; // https://www.npmjs.com/package/benchmark' -import { - printEnv, - benchSets, - printStats, - logSection, -} from "./utils.mjs"; - -async function main() { - printEnv(); - - for (const bench of benchSets) { - logSection(bench.title); - const suite = new Benchmark.Suite(); - const stats = {}; - suite.add("lookup", () => { - for (const req of bench.requests) { - const match = bench.router.lookup(req.path); - if (!match) { - stats[match] = (stats[match] || 0) + 1; - } - stats[req.path] = (stats[req.path] || 0) + 1; - } - }); - suite.on("cycle", (event) => { - console.log(String(event.target)); - }); - const promise = new Promise((resolve) => - suite.on("complete", () => resolve()), - ); - suite.run({ async: true }); - await promise; - printStats(stats); - } -} - -// eslint-disable-next-line unicorn/prefer-top-level-await -main().catch(console.error); diff --git a/benchmark/http.mjs b/benchmark/http.mjs deleted file mode 100644 index 477baa7..0000000 --- a/benchmark/http.mjs +++ /dev/null @@ -1,53 +0,0 @@ - - -import autocannon from "autocannon"; // https://github.com/mcollina/autocannon -import { listen } from "listhen"; -import { - printEnv, - benchSets, - printStats, - logSection, - router, -} from "./utils.mjs"; - -async function main() { - printEnv(); - - for (const bench of benchSets) { - logSection(`Benchmark: ${bench.title}`); - const { listener, stats } = await createServer(); - const instance = autocannon({ - url: listener.url, - requests: bench.requests, - }); - autocannon.track(instance); - process.once("SIGINT", () => { - instance.stop(); - listener.close(); - process.exit(1); - }); - await instance; // Resolves to details results - printStats(stats); - await listener.close(); - } -} - -// eslint-disable-next-line unicorn/prefer-top-level-await -main().catch(console.error); - -async function createServer() { - const stats = {}; - const listener = await listen( - (req, res) => { - stats[req.url] = (stats[req.url] || 0) + 1; - const match = router.lookup(req.url); - if (!match) { - stats[match] = (stats[match] || 0) + 1; - } - res.end(JSON.stringify(match || { error: 404 })); - }, - { showURL: false }, - ); - - return { listener, stats }; -} diff --git a/benchmark/input.mjs b/benchmark/input.mjs new file mode 100644 index 0000000..c967b8f --- /dev/null +++ b/benchmark/input.mjs @@ -0,0 +1,59 @@ +// Inputs are based on hono router benchmarks + +export const routes = [ + { method: "GET", path: "/user" }, + { method: "GET", path: "/user/comments" }, + { method: "GET", path: "/user/avatar" }, + { method: "GET", path: "/user/lookup/username/:username" }, + { method: "GET", path: "/user/lookup/email/:address" }, + { method: "GET", path: "/event/:id" }, + { method: "GET", path: "/event/:id/comments" }, + { method: "POST", path: "/event/:id/comment" }, + { method: "GET", path: "/map/:location/events" }, + { method: "GET", path: "/status" }, + { method: "GET", path: "/very/deeply/nested/route/hello/there" }, + { method: "GET", path: "/static/:path" }, +] + +export const requests = [ + { + name: 'short static', + method: 'GET', + path: '/user', + }, + { + name: 'static with same radix', + method: 'GET', + path: '/user/comments', + }, + { + name: 'dynamic route', + method: 'GET', + path: '/user/lookup/username/hey', + params: { username: 'hey' } + }, + { + name: 'mixed static dynamic', + method: 'GET', + path: '/event/abcd1234/comments', + params: { id: 'abcd1234' } + }, + { + name: 'post', + method: 'POST', + path: '/event/abcd1234/comment', + params: { id: 'abcd1234' } + }, + { + name: 'long static', + method: 'GET', + path: '/very/deeply/nested/route/hello/there', + }, + { + name: 'wildcard', + method: 'GET', + path: '/static/index.html', + params: { 'path': 'index.html' } + }, +] + diff --git a/benchmark/routers.mjs b/benchmark/routers.mjs new file mode 100644 index 0000000..17aebe4 --- /dev/null +++ b/benchmark/routers.mjs @@ -0,0 +1,152 @@ +import * as radix3 from '../dist/index.mjs' +import * as radix3V1 from 'radix3-v1' +import MedlyRouter from '@medley/router' +import { RegExpRouter as HonoRegExpRouter } from 'hono/router/reg-exp-router' +import { TrieRouter as HonoTrieRouter } from 'hono/router/trie-router' +import KoaTreeRouter from 'koa-tree-router' + +const noop = () => undefined + +class BaseRouter { + constructor(routes, withParams = true) { + this.routes = routes + this.withParams = withParams + } +} + +// https://github.com/unjs/radix3 + +class Radix3 extends BaseRouter { + init() { + this.router = radix3.createRouter() + for (const route of this.routes) { + this.router.insert(route.path, { [route.method]: noop }) + } + } + match(request) { + const match = this.router.lookup(request.path) + return { + handler: match.data[request.method], + params: this.withParams ? match.params : undefined + } + } +} + +class Radix3V1 extends BaseRouter { + init() { + this.router = radix3V1.createRouter() + for (const route of this.routes) { + this.router.insert(route.path, { [route.method]: noop }) + } + } + match(request) { + const match = this.router.lookup(request.path) + return { + handler: match[request.method], + params: this.withParams ? match.params : undefined + } + } +} + +// https://github.com/medleyjs/router + +class Medley extends BaseRouter { + init() { + this.router = new MedlyRouter() + for (const route of this.routes) { + const store = this.router.register(route.path) + store[route.method] = noop + } + } + match(request) { + // eslint-disable-next-line unicorn/no-array-callback-reference + const match = this.router.find(request.path) + return { + handler: match.store[request.method], + params: this.withParams ? match.params : undefined + } + } +} + +// https://hono.dev/docs/concepts/routers + +class HonoRegExp extends BaseRouter { + init() { + this.router = new HonoRegExpRouter() + for (const route of this.routes) { + this.router.add(route.method, route.path, noop) + } + } + match(request) { + // [[handler, paramIndexMap][], paramArray] + const match = this.router.match(request.method, request.path) + let params + if (this.withParams && match[1]) { + // TODO: Where does hono do it ?! + params = Object.create(null) + const paramArray = match[1] + for (let i = 1; i < match[0][0].length; i++) { + for (const paramName in match[0][0][i]) { + const paramIndex = match[0][0][i][paramName] + params[paramName] = paramArray[paramIndex] + } + } + } + return { + handler: match[0][0][0], + params: params + } + } +} + +class HonoTrie extends BaseRouter { + init() { + this.router = new HonoTrieRouter() + for (const route of this.routes) { + this.router.add(route.method, route.path, noop) + } + } + match(request) { + // [[handler, paramIndexMap][], paramArray] + const match = this.router.match(request.method, request.path) + return { + handler: match[0][0][0], + params: this.withParams ? match[0][0][1] : undefined + } + } +} + +// https://github.com/steambap/koa-tree-router + +class KoaTree extends BaseRouter { + init() { + this.router = new KoaTreeRouter() + for (const route of this.routes) { + this.router.on(route.method, route.path, noop) + } + } + match(request) { + // eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-method-this-argument + const match = this.router.find(request.method, request.path) + let params + if (this.withParams && match.params) { + params = Object.create(null) + for (const param of match.params) { + params[param.key] = param.value + } + } + return { + handler: match.handle[0], + params + } + } +} + +export const routers = { + radix3: Radix3, + 'radix3-v1': Radix3V1, + medley: Medley, + 'hono-regexp': HonoRegExp, + 'hono-trie': HonoTrie, + 'koa-tree': KoaTree +} diff --git a/benchmark/utils.mjs b/benchmark/utils.mjs deleted file mode 100644 index 8903d66..0000000 --- a/benchmark/utils.mjs +++ /dev/null @@ -1,65 +0,0 @@ - -import { readFileSync } from "node:fs"; -import os from "node:os"; -import { createRouter } from "../dist/index.mjs"; -import { createRouter as createRouterV1 } from "radix3-v1"; - -export const logSection = (title) => { - console.log(`\n--- ${title} ---\n`); -}; - -const pkgVersion = JSON.parse( - readFileSync(new URL("../package.json", import.meta.url), "utf8"), -).version; - -export function printEnv() { - logSection("Test environment"); - console.log("Node.js version:", process.versions.node); - console.log("Radix3 version:", pkgVersion); - console.log("OS:", os.platform()); - console.log("CPU count:", os.cpus().length); - console.log("Current load:", os.loadavg()); - console.log(""); -} - -export function printStats(stats) { - console.log( - "Stats:\n" + - Object.entries(stats) - .map(([path, hits]) => ` - ${path}: ${hits}`) - .join("\n"), - ); -} - -const routes = Object.fromEntries( - [ - "/hello", - "/cool", - "/hi", - "/helium", - "/coooool", - "/chrome", - "/choot", - "/choot/:choo", - "/ui/**", - "/ui/components/**", - ].map((path) => [path, { path }]) -); - -const requests = [ - { path: "/hi" }, - { path: "/choot/123" } -] - -export const benchSets = [ - { - title: "v2", - router: createRouter({ routes }), - requests, - }, - { - title: "v1", - router: createRouterV1({ routes }), - requests, - }, -]; diff --git a/package.json b/package.json index 7908b6d..85dc79f 100644 --- a/package.json +++ b/package.json @@ -34,20 +34,26 @@ }, "devDependencies": { "0x": "^5.7.0", + "@medley/router": "^0.2.1", "@vitest/coverage-v8": "^1.6.0", "autocannon": "^7.15.0", "benchmark": "^2.1.4", "changelogen": "^0.5.5", "eslint": "^9.6.0", "eslint-config-unjs": "^0.3.2", + "find-my-way": "^8.2.0", + "hono": "^4.4.11", "jiti": "^1.21.6", + "koa-tree-router": "^0.12.1", "listhen": "^1.7.2", + "mitata": "^0.1.11", "prettier": "^3.3.2", + "radix3-v1": "npm:radix3@1.1.2", "standard-version": "^9.5.0", + "trek-router": "^1.2.0", "typescript": "^5.5.3", "unbuild": "^2.0.0", - "vitest": "^1.6.0", - "radix3-v1": "npm:radix3@1.1.2" + "vitest": "^1.6.0" }, "packageManager": "pnpm@9.4.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7c8291..593059d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,12 @@ importers: 0x: specifier: ^5.7.0 version: 5.7.0 + '@medley/router': + specifier: ^0.2.1 + version: 0.2.1 '@vitest/coverage-v8': specifier: ^1.6.0 - version: 1.6.0(vitest@1.6.0) + version: 1.6.0(vitest@1.6.0(@types/node@20.14.9)) autocannon: specifier: ^7.15.0 version: 7.15.0 @@ -29,12 +32,24 @@ importers: eslint-config-unjs: specifier: ^0.3.2 version: 0.3.2(eslint@9.6.0)(typescript@5.5.3) + find-my-way: + specifier: ^8.2.0 + version: 8.2.0 + hono: + specifier: ^4.4.11 + version: 4.4.11 jiti: specifier: ^1.21.6 version: 1.21.6 + koa-tree-router: + specifier: ^0.12.1 + version: 0.12.1 listhen: specifier: ^1.7.2 version: 1.7.2 + mitata: + specifier: ^0.1.11 + version: 0.1.11 prettier: specifier: ^3.3.2 version: 3.3.2 @@ -44,6 +59,9 @@ importers: standard-version: specifier: ^9.5.0 version: 9.5.0 + trek-router: + specifier: ^1.2.0 + version: 1.2.0 typescript: specifier: ^5.5.3 version: 5.5.3 @@ -52,7 +70,7 @@ importers: version: 2.0.0(typescript@5.5.3) vitest: specifier: ^1.6.0 - version: 1.6.0 + version: 1.6.0(@types/node@20.14.9) packages: @@ -506,6 +524,10 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@medley/router@0.2.1': + resolution: {integrity: sha512-mdvS1spIxmZoUbTdYmWknHtwm72WwrGNoQCDd4RTvcXJ9G6XThxeC3g+cpOf6Fw6vIERHt50pYiJpsk5XTJQ5w==} + engines: {node: '>=8'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -745,21 +767,75 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@types/accepts@1.3.7': + resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + + '@types/body-parser@1.19.5': + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/content-disposition@0.5.8': + resolution: {integrity: sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==} + + '@types/cookies@0.9.0': + resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} + '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + '@types/express-serve-static-core@4.19.5': + resolution: {integrity: sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==} + + '@types/express@4.17.21': + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + + '@types/http-assert@1.5.5': + resolution: {integrity: sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==} + + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + + '@types/keygrip@1.0.6': + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + + '@types/koa-compose@3.2.8': + resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} + + '@types/koa@2.15.0': + resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==} + '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + '@types/node@20.14.9': + resolution: {integrity: sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/qs@6.9.15': + resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + + '@types/serve-static@1.15.7': + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -1737,6 +1813,9 @@ packages: execspawn@1.0.1: resolution: {integrity: sha512-s2k06Jy9i8CUkYe0+DxRlvtkZoOkwwfhB+Xxo5HGUtrISVW2m98jO2tr67DGRFxZwkjQqloA3v/tNtjhBRBieg==} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1750,6 +1829,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -1768,6 +1850,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@8.2.0: + resolution: {integrity: sha512-HdWXgFYc6b1BJcOBDBwjqWuHJj1WYiqrxSh25qtU4DabpMFdj/gSunNBQb83t+8Zt67D7CXEzJWTkxaShMTMOA==} + engines: {node: '>=14'} + find-up@2.1.0: resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} engines: {node: '>=4'} @@ -2003,6 +2089,10 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + hono@4.4.11: + resolution: {integrity: sha512-R5RADpjoRsR3/VsnFovpsYNLPnC1f+FgdfsePk3qIgjb4D41Sg7uW5QCj41kzEOwXCjBg0sVvOZMvUNZ0DKB7g==} + engines: {node: '>=16.0.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -2328,6 +2418,13 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa-tree-router@0.12.1: + resolution: {integrity: sha512-U/jJoV+rDFYtbaU/X6r2hcNKT7+DZs8HeXONWA7/OSIMk6/cYhoW5P9MPrjg7vHWRrmZOAiFkPoW7vtxvwLWpw==} + engines: {node: '>=12.0'} + labeled-stream-splicer@2.0.2: resolution: {integrity: sha512-Ca4LSXFFZUjPScRaqOcFxneA0VpKZr4MMYCljyQr4LIewTLb3Y0IUTIsnBBsVubIeEfxeSZpSjSsRM8APEQaAw==} @@ -2539,6 +2636,9 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + mitata@0.1.11: + resolution: {integrity: sha512-cs6FiWcnRxn7atVumm8wA8R70XCDmMXgVgb/qWUSjr5dwuIBr7zC+22mbGYPlbyFixlIOjuP//A0e72Q1ZoGDw==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -2672,6 +2772,10 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + object.assign@4.1.5: resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} engines: {node: '>= 0.4'} @@ -3194,6 +3298,10 @@ packages: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true + ret@0.4.3: + resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} + engines: {node: '>=10'} + retimer@3.0.0: resolution: {integrity: sha512-WKE0j11Pa0ZJI5YIk0nflGI7SQsfl2ljihVy7ogh7DeQSeYAUi0ubZ/yEueGtDfUPk6GH5LRw1hBdLq4IwUBWA==} @@ -3234,6 +3342,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@3.1.0: + resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -3518,6 +3629,10 @@ packages: transform-ast@2.4.4: resolution: {integrity: sha512-AxjeZAcIOUO2lev2GDe3/xZ1Q0cVGjIMk5IsriTy8zbWlsEnjeB025AhkhBJHoy997mXpLd4R+kRbvnnQVuQHQ==} + trek-router@1.2.0: + resolution: {integrity: sha512-43A1krE0myUO2DV+RQBUYLwK3Q5osszQ65jFe/TFGWMnhdZx0nvq2GQXecXwIPU0weSFo1pYmHfhHHaUPPIRNg==} + engines: {node: '>= 6'} + trim-newlines@3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} @@ -3600,6 +3715,9 @@ packages: resolution: {integrity: sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw==} hasBin: true + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + unenv@1.9.0: resolution: {integrity: sha512-QKnFNznRxmbOF1hDgzpqrlIf6NC5sbZ2OJ+5Wl3OX8uM+LUJXbj4TXvLJCtwbPTmbMHCLIz6JLKNinNsMShK9g==} @@ -4172,6 +4290,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 + '@medley/router@0.2.1': + dependencies: + object-treeify: 1.1.33 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4348,18 +4470,96 @@ snapshots: '@trysound/sax@0.2.0': {} + '@types/accepts@1.3.7': + dependencies: + '@types/node': 20.14.9 + + '@types/body-parser@1.19.5': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.14.9 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.14.9 + + '@types/content-disposition@0.5.8': {} + + '@types/cookies@0.9.0': + dependencies: + '@types/connect': 3.4.38 + '@types/express': 4.17.21 + '@types/keygrip': 1.0.6 + '@types/node': 20.14.9 + '@types/estree@1.0.5': {} + '@types/express-serve-static-core@4.19.5': + dependencies: + '@types/node': 20.14.9 + '@types/qs': 6.9.15 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express@4.17.21': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 4.19.5 + '@types/qs': 6.9.15 + '@types/serve-static': 1.15.7 + + '@types/http-assert@1.5.5': {} + + '@types/http-errors@2.0.4': {} + + '@types/keygrip@1.0.6': {} + + '@types/koa-compose@3.2.8': + dependencies: + '@types/koa': 2.15.0 + + '@types/koa@2.15.0': + dependencies: + '@types/accepts': 1.3.7 + '@types/content-disposition': 0.5.8 + '@types/cookies': 0.9.0 + '@types/http-assert': 1.5.5 + '@types/http-errors': 2.0.4 + '@types/keygrip': 1.0.6 + '@types/koa-compose': 3.2.8 + '@types/node': 20.14.9 + '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.10 + '@types/mime@1.3.5': {} + '@types/minimist@1.2.5': {} + '@types/node@20.14.9': + dependencies: + undici-types: 5.26.5 + '@types/normalize-package-data@2.4.4': {} + '@types/qs@6.9.15': {} + + '@types/range-parser@1.2.7': {} + '@types/resolve@1.20.2': {} + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.14.9 + + '@types/serve-static@1.15.7': + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 20.14.9 + '@types/send': 0.17.4 + '@types/unist@2.0.10': {} '@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@9.6.0)(typescript@5.5.3))(eslint@9.6.0)(typescript@5.5.3)': @@ -4443,7 +4643,7 @@ snapshots: '@typescript-eslint/types': 7.15.0 eslint-visitor-keys: 3.4.3 - '@vitest/coverage-v8@1.6.0(vitest@1.6.0)': + '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.14.9))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -4458,7 +4658,7 @@ snapshots: std-env: 3.7.0 strip-literal: 2.1.0 test-exclude: 6.0.0 - vitest: 1.6.0 + vitest: 1.6.0(@types/node@20.14.9) transitivePeerDependencies: - supports-color @@ -5737,6 +5937,8 @@ snapshots: dependencies: util-extend: 1.0.3 + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.2: @@ -5751,6 +5953,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} fastq@1.17.1: @@ -5769,6 +5975,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@8.2.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 3.1.0 + find-up@2.1.0: dependencies: locate-path: 2.0.0 @@ -6042,6 +6254,8 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + hono@4.4.11: {} + hookable@5.5.3: {} hosted-git-info@2.8.9: {} @@ -6304,6 +6518,13 @@ snapshots: kind-of@6.0.3: {} + koa-compose@4.1.0: {} + + koa-tree-router@0.12.1: + dependencies: + '@types/koa': 2.15.0 + koa-compose: 4.1.0 + labeled-stream-splicer@2.0.2: dependencies: inherits: 2.0.4 @@ -6541,6 +6762,8 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + mitata@0.1.11: {} + mkdirp-classic@0.5.3: {} mkdirp@1.0.4: {} @@ -6690,6 +6913,8 @@ snapshots: object-keys@1.1.1: {} + object-treeify@1.1.33: {} + object.assign@4.1.5: dependencies: call-bind: 1.0.7 @@ -7193,6 +7418,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + ret@0.4.3: {} + retimer@3.0.0: {} reusify@1.0.4: {} @@ -7248,6 +7475,10 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex2@3.1.0: + dependencies: + ret: 0.4.3 + scule@1.3.0: {} semver@5.7.2: {} @@ -7535,6 +7766,8 @@ snapshots: merge-source-map: 1.0.4 nanobench: 2.1.1 + trek-router@1.2.0: {} + trim-newlines@3.0.1: {} ts-api-utils@1.3.0(typescript@5.5.3): @@ -7622,6 +7855,8 @@ snapshots: simple-concat: 1.0.1 xtend: 4.0.2 + undici-types@5.26.5: {} + unenv@1.9.0: dependencies: consola: 3.2.3 @@ -7702,13 +7937,13 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-node@1.6.0: + vite-node@1.6.0(@types/node@20.14.9): dependencies: cac: 6.7.14 debug: 4.3.5 pathe: 1.1.2 picocolors: 1.0.1 - vite: 5.3.3 + vite: 5.3.3(@types/node@20.14.9) transitivePeerDependencies: - '@types/node' - less @@ -7719,15 +7954,16 @@ snapshots: - supports-color - terser - vite@5.3.3: + vite@5.3.3(@types/node@20.14.9): dependencies: esbuild: 0.21.5 postcss: 8.4.39 rollup: 4.18.0 optionalDependencies: + '@types/node': 20.14.9 fsevents: 2.3.3 - vitest@1.6.0: + vitest@1.6.0(@types/node@20.14.9): dependencies: '@vitest/expect': 1.6.0 '@vitest/runner': 1.6.0 @@ -7746,9 +7982,11 @@ snapshots: strip-literal: 2.1.0 tinybench: 2.8.0 tinypool: 0.8.4 - vite: 5.3.3 - vite-node: 1.6.0 + vite: 5.3.3(@types/node@20.14.9) + vite-node: 1.6.0(@types/node@20.14.9) why-is-node-running: 2.2.2 + optionalDependencies: + '@types/node': 20.14.9 transitivePeerDependencies: - less - lightningcss From 4896e7a2e65c31d619124be9f89817310f45d9e5 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 14:28:05 +0200 Subject: [PATCH 07/15] feat: `lookup` with `ignoreParams` --- benchmark/routers.mjs | 2 +- src/router.ts | 9 ++++++--- src/types.ts | 5 ++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/benchmark/routers.mjs b/benchmark/routers.mjs index 17aebe4..faa9fd0 100644 --- a/benchmark/routers.mjs +++ b/benchmark/routers.mjs @@ -24,7 +24,7 @@ class Radix3 extends BaseRouter { } } match(request) { - const match = this.router.lookup(request.path) + const match = this.router.lookup(request.path, { ignoreParams: !this.withParams }) return { handler: match.data[request.method], params: this.withParams ? match.params : undefined diff --git a/src/router.ts b/src/router.ts index 2eab1f9..8f6d30e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,8 +27,10 @@ export function createRouter( return { ctx, - lookup: (path: string) => - lookup(ctx, normalizeTrailingSlash(path)) as MatchedRoute | undefined, + lookup: (path: string, opts) => + lookup(ctx, normalizeTrailingSlash(path), opts?.ignoreParams) as + | MatchedRoute + | undefined, matchAll: (path: string) => _matchAll(ctx, ctx.root, _splitPath(path), 0) as RadixNodeData[], insert: (path: string, data: any) => @@ -113,6 +115,7 @@ function insert(ctx: RadixRouterContext, path: string, data: any) { function lookup( ctx: RadixRouterContext, path: string, + ignoreParams?: boolean, ): MatchedRoute | undefined { const staticMatch = ctx.staticRoutesMap.get(path); if (staticMatch && staticMatch.data !== undefined) { @@ -125,7 +128,7 @@ function lookup( return; } const data = matchedNode.data; - if (!matchedNode.paramNames && matchedNode.key !== "**") { + if (ignoreParams || (!matchedNode.paramNames && matchedNode.key !== "**")) { return { data }; } const params = _getParams(segments, matchedNode); diff --git a/src/types.ts b/src/types.ts index 19a6520..e1d79bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,7 +46,10 @@ export interface RadixRouter { * * @returns The data that was originally inserted into the tree */ - lookup(path: string): MatchedRoute | undefined; + lookup( + path: string, + opts?: { ignoreParams?: boolean }, + ): MatchedRoute | undefined; /** * Match all routes that match the given path. From fe6287cff1997567f617aeb9816e366ffd1e9be3 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 14:56:02 +0200 Subject: [PATCH 08/15] update bench --- benchmark/bench.mjs | 41 +---------------------------------------- benchmark/test.mjs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 40 deletions(-) create mode 100644 benchmark/test.mjs diff --git a/benchmark/bench.mjs b/benchmark/bench.mjs index a78474d..a6d0196 100644 --- a/benchmark/bench.mjs +++ b/benchmark/bench.mjs @@ -1,47 +1,8 @@ import { bench, group, run } from 'mitata' +import "./test.mjs" import { routers } from './routers.mjs' import { requests, routes } from './input.mjs' -// Test all routers -let testsFailed = false -for (const name in routers) { - let routerHasIssues = false - console.log(`\n🧪 Validating ${name}`) - const router = new routers[name](routes) - router.init() - for (const request of requests) { - const title = `${request.name} ([${request.method}] ${request.path})` - const match = router.match(request) - if (!match) { - console.error(`❌ No match for ${title}`) - routerHasIssues = true - testsFailed = true - continue - } - if (typeof match.handler !== 'function') { - console.error(`❌ No handler for ${title}`) - routerHasIssues = true - testsFailed = true - continue - } - if (request.params && JSON.stringify(match.params) !== JSON.stringify(request.params)) { - console.error(`❌ Invalid params for ${title}. Expected %s Got %s`, request.params, match.params) - routerHasIssues = true - testsFailed = true - continue - } - - } - if (!routerHasIssues) { - console.log(`\r✅ Validated ${name}`) - } -} -if (testsFailed) { - console.error('❌ Some routers failed validation') - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1) -} - // Benchmark all routers group('All requests (with params)', () => { for (const name in routers) { diff --git a/benchmark/test.mjs b/benchmark/test.mjs new file mode 100644 index 0000000..a857865 --- /dev/null +++ b/benchmark/test.mjs @@ -0,0 +1,36 @@ +import { routers } from './routers.mjs' +import { requests, routes } from './input.mjs' + +// Test all routers +let testsFailed = false +for (const name in routers) { + process.stdout.write(`🧪 Testing ${name}`) + const router = new routers[name](routes) + router.init() + const issues = [] + for (const request of requests) { + const reqLabel = `[${request.method}] ${request.path} (${request.name})` + const match = router.match(request) + if (!match) { + issues.push(`${reqLabel}: No route matched`) + continue + } + if (typeof match.handler !== 'function') { + issues.push(`${reqLabel}: No handler returned`) + continue + } + if (request.params && JSON.stringify(match.params) !== JSON.stringify(request.params)) { + issues.push(`${reqLabel}: Params not matched. Expected ${JSON.stringify(request.params)} Got ${JSON.stringify(match.params)}`) + continue + } + } + console.log( + issues.length > 0 ? + `\r❌ Some tests failed for ${name}: \n - ${issues.join('\n - ')}` : + `\r✅ All tests passed for ${name}!`) +} +if (testsFailed) { + console.error('❌ Some routers failed validation') + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1) +} From 20f619870371c7d8d791e47a756c2efae4e62b5b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 18:06:27 +0200 Subject: [PATCH 09/15] refactor use plain objects --- src/router.ts | 90 ++++++++++++++++++++++---------------------- src/types.ts | 7 ++-- tests/_utils.ts | 2 +- tests/router.test.ts | 15 -------- 4 files changed, 49 insertions(+), 65 deletions(-) diff --git a/src/router.ts b/src/router.ts index 8f6d30e..43f5c44 100644 --- a/src/router.ts +++ b/src/router.ts @@ -13,7 +13,7 @@ export function createRouter( const ctx: RadixRouterContext = { options, root: { key: "" }, - staticRoutesMap: new Map(), + staticRoutesMap: Object.create(null), }; const normalizeTrailingSlash = (p: string) => @@ -85,15 +85,15 @@ function insert(ctx: RadixRouterContext, path: string, data: any) { } // Static - const child = node.staticChildren?.get(segment); + const child = node.staticChildren?.[segment]; if (child) { node = child; } else { const staticNode = { key: segment }; if (!node.staticChildren) { - node.staticChildren = new Map(); + node.staticChildren = Object.create(null); } - node.staticChildren.set(segment, staticNode); + node.staticChildren![segment] = staticNode; node = staticNode; } } @@ -106,7 +106,7 @@ function insert(ctx: RadixRouterContext, path: string, data: any) { node.paramNames = nodeParams; } else { // Static route - ctx.staticRoutesMap.set(path, node); + ctx.staticRoutesMap[path] = node; } } @@ -117,7 +117,7 @@ function lookup( path: string, ignoreParams?: boolean, ): MatchedRoute | undefined { - const staticMatch = ctx.staticRoutesMap.get(path); + const staticMatch = ctx.staticRoutesMap[path]; if (staticMatch && staticMatch.data !== undefined) { return { data: staticMatch.data }; } @@ -152,7 +152,7 @@ function _lookup( const segment = segments[index]; // 1. Static - const staticChild = node.staticChildren?.get(segment); + const staticChild = node.staticChildren?.[segment]; if (staticChild) { const matchedNode = _lookup(ctx, staticChild, segments, index + 1); if (matchedNode) { @@ -193,7 +193,7 @@ function _matchAll( } // 2. Static - const staticChild = node.staticChildren?.get(segment); + const staticChild = node.staticChildren?.[segment]; if (staticChild) { matchedNodes.unshift(..._matchAll(ctx, staticChild, segments, index + 1)); } @@ -218,66 +218,57 @@ function _matchAll( function remove(ctx: RadixRouterContext, path: string) { const segments = _splitPath(path); + ctx.staticRoutesMap[path] = undefined; return _remove(ctx.root, segments, 0); } -function _remove(node: RadixNode, segments: string[], index: number): boolean { +function _remove( + node: RadixNode, + segments: string[], + index: number, +): void /* should delete */ { if (index === segments.length) { - if (node.data === undefined) { - return false; - } node.data = undefined; node.index = undefined; node.paramNames = undefined; - return !( - node.staticChildren?.size || - node.paramChild || - node.wildcardChild - ); + return; } const segment = segments[index]; // Param if (segment === "*") { - if (!node.paramChild) { - return false; - } - const shouldDelete = _remove(node.paramChild, segments, index + 1); - if (shouldDelete) { - node.paramChild = undefined; - return ( - node.staticChildren?.size === 0 && node.wildcardChild === undefined - ); + if (node.paramChild) { + _remove(node.paramChild, segments, index + 1); + if (_isEmptyNode(node.paramChild)) { + node.paramChild = undefined; + } } - return false; + return; } // Wildcard if (segment === "**") { - if (!node.wildcardChild) { - return false; - } - const shouldDelete = _remove(node.wildcardChild, segments, index + 1); - if (shouldDelete) { - node.wildcardChild = undefined; - return node.staticChildren?.size === 0 && node.paramChild === undefined; + if (node.wildcardChild) { + _remove(node.wildcardChild, segments, index + 1); + if (_isEmptyNode(node.wildcardChild)) { + node.wildcardChild = undefined; + } } - return false; + return; } // Static - const childNode = node.staticChildren?.get(segment); - if (!childNode) { - return false; - } - const shouldDelete = _remove(childNode, segments, index + 1); - if (shouldDelete) { - node.staticChildren?.delete(segment); - return node.staticChildren?.size === 0 && node.data === undefined; + const childNode = node.staticChildren?.[segment]; + if (childNode) { + _remove(childNode, segments, index + 1); + if (_isEmptyNode(childNode)) { + delete node.staticChildren![segment]; + if (Object.keys(node.staticChildren!).length === 0) { + node.staticChildren = undefined; + } + } } - - return false; } // --- shared utils --- @@ -299,6 +290,15 @@ function _getParamMatcher(segment: string): string | RegExp { return new RegExp(`^${sectionRegexString}$`); } +function _isEmptyNode(node: RadixNode) { + return ( + node.data === undefined && + node.staticChildren === undefined && + node.paramChild === undefined && + node.wildcardChild === undefined + ); +} + function _getParams( segments: string[], node: RadixNode, diff --git a/src/types.ts b/src/types.ts index e1d79bc..05e5c22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,7 +17,7 @@ export type MatchedRoute = { export interface RadixNode { key: string; - staticChildren?: Map>; + staticChildren?: Record>; paramChild?: RadixNode; wildcardChild?: RadixNode; @@ -34,7 +34,7 @@ export interface RadixRouterOptions { export interface RadixRouterContext { options: RadixRouterOptions; root: RadixNode; - staticRoutesMap: Map; + staticRoutesMap: Record; } export interface RadixRouter { @@ -71,9 +71,8 @@ export interface RadixRouter { * Perform a remove on the tree * @param { string } data.path - the route to match * - * @returns A boolean signifying if the remove was successful or not */ - remove(path: string): boolean; + remove(path: string): void; } export interface MatcherExport { diff --git a/tests/_utils.ts b/tests/_utils.ts index 32dd29b..cc687a3 100644 --- a/tests/_utils.ts +++ b/tests/_utils.ts @@ -12,7 +12,7 @@ export function formatTree( ); const childrenArray = [ - ...(node.staticChildren?.values() || []), + ...Object.values(node.staticChildren || []), node.paramChild, node.wildcardChild, ].filter(Boolean) as RadixNode[]; diff --git a/tests/router.test.ts b/tests/router.test.ts index 74d07b0..e0fa253 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -447,19 +447,4 @@ describe("Router remove", function () { params: { _: "components/snackbars" }, }); }); - - it("should return a result signifying that the remove operation was successful or not", function () { - const router = createRouter({ - routes: createTestRoutes(["/some/route"]), - }); - - let removeResult = router.remove("/some/route"); - expect(removeResult).to.equal(true); - - removeResult = router.remove("/some/route"); - expect(removeResult).to.equal(false); - - removeResult = router.remove("/some/route/that/never/existed"); - expect(removeResult).to.equal(false); - }); }); From 6e446ea5685abda4094550fa5347d2c4b069a279 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 19:29:12 +0200 Subject: [PATCH 10/15] overhaul --- README.md | 168 +++++++++----------- benchmark/routers.mjs | 4 +- benchmark/test.mjs | 11 +- package.json | 5 +- pnpm-lock.yaml | 77 +++++++++ src/context.ts | 15 ++ src/index.ts | 8 +- src/operations/_utils.ts | 63 ++++++++ src/operations/add.ts | 77 +++++++++ src/operations/find.ts | 76 +++++++++ src/operations/match.ts | 50 ++++++ src/operations/remove.ts | 70 +++++++++ src/router.ts | 326 --------------------------------------- src/types.ts | 86 +++-------- tests/_utils.ts | 21 ++- tests/basic.test.ts | 70 ++++----- tests/matcher.test.ts | 78 +++++----- tests/router.test.ts | 189 ++++++++++++----------- 18 files changed, 727 insertions(+), 667 deletions(-) create mode 100644 src/context.ts create mode 100644 src/operations/_utils.ts create mode 100644 src/operations/add.ts create mode 100644 src/operations/find.ts create mode 100644 src/operations/match.ts create mode 100644 src/operations/remove.ts delete mode 100644 src/router.ts diff --git a/README.md b/README.md index cba86fd..6094874 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,114 @@ # 🌳 radix3 -[![npm version][npm-version-src]][npm-version-href] -[![npm downloads][npm-downloads-src]][npm-downloads-href] -[![bundle][bundle-src]][bundle-href] -[![Codecov][codecov-src]][codecov-href] -[![License][license-src]][license-href] -[![JSDocs][jsdocs-src]][jsdocs-href] + -Lightweight and fast router for JavaScript based on [Radix Tree](https://en.wikipedia.org/wiki/Radix_tree). +[![npm version](https://img.shields.io/npm/v/radix3)](https://npmjs.com/package/radix3) +[![npm downloads](https://img.shields.io/npm/dm/radix3)](https://npmjs.com/package/radix3) + + + +Lightweight and fast router for JavaScript. + +> [!NOTE] +> You are on the main branch looking at v2 docs. See [v1 branch](https://github.com/unjs/radix3/tree/v1) for current release. ## Usage -**Install package:** +**Install:** + + ```sh +# ✨ Auto-detect +npx nypm install radix3 + # npm -npm i radix3 +npm install radix3 # yarn yarn add radix3 # pnpm -pnpm i radix3 +pnpm install radix3 + +# bun +bun install radix3 ``` + + **Import:** -```js -// ESM -import { createRouter } from "radix3"; + + +**ESM** (Node.js, Bun) -// CJS -const { createRouter } = require("radix3"); +```js +import { + createRouter, + addRoute, + findRoute, + removeRoute, + matchAllRoutes, +} from "radix3"; ``` -**Create a router instance and insert routes:** +**CommonJS** (Legacy Node.js) ```js -const router = createRouter(/* options */); - -router.insert("/path", { payload: "this path" }); -router.insert("/path/:name", { payload: "named route" }); -router.insert("/path/foo/**", { payload: "wildcard route" }); -router.insert("/path/foo/**:name", { payload: "named wildcard route" }); +const { + createRouter, + addRoute, + findRoute, + removeRoute, + matchAllRoutes, +} = require("radix3"); ``` -**Match route to access matched data:** +**CDN** (Deno, Bun and Browsers) ```js -router.lookup("/path"); -// { payload: 'this path' } +import { + createRouter, + addRoute, + findRoute, + removeRoute, + matchAllRoutes, +} from "https://esm.sh/radix3"; +``` -router.lookup("/path/fooval"); -// { payload: 'named route', params: { name: 'fooval' } } + -router.lookup("/path/foo/bar/baz"); -// { payload: 'wildcard route' } +**Create a router instance and insert routes:** -router.lookup("/"); -// null (no route matched for/) -``` +```js +import { createRouter, addRoute } from "radix3"; -## Methods +const router = createRouter(/* options */); -### `router.insert(path, data)` +addRoute(router, "/path", { payload: "this path" }); +addRoute(router, "/path/:name", { payload: "named route" }); +addRoute(router, "/path/foo/**", { payload: "wildcard route" }); +addRoute(router, "/path/foo/**:name", { payload: "named wildcard route" }); +``` -`path` can be static or using `:placeholder` or `**` for wildcard paths. +**Match route to access matched data:** -The `data` object will be returned on matching params. It should be an object like `{ handler }` and not containing reserved keyword `params`. +```js +// Returns { payload: 'this path' } +findRoute(router, "/path"); -### `router.lookup(path)` +// Returns { payload: 'named route', params: { name: 'fooval' } } +findRoute(router, "/path/fooval"); -Returns matched data for `path` with optional `params` key if mached route using placeholders. +// Returns { payload: 'wildcard route' } +findRoute(router, "/path/foo/bar/baz"); -### `router.remove(path)` +// Returns undefined (no route matched for/) +findRoute(router, "/"); +``` -Remove route matching `path`. +## Methods ## Options @@ -84,66 +117,11 @@ You can initialize router instance with options: ```ts const router = createRouter({ strictTrailingSlash: true, - routes: { - "/foo": {}, - }, }); ``` -- `routes`: An object specifying initial routes to add - `strictTrailingSlash`: By default router ignored trailing slash for matching and adding routes. When set to `true`, matching with trailing slash is different. -### Route Matcher - -Creates a multi matcher from router tree that can match **all routes** matching path: - -```ts -import { createRouter, toRouteMatcher } from "radix3"; - -const router = createRouter({ - routes: { - "/foo": { m: "foo" }, // Matches /foo only - "/foo/**": { m: "foo/**" }, // Matches /foo/ - "/foo/bar": { m: "foo/bar" }, // Matches /foo/bar only - "/foo/bar/baz": { m: "foo/bar/baz" }, // Matches /foo/bar/baz only - "/foo/*/baz": { m: "foo/*/baz" }, // Matches /foo//baz - }, -}); - -const matcher = toRouteMatcher(router); - -const matches = matcher.matchAll("/foo/bar/baz"); - -// [ -// { -// "m": "foo/**", -// }, -// { -// "m": "foo/*/baz", -// }, -// { -// "m": "foo/bar/baz", -// }, -// ] -``` - -### Route Matcher Export - -It is also possible to export and then rehydrate a matcher from pre-compiled rules. - -```ts -import { exportMatcher, createMatcherFromExport } from "radix3"; - -// Assuming you already have a matcher -// you can export this to a JSON-type object -const json = exportMatcher(matcher); - -// and then rehydrate this later -const newMatcher = createMatcherFromExport(json); - -const matches = newMatcher.matchAll("/foo/bar/baz"); -``` - ## Performance See [benchmark](./benchmark). diff --git a/benchmark/routers.mjs b/benchmark/routers.mjs index faa9fd0..7d23632 100644 --- a/benchmark/routers.mjs +++ b/benchmark/routers.mjs @@ -20,11 +20,11 @@ class Radix3 extends BaseRouter { init() { this.router = radix3.createRouter() for (const route of this.routes) { - this.router.insert(route.path, { [route.method]: noop }) + radix3.addRoute(this.router, route.path, { [route.method]: noop }) } } match(request) { - const match = this.router.lookup(request.path, { ignoreParams: !this.withParams }) + const match = radix3.findRoute(this.router, request.path, { ignoreParams: !this.withParams }) return { handler: match.data[request.method], params: this.withParams ? match.params : undefined diff --git a/benchmark/test.mjs b/benchmark/test.mjs index a857865..ae70fee 100644 --- a/benchmark/test.mjs +++ b/benchmark/test.mjs @@ -24,10 +24,13 @@ for (const name in routers) { continue } } - console.log( - issues.length > 0 ? - `\r❌ Some tests failed for ${name}: \n - ${issues.join('\n - ')}` : - `\r✅ All tests passed for ${name}!`) + + if (issues.length > 0) { + testsFailed = true + console.error(`\r❌ Some tests failed for ${name}: \n - ${issues.join('\n - ')}`) + } else { + console.log(`\r✅ All tests passed for ${name}!`) + } } if (testsFailed) { console.error('❌ Some routers failed validation') diff --git a/package.json b/package.json index 85dc79f..e840101 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,8 @@ "unbuild": "^2.0.0", "vitest": "^1.6.0" }, - "packageManager": "pnpm@9.4.0" + "packageManager": "pnpm@9.4.0", + "dependencies": { + "automd": "^0.3.7" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 593059d..19625aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + automd: + specifier: ^0.3.7 + version: 0.3.7(magicast@0.3.4) devDependencies: 0x: specifier: ^5.7.0 @@ -161,6 +165,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.24.7': + resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==} + engines: {node: '>=6.9.0'} + '@babel/standalone@7.24.7': resolution: {integrity: sha512-QRIRMJ2KTeN+vt4l9OjYlxDVXEpcor1Z6V7OeYzeBOw6Q8ew9oMTHjzTx8s6ClsZO7wVf6JgTRutihatN6K0yA==} engines: {node: '>=6.9.0'} @@ -1014,6 +1022,10 @@ packages: resolution: {integrity: sha512-NaP2rQyA+tcubOJMFv2+oeW9jv2pq/t+LM6BL3cfJic0HEfscEcnWgAyU5YovE/oTHUzAgTliGdLPR+RQAWUbg==} hasBin: true + automd@0.3.7: + resolution: {integrity: sha512-h+w7Z1xQSdIuGmgDKVVvCl6fwj7V9b7BqUs6LlelvTcrwkLzqJnxpzcK0HoiesPb0xsf1yTfngeoDgYs2NN2Tw==} + hasBin: true + autoprefixer@10.4.19: resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==} engines: {node: ^10 || ^12 || >=14} @@ -1629,6 +1641,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + didyoumean2@6.0.1: + resolution: {integrity: sha512-PSy0zQwMg5O+LjT5Mz7vnKC8I7DfWLPF6M7oepqW7WP5mn2CY3hz46xZOa1GJY+KVfyXhdmz6+tdgXwrHlZc5g==} + engines: {node: ^16.14.0 || >=18.12.0} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1835,6 +1851,10 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -2473,6 +2493,9 @@ packages: lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.deburr@4.1.0: + resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==} + lodash.flatten@4.4.0: resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} @@ -2535,6 +2558,9 @@ packages: resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} engines: {node: '>=8'} + md4w@0.2.6: + resolution: {integrity: sha512-CBLQ2PxVe9WA+/nndZCx/Y+1C3DtmtSeubmXTPhMIgsXtq9gVGleikREko5FYnV6Dz4cHDWm0Ea+YMLpIjP4Kw==} + md5.js@1.3.5: resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} @@ -2544,6 +2570,9 @@ packages: mdast-util-to-string@2.0.0: resolution: {integrity: sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==} + mdbox@0.1.0: + resolution: {integrity: sha512-eQA+6vf5XM4LqdfLsfPMxqUBSU8AMzSCSFbojWLXSDL2jZeO+xgHhxTggrG2jfGPAyyIWIukj6SuoFBd9a7XZw==} + mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} @@ -3271,6 +3300,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -4056,6 +4088,10 @@ snapshots: dependencies: '@babel/types': 7.24.7 + '@babel/runtime@7.24.7': + dependencies: + regenerator-runtime: 0.14.1 + '@babel/standalone@7.24.7': {} '@babel/template@7.24.7': @@ -4802,6 +4838,29 @@ snapshots: subarg: 1.0.0 timestring: 6.0.0 + automd@0.3.7(magicast@0.3.4): + dependencies: + '@parcel/watcher': 2.4.1 + c12: 1.11.1(magicast@0.3.4) + citty: 0.1.6 + consola: 3.2.3 + defu: 6.1.4 + destr: 2.0.3 + didyoumean2: 6.0.1 + globby: 14.0.2 + magic-string: 0.30.10 + mdbox: 0.1.0 + mlly: 1.7.1 + ofetch: 1.3.4 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.1.3 + scule: 1.3.0 + untyped: 1.4.2 + transitivePeerDependencies: + - magicast + - supports-color + autoprefixer@10.4.19(postcss@8.4.39): dependencies: browserslist: 4.23.1 @@ -5631,6 +5690,12 @@ snapshots: defined: 1.0.1 minimist: 1.2.8 + didyoumean2@6.0.1: + dependencies: + '@babel/runtime': 7.24.7 + fastest-levenshtein: 1.0.16 + lodash.deburr: 4.1.0 + diff-sequences@29.6.3: {} diffie-hellman@5.0.3: @@ -5959,6 +6024,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fastest-levenshtein@1.0.16: {} + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -6596,6 +6663,8 @@ snapshots: lodash.clonedeep@4.5.0: {} + lodash.deburr@4.1.0: {} + lodash.flatten@4.4.0: {} lodash.ismatch@4.4.0: {} @@ -6652,6 +6721,8 @@ snapshots: map-obj@4.3.0: {} + md4w@0.2.6: {} + md5.js@1.3.5: dependencies: hash-base: 3.1.0 @@ -6670,6 +6741,10 @@ snapshots: mdast-util-to-string@2.0.0: {} + mdbox@0.1.0: + dependencies: + md4w: 0.2.6 + mdn-data@2.0.28: {} mdn-data@2.0.30: {} @@ -7398,6 +7473,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + regenerator-runtime@0.14.1: {} + regexp-tree@0.1.27: {} regjsparser@0.10.0: diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..ffc0acd --- /dev/null +++ b/src/context.ts @@ -0,0 +1,15 @@ +import type { RouterContext, RouteData, RouterOptions } from "./types"; + +/** + * Create a new router context. + */ +export function createRouter( + options: RouterOptions = {}, +): RouterContext { + const ctx: RouterContext = { + options, + root: { key: "" }, + static: Object.create(null), + }; + return ctx; +} diff --git a/src/index.ts b/src/index.ts index 5dbcb68..42d8520 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ -export * from "./router"; +export { createRouter } from "./context"; +export type { RouterContext, RouterOptions } from "./types"; -export * from "./types"; +export { addRoute } from "./operations/add"; +export { findRoute } from "./operations/find"; +export { removeRoute } from "./operations/remove"; +export { matchAllRoutes } from "./operations/match"; diff --git a/src/operations/_utils.ts b/src/operations/_utils.ts new file mode 100644 index 0000000..b812399 --- /dev/null +++ b/src/operations/_utils.ts @@ -0,0 +1,63 @@ +import type { Node, MatchedRoute, RouterContext } from "../types"; + +export function splitPath(path: string) { + return path.split("/").filter(Boolean); +} + +export function getParamMatcher(segment: string): string | RegExp { + const PARAMS_RE = /:\w+|[^:]+/g; + const params = [...segment.matchAll(PARAMS_RE)]; + if (params.length === 1) { + return params[0][0].slice(1); + } + const sectionRegexString = segment.replace( + /:(\w+)/g, + (_, id) => `(?<${id}>\\w+)`, + ); + return new RegExp(`^${sectionRegexString}$`); +} + +export function _isEmptyNode(node: Node) { + return ( + node.data === undefined && + node.staticChildren === undefined && + node.paramChild === undefined && + node.wildcardChild === undefined + ); +} + +export function _getParams( + segments: string[], + node: Node, +): MatchedRoute["params"] { + const params = Object.create(null); + for (const param of node.paramNames || []) { + const segment = segments[param.index]; + if (typeof param.name === "string") { + params[param.name] = segment; + } else { + const match = segment.match(param.name); + if (match) { + for (const key in match.groups) { + params[key] = match.groups[key]; + } + } + } + } + if (node.key === "**") { + const paramName = + (node.paramNames?.[node.paramNames.length - 1].name as string) || "_"; + params[paramName] = segments.slice(node.index).join("/"); + } + return params; +} + +export function normalizeTrailingSlash(ctx: RouterContext, path: string = "/") { + if (!ctx.options.strictTrailingSlash) { + return path; + } + if (path.length > 1 && path.endsWith("/")) { + return path.slice(0, -1); + } + return path; +} diff --git a/src/operations/add.ts b/src/operations/add.ts new file mode 100644 index 0000000..f1e5cf7 --- /dev/null +++ b/src/operations/add.ts @@ -0,0 +1,77 @@ +import type { RouterContext, Node, RouteData } from "../types"; +import { getParamMatcher, normalizeTrailingSlash, splitPath } from "./_utils"; + +/** + * Add a route to the router context. + */ +export function addRoute( + ctx: RouterContext, + _path: string, + data: T, +) { + const path = normalizeTrailingSlash(ctx, _path); + const segments = splitPath(path); + + let node = ctx.root; + + let _unnamedParamIndex = 0; + + const nodeParams: Node["paramNames"] = []; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Wildcard + if (segment.startsWith("**")) { + if (!node.wildcardChild) { + node.wildcardChild = { key: "**" }; + } + node = node.wildcardChild; + nodeParams.push({ + index: i, + name: segment.split(":")[1] || "_", + }); + break; + } + + // Param + if (segment === "*" || segment.includes(":")) { + if (!node.paramChild) { + node.paramChild = { key: "*" }; + } + node = node.paramChild; + nodeParams.push({ + index: i, + name: + segment === "*" + ? `_${_unnamedParamIndex++}` + : (getParamMatcher(segment) as string), + }); + continue; + } + + // Static + const child = node.staticChildren?.[segment]; + if (child) { + node = child; + } else { + const staticNode = { key: segment }; + if (!node.staticChildren) { + node.staticChildren = Object.create(null); + } + node.staticChildren![segment] = staticNode; + node = staticNode; + } + } + + // Assign data and params to the final node + node.index = segments.length - 1; + node.data = data; + if (nodeParams.length > 0) { + // Dynamic route + node.paramNames = nodeParams; + } else { + // Static route + ctx.static[path] = node; + } +} diff --git a/src/operations/find.ts b/src/operations/find.ts new file mode 100644 index 0000000..b50bc24 --- /dev/null +++ b/src/operations/find.ts @@ -0,0 +1,76 @@ +import type { RouterContext, MatchedRoute, Node, RouteData } from "../types"; +import { _getParams, normalizeTrailingSlash, splitPath } from "./_utils"; + +/** + * Find a route by path. + */ +export function findRoute( + ctx: RouterContext, + _path: string, + opts?: { ignoreParams?: boolean }, +): MatchedRoute | undefined { + const path = normalizeTrailingSlash(ctx, _path); + + const staticNode = ctx.static[path]; + if (staticNode && staticNode.data !== undefined) { + return { data: staticNode.data }; + } + + const segments = splitPath(path); + + const node = _find(ctx, ctx.root, segments, 0) as Node | undefined; + if (!node || node.data === undefined) { + return; + } + + const data = node.data; + if (opts?.ignoreParams || (!node.paramNames && node.key !== "**")) { + return { data }; + } + + const params = _getParams(segments, node); + + return { + data, + params, + }; +} + +function _find( + ctx: RouterContext, + node: Node, + segments: string[], + index: number, +): Node | undefined { + // End of path + if (index === segments.length) { + return node; + } + + const segment = segments[index]; + + // 1. Static + const staticChild = node.staticChildren?.[segment]; + if (staticChild) { + const matchedNode = _find(ctx, staticChild, segments, index + 1); + if (matchedNode) { + return matchedNode; + } + } + + // 2. Param + if (node.paramChild) { + const nextNode = _find(ctx, node.paramChild, segments, index + 1); + if (nextNode) { + return nextNode; + } + } + + // 3. Wildcard + if (node.wildcardChild) { + return node.wildcardChild; + } + + // No match + return; +} diff --git a/src/operations/match.ts b/src/operations/match.ts new file mode 100644 index 0000000..87b07d1 --- /dev/null +++ b/src/operations/match.ts @@ -0,0 +1,50 @@ +import type { RouterContext, Node, RouteData } from "../types"; +import { _getParams, normalizeTrailingSlash, splitPath } from "./_utils"; + +/** + * Find all route patterns that match the given path. + */ +export function matchAllRoutes( + ctx: RouterContext, + _path: string, +): RouteData[] { + const path = normalizeTrailingSlash(ctx, _path); + return _matchAll(ctx, ctx.root, splitPath(path), 0) as RouteData[]; +} + +function _matchAll( + ctx: RouterContext, + node: Node, + segments: string[], + index: number, +): RouteData[] { + const matchedNodes: RouteData[] = []; + + const segment = segments[index]; + + // 1. Node self data + if (index === segments.length && node.data !== undefined) { + matchedNodes.unshift(node.data); + } + + // 2. Static + const staticChild = node.staticChildren?.[segment]; + if (staticChild) { + matchedNodes.unshift(..._matchAll(ctx, staticChild, segments, index + 1)); + } + + // 3. Param + if (node.paramChild) { + matchedNodes.unshift( + ..._matchAll(ctx, node.paramChild, segments, index + 1), + ); + } + + // 4. Wildcard + if (node.wildcardChild?.data) { + matchedNodes.unshift(node.wildcardChild.data); + } + + // No match + return matchedNodes; +} diff --git a/src/operations/remove.ts b/src/operations/remove.ts new file mode 100644 index 0000000..959d235 --- /dev/null +++ b/src/operations/remove.ts @@ -0,0 +1,70 @@ +import type { RouterContext, Node, RouteData } from "../types"; +import { + _getParams, + _isEmptyNode, + normalizeTrailingSlash, + splitPath, +} from "./_utils"; + +/** + * Remove a route from the router context. + */ +export function removeRoute( + ctx: RouterContext, + _path: string, +) { + const path = normalizeTrailingSlash(ctx, _path); + + const segments = splitPath(path); + ctx.static[path] = undefined; + return _remove(ctx.root, segments, 0); +} + +function _remove( + node: Node, + segments: string[], + index: number, +): void /* should delete */ { + if (index === segments.length) { + node.data = undefined; + node.index = undefined; + node.paramNames = undefined; + return; + } + + const segment = segments[index]; + + // Param + if (segment === "*") { + if (node.paramChild) { + _remove(node.paramChild, segments, index + 1); + if (_isEmptyNode(node.paramChild)) { + node.paramChild = undefined; + } + } + return; + } + + // Wildcard + if (segment === "**") { + if (node.wildcardChild) { + _remove(node.wildcardChild, segments, index + 1); + if (_isEmptyNode(node.wildcardChild)) { + node.wildcardChild = undefined; + } + } + return; + } + + // Static + const childNode = node.staticChildren?.[segment]; + if (childNode) { + _remove(childNode, segments, index + 1); + if (_isEmptyNode(childNode)) { + delete node.staticChildren![segment]; + if (Object.keys(node.staticChildren!).length === 0) { + node.staticChildren = undefined; + } + } + } +} diff --git a/src/router.ts b/src/router.ts deleted file mode 100644 index 43f5c44..0000000 --- a/src/router.ts +++ /dev/null @@ -1,326 +0,0 @@ -import type { - RadixRouterContext, - RadixNode, - MatchedRoute, - RadixRouter, - RadixNodeData, - RadixRouterOptions, -} from "./types"; - -export function createRouter( - options: RadixRouterOptions = {}, -): RadixRouter { - const ctx: RadixRouterContext = { - options, - root: { key: "" }, - staticRoutesMap: Object.create(null), - }; - - const normalizeTrailingSlash = (p: string) => - options.strictTrailingSlash ? p : p.replace(/\/$/, "") || "/"; - - if (options.routes) { - for (const path in options.routes) { - insert(ctx, normalizeTrailingSlash(path), options.routes[path]); - } - } - - return { - ctx, - lookup: (path: string, opts) => - lookup(ctx, normalizeTrailingSlash(path), opts?.ignoreParams) as - | MatchedRoute - | undefined, - matchAll: (path: string) => - _matchAll(ctx, ctx.root, _splitPath(path), 0) as RadixNodeData[], - insert: (path: string, data: any) => - insert(ctx, normalizeTrailingSlash(path), data), - remove: (path: string) => remove(ctx, normalizeTrailingSlash(path)), - }; -} - -// --- tree operations --- - -// --- Insert --- - -function insert(ctx: RadixRouterContext, path: string, data: any) { - const segments = _splitPath(path); - - let node = ctx.root; - - let _unnamedParamIndex = 0; - - const nodeParams: RadixNode["paramNames"] = []; - - for (let i = 0; i < segments.length; i++) { - const segment = segments[i]; - - // Wildcard - if (segment.startsWith("**")) { - if (!node.wildcardChild) { - node.wildcardChild = { key: "**" }; - } - node = node.wildcardChild; - nodeParams.push({ - index: i, - name: segment.split(":")[1] || "_", - }); - break; - } - - // Param - if (segment === "*" || segment.includes(":")) { - if (!node.paramChild) { - node.paramChild = { key: "*" }; - } - node = node.paramChild; - nodeParams.push({ - index: i, - name: - segment === "*" - ? `_${_unnamedParamIndex++}` - : (_getParamMatcher(segment) as string), - }); - continue; - } - - // Static - const child = node.staticChildren?.[segment]; - if (child) { - node = child; - } else { - const staticNode = { key: segment }; - if (!node.staticChildren) { - node.staticChildren = Object.create(null); - } - node.staticChildren![segment] = staticNode; - node = staticNode; - } - } - - // Assign data and params to the final node - node.index = segments.length - 1; - node.data = data; - if (nodeParams.length > 0) { - // Dynamic route - node.paramNames = nodeParams; - } else { - // Static route - ctx.staticRoutesMap[path] = node; - } -} - -// --- Lookup --- - -function lookup( - ctx: RadixRouterContext, - path: string, - ignoreParams?: boolean, -): MatchedRoute | undefined { - const staticMatch = ctx.staticRoutesMap[path]; - if (staticMatch && staticMatch.data !== undefined) { - return { data: staticMatch.data }; - } - - const segments = _splitPath(path); - const matchedNode = _lookup(ctx, ctx.root, segments, 0); - if (!matchedNode || matchedNode.data === undefined) { - return; - } - const data = matchedNode.data; - if (ignoreParams || (!matchedNode.paramNames && matchedNode.key !== "**")) { - return { data }; - } - const params = _getParams(segments, matchedNode); - return { - data, - params, - }; -} - -function _lookup( - ctx: RadixRouterContext, - node: RadixNode, - segments: string[], - index: number, -): RadixNode | undefined { - // End of path - if (index === segments.length) { - return node; - } - - const segment = segments[index]; - - // 1. Static - const staticChild = node.staticChildren?.[segment]; - if (staticChild) { - const matchedNode = _lookup(ctx, staticChild, segments, index + 1); - if (matchedNode) { - return matchedNode; - } - } - - // 2. Param - if (node.paramChild) { - const nextNode = _lookup(ctx, node.paramChild, segments, index + 1); - if (nextNode) { - return nextNode; - } - } - - // 3. Wildcard - if (node.wildcardChild) { - return node.wildcardChild; - } - - // No match - return; -} - -function _matchAll( - ctx: RadixRouterContext, - node: RadixNode, - segments: string[], - index: number, -): RadixNodeData[] { - const matchedNodes: RadixNodeData[] = []; - - const segment = segments[index]; - - // 1. Node self data - if (index === segments.length && node.data !== undefined) { - matchedNodes.unshift(node.data); - } - - // 2. Static - const staticChild = node.staticChildren?.[segment]; - if (staticChild) { - matchedNodes.unshift(..._matchAll(ctx, staticChild, segments, index + 1)); - } - - // 3. Param - if (node.paramChild) { - matchedNodes.unshift( - ..._matchAll(ctx, node.paramChild, segments, index + 1), - ); - } - - // 4. Wildcard - if (node.wildcardChild?.data) { - matchedNodes.unshift(node.wildcardChild.data); - } - - // No match - return matchedNodes; -} - -// --- Remove --- - -function remove(ctx: RadixRouterContext, path: string) { - const segments = _splitPath(path); - ctx.staticRoutesMap[path] = undefined; - return _remove(ctx.root, segments, 0); -} - -function _remove( - node: RadixNode, - segments: string[], - index: number, -): void /* should delete */ { - if (index === segments.length) { - node.data = undefined; - node.index = undefined; - node.paramNames = undefined; - return; - } - - const segment = segments[index]; - - // Param - if (segment === "*") { - if (node.paramChild) { - _remove(node.paramChild, segments, index + 1); - if (_isEmptyNode(node.paramChild)) { - node.paramChild = undefined; - } - } - return; - } - - // Wildcard - if (segment === "**") { - if (node.wildcardChild) { - _remove(node.wildcardChild, segments, index + 1); - if (_isEmptyNode(node.wildcardChild)) { - node.wildcardChild = undefined; - } - } - return; - } - - // Static - const childNode = node.staticChildren?.[segment]; - if (childNode) { - _remove(childNode, segments, index + 1); - if (_isEmptyNode(childNode)) { - delete node.staticChildren![segment]; - if (Object.keys(node.staticChildren!).length === 0) { - node.staticChildren = undefined; - } - } - } -} - -// --- shared utils --- - -function _splitPath(path: string) { - return path.split("/").filter(Boolean); -} - -function _getParamMatcher(segment: string): string | RegExp { - const PARAMS_RE = /:\w+|[^:]+/g; - const params = [...segment.matchAll(PARAMS_RE)]; - if (params.length === 1) { - return params[0][0].slice(1); - } - const sectionRegexString = segment.replace( - /:(\w+)/g, - (_, id) => `(?<${id}>\\w+)`, - ); - return new RegExp(`^${sectionRegexString}$`); -} - -function _isEmptyNode(node: RadixNode) { - return ( - node.data === undefined && - node.staticChildren === undefined && - node.paramChild === undefined && - node.wildcardChild === undefined - ); -} - -function _getParams( - segments: string[], - node: RadixNode, -): MatchedRoute["params"] { - const params = Object.create(null); - for (const param of node.paramNames || []) { - const segment = segments[param.index]; - if (typeof param.name === "string") { - params[param.name] = segment; - } else { - const match = segment.match(param.name); - if (match) { - for (const key in match.groups) { - params[key] = match.groups[key]; - } - } - } - } - if (node.key === "**") { - const paramName = - (node.paramNames?.[node.paramNames.length - 1].name as string) || "_"; - params[paramName] = segments.slice(node.index).join("/"); - } - return params; -} diff --git a/src/types.ts b/src/types.ts index 05e5c22..297429f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,15 @@ +export type RouteData> = T; + +export interface RouterOptions { + strictTrailingSlash?: boolean; +} + +export interface RouterContext { + options: RouterOptions; + root: Node; + static: Record | undefined>; +} + export const NODE_TYPES = { STATIC: 0 as const, PARAM: 1 as const, @@ -5,78 +17,22 @@ export const NODE_TYPES = { }; type _NODE_TYPES = typeof NODE_TYPES; -export type NODE_TYPE = _NODE_TYPES[keyof _NODE_TYPES]; -export type RadixNodeData> = T; - -export type MatchedRoute = { - data?: T; - params?: Record; -}; +export type NODE_TYPE = _NODE_TYPES[keyof _NODE_TYPES]; -export interface RadixNode { +export interface Node { key: string; - staticChildren?: Record>; - paramChild?: RadixNode; - wildcardChild?: RadixNode; + staticChildren?: Record>; + paramChild?: Node; + wildcardChild?: Node; index?: number; data?: T; paramNames?: Array<{ index: number; name: string | RegExp }>; } -export interface RadixRouterOptions { - strictTrailingSlash?: boolean; - routes?: Record; -} - -export interface RadixRouterContext { - options: RadixRouterOptions; - root: RadixNode; - staticRoutesMap: Record; -} - -export interface RadixRouter { - ctx: RadixRouterContext; - - /** - * Perform lookup of given path in radix tree - * @param path - the path to search for - * - * @returns The data that was originally inserted into the tree - */ - lookup( - path: string, - opts?: { ignoreParams?: boolean }, - ): MatchedRoute | undefined; - - /** - * Match all routes that match the given path. - * @param path - the path to search for - * - * @returns The data that was originally inserted into the tree - */ - matchAll(path: string): RadixNodeData[]; - - /** - * Perform an insert into the radix tree - * @param path - the prefix to match - * @param data - the associated data to path - * - */ - insert(path: string, data: T): void; - - /** - * Perform a remove on the tree - * @param { string } data.path - the route to match - * - */ - remove(path: string): void; -} - -export interface MatcherExport { - dynamic: Map; - wildcard: Map; - static: Map; -} +export type MatchedRoute = { + data?: T; + params?: Record; +}; diff --git a/tests/_utils.ts b/tests/_utils.ts index cc687a3..829f6e1 100644 --- a/tests/_utils.ts +++ b/tests/_utils.ts @@ -1,7 +1,22 @@ -import type { RadixNode } from "../src"; +import type { Node, RouteData } from "../src/types"; +import { createRouter as _createRouter, addRoute } from "../src"; + +export function createRouter(routes: string[] | Record) { + const router = _createRouter(); + if (Array.isArray(routes)) { + for (const route of routes) { + addRoute(router, route, { path: route }); + } + } else { + for (const [route, data] of Object.entries(routes)) { + addRoute(router, route, data); + } + } + return router; +} export function formatTree( - node: RadixNode, + node: Node, depth = 0, result = [] as string[], prefix = "", @@ -15,7 +30,7 @@ export function formatTree( ...Object.values(node.staticChildren || []), node.paramChild, node.wildcardChild, - ].filter(Boolean) as RadixNode[]; + ].filter(Boolean) as Node[]; for (const [index, child] of childrenArray.entries()) { const lastChild = index === childrenArray.length - 1; formatTree( diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 89d2c44..687571d 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -1,28 +1,24 @@ import { describe, it, expect } from "vitest"; -import { createRouter } from "../src"; -import { formatTree } from "./_utils"; +import { createRouter, formatTree } from "./_utils"; +import { findRoute, removeRoute } from "../src"; describe("Basic router", () => { - const router = createRouter({}); + const router = createRouter([ + "/test", + "/test/:id", + "/test/:idYZ/y/z", + "/test/:idY/y", + "/test/foo", + "/test/foo/*", + "/test/foo/**", + "/test/foo/bar/qux", + "/test/foo/baz", + "/test/fooo", + "/another/path", + ]); - it("add routes", () => { - for (const path of [ - "/test", - "/test/:id", - "/test/:idYZ/y/z", - "/test/:idY/y", - "/test/foo", - "/test/foo/*", - "/test/foo/**", - "/test/foo/bar/qux", - "/test/foo/baz", - "/test/fooo", - "/another/path", - ]) { - router.insert(path, { path }); - } - - expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + it("snapshot", () => { + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /test ┈> [/test] │ ├── /foo ┈> [/test/foo] @@ -42,44 +38,46 @@ describe("Basic router", () => { it("lookup works", () => { // Static - expect(router.lookup("/test")).toEqual({ data: { path: "/test" } }); - expect(router.lookup("/test/foo")).toEqual({ data: { path: "/test/foo" } }); - expect(router.lookup("/test/fooo")).toEqual({ + expect(findRoute(router, "/test")).toEqual({ data: { path: "/test" } }); + expect(findRoute(router, "/test/foo")).toEqual({ + data: { path: "/test/foo" }, + }); + expect(findRoute(router, "/test/fooo")).toEqual({ data: { path: "/test/fooo" }, }); - expect(router.lookup("/another/path")).toEqual({ + expect(findRoute(router, "/another/path")).toEqual({ data: { path: "/another/path" }, }); // Param - expect(router.lookup("/test/123")).toEqual({ + expect(findRoute(router, "/test/123")).toEqual({ data: { path: "/test/:id" }, params: { id: "123" }, }); - expect(router.lookup("/test/123/y")).toEqual({ + expect(findRoute(router, "/test/123/y")).toEqual({ data: { path: "/test/:idY/y" }, params: { idY: "123" }, }); - expect(router.lookup("/test/123/y/z")).toEqual({ + expect(findRoute(router, "/test/123/y/z")).toEqual({ data: { path: "/test/:idYZ/y/z" }, params: { idYZ: "123" }, }); - expect(router.lookup("/test/foo/123")).toEqual({ + expect(findRoute(router, "/test/foo/123")).toEqual({ data: { path: "/test/foo/*" }, params: { _0: "123" }, }); // Wildcard - expect(router.lookup("/test/foo/123/456")).toEqual({ + expect(findRoute(router, "/test/foo/123/456")).toEqual({ data: { path: "/test/foo/**" }, params: { _: "123/456" }, }); }); it("remove works", () => { - router.remove("/test"); - router.remove("/test/*"); - router.remove("/test/foo/*"); - router.remove("/test/foo/**"); - expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + removeRoute(router, "/test"); + removeRoute(router, "/test/*"); + removeRoute(router, "/test/foo/*"); + removeRoute(router, "/test/foo/**"); + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /test │ ├── /foo ┈> [/test/foo] @@ -93,6 +91,6 @@ describe("Basic router", () => { ├── /another │ ├── /path ┈> [/another/path]" `); - expect(router.lookup("/test")).toBeUndefined(); + expect(findRoute(router, "/test")).toBeUndefined(); }); }); diff --git a/tests/matcher.test.ts b/tests/matcher.test.ts index 7efaf3e..51df1d4 100644 --- a/tests/matcher.test.ts +++ b/tests/matcher.test.ts @@ -1,24 +1,19 @@ import { describe, it, expect } from "vitest"; -import { createRouter } from "../src"; -import { formatTree } from "./_utils"; +import { createRouter, formatTree } from "./_utils"; +import { matchAllRoutes } from "../src"; -export function createRoutes(paths) { - return Object.fromEntries(paths.map((path) => [path, { path }])); -} - -it("readme example works", () => { +describe("readme example", () => { const router = createRouter({ - routes: { - "/foo": { m: "foo" }, - "/foo/**": { m: "foo/**", order: "2" }, - "/foo/bar": { m: "foo/bar" }, - "/foo/bar/baz": { m: "foo/bar/baz", order: "4" }, - "/foo/*/baz": { m: "foo/*/baz", order: "3" }, - "/**": { m: "/**", order: "1" }, - }, + "/foo": { m: "foo" }, + "/foo/**": { m: "foo/**", order: "2" }, + "/foo/bar": { m: "foo/bar" }, + "/foo/bar/baz": { m: "foo/bar/baz", order: "4" }, + "/foo/*/baz": { m: "foo/*/baz", order: "3" }, + "/**": { m: "/**", order: "1" }, }); - expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + it("snapshot", () => { + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /foo ┈> [{"m":"foo"}] │ ├── /bar ┈> [{"m":"foo/bar"}] @@ -28,10 +23,11 @@ it("readme example works", () => { │ ├── /** ┈> [{"m":"foo/**","order":"2"}] ├── /** ┈> [{"m":"/**","order":"1"}]" `); + }); - const matches = router.matchAll("/foo/bar/baz"); - - expect(matches).to.toMatchInlineSnapshot(` + it("matches /foo/bar/baz pattern", () => { + const matches = matchAllRoutes(router, "/foo/bar/baz"); + expect(matches).to.toMatchInlineSnapshot(` [ { "m": "/**", @@ -51,10 +47,11 @@ it("readme example works", () => { }, ] `); + }); }); describe("route matcher", () => { - const routes = createRoutes([ + const router = createRouter([ "/", "/foo", "/foo/*", @@ -69,10 +66,8 @@ describe("route matcher", () => { "/cart", ]); - const router = createRouter({ routes }); - it("snapshot", () => { - expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ┈> [/] ├── /foo ┈> [/foo] │ ├── /bar ┈> [/foo/bar] @@ -90,14 +85,14 @@ describe("route matcher", () => { }); it("can match routes", () => { - expect(router.matchAll("/")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/")).to.toMatchInlineSnapshot(` [ { "path": "/", }, ] `); - expect(router.matchAll("/foo")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/foo")).to.toMatchInlineSnapshot(` [ { "path": "/foo/**", @@ -107,7 +102,7 @@ describe("route matcher", () => { }, ] `); - expect(router.matchAll("/foo/bar")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/foo/bar")).to.toMatchInlineSnapshot(` [ { "path": "/foo/**", @@ -120,7 +115,7 @@ describe("route matcher", () => { }, ] `); - expect(router.matchAll("/foo/baz")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/foo/baz")).to.toMatchInlineSnapshot(` [ { "path": "/foo/**", @@ -136,7 +131,7 @@ describe("route matcher", () => { }, ] `); - expect(router.matchAll("/foo/123/sub")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/foo/123/sub")).to.toMatchInlineSnapshot(` [ { "path": "/foo/**", @@ -146,7 +141,7 @@ describe("route matcher", () => { }, ] `); - expect(router.matchAll("/foo/123")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/foo/123")).to.toMatchInlineSnapshot(` [ { "path": "/foo/**", @@ -160,42 +155,47 @@ describe("route matcher", () => { it("trailing slash", () => { // Defined with trailing slash - expect(router.matchAll("/with-trailing")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/with-trailing")).to.toMatchInlineSnapshot(` [ { "path": "/with-trailing/", }, ] `); - expect(router.matchAll("/with-trailing")).toMatchObject( - router.matchAll("/with-trailing/"), + expect(matchAllRoutes(router, "/with-trailing")).toMatchObject( + matchAllRoutes(router, "/with-trailing/"), ); // Defined without trailing slash - expect(router.matchAll("/without-trailing")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/without-trailing")).to + .toMatchInlineSnapshot(` [ { "path": "/without-trailing", }, ] `); - expect(router.matchAll("/without-trailing")).toMatchObject( - router.matchAll("/without-trailing/"), + expect(matchAllRoutes(router, "/without-trailing")).toMatchObject( + matchAllRoutes(router, "/without-trailing/"), ); }); it("prefix overlap", () => { - expect(router.matchAll("/c/123")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/c/123")).to.toMatchInlineSnapshot(` [ { "path": "/c/**", }, ] `); - expect(router.matchAll("/c/123")).toMatchObject(router.matchAll("/c/123/")); - expect(router.matchAll("/c/123")).toMatchObject(router.matchAll("/c")); + expect(matchAllRoutes(router, "/c/123")).toMatchObject( + matchAllRoutes(router, "/c/123/"), + ); + expect(matchAllRoutes(router, "/c/123")).toMatchObject( + matchAllRoutes(router, "/c"), + ); - expect(router.matchAll("/cart")).to.toMatchInlineSnapshot(` + expect(matchAllRoutes(router, "/cart")).to.toMatchInlineSnapshot(` [ { "path": "/cart", diff --git a/tests/router.test.ts b/tests/router.test.ts index e0fa253..72ef6f3 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -1,6 +1,7 @@ +import type { RouterContext, RouteData } from "../src/types"; import { describe, it, expect } from "vitest"; -import { createRouter, RadixRouter } from "../src"; -import { formatTree } from "./_utils"; +import { createRouter, formatTree } from "./_utils"; +import { addRoute, findRoute, removeRoute } from "../src"; type TestRoute = { data: { path: string }; @@ -15,27 +16,34 @@ export function createTestRoutes(paths: string[]): Record { } function testRouter( - paths: string[], - before?: (ctx: { routes: TestRoutes; router: RadixRouter }) => void, + routes: string[] | Record, + before?: (router: RouterContext) => void, tests?: TestRoutes, ) { - const routes = createTestRoutes(paths); - const router = createRouter({ routes }); + const router = createRouter(routes); if (!tests) { - tests = Object.fromEntries( - paths.map((path) => [ - path, - { - data: { path }, - }, - ]), - ); + tests = Array.isArray(routes) + ? Object.fromEntries( + routes.map((path) => [ + path, + { + data: { path }, + }, + ]), + ) + : Object.fromEntries( + Object.keys(routes).map((path) => [ + path, + { + data: { path }, + }, + ]), + ); } - if (before) { it("before", () => { - before({ routes, router }); + before(router); }); } @@ -43,7 +51,7 @@ function testRouter( it.skipIf(tests[path]?.skip)( `lookup ${path} should be ${JSON.stringify(tests[path])}`, () => { - expect(router.lookup(path)).to.toMatchObject(tests[path]!); + expect(findRoute(router, path)).to.toMatchObject(tests[path]!); }, ); } @@ -53,8 +61,8 @@ describe("Router lookup", function () { describe("static routes", () => { testRouter( ["/", "/route", "/another-router", "/this/is/yet/another/route"], - (ctx) => - expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + (router) => + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ┈> [/] ├── /route ┈> [/route] ├── /another-router ┈> [/another-router] @@ -74,8 +82,8 @@ describe("Router lookup", function () { "carbon/:element/test/:testing", "this/:route/has/:cool/stuff", ], - (ctx) => - expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + (router) => + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /carbon │ ├── /* ┈> [carbon/:element] @@ -115,8 +123,8 @@ describe("Router lookup", function () { testRouter( ["/", "/:a", "/:a/:y/:x/:b", "/:a/:x/:b", "/:a/:b"], - (ctx) => - expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot( + (router) => + expect(formatTree(router.root)).toMatchInlineSnapshot( ` " ┈> [/] ├── /* ┈> [/:a] @@ -168,8 +176,8 @@ describe("Router lookup", function () { "/:owner/:repo/:packageAndRefOrSha", "/:owner/:repo/:npmOrg/:packageAndRefOrSha", ], - (ctx) => - expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot( + (router) => + expect(formatTree(router.root)).toMatchInlineSnapshot( ` " ┈> [/] ├── /* ┈> [/:packageAndRefOrSha] @@ -203,8 +211,8 @@ describe("Router lookup", function () { describe("should be able to perform wildcard lookups", () => { testRouter( ["polymer/**:id", "polymer/another/route", "route/:p1/something/**:rest"], - (ctx) => - expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + (router) => + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /polymer │ ├── /another @@ -236,8 +244,8 @@ describe("Router lookup", function () { describe("unnamed placeholders", function () { testRouter( ["polymer/**", "polymer/route/*"], - (ctx) => - expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + (router) => + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /polymer │ ├── /route @@ -265,8 +273,8 @@ describe("Router lookup", function () { const mixedPath = "/files/:category/:id,name=:name.txt"; testRouter( [mixedPath], - (ctx) => - expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + (router) => + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /files │ ├── /* @@ -284,8 +292,8 @@ describe("Router lookup", function () { describe("should be able to match routes with trailing slash", function () { testRouter( ["route/without/trailing/slash", "route/with/trailing/slash/"], - (ctx) => - expect(formatTree(ctx.router.ctx.root)).toMatchInlineSnapshot(` + (router) => + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /route │ ├── /without @@ -315,28 +323,29 @@ describe("Router lookup", function () { describe("Router insert", () => { it("should be able to insert nodes correctly into the tree", () => { - const router = createRouter({ - routes: createTestRoutes([ - "hello", - "cool", - "hi", - "helium", - "/choo", - "//choo", - "coooool", - "chrome", - "choot", - "choot/:choo", - "ui/**", - "ui/components/**", - "/api/v1", - "/api/v2", - "/api/v3", - ]), + const router = createRouter([ + "hello", + "cool", + "hi", + "helium", + "/choo", + "//choo", + "coooool", + "chrome", + "choot", + "choot/:choo", + "ui/**", + "ui/components/**", + "/api/v1", + "/api/v2", + "/api/v3", + ]); + addRoute(router, "/api/v3", { + data: { path: "/api/v3" }, + overridden: true, }); - router.insert("/api/v3", { data: { path: "/api/v3" }, overridden: true }); - expect(formatTree(router.ctx.root)).toMatchInlineSnapshot(` + expect(formatTree(router.root)).toMatchInlineSnapshot(` " ├── /hello ┈> [hello] ├── /cool ┈> [cool] @@ -361,58 +370,52 @@ describe("Router insert", () => { describe("Router remove", function () { it("should be able to remove nodes", function () { - const router = createRouter({ - routes: createTestRoutes([ - "hello", - "cool", - "hi", - "helium", - "coooool", - "chrome", - "choot", - "choot/:choo", - "ui/**", - "ui/components/**", - ]), - }); + const router = createRouter([ + "hello", + "cool", + "hi", + "helium", + "coooool", + "chrome", + "choot", + "choot/:choo", + "ui/**", + "ui/components/**", + ]); - router.remove("choot"); - expect(router.lookup("choot")).to.deep.equal(undefined); + removeRoute(router, "choot"); + expect(findRoute(router, "choot")).to.deep.equal(undefined); - expect(router.lookup("ui/components/snackbars")).to.deep.equal({ + expect(findRoute(router, "ui/components/snackbars")).to.deep.equal({ data: { path: "ui/components/**" }, params: { _: "snackbars" }, }); - router.remove("ui/components/**"); - expect(router.lookup("ui/components/snackbars")).to.deep.equal({ + removeRoute(router, "ui/components/**"); + expect(findRoute(router, "ui/components/snackbars")).to.deep.equal({ data: { path: "ui/**" }, params: { _: "components/snackbars" }, }); }); it("removes data but does not delete a node if it has children", function () { - const router = createRouter({ - routes: createTestRoutes(["a/b", "a/b/:param1"]), - }); + const router = createRouter(["a/b", "a/b/:param1"]); - router.remove("a/b"); - expect(router.lookup("a/b")).to.deep.equal(undefined); - expect(router.lookup("a/b/c")).to.deep.equal({ + removeRoute(router, "a/b"); + expect(findRoute(router, "a/b")).to.deep.equal(undefined); + expect(findRoute(router, "a/b/c")).to.deep.equal({ params: { param1: "c" }, data: { path: "a/b/:param1" }, }); }); it("should be able to remove placeholder routes", function () { - const router = createRouter({ - routes: createTestRoutes([ - "placeholder/:choo", - "placeholder/:choo/:choo2", - ]), - }); + const router = createRouter([ + "placeholder/:choo", + "placeholder/:choo/:choo2", + ]); - expect(router.lookup("placeholder/route")).to.deep.equal({ + expect(findRoute(router, "placeholder/route")).to.deep.equal({ data: { path: "placeholder/:choo" }, params: { choo: "route", @@ -420,10 +423,10 @@ describe("Router remove", function () { }); // TODO - // router.remove("placeholder/:choo"); - // expect(router.lookup("placeholder/route")).to.deep.equal(undefined); + // removeRoute(router,"placeholder/:choo"); + // expect(findRoute(router,"placeholder/route")).to.deep.equal(undefined); - expect(router.lookup("placeholder/route/route2")).to.deep.equal({ + expect(findRoute(router, "placeholder/route/route2")).to.deep.equal({ data: { path: "placeholder/:choo/:choo2" }, params: { choo: "route", @@ -433,16 +436,14 @@ describe("Router remove", function () { }); it("should be able to remove wildcard routes", function () { - const router = createRouter({ - routes: createTestRoutes(["ui/**", "ui/components/**"]), - }); + const router = createRouter(["ui/**", "ui/components/**"]); - expect(router.lookup("ui/components/snackbars")).to.deep.equal({ + expect(findRoute(router, "ui/components/snackbars")).to.deep.equal({ data: { path: "ui/components/**" }, params: { _: "snackbars" }, }); - router.remove("ui/components/**"); - expect(router.lookup("ui/components/snackbars")).to.deep.equal({ + removeRoute(router, "ui/components/**"); + expect(findRoute(router, "ui/components/snackbars")).to.deep.equal({ data: { path: "ui/**" }, params: { _: "components/snackbars" }, }); From 5e3f74281dc28dd1252b7c7d55c85390f547766b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 20:06:42 +0200 Subject: [PATCH 11/15] rename to rou3 --- README.md | 63 +++++++++++++++++++++---------------------- benchmark/routers.mjs | 20 +++++++------- changelog.config.json | 3 --- package.json | 8 +++--- pnpm-lock.yaml | 6 ++--- 5 files changed, 48 insertions(+), 52 deletions(-) delete mode 100644 changelog.config.json diff --git a/README.md b/README.md index 6094874..8d2569d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# 🌳 radix3 +# 🌳 rou3 -[![npm version](https://img.shields.io/npm/v/radix3)](https://npmjs.com/package/radix3) -[![npm downloads](https://img.shields.io/npm/dm/radix3)](https://npmjs.com/package/radix3) +[![npm version](https://img.shields.io/npm/v/rou3)](https://npmjs.com/package/rou3) +[![npm downloads](https://img.shields.io/npm/dm/rou3)](https://npmjs.com/package/rou3) Lightweight and fast router for JavaScript. > [!NOTE] -> You are on the main branch looking at v2 docs. See [v1 branch](https://github.com/unjs/radix3/tree/v1) for current release. +> Radix3 migrated to Rou3! See [#108](https://github.com/unjs/radix3/issues/108) for notes and [radix3 branch](https://github.com/unjs/rou3/tree/radix3) for legacy codebase. ## Usage @@ -20,19 +20,19 @@ Lightweight and fast router for JavaScript. ```sh # ✨ Auto-detect -npx nypm install radix3 +npx nypm install rou3 # npm -npm install radix3 +npm install rou3 # yarn -yarn add radix3 +yarn add rou3 # pnpm -pnpm install radix3 +pnpm install rou3 # bun -bun install radix3 +bun install rou3 ``` @@ -50,7 +50,7 @@ import { findRoute, removeRoute, matchAllRoutes, -} from "radix3"; +} from "rou3"; ``` **CommonJS** (Legacy Node.js) @@ -62,7 +62,7 @@ const { findRoute, removeRoute, matchAllRoutes, -} = require("radix3"); +} = require("rou3"); ``` **CDN** (Deno, Bun and Browsers) @@ -74,7 +74,7 @@ import { findRoute, removeRoute, matchAllRoutes, -} from "https://esm.sh/radix3"; +} from "https://esm.sh/rou3"; ``` @@ -82,7 +82,7 @@ import { **Create a router instance and insert routes:** ```js -import { createRouter, addRoute } from "radix3"; +import { createRouter, addRoute } from "rou3"; const router = createRouter(/* options */); @@ -128,22 +128,21 @@ See [benchmark](./benchmark). ## License -Based on original work of [`charlieduong94/radix-router`](https://github.com/charlieduong94/radix-router) -by [Charlie Duong](https://github.com/charlieduong94) (MIT) - -[MIT](./LICENSE) - Made with ❤️ - - - -[npm-version-src]: https://img.shields.io/npm/v/radix3?style=flat&colorA=18181B&colorB=F0DB4F -[npm-version-href]: https://npmjs.com/package/radix3 -[npm-downloads-src]: https://img.shields.io/npm/dm/radix3?style=flat&colorA=18181B&colorB=F0DB4F -[npm-downloads-href]: https://npmjs.com/package/radix3 -[codecov-src]: https://img.shields.io/codecov/c/gh/unjs/radix3/main?style=flat&colorA=18181B&colorB=F0DB4F -[codecov-href]: https://codecov.io/gh/unjs/radix3 -[bundle-src]: https://img.shields.io/bundlephobia/minzip/radix3?style=flat&colorA=18181B&colorB=F0DB4F -[bundle-href]: https://bundlephobia.com/result?p=radix3 -[license-src]: https://img.shields.io/github/license/unjs/radix3.svg?style=flat&colorA=18181B&colorB=F0DB4F -[license-href]: https://github.com/unjs/radix3/blob/main/LICENSE -[jsdocs-src]: https://img.shields.io/badge/jsDocs.io-reference-18181B?style=flat&colorA=18181B&colorB=F0DB4F -[jsdocs-href]: https://www.jsdocs.io/package/radix3 + + +Published under the [MIT](https://github.com/unjs/h3/blob/main/LICENSE) license. +Made by [@pi0](https://github.com/pi0) and [community](https://github.com/unjs/h3/graphs/contributors) 💛 +

+ + + + + + + + +--- + +_🤖 auto updated with [automd](https://automd.unjs.io)_ + + diff --git a/benchmark/routers.mjs b/benchmark/routers.mjs index 7d23632..08f9adf 100644 --- a/benchmark/routers.mjs +++ b/benchmark/routers.mjs @@ -1,5 +1,5 @@ -import * as radix3 from '../dist/index.mjs' -import * as radix3V1 from 'radix3-v1' +import * as rou3 from '../dist/index.mjs' +import * as radix3 from 'radix3' import MedlyRouter from '@medley/router' import { RegExpRouter as HonoRegExpRouter } from 'hono/router/reg-exp-router' import { TrieRouter as HonoTrieRouter } from 'hono/router/trie-router' @@ -14,17 +14,17 @@ class BaseRouter { } } -// https://github.com/unjs/radix3 +// https://github.com/unjs/rou3 -class Radix3 extends BaseRouter { +class Rou3 extends BaseRouter { init() { - this.router = radix3.createRouter() + this.router = rou3.createRouter() for (const route of this.routes) { - radix3.addRoute(this.router, route.path, { [route.method]: noop }) + rou3.addRoute(this.router, route.path, { [route.method]: noop }) } } match(request) { - const match = radix3.findRoute(this.router, request.path, { ignoreParams: !this.withParams }) + const match = rou3.findRoute(this.router, request.path, { ignoreParams: !this.withParams }) return { handler: match.data[request.method], params: this.withParams ? match.params : undefined @@ -32,9 +32,9 @@ class Radix3 extends BaseRouter { } } -class Radix3V1 extends BaseRouter { +class Radix3 extends BaseRouter { init() { - this.router = radix3V1.createRouter() + this.router = radix3.createRouter() for (const route of this.routes) { this.router.insert(route.path, { [route.method]: noop }) } @@ -143,8 +143,8 @@ class KoaTree extends BaseRouter { } export const routers = { + 'rout3': Rou3, radix3: Radix3, - 'radix3-v1': Radix3V1, medley: Medley, 'hono-regexp': HonoRegExp, 'hono-trie': HonoTrie, diff --git a/changelog.config.json b/changelog.config.json deleted file mode 100644 index a604bc0..0000000 --- a/changelog.config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "github": "unjs/radix3" -} diff --git a/package.json b/package.json index e840101..6f967ae 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "radix3", - "version": "1.1.2", + "name": "rou3", + "version": "0.0.1", "description": "Lightweight and fast router for JavaScript based on Radix Tree", - "repository": "unjs/radix3", + "repository": "unjs/rou3", "license": "MIT", "sideEffects": false, "type": "module", @@ -48,7 +48,7 @@ "listhen": "^1.7.2", "mitata": "^0.1.11", "prettier": "^3.3.2", - "radix3-v1": "npm:radix3@1.1.2", + "radix3": "^1.1.2", "standard-version": "^9.5.0", "trek-router": "^1.2.0", "typescript": "^5.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19625aa..c4a8f05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,9 +57,9 @@ importers: prettier: specifier: ^3.3.2 version: 3.3.2 - radix3-v1: - specifier: npm:radix3@1.1.2 - version: radix3@1.1.2 + radix3: + specifier: ^1.1.2 + version: 1.1.2 standard-version: specifier: ^9.5.0 version: 9.5.0 From 0148ecd5b15453d3e7284c15fb5ebafda53a356f Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 20:07:30 +0200 Subject: [PATCH 12/15] typo in benchmark title --- benchmark/routers.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/routers.mjs b/benchmark/routers.mjs index 08f9adf..08d3146 100644 --- a/benchmark/routers.mjs +++ b/benchmark/routers.mjs @@ -143,7 +143,7 @@ class KoaTree extends BaseRouter { } export const routers = { - 'rout3': Rou3, + 'rou3': Rou3, radix3: Radix3, medley: Medley, 'hono-regexp': HonoRegExp, From f1a7765059f2b38eff43378fdbef79001394b36e Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 20:31:08 +0200 Subject: [PATCH 13/15] refactor: simplify node props --- src/operations/_utils.ts | 6 +++--- src/operations/add.ts | 20 ++++++++++---------- src/operations/find.ts | 10 +++++----- src/operations/match.ts | 12 +++++------- src/operations/remove.ts | 24 ++++++++++++------------ src/types.ts | 6 +++--- tests/_utils.ts | 6 +++--- 7 files changed, 41 insertions(+), 43 deletions(-) diff --git a/src/operations/_utils.ts b/src/operations/_utils.ts index b812399..4f56731 100644 --- a/src/operations/_utils.ts +++ b/src/operations/_utils.ts @@ -20,9 +20,9 @@ export function getParamMatcher(segment: string): string | RegExp { export function _isEmptyNode(node: Node) { return ( node.data === undefined && - node.staticChildren === undefined && - node.paramChild === undefined && - node.wildcardChild === undefined + node.static === undefined && + node.param === undefined && + node.wildcard === undefined ); } diff --git a/src/operations/add.ts b/src/operations/add.ts index f1e5cf7..d351ffe 100644 --- a/src/operations/add.ts +++ b/src/operations/add.ts @@ -23,10 +23,10 @@ export function addRoute( // Wildcard if (segment.startsWith("**")) { - if (!node.wildcardChild) { - node.wildcardChild = { key: "**" }; + if (!node.wildcard) { + node.wildcard = { key: "**" }; } - node = node.wildcardChild; + node = node.wildcard; nodeParams.push({ index: i, name: segment.split(":")[1] || "_", @@ -36,10 +36,10 @@ export function addRoute( // Param if (segment === "*" || segment.includes(":")) { - if (!node.paramChild) { - node.paramChild = { key: "*" }; + if (!node.param) { + node.param = { key: "*" }; } - node = node.paramChild; + node = node.param; nodeParams.push({ index: i, name: @@ -51,15 +51,15 @@ export function addRoute( } // Static - const child = node.staticChildren?.[segment]; + const child = node.static?.[segment]; if (child) { node = child; } else { const staticNode = { key: segment }; - if (!node.staticChildren) { - node.staticChildren = Object.create(null); + if (!node.static) { + node.static = Object.create(null); } - node.staticChildren![segment] = staticNode; + node.static![segment] = staticNode; node = staticNode; } } diff --git a/src/operations/find.ts b/src/operations/find.ts index b50bc24..9b7f620 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -50,7 +50,7 @@ function _find( const segment = segments[index]; // 1. Static - const staticChild = node.staticChildren?.[segment]; + const staticChild = node.static?.[segment]; if (staticChild) { const matchedNode = _find(ctx, staticChild, segments, index + 1); if (matchedNode) { @@ -59,16 +59,16 @@ function _find( } // 2. Param - if (node.paramChild) { - const nextNode = _find(ctx, node.paramChild, segments, index + 1); + if (node.param) { + const nextNode = _find(ctx, node.param, segments, index + 1); if (nextNode) { return nextNode; } } // 3. Wildcard - if (node.wildcardChild) { - return node.wildcardChild; + if (node.wildcard) { + return node.wildcard; } // No match diff --git a/src/operations/match.ts b/src/operations/match.ts index 87b07d1..0faa948 100644 --- a/src/operations/match.ts +++ b/src/operations/match.ts @@ -28,21 +28,19 @@ function _matchAll( } // 2. Static - const staticChild = node.staticChildren?.[segment]; + const staticChild = node.static?.[segment]; if (staticChild) { matchedNodes.unshift(..._matchAll(ctx, staticChild, segments, index + 1)); } // 3. Param - if (node.paramChild) { - matchedNodes.unshift( - ..._matchAll(ctx, node.paramChild, segments, index + 1), - ); + if (node.param) { + matchedNodes.unshift(..._matchAll(ctx, node.param, segments, index + 1)); } // 4. Wildcard - if (node.wildcardChild?.data) { - matchedNodes.unshift(node.wildcardChild.data); + if (node.wildcard?.data) { + matchedNodes.unshift(node.wildcard.data); } // No match diff --git a/src/operations/remove.ts b/src/operations/remove.ts index 959d235..a59bae7 100644 --- a/src/operations/remove.ts +++ b/src/operations/remove.ts @@ -36,10 +36,10 @@ function _remove( // Param if (segment === "*") { - if (node.paramChild) { - _remove(node.paramChild, segments, index + 1); - if (_isEmptyNode(node.paramChild)) { - node.paramChild = undefined; + if (node.param) { + _remove(node.param, segments, index + 1); + if (_isEmptyNode(node.param)) { + node.param = undefined; } } return; @@ -47,23 +47,23 @@ function _remove( // Wildcard if (segment === "**") { - if (node.wildcardChild) { - _remove(node.wildcardChild, segments, index + 1); - if (_isEmptyNode(node.wildcardChild)) { - node.wildcardChild = undefined; + if (node.wildcard) { + _remove(node.wildcard, segments, index + 1); + if (_isEmptyNode(node.wildcard)) { + node.wildcard = undefined; } } return; } // Static - const childNode = node.staticChildren?.[segment]; + const childNode = node.static?.[segment]; if (childNode) { _remove(childNode, segments, index + 1); if (_isEmptyNode(childNode)) { - delete node.staticChildren![segment]; - if (Object.keys(node.staticChildren!).length === 0) { - node.staticChildren = undefined; + delete node.static![segment]; + if (Object.keys(node.static!).length === 0) { + node.static = undefined; } } } diff --git a/src/types.ts b/src/types.ts index 297429f..4793d78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,9 +23,9 @@ export type NODE_TYPE = _NODE_TYPES[keyof _NODE_TYPES]; export interface Node { key: string; - staticChildren?: Record>; - paramChild?: Node; - wildcardChild?: Node; + static?: Record>; + param?: Node; + wildcard?: Node; index?: number; data?: T; diff --git a/tests/_utils.ts b/tests/_utils.ts index 829f6e1..e8c3dcd 100644 --- a/tests/_utils.ts +++ b/tests/_utils.ts @@ -27,9 +27,9 @@ export function formatTree( ); const childrenArray = [ - ...Object.values(node.staticChildren || []), - node.paramChild, - node.wildcardChild, + ...Object.values(node.static || []), + node.param, + node.wildcard, ].filter(Boolean) as Node[]; for (const [index, child] of childrenArray.entries()) { const lastChild = index === childrenArray.length - 1; From 775e029b806df9e3e6ac02fbeba43ff9146b6f65 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 4 Jul 2024 20:49:39 +0200 Subject: [PATCH 14/15] simplify route data types --- src/context.ts | 4 ++-- src/operations/add.ts | 8 ++------ src/operations/match.ts | 19 ++++++++----------- src/operations/remove.ts | 7 ++----- src/types.ts | 11 +++++------ 5 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/context.ts b/src/context.ts index ffc0acd..f580e2b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,9 +1,9 @@ -import type { RouterContext, RouteData, RouterOptions } from "./types"; +import type { RouterContext, RouterOptions } from "./types"; /** * Create a new router context. */ -export function createRouter( +export function createRouter( options: RouterOptions = {}, ): RouterContext { const ctx: RouterContext = { diff --git a/src/operations/add.ts b/src/operations/add.ts index d351ffe..5b85896 100644 --- a/src/operations/add.ts +++ b/src/operations/add.ts @@ -1,14 +1,10 @@ -import type { RouterContext, Node, RouteData } from "../types"; +import type { RouterContext, Node } from "../types"; import { getParamMatcher, normalizeTrailingSlash, splitPath } from "./_utils"; /** * Add a route to the router context. */ -export function addRoute( - ctx: RouterContext, - _path: string, - data: T, -) { +export function addRoute(ctx: RouterContext, _path: string, data: T) { const path = normalizeTrailingSlash(ctx, _path); const segments = splitPath(path); diff --git a/src/operations/match.ts b/src/operations/match.ts index 0faa948..c072fe6 100644 --- a/src/operations/match.ts +++ b/src/operations/match.ts @@ -1,24 +1,21 @@ -import type { RouterContext, Node, RouteData } from "../types"; +import type { RouterContext, Node } from "../types"; import { _getParams, normalizeTrailingSlash, splitPath } from "./_utils"; /** * Find all route patterns that match the given path. */ -export function matchAllRoutes( - ctx: RouterContext, - _path: string, -): RouteData[] { +export function matchAllRoutes(ctx: RouterContext, _path: string): T[] { const path = normalizeTrailingSlash(ctx, _path); - return _matchAll(ctx, ctx.root, splitPath(path), 0) as RouteData[]; + return _matchAll(ctx, ctx.root, splitPath(path), 0) as T[]; } -function _matchAll( - ctx: RouterContext, - node: Node, +function _matchAll( + ctx: RouterContext, + node: Node, segments: string[], index: number, -): RouteData[] { - const matchedNodes: RouteData[] = []; +): T[] { + const matchedNodes: T[] = []; const segment = segments[index]; diff --git a/src/operations/remove.ts b/src/operations/remove.ts index a59bae7..05d8c78 100644 --- a/src/operations/remove.ts +++ b/src/operations/remove.ts @@ -1,4 +1,4 @@ -import type { RouterContext, Node, RouteData } from "../types"; +import type { RouterContext, Node } from "../types"; import { _getParams, _isEmptyNode, @@ -9,10 +9,7 @@ import { /** * Remove a route from the router context. */ -export function removeRoute( - ctx: RouterContext, - _path: string, -) { +export function removeRoute(ctx: RouterContext, _path: string) { const path = normalizeTrailingSlash(ctx, _path); const segments = splitPath(path); diff --git a/src/types.ts b/src/types.ts index 4793d78..460c1d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,8 @@ -export type RouteData> = T; - export interface RouterOptions { strictTrailingSlash?: boolean; } -export interface RouterContext { +export interface RouterContext { options: RouterOptions; root: Node; static: Record | undefined>; @@ -20,7 +18,7 @@ type _NODE_TYPES = typeof NODE_TYPES; export type NODE_TYPE = _NODE_TYPES[keyof _NODE_TYPES]; -export interface Node { +export interface Node { key: string; static?: Record>; @@ -29,10 +27,11 @@ export interface Node { index?: number; data?: T; + mData?: Record; paramNames?: Array<{ index: number; name: string | RegExp }>; } -export type MatchedRoute = { - data?: T; +export type MatchedRoute = { + data?: T | undefined; params?: Record; }; From 28bf6988f5670952627283c3edd2abae0695a219 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 5 Jul 2024 00:09:34 +0200 Subject: [PATCH 15/15] feat: native method support --- README.md | 18 +++++---- benchmark/bench.mjs | 18 ++------- benchmark/routers.mjs | 14 ++++--- benchmark/test.mjs | 4 ++ eslint.config.mjs | 3 ++ playground.ts | 14 ------- src/operations/_utils.ts | 50 +---------------------- src/operations/add.ts | 66 +++++++++++++++++++------------ src/operations/find.ts | 85 +++++++++++++++++++++++++++------------- src/operations/match.ts | 35 ++++++++++++----- src/operations/remove.ts | 46 +++++++++++++--------- src/types.ts | 6 +-- tests/_utils.ts | 27 ++++++++++--- tests/router.test.ts | 8 ++-- 14 files changed, 211 insertions(+), 183 deletions(-) delete mode 100644 playground.ts diff --git a/README.md b/README.md index 8d2569d..9b2d22d 100644 --- a/README.md +++ b/README.md @@ -86,26 +86,28 @@ import { createRouter, addRoute } from "rou3"; const router = createRouter(/* options */); -addRoute(router, "/path", { payload: "this path" }); -addRoute(router, "/path/:name", { payload: "named route" }); -addRoute(router, "/path/foo/**", { payload: "wildcard route" }); -addRoute(router, "/path/foo/**:name", { payload: "named wildcard route" }); +addRoute(router, "/path", "GET", { payload: "this path" }); +addRoute(router, "/path/:name", "GET", { payload: "named route" }); +addRoute(router, "/path/foo/**", "GET", { payload: "wildcard route" }); +addRoute(router, "/path/foo/**:name", "GET", { + payload: "named wildcard route", +}); ``` **Match route to access matched data:** ```js // Returns { payload: 'this path' } -findRoute(router, "/path"); +findRoute(router, "/path", "GET"); // Returns { payload: 'named route', params: { name: 'fooval' } } -findRoute(router, "/path/fooval"); +findRoute(router, "/path/fooval", "GET"); // Returns { payload: 'wildcard route' } -findRoute(router, "/path/foo/bar/baz"); +findRoute(router, "/path/foo/bar/baz", "GET"); // Returns undefined (no route matched for/) -findRoute(router, "/"); +findRoute(router, "/", "GET"); ``` ## Methods diff --git a/benchmark/bench.mjs b/benchmark/bench.mjs index a6d0196..b8bad82 100644 --- a/benchmark/bench.mjs +++ b/benchmark/bench.mjs @@ -3,22 +3,12 @@ import "./test.mjs" import { routers } from './routers.mjs' import { requests, routes } from './input.mjs' -// Benchmark all routers -group('All requests (with params)', () => { - for (const name in routers) { - const router = new routers[name](routes, true /* params */) - router.init() - bench(name, () => { - for (const request of requests) { - router.match(request) - } - }) - } -}) +const withParams = !process.argv.includes('--no-params') -group('All requests (without params)', () => { +// Benchmark all routers +group(`All requests (match params: ${withParams})`, () => { for (const name in routers) { - const router = new routers[name](routes, false /* params */) + const router = new routers[name](routes, withParams) router.init() bench(name, () => { for (const request of requests) { diff --git a/benchmark/routers.mjs b/benchmark/routers.mjs index 08d3146..7088ced 100644 --- a/benchmark/routers.mjs +++ b/benchmark/routers.mjs @@ -20,13 +20,14 @@ class Rou3 extends BaseRouter { init() { this.router = rou3.createRouter() for (const route of this.routes) { - rou3.addRoute(this.router, route.path, { [route.method]: noop }) + rou3.addRoute(this.router, route.path, route.method, noop) } } match(request) { - const match = rou3.findRoute(this.router, request.path, { ignoreParams: !this.withParams }) + const match = rou3.findRoute(this.router, request.path, request.method, { ignoreParams: !this.withParams }) + if (!match) return undefined // 404 return { - handler: match.data[request.method], + handler: match.data, params: this.withParams ? match.params : undefined } } @@ -41,6 +42,7 @@ class Radix3 extends BaseRouter { } match(request) { const match = this.router.lookup(request.path) + if (!match || !match[request.method]) return undefined // 404 return { handler: match[request.method], params: this.withParams ? match.params : undefined @@ -59,8 +61,8 @@ class Medley extends BaseRouter { } } match(request) { - // eslint-disable-next-line unicorn/no-array-callback-reference const match = this.router.find(request.path) + if (!match.store[request.method]) return undefined // 404 return { handler: match.store[request.method], params: this.withParams ? match.params : undefined @@ -80,6 +82,7 @@ class HonoRegExp extends BaseRouter { match(request) { // [[handler, paramIndexMap][], paramArray] const match = this.router.match(request.method, request.path) + if (!match || match[0]?.length === 0) return undefined // 404 let params if (this.withParams && match[1]) { // TODO: Where does hono do it ?! @@ -109,6 +112,7 @@ class HonoTrie extends BaseRouter { match(request) { // [[handler, paramIndexMap][], paramArray] const match = this.router.match(request.method, request.path) + if (!match || match[0]?.length === 0) return undefined // 404 return { handler: match[0][0][0], params: this.withParams ? match[0][0][1] : undefined @@ -126,8 +130,8 @@ class KoaTree extends BaseRouter { } } match(request) { - // eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-method-this-argument const match = this.router.find(request.method, request.path) + if (!match || !match.handle) return undefined // 404 let params if (this.withParams && match.params) { params = Object.create(null) diff --git a/benchmark/test.mjs b/benchmark/test.mjs index ae70fee..0e6e4f9 100644 --- a/benchmark/test.mjs +++ b/benchmark/test.mjs @@ -15,6 +15,10 @@ for (const name in routers) { issues.push(`${reqLabel}: No route matched`) continue } + if (router.match({ ...request, method: "OPTIONS" })) { + issues.push(`${reqLabel}: Wrongly matched OPTIONS method`) + continue + } if (typeof match.handler !== 'function') { issues.push(`${reqLabel}: No handler returned`) continue diff --git a/eslint.config.mjs b/eslint.config.mjs index 9f96510..8897fa1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,5 +9,8 @@ export default unjs({ "unicorn/prevent-abbreviations": 0, "no-unused-expressions": 0, "unicorn/no-for-loop": 0, + "unicorn/prefer-regexp-test": 0, + "unicorn/no-array-callback-reference": 0, + "unicorn/no-array-method-this-argument": 0, }, }); diff --git a/playground.ts b/playground.ts deleted file mode 100644 index 5afcf34..0000000 --- a/playground.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createRouter } from "./src"; - -const router = createRouter(); - -router.insert("/path", { payload: "this path" }); -router.insert("/path/:name", { payload: "named route" }); -router.insert("/path/foo/**", { payload: "wildcard route" }); - -console.log([ - router.lookup("/path"), - router.lookup("/path/fooval"), - router.lookup("/path/foo/bar/baz"), - router.lookup("/"), -]); diff --git a/src/operations/_utils.ts b/src/operations/_utils.ts index 4f56731..0651a19 100644 --- a/src/operations/_utils.ts +++ b/src/operations/_utils.ts @@ -1,57 +1,9 @@ -import type { Node, MatchedRoute, RouterContext } from "../types"; +import type { RouterContext } from "../types"; export function splitPath(path: string) { return path.split("/").filter(Boolean); } -export function getParamMatcher(segment: string): string | RegExp { - const PARAMS_RE = /:\w+|[^:]+/g; - const params = [...segment.matchAll(PARAMS_RE)]; - if (params.length === 1) { - return params[0][0].slice(1); - } - const sectionRegexString = segment.replace( - /:(\w+)/g, - (_, id) => `(?<${id}>\\w+)`, - ); - return new RegExp(`^${sectionRegexString}$`); -} - -export function _isEmptyNode(node: Node) { - return ( - node.data === undefined && - node.static === undefined && - node.param === undefined && - node.wildcard === undefined - ); -} - -export function _getParams( - segments: string[], - node: Node, -): MatchedRoute["params"] { - const params = Object.create(null); - for (const param of node.paramNames || []) { - const segment = segments[param.index]; - if (typeof param.name === "string") { - params[param.name] = segment; - } else { - const match = segment.match(param.name); - if (match) { - for (const key in match.groups) { - params[key] = match.groups[key]; - } - } - } - } - if (node.key === "**") { - const paramName = - (node.paramNames?.[node.paramNames.length - 1].name as string) || "_"; - params[paramName] = segments.slice(node.index).join("/"); - } - return params; -} - export function normalizeTrailingSlash(ctx: RouterContext, path: string = "/") { if (!ctx.options.strictTrailingSlash) { return path; diff --git a/src/operations/add.ts b/src/operations/add.ts index 5b85896..f857376 100644 --- a/src/operations/add.ts +++ b/src/operations/add.ts @@ -1,18 +1,23 @@ -import type { RouterContext, Node } from "../types"; -import { getParamMatcher, normalizeTrailingSlash, splitPath } from "./_utils"; +import type { RouterContext, Params } from "../types"; +import { normalizeTrailingSlash, splitPath } from "./_utils"; /** * Add a route to the router context. */ -export function addRoute(ctx: RouterContext, _path: string, data: T) { - const path = normalizeTrailingSlash(ctx, _path); - const segments = splitPath(path); +export function addRoute( + ctx: RouterContext, + path: string, + method: string = "", + data?: T, +) { + const _path = normalizeTrailingSlash(ctx, path); + const segments = splitPath(_path); let node = ctx.root; let _unnamedParamIndex = 0; - const nodeParams: Node["paramNames"] = []; + const params: Params = []; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; @@ -23,10 +28,7 @@ export function addRoute(ctx: RouterContext, _path: string, data: T) { node.wildcard = { key: "**" }; } node = node.wildcard; - nodeParams.push({ - index: i, - name: segment.split(":")[1] || "_", - }); + params.push([-i, segment.split(":")[1] || "_"]); break; } @@ -36,13 +38,12 @@ export function addRoute(ctx: RouterContext, _path: string, data: T) { node.param = { key: "*" }; } node = node.param; - nodeParams.push({ - index: i, - name: - segment === "*" - ? `_${_unnamedParamIndex++}` - : (getParamMatcher(segment) as string), - }); + params.push([ + i, + segment === "*" + ? `_${_unnamedParamIndex++}` + : (_getParamMatcher(segment) as string), + ]); continue; } @@ -60,14 +61,29 @@ export function addRoute(ctx: RouterContext, _path: string, data: T) { } } - // Assign data and params to the final node + // Assign index, params and data to the node + const hasParams = params.length > 0; + if (!node.methods) { + node.methods = Object.create(null); + } + node.methods![method] = [data || (null as T), hasParams ? params : undefined]; node.index = segments.length - 1; - node.data = data; - if (nodeParams.length > 0) { - // Dynamic route - node.paramNames = nodeParams; - } else { - // Static route - ctx.static[path] = node; + + // Static + if (!hasParams) { + ctx.static[_path] = node; + } +} + +function _getParamMatcher(segment: string): string | RegExp { + const PARAMS_RE = /:\w+|[^:]+/g; + const params = [...segment.matchAll(PARAMS_RE)]; + if (params.length === 1) { + return params[0][0].slice(1); } + const sectionRegexString = segment.replace( + /:(\w+)/g, + (_, id) => `(?<${id}>\\w+)`, + ); + return new RegExp(`^${sectionRegexString}$`); } diff --git a/src/operations/find.ts b/src/operations/find.ts index 9b7f620..1f2f310 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -1,34 +1,39 @@ -import type { RouterContext, MatchedRoute, Node, RouteData } from "../types"; -import { _getParams, normalizeTrailingSlash, splitPath } from "./_utils"; +import type { RouterContext, MatchedRoute, Node, Params } from "../types"; +import { normalizeTrailingSlash, splitPath } from "./_utils"; /** * Find a route by path. */ -export function findRoute( +export function findRoute( ctx: RouterContext, - _path: string, + path: string, + method: string = "", opts?: { ignoreParams?: boolean }, ): MatchedRoute | undefined { - const path = normalizeTrailingSlash(ctx, _path); + const _path = normalizeTrailingSlash(ctx, path); - const staticNode = ctx.static[path]; - if (staticNode && staticNode.data !== undefined) { - return { data: staticNode.data }; + // Static + const staticNode = ctx.static[_path]; + if (staticNode && staticNode.methods) { + const staticMatch = staticNode.methods[method] || staticNode.methods[""]; + if (staticMatch !== undefined) { + return { data: staticMatch[0] }; + } } - const segments = splitPath(path); - - const node = _find(ctx, ctx.root, segments, 0) as Node | undefined; - if (!node || node.data === undefined) { + // Lookup tree + const segments = splitPath(_path); + const match = _lookupTree(ctx, ctx.root, method, segments, 0); + if (match === undefined) { return; } - const data = node.data; - if (opts?.ignoreParams || (!node.paramNames && node.key !== "**")) { + const [data, paramNames] = match; + if (opts?.ignoreParams || !paramNames) { return { data }; } - const params = _getParams(segments, node); + const params = _getParams(segments, paramNames); return { data, @@ -36,15 +41,19 @@ export function findRoute( }; } -function _find( +function _lookupTree( ctx: RouterContext, - node: Node, + node: Node, + method: string, segments: string[], index: number, -): Node | undefined { +): [Data: T, Params?: Params] | undefined { // End of path if (index === segments.length) { - return node; + if (!node.methods) { + return undefined; + } + return node.methods[method] || node.methods[""]; } const segment = segments[index]; @@ -52,25 +61,47 @@ function _find( // 1. Static const staticChild = node.static?.[segment]; if (staticChild) { - const matchedNode = _find(ctx, staticChild, segments, index + 1); - if (matchedNode) { - return matchedNode; + const match = _lookupTree(ctx, staticChild, method, segments, index + 1); + if (match) { + return match; } } // 2. Param if (node.param) { - const nextNode = _find(ctx, node.param, segments, index + 1); - if (nextNode) { - return nextNode; + const match = _lookupTree(ctx, node.param, method, segments, index + 1); + if (match) { + return match; } } // 3. Wildcard - if (node.wildcard) { - return node.wildcard; + if (node.wildcard && node.wildcard.methods) { + return node.wildcard.methods[method]; } // 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 index c072fe6..764ef27 100644 --- a/src/operations/match.ts +++ b/src/operations/match.ts @@ -1,17 +1,22 @@ import type { RouterContext, Node } from "../types"; -import { _getParams, normalizeTrailingSlash, splitPath } from "./_utils"; +import { normalizeTrailingSlash, splitPath } from "./_utils"; /** * Find all route patterns that match the given path. */ -export function matchAllRoutes(ctx: RouterContext, _path: string): T[] { - const path = normalizeTrailingSlash(ctx, _path); - return _matchAll(ctx, ctx.root, splitPath(path), 0) as T[]; +export function matchAllRoutes( + ctx: RouterContext, + path: string, + method?: string, +): T[] { + const _path = normalizeTrailingSlash(ctx, path); + return _matchAll(ctx, ctx.root, method || "", splitPath(_path), 0) as T[]; } function _matchAll( ctx: RouterContext, node: Node, + method: string, segments: string[], index: number, ): T[] { @@ -20,24 +25,34 @@ function _matchAll( const segment = segments[index]; // 1. Node self data - if (index === segments.length && node.data !== undefined) { - matchedNodes.unshift(node.data); + if (index === segments.length && node.methods) { + const match = node.methods[method] || node.methods[""]; + if (match) { + matchedNodes.unshift(match[0 /* data */]); + } } // 2. Static const staticChild = node.static?.[segment]; if (staticChild) { - matchedNodes.unshift(..._matchAll(ctx, staticChild, segments, index + 1)); + matchedNodes.unshift( + ..._matchAll(ctx, staticChild, method, segments, index + 1), + ); } // 3. Param if (node.param) { - matchedNodes.unshift(..._matchAll(ctx, node.param, segments, index + 1)); + matchedNodes.unshift( + ..._matchAll(ctx, node.param, method, segments, index + 1), + ); } // 4. Wildcard - if (node.wildcard?.data) { - matchedNodes.unshift(node.wildcard.data); + if (node.wildcard && node.wildcard.methods) { + const match = node.wildcard.methods[method] || node.wildcard.methods[""]; + if (match) { + matchedNodes.unshift(match[0 /* data */]); + } } // No match diff --git a/src/operations/remove.ts b/src/operations/remove.ts index 05d8c78..425a9d1 100644 --- a/src/operations/remove.ts +++ b/src/operations/remove.ts @@ -1,31 +1,32 @@ import type { RouterContext, Node } from "../types"; -import { - _getParams, - _isEmptyNode, - normalizeTrailingSlash, - splitPath, -} from "./_utils"; +import { normalizeTrailingSlash, splitPath } from "./_utils"; /** * Remove a route from the router context. */ -export function removeRoute(ctx: RouterContext, _path: string) { - const path = normalizeTrailingSlash(ctx, _path); - - const segments = splitPath(path); - ctx.static[path] = undefined; - return _remove(ctx.root, segments, 0); +export function removeRoute( + ctx: RouterContext, + path: string, + method?: string, +) { + const _path = normalizeTrailingSlash(ctx, path); + const segments = splitPath(_path); + return _remove(ctx.root, method || "", segments, 0); } function _remove( node: Node, + method: string, segments: string[], index: number, ): void /* should delete */ { if (index === segments.length) { - node.data = undefined; - node.index = undefined; - node.paramNames = undefined; + if (node.methods && method in node.methods) { + delete node.methods[method]; + if (Object.keys(node.methods).length === 0) { + node.methods = undefined; + } + } return; } @@ -34,7 +35,7 @@ function _remove( // Param if (segment === "*") { if (node.param) { - _remove(node.param, segments, index + 1); + _remove(node.param, method, segments, index + 1); if (_isEmptyNode(node.param)) { node.param = undefined; } @@ -45,7 +46,7 @@ function _remove( // Wildcard if (segment === "**") { if (node.wildcard) { - _remove(node.wildcard, segments, index + 1); + _remove(node.wildcard, method, segments, index + 1); if (_isEmptyNode(node.wildcard)) { node.wildcard = undefined; } @@ -56,7 +57,7 @@ function _remove( // Static const childNode = node.static?.[segment]; if (childNode) { - _remove(childNode, segments, index + 1); + _remove(childNode, method, segments, index + 1); if (_isEmptyNode(childNode)) { delete node.static![segment]; if (Object.keys(node.static!).length === 0) { @@ -65,3 +66,12 @@ function _remove( } } } + +function _isEmptyNode(node: Node) { + return ( + node.methods === undefined && + node.static === undefined && + node.param === undefined && + node.wildcard === undefined + ); +} diff --git a/src/types.ts b/src/types.ts index 460c1d6..147c6c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,8 @@ type _NODE_TYPES = typeof NODE_TYPES; export type NODE_TYPE = _NODE_TYPES[keyof _NODE_TYPES]; +export type Params = Array<[Index: number, name: string | RegExp]>; + export interface Node { key: string; @@ -26,9 +28,7 @@ export interface Node { wildcard?: Node; index?: number; - data?: T; - mData?: Record; - paramNames?: Array<{ index: number; name: string | RegExp }>; + methods?: Record; } export type MatchedRoute = { diff --git a/tests/_utils.ts b/tests/_utils.ts index e8c3dcd..6b569e3 100644 --- a/tests/_utils.ts +++ b/tests/_utils.ts @@ -1,15 +1,17 @@ -import type { Node, RouteData } from "../src/types"; +import type { Node, RouterContext } from "../src/types"; import { createRouter as _createRouter, addRoute } from "../src"; -export function createRouter(routes: string[] | Record) { - const router = _createRouter(); +export function createRouter< + T extends Record = Record, +>(routes: string[] | Record): RouterContext { + const router = _createRouter(); if (Array.isArray(routes)) { for (const route of routes) { - addRoute(router, route, { path: route }); + addRoute(router, route, "", { path: route } as unknown as T); } } else { for (const [route, data] of Object.entries(routes)) { - addRoute(router, route, data); + addRoute(router, route, "", data); } } return router; @@ -23,7 +25,7 @@ export function formatTree( ) { result.push( // prettier-ignore - `${prefix}${depth === 0 ? "" : "├── "}${node.key ? `/${node.key}` : (depth === 0 ? "" : "")}${node.data === undefined ? "" : ` ┈> [${node.data.path || JSON.stringify(node.data)}]`}`, + `${prefix}${depth === 0 ? "" : "├── "}${node.key ? `/${node.key}` : (depth === 0 ? "" : "")}${_formatMethods(node)}`, ); const childrenArray = [ @@ -44,3 +46,16 @@ export function formatTree( return depth === 0 ? result.join("\n") : result; } + +function _formatMethods(node: Node) { + 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); + return method ? `[${method}]: ${val}` : val; + }) + .join(", ")}]`; +} diff --git a/tests/router.test.ts b/tests/router.test.ts index 72ef6f3..8841f80 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -340,9 +340,9 @@ describe("Router insert", () => { "/api/v2", "/api/v3", ]); - addRoute(router, "/api/v3", { - data: { path: "/api/v3" }, - overridden: true, + + addRoute(router, "/api/v3", "", { + path: "/api/v3(overridden)", }); expect(formatTree(router.root)).toMatchInlineSnapshot(` @@ -363,7 +363,7 @@ describe("Router insert", () => { ├── /api │ ├── /v1 ┈> [/api/v1] │ ├── /v2 ┈> [/api/v2] - │ ├── /v3 ┈> [{"data":{"path":"/api/v3"},"overridden":true}]" + │ ├── /v3 ┈> [/api/v3(overridden)]" `); }); });