@esportsplus/routing 0.7.2 → 0.7.4
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/README.md +10 -14
- package/build/client/index.d.ts +0 -1
- package/build/client/index.js +10 -11
- package/build/client/router/index.test.d.ts +1 -0
- package/build/client/router/index.test.js +245 -0
- package/build/client/router/node.test.d.ts +1 -0
- package/build/client/router/node.test.js +214 -0
- package/package.json +6 -2
- package/src/client/index.ts +0 -1
- package/src/client/router/index.test.ts +392 -0
- package/src/client/router/node.test.ts +363 -0
- package/vitest.config.ts +14 -0
- /package/{test → tests}/dist/test.js +0 -0
- /package/{test → tests}/dist/test.js.map +0 -0
- /package/{test → tests}/index.ts +0 -0
- /package/{test → tests}/vite.config.ts +0 -0
package/README.md
CHANGED
|
@@ -88,26 +88,22 @@ const loggerMiddleware: Middleware<Response> = (req, next) => {
|
|
|
88
88
|
return next(req);
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
-
// Apply
|
|
92
|
-
app.middleware(
|
|
91
|
+
// Apply middleware chain — match route, log, then dispatch to route handler
|
|
92
|
+
let matchMiddleware = app.middleware.match(notFound);
|
|
93
|
+
|
|
94
|
+
app.middleware(matchMiddleware, loggerMiddleware, app.middleware.dispatch);
|
|
93
95
|
```
|
|
94
96
|
|
|
95
|
-
###
|
|
97
|
+
### Fallback Route
|
|
96
98
|
|
|
97
99
|
```typescript
|
|
98
|
-
// Create fallback route
|
|
100
|
+
// Create fallback route for unmatched paths
|
|
99
101
|
const notFound: Route<Response> = {
|
|
100
102
|
name: 'not-found',
|
|
101
103
|
path: null,
|
|
102
|
-
|
|
104
|
+
middleware: (req) => renderNotFound(),
|
|
103
105
|
subdomain: null
|
|
104
106
|
};
|
|
105
|
-
|
|
106
|
-
// Middleware that reactively matches routes
|
|
107
|
-
const matchMiddleware = app.middleware.match(notFound);
|
|
108
|
-
|
|
109
|
-
// Compose and dispatch
|
|
110
|
-
app.middleware(matchMiddleware, loggerMiddleware).dispatch;
|
|
111
107
|
```
|
|
112
108
|
|
|
113
109
|
### Route Groups
|
|
@@ -138,8 +134,8 @@ const apiRoutes: RouteFactory<Response> = (r) => r
|
|
|
138
134
|
// Required parameter
|
|
139
135
|
.get({ name: 'user', path: '/users/:id', responder })
|
|
140
136
|
|
|
141
|
-
// Optional parameter (prefix with
|
|
142
|
-
.get({ name: 'archive', path: '/posts
|
|
137
|
+
// Optional parameter (prefix with ?:, no preceding /)
|
|
138
|
+
.get({ name: 'archive', path: '/posts?:year?:month', responder })
|
|
143
139
|
|
|
144
140
|
// Wildcard (captures rest of path)
|
|
145
141
|
.get({ name: 'files', path: '/files/*:path', responder })
|
|
@@ -187,7 +183,7 @@ type Request<T> = {
|
|
|
187
183
|
type Route<T> = {
|
|
188
184
|
name: string | null;
|
|
189
185
|
path: string | null;
|
|
190
|
-
|
|
186
|
+
middleware: Middleware<T>[] | Next<T>;
|
|
191
187
|
subdomain: string | null;
|
|
192
188
|
};
|
|
193
189
|
```
|
package/build/client/index.d.ts
CHANGED
|
@@ -10,7 +10,6 @@ declare const router: <const Factories extends readonly RouteFactory<any>[]>(...
|
|
|
10
10
|
match(fallback: Route<InferOutput<Factories[number]>>): (request: Request<InferOutput<Factories[number]>>, next: Next<InferOutput<Factories[number]>>) => InferOutput<Factories[number]>;
|
|
11
11
|
};
|
|
12
12
|
redirect: <RouteName extends keyof AccumulateRoutes<Factories>>(name: RouteName, ...values: ExtractRequiredParams<RoutePath<AccumulateRoutes<Factories>, RouteName>> extends never ? ExtractOptionalParams<RoutePath<AccumulateRoutes<Factories>, RouteName>> extends never ? [] : [params?: PathParamsObject<RoutePath<AccumulateRoutes<Factories>, RouteName>>] : [params: PathParamsObject<RoutePath<AccumulateRoutes<Factories>, RouteName>>]) => void;
|
|
13
|
-
routes: Record<string, Route<InferOutput<Factories[number]>>>;
|
|
14
13
|
uri: <RouteName extends keyof AccumulateRoutes<Factories>>(name: RouteName, ...values: ExtractRequiredParams<RoutePath<AccumulateRoutes<Factories>, RouteName>> extends never ? ExtractOptionalParams<RoutePath<AccumulateRoutes<Factories>, RouteName>> extends never ? [] : [params?: PathParamsObject<RoutePath<AccumulateRoutes<Factories>, RouteName>>] : [params: PathParamsObject<RoutePath<AccumulateRoutes<Factories>, RouteName>>]) => string;
|
|
15
14
|
};
|
|
16
15
|
export { router };
|
package/build/client/index.js
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as reactivity_b6d4b87c525e4841a152a715c8109b3e0 from '@esportsplus/reactivity';
|
|
2
2
|
import { effect, root } from '@esportsplus/reactivity';
|
|
3
3
|
import { Router } from './router/index.js';
|
|
4
4
|
import { PACKAGE_NAME } from './constants.js';
|
|
5
|
-
class
|
|
5
|
+
class ReactiveObject_b6d4b87c525e4841a152a715c8109b3e1 extends reactivity_b6d4b87c525e4841a152a715c8109b3e0.ReactiveObject {
|
|
6
6
|
#parameters;
|
|
7
7
|
#route;
|
|
8
8
|
constructor(_p0, _p1) {
|
|
9
9
|
super(null);
|
|
10
|
-
this.#parameters = this[
|
|
11
|
-
this.#route = this[
|
|
10
|
+
this.#parameters = this[reactivity_b6d4b87c525e4841a152a715c8109b3e0.SIGNAL](_p0);
|
|
11
|
+
this.#route = this[reactivity_b6d4b87c525e4841a152a715c8109b3e0.SIGNAL](_p1);
|
|
12
12
|
}
|
|
13
13
|
get parameters() {
|
|
14
|
-
return
|
|
14
|
+
return reactivity_b6d4b87c525e4841a152a715c8109b3e0.read(this.#parameters);
|
|
15
15
|
}
|
|
16
16
|
set parameters(_v0) {
|
|
17
|
-
|
|
17
|
+
reactivity_b6d4b87c525e4841a152a715c8109b3e0.write(this.#parameters, _v0);
|
|
18
18
|
}
|
|
19
19
|
get route() {
|
|
20
|
-
return
|
|
20
|
+
return reactivity_b6d4b87c525e4841a152a715c8109b3e0.read(this.#route);
|
|
21
21
|
}
|
|
22
22
|
set route(_v1) {
|
|
23
|
-
|
|
23
|
+
reactivity_b6d4b87c525e4841a152a715c8109b3e0.write(this.#route, _v1);
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
let cache = [], location = window.location;
|
|
@@ -90,7 +90,7 @@ function middleware(request, router) {
|
|
|
90
90
|
return route.middleware(request);
|
|
91
91
|
};
|
|
92
92
|
host.match = (fallback) => {
|
|
93
|
-
let state = new
|
|
93
|
+
let state = new ReactiveObject_b6d4b87c525e4841a152a715c8109b3e1(undefined, undefined);
|
|
94
94
|
if (fallback === undefined) {
|
|
95
95
|
throw new Error(`${PACKAGE_NAME}: fallback route does not exist`);
|
|
96
96
|
}
|
|
@@ -130,7 +130,7 @@ function onpopstate() {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
const router = (...factories) => {
|
|
133
|
-
let instance = factories.reduce((r, factory) => factory(r), new Router()), request =
|
|
133
|
+
let instance = factories.reduce((r, factory) => factory(r), new Router()), request = reactivity_b6d4b87c525e4841a152a715c8109b3e0.reactive(Object.assign(href(), { data: {} }));
|
|
134
134
|
if (cache.push(request) === 1) {
|
|
135
135
|
window.addEventListener('hashchange', onpopstate);
|
|
136
136
|
}
|
|
@@ -144,7 +144,6 @@ const router = (...factories) => {
|
|
|
144
144
|
}
|
|
145
145
|
window.location.hash = normalize(instance.uri(name, values));
|
|
146
146
|
},
|
|
147
|
-
routes: instance.routes,
|
|
148
147
|
uri: (name, ...values) => {
|
|
149
148
|
return normalize(instance.uri(name, values));
|
|
150
149
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Router } from './index.js';
|
|
3
|
+
function responder(label) {
|
|
4
|
+
return () => label;
|
|
5
|
+
}
|
|
6
|
+
function mw(label) {
|
|
7
|
+
return (_input, next) => label + ':' + next(_input);
|
|
8
|
+
}
|
|
9
|
+
describe('Router', () => {
|
|
10
|
+
describe('match()', () => {
|
|
11
|
+
it('matches static GET path', () => {
|
|
12
|
+
let router = new Router();
|
|
13
|
+
router.get({ path: '/home', responder: responder('home') });
|
|
14
|
+
let result = router.match('GET', '/home');
|
|
15
|
+
expect(result.route).toBeDefined();
|
|
16
|
+
expect(result.route.path).toBe('/home');
|
|
17
|
+
});
|
|
18
|
+
it('matches dynamic path with parameters', () => {
|
|
19
|
+
let router = new Router();
|
|
20
|
+
router.get({ path: '/users/:id', responder: responder('user') });
|
|
21
|
+
let result = router.match('GET', '/users/42');
|
|
22
|
+
expect(result.route).toBeDefined();
|
|
23
|
+
expect(result.parameters).toEqual({ id: '42' });
|
|
24
|
+
});
|
|
25
|
+
it('returns empty for unregistered method', () => {
|
|
26
|
+
let router = new Router();
|
|
27
|
+
router.get({ path: '/home', responder: responder('home') });
|
|
28
|
+
let result = router.match('POST', '/home');
|
|
29
|
+
expect(result.route).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
it('returns empty for unregistered path', () => {
|
|
32
|
+
let router = new Router();
|
|
33
|
+
router.get({ path: '/home', responder: responder('home') });
|
|
34
|
+
let result = router.match('GET', '/missing');
|
|
35
|
+
expect(result.route).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
it('normalizes path (adds leading /, strips trailing /)', () => {
|
|
38
|
+
let router = new Router();
|
|
39
|
+
router.get({ path: '/users', responder: responder('users') });
|
|
40
|
+
let noLeading = router.match('GET', 'users'), trailing = router.match('GET', '/users/');
|
|
41
|
+
expect(noLeading.route).toBeDefined();
|
|
42
|
+
expect(noLeading.route.path).toBe('/users');
|
|
43
|
+
expect(trailing.route).toBeDefined();
|
|
44
|
+
expect(trailing.route.path).toBe('/users');
|
|
45
|
+
});
|
|
46
|
+
it('matches with subdomain bucketing', () => {
|
|
47
|
+
let router = new Router();
|
|
48
|
+
router.get({ path: '/api', responder: responder('api'), subdomain: 'api' });
|
|
49
|
+
let withSubdomain = router.match('GET', '/api', 'api'), withoutSubdomain = router.match('GET', '/api');
|
|
50
|
+
expect(withSubdomain.route).toBeDefined();
|
|
51
|
+
expect(withoutSubdomain.route).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
it('static match takes priority over tree match', () => {
|
|
54
|
+
let router = new Router(), dynamicResponder = responder('dynamic'), staticResponder = responder('static');
|
|
55
|
+
router.get({ path: '/users/:id', responder: dynamicResponder });
|
|
56
|
+
router.get({ path: '/users/all', responder: staticResponder });
|
|
57
|
+
let result = router.match('GET', '/users/all');
|
|
58
|
+
expect(result.route).toBeDefined();
|
|
59
|
+
expect(result.route.path).toBe('/users/all');
|
|
60
|
+
expect(result.route.middleware).toContain(staticResponder);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('on()', () => {
|
|
64
|
+
it('registers route for single method', () => {
|
|
65
|
+
let router = new Router();
|
|
66
|
+
router.on(['GET'], { path: '/test', responder: responder('test') });
|
|
67
|
+
let result = router.match('GET', '/test');
|
|
68
|
+
expect(result.route).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
it('registers route for multiple methods', () => {
|
|
71
|
+
let router = new Router();
|
|
72
|
+
router.on(['GET', 'POST'], { path: '/test', responder: responder('test') });
|
|
73
|
+
let getResult = router.match('GET', '/test'), postResult = router.match('POST', '/test');
|
|
74
|
+
expect(getResult.route).toBeDefined();
|
|
75
|
+
expect(postResult.route).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
it('throws on duplicate route name', () => {
|
|
78
|
+
let router = new Router();
|
|
79
|
+
router.on(['GET'], { name: 'home', path: '/home', responder: responder('home') });
|
|
80
|
+
expect(() => {
|
|
81
|
+
router.on(['GET'], { name: 'home', path: '/home2', responder: responder('home2') });
|
|
82
|
+
}).toThrow("@esportsplus/routing: 'home' is already in use");
|
|
83
|
+
});
|
|
84
|
+
it('throws on duplicate static path', () => {
|
|
85
|
+
let router = new Router();
|
|
86
|
+
router.on(['GET'], { path: '/home', responder: responder('home1') });
|
|
87
|
+
expect(() => {
|
|
88
|
+
router.on(['GET'], { path: '/home', responder: responder('home2') });
|
|
89
|
+
}).toThrow("@esportsplus/routing: static path '/home' is already in use");
|
|
90
|
+
});
|
|
91
|
+
it('expands optional parameters', () => {
|
|
92
|
+
let router = new Router();
|
|
93
|
+
router.on(['GET'], { path: '/users?:id', responder: responder('users') });
|
|
94
|
+
let base = router.match('GET', '/users'), withParam = router.match('GET', '/users/42');
|
|
95
|
+
expect(base.route).toBeDefined();
|
|
96
|
+
expect(withParam.route).toBeDefined();
|
|
97
|
+
expect(withParam.parameters).toEqual({ id: '42' });
|
|
98
|
+
});
|
|
99
|
+
it('registers subdomain (lowercased)', () => {
|
|
100
|
+
let router = new Router();
|
|
101
|
+
router.on(['GET'], { path: '/test', responder: responder('test'), subdomain: 'API' });
|
|
102
|
+
expect(router.subdomains).toContain('api');
|
|
103
|
+
});
|
|
104
|
+
it('normalizes www subdomain to empty string', () => {
|
|
105
|
+
let router = new Router();
|
|
106
|
+
router.on(['GET'], { path: '/test', responder: responder('test'), subdomain: 'www' });
|
|
107
|
+
let result = router.match('GET', '/test', '');
|
|
108
|
+
expect(result.route).toBeDefined();
|
|
109
|
+
expect(router.subdomains).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
it('expands multiple optional parameters', () => {
|
|
112
|
+
let router = new Router();
|
|
113
|
+
router.on(['GET'], { path: '/items?:a?:b', responder: responder('items') });
|
|
114
|
+
let base = router.match('GET', '/items'), oneParam = router.match('GET', '/items/x'), twoParams = router.match('GET', '/items/x/y');
|
|
115
|
+
expect(base.route).toBeDefined();
|
|
116
|
+
expect(oneParam.route).toBeDefined();
|
|
117
|
+
expect(oneParam.parameters).toEqual({ a: 'x' });
|
|
118
|
+
expect(twoParams.route).toBeDefined();
|
|
119
|
+
expect(twoParams.parameters).toEqual({ a: 'x', b: 'y' });
|
|
120
|
+
});
|
|
121
|
+
it('stores named route in routes registry', () => {
|
|
122
|
+
let router = new Router();
|
|
123
|
+
router.on(['GET'], { name: 'dashboard', path: '/dashboard', responder: responder('dash') });
|
|
124
|
+
expect(router.routes['dashboard']).toBeDefined();
|
|
125
|
+
expect(router.routes['dashboard'].path).toBe('/dashboard');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('uri()', () => {
|
|
129
|
+
it('generates static URI (no params)', () => {
|
|
130
|
+
let router = new Router();
|
|
131
|
+
router.get({ name: 'home', path: '/home', responder: responder('home') });
|
|
132
|
+
let uri = router.uri('home');
|
|
133
|
+
expect(uri).toBe('/home');
|
|
134
|
+
});
|
|
135
|
+
it('generates URI with required params', () => {
|
|
136
|
+
let router = new Router();
|
|
137
|
+
router.get({ name: 'user', path: '/users/:id', responder: responder('user') });
|
|
138
|
+
let uri = router.uri('user', [42]);
|
|
139
|
+
expect(uri).toBe('/users/42');
|
|
140
|
+
});
|
|
141
|
+
it('generates URI with optional params present', () => {
|
|
142
|
+
let router = new Router();
|
|
143
|
+
router.get({ name: 'users', path: '/users/?:id', responder: responder('users') });
|
|
144
|
+
let uri = router.uri('users', [7]);
|
|
145
|
+
expect(uri).toBe('/users/7');
|
|
146
|
+
});
|
|
147
|
+
it('generates URI with optional params absent (stops at first missing)', () => {
|
|
148
|
+
let router = new Router();
|
|
149
|
+
router.get({ name: 'users', path: '/users/?:id', responder: responder('users') });
|
|
150
|
+
let uri = router.uri('users');
|
|
151
|
+
expect(uri).toBe('/users');
|
|
152
|
+
});
|
|
153
|
+
it('generates URI with wildcard params', () => {
|
|
154
|
+
let router = new Router();
|
|
155
|
+
router.get({ name: 'files', path: '/files/*:path', responder: responder('files') });
|
|
156
|
+
let uri = router.uri('files', ['docs', 'readme.txt']);
|
|
157
|
+
expect(uri).toBe('/files/docs/readme.txt');
|
|
158
|
+
});
|
|
159
|
+
it('throws for non-existent route name', () => {
|
|
160
|
+
let router = new Router();
|
|
161
|
+
expect(() => {
|
|
162
|
+
router.uri('missing');
|
|
163
|
+
}).toThrow("@esportsplus/routing: route name 'missing' does not exist or it does not provide a path");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('group()', () => {
|
|
167
|
+
it('prefixes path to child routes', () => {
|
|
168
|
+
let router = new Router();
|
|
169
|
+
router.group({ path: '/api' }).routes((r) => {
|
|
170
|
+
r.get({ path: '/users', responder: responder('users') });
|
|
171
|
+
});
|
|
172
|
+
let result = router.match('GET', '/api/users');
|
|
173
|
+
expect(result.route).toBeDefined();
|
|
174
|
+
});
|
|
175
|
+
it('cascades middleware to child routes', () => {
|
|
176
|
+
let router = new Router(), authMw = mw('auth');
|
|
177
|
+
router.group({ middleware: [authMw] }).routes((r) => {
|
|
178
|
+
r.get({ path: '/protected', responder: responder('protected') });
|
|
179
|
+
});
|
|
180
|
+
let result = router.match('GET', '/protected');
|
|
181
|
+
expect(result.route).toBeDefined();
|
|
182
|
+
expect(result.route.middleware).toContain(authMw);
|
|
183
|
+
});
|
|
184
|
+
it('applies subdomain to child routes', () => {
|
|
185
|
+
let router = new Router();
|
|
186
|
+
router.group({ subdomain: 'api' }).routes((r) => {
|
|
187
|
+
r.get({ path: '/data', responder: responder('data') });
|
|
188
|
+
});
|
|
189
|
+
let withSubdomain = router.match('GET', '/data', 'api'), withoutSubdomain = router.match('GET', '/data');
|
|
190
|
+
expect(withSubdomain.route).toBeDefined();
|
|
191
|
+
expect(withoutSubdomain.route).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
it('cleans up group stack after callback', () => {
|
|
194
|
+
let router = new Router();
|
|
195
|
+
router.group({ path: '/api' }).routes((r) => {
|
|
196
|
+
r.get({ path: '/inner', responder: responder('inner') });
|
|
197
|
+
});
|
|
198
|
+
router.get({ path: '/outer', responder: responder('outer') });
|
|
199
|
+
let inner = router.match('GET', '/api/inner'), outer = router.match('GET', '/outer'), wrongOuter = router.match('GET', '/api/outer');
|
|
200
|
+
expect(inner.route).toBeDefined();
|
|
201
|
+
expect(outer.route).toBeDefined();
|
|
202
|
+
expect(wrongOuter.route).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
it('handles nested groups (path accumulation)', () => {
|
|
205
|
+
let router = new Router();
|
|
206
|
+
router.group({ path: '/api' }).routes((r) => {
|
|
207
|
+
r.group({ path: '/v1' }).routes((r2) => {
|
|
208
|
+
r2.get({ path: '/users', responder: responder('users') });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
let result = router.match('GET', '/api/v1/users');
|
|
212
|
+
expect(result.route).toBeDefined();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('HTTP method shortcuts', () => {
|
|
216
|
+
it('get() registers for GET method', () => {
|
|
217
|
+
let router = new Router();
|
|
218
|
+
router.get({ path: '/test', responder: responder('test') });
|
|
219
|
+
let getResult = router.match('GET', '/test'), postResult = router.match('POST', '/test');
|
|
220
|
+
expect(getResult.route).toBeDefined();
|
|
221
|
+
expect(postResult.route).toBeUndefined();
|
|
222
|
+
});
|
|
223
|
+
it('post() registers for POST method', () => {
|
|
224
|
+
let router = new Router();
|
|
225
|
+
router.post({ path: '/test', responder: responder('test') });
|
|
226
|
+
let getResult = router.match('GET', '/test'), postResult = router.match('POST', '/test');
|
|
227
|
+
expect(postResult.route).toBeDefined();
|
|
228
|
+
expect(getResult.route).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
it('put() registers for PUT method', () => {
|
|
231
|
+
let router = new Router();
|
|
232
|
+
router.put({ path: '/test', responder: responder('test') });
|
|
233
|
+
let putResult = router.match('PUT', '/test'), getResult = router.match('GET', '/test');
|
|
234
|
+
expect(putResult.route).toBeDefined();
|
|
235
|
+
expect(getResult.route).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
it('delete() registers for DELETE method', () => {
|
|
238
|
+
let router = new Router();
|
|
239
|
+
router.delete({ path: '/test', responder: responder('test') });
|
|
240
|
+
let deleteResult = router.match('DELETE', '/test'), getResult = router.match('GET', '/test');
|
|
241
|
+
expect(deleteResult.route).toBeDefined();
|
|
242
|
+
expect(getResult.route).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { PARAMETER, STATIC, WILDCARD } from '../constants.js';
|
|
3
|
+
import { Node } from './node.js';
|
|
4
|
+
function route(name) {
|
|
5
|
+
return { name, path: null, middleware: [], subdomain: null };
|
|
6
|
+
}
|
|
7
|
+
describe('Node', () => {
|
|
8
|
+
describe('add', () => {
|
|
9
|
+
it('adds static path (single segment)', () => {
|
|
10
|
+
let root = new Node(), r = route('home');
|
|
11
|
+
root.add('home', r);
|
|
12
|
+
expect(root.static?.get('home')).toBeDefined();
|
|
13
|
+
expect(root.static?.get('home')?.route).toBe(r);
|
|
14
|
+
});
|
|
15
|
+
it('adds multi-segment static path', () => {
|
|
16
|
+
let root = new Node(), r = route('about-team');
|
|
17
|
+
root.add('about/team', r);
|
|
18
|
+
let aboutNode = root.static?.get('about');
|
|
19
|
+
expect(aboutNode).toBeDefined();
|
|
20
|
+
expect(aboutNode?.static?.get('team')?.route).toBe(r);
|
|
21
|
+
});
|
|
22
|
+
it('adds parameter path and creates parameter child with correct name', () => {
|
|
23
|
+
let root = new Node(), r = route('user');
|
|
24
|
+
root.add('users/:id', r);
|
|
25
|
+
let usersNode = root.static?.get('users');
|
|
26
|
+
expect(usersNode).toBeDefined();
|
|
27
|
+
expect(usersNode?.parameter).toBeDefined();
|
|
28
|
+
expect(usersNode?.parameter?.name).toBe('id');
|
|
29
|
+
expect(usersNode?.parameter?.route).toBe(r);
|
|
30
|
+
});
|
|
31
|
+
it('adds wildcard path and creates wildcard child with correct name', () => {
|
|
32
|
+
let root = new Node(), r = route('catchall');
|
|
33
|
+
root.add('files/*:path', r);
|
|
34
|
+
let filesNode = root.static?.get('files');
|
|
35
|
+
expect(filesNode).toBeDefined();
|
|
36
|
+
expect(filesNode?.wildcard).toBeDefined();
|
|
37
|
+
expect(filesNode?.wildcard?.name).toBe('path');
|
|
38
|
+
expect(filesNode?.wildcard?.route).toBe(r);
|
|
39
|
+
});
|
|
40
|
+
it('adds mixed path (static + parameter segments)', () => {
|
|
41
|
+
let root = new Node(), r = route('user-post');
|
|
42
|
+
root.add('users/:id/posts', r);
|
|
43
|
+
let usersNode = root.static?.get('users');
|
|
44
|
+
expect(usersNode?.parameter?.name).toBe('id');
|
|
45
|
+
expect(usersNode?.parameter?.static?.get('posts')?.route).toBe(r);
|
|
46
|
+
});
|
|
47
|
+
it('reuses existing static nodes for shared prefixes', () => {
|
|
48
|
+
let root = new Node(), r1 = route('team'), r2 = route('contact');
|
|
49
|
+
root.add('about/team', r1);
|
|
50
|
+
root.add('about/contact', r2);
|
|
51
|
+
let aboutNode = root.static?.get('about');
|
|
52
|
+
expect(aboutNode?.static?.get('team')?.route).toBe(r1);
|
|
53
|
+
expect(aboutNode?.static?.get('contact')?.route).toBe(r2);
|
|
54
|
+
});
|
|
55
|
+
it('reuses existing parameter node', () => {
|
|
56
|
+
let root = new Node(), r1 = route('user-posts'), r2 = route('user-comments');
|
|
57
|
+
root.add('users/:id/posts', r1);
|
|
58
|
+
root.add('users/:id/comments', r2);
|
|
59
|
+
let paramNode = root.static?.get('users')?.parameter;
|
|
60
|
+
expect(paramNode?.name).toBe('id');
|
|
61
|
+
expect(paramNode?.static?.get('posts')?.route).toBe(r1);
|
|
62
|
+
expect(paramNode?.static?.get('comments')?.route).toBe(r2);
|
|
63
|
+
});
|
|
64
|
+
it('handles unnamed parameters with auto-increment naming', () => {
|
|
65
|
+
let root = new Node(), r = route('unnamed');
|
|
66
|
+
root.add(':/:', r);
|
|
67
|
+
expect(root.parameter?.name).toBe('0');
|
|
68
|
+
expect(root.parameter?.parameter?.name).toBe('1');
|
|
69
|
+
});
|
|
70
|
+
it('handles unnamed wildcard parameters', () => {
|
|
71
|
+
let root = new Node(), r = route('unnamed-wildcard');
|
|
72
|
+
root.add('files/*:', r);
|
|
73
|
+
let filesNode = root.static?.get('files');
|
|
74
|
+
expect(filesNode?.wildcard?.name).toBe('0');
|
|
75
|
+
});
|
|
76
|
+
it('sets path, route, and type on terminal node', () => {
|
|
77
|
+
let root = new Node(), r1 = route('static-route'), r2 = route('param-route'), r3 = route('wild-route');
|
|
78
|
+
let n1 = root.add('about', r1), n2 = root.add('users/:id', r2), n3 = root.add('files/*:path', r3);
|
|
79
|
+
expect(n1.path).toBe('about');
|
|
80
|
+
expect(n1.route).toBe(r1);
|
|
81
|
+
expect(n1.type).toBe(STATIC);
|
|
82
|
+
expect(n2.path).toBe('users/:id');
|
|
83
|
+
expect(n2.route).toBe(r2);
|
|
84
|
+
expect(n2.type).toBe(PARAMETER);
|
|
85
|
+
expect(n3.path).toBe('files/*:path');
|
|
86
|
+
expect(n3.route).toBe(r3);
|
|
87
|
+
expect(n3.type).toBe(WILDCARD);
|
|
88
|
+
});
|
|
89
|
+
it('reuses existing wildcard node', () => {
|
|
90
|
+
let root = new Node(), r1 = route('catchall-1'), r2 = route('catchall-2');
|
|
91
|
+
root.add('files/*:path', r1);
|
|
92
|
+
root.add('docs/*:path', r2);
|
|
93
|
+
let filesWildcard = root.static?.get('files')?.wildcard, docsWildcard = root.static?.get('docs')?.wildcard;
|
|
94
|
+
expect(filesWildcard?.route).toBe(r1);
|
|
95
|
+
expect(docsWildcard?.route).toBe(r2);
|
|
96
|
+
});
|
|
97
|
+
it('returns the terminal node', () => {
|
|
98
|
+
let root = new Node(), r = route('terminal');
|
|
99
|
+
let result = root.add('a/b/c', r);
|
|
100
|
+
expect(result).toBeInstanceOf(Node);
|
|
101
|
+
expect(result.route).toBe(r);
|
|
102
|
+
expect(result.path).toBe('a/b/c');
|
|
103
|
+
});
|
|
104
|
+
it('sets parent on child nodes', () => {
|
|
105
|
+
let root = new Node(), r = route('user');
|
|
106
|
+
root.add('users/:id', r);
|
|
107
|
+
let usersNode = root.static?.get('users');
|
|
108
|
+
expect(usersNode?.parent).toBe(root);
|
|
109
|
+
expect(usersNode?.parameter?.parent).toBe(usersNode);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('find', () => {
|
|
113
|
+
it('finds exact static path', () => {
|
|
114
|
+
let root = new Node(), r = route('home');
|
|
115
|
+
root.add('home', r);
|
|
116
|
+
let result = root.find('home');
|
|
117
|
+
expect(result.route).toBe(r);
|
|
118
|
+
expect(result.parameters).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
it('returns empty object for no match', () => {
|
|
121
|
+
let root = new Node();
|
|
122
|
+
root.add('home', route('home'));
|
|
123
|
+
let result = root.find('about');
|
|
124
|
+
expect(result.route).toBeUndefined();
|
|
125
|
+
expect(result.parameters).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
it('finds parameter path and extracts params', () => {
|
|
128
|
+
let root = new Node(), r = route('user');
|
|
129
|
+
root.add('users/:id', r);
|
|
130
|
+
let result = root.find('users/42');
|
|
131
|
+
expect(result.route).toBe(r);
|
|
132
|
+
expect(result.parameters).toEqual({ id: '42' });
|
|
133
|
+
});
|
|
134
|
+
it('static segment takes priority over parameter', () => {
|
|
135
|
+
let root = new Node(), rStatic = route('users-all'), rParam = route('user-by-id');
|
|
136
|
+
root.add('users/:id', rParam);
|
|
137
|
+
root.add('users/all', rStatic);
|
|
138
|
+
let result = root.find('users/all');
|
|
139
|
+
expect(result.route).toBe(rStatic);
|
|
140
|
+
expect(result.parameters).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
it('falls back to wildcard when no match', () => {
|
|
143
|
+
let root = new Node(), r = route('catchall');
|
|
144
|
+
root.add('files/*:path', r);
|
|
145
|
+
let result = root.find('files/docs/readme.txt');
|
|
146
|
+
expect(result.route).toBe(r);
|
|
147
|
+
expect(result.parameters).toEqual({ path: 'docs/readme.txt' });
|
|
148
|
+
});
|
|
149
|
+
it('wildcard captures remaining segments joined with /', () => {
|
|
150
|
+
let root = new Node(), r = route('catchall');
|
|
151
|
+
root.add('api/*:rest', r);
|
|
152
|
+
let result = root.find('api/v1/users/42/posts');
|
|
153
|
+
expect(result.route).toBe(r);
|
|
154
|
+
expect(result.parameters).toEqual({ rest: 'v1/users/42/posts' });
|
|
155
|
+
});
|
|
156
|
+
it('finds mixed static + parameter paths', () => {
|
|
157
|
+
let root = new Node(), r = route('user-posts');
|
|
158
|
+
root.add('users/:id/posts', r);
|
|
159
|
+
let result = root.find('users/7/posts');
|
|
160
|
+
expect(result.route).toBe(r);
|
|
161
|
+
expect(result.parameters).toEqual({ id: '7' });
|
|
162
|
+
});
|
|
163
|
+
it('returns empty when no parameter node and no static match', () => {
|
|
164
|
+
let root = new Node();
|
|
165
|
+
root.add('users/list', route('users-list'));
|
|
166
|
+
let result = root.find('users/999');
|
|
167
|
+
expect(result.route).toBeUndefined();
|
|
168
|
+
expect(result.parameters).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
it('finds root-level segments', () => {
|
|
171
|
+
let root = new Node(), r = route('root-param');
|
|
172
|
+
root.add(':slug', r);
|
|
173
|
+
let result = root.find('hello');
|
|
174
|
+
expect(result.route).toBe(r);
|
|
175
|
+
expect(result.parameters).toEqual({ slug: 'hello' });
|
|
176
|
+
});
|
|
177
|
+
it('wildcard fallback when terminal node has no route', () => {
|
|
178
|
+
let root = new Node(), rWild = route('catchall');
|
|
179
|
+
root.add('*:rest', rWild);
|
|
180
|
+
root.add('api/v1/endpoint', route('endpoint'));
|
|
181
|
+
let result = root.find('api/v1');
|
|
182
|
+
expect(result.route).toBe(rWild);
|
|
183
|
+
expect(result.parameters).toEqual({ rest: 'api/v1' });
|
|
184
|
+
});
|
|
185
|
+
it('multiple parameters in path', () => {
|
|
186
|
+
let root = new Node(), r = route('user-post');
|
|
187
|
+
root.add('users/:userId/posts/:postId', r);
|
|
188
|
+
let result = root.find('users/5/posts/99');
|
|
189
|
+
expect(result.route).toBe(r);
|
|
190
|
+
expect(result.parameters).toEqual({ userId: '5', postId: '99' });
|
|
191
|
+
});
|
|
192
|
+
it('finds multi-segment static path', () => {
|
|
193
|
+
let root = new Node(), r = route('deep');
|
|
194
|
+
root.add('a/b/c/d', r);
|
|
195
|
+
let result = root.find('a/b/c/d');
|
|
196
|
+
expect(result.route).toBe(r);
|
|
197
|
+
expect(result.parameters).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
it('returns empty on empty root with no routes', () => {
|
|
200
|
+
let root = new Node();
|
|
201
|
+
let result = root.find('anything');
|
|
202
|
+
expect(result.route).toBeUndefined();
|
|
203
|
+
expect(result.parameters).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
it('parameter fallback when static does not exist for segment', () => {
|
|
206
|
+
let root = new Node(), rParam = route('by-id'), rStatic = route('known');
|
|
207
|
+
root.add('items/:id', rParam);
|
|
208
|
+
root.add('items/known', rStatic);
|
|
209
|
+
let result = root.find('items/unknown-value');
|
|
210
|
+
expect(result.route).toBe(rParam);
|
|
211
|
+
expect(result.parameters).toEqual({ id: 'unknown-value' });
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
package/package.json
CHANGED
|
@@ -28,10 +28,14 @@
|
|
|
28
28
|
},
|
|
29
29
|
"type": "module",
|
|
30
30
|
"types": "./build/index.d.ts",
|
|
31
|
-
"
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"vitest": "^4.1.1"
|
|
33
|
+
},
|
|
34
|
+
"version": "0.7.4",
|
|
32
35
|
"scripts": {
|
|
33
36
|
"build": "tsc",
|
|
34
|
-
"build:test": "vite build --config
|
|
37
|
+
"build:test": "vite build --config tests/vite.config.ts",
|
|
38
|
+
"test": "vitest run",
|
|
35
39
|
"-": "-"
|
|
36
40
|
}
|
|
37
41
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -187,7 +187,6 @@ const router = <const Factories extends readonly RouteFactory<any>[]>(...factori
|
|
|
187
187
|
|
|
188
188
|
window.location.hash = normalize(instance.uri(name as any, values as any));
|
|
189
189
|
},
|
|
190
|
-
routes: instance.routes,
|
|
191
190
|
uri: <RouteName extends keyof Routes>(
|
|
192
191
|
name: RouteName,
|
|
193
192
|
...values: ExtractRequiredParams<RoutePath<Routes, RouteName>> extends never
|