@thi.ng/router 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Change Log
2
2
 
3
- - **Last updated**: 2024-03-09T16:05:59Z
3
+ - **Last updated**: 2024-03-13T14:04:31Z
4
4
  - **Generator**: [thi.ng/monopub](https://thi.ng/monopub)
5
5
 
6
6
  All notable changes to this project will be documented in this file.
@@ -9,6 +9,54 @@ See [Conventional Commits](https://conventionalcommits.org/) for commit guidelin
9
9
  **Note:** Unlisted _patch_ versions only involve non-code or otherwise excluded changes
10
10
  and/or version bumps of transitive dependencies.
11
11
 
12
+ # [4.0.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/router@4.0.0) (2024-03-13)
13
+
14
+ #### 🛑 Breaking changes
15
+
16
+ - trie-based route matching & wildcard support ([f1ab427](https://github.com/thi-ng/umbrella/commit/f1ab427))
17
+ - BREAKING CHANGES: update types, args, rename option fields
18
+ - add `Trie` data structure for route storage & matching
19
+ - add support for `+` wildcards to match arbitrary length routes
20
+ - update `BasicRouter.route()` & replace `.matchRoutes()` with new impl
21
+ - add `AugmentedRoute` interface & pre-process routes to compute
22
+ wildcard indices for faster matching (along with using the trie)
23
+ - update `Route.match` to be initially specified as string
24
+ - update `RouteMatch` to include `.rest` args (if any)
25
+ - add optional `RouteMatch.redirect` flag
26
+ - REMOVE `Route.title` & `RouteMatch.title` (obsolete since only used
27
+ by `HTMLRouter`, but usage unsupported by browsers now
28
+ (`history.pushState()` doesn't support title anymore)
29
+ - RENAME `RouterConfig` => `RouterOpts` (align naming convention)
30
+ - RENAME `RouterOpts.defaultRouteID` => `RouterOpts.default`
31
+ - RENAME `RouterOpts.initialRouteID` => `RouterOpts.initial`
32
+ - RENAME `RouterOpts.removeTrailingSlash` => `RouterOpts.trim`
33
+ - REMOVE obsolete `defMatch()` helper
34
+ - add/update tests
35
+ - rename BasicRouter => Router ([4d14aab](https://github.com/thi-ng/umbrella/commit/4d14aab))
36
+ - BREAKING CHANGE: rename BasicRouter => Router
37
+ - update all refs
38
+
39
+ #### 🚀 Features
40
+
41
+ - update RouteAuthenticator and .route() args ([f009afb](https://github.com/thi-ng/umbrella/commit/f009afb))
42
+ - add support for optional arbitrary user context object passed
43
+ to .route() and global auth handler
44
+ - update optional args for HTMLRouter.route()/.routeTo()
45
+ - update/improve wildcard priority handling ([59c2557](https://github.com/thi-ng/umbrella/commit/59c2557))
46
+ - implement wildcard fallback logic in `Trie.get()`
47
+ - add docs
48
+ - add tests
49
+
50
+ ## [3.4.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/router@3.4.0) (2024-03-10)
51
+
52
+ #### 🚀 Features
53
+
54
+ - add defMatch() helper ([12134b6](https://github.com/thi-ng/umbrella/commit/12134b6))
55
+
56
+ #### 🩹 Bug fixes
57
+
58
+ - rebuild index in updateRoutes() ([deb494e](https://github.com/thi-ng/umbrella/commit/deb494e))
59
+
12
60
  ## [3.3.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/router@3.3.0) (2024-03-09)
13
61
 
14
62
  #### 🚀 Features
package/README.md CHANGED
@@ -21,25 +21,24 @@
21
21
  - [Dependencies](#dependencies)
22
22
  - [Usage examples](#usage-examples)
23
23
  - [API](#api)
24
+ - [Benchmarks](#benchmarks)
24
25
  - [Authors](#authors)
25
26
  - [License](#license)
26
27
 
27
28
  ## About
28
29
 
29
- Generic router for browser & non-browser based applications.
30
+ Generic trie-based router with support for wildcards, route param validation/coercion, auth.
30
31
 
31
- - Declarative route definitions
32
- - Parametric routes, each param with optional value coercion &
33
- validation
34
- - Route authentication handler to enable/disable routes based on other
35
- state factors
36
- - Fallback route
32
+ - **Not bound to any environment**, usable on both client & server side
33
+ - Declarative route definitions, incl. [wildcards for matching rest
34
+ args](https://docs.thi.ng/umbrella/router/classes/Trie.html)
35
+ - Parametric routes, each param with optional value coercion & validation
36
+ - Route authentication handler to enable/disable routes based on other state
37
+ factors
38
+ - Fallback route redirect
37
39
  - Enforced initial route (optional)
38
- - Route formatting (with params)
39
- - HTML5 history & hash fragment support
40
-
41
- Partially based on the Clojure implementation in
42
- [thi.ng/domus](https://github.com/thi-ng/domus/blob/develop/src/router.org).
40
+ - Route formatting (with params & rest args)
41
+ - Optional HTML5 history & hash fragment support
43
42
 
44
43
  ## Status
45
44
 
@@ -72,7 +71,7 @@ For Node.js REPL:
72
71
  const router = await import("@thi.ng/router");
73
72
  ```
74
73
 
75
- Package sizes (brotli'd, pre-treeshake): ESM: 1.66 KB
74
+ Package sizes (brotli'd, pre-treeshake): ESM: 1.93 KB
76
75
 
77
76
  ## Dependencies
78
77
 
@@ -97,19 +96,21 @@ directory is using this package:
97
96
  [Generated API docs](https://docs.thi.ng/umbrella/router/)
98
97
 
99
98
  ```ts tangle:export/readme.ts
100
- import { HTMLRouter, EVENT_ROUTE_CHANGED } from "@thi.ng/router";
99
+ import {
100
+ HTMLRouter, HTMLRouterOpts, EVENT_ROUTE_CHANGED,
101
+ } from "@thi.ng/router";
102
+ import { isUUID } from "@thi.ng/checks";
101
103
 
102
104
  // router configuration
103
- const config = {
104
-
105
+ const config: HTMLRouterOpts = {
105
106
  // use hash fragment for routes
106
107
  useFragment: true,
107
108
 
108
109
  // fallback route (when no other matches)
109
- defaultRouteID: "home",
110
+ default: "home",
110
111
 
111
112
  // optional enforced route when router starts
112
- initialRouteID: "home",
113
+ initial: "home",
113
114
 
114
115
  // Optional route path component separator. Default: `/`
115
116
  separator: "/",
@@ -120,70 +121,114 @@ const config = {
120
121
  prefix: "#/",
121
122
 
122
123
  // actual route defs
123
- // An array of route specs which route input strings will be matched
124
- // against. Routes will be sorted from longest to shortest.
124
+ // An array of route specs which route input strings will be matched against.
125
+ // Given routes will be pre-processed and stored in a Trie for fast matching.
126
+ // Additional routes can be dynamically added at a later time via .addRoutes()
125
127
  routes: [
126
128
  {
127
129
  // each route MUST have an ID
128
130
  id: "home",
129
- // optional title for UI purposes (no internal function)
130
- title: "Home page",
131
131
  // this array defines the route path items
132
- match: ["home"]
132
+ match: "/home",
133
133
  },
134
134
  {
135
135
  id: "user-profile",
136
136
  // this rule is parametric
137
137
  // variable items are prefixed with `?`
138
- match: ["users", "?id"],
138
+ match: "/users/?id",
139
139
  // coercion & validation handlers for "?id" param
140
140
  // coercion fn is applied BEFORE validator
141
141
  validate: {
142
142
  id: {
143
143
  coerce: (x) => parseInt(x),
144
- check: (x)=> x > 0 && x < 100
145
- }
146
- }
144
+ check: (x) => x > 0 && x < 100,
145
+ },
146
+ },
147
147
  },
148
148
  {
149
149
  id: "image",
150
150
  // this route has 2 params and matches (for example):
151
151
  // "/images/07a9d87b-c07a-42e3-82cf-baea2f94facc/xl"
152
- match: ["images", "?id", "?size"],
152
+ match: "/images/?id/?size",
153
153
  validate: {
154
154
  id: {
155
- check: (x)=> isUUID(x)
155
+ check: (x) => isUUID(x),
156
156
  },
157
157
  size: {
158
- check: (x)=> /^(s|m|l|xl)$/.test(x)
159
- }
158
+ check: (x) => /^(s|m|l|xl)$/.test(x),
159
+ },
160
160
  },
161
- // enable auth for this route
162
- // (see info about authenticator functions below)
163
- auth: true
164
161
  },
165
162
  {
166
163
  id: "group-list",
167
164
  // matches only: "/users" or "/images"
168
- match: ["?type"],
165
+ match: "/?type",
169
166
  validate: {
170
167
  type: {
171
- check: (x) => /^(users|images)$/.test(x)
172
- }
168
+ check: (x) => /^(users|images)$/.test(x),
169
+ },
173
170
  },
174
- auth: true
175
171
  },
176
- ]
172
+ ],
177
173
  };
178
174
 
179
175
  // `HTMLRouter` ONLY works in browser environments
180
- // for non-browser use cases use `BasicRouter`
176
+ // for non-browser use cases use `Router`
181
177
  const router = new HTMLRouter(config);
182
178
  router.addListener(EVENT_ROUTE_CHANGED, console.log);
183
179
 
184
180
  router.start();
185
181
  ```
186
182
 
183
+ ## Benchmarks
184
+
185
+ The below benchmarks are ported from
186
+ [router-benchmark](https://github.com/delvedor/router-benchmark), showing
187
+ **highly competitive** results for this package. The
188
+ [benchmark](https://github.com/thi-ng/umbrella/blob/develop/packages/router/bench/index.ts)
189
+ itself can be run from the repo root like so:
190
+
191
+ ```bash
192
+ bun packages/router/bench/index.ts
193
+ ```
194
+
195
+ ```text
196
+ benchmarking: short static
197
+ warmup... 133.48ms (0.1 runs)
198
+ total: 113.79ms, runs: 1 (@ 1 calls/iter)
199
+ freq: 8788202.11 ops/sec
200
+
201
+ benchmarking: static with same radix
202
+ warmup... 166.98ms (0.1 runs)
203
+ total: 161.89ms, runs: 1 (@ 1 calls/iter)
204
+ freq: 6176873.20 ops/sec
205
+
206
+ benchmarking: dynamic route
207
+ warmup... 378.30ms (0.1 runs)
208
+ total: 374.80ms, runs: 1 (@ 1 calls/iter)
209
+ freq: 2668082.83 ops/sec
210
+
211
+ benchmarking: mixed static dynamic
212
+ warmup... 344.19ms (0.1 runs)
213
+ total: 340.33ms, runs: 1 (@ 1 calls/iter)
214
+ freq: 2938310.18 ops/sec
215
+
216
+ benchmarking: long static
217
+ warmup... 326.61ms (0.1 runs)
218
+ total: 327.84ms, runs: 1 (@ 1 calls/iter)
219
+ freq: 3050259.51 ops/sec
220
+
221
+ benchmarking: wildcard
222
+ warmup... 207.84ms (0.1 runs)
223
+ total: 207.49ms, runs: 1 (@ 1 calls/iter)
224
+ freq: 4819484.22 ops/sec
225
+
226
+ benchmarking: all together
227
+ warmup... 1525.05ms (0.1 runs)
228
+ total: 1532.24ms, runs: 1 (@ 1 calls/iter)
229
+ freq: 652640.66 ops/sec
230
+ ```
231
+
187
232
  ## Authors
188
233
 
189
234
  - [Karsten Schmidt](https://thi.ng)
package/api.d.ts CHANGED
@@ -1,13 +1,19 @@
1
- import type { EVENT_ALL, Fn, IID, IObjectOf } from "@thi.ng/api";
1
+ import type { EVENT_ALL, Fn, IObjectOf } from "@thi.ng/api";
2
2
  /**
3
- * A validation function for to-be authenticated routes. If this function
4
- * determines that the user is not allowed to access this route, it should
5
- * return nothing or a {@link RouteMatch} object for redirecting (e.g. to a
6
- * login, home page or other non-protected route). If nothing is returned and no
7
- * other routes can be matched, the router will eventually return the configured
8
- * default fallback route (see {@link RouterConfig.defaultRouteID}).
3
+ * A validation function for to-be authenticated routes.
4
+ *
5
+ * @remarks
6
+ * If this function determines that the user is not allowed to access this
7
+ * route, it should return nothing or a {@link RouteMatch} object for
8
+ * redirecting (e.g. to a login, home page or other non-protected route). If
9
+ * nothing is returned and no other routes can be matched, the router will
10
+ * eventually return the configured default fallback route (see
11
+ * {@link RouterOpts.default}).
12
+ *
13
+ * The optional `ctx` is an arbitrary user provided context value given to
14
+ * {@link BasicRouter.route} (e.g. the original request object).
9
15
  */
10
- export type RouteAuthenticator = (route: Route, curr: string[], params: any) => RouteMatch;
16
+ export type RouteAuthenticator<T = any> = (match: RouteMatch, route: AugmentedRoute, ctx?: T) => RouteMatch | undefined;
11
17
  /**
12
18
  * Route validator subspecs are optional and used to coerce and/or validate
13
19
  * individual route parameters.
@@ -29,19 +35,29 @@ export interface RouteParamValidator {
29
35
  * parameter coercion, validation and overall route authentication. Apart from
30
36
  * `id` and `match` all other fields are optional.
31
37
  */
32
- export interface Route extends IID<string> {
38
+ export interface Route {
33
39
  /**
34
- * Array of path components. If a value is prefixed with `?` this path
35
- * component will be captured under that name. E.g. `["projects", "?id"]`
36
- * will match any of these routes:
40
+ * Unique ID for this route. This value will be returned as part of a
41
+ * {@link RouteMatch} resulting from {@link BasicRouter.route} and also used
42
+ * to look up routes in {@link BasicRouter.routeForID} and
43
+ * {@link BasicRouter.format}.
44
+ */
45
+ id: string;
46
+ /**
47
+ * Array or string of path components, incl. wildcards. If a value is
48
+ * prefixed with `?` this path component will be captured under that name. A
49
+ * `+` component matches one or more rest args.
50
+ *
51
+ * @remarks
52
+ * See {@link Trie} for rules & comments on wildcard priorities & handling.
37
53
  *
38
- * - `projects/123`
39
- * - `projects/abcde`
54
+ * E.g. `/projects/?pid` will match routes: `/projects/123` or
55
+ * `/projects/abcde`, but NOT: `/projects`...
40
56
  *
41
- * `validate` options can then be used to further restrict the possible
42
- * value range of the `id` value...
57
+ * {@link Route.validate} options can then be used to further restrict the
58
+ * possible value range of the `pid` param value and/or coerce it...
43
59
  */
44
- match: string[];
60
+ match: string | string[];
45
61
  /**
46
62
  * This object specifies coercions and validators for variable /
47
63
  * parameterized path components, e.g.
@@ -50,7 +66,7 @@ export interface Route extends IID<string> {
50
66
  * {
51
67
  * id: {
52
68
  * coerce: (x) => parseInt(x,10),
53
- * validate: (x)=> x < 100
69
+ * check: (x)=> x < 100
54
70
  * }
55
71
  * }
56
72
  * ```
@@ -66,53 +82,84 @@ export interface Route extends IID<string> {
66
82
  */
67
83
  auth?: boolean;
68
84
  /**
69
- * Optional route title (passed to {@link RouteMatch})
85
+ * Reserved property.
86
+ *
87
+ * @internal used by {@link AugmentedRoute}
70
88
  */
71
- title?: string;
89
+ rest?: never;
72
90
  /**
73
- * Allow route objects to be extented w/ custom data
91
+ * Reserved property.
92
+ *
93
+ * @internal used by {@link AugmentedRoute}
74
94
  */
75
- [id: string]: any;
95
+ params?: never;
96
+ }
97
+ export interface AugmentedRoute extends Omit<Route, "match" | "rest" | "params"> {
98
+ match: string[];
99
+ params?: Record<number, string>;
100
+ rest: number;
76
101
  }
77
102
  /**
78
103
  * Result object returned by a routing operation and event value for
79
104
  * {@link EVENT_ROUTE_CHANGED}. Contains the matched route ID and any route
80
105
  * params.
81
106
  */
82
- export interface RouteMatch extends IID<string> {
83
- title?: string;
107
+ export interface RouteMatch {
84
108
  /**
85
- * Matched & processed route params.
109
+ * ID of matched {@link Route}.
110
+ */
111
+ id: string;
112
+ /**
113
+ * Matched & processed/coerced route params.
86
114
  */
87
115
  params?: any;
116
+ /**
117
+ * Only used for `*` wildcard routes. Contains remaining route elements.
118
+ */
119
+ rest?: string[];
120
+ /**
121
+ * If true, indicates the ID of this route match is a redirect (e.g.
122
+ * triggered by the {@link RouteAuthenticator} or if no route matched and
123
+ * the {@link RouterOpts.default} was triggered).
124
+ *
125
+ * @remarks
126
+ * Only intended for client purposes, not used internally. I.e. clients
127
+ * should check if this flag is set and take appropriate redirect measures.
128
+ */
129
+ redirect?: true;
88
130
  }
89
131
  /**
90
132
  * Configuration object for {@link BasicRouter} and {@link HTMLRouter}
91
133
  * instances.
92
134
  */
93
- export interface RouterConfig {
135
+ export interface RouterOpts<T = any> {
94
136
  /**
95
137
  * An array of route specs which route input strings will be matched
96
- * against. Routes will be sorted from longest to shortest.
138
+ * against. Given routes will be pre-processed and stored in a {@link Trie}
139
+ * for fast matching.
140
+ *
141
+ * @remarks
142
+ * Additional routes can be dynamically added at a later time via
143
+ * {@link Router.addRoutes}.
97
144
  */
98
145
  routes: Route[];
99
146
  /**
100
147
  * Fallback route ID (MUST exist in `routes`), used if none of the defined
101
148
  * routes could be matched against user input, e.g. a home or error page.
102
149
  */
103
- defaultRouteID: string;
150
+ default: string;
104
151
  /**
105
152
  * Optional initial route to trigger when router starts. If given, this MUST
106
153
  * be a route without params.
107
154
  */
108
- initialRouteID?: string;
155
+ initial?: string;
109
156
  /**
110
157
  * Optional route authentication function. See {@link RouteAuthenticator}
111
158
  * for further details. If no authenticator is given, all matched routes
112
159
  * will always succeed, regardless if a rule's `auth` flag is enabled or
113
160
  * not.
114
161
  */
115
- authenticator?: RouteAuthenticator;
162
+ authenticator?: RouteAuthenticator<T>;
116
163
  /**
117
164
  * Optional route path component separator. Default: `/`
118
165
  */
@@ -121,19 +168,22 @@ export interface RouterConfig {
121
168
  * Route prefix. Default: `/`. All routes to be parsed by
122
169
  * {@link BasicRouter.route} are assumed to have this prefix. All routes
123
170
  * returned by {@link BasicRouter.format} will include this prefix.
171
+ *
172
+ * @remarks
173
+ * If given, the prefix MUST end with {@link RouterOpts.separator}.
124
174
  */
125
175
  prefix?: string;
126
176
  /**
127
177
  * If true (default), the trailing slash (actually
128
- * {@link RouterConfig.separator}) of a given route string will be removed
129
- * before matching.
178
+ * {@link RouterOpts.separator}) of a given route input string will be
179
+ * removed before matching.
130
180
  */
131
- removeTrailingSlash?: boolean;
181
+ trim?: boolean;
132
182
  }
133
- export interface HTMLRouterConfig extends RouterConfig {
183
+ export interface HTMLRouterOpts<T = any> extends RouterOpts<T> {
134
184
  /**
135
- * Same as {@link RouterConfig.prefix}. If
136
- * {@link HTMLRouterConfig.useFragment} is true, then the default changes to
185
+ * Same as {@link RouterOpts.prefix}. If
186
+ * {@link HTMLRouterOpts.useFragment} is true, then the default changes to
137
187
  * `#/`. If `useFragment` is enabled and a custom prefix is given, it MUST
138
188
  * include the leading `#` as well.
139
189
  */
package/basic.d.ts CHANGED
@@ -1,15 +1,17 @@
1
- import type { Event, INotify, IObjectOf, Listener } from "@thi.ng/api";
2
- import { type Route, type RouteMatch, type RouteParamValidator, type RouterConfig, type RouterEventType } from "./api.js";
3
- export declare class BasicRouter implements INotify<RouterEventType> {
4
- config: RouterConfig;
1
+ import type { Event, INotify, IObjectOf, Listener, SomeRequired } from "@thi.ng/api";
2
+ import { type AugmentedRoute, type Route, type RouteMatch, type RouteParamValidator, type RouterEventType, type RouterOpts } from "./api.js";
3
+ import { Trie } from "./trie.js";
4
+ export declare class BasicRouter<T = any> implements INotify<RouterEventType> {
5
+ opts: RouterOpts<T>;
5
6
  current: RouteMatch | undefined;
6
- routeIndex: Record<string, Route>;
7
- constructor(config: RouterConfig);
7
+ protected index: Record<string, AugmentedRoute>;
8
+ protected routes: Trie<AugmentedRoute>;
9
+ constructor(config: RouterOpts<T>);
8
10
  addListener(id: RouterEventType, fn: Listener<RouterEventType>, scope?: any): boolean;
9
11
  removeListener(id: RouterEventType, fn: Listener<RouterEventType>, scope?: any): boolean;
10
12
  notify(event: Event<RouterEventType>): boolean;
11
13
  start(): void;
12
- addRoutes(route: Route[]): void;
14
+ addRoutes(routes: Route[]): void;
13
15
  /**
14
16
  * Main router function. Attempts to match given input string against all
15
17
  * configured routes. Before returning, triggers {@link EVENT_ROUTE_CHANGED}
@@ -17,9 +19,13 @@ export declare class BasicRouter implements INotify<RouterEventType> {
17
19
  * {@link EVENT_ROUTE_FAILED} and then falls back to configured default
18
20
  * route.
19
21
  *
22
+ * @remarks
23
+ * See {@link RouteAuthenticator} for details about `ctx` handling.
24
+ *
20
25
  * @param src - route path to match
26
+ * @param ctx - arbitrary user context
21
27
  */
22
- route(src: string): RouteMatch | undefined;
28
+ route(src: string, ctx?: T): RouteMatch | undefined;
23
29
  /**
24
30
  * Returns a formatted version of given {@link RouteMatch}, incl. any
25
31
  * params, or alternatively a registered route ID (and optional route
@@ -27,13 +33,13 @@ export declare class BasicRouter implements INotify<RouterEventType> {
27
33
  *
28
34
  * @param id -
29
35
  * @param params -
36
+ * @param rest -
30
37
  */
31
- format(id: string, params?: any): string;
32
- format(match: Partial<RouteMatch>): string;
33
- routeForID(id: string): Route | undefined;
34
- protected updateRoutes(): void;
35
- protected matchRoutes(src: string): RouteMatch | undefined;
36
- protected matchRoute(curr: string[], route: Route): RouteMatch | undefined;
38
+ format(id: string, params?: any, rest?: string[]): string;
39
+ format(match: SomeRequired<RouteMatch, "id">): string;
40
+ routeForID(id: string): AugmentedRoute | undefined;
41
+ protected augmentRoute(route: Route): AugmentedRoute;
42
+ protected matchRoutes(src: string, ctx?: T): RouteMatch | undefined;
37
43
  protected validateRouteParams(params: any, validators: IObjectOf<Partial<RouteParamValidator>>): boolean;
38
44
  protected handleRouteFailure(): boolean;
39
45
  }