From d95de5294ae75c68fec9e50dff7b0e111b08ca72 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Sun, 7 Dec 2025 09:36:51 +0100 Subject: [PATCH] feat(wobe): add new router --- bun.lock | 69 +- packages/wobe-benchmark/package.json | 45 +- packages/wobe-benchmark/router/koaRouter.ts | 4 +- packages/wobe-benchmark/router/wobe.ts | 9 +- packages/wobe-benchmark/startup/elysia.ts | 16 +- packages/wobe/src/Context.ts | 6 +- packages/wobe/src/Wobe.ts | 11 +- packages/wobe/src/adapters/bun/bun.ts | 4 +- packages/wobe/src/adapters/index.ts | 4 +- packages/wobe/src/adapters/node/node.ts | 4 +- packages/wobe/src/router/RadixTree.test.ts | 16 + packages/wobe/src/router/RadixTree.ts | 13 +- .../wobe/src/router/UrlPatternRouter.test.ts | 1680 +++++++++++++++++ packages/wobe/src/router/UrlPatternRouter.ts | 416 ++++ packages/wobe/src/router/index.ts | 28 + 15 files changed, 2242 insertions(+), 83 deletions(-) create mode 100644 packages/wobe/src/router/UrlPatternRouter.test.ts create mode 100644 packages/wobe/src/router/UrlPatternRouter.ts diff --git a/bun.lock b/bun.lock index 7b02688..7086a9a 100644 --- a/bun.lock +++ b/bun.lock @@ -27,12 +27,13 @@ "wobe": "*", }, "devDependencies": { - "elysia": "1.0.16", + "@koa/router": "15.0.0", + "@sinclair/typebox": "0.34.41", + "elysia": "1.4.18", "find-my-way": "9.3.0", "get-port": "7.1.0", - "hono": "4.10.4", - "koa-router": "12.0.1", - "mitata": "0.1.11", + "hono": "4.10.7", + "mitata": "1.0.34", "radix3": "1.1.2", }, }, @@ -175,6 +176,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-FGCijXecmC4IedQ0esdYNlMpx0Jxgf4zceCaMu6fkjWyjgn50ZQtMiqZZQ0Q/77yqPxvtkgZAvt5uGw0gAAjig=="], + "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], + "@commitlint/cli": ["@commitlint/cli@19.3.0", "", { "dependencies": { "@commitlint/format": "^19.3.0", "@commitlint/lint": "^19.2.2", "@commitlint/load": "^19.2.0", "@commitlint/read": "^19.2.1", "@commitlint/types": "^19.0.3", "execa": "^8.0.1", "yargs": "^17.0.0" }, "bin": { "commitlint": "cli.js" } }, "sha512-LgYWOwuDR7BSTQ9OLZ12m7F/qhNY+NpAyPBgo4YNMkACE7lGuUnuQq1yi9hz1KA4+3VqpOYl8H1rY/LYK43v7g=="], "@commitlint/config-conventional": ["@commitlint/config-conventional@19.2.2", "", { "dependencies": { "@commitlint/types": "^19.0.3", "conventional-changelog-conventionalcommits": "^7.0.2" } }, "sha512-mLXjsxUVLYEGgzbxbxicGPggDuyWNkf25Ht23owXIH+zV2pv1eJuzLK3t1gDY5Gp6pxdE60jZnWUY5cvgL3ufw=="], @@ -291,6 +294,8 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@koa/router": ["@koa/router@15.0.0", "", { "dependencies": { "debug": "^4.4.3", "http-errors": "^2.0.1", "koa-compose": "^4.1.0", "path-to-regexp": "^8.3.0" } }, "sha512-qAoA07CndM5XuBZbTbsnvUj1RNVZtwOvO9xGz7CCE/t4nSopI+xEiFGHyJS1UuSDCt8cJZ9vfCvqbAFga+0y7w=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -367,7 +372,11 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.1", "", {}, "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg=="], - "@sinclair/typebox": ["@sinclair/typebox@0.32.27", "", {}, "sha512-JHRrubCKiXi6VKlbBTpTQnExkUFasPMIaXCJYJhqVBGLliQVt1yBZZgiZo3/uSmvAdXlIIdGoTAT6RB09L0QqA=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], @@ -499,7 +508,7 @@ "conventional-commits-parser": ["conventional-commits-parser@5.0.0", "", { "dependencies": { "JSONStream": "^1.3.5", "is-text-path": "^2.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "conventional-commits-parser": "cli.mjs" } }, "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA=="], - "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="], @@ -517,7 +526,7 @@ "dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], - "debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -533,7 +542,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "elysia": ["elysia@1.0.16", "", { "dependencies": { "@sinclair/typebox": "^0.32.15", "cookie": "^0.6.0", "elysia": "1.0.15", "eventemitter3": "^5.0.1", "fast-decode-uri-component": "^1.0.1", "fast-querystring": "^1.1.2", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-6TvgaTA8o3GwUkBZ2ulm4NBicUZxSLWS5mRFB8sckKs//ybgM6O5GqOmI6VGqWSopAHI3pXYd8YhII69agCj9A=="], + "elysia": ["elysia@1.4.18", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "0.2.5", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-A6BhlipmSvgCy69SBgWADYZSdDIj3fT2gk8/9iMAC8iD+aGcnCr0fitziX0xr36MFDs/fsvVp8dWqxeq1VCgKg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -563,7 +572,7 @@ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "exact-mirror": ["exact-mirror@0.2.5", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-u8Wu2lO8nio5lKSJubOydsdNtQmH8ENba5m0nbQYmTvsjksXKYIS1nSShdDlO8Uem+kbo+N6eD5I03cpZ+QsRQ=="], "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], @@ -573,6 +582,8 @@ "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + "file-type": ["file-type@21.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="], + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], "find-my-way": ["find-my-way@9.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg=="], @@ -615,18 +626,20 @@ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], - "hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="], + "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="], "import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="], @@ -663,8 +676,6 @@ "koa-compose": ["koa-compose@4.1.0", "", {}, "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw=="], - "koa-router": ["koa-router@12.0.1", "", { "dependencies": { "debug": "^4.3.4", "http-errors": "^2.0.0", "koa-compose": "^4.1.0", "methods": "^1.1.2", "path-to-regexp": "^6.2.1" } }, "sha512-gaDdj3GtzoLoeosacd50kBBTnnh3B9AYxDThQUo4sfUyXdOhY6ku1qyZKW88tQCRgc3Sw6ChXYXWZwwgjOxE0w=="], - "lefthook": ["lefthook@1.6.10", "", { "optionalDependencies": { "lefthook-darwin-arm64": "1.6.10", "lefthook-darwin-x64": "1.6.10", "lefthook-freebsd-arm64": "1.6.10", "lefthook-freebsd-x64": "1.6.10", "lefthook-linux-arm64": "1.6.10", "lefthook-linux-x64": "1.6.10", "lefthook-windows-arm64": "1.6.10", "lefthook-windows-x64": "1.6.10" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-HeVjsDCrHLe9htQHbLuQJu2YdLK6Tl5bh36fOpmXqckEXTI0BDR0Y5JYc7G5Inj4YXQsc51a9dUDZMeniSnSag=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@1.6.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hh11OkoKG7FEOByS1dcgNV7ETq45VmwBbw0VPTiBznyfOG4k+pi0fIdc1qbmbxvYqNE0r420QR/Q3bimaa4Kxg=="], @@ -723,12 +734,12 @@ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + "meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], - "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], - "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], @@ -749,11 +760,11 @@ "minisearch": ["minisearch@7.1.1", "", {}, "sha512-b3YZEYCEH4EdCAtYP7OlDyx7FdPwNzuNwLQ34SfJpM9dlbBZzeXndGavTrC+VCiRWomL21SWfMc6SCKO/U2ZNw=="], - "mitata": ["mitata@0.1.11", "", {}, "sha512-cs6FiWcnRxn7atVumm8wA8R70XCDmMXgVgb/qWUSjr5dwuIBr7zC+22mbGYPlbyFixlIOjuP//A0e72Q1ZoGDw=="], + "mitata": ["mitata@1.0.34", "", {}, "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], - "ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], @@ -787,7 +798,7 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@6.2.2", "", {}, "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw=="], + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], @@ -865,7 +876,7 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -875,6 +886,8 @@ "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + "superjson": ["superjson@2.2.2", "", { "dependencies": { "copy-anything": "^3.0.2" } }, "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q=="], "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], @@ -887,6 +900,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -895,6 +910,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -971,11 +988,9 @@ "@vue/compiler-sfc/source-map-js": ["source-map-js@1.2.0", "", {}, "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg=="], - "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "body-parser/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], - "elysia/elysia": ["elysia@1.0.15", "", { "dependencies": { "@sinclair/typebox": "^0.32.15", "cookie": "^0.6.0", "eventemitter3": "^5.0.1", "fast-decode-uri-component": "^1.0.1", "fast-querystring": "^1.1.2", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-18dJnNV3X1a7vYKQHOQ9yS+upg6v0gb/L3kNlFwoq4grUPCpOi4II6ViXtWs1Qd1uWyqDJ+S6oUGMGlhIneehg=="], - - "finalhandler/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "finalhandler/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "graphql-yoga/lru-cache": ["lru-cache@10.2.2", "", {}, "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="], @@ -983,15 +998,19 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "raw-body/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], "wobe-benchmark/get-port": ["get-port@7.1.0", "", {}, "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw=="], + "wobe-validator/@sinclair/typebox": ["@sinclair/typebox@0.32.27", "", {}, "sha512-JHRrubCKiXi6VKlbBTpTQnExkUFasPMIaXCJYJhqVBGLliQVt1yBZZgiZo3/uSmvAdXlIIdGoTAT6RB09L0QqA=="], + "@babel/highlight/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - "body-parser/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "body-parser/http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "finalhandler/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "raw-body/http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], diff --git a/packages/wobe-benchmark/package.json b/packages/wobe-benchmark/package.json index 3764a1c..d3afe9e 100644 --- a/packages/wobe-benchmark/package.json +++ b/packages/wobe-benchmark/package.json @@ -1,23 +1,26 @@ { - "name": "wobe-benchmark", - "version": "0.1.0", - "main": "index.ts", - "dependencies": { - "wobe": "*" - }, - "devDependencies": { - "elysia": "1.0.16", - "get-port": "7.1.0", - "mitata": "0.1.11", - "hono": "4.10.4", - "koa-router": "12.0.1", - "radix3": "1.1.2", - "find-my-way": "9.3.0" - }, - "scripts": { - "bench:startup": "bun run startup/benchmark.ts", - "bench:router": "bun run router/benchmark.ts", - "bench:extracter": "bun run pathExtract/benchmark.ts", - "bench:findHook": "bun run findHook/benchmark.ts" - } + "name": "wobe-benchmark", + "version": "0.1.0", + "main": "index.ts", + "dependencies": { + "wobe": "*" + }, + "devDependencies": { + "@sinclair/typebox": "0.34.41", + "elysia": "1.4.18", + "get-port": "7.1.0", + "mitata": "1.0.34", + "hono": "4.10.7", + "@koa/router": "15.0.0", + "radix3": "1.1.2", + "find-my-way": "9.3.0" + }, + "scripts": { + "bench:startup": "bun run startup/benchmark.ts", + "bench:router": "bun run router/benchmark.ts", + "bench:extracter": "bun run pathExtract/benchmark.ts", + "bench:findHook": "bun run findHook/benchmark.ts", + "lint": "biome lint . --no-errors-on-unmatched", + "format": "biome format --write ." + } } diff --git a/packages/wobe-benchmark/router/koaRouter.ts b/packages/wobe-benchmark/router/koaRouter.ts index ab63cec..44f1efc 100644 --- a/packages/wobe-benchmark/router/koaRouter.ts +++ b/packages/wobe-benchmark/router/koaRouter.ts @@ -1,4 +1,4 @@ -import KoaRouter from 'koa-router' +import KoaRouter from '@koa/router' import type { RouterInterface } from './tools' import { routes, handler } from './tools' @@ -7,7 +7,7 @@ const router = new KoaRouter() for (const route of routes) { if (route.method === 'GET') { - router.get(route.pathToCompile.replace('*', '(.*)'), handler) + router.get(route.pathToCompile.replace('*', '/*path'), handler) } else { router.post(route.pathToCompile, handler) } diff --git a/packages/wobe-benchmark/router/wobe.ts b/packages/wobe-benchmark/router/wobe.ts index 3eed92c..83c298e 100644 --- a/packages/wobe-benchmark/router/wobe.ts +++ b/packages/wobe-benchmark/router/wobe.ts @@ -1,7 +1,7 @@ -import { RadixTree } from 'wobe/src/router' import { routes, type Route } from './tools' +import { UrlPatternRouter, type Router } from 'wobe' -const createWobeRouter = (name: string, radixTree: RadixTree) => { +const createWobeRouter = (name: string, radixTree: Router) => { for (const route of routes) { radixTree.addRoute(route.method, route.pathToCompile, () => Promise.resolve(), @@ -18,4 +18,7 @@ const createWobeRouter = (name: string, radixTree: RadixTree) => { } } -export const wobeRouter = createWobeRouter('Radix router', new RadixTree()) +export const wobeRouter = createWobeRouter( + 'UrlPattern router', + new UrlPatternRouter(), +) diff --git a/packages/wobe-benchmark/startup/elysia.ts b/packages/wobe-benchmark/startup/elysia.ts index ff9df1a..e27a0cd 100644 --- a/packages/wobe-benchmark/startup/elysia.ts +++ b/packages/wobe-benchmark/startup/elysia.ts @@ -3,17 +3,17 @@ import getPort from 'get-port' export const elysiaApp = async () => { const port = await getPort() - const elysia = new Elysia({ precompile: true }) + const app = new Elysia() .get('/', 'Hi') - .post('/json', (c) => c.body, { - type: 'json', - }) - .get('/id/:id', ({ set, params: { id }, query: { name } }) => { - set.headers['x-powered-by'] = 'benchmark' + .get('/id/:id', (c) => { + c.set.headers['x-powered-by'] = 'benchmark' - return id + ' ' + name + return `${c.params.id} ${c.query.name}` + }) + .post('/json', (c) => c.body, { + parse: 'json', }) .listen(port) - elysia.stop() + app.stop() } diff --git a/packages/wobe/src/Context.ts b/packages/wobe/src/Context.ts index ba2aa61..fba3d60 100644 --- a/packages/wobe/src/Context.ts +++ b/packages/wobe/src/Context.ts @@ -1,6 +1,6 @@ import type { HttpMethod, WobeHandler, WobeHandlerOutput } from './Wobe' import { WobeResponse } from './WobeResponse' -import type { RadixTree } from './router' +import type { Router } from './router' import { extractPathnameAndSearchParams } from './utils' export class Context { @@ -18,14 +18,14 @@ export class Context { public beforeHandlerHook: Array> = [] public afterHandlerHook: Array> = [] - constructor(request: Request, router?: RadixTree) { + constructor(request: Request, router?: Router) { this.request = request this.res = new WobeResponse(request) this._findRoute(router) } - private _findRoute(router?: RadixTree) { + private _findRoute(router?: Router) { const { pathName, searchParams } = extractPathnameAndSearchParams( this.request.url, ) diff --git a/packages/wobe/src/Wobe.ts b/packages/wobe/src/Wobe.ts index b0525ef..b07a51e 100644 --- a/packages/wobe/src/Wobe.ts +++ b/packages/wobe/src/Wobe.ts @@ -1,5 +1,5 @@ import type { Server, ServerWebSocket } from 'bun' -import { RadixTree } from './router' +import { RadixTree, type Router } from './router' import { BunAdapter, NodeAdapter, type RuntimeAdapter } from './adapters' import type { Context } from './Context' @@ -29,6 +29,11 @@ export interface WobeOptions { cert: string passphrase?: string } + /** + * Provide a custom router implementation (RadixTree, UrlPatternRouter, or compatible). + * Defaults to UrlPatternRouter when not supplied. + */ + router?: Router } export type HttpMethod = 'POST' | 'GET' | 'DELETE' | 'PUT' | 'ALL' | 'OPTIONS' @@ -100,7 +105,7 @@ export class Wobe { hook: Hook method: HttpMethod }> - private router: RadixTree + private router: Router private runtimeAdapter: RuntimeAdapter = factoryOfRuntime() private httpMethods: Array = [ 'GET', @@ -119,7 +124,7 @@ export class Wobe { this.wobeOptions = options this.hooks = [] this.server = null - this.router = new RadixTree() + this.router = options?.router || new RadixTree() } /** diff --git a/packages/wobe/src/adapters/bun/bun.ts b/packages/wobe/src/adapters/bun/bun.ts index 36f88af..c637da7 100644 --- a/packages/wobe/src/adapters/bun/bun.ts +++ b/packages/wobe/src/adapters/bun/bun.ts @@ -2,7 +2,7 @@ import type { RuntimeAdapter } from '..' import { Context } from '../../Context' import { HttpException } from '../../HttpException' import type { WobeOptions, WobeWebSocket } from '../../Wobe' -import type { RadixTree } from '../../router' +import type { Router } from '../../router' import { bunWebSocket } from './websocket' import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib' @@ -55,7 +55,7 @@ const decompressBody = ( export const BunAdapter = (): RuntimeAdapter => ({ createServer: ( port: number, - router: RadixTree, + router: Router, options?: WobeOptions, webSocket?: WobeWebSocket, ) => diff --git a/packages/wobe/src/adapters/index.ts b/packages/wobe/src/adapters/index.ts index fa62bb9..90e9a84 100644 --- a/packages/wobe/src/adapters/index.ts +++ b/packages/wobe/src/adapters/index.ts @@ -1,5 +1,5 @@ import type { WobeOptions, WobeWebSocket } from '../Wobe' -import type { RadixTree } from '../router' +import type { Router } from '../router' export * from './bun' export * from './node' @@ -7,7 +7,7 @@ export * from './node' export interface RuntimeAdapter { createServer: ( port: number, - router: RadixTree, + router: Router, options?: WobeOptions, webSocket?: WobeWebSocket, ) => any diff --git a/packages/wobe/src/adapters/node/node.ts b/packages/wobe/src/adapters/node/node.ts index 1e338d6..67ced0f 100644 --- a/packages/wobe/src/adapters/node/node.ts +++ b/packages/wobe/src/adapters/node/node.ts @@ -4,7 +4,7 @@ import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib' import { HttpException } from '../../HttpException' import { Context } from '../../Context' import type { RuntimeAdapter } from '..' -import type { RadixTree } from '../../router' +import type { Router } from '../../router' import type { WobeOptions } from '../../Wobe' const DEFAULT_MAX_BODY_SIZE = 1024 * 1024 // 1 MiB @@ -85,7 +85,7 @@ const transformResponseInstanceToValidResponse = async (response: Response) => { } export const NodeAdapter = (): RuntimeAdapter => ({ - createServer: (port: number, router: RadixTree, options?: WobeOptions) => { + createServer: (port: number, router: Router, options?: WobeOptions) => { // @ts-expect-error const createServer: typeof createHttpsServer = options?.tls ? createHttpsServer diff --git a/packages/wobe/src/router/RadixTree.test.ts b/packages/wobe/src/router/RadixTree.test.ts index d40113c..a23e73f 100644 --- a/packages/wobe/src/router/RadixTree.test.ts +++ b/packages/wobe/src/router/RadixTree.test.ts @@ -1032,6 +1032,22 @@ describe('RadixTree', () => { expect(route2?.handler).toBeDefined() }) + it('should match a param route when the value is missing but a trailing slash is present', () => { + const radixTree = new RadixTree() + + radixTree.addRoute('GET', '/bucket/:filename', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/bucket/') + + expect(route).not.toBeNull() + expect(route?.handler).toBeDefined() + expect(route?.params).toEqual({ filename: '' }) + }) + it('should find a route by method', () => { const radixTree = new RadixTree() diff --git a/packages/wobe/src/router/RadixTree.ts b/packages/wobe/src/router/RadixTree.ts index cc8c0c8..5ace577 100644 --- a/packages/wobe/src/router/RadixTree.ts +++ b/packages/wobe/src/router/RadixTree.ts @@ -1,17 +1,6 @@ +import type { Node } from '.' import type { Hook, HttpMethod, WobeHandler } from '../Wobe' -export interface Node { - name: string - children: Array - handler?: WobeHandler - beforeHandlerHook?: Array> - afterHandlerHook?: Array> - method?: HttpMethod - isParameterNode?: boolean - isWildcardNode?: boolean - params?: Record -} - export class RadixTree { public root: Node = { name: '/', children: [] } private isOptimized = false diff --git a/packages/wobe/src/router/UrlPatternRouter.test.ts b/packages/wobe/src/router/UrlPatternRouter.test.ts new file mode 100644 index 0000000..146480d --- /dev/null +++ b/packages/wobe/src/router/UrlPatternRouter.test.ts @@ -0,0 +1,1680 @@ +import { describe, expect, it } from 'bun:test' +import { UrlPatternRouter } from './UrlPatternRouter' + +describe('UrlPatternRouter', () => { + describe('addHook', () => { + it('should throw an error when addHook is called after optimizeTree', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/test', () => Promise.resolve()) + + radixTree.optimizeTree() + + expect(() => { + radixTree.addHook( + 'beforeHandler', + '/test', + () => Promise.resolve(), + 'GET', + ) + }).toThrowError( + 'Cannot add hooks after the tree has been optimized', + ) + }) + + it('should add a single hook based on the method of the request', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addRoute('POST', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeHandler', + '/a/simple/route', + () => Promise.resolve(), + 'GET', + ) + + radixTree.addHook( + 'beforeHandler', + '/a/simple/route', + () => Promise.resolve(), + 'POST', + ) + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + + expect( + radixTree.root.children[0].children[0].children[1].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[1].method, + ).toBe('POST') + expect( + radixTree.root.children[0].children[0].children[1].handler, + ).toBeDefined() + expect( + radixTree.root.children[0].children[0].children[1] + .beforeHandlerHook?.length, + ).toBe(1) + }) + + it('should add a hook with path * on beforeHandler with multiple route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.addRoute('GET', '/a/simple', () => Promise.resolve()) + + radixTree.addHook( + 'beforeHandler', + '*', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + + expect( + radixTree.root.children[0].children[0].beforeHandlerHook + ?.length, + ).toBe(1) + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + + it('should add a hook with path * on beforeHandler', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.addHook( + 'beforeHandler', + '*', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + + it('should add a hook beforeHandler to the radix tree', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeHandler', + '/a/simple/route', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + + it('should add a hook afterHandler to the radix tree', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'afterHandler', + '/a/simple/route', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook?.length, + ).toBe(1) + }) + + it('should add a hook beforeAndAfterHandler to the radix tree', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeAndAfterHandler', + '/a/simple/route', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook?.length, + ).toBe(1) + }) + + it('should add a hook with a wildcard in the middle', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeHandler', + '/a/*/route', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + + it('should add a hook with a wildcard at the end', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.addHook( + 'beforeHandler', + '/a/simple/*', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + + it('should add a hook with a slash at the end', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeHandler', + '/a/simple/route/', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + + it('should add two hooks if there are two routes', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addRoute('GET', '/a/simple/route2', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeHandler', + '/a/simple/route', + () => Promise.resolve(), + 'GET', + ) + radixTree.addHook( + 'afterHandler', + '/a/simple/route2', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook?.[0], + ).toBeFunction() + + expect( + radixTree.root.children[0].children[0].children[1] + .beforeHandlerHook, + ).toBeUndefined() + + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[1] + .afterHandlerHook?.length, + ).toBe(1) + expect( + radixTree.root.children[0].children[0].children[1] + .afterHandlerHook?.[0], + ).toBeFunction() + }) + + it('should not add a hook with a wildcard in the middle if the path not match', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeHandler', + '/a/simple/route/*/tata', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + + it('should not add a hook if the route not match', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeHandler', + '/a/simple/route2', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + + it('should not add a hook if the hook is shorter than the route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addHook( + 'beforeHandler', + '/a/simple', + () => Promise.resolve(), + 'GET', + ) + + expect( + radixTree.root.children[0].children[0].children[0] + .beforeHandlerHook, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0] + .afterHandlerHook, + ).toBeUndefined() + }) + }) + + describe('addRoute', () => { + it('should throw an error if the route already exist with same http method', () => { + const radixTree = new UrlPatternRouter() + radixTree.addRoute('GET', '/route', () => Promise.resolve()) + expect(() => + radixTree.addRoute('GET', '/route', () => Promise.resolve()), + ).toThrow() + }) + + it('should add a route to the radix tree', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + }) + + it('should add a route with HTTP method equal to ALL', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('ALL', '/a/simple/route', () => + Promise.resolve(), + ) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('ALL') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + }) + + it('should add a route to the radix tree that is a part of another route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple', () => Promise.resolve()) + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect(radixTree.root.children[0].children[0].method).toBe('GET') + expect(radixTree.root.children[0].children[0].handler).toBeDefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + }) + + it('should add a route to the radix tree with no slash at the begining', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', 'a/simple/route/', () => + Promise.resolve(), + ) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + }) + + it('should add a route to the radix tree with slash at the end', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route/', () => + Promise.resolve(), + ) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + }) + + it('should add a route to the radix tree with param', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route/:id', () => + Promise.resolve(), + ) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .name, + ).toBe('/:id') + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .method, + ).toBe('GET') + + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .isParameterNode, + ).toBe(true) + }) + + it('should add two routes to the radix tree', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addRoute('POST', '/a/simple/route', () => + Promise.resolve(), + ) + + expect(radixTree.root.children.length).toBe(1) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[1].method, + ).toBe('POST') + }) + + it('should add two routes to the radix tree with param', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route/:id', () => + Promise.resolve(), + ) + radixTree.addRoute('GET', '/a/simple/route/:id/test2/', () => + Promise.resolve(), + ) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .name, + ).toBe('/:id') + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .isParameterNode, + ).toBe(true) + + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .name, + ).toBe('/:id') + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .children[0].name, + ).toBe('/test2') + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .method, + ).toBe('GET') + }) + + it('should add two routes to the radix tree with diffent radix', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addRoute('POST', '/a2/simple/route', () => + Promise.resolve(), + ) + + expect(radixTree.root.children.length).toBe(2) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + + expect(radixTree.root.children[1].name).toBe('a2') + expect(radixTree.root.children[1].method).toBeUndefined() + expect(radixTree.root.children[1].handler).toBeUndefined() + expect(radixTree.root.children[1].children[0].name).toBe('/simple') + expect( + radixTree.root.children[1].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[1].children[0].handler, + ).toBeUndefined() + expect( + radixTree.root.children[1].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[1].children[0].children[0].method, + ).toBe('POST') + }) + + it('should add a route with a wildcard at the end', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/*', () => Promise.resolve()) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/*') + expect( + radixTree.root.children[0].children[0].children[0] + .isWildcardNode, + ).toBeTrue() + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + }) + + it('should add a route with a wildcard at the middle', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/*/route', () => + Promise.resolve(), + ) + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + expect(radixTree.root.children[0].isWildcardNode).toBeFalse() + + expect(radixTree.root.children[0].children[0].name).toBe('/simple') + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/*') + expect( + radixTree.root.children[0].children[0].children[0] + .isWildcardNode, + ).toBeTrue() + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeUndefined() + + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].children[0] + .handler, + ).toBeDefined() + }) + }) + + describe('findRoute', () => { + it('should find a route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route-2', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route-2') + + expect(route).not.toBeNull() + expect(route?.name).toBe('a/simple/route-2') + expect(route?.handler).toBeDefined() + }) + + it("should find a route with any HTTP method when the route's method is ALL", () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('ALL', '/a/simple/route-2', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route-2') + + expect(route).not.toBeNull() + expect(route?.name).toBe('a/simple/route-2') + expect(route?.method).toBe('ALL') + expect(route?.handler).toBeDefined() + + const route2 = radixTree.findRoute('POST', '/a/simple/route-2') + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('a/simple/route-2') + expect(route2?.method).toBe('ALL') + expect(route2?.handler).toBeDefined() + + const route3 = radixTree.findRoute('PUT', '/a/simple/route-2') + + expect(route3).not.toBeNull() + expect(route3?.name).toBe('a/simple/route-2') + expect(route3?.method).toBe('ALL') + expect(route3?.handler).toBeDefined() + + const route4 = radixTree.findRoute('DELETE', '/a/simple/route-2') + + expect(route4).not.toBeNull() + expect(route4?.name).toBe('a/simple/route-2') + expect(route4?.method).toBe('ALL') + expect(route4?.handler).toBeDefined() + }) + + it.each([true, false])( + 'should find a route that is a simple root route', + (withOptimizeTree) => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/', () => Promise.resolve()) + + if (withOptimizeTree) radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/') + + expect(route).not.toBeNull() + expect(route?.handler).toBeDefined() + }, + ) + + it.each([true, false])( + 'should find a route that is a simple root route with another route', + (withOptimizeTree) => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/', () => Promise.resolve()) + radixTree.addRoute('GET', '/:id', () => Promise.resolve()) + + if (withOptimizeTree) radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/') + const route2 = radixTree.findRoute('GET', '/1') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe(':id') + expect(route2?.handler).toBeDefined() + }, + ) + + it('should find a route with a part of another route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple', () => Promise.resolve()) + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple') + const route2 = radixTree.findRoute('GET', '/a/simple/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('a/simple') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('/route') + expect(route2?.handler).toBeDefined() + }) + + it('should find a route not begining with a slash', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', 'a/simple/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('a/simple/route') + expect(route?.handler).toBeDefined() + }) + + it('should find a route with same length on multiple children', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route1', () => + Promise.resolve(), + ) + radixTree.addRoute('GET', '/a/simple/route2', () => + Promise.resolve(), + ) + radixTree.addRoute('GET', '/a/simple/route3', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route3') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/route3') + expect(route?.handler).toBeDefined() + }) + + it.each([true, false])( + 'should not find a route that not exist', + (withOptimizeTree) => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + if (withOptimizeTree) radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple') + const route2 = radixTree.findRoute( + 'GET', + '/a/simple/route/bigger', + ) + + expect(route).toBeNull() + expect(route2).toBeNull() + }, + ) + + it('should find a route ending by a slash', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route/', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route/') + const route2 = radixTree.findRoute('GET', '/a/simple/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('a/simple/route') + expect(route2?.method).toBe('GET') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('a/simple/route') + expect(route2?.method).toBe('GET') + expect(route2?.handler).toBeDefined() + }) + + it('should match a param route when the value is missing but a trailing slash is present', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/bucket/:filename', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/bucket/') + + expect(route).not.toBeNull() + expect(route?.handler).toBeDefined() + expect(route?.params).toBeUndefined() + }) + + it('should find a route by method', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addRoute('POST', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('POST', '/a/simple/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/route') + expect(route?.method).toBe('POST') + expect(route?.handler).toBeDefined() + }) + + it.each([true, false])( + 'should find a complex route by method', + (withOptimizeTree) => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute( + 'GET', + '/there/is/a/very/long/and/complex/route', + () => Promise.resolve(), + ) + radixTree.addRoute( + 'GET', + '/there/is/a/very/long/and/complex/route2', + () => Promise.resolve(), + ) + radixTree.addRoute( + 'POST', + '/there/is/a/very/long/and/complex/addRoute', + () => Promise.resolve(), + ) + + if (withOptimizeTree) radixTree.optimizeTree() + + const route = radixTree.findRoute( + 'GET', + '/there/is/a/very/long/and/complex/route', + ) + + const route2 = radixTree.findRoute( + 'GET', + '/there/is/a/very/long/and/complex/route2', + ) + + const route3 = radixTree.findRoute( + 'POST', + '/there/is/a/very/long/and/complex/addRoute', + ) + + const invalidRoute = radixTree.findRoute( + 'POST', + '/there/is/a/very/long/and/complex/route', + ) + + expect(route).not.toBeNull() + expect(route?.name).toBe('/route') + expect(route?.method).toBe('GET') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('/route2') + expect(route2?.method).toBe('GET') + expect(route2?.handler).toBeDefined() + + expect(route3).not.toBeNull() + expect(route3?.method).toBe('POST') + expect(route3?.handler).toBeDefined() + + expect(invalidRoute).toBeNull() + }, + ) + + it.each([true, false])( + 'should find a route with a parameter directly after the root', + (withOptimizeTree) => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/:id', () => Promise.resolve()) + + if (withOptimizeTree) radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/1') + + expect(route).not.toBeNull() + expect(route?.name).toBe(':id') + expect(route?.handler).toBeDefined() + }, + ) + + it('should find a route with a parameter at the end of the route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/1/') + const route2 = radixTree.findRoute('GET', '/a/1') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/:id') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('/:id') + expect(route2?.handler).toBeDefined() + }) + + it('should find a route with parameter that is a part of another route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id', () => Promise.resolve()) + radixTree.addRoute('GET', '/a/:id/route', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple') + const route2 = radixTree.findRoute('GET', '/a/simple/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/:id') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('/route') + expect(route2?.handler).toBeDefined() + }) + + it('should find a route with parameter that is a part of another route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id', () => Promise.resolve()) + radixTree.addRoute('GET', '/a/:id/route', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple') + const route2 = radixTree.findRoute('GET', '/a/simple/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/:id') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('/route') + expect(route2?.handler).toBeDefined() + }) + + it('should find a route with parameter at the middle of the route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id/route', () => Promise.resolve()) + radixTree.addRoute('GET', '/a/:id/route2', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/1/route') + const route2 = radixTree.findRoute('GET', '/a/1/route2') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/route') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('/route2') + expect(route2?.handler).toBeDefined() + }) + + it('should find a route begining by *', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/*', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route') + const route2 = radixTree.findRoute( + 'GET', + '/*/another/big/long/route', + ) + + expect(route).not.toBeNull() + expect(route?.name).toBe('*') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('*') + expect(route2?.handler).toBeDefined() + }) + + it('should find a route ending by */', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route/*/', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/*') + expect(route?.handler).toBeDefined() + }) + + it('should find a complex route with a wildcard', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route/*/', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute( + 'GET', + '/a/simple/route/*/*/*/route', + ) + + expect(route).not.toBeNull() + expect(route?.name).toBe('/*') + expect(route?.handler).toBeDefined() + }) + + it('should find a route with many parameters', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id/:name/:age', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/1/john/30') + const invalidRoute = radixTree.findRoute('GET', '/a/1/john') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/:age') + expect(route?.handler).toBeDefined() + + expect(invalidRoute).toBeNull() + }) + + it('should find a route with a parameter at the middle of the route with different size (:id has length 3, and 1 is only one)', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id/route', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/1/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/route') + expect(route?.handler).toBeDefined() + }) + + it('should find a route with a parameter at the middle of the route with same size (:id has length 3, and 123 is also 3)', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id/route', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/123/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/route') + expect(route?.handler).toBeDefined() + }) + + it('should find a route with a wildcard', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/*', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route') + const route2 = radixTree.findRoute('GET', '/a/simple/route/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/*') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('/*') + expect(route2?.handler).toBeDefined() + }) + + it('should find a route with multiple wildcards', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/*/*/*/', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route') + const route2 = radixTree.findRoute('GET', '/a/simple/route/route') + const route3 = radixTree.findRoute( + 'GET', + '/a/simple/route/route/again/another/route', + ) + + expect(route).not.toBeNull() + expect(route?.method).toBe('GET') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.method).toBe('GET') + expect(route2?.handler).toBeDefined() + + expect(route3).not.toBeNull() + expect(route3?.method).toBe('GET') + expect(route3?.handler).toBeDefined() + }) + + it('should find a route with a wildcard at the middle', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/*/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route/route') + const route2 = radixTree.findRoute('GET', '/a/simple/another/route') + const invalidRoute = radixTree.findRoute('GET', '/a/simple/route') + + expect(route).not.toBeNull() + expect(route?.name).toBe('/route') + expect(route?.handler).toBeDefined() + + expect(route2).not.toBeNull() + expect(route2?.name).toBe('/route') + expect(route2?.handler).toBeDefined() + + expect(invalidRoute).toBeNull() + }) + + it('should not find a non existing route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/simple/route/route') + + expect(route).toBeNull() + }) + + it('should extract the parameter from a parameter route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id/route', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/1/route') + + expect(route).not.toBeNull() + expect(route?.params).toEqual({ id: '1' }) + }) + + it('should extract the parameter when the parameter is at the begin of the route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/:name/:id/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/1/route') + + expect(route).not.toBeNull() + expect(route?.params).toEqual({ id: '1', name: 'a' }) + }) + + it('should extract the parameter when the parameter is at the end of the route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/:name/:id/:route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/a/1/route') + + expect(route).not.toBeNull() + expect(route?.params).toEqual({ + id: '1', + name: 'a', + route: 'route', + }) + }) + + it('should extract the parameter when the parameter is at the end of the route with a slash', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/name/id/:route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/name/id/route/') + + expect(route).not.toBeNull() + expect(route?.params).toEqual({ + route: 'route', + }) + }) + + it('should not extract the parameter when there is not parameter', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/name/id/route', () => Promise.resolve()) + + radixTree.optimizeTree() + + const route = radixTree.findRoute('GET', '/name/id/route/') + + expect(route).not.toBeNull() + expect(route?.params).toBeUndefined() + }) + }) + + describe('optimizeTree', () => { + it('should optimize a tree by merging all the node with only one child', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + radixTree.addRoute('POST', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + expect(radixTree.root.name).toBe('/') + expect(radixTree.root.method).toBeUndefined() + expect(radixTree.root.handler).toBeUndefined() + + expect(radixTree.root.children[0].name).toBe('a/simple') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + + expect(radixTree.root.children[0].children[0].name).toBe('/route') + expect(radixTree.root.children[0].children[0].method).toBe('GET') + expect(radixTree.root.children[0].children[0].handler).toBeDefined() + + expect(radixTree.root.children[0].children[1].name).toBe('/route') + expect(radixTree.root.children[0].children[1].method).toBe('POST') + expect(radixTree.root.children[0].children[1].handler).toBeDefined() + }) + + it('should optimize route that is a part of another route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple', () => Promise.resolve()) + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + expect(radixTree.root.children[0].name).toBe('a/simple') + expect(radixTree.root.children[0].method).toBe('GET') + expect(radixTree.root.children[0].handler).toBeDefined() + expect(radixTree.root.children[0].children[0].name).toBe('/route') + expect(radixTree.root.children[0].children[0].method).toBe('GET') + expect(radixTree.root.children[0].children[0].handler).toBeDefined() + }) + + it('should merge a tree when there is only one route', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/route', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + expect(radixTree.root.name).toBe('/') + expect(radixTree.root.method).toBeUndefined() + expect(radixTree.root.handler).toBeUndefined() + + expect(radixTree.root.children[0].name).toBe('a/simple/route') + expect(radixTree.root.children[0].method).toBe('GET') + expect(radixTree.root.children[0].handler).toBeDefined() + }) + + it('should merge a tree with parametric node', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/:id/route', () => Promise.resolve()) + + radixTree.optimizeTree() + + expect(radixTree.root.name).toBe('/') + expect(radixTree.root.method).toBeUndefined() + expect(radixTree.root.handler).toBeUndefined() + + expect(radixTree.root.children[0].name).toBe('a') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + + expect(radixTree.root.children[0].children[0].name).toBe('/:id') + expect( + radixTree.root.children[0].children[0].isParameterNode, + ).toBeTrue() + expect( + radixTree.root.children[0].children[0].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[0].handler, + ).toBeUndefined() + + expect( + radixTree.root.children[0].children[0].children[0].name, + ).toBe('/route') + expect( + radixTree.root.children[0].children[0].children[0].method, + ).toBe('GET') + expect( + radixTree.root.children[0].children[0].children[0].handler, + ).toBeDefined() + }) + + it('should correctly merge a tree with a wildcard', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/a/simple/*', () => Promise.resolve()) + + radixTree.optimizeTree() + + expect(radixTree.root.name).toBe('/') + expect(radixTree.root.method).toBeUndefined() + expect(radixTree.root.handler).toBeUndefined() + + expect(radixTree.root.children[0].name).toBe('a/simple') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + + expect(radixTree.root.children[0].children[0].name).toBe('/*') + expect( + radixTree.root.children[0].children[0].isWildcardNode, + ).toBeTrue() + expect(radixTree.root.children[0].children[0].method).toBe('GET') + expect(radixTree.root.children[0].children[0].handler).toBeDefined() + }) + + it('should merge a tree with complex structure', () => { + const radixTree = new UrlPatternRouter() + + radixTree.addRoute('GET', '/there/is/a/complex/route/next2', () => + Promise.resolve(), + ) + radixTree.addRoute('POST', '/there/is/a2/complex/route/next2', () => + Promise.resolve(), + ) + radixTree.addRoute('POST', '/there/is/a2/complex/route/next3', () => + Promise.resolve(), + ) + + radixTree.optimizeTree() + + expect(radixTree.root.name).toBe('/') + expect(radixTree.root.method).toBeUndefined() + expect(radixTree.root.handler).toBeUndefined() + + expect(radixTree.root.children[0].name).toBe('there/is') + expect(radixTree.root.children[0].method).toBeUndefined() + expect(radixTree.root.children[0].handler).toBeUndefined() + + expect(radixTree.root.children[0].children[0].name).toBe( + '/a/complex/route/next2', + ) + expect(radixTree.root.children[0].children[0].method).toBe('GET') + expect(radixTree.root.children[0].children[0].handler).toBeDefined() + + expect(radixTree.root.children[0].children[1].name).toBe( + '/a2/complex/route', + ) + expect( + radixTree.root.children[0].children[1].method, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[1].handler, + ).toBeUndefined() + expect( + radixTree.root.children[0].children[1].children[0].name, + ).toBe('/next2') + expect( + radixTree.root.children[0].children[1].children[0].method, + ).toBe('POST') + expect( + radixTree.root.children[0].children[1].children[0].handler, + ).toBeDefined() + expect( + radixTree.root.children[0].children[1].children[1].name, + ).toBe('/next3') + expect( + radixTree.root.children[0].children[1].children[1].method, + ).toBe('POST') + expect( + radixTree.root.children[0].children[1].children[1].handler, + ).toBeDefined() + }) + }) +}) diff --git a/packages/wobe/src/router/UrlPatternRouter.ts b/packages/wobe/src/router/UrlPatternRouter.ts new file mode 100644 index 0000000..936adcc --- /dev/null +++ b/packages/wobe/src/router/UrlPatternRouter.ts @@ -0,0 +1,416 @@ +import type { Node } from '.' +import type { Hook, HttpMethod, WobeHandler } from '../Wobe' + +type URLPatternMatch = { + pathname?: { groups: Record } +} + +type URLPatternLike = { + exec(input: { pathname: string } | string): URLPatternMatch | null + test(input: { pathname: string } | string): boolean +} + +type RouteEntry = { + node: Node + method: HttpMethod + pattern: URLPatternLike | null + normalizedPath: string +} + +const createURLPattern = (pathname: string): URLPatternLike | null => { + const URLPatternConstructor = (globalThis as any).URLPattern as + | (new (init: { + pathname: string + }) => URLPatternLike) + | undefined + + if (!URLPatternConstructor) return null + + return new URLPatternConstructor({ pathname }) +} + +export class UrlPatternRouter { + public root: Node = { name: '/', children: [] } + private isOptimized = false + private routePatterns = new Map() + private hasURLPattern = + typeof (globalThis as any).URLPattern === 'function' || + typeof (globalThis as any).URLPattern === 'object' + private routes: Array = [] + + private addRouteEntry( + node: Node, + method: HttpMethod, + normalizedPath: string, + ) { + const pattern = this.hasURLPattern + ? createURLPattern(normalizedPath) + : null + + this.routePatterns.set(node, pattern) + + this.routes.push({ + node, + method, + pattern, + normalizedPath, + }) + } + + private normalizePath(path: string) { + let normalized = path[0] === '/' ? path : '/' + path + + if (normalized.length > 1 && normalized.endsWith('/')) + normalized = normalized.replace(/\/+$/, '') + + return normalized === '' ? '/' : normalized + } + + addRoute(method: HttpMethod, path: string, handler: WobeHandler) { + const normalizedPath = this.normalizePath(path) + const pathParts = normalizedPath.split('/').filter(Boolean) + + let currentNode = this.root + + for (let i = 0; i < pathParts.length; i++) { + const pathPart = pathParts[i] + const isParameterNode = pathPart[0] === ':' + const isWildcardNode = pathPart[0] === '*' + + let foundNode = currentNode.children.find( + (node) => + node.name === (i === 0 ? '' : '/') + pathPart && + (node.method === method || !node.method), + ) + + if ( + foundNode && + foundNode.method === method && + i === pathParts.length - 1 + ) + throw new Error(`Route ${method} ${path} already exists`) + + if (!foundNode) { + foundNode = { + name: (i === 0 ? '' : '/') + pathPart, + children: [], + isParameterNode, + isWildcardNode, + } + + currentNode.children.push(foundNode) + } + + currentNode = foundNode + } + + currentNode.handler = handler + currentNode.method = method + ;(currentNode as any).fullPath = normalizedPath + + this.addRouteEntry(currentNode, method, normalizedPath) + } + + _addHookToNode(node: Node, hook: Hook, handler: WobeHandler) { + switch (hook) { + case 'beforeHandler': { + if (!node.beforeHandlerHook) node.beforeHandlerHook = [] + + node.beforeHandlerHook.push(handler) + break + } + case 'afterHandler': { + if (!node.afterHandlerHook) node.afterHandlerHook = [] + + node.afterHandlerHook.push(handler) + break + } + + case 'beforeAndAfterHandler': { + if (!node.beforeHandlerHook) node.beforeHandlerHook = [] + + if (!node.afterHandlerHook) node.afterHandlerHook = [] + + node.beforeHandlerHook.push(handler) + node.afterHandlerHook.push(handler) + break + } + default: + break + } + } + + addHook( + hook: Hook, + path: string, + handler: WobeHandler, + method: HttpMethod, + node?: Node, + ) { + if (this.isOptimized) + throw new Error( + 'Cannot add hooks after the tree has been optimized', + ) + + let currentNode = node || this.root + + // For hooks with no specific path + if (path === '*') { + const stack = [...currentNode.children] + + while (stack.length > 0) { + const child = stack.pop() as Node + + if ( + child.handler && + (method === child.method || method === 'ALL') + ) + this._addHookToNode(child, hook, handler) + + if (child.children.length > 0) stack.push(...child.children) + } + + return + } + + const pathParts = path.split('/').filter(Boolean) + + for (let i = 0; i < pathParts.length; i++) { + const pathPart = pathParts[i] + const isWildcardNode = pathPart[0] === '*' + + if (isWildcardNode) { + const nextPathJoin = '/' + pathParts.slice(i + 1).join('/') + + for (const child of currentNode.children) { + if (child.method === method || !child.method) + this.addHook(hook, nextPathJoin, handler, method, child) + } + + return + } + + const foundNode = currentNode.children.find( + (node) => + node.name === + (currentNode.name === '/' ? '' : '/') + pathPart && + ((node.method && node.method === method) || !node.method), + ) + + if (!foundNode) break + + currentNode = foundNode + } + + this._addHookToNode(currentNode, hook, handler) + } + + // This function is used to find the route in the tree + // The path in the node could be for example /a and in children /simple + // or it can also be /a/simple/route if there is only one children in each node + findRoute(method: HttpMethod, path: string) { + const hadTrailingSlash = path.endsWith('/') + const localPath = this.normalizePath(path) + const { length: pathLength } = localPath + + if (pathLength === 1 && localPath === '/') return this.root + + // Prefer URLPattern-only matching, pick the most specific (longest path) match + if (this.hasURLPattern) { + let bestMatch: RouteEntry | null = null + let bestLength = -1 + + for (const entry of this.routes) { + if (entry.method !== method && entry.method !== 'ALL') continue + + const { pattern } = entry + let matched = false + + if (pattern?.test({ pathname: localPath })) { + matched = true + } else if (entry.normalizedPath.endsWith('/*')) { + const prefix = entry.normalizedPath.split('/*')[0] + if ( + localPath === prefix || + localPath.startsWith(prefix + '/') + ) + matched = true + } else { + const paramIndex = entry.normalizedPath.indexOf('/:') + if (paramIndex !== -1) { + const prefix = entry.normalizedPath.slice(0, paramIndex) + if ( + localPath === prefix || + (hadTrailingSlash && localPath === prefix) + ) + matched = true + } + } + + if (matched) { + const len = entry.normalizedPath.length + if (len > bestLength) { + bestMatch = entry + bestLength = len + } + } + } + + if (!bestMatch) return null + + const match = bestMatch.pattern?.exec({ pathname: localPath }) + + if (match?.pathname?.groups) { + const groups = match.pathname.groups + if (Object.keys(groups).length > 0) + bestMatch.node.params = groups as Record + else bestMatch.node.params = undefined + } else { + bestMatch.node.params = undefined + } + + return bestMatch.node + } + + // Fallback to the legacy traversal when URLPattern is unavailable + let nextIndexToEnd = 0 + let params: Record | undefined + + const isNodeMatch = ( + node: Node, + indexToBegin: number, + indexToEnd: number, + ): Node | null => { + const nextIndexToBegin = indexToBegin + (indexToEnd - indexToBegin) + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] + const childName = child.name + + const isChildWildcardOrParameterNode = + child.isWildcardNode || child.isParameterNode + + nextIndexToEnd = localPath.indexOf( + '/', + isChildWildcardOrParameterNode + ? nextIndexToBegin + 1 + : nextIndexToBegin + childName.length - 1, + ) + + if (nextIndexToEnd === -1) nextIndexToEnd = pathLength + + if (indexToEnd === nextIndexToEnd && !child.isWildcardNode) + continue + + if ( + !isChildWildcardOrParameterNode && + nextIndexToEnd - nextIndexToBegin !== childName.length + ) + continue + + if (child.isParameterNode) { + if (!params) params = {} + + const indexToAddIfFirstNode = indexToBegin === 0 ? 0 : 1 + + params[childName.slice(1 + indexToAddIfFirstNode)] = + localPath.slice( + nextIndexToBegin + indexToAddIfFirstNode, + nextIndexToEnd, + ) + } + + if ( + isChildWildcardOrParameterNode && + child.children.length === 0 && + child.method === method + ) + return child + + if ( + nextIndexToEnd >= pathLength - 1 && + (child.method === method || child.method === 'ALL') + ) { + if (isChildWildcardOrParameterNode) return child + + const pathToCompute = localPath.slice( + nextIndexToBegin, + nextIndexToEnd, + ) + + if (pathToCompute === childName) return child + } + + const foundNode = isNodeMatch( + child, + nextIndexToBegin, + nextIndexToEnd, + ) + + if (foundNode) return foundNode + } + + return null + } + + const route = isNodeMatch(this.root, 0, this.root.name.length) + + if (route && params) route.params = params + + return route + } + + // This function optimize the tree by merging all the nodes that only have one child + optimizeTree() { + const optimizeNode = (node: Node) => { + // Merge multiple nodes that have only one child except parameter, wildcard and root nodes + if ( + node.children.length === 1 && + !node.handler && + !node.isParameterNode && + !node.children[0].isParameterNode && + !node.isWildcardNode && + !node.children[0].isWildcardNode && + node.name !== '/' + ) { + const child = node.children[0] + + node.name += child.name + node.children = child.children + node.handler = child.handler + node.method = child.method + node.beforeHandlerHook = child.beforeHandlerHook + node.afterHandlerHook = child.afterHandlerHook + if ((child as any).fullPath) { + ;(node as any).fullPath = (child as any).fullPath + } + + optimizeNode(node) + } + + node.children.forEach(optimizeNode) + } + + optimizeNode(this.root) + + // Rebuild patterns and routes to reflect merged nodes + this.routes = [] + this.routePatterns.clear() + + const rebuild = (node: Node) => { + if (node.handler && (node as any).fullPath) { + this.addRouteEntry( + node, + node.method as HttpMethod, + (node as any).fullPath, + ) + } + + node.children.forEach(rebuild) + } + + rebuild(this.root) + + this.isOptimized = true + } +} diff --git a/packages/wobe/src/router/index.ts b/packages/wobe/src/router/index.ts index dcb82d5..207c081 100644 --- a/packages/wobe/src/router/index.ts +++ b/packages/wobe/src/router/index.ts @@ -1 +1,29 @@ +import type { Hook, HttpMethod, WobeHandler } from '../Wobe' export * from './RadixTree' +export * from './UrlPatternRouter' + +export interface Node { + name: string + children: Array + handler?: WobeHandler + beforeHandlerHook?: Array> + afterHandlerHook?: Array> + method?: HttpMethod + isParameterNode?: boolean + isWildcardNode?: boolean + params?: Record +} + +export interface Router { + root: Node + addRoute(method: HttpMethod, path: string, handler: WobeHandler): void + addHook( + hook: Hook, + path: string, + handler: WobeHandler, + method: HttpMethod, + node?: Node, + ): void + findRoute(method: HttpMethod, path: string): Node | null + optimizeTree(): void +}