@nextrush/router 3.0.6 → 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.
Files changed (40) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +142 -185
  3. package/dist/index.js +816 -665
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -6
  6. package/src/__tests__/allowed-methods.test.ts +144 -0
  7. package/src/__tests__/audit-fixes.test.ts +71 -0
  8. package/src/__tests__/dispatch-deasync.test.ts +312 -0
  9. package/src/__tests__/find-node-differential.test.ts +220 -0
  10. package/src/__tests__/fixtures/match-golden.json +556 -0
  11. package/src/__tests__/helpers/differential-corpus.ts +316 -0
  12. package/src/__tests__/match-differential.test.ts +46 -0
  13. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  14. package/src/__tests__/match-normalize-fastpath.test.ts +66 -0
  15. package/src/__tests__/match-safety.test.ts +177 -0
  16. package/src/__tests__/match-single-alloc.test.ts +84 -0
  17. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  18. package/src/__tests__/param-decoding.test.ts +78 -0
  19. package/src/__tests__/public-surface.test.ts +59 -0
  20. package/src/__tests__/route-metadata.test.ts +172 -0
  21. package/src/__tests__/router-audit.test.ts +326 -0
  22. package/src/__tests__/router-edge-cases.test.ts +2 -2
  23. package/src/__tests__/router.test.ts +148 -2
  24. package/src/__tests__/static-map-reset.test.ts +50 -0
  25. package/src/composition.ts +75 -0
  26. package/src/constants.ts +25 -0
  27. package/src/dispatch.ts +117 -0
  28. package/src/find-node.ts +125 -0
  29. package/src/group-router.ts +208 -0
  30. package/src/index.ts +8 -5
  31. package/src/match-route.ts +178 -0
  32. package/src/matching.ts +240 -0
  33. package/src/middleware-adapter.ts +59 -0
  34. package/src/redirect.ts +97 -0
  35. package/src/registration.ts +291 -0
  36. package/src/route-metadata.ts +68 -0
  37. package/src/router.ts +150 -881
  38. package/src/segment-trie.ts +219 -0
  39. package/src/state.ts +52 -0
  40. 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 extract URL-encoded special characters', () => {
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%20world');
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 handle URL-encoded parameters', () => {
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%20world' });
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
+ }
@@ -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
+ );