From 01e2f17fd7760be185837b5446d6f48aaa40704a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CA=9C=C9=AA=E1=B4=8D=E1=B4=9C=CA=80=E1=B4=80=20Yu=CC=84?= Date: Sun, 25 Jun 2023 03:14:25 +0900 Subject: [PATCH] feat: allow setting precedence for each route --- README.md | 71 +++++++++++++++++++++---------------- examples/*.route.ts | 4 ++- examples/.route.ts | 4 ++- examples/:dynamic.route.ts | 7 ++++ examples/api/hello.route.ts | 4 ++- examples/hello.route.ts | 4 ++- examples/null.route.ts | 4 ++- examples/react.route.tsx | 4 ++- examples/serve.gen.ts | 14 ++++---- examples/serve.ts | 13 +------ examples/throw.route.ts | 4 ++- examples/undefined.route.ts | 4 ++- examples/void.route.ts | 4 ++- src/Router.ts | 16 +++++++-- 14 files changed, 97 insertions(+), 60 deletions(-) create mode 100644 examples/:dynamic.route.ts diff --git a/README.md b/README.md index 0ece08c..d313cb4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,46 @@ import { serve } from "https://deno.land/std@0.192.0/http/server.ts" await serve(await new Router({ root: import.meta.resolve("./.") })) ``` +## Dynamic Routes + +`routets` supports dynamic routes by [URL Pattern API](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API). Please refer to the MDN documentation for the syntax and examples. + +Matched parts of the pathname will be passed to the second argument of the handler. For example, when you have `:dynamic.route.ts` with the content being: + +```typescript +import Route from "https://lib.deno.dev/x/routets@v1/Route.ts" + +export default new Route(async (request, slugs) => { + return new Response(JSON.stringify(slugs), { headers: { "Content-Type": "application/json" } }) +}) +``` + +Accessing `/route` will show you `{"dynamic":"route"}`. + +## Route Precedence + +Once you have started using dynamic routes, you may notice it is unclear which route will be matched when multiple dynamic routes are valid for the requested pathname. For example, if you have a file named `greet.route.ts` and another file named `*.route.ts`, which one will be matched when you access `/greet`? + +By default, `routets` doesn't do anything smart, and just performs codepoint-wise lexicographic ordering. So, in the above example, `*.route.ts` will be matched first, as `*` precedes `g` in Unicode. If you want to change this behavior, just named-export a number as `precedence` from each route: + +```typescript +// in `*.route.ts` +export const precedence = 0 +``` + +```typescript +// in `greet.route.ts` +export const precedence = 9 +``` + +Routes with greater precedences are matched first. Think of it as `z-index` in CSS. So, this time `greet.route.ts` will be matched first. + +If `precedence` is not exported, it implies 0. + +## Route Fallthrough + +If a route returns nothing (namely `undefined`), then it fallthroughs to the next matching route. + ## Extending `Route` If you want to insert middlewares before/after an execution of handlers, you can extend the `Route` class as usual in TypeScript. @@ -95,35 +135,6 @@ export default new RouteReact(async () => { }) ``` -## Route Precedence - -Once you have started using dynamic routes, you may notice it is unclear which route will be matched when multiple dynamic routes are valid for the requested pathname. For example, if you have a file named `greet.route.ts` and another file named `*.route.ts`, which one will be matched when you access `/greet`? - -By default, `routets` doesn't do anything smart, and just performs codepoint-wise lexicographic ordering. So, in the above example, `*.route.ts` will be matched first, as `*` precedes `g` in Unicode. If you want to change this behavior, you can use the `precedence` option in the `Router` constructor (in the CLI this isn't available): - -```typescript -await serve( - await new Router({ - root: import.meta.resolve("./."), - // Comparison function for pathname patterns. - precedence: (a, b) => { - // Sorry for the dumb algorithm :P - const wildcard = /\/\*/ - if (wildcard.test(a)) return 1 - if (wildcard.test(b)) return -1 - // Returning `undefined` fallbacks to the default behavior. - return undefined - }, - }), -) -``` - -Improved handling for this is a planned feature. - -## Route Fallthrough - -If a route returns nothing (namely `undefined`), then it fallthroughs to the next matching route. - ## Suffix Restrictions Changing the route filename suffix (`route` by default) is possible by `--suffix` when using the CLI and by `suffix` option when using the `Router` constructor. Although, there are some restrictions on the shape of suffixes: @@ -138,7 +149,7 @@ These are by design and will never be lifted. `routets` uses dynamic imports to discover routes. This works well locally, but can be a problem if you want to get it to work with environments that don't support dynamic imports, such as [Deno Deploy](https://github.com/denoland/deploy_feedback/issues/1). For this use case, by default the `routets` CLI and the `Router` constructor do generate a server module `serve.gen.ts` that statically import routes. This module can directly be used as the entrypoint for Deno Deploy. -## Difference From `fsrouter` +## Difference from `fsrouter` There exists a similar package [`fsrouter`](https://deno.land/x/fsrouter) which has quite the same UX overall, but slightly different in: diff --git a/examples/*.route.ts b/examples/*.route.ts index 53ff523..0108415 100644 --- a/examples/*.route.ts +++ b/examples/*.route.ts @@ -2,4 +2,6 @@ import Route from "../src/Route.ts" export default new Route(async () => { return new Response("Page not found.", { status: 404 }) -}) \ No newline at end of file +}) + +export const precedence = 0 \ No newline at end of file diff --git a/examples/.route.ts b/examples/.route.ts index 6fdbd68..7b4102e 100644 --- a/examples/.route.ts +++ b/examples/.route.ts @@ -2,4 +2,6 @@ import Route from "../src/Route.ts" export default new Route(async () => { return new Response("Empty routename example.") -}) \ No newline at end of file +}) + +export const precedence = 9 \ No newline at end of file diff --git a/examples/:dynamic.route.ts b/examples/:dynamic.route.ts new file mode 100644 index 0000000..88a54e2 --- /dev/null +++ b/examples/:dynamic.route.ts @@ -0,0 +1,7 @@ +import Route from "../src/Route.ts" + +export default new Route(async (request, slugs) => { + return new Response(JSON.stringify(slugs), { headers: { "Content-Type": "application/json" } }) +}) + +export const precedence = 1 \ No newline at end of file diff --git a/examples/api/hello.route.ts b/examples/api/hello.route.ts index 94431a8..0244f40 100644 --- a/examples/api/hello.route.ts +++ b/examples/api/hello.route.ts @@ -2,4 +2,6 @@ import Route from "../../src/Route.ts" export default new Route(async () => { return new Response("Hello from API!") -}) \ No newline at end of file +}) + +export const precedence = 19 \ No newline at end of file diff --git a/examples/hello.route.ts b/examples/hello.route.ts index 306d196..18c4021 100644 --- a/examples/hello.route.ts +++ b/examples/hello.route.ts @@ -2,4 +2,6 @@ import Route from "../src/Route.ts" export default new Route(async () => { return new Response("Hello, World!") -}) \ No newline at end of file +}) + +export const precedence = 9 \ No newline at end of file diff --git a/examples/null.route.ts b/examples/null.route.ts index 8f1cced..5f88a21 100644 --- a/examples/null.route.ts +++ b/examples/null.route.ts @@ -3,4 +3,6 @@ import Route from "../src/Route.ts" // @ts-expect-error: Testing a return type other than `Response` or `undefined`. This will cause a 500. export default new Route(async () => { return null -}) \ No newline at end of file +}) + +export const precedence = 9 \ No newline at end of file diff --git a/examples/react.route.tsx b/examples/react.route.tsx index 1ff37be..927c532 100644 --- a/examples/react.route.tsx +++ b/examples/react.route.tsx @@ -2,4 +2,6 @@ import RouteReact from "./RouteReact.ts" export default new RouteReact(async () => { return Hello, World! -}) \ No newline at end of file +}) + +export const precedence = 9 \ No newline at end of file diff --git a/examples/serve.gen.ts b/examples/serve.gen.ts index daa5674..c078b22 100644 --- a/examples/serve.gen.ts +++ b/examples/serve.gen.ts @@ -1,7 +1,8 @@ -import _8 from "./*.route.ts" +import _9 from "./*.route.ts" import Router from "./../src/Router.ts" -import _0 from "./.route.ts" -import _1 from "./api/hello.route.ts" +import _1 from "./.route.ts" +import _8 from "./:dynamic.route.ts" +import _0 from "./api/hello.route.ts" import _2 from "./hello.route.ts" import _3 from "./null.route.ts" import _4 from "./react.route.tsx" @@ -11,14 +12,15 @@ import _7 from "./void.route.ts" import { serve } from "https://deno.land/std@0.192.0/http/server.ts" const routetslist = [ - ["/", _0], - ["/api/hello", _1], + ["/api/hello", _0], + ["/", _1], ["/hello", _2], ["/null", _3], ["/react", _4], ["/throw", _5], ["/undefined", _6], ["/void", _7], - ["/*", _8], + ["/:dynamic", _8], + ["/*", _9], ] as const await serve(await new Router(routetslist)) \ No newline at end of file diff --git a/examples/serve.ts b/examples/serve.ts index 7b5cd70..06377eb 100644 --- a/examples/serve.ts +++ b/examples/serve.ts @@ -1,15 +1,4 @@ import Router from "../src/Router.ts" import { serve } from "https://deno.land/std@0.192.0/http/server.ts" -await serve( - await new Router({ - root: import.meta.resolve("./."), - precedence: (a, b) => { - // Sorry for the dumb algorithm :P - const wildcard = /\/\*/ - if (wildcard.test(a)) return 1 - if (wildcard.test(b)) return -1 - return undefined - }, - }), -) \ No newline at end of file +await serve(await new Router({ root: import.meta.resolve("./.") })) \ No newline at end of file diff --git a/examples/throw.route.ts b/examples/throw.route.ts index 11c750a..9396aee 100644 --- a/examples/throw.route.ts +++ b/examples/throw.route.ts @@ -3,4 +3,6 @@ import Route from "../src/Route.ts" // Throwing will result in a 500. export default new Route(async () => { throw undefined -}) \ No newline at end of file +}) + +export const precedence = 9 \ No newline at end of file diff --git a/examples/undefined.route.ts b/examples/undefined.route.ts index 897ca9b..34be67b 100644 --- a/examples/undefined.route.ts +++ b/examples/undefined.route.ts @@ -3,4 +3,6 @@ import Route from "../src/Route.ts" // Returning `undefined` fallthroughs to the next matching route. export default new Route(async () => { return undefined -}) \ No newline at end of file +}) + +export const precedence = 9 \ No newline at end of file diff --git a/examples/void.route.ts b/examples/void.route.ts index 8aa3eaa..5771d01 100644 --- a/examples/void.route.ts +++ b/examples/void.route.ts @@ -1,4 +1,6 @@ import RouteReact from "../src/Route.ts" // Returning nothing (i.e. `undefined`) fallthroughs to the next matching route. -export default new RouteReact(async () => {}) \ No newline at end of file +export default new RouteReact(async () => {}) + +export const precedence = 9 \ No newline at end of file diff --git a/src/Router.ts b/src/Router.ts index 84a53dc..bd8d2e9 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -91,15 +91,21 @@ const enumerate = async ({ root, suffix, compare }: OptionsNormalized): Promise< const distree = await Distree.fromDirectory(rootReal, async path => { const pathname = `/${relative(rootReal, path)}`.match(regExp)?.groups?.pattern if (pathname) { - const { default: route } = await import(toFileUrl(path).href) + const { default: route, precedence = 0 } = await import(toFileUrl(path).href) + if (typeof precedence !== "number") throw new Error("Precedence must be a number.") + if (Number.isNaN(precedence)) throw new Error("`NaN` is not a valid precedence.") if (Route.isRoute(route)) { const pattern = new URLPatternPretty({ pathname }) - return Object.assign(route, { pattern }) + return Object.assign(route, { pattern, precedence }) } } throw undefined }) - return [...distree].sort(([, a], [, b]) => compare(a.pattern.pathname, b.pattern.pathname)) + return [...distree].sort(([, a], [, b]) => { + const precedence = b.precedence - a.precedence + if (precedence !== 0) return precedence + return compare(a.pattern.pathname, b.pattern.pathname) + }) } class IdentifierPretty { @@ -174,6 +180,10 @@ namespace Router { readonly write?: boolean | undefined /** * A function to compare two pathname patterns. If unspecified or `undefined` is returned, it fallbacks to the codepoint-wise lexicographical order. + * + * Probably you shouldn't use this option. Instead, just named-export a number as `precedence` from each route. Greater wins. This option is only used when the exported `precedence` is the same. + * + * @deprecated This will be removed in the next major release. */ readonly precedence?: ((patternA: string, patternB: string) => number | undefined) | undefined }