Skip to content

Commit

Permalink
feat: allow setting precedence for each route
Browse files Browse the repository at this point in the history
  • Loading branch information
yuhr committed Jun 24, 2023
1 parent 8d3ee82 commit 01e2f17
Show file tree
Hide file tree
Showing 14 changed files with 97 additions and 60 deletions.
71 changes: 41 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:

Expand Down
4 changes: 3 additions & 1 deletion examples/*.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ import Route from "../src/Route.ts"

export default new Route(async () => {
return new Response("Page not found.", { status: 404 })
})
})

export const precedence = 0
4 changes: 3 additions & 1 deletion examples/.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ import Route from "../src/Route.ts"

export default new Route(async () => {
return new Response("Empty routename example.")
})
})

export const precedence = 9
7 changes: 7 additions & 0 deletions examples/:dynamic.route.ts
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion examples/api/hello.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ import Route from "../../src/Route.ts"

export default new Route(async () => {
return new Response("Hello from API!")
})
})

export const precedence = 19
4 changes: 3 additions & 1 deletion examples/hello.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ import Route from "../src/Route.ts"

export default new Route(async () => {
return new Response("Hello, World!")
})
})

export const precedence = 9
4 changes: 3 additions & 1 deletion examples/null.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})

export const precedence = 9
4 changes: 3 additions & 1 deletion examples/react.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ import RouteReact from "./RouteReact.ts"

export default new RouteReact(async () => {
return <b>Hello, World!</b>
})
})

export const precedence = 9
14 changes: 8 additions & 6 deletions examples/serve.gen.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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))
13 changes: 1 addition & 12 deletions examples/serve.ts
Original file line number Diff line number Diff line change
@@ -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
},
}),
)
await serve(await new Router({ root: import.meta.resolve("./.") }))
4 changes: 3 additions & 1 deletion examples/throw.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ import Route from "../src/Route.ts"
// Throwing will result in a 500.
export default new Route(async () => {
throw undefined
})
})

export const precedence = 9
4 changes: 3 additions & 1 deletion examples/undefined.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})

export const precedence = 9
4 changes: 3 additions & 1 deletion examples/void.route.ts
Original file line number Diff line number Diff line change
@@ -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 () => {})
export default new RouteReact(async () => {})

export const precedence = 9
16 changes: 13 additions & 3 deletions src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down

0 comments on commit 01e2f17

Please sign in to comment.