@nextrush/router 3.0.7 → 4.0.0-beta.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/README.md +250 -492
- package/dist/index.d.ts +142 -185
- package/dist/index.js +816 -665
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/allowed-methods.test.ts +144 -0
- package/src/__tests__/audit-fixes.test.ts +71 -0
- package/src/__tests__/dispatch-deasync.test.ts +312 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/helpers/differential-corpus.ts +316 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +66 -0
- package/src/__tests__/match-safety.test.ts +177 -0
- package/src/__tests__/match-single-alloc.test.ts +84 -0
- package/src/__tests__/middleware-pipeline.test.ts +306 -0
- package/src/__tests__/param-decoding.test.ts +78 -0
- package/src/__tests__/public-surface.test.ts +59 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +326 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -2
- package/src/__tests__/router.test.ts +148 -2
- package/src/__tests__/static-map-reset.test.ts +50 -0
- package/src/composition.ts +75 -0
- package/src/constants.ts +25 -0
- package/src/dispatch.ts +117 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +8 -5
- package/src/match-route.ts +178 -0
- package/src/matching.ts +240 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +291 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +150 -881
- package/src/segment-trie.ts +219 -0
- package/src/state.ts +52 -0
- package/src/radix-tree.ts +0 -184
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Comprehensive correctness & edge-case audit
|
|
3
|
+
*
|
|
4
|
+
* Proves routing correctness by exhaustive assertion, not assumption. Organized
|
|
5
|
+
* by audit phase. Tests that assert *actual* behavior of an unsupported feature
|
|
6
|
+
* (brace syntax, regex constraints, param decoding) are marked CHARACTERIZATION
|
|
7
|
+
* and documented in the audit report as limitations, not bugs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { HttpMethod, RouteHandler } from '@nextrush/types';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { createRouter, Router } from '../router';
|
|
13
|
+
|
|
14
|
+
const h = (): RouteHandler => vi.fn();
|
|
15
|
+
|
|
16
|
+
describe('Router audit', () => {
|
|
17
|
+
let router: Router;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
router = createRouter();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ── Phase 1: route path syntax ──────────────────────────────────────────
|
|
23
|
+
describe('phase 1 — path syntax support', () => {
|
|
24
|
+
it('supports the colon param syntax /users/:id', () => {
|
|
25
|
+
router.get('/users/:id', h());
|
|
26
|
+
expect(router.match('GET', '/users/42')?.params).toEqual({ id: '42' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('CHARACTERIZATION: brace syntax /users/{id} is a LITERAL static segment (not a param)', () => {
|
|
30
|
+
router.get('/users/{id}', h());
|
|
31
|
+
// Only the literal path matches; it does NOT capture a param.
|
|
32
|
+
expect(router.match('GET', '/users/{id}')).not.toBeNull();
|
|
33
|
+
expect(router.match('GET', '/users/{id}')?.params).toEqual({});
|
|
34
|
+
expect(router.match('GET', '/users/42')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('CHARACTERIZATION: regex constraint /:id(\\d+) is NOT a regex — the param is literally named "id(\\d+)"', () => {
|
|
38
|
+
router.get('/n/:id(\\d+)', h());
|
|
39
|
+
const m = router.match('GET', '/n/abc'); // matches any segment (no regex enforcement)
|
|
40
|
+
expect(m).not.toBeNull();
|
|
41
|
+
expect(m?.params).toHaveProperty('id(\\d+)');
|
|
42
|
+
expect(m?.params['id']).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── Phase 2: routing correctness ─────────────────────────────────────────
|
|
47
|
+
describe('phase 2 — correctness', () => {
|
|
48
|
+
it('matches the root route', () => {
|
|
49
|
+
router.get('/', h());
|
|
50
|
+
expect(router.match('GET', '/')).not.toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('matches static routes exactly', () => {
|
|
54
|
+
router.get('/a/b/c', h());
|
|
55
|
+
expect(router.match('GET', '/a/b/c')).not.toBeNull();
|
|
56
|
+
expect(router.match('GET', '/a/b')).toBeNull();
|
|
57
|
+
expect(router.match('GET', '/a/b/c/d')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('extracts multiple params', () => {
|
|
61
|
+
router.get('/:a/:b/:c', h());
|
|
62
|
+
expect(router.match('GET', '/1/2/3')?.params).toEqual({ a: '1', b: '2', c: '3' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('matches a param between static segments', () => {
|
|
66
|
+
router.get('/files/:name/download', h());
|
|
67
|
+
expect(router.match('GET', '/files/report.pdf/download')?.params).toEqual({
|
|
68
|
+
name: 'report.pdf',
|
|
69
|
+
});
|
|
70
|
+
expect(router.match('GET', '/files/report.pdf')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('captures the rest of the path with a wildcard', () => {
|
|
74
|
+
router.get('/static/*', h());
|
|
75
|
+
expect(router.match('GET', '/static/css/app.css')?.params['*']).toBe('css/app.css');
|
|
76
|
+
expect(router.match('GET', '/static/x')?.params['*']).toBe('x');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('normalizes duplicate slashes', () => {
|
|
80
|
+
router.get('/a/b', h());
|
|
81
|
+
expect(router.match('GET', '/a//b')).not.toBeNull();
|
|
82
|
+
expect(router.match('GET', '//a///b')).not.toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('ignores a trailing slash in non-strict mode (default)', () => {
|
|
86
|
+
router.get('/users', h());
|
|
87
|
+
expect(router.match('GET', '/users/')).not.toBeNull();
|
|
88
|
+
router.get('/p/:id', h());
|
|
89
|
+
expect(router.match('GET', '/p/7/')?.params).toEqual({ id: '7' });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('handles long param values', () => {
|
|
93
|
+
router.get('/x/:v', h());
|
|
94
|
+
const long = 'a'.repeat(5000);
|
|
95
|
+
expect(router.match('GET', `/x/${long}`)?.params.v).toBe(long);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('handles a param value with dots and dashes', () => {
|
|
99
|
+
router.get('/v/:name', h());
|
|
100
|
+
expect(router.match('GET', '/v/my-file.tar.gz')?.params.name).toBe('my-file.tar.gz');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns null for a completely unknown path', () => {
|
|
104
|
+
router.get('/known', h());
|
|
105
|
+
expect(router.match('GET', '/unknown/deep/path')).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── Phase 3: priority ────────────────────────────────────────────────────
|
|
110
|
+
describe('phase 3 — priority (static > param > wildcard)', () => {
|
|
111
|
+
it('prefers a static route over a param route', () => {
|
|
112
|
+
const staticH = h();
|
|
113
|
+
const paramH = h();
|
|
114
|
+
router.get('/users/:id', paramH);
|
|
115
|
+
router.get('/users/me', staticH);
|
|
116
|
+
expect(router.match('GET', '/users/me')?.handler).toBe(staticH);
|
|
117
|
+
expect(router.match('GET', '/users/42')?.handler).toBe(paramH);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('prefers a param route over a wildcard', () => {
|
|
121
|
+
const paramH = h();
|
|
122
|
+
const wildH = h();
|
|
123
|
+
router.get('/a/*', wildH);
|
|
124
|
+
router.get('/a/:id', paramH);
|
|
125
|
+
expect(router.match('GET', '/a/x')?.handler).toBe(paramH);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('backtracks: static subtree miss falls through to the param subtree', () => {
|
|
129
|
+
const meProfile = h();
|
|
130
|
+
const idPosts = h();
|
|
131
|
+
router.get('/users/me/profile', meProfile);
|
|
132
|
+
router.get('/users/:id/posts', idPosts);
|
|
133
|
+
// /users/me/posts must fall back to the :id branch (me is not a dead end)
|
|
134
|
+
expect(router.match('GET', '/users/me/posts')?.handler).toBe(idPosts);
|
|
135
|
+
expect(router.match('GET', '/users/me/profile')?.handler).toBe(meProfile);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('is deterministic regardless of registration order', () => {
|
|
139
|
+
const r1 = createRouter();
|
|
140
|
+
r1.get('/users/me', h());
|
|
141
|
+
r1.get('/users/:id', h());
|
|
142
|
+
const r2 = createRouter();
|
|
143
|
+
r2.get('/users/:id', h());
|
|
144
|
+
r2.get('/users/me', h());
|
|
145
|
+
expect(r1.match('GET', '/users/me')).not.toBeNull();
|
|
146
|
+
expect(r2.match('GET', '/users/me')?.params).toEqual({});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Phase 4: nested / deep routes ────────────────────────────────────────
|
|
151
|
+
describe('phase 4 — nested & deep routes', () => {
|
|
152
|
+
it('matches deeply nested param chains', () => {
|
|
153
|
+
router.get('/api/v1/orgs/:orgId/teams/:teamId/members/:memberId', h());
|
|
154
|
+
expect(
|
|
155
|
+
router.match('GET', '/api/v1/orgs/1/teams/2/members/3')?.params
|
|
156
|
+
).toEqual({ orgId: '1', teamId: '2', memberId: '3' });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('supports many sibling nested routes without ambiguity', () => {
|
|
160
|
+
router.get('/api/users', h());
|
|
161
|
+
router.get('/api/users/:id', h());
|
|
162
|
+
router.get('/api/users/:id/posts', h());
|
|
163
|
+
router.get('/api/users/:id/posts/:postId', h());
|
|
164
|
+
expect(router.match('GET', '/api/users')?.params).toEqual({});
|
|
165
|
+
expect(router.match('GET', '/api/users/9')?.params).toEqual({ id: '9' });
|
|
166
|
+
expect(router.match('GET', '/api/users/9/posts')?.params).toEqual({ id: '9' });
|
|
167
|
+
expect(router.match('GET', '/api/users/9/posts/5')?.params).toEqual({
|
|
168
|
+
id: '9',
|
|
169
|
+
postId: '5',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── Phase 6: query strings must not affect matching ──────────────────────
|
|
175
|
+
describe('phase 6 — query strings are ignored during matching', () => {
|
|
176
|
+
it('matches a static route with a query string', () => {
|
|
177
|
+
router.get('/users', h());
|
|
178
|
+
expect(router.match('GET', '/users?page=5')).not.toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('matches a param route and excludes the query from the param value', () => {
|
|
182
|
+
router.get('/users/:id', h());
|
|
183
|
+
expect(router.match('GET', '/users/42?expand=true')?.params).toEqual({ id: '42' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('matches the root route with a query string', () => {
|
|
187
|
+
router.get('/', h());
|
|
188
|
+
expect(router.match('GET', '/?a=1&b=2')).not.toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Phase 7: decoding & unicode ──────────────────────────────────────────
|
|
193
|
+
describe('phase 7 — decoding & unicode', () => {
|
|
194
|
+
it('matches unicode path segments (raw)', () => {
|
|
195
|
+
router.get('/u/:name', h());
|
|
196
|
+
expect(router.match('GET', '/u/josé')?.params.name).toBe('josé');
|
|
197
|
+
expect(router.match('GET', '/u/日本語')?.params.name).toBe('日本語');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('percent-encoded params are DECODED by default (raw available via decode:false)', () => {
|
|
201
|
+
router.get('/u/:name', h());
|
|
202
|
+
expect(router.match('GET', '/u/hello%20world')?.params.name).toBe('hello world');
|
|
203
|
+
|
|
204
|
+
const raw = createRouter({ decode: false });
|
|
205
|
+
raw.get('/u/:name', h());
|
|
206
|
+
expect(raw.match('GET', '/u/hello%20world')?.params.name).toBe('hello%20world');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── Phase 8: HTTP methods ────────────────────────────────────────────────
|
|
211
|
+
describe('phase 8 — HTTP methods', () => {
|
|
212
|
+
it('registers and matches all standard methods', () => {
|
|
213
|
+
const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
214
|
+
for (const m of methods) {
|
|
215
|
+
const r = createRouter();
|
|
216
|
+
r.all('/x', h());
|
|
217
|
+
expect(r.match(m, '/x')).not.toBeNull();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('distinguishes the same path by method', () => {
|
|
222
|
+
const getH = h();
|
|
223
|
+
const postH = h();
|
|
224
|
+
router.get('/r', getH);
|
|
225
|
+
router.post('/r', postH);
|
|
226
|
+
expect(router.match('GET', '/r')?.handler).toBe(getH);
|
|
227
|
+
expect(router.match('POST', '/r')?.handler).toBe(postH);
|
|
228
|
+
expect(router.match('DELETE', '/r')).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('reports allowed methods for a path (via the public allowedMethods() middleware)', async () => {
|
|
232
|
+
router.get('/r', h());
|
|
233
|
+
router.post('/r', h());
|
|
234
|
+
const ctx = {
|
|
235
|
+
method: 'OPTIONS',
|
|
236
|
+
path: '/r',
|
|
237
|
+
status: 404,
|
|
238
|
+
set: vi.fn(),
|
|
239
|
+
} as unknown as import('@nextrush/types').Context;
|
|
240
|
+
await router.allowedMethods()(ctx, async () => {});
|
|
241
|
+
expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('GET'));
|
|
242
|
+
expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('POST'));
|
|
243
|
+
expect(ctx.set).not.toHaveBeenCalledWith('Allow', expect.stringContaining('DELETE'));
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── Phase 9: large scale ─────────────────────────────────────────────────
|
|
248
|
+
describe('phase 9 — large scale', () => {
|
|
249
|
+
it('stays correct with 1000 mixed static/param routes', () => {
|
|
250
|
+
for (let i = 0; i < 1000; i++) {
|
|
251
|
+
router.get(`/r${i}/static`, h());
|
|
252
|
+
router.get(`/r${i}/:id`, h());
|
|
253
|
+
}
|
|
254
|
+
expect(router.match('GET', '/r0/static')?.params).toEqual({});
|
|
255
|
+
expect(router.match('GET', '/r500/:id'.replace(':id', 'abc'))?.params).toEqual({ id: 'abc' });
|
|
256
|
+
expect(router.match('GET', '/r999/xyz')?.params).toEqual({ id: 'xyz' });
|
|
257
|
+
expect(router.match('GET', '/r1000/nope')).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── Phase 10: failure cases ──────────────────────────────────────────────
|
|
262
|
+
describe('phase 10 — failure & graceful handling', () => {
|
|
263
|
+
it('throws a clear error on duplicate route registration', () => {
|
|
264
|
+
router.get('/dup', h());
|
|
265
|
+
expect(() => router.get('/dup', h())).toThrow(/already registered|conflict/i);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('throws a clear error on conflicting param names at the same position', () => {
|
|
269
|
+
router.get('/u/:id', h());
|
|
270
|
+
expect(() => router.get('/u/:name/x', h())).toThrow(/param name conflict/i);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('treats an empty path as the root route', () => {
|
|
274
|
+
router.get('', h());
|
|
275
|
+
expect(router.match('GET', '/')).not.toBeNull();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('throws a clear TypeError for a non-string path', () => {
|
|
279
|
+
expect(() => router.get(null as unknown as string, h())).toThrow(TypeError);
|
|
280
|
+
expect(() => router.get(undefined as unknown as string, h())).toThrow(TypeError);
|
|
281
|
+
expect(() => router.get(123 as unknown as string, h())).toThrow(TypeError);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('requires at least one handler', () => {
|
|
285
|
+
expect(() => router.get('/no-handler')).toThrow(/at least one handler/i);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ── Phase 12: historical routing bugs from other frameworks ──────────────
|
|
290
|
+
describe('phase 12 — hardening against known routing bugs', () => {
|
|
291
|
+
it('does not confuse a param value with a similarly-named static route', () => {
|
|
292
|
+
router.get('/search', h());
|
|
293
|
+
router.get('/:term', h());
|
|
294
|
+
expect(router.match('GET', '/search')?.params).toEqual({}); // static wins
|
|
295
|
+
expect(router.match('GET', '/anything')?.params).toEqual({ term: 'anything' });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('does not leak params across non-matching branches (backtracking cleanup)', () => {
|
|
299
|
+
router.get('/a/:x/b', h());
|
|
300
|
+
router.get('/a/fixed', h());
|
|
301
|
+
// /a/fixed matches the static branch and must NOT carry an :x param
|
|
302
|
+
expect(router.match('GET', '/a/fixed')?.params).toEqual({});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('handles adjacent params without a static separator', () => {
|
|
306
|
+
router.get('/:a/:b', h());
|
|
307
|
+
expect(router.match('GET', '/x/y')?.params).toEqual({ a: 'x', b: 'y' });
|
|
308
|
+
expect(router.match('GET', '/x')).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('wildcard does not swallow a more specific static sibling', () => {
|
|
312
|
+
const wild = h();
|
|
313
|
+
const exact = h();
|
|
314
|
+
router.get('/assets/*', wild);
|
|
315
|
+
router.get('/assets/favicon.ico', exact);
|
|
316
|
+
expect(router.match('GET', '/assets/favicon.ico')?.handler).toBe(exact);
|
|
317
|
+
expect(router.match('GET', '/assets/img/logo.png')?.handler).toBe(wild);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('does not match a prefix of a longer static route', () => {
|
|
321
|
+
router.get('/api/users/list', h());
|
|
322
|
+
expect(router.match('GET', '/api/users')).toBeNull();
|
|
323
|
+
expect(router.match('GET', '/api')).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -90,10 +90,10 @@ describe('Router Edge Cases', () => {
|
|
|
90
90
|
expect(match?.params.filename).toBe('document.pdf');
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
it('should
|
|
93
|
+
it('should decode URL-encoded special characters by default', () => {
|
|
94
94
|
router.get('/search/:query', vi.fn());
|
|
95
95
|
const match = router.match('GET', '/search/hello%20world');
|
|
96
|
-
expect(match?.params.query).toBe('hello
|
|
96
|
+
expect(match?.params.query).toBe('hello world');
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it('should extract parameters with plus signs', () => {
|
|
@@ -34,6 +34,22 @@ function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
|
34
34
|
} as Context;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Mock context whose setNext/next behave like a real adapter context:
|
|
39
|
+
* `setNext` stores the next fn and `ctx.next()` invokes it. Used to exercise the
|
|
40
|
+
* modern `ctx.next()` middleware syntax through the router executor.
|
|
41
|
+
*/
|
|
42
|
+
function createNextAwareContext(overrides: Partial<Context> = {}): Context {
|
|
43
|
+
let stored: () => Promise<void> = () => Promise.resolve();
|
|
44
|
+
return createMockContext({
|
|
45
|
+
setNext: (fn: () => Promise<void>) => {
|
|
46
|
+
stored = fn;
|
|
47
|
+
},
|
|
48
|
+
next: () => stored(),
|
|
49
|
+
...overrides,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
describe('Router', () => {
|
|
38
54
|
let router: Router;
|
|
39
55
|
|
|
@@ -105,6 +121,22 @@ describe('Router', () => {
|
|
|
105
121
|
}
|
|
106
122
|
});
|
|
107
123
|
|
|
124
|
+
it('should register .all() as a single route-table entry, not one per method (T016)', () => {
|
|
125
|
+
const handler: RouteHandler = vi.fn();
|
|
126
|
+
router.all('/any', handler);
|
|
127
|
+
|
|
128
|
+
const routes = router.getRoutes();
|
|
129
|
+
const anyRoutes = routes.filter((r) => r.path === '/any');
|
|
130
|
+
|
|
131
|
+
// The introspection registry (getRoutes()) must show exactly ONE entry
|
|
132
|
+
// for an .all() route, not one row per HTTP method — this is the
|
|
133
|
+
// observable, spec-mandated behavior change (T016). Matching behavior
|
|
134
|
+
// (every method still dispatches correctly) is covered separately by
|
|
135
|
+
// 'should register all methods with .all()' above and is unaffected.
|
|
136
|
+
expect(anyRoutes).toHaveLength(1);
|
|
137
|
+
expect(anyRoutes[0]?.isAnyMethod).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
108
140
|
it('should allow method chaining', () => {
|
|
109
141
|
const result = router.get('/a', vi.fn()).post('/b', vi.fn()).put('/c', vi.fn());
|
|
110
142
|
|
|
@@ -160,11 +192,11 @@ describe('Router', () => {
|
|
|
160
192
|
expect(match?.params).toEqual({ userId: '1', postId: '2' });
|
|
161
193
|
});
|
|
162
194
|
|
|
163
|
-
it('should
|
|
195
|
+
it('should decode URL-encoded parameters by default', () => {
|
|
164
196
|
router.get('/search/:query', vi.fn());
|
|
165
197
|
|
|
166
198
|
const match = router.match('GET', '/search/hello%20world');
|
|
167
|
-
expect(match?.params).toEqual({ query: 'hello
|
|
199
|
+
expect(match?.params).toEqual({ query: 'hello world' });
|
|
168
200
|
});
|
|
169
201
|
});
|
|
170
202
|
|
|
@@ -317,6 +349,104 @@ describe('Router', () => {
|
|
|
317
349
|
});
|
|
318
350
|
});
|
|
319
351
|
|
|
352
|
+
describe('ctx.next() modern middleware syntax', () => {
|
|
353
|
+
it('runs a single per-route middleware that uses ctx.next()', async () => {
|
|
354
|
+
const order: number[] = [];
|
|
355
|
+
const mw = async (ctx: Context) => {
|
|
356
|
+
order.push(1);
|
|
357
|
+
await ctx.next();
|
|
358
|
+
};
|
|
359
|
+
const handler: RouteHandler = async () => {
|
|
360
|
+
order.push(2);
|
|
361
|
+
};
|
|
362
|
+
router.get('/one', mw, handler);
|
|
363
|
+
|
|
364
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/one' });
|
|
365
|
+
await router.routes()(ctx, async () => {});
|
|
366
|
+
|
|
367
|
+
expect(order).toEqual([1, 2]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('runs two per-route middleware that use ctx.next()', async () => {
|
|
371
|
+
const order: number[] = [];
|
|
372
|
+
const mw0 = async (ctx: Context) => {
|
|
373
|
+
order.push(1);
|
|
374
|
+
await ctx.next();
|
|
375
|
+
};
|
|
376
|
+
const mw1 = async (ctx: Context) => {
|
|
377
|
+
order.push(2);
|
|
378
|
+
await ctx.next();
|
|
379
|
+
};
|
|
380
|
+
const handler: RouteHandler = async () => {
|
|
381
|
+
order.push(3);
|
|
382
|
+
};
|
|
383
|
+
router.get('/two', mw0, mw1, handler);
|
|
384
|
+
|
|
385
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/two' });
|
|
386
|
+
await router.routes()(ctx, async () => {});
|
|
387
|
+
|
|
388
|
+
expect(order).toEqual([1, 2, 3]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('runs a chain of 5 per-route middleware that use ctx.next() (general path)', async () => {
|
|
392
|
+
const order: number[] = [];
|
|
393
|
+
const layers = [1, 2, 3, 4, 5].map((n) => async (ctx: Context) => {
|
|
394
|
+
order.push(n);
|
|
395
|
+
await ctx.next();
|
|
396
|
+
});
|
|
397
|
+
const handler: RouteHandler = async () => {
|
|
398
|
+
order.push(6);
|
|
399
|
+
};
|
|
400
|
+
router.get('/five', ...layers, handler);
|
|
401
|
+
|
|
402
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/five' });
|
|
403
|
+
await router.routes()(ctx, async () => {});
|
|
404
|
+
|
|
405
|
+
expect(order).toEqual([1, 2, 3, 4, 5, 6]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('lets ctx.next() middleware observe side effects of every layer (all headers set)', async () => {
|
|
409
|
+
const headers: Record<string, string> = {};
|
|
410
|
+
const setHeader = (name: string, value: string) => (ctx: Context) => {
|
|
411
|
+
headers[name] = value;
|
|
412
|
+
return ctx.next();
|
|
413
|
+
};
|
|
414
|
+
router.get(
|
|
415
|
+
'/headers',
|
|
416
|
+
setHeader('X-A', '1'),
|
|
417
|
+
setHeader('X-B', '2'),
|
|
418
|
+
setHeader('X-C', '3'),
|
|
419
|
+
async (ctx: Context) => ctx.json({ ok: true })
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/headers' });
|
|
423
|
+
await router.routes()(ctx, async () => {});
|
|
424
|
+
|
|
425
|
+
expect(headers).toEqual({ 'X-A': '1', 'X-B': '2', 'X-C': '3' });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('interoperates with the traditional (ctx, next) signature in the same chain', async () => {
|
|
429
|
+
const order: number[] = [];
|
|
430
|
+
const modern = async (ctx: Context) => {
|
|
431
|
+
order.push(1);
|
|
432
|
+
await ctx.next();
|
|
433
|
+
};
|
|
434
|
+
const traditional = async (_ctx: Context, next: () => Promise<void>) => {
|
|
435
|
+
order.push(2);
|
|
436
|
+
await next();
|
|
437
|
+
};
|
|
438
|
+
const handler: RouteHandler = async () => {
|
|
439
|
+
order.push(3);
|
|
440
|
+
};
|
|
441
|
+
router.get('/mixed', modern, traditional, handler);
|
|
442
|
+
|
|
443
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/mixed' });
|
|
444
|
+
await router.routes()(ctx, async () => {});
|
|
445
|
+
|
|
446
|
+
expect(order).toEqual([1, 2, 3]);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
320
450
|
describe('route groups', () => {
|
|
321
451
|
it('should create a group with prefix', () => {
|
|
322
452
|
router.group('/api', (r) => {
|
|
@@ -447,6 +577,22 @@ describe('Router', () => {
|
|
|
447
577
|
expect(router.match('POST', '/api/any')).not.toBeNull();
|
|
448
578
|
expect(router.match('PUT', '/api/any')).not.toBeNull();
|
|
449
579
|
});
|
|
580
|
+
|
|
581
|
+
it('should register a group\'s .all() as a single route-table entry, not one per method (T016)', () => {
|
|
582
|
+
// GroupRouter.all() forwards through _addGroupRoute rather than
|
|
583
|
+
// Router.all() directly — a second, independent call site for the same
|
|
584
|
+
// 7-registrations bug, found during T016's own implementation (not a
|
|
585
|
+
// pre-planned test) and fixed alongside Router.all() itself.
|
|
586
|
+
router.group('/api', (r) => {
|
|
587
|
+
r.all('/any', vi.fn());
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const routes = router.getRoutes();
|
|
591
|
+
const anyRoutes = routes.filter((r) => r.path === '/api/any');
|
|
592
|
+
|
|
593
|
+
expect(anyRoutes).toHaveLength(1);
|
|
594
|
+
expect(anyRoutes[0]?.isAnyMethod).toBe(true);
|
|
595
|
+
});
|
|
450
596
|
});
|
|
451
597
|
|
|
452
598
|
describe('redirect', () => {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - HP-9 static-map reset contract
|
|
3
|
+
*
|
|
4
|
+
* The differential golden (`match-differential.test.ts`) already pins the
|
|
5
|
+
* behavior-preserving HP-9 scenarios that live on a freshly-built router:
|
|
6
|
+
* static-over-trie precedence, method-miss, trailing-slash, `all()`, and the
|
|
7
|
+
* prefix/mount/group registration flows. The one contract it CANNOT cover —
|
|
8
|
+
* because it always builds fresh routers — is that `reset()` fully clears the
|
|
9
|
+
* (now method-nested) static map with no ghost entries. This file pins that.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RouteHandler } from '@nextrush/types';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { createRouter } from '../router';
|
|
15
|
+
|
|
16
|
+
const noop: RouteHandler = async () => {
|
|
17
|
+
/* no-op */
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('HP-9 — reset() clears the method-nested static map fully', () => {
|
|
21
|
+
it('clears static entries across every method and leaves no ghost matches', () => {
|
|
22
|
+
const router = createRouter();
|
|
23
|
+
router.get('/a', noop);
|
|
24
|
+
router.post('/a', noop);
|
|
25
|
+
router.get('/b/c', noop);
|
|
26
|
+
router.put('/b/c', noop);
|
|
27
|
+
|
|
28
|
+
expect(router.match('GET', '/a')).not.toBeNull();
|
|
29
|
+
expect(router.match('POST', '/a')).not.toBeNull();
|
|
30
|
+
expect(router.match('PUT', '/b/c')).not.toBeNull();
|
|
31
|
+
|
|
32
|
+
router.reset();
|
|
33
|
+
|
|
34
|
+
// Every method's inner map must be gone — no ghost entries.
|
|
35
|
+
expect(router.match('GET', '/a')).toBeNull();
|
|
36
|
+
expect(router.match('POST', '/a')).toBeNull();
|
|
37
|
+
expect(router.match('GET', '/b/c')).toBeNull();
|
|
38
|
+
expect(router.match('PUT', '/b/c')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('allows re-registering the same static routes after reset without a conflict throw', () => {
|
|
42
|
+
const router = createRouter();
|
|
43
|
+
router.get('/x', noop);
|
|
44
|
+
router.reset();
|
|
45
|
+
|
|
46
|
+
// A lingering inner-map entry would make this throw "Route conflict".
|
|
47
|
+
expect(() => router.get('/x', noop)).not.toThrow();
|
|
48
|
+
expect(router.match('GET', '/x')).not.toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Router Composition
|
|
3
|
+
*
|
|
4
|
+
* Sub-router mounting/copying logic extracted from the `Router` class (T014,
|
|
5
|
+
* design.md D3 — extracted after the matching-engine split left `router.ts`
|
|
6
|
+
* still over the 300-line ceiling).
|
|
7
|
+
*
|
|
8
|
+
* `copyRoutes`'s tree walk is structurally pure (segments/prefix/middleware are
|
|
9
|
+
* all explicit parameters), but its side effect — registering the copied route —
|
|
10
|
+
* needs `Router.addRoute`, a private method. Rather than reach into `Router`
|
|
11
|
+
* internals or promote `addRoute` to `public` (a public-API change out of scope
|
|
12
|
+
* for this refactor), the effect is injected explicitly as an `addRoute`
|
|
13
|
+
* callback parameter. `use`/`mount`/`mountRouter` stay on `Router` itself:
|
|
14
|
+
* they return `this` for fluent chaining and read `Router` instance state
|
|
15
|
+
* (`root`, `routerMiddleware`) that isn't naturally expressible as function
|
|
16
|
+
* parameters without more ceremony than the win justifies.
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { HttpMethod, Middleware, RouteHandler } from '@nextrush/types';
|
|
23
|
+
import type { TrieNode } from './segment-trie';
|
|
24
|
+
|
|
25
|
+
/** Callback signature matching `Router['addRoute']` — injected, not imported. */
|
|
26
|
+
export type AddRouteFn = (
|
|
27
|
+
method: HttpMethod,
|
|
28
|
+
path: string,
|
|
29
|
+
handlers: RouteHandler[],
|
|
30
|
+
middleware: Middleware[]
|
|
31
|
+
) => void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recursively copy routes from one router's trie into another via the
|
|
35
|
+
* supplied `addRoute` callback.
|
|
36
|
+
*/
|
|
37
|
+
export function copyRoutes(
|
|
38
|
+
node: TrieNode,
|
|
39
|
+
prefix: string,
|
|
40
|
+
segments: string[],
|
|
41
|
+
subRouterMiddleware: Middleware[],
|
|
42
|
+
addRoute: AddRouteFn
|
|
43
|
+
): void {
|
|
44
|
+
// Copy handlers at this node
|
|
45
|
+
for (const [method, entry] of node.handlers) {
|
|
46
|
+
const path = prefix + '/' + segments.join('/');
|
|
47
|
+
// Prepend sub-router middleware so it runs before the route's own middleware
|
|
48
|
+
const combined =
|
|
49
|
+
subRouterMiddleware.length > 0
|
|
50
|
+
? [...subRouterMiddleware, ...entry.middleware]
|
|
51
|
+
: entry.middleware;
|
|
52
|
+
addRoute(method, path || '/', [entry.handler], combined);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Copy static children
|
|
56
|
+
for (const [, child] of node.children) {
|
|
57
|
+
copyRoutes(child, prefix, [...segments, child.segment], subRouterMiddleware, addRoute);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Copy param child
|
|
61
|
+
if (node.paramChild) {
|
|
62
|
+
copyRoutes(
|
|
63
|
+
node.paramChild,
|
|
64
|
+
prefix,
|
|
65
|
+
[...segments, node.paramChild.segment],
|
|
66
|
+
subRouterMiddleware,
|
|
67
|
+
addRoute
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Copy wildcard child
|
|
72
|
+
if (node.wildcardChild) {
|
|
73
|
+
copyRoutes(node.wildcardChild, prefix, [...segments, '*'], subRouterMiddleware, addRoute);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Shared internal constants
|
|
3
|
+
*
|
|
4
|
+
* A leaf module that imports nothing, so any router module can depend on it
|
|
5
|
+
* without risking an import cycle. This is the resolution of the former
|
|
6
|
+
* `EMPTY_PARAMS` duplication: the constant was previously copied across modules
|
|
7
|
+
* to dodge a `router.ts` <-> `match-route.ts` cycle, which a dependency-free
|
|
8
|
+
* leaf module makes impossible by construction.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Frozen, null-prototype empty params object for matches with no path
|
|
16
|
+
* parameters (static-map hits and successful walks that bound no `:param`/`*`).
|
|
17
|
+
*
|
|
18
|
+
* Shared as a single instance to avoid allocating a fresh object per request on
|
|
19
|
+
* the hot path. Null-prototype so callers can never read an inherited
|
|
20
|
+
* `Object.prototype` key as if it were a route param; frozen so the shared
|
|
21
|
+
* instance can never be mutated by a handler.
|
|
22
|
+
*/
|
|
23
|
+
export const EMPTY_PARAMS: Record<string, string> = Object.freeze(
|
|
24
|
+
Object.create(null) as Record<string, string>
|
|
25
|
+
);
|