@nextrush/router 4.0.0-beta.0 → 4.0.0-beta.1

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 (38) hide show
  1. package/dist/index.d.ts +93 -1
  2. package/dist/index.js +294 -27
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/__tests__/allowed-methods.test.ts +2 -2
  6. package/src/__tests__/audit-fixes.test.ts +0 -12
  7. package/src/__tests__/canonical-path-security.test.ts +198 -0
  8. package/src/__tests__/canonicalize-memo.test.ts +108 -0
  9. package/src/__tests__/dispatch-deasync.test.ts +0 -20
  10. package/src/__tests__/find-node-differential.test.ts +6 -6
  11. package/src/__tests__/head-auto-registration.test.ts +197 -0
  12. package/src/__tests__/helpers/differential-corpus.ts +1 -3
  13. package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
  14. package/src/__tests__/match-normalize-fastpath.test.ts +1 -3
  15. package/src/__tests__/match-prenormalized.test.ts +95 -0
  16. package/src/__tests__/match-safety.test.ts +53 -7
  17. package/src/__tests__/match-single-alloc.test.ts +1 -3
  18. package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
  19. package/src/__tests__/public-surface.test.ts +14 -1
  20. package/src/__tests__/registration-max-depth.test.ts +56 -0
  21. package/src/__tests__/router-audit.test.ts +0 -11
  22. package/src/__tests__/router-edge-cases.test.ts +0 -5
  23. package/src/__tests__/router.test.ts +0 -5
  24. package/src/__tests__/static-map-reset.test.ts +1 -3
  25. package/src/__tests__/walk-pool-sizing.test.ts +72 -0
  26. package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
  27. package/src/canonicalize.ts +137 -0
  28. package/src/composition.ts +4 -0
  29. package/src/constants.ts +24 -6
  30. package/src/dispatch.ts +34 -6
  31. package/src/index.ts +6 -0
  32. package/src/match-route.ts +82 -19
  33. package/src/matching.ts +20 -14
  34. package/src/registration.ts +62 -10
  35. package/src/router.ts +79 -1
  36. package/src/segment-trie.ts +8 -0
  37. package/src/state.ts +1 -0
  38. package/src/walk-pool.ts +208 -0
@@ -29,9 +29,7 @@ import { createRouter, Router } from '../../router';
29
29
 
30
30
  /** A tagged no-op handler so a match result's `handler` is identifiable. */
31
31
  function h(tag: string): RouteHandler {
32
- const fn = (async (_ctx: Context) => {
33
- /* no-op */
34
- }) as RouteHandler & { tag: string };
32
+ const fn = (async (_ctx: Context) => {}) as RouteHandler & { tag: string };
35
33
  fn.tag = tag;
36
34
  return fn;
37
35
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @nextrush/router - matchNodeIndexed's unpooled fallback stays correct
3
+ * (`reduce-router-match-allocations`)
4
+ *
5
+ * `Router` always supplies a `WalkPool` once it has any param/wildcard route
6
+ * (via `Router.addRoute`'s pool-rebuild hook), so the unpooled branch of
7
+ * `matchNodeIndexed` has no reachable caller through the public API today.
8
+ * It is kept as an explicit, documented fallback for any future direct caller
9
+ * that doesn't have a pool — this file is that caller, proving the fallback
10
+ * itself is still correct (same results as the pooled path) rather than
11
+ * leaving it as untested dead code.
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+ import { matchNodeIndexed } from '../matching';
16
+ import { createNode, NodeType, type HandlerEntry } from '../segment-trie';
17
+ import { compileExecutor } from '../segment-trie';
18
+
19
+ const noop = async (): Promise<void> => {};
20
+
21
+ function buildTrie() {
22
+ const root = createNode('');
23
+ const files = createNode('files');
24
+ root.children.set('files', files);
25
+ const executor = compileExecutor(noop, []);
26
+ const entry: HandlerEntry = { handler: noop, middleware: [], executor };
27
+
28
+ const idNode = createNode(':id', NodeType.PARAM);
29
+ idNode.paramName = 'id';
30
+ idNode.handlers.set('GET', entry);
31
+ files.paramChild = idNode;
32
+
33
+ const wildcard = createNode('*', NodeType.WILDCARD);
34
+ wildcard.handlers.set('GET', entry);
35
+ root.wildcardChild = wildcard;
36
+
37
+ return { root, entry };
38
+ }
39
+
40
+ describe('matchNodeIndexed — unpooled fallback (no WalkPool argument)', () => {
41
+ it('matches a param route without a pool', () => {
42
+ const { root, entry } = buildTrie();
43
+ const bindNames: string[] = [];
44
+ const bindValues: string[] = [];
45
+ const result = matchNodeIndexed(root, '/files/42', 1, bindNames, bindValues, 'GET', true);
46
+ expect(result).toBe(entry);
47
+ expect(bindNames).toEqual(['id']);
48
+ expect(bindValues).toEqual(['42']);
49
+ });
50
+
51
+ it('returns the empty-segment terminal handler on a bare trailing slash', () => {
52
+ const root = createNode('');
53
+ const executor = compileExecutor(noop, []);
54
+ const entry: HandlerEntry = { handler: noop, middleware: [], executor };
55
+ root.handlers.set('GET', entry);
56
+ const bindNames: string[] = [];
57
+ const bindValues: string[] = [];
58
+ // Position 1 on '/' lands on an empty segment at the root itself.
59
+ const result = matchNodeIndexed(root, '/', 1, bindNames, bindValues, 'GET', true);
60
+ expect(result).toBe(entry);
61
+ });
62
+
63
+ it('backtracks from a failed param branch to a wildcard without a pool', () => {
64
+ const { root, entry } = buildTrie();
65
+ const bindNames: string[] = [];
66
+ const bindValues: string[] = [];
67
+ // No 'files' segment here — falls through directly to the root's own wildcard.
68
+ const result = matchNodeIndexed(root, '/anything/here', 1, bindNames, bindValues, 'GET', true);
69
+ expect(result).toBe(entry);
70
+ expect(bindNames).toEqual(['*']);
71
+ expect(bindValues).toEqual(['anything/here']);
72
+ });
73
+
74
+ it('returns null cleanly on a miss without a pool', () => {
75
+ const root = createNode('');
76
+ const bindNames: string[] = [];
77
+ const bindValues: string[] = [];
78
+ const result = matchNodeIndexed(root, '/nope', 1, bindNames, bindValues, 'GET', true);
79
+ expect(result).toBeNull();
80
+ expect(bindNames).toEqual([]);
81
+ expect(bindValues).toEqual([]);
82
+ });
83
+ });
@@ -14,9 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
14
14
  import { normalizePathForMatch } from '../matching';
15
15
  import { createRouter } from '../router';
16
16
 
17
- const noop: RouteHandler = async () => {
18
- /* no-op */
19
- };
17
+ const noop: RouteHandler = async () => {};
20
18
 
21
19
  afterEach(() => {
22
20
  vi.restoreAllMocks();
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @nextrush/router - F-10 preNormalized fast-path
3
+ *
4
+ * Pins the fix for F-10 (reconciliation report, Week 2): on the real HTTP
5
+ * dispatch path (`Router.routes()` -> `createRoutesMiddleware`), the request
6
+ * path is already canonicalized via `canonicalizePath()` before `match()` is
7
+ * ever called, so `matchRoute`'s own independent fold+collapse re-derivation
8
+ * (HP-12's `caseStable`/`isProvablyLowerAscii` decision) is pure repeated
9
+ * work on that path. `resolveMatch`/`matchRoute` gain a `preNormalized`
10
+ * parameter (default `false`) that, when `true`, trusts the input as already
11
+ * canonical and skips the fold+collapse re-derivation entirely. `Router.
12
+ * match()` — the public two-argument method used by tests and direct API
13
+ * callers, none of which canonicalize first — is unaffected: it still omits
14
+ * the argument and gets today's behavior exactly.
15
+ *
16
+ * Note: this test intentionally does not assert on path-parameter casing —
17
+ * a pre-existing, separate behavior (confirmed present before this change,
18
+ * unrelated to F-10) where `Router.routes()`'s dispatch path already
19
+ * publishes a case-folded `ctx.path` upstream of `match()` for a
20
+ * case-insensitive router, regardless of this fix. F-10 is about redundant
21
+ * computation, not about that param-casing question — out of this change's
22
+ * scope.
23
+ */
24
+
25
+ import type { RouteHandler } from '@nextrush/types';
26
+ import { afterEach, describe, expect, it, vi } from 'vitest';
27
+ import { resolveMatch, type MatchState } from '../match-route';
28
+ import { createRouter } from '../router';
29
+
30
+ const noop: RouteHandler = async () => {};
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ });
35
+
36
+ function buildState(overrides: Partial<MatchState> = {}): MatchState {
37
+ const router = createRouter();
38
+ router.get('/users/:id', noop);
39
+ const state = (router as unknown as { state: MatchState }).state;
40
+ return { ...state, ...overrides };
41
+ }
42
+
43
+ describe("F-10 — preNormalized skips matchRoute's redundant fold+collapse re-derivation", () => {
44
+ it('preNormalized: true skips the fold entirely, even on an uppercase path a fold would normally catch', () => {
45
+ const state = buildState();
46
+ const spy = vi.spyOn(String.prototype, 'toLowerCase');
47
+
48
+ // A caller passing preNormalized: true is asserting "I already
49
+ // canonicalized this" — matchRoute must trust that and never re-fold,
50
+ // even though '/USERS/ID' looks like it would otherwise trigger HP-12's
51
+ // fold branch. This is the direct, unambiguous proof the flag suppresses
52
+ // the fold step itself, independent of any caller's actual behavior.
53
+ resolveMatch(state, true, 'GET', '/USERS/ID', true);
54
+
55
+ expect(spy).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('preNormalized omitted (default false) behaves identically to today — still folds when needed', () => {
59
+ const state = buildState();
60
+ const match = resolveMatch(state, true, 'GET', '/Users/AbC');
61
+
62
+ expect(match).not.toBeNull();
63
+ expect(match?.params).toEqual({ id: 'AbC' });
64
+ });
65
+
66
+ it("Router.routes()'s internal dispatch wires preNormalized: true (no toLowerCase inside matchRoute for an already-canonical path)", async () => {
67
+ const router = createRouter();
68
+ router.get('/users/:id', noop);
69
+ const middleware = router.routes();
70
+
71
+ const ctx = {
72
+ path: '/users/abc',
73
+ originalPath: '/users/abc',
74
+ method: 'GET',
75
+ params: {},
76
+ set: () => undefined,
77
+ } as unknown as import('@nextrush/types').Context;
78
+
79
+ const spy = vi.spyOn(String.prototype, 'toLowerCase');
80
+ await middleware(ctx, undefined);
81
+
82
+ // canonicalizePath's own upstream fold call (for ctx.path) is the only
83
+ // toLowerCase attributable to this dispatch — matchRoute must add none.
84
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(1);
85
+ });
86
+
87
+ it("Router.match() — the public two-argument method — is unaffected: still folds its own raw input", () => {
88
+ const router = createRouter();
89
+ router.get('/users/:id', noop);
90
+
91
+ const match = router.match('GET', '/Users/AbC');
92
+ expect(match).not.toBeNull();
93
+ expect(match?.params).toEqual({ id: 'AbC' });
94
+ });
95
+ });
@@ -19,21 +19,68 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
19
19
  import { EMPTY_PARAMS } from '../constants';
20
20
  import { createRouter } from '../router';
21
21
 
22
- const noop: RouteHandler = async () => {
23
- /* no-op */
24
- };
22
+ const noop: RouteHandler = async () => {};
25
23
 
26
24
  afterEach(() => {
27
25
  vi.restoreAllMocks();
28
26
  });
29
27
 
30
- describe('HP-11 — null-prototype params (D8)', () => {
31
- it('materializes a populated params object with a null prototype', () => {
28
+ describe('HP-11 — params expose no Object.prototype (D8)', () => {
29
+ it('materializes a populated params object whose chain excludes Object.prototype', () => {
32
30
  const router = createRouter();
33
31
  router.get('/users/:id', noop);
34
32
  const match = router.match('GET', '/users/42');
35
33
  expect(match?.params).toEqual({ id: '42' });
36
- expect(Object.getPrototypeOf(match?.params)).toBeNull();
34
+
35
+ // The requirement is that `Object.prototype` is unreachable, NOT that the
36
+ // immediate prototype is literally `null` — params are built on a shared
37
+ // null-prototype base so V8 keeps them in fast-property mode. Walk the whole
38
+ // chain: it must terminate, and never pass through `Object.prototype`.
39
+ let proto: unknown = Object.getPrototypeOf(match?.params);
40
+ let hops = 0;
41
+ while (proto !== null) {
42
+ expect(proto).not.toBe(Object.prototype);
43
+ proto = Object.getPrototypeOf(proto as object);
44
+ expect(++hops).toBeLessThan(10);
45
+ }
46
+ expect(match?.params instanceof Object).toBe(false);
47
+ });
48
+
49
+ it('keeps the params container in V8 fast-property mode', () => {
50
+ // %HasFastProperties needs --allow-natives-syntax, which vitest's threads
51
+ // pool cannot enable; the enforced gate is
52
+ // apps/benchmark/scripts/alloc/params-shape-gate.mjs. This asserts the
53
+ // structural precondition that gate depends on: params derive from a shared
54
+ // base object, not from `null`.
55
+ const router = createRouter();
56
+ router.get('/a/:x', noop);
57
+ router.get('/b/:y', noop);
58
+
59
+ const first = Object.getPrototypeOf(router.match('GET', '/a/1')?.params);
60
+ const second = Object.getPrototypeOf(router.match('GET', '/b/2')?.params);
61
+ expect(first).not.toBeNull();
62
+ expect(first).toBe(second);
63
+ expect(Object.getPrototypeOf(first as object)).toBeNull();
64
+ expect(Object.keys(first as object)).toHaveLength(0);
65
+ });
66
+
67
+ it('behaves identically to a plain object under every enumeration path', () => {
68
+ const router = createRouter();
69
+ router.get('/u/:id/:tab', noop);
70
+ const params = router.match('GET', '/u/42/profile')?.params as Record<string, string>;
71
+
72
+ expect(JSON.stringify(params)).toBe('{"id":"42","tab":"profile"}');
73
+ expect({ ...params }).toEqual({ id: '42', tab: 'profile' });
74
+ expect(Object.keys(params)).toEqual(['id', 'tab']);
75
+ expect(Object.entries(params)).toEqual([
76
+ ['id', '42'],
77
+ ['tab', 'profile'],
78
+ ]);
79
+ const seen: string[] = [];
80
+ for (const k in params) seen.push(k);
81
+ expect(seen).toEqual(['id', 'tab']);
82
+ expect(structuredClone(params)).toEqual({ id: '42', tab: 'profile' });
83
+ expect(Object.assign({}, params)).toEqual({ id: '42', tab: 'profile' });
37
84
  });
38
85
 
39
86
  it('exposes no inherited Object.prototype members on params', () => {
@@ -51,7 +98,6 @@ describe('HP-11 — null-prototype params (D8)', () => {
51
98
  expect(match).not.toBeNull();
52
99
  expect(Object.prototype.hasOwnProperty.call(match?.params, '__proto__')).toBe(true);
53
100
  expect((match?.params as Record<string, string>)['__proto__']).toBe('danger');
54
- // No global prototype pollution.
55
101
  expect(({} as Record<string, unknown>)['danger']).toBeUndefined();
56
102
  expect((Object.prototype as Record<string, unknown>)['danger']).toBeUndefined();
57
103
  });
@@ -18,9 +18,7 @@ import { matchRoute } from '../match-route';
18
18
  import { compileExecutor, createNode, NodeType, type HandlerEntry, type StaticRouteMap } from '../segment-trie';
19
19
  import { createRouter } from '../router';
20
20
 
21
- const noop: RouteHandler = async () => {
22
- /* no-op */
23
- };
21
+ const noop: RouteHandler = async () => {};
24
22
 
25
23
  describe('HP-10 — single RouteMatch allocation', () => {
26
24
  it('matchRoute attaches routerMiddleware to its own returned object (param walk)', () => {
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @nextrush/router - reused walk-frame pool safety (`reduce-router-match-allocations`)
3
+ *
4
+ * `matchNodeIndexed`'s `WalkFrame[]` stack and `matchRoute`'s `bindNames`/
5
+ * `bindValues` arrays are pooled per-router-instance to avoid a fresh
6
+ * allocation on every call (F-02). Pinned here, per the new `router`
7
+ * capability requirement ("Reused internal walk state is never shared across
8
+ * concurrent in-flight matches"): pooling must never let one match observe or
9
+ * corrupt another's in-progress walk, and the walk must stay fully
10
+ * synchronous end-to-end for that guarantee to hold.
11
+ *
12
+ * `match-safety.test.ts`'s existing "concurrency isolation (D10)" describe
13
+ * block already proves the returned `params` object is never shared across
14
+ * matches — that stays true unchanged by this pool, since `params` is still
15
+ * materialized fresh via `Object.create(null)` regardless of pooling. This
16
+ * file adds the pool-specific contract the existing suite doesn't cover:
17
+ * sequential reuse safety and synchronous-only execution.
18
+ */
19
+
20
+ import type { RouteHandler } from '@nextrush/types';
21
+ import { describe, expect, it } from 'vitest';
22
+ import { createRouter } from '../router';
23
+
24
+ const noop: RouteHandler = async () => {};
25
+
26
+ describe('reused walk state — sequential matches reuse pooled scratch safely', () => {
27
+ it('a second match on the same router is unaffected by the first match’s path or params', () => {
28
+ const router = createRouter();
29
+ router.get('/users/:id', noop);
30
+ router.get('/orgs/:orgId/teams/:teamId', noop);
31
+
32
+ const first = router.match('GET', '/orgs/42/teams/7');
33
+ const second = router.match('GET', '/users/99');
34
+
35
+ expect(first?.params).toEqual({ orgId: '42', teamId: '7' });
36
+ expect(second?.params).toEqual({ id: '99' });
37
+ });
38
+
39
+ it('a miss followed by a hit on the same router produces a clean, uncontaminated result', () => {
40
+ const router = createRouter();
41
+ router.get('/a/:x/c', noop);
42
+ router.get('/a/b/d', noop);
43
+
44
+ const miss = router.match('GET', '/a/zz/zz');
45
+ const hit = router.match('GET', '/a/b/c');
46
+
47
+ expect(miss).toBeNull();
48
+ expect(hit?.params).toEqual({ x: 'b' });
49
+ });
50
+
51
+ it('many interleaved matches at varying depth never bleed state into each other', () => {
52
+ const router = createRouter();
53
+ router.get('/x/:a', noop);
54
+ router.get('/y/:a/:b/:c', noop);
55
+ router.get('/z', noop);
56
+
57
+ const results = Array.from({ length: 500 }, (_, i) => {
58
+ const variant = i % 3;
59
+ if (variant === 0) return router.match('GET', `/x/v${i}`);
60
+ if (variant === 1) return router.match('GET', `/y/p${i}/q${i}/r${i}`);
61
+ return router.match('GET', '/z');
62
+ });
63
+
64
+ results.forEach((m, i) => {
65
+ const variant = i % 3;
66
+ if (variant === 0) expect(m?.params).toEqual({ a: `v${i}` });
67
+ else if (variant === 1) expect(m?.params).toEqual({ a: `p${i}`, b: `q${i}`, c: `r${i}` });
68
+ else expect(m).not.toBeNull();
69
+ });
70
+ });
71
+ });
72
+
73
+ describe('reused walk state — the walk never suspends mid-frame', () => {
74
+ it('router.match returns synchronously (never a Promise) for a hit', () => {
75
+ const router = createRouter();
76
+ router.get('/users/:id', noop);
77
+ const result = router.match('GET', '/users/1');
78
+ expect(result).not.toBeInstanceOf(Promise);
79
+ });
80
+
81
+ it('router.match returns synchronously (never a Promise) for a miss', () => {
82
+ const router = createRouter();
83
+ router.get('/users/:id', noop);
84
+ const result = router.match('GET', '/nope');
85
+ expect(result).not.toBeInstanceOf(Promise);
86
+ });
87
+
88
+ it('a deep param match completes entirely within one synchronous call (no microtask boundary)', () => {
89
+ const router = createRouter();
90
+ router.get('/' + Array.from({ length: 50 }, () => ':p').join('/'), noop);
91
+ const path = '/' + Array.from({ length: 50 }, (_, i) => `x${i}`).join('/');
92
+
93
+ let sawMicrotask = false;
94
+ queueMicrotask(() => {
95
+ sawMicrotask = true;
96
+ });
97
+ const result = router.match('GET', path);
98
+ // If the walk had suspended on a promise, the queued microtask above
99
+ // would have had a chance to run before match() returned.
100
+ expect(sawMicrotask).toBe(false);
101
+ expect(result).not.toBeNull();
102
+ });
103
+ });
@@ -30,7 +30,20 @@ describe('Public API surface (runtime exports)', () => {
30
30
  // `createNode`/`NodeType`/`parseSegments` are internal segment-trie
31
31
  // helpers exposed for advanced usage (see the barrel's own comment) —
32
32
  // locked as-is here; renaming any of them is a separate breaking change.
33
- const expectedRuntime = ['createRouter', 'endpoint', 'Router', 'createNode', 'NodeType', 'parseSegments'].sort();
33
+ // `canonicalizePath`/`hasDotSegment` are RFC-029's canonical-path surface:
34
+ // the single normalization owner every path-based consumer (mount-prefix
35
+ // matching, CSRF exclude paths, a hand-written policy guard) should call
36
+ // instead of re-deriving its own fold/collapse/strip.
37
+ const expectedRuntime = [
38
+ 'createRouter',
39
+ 'endpoint',
40
+ 'Router',
41
+ 'createNode',
42
+ 'NodeType',
43
+ 'parseSegments',
44
+ 'canonicalizePath',
45
+ 'hasDotSegment',
46
+ ].sort();
34
47
 
35
48
  expect(actualExports).toEqual(expectedRuntime);
36
49
  expect(typeof NodeType).toBe('object');
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @nextrush/router - registration-time max-depth tracking
3
+ *
4
+ * `addRoute` inserts each route's `:param`/static segments as a chain of trie
5
+ * nodes. The reused walk-frame pool (`reduce-router-match-allocations`) needs
6
+ * this chain's worst-case length known at registration time, not guessed —
7
+ * an attacker's request path can never make the walk descend deeper than the
8
+ * trie's own real depth (a mismatched segment backtracks, it never pushes),
9
+ * so sizing the pool from registered-route depth preserves the walk's
10
+ * existing recursion-depth DoS guard rather than reintroducing it.
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import { addRoute, type RegistrationState } from '../registration';
15
+ import { createNode } from '../segment-trie';
16
+
17
+ function createState(): RegistrationState & { maxDepth: number } {
18
+ return {
19
+ root: createNode(''),
20
+ caseSensitive: false,
21
+ staticRoutes: new Map(),
22
+ routeDefinitions: [],
23
+ maxDepth: 0,
24
+ };
25
+ }
26
+
27
+ const noop = async (): Promise<void> => {};
28
+
29
+ describe('registration tracks the deepest registered route', () => {
30
+ it('a single-segment route has depth 1', () => {
31
+ const state = createState();
32
+ addRoute('GET', '/a', [noop], [], state);
33
+ expect(state.maxDepth).toBe(1);
34
+ });
35
+
36
+ it('tracks the deepest of several routes, not the last registered', () => {
37
+ const state = createState();
38
+ addRoute('GET', '/a', [noop], [], state);
39
+ addRoute('GET', '/a/b/c/:id', [noop], [], state);
40
+ addRoute('GET', '/x', [noop], [], state);
41
+ expect(state.maxDepth).toBe(4);
42
+ });
43
+
44
+ it('a wildcard segment counts toward depth but never pushes beyond it', () => {
45
+ const state = createState();
46
+ addRoute('GET', '/files/*', [noop], [], state);
47
+ expect(state.maxDepth).toBe(2);
48
+ });
49
+
50
+ it('registering a shallower route after a deeper one does not shrink maxDepth', () => {
51
+ const state = createState();
52
+ addRoute('GET', '/a/b/c', [noop], [], state);
53
+ addRoute('GET', '/x', [noop], [], state);
54
+ expect(state.maxDepth).toBe(3);
55
+ });
56
+ });
@@ -19,7 +19,6 @@ describe('Router audit', () => {
19
19
  router = createRouter();
20
20
  });
21
21
 
22
- // ── Phase 1: route path syntax ──────────────────────────────────────────
23
22
  describe('phase 1 — path syntax support', () => {
24
23
  it('supports the colon param syntax /users/:id', () => {
25
24
  router.get('/users/:id', h());
@@ -28,7 +27,6 @@ describe('Router audit', () => {
28
27
 
29
28
  it('CHARACTERIZATION: brace syntax /users/{id} is a LITERAL static segment (not a param)', () => {
30
29
  router.get('/users/{id}', h());
31
- // Only the literal path matches; it does NOT capture a param.
32
30
  expect(router.match('GET', '/users/{id}')).not.toBeNull();
33
31
  expect(router.match('GET', '/users/{id}')?.params).toEqual({});
34
32
  expect(router.match('GET', '/users/42')).toBeNull();
@@ -43,7 +41,6 @@ describe('Router audit', () => {
43
41
  });
44
42
  });
45
43
 
46
- // ── Phase 2: routing correctness ─────────────────────────────────────────
47
44
  describe('phase 2 — correctness', () => {
48
45
  it('matches the root route', () => {
49
46
  router.get('/', h());
@@ -106,7 +103,6 @@ describe('Router audit', () => {
106
103
  });
107
104
  });
108
105
 
109
- // ── Phase 3: priority ────────────────────────────────────────────────────
110
106
  describe('phase 3 — priority (static > param > wildcard)', () => {
111
107
  it('prefers a static route over a param route', () => {
112
108
  const staticH = h();
@@ -147,7 +143,6 @@ describe('Router audit', () => {
147
143
  });
148
144
  });
149
145
 
150
- // ── Phase 4: nested / deep routes ────────────────────────────────────────
151
146
  describe('phase 4 — nested & deep routes', () => {
152
147
  it('matches deeply nested param chains', () => {
153
148
  router.get('/api/v1/orgs/:orgId/teams/:teamId/members/:memberId', h());
@@ -171,7 +166,6 @@ describe('Router audit', () => {
171
166
  });
172
167
  });
173
168
 
174
- // ── Phase 6: query strings must not affect matching ──────────────────────
175
169
  describe('phase 6 — query strings are ignored during matching', () => {
176
170
  it('matches a static route with a query string', () => {
177
171
  router.get('/users', h());
@@ -189,7 +183,6 @@ describe('Router audit', () => {
189
183
  });
190
184
  });
191
185
 
192
- // ── Phase 7: decoding & unicode ──────────────────────────────────────────
193
186
  describe('phase 7 — decoding & unicode', () => {
194
187
  it('matches unicode path segments (raw)', () => {
195
188
  router.get('/u/:name', h());
@@ -207,7 +200,6 @@ describe('Router audit', () => {
207
200
  });
208
201
  });
209
202
 
210
- // ── Phase 8: HTTP methods ────────────────────────────────────────────────
211
203
  describe('phase 8 — HTTP methods', () => {
212
204
  it('registers and matches all standard methods', () => {
213
205
  const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
@@ -244,7 +236,6 @@ describe('Router audit', () => {
244
236
  });
245
237
  });
246
238
 
247
- // ── Phase 9: large scale ─────────────────────────────────────────────────
248
239
  describe('phase 9 — large scale', () => {
249
240
  it('stays correct with 1000 mixed static/param routes', () => {
250
241
  for (let i = 0; i < 1000; i++) {
@@ -258,7 +249,6 @@ describe('Router audit', () => {
258
249
  });
259
250
  });
260
251
 
261
- // ── Phase 10: failure cases ──────────────────────────────────────────────
262
252
  describe('phase 10 — failure & graceful handling', () => {
263
253
  it('throws a clear error on duplicate route registration', () => {
264
254
  router.get('/dup', h());
@@ -286,7 +276,6 @@ describe('Router audit', () => {
286
276
  });
287
277
  });
288
278
 
289
- // ── Phase 12: historical routing bugs from other frameworks ──────────────
290
279
  describe('phase 12 — hardening against known routing bugs', () => {
291
280
  it('does not confuse a param value with a similarly-named static route', () => {
292
281
  router.get('/search', h());
@@ -9,9 +9,6 @@ import type { Context, RouteHandler } from '@nextrush/types';
9
9
  import { beforeEach, describe, expect, it, vi } from 'vitest';
10
10
  import { createRouter, Router } from '../router';
11
11
 
12
- /**
13
- * Create mock context for testing
14
- */
15
12
  function createMockContext(overrides: Partial<Context> = {}): Context {
16
13
  return {
17
14
  method: 'GET',
@@ -260,7 +257,6 @@ describe('Router Edge Cases', () => {
260
257
 
261
258
  it('should not match wildcard if path ends at parent', () => {
262
259
  router.get('/files/*', vi.fn());
263
- // Path doesn't have anything after /files
264
260
  expect(router.match('GET', '/files')).toBeNull();
265
261
  });
266
262
  });
@@ -387,7 +383,6 @@ describe('Router Edge Cases', () => {
387
383
  const ctx = createMockContext({ method: 'GET', path: '/chain' });
388
384
  await router.routes()(ctx, async () => {});
389
385
 
390
- // Middleware should execute before handler
391
386
  expect(order).toEqual([1, 2, 0, 3, 4]);
392
387
  });
393
388
  });
@@ -6,9 +6,6 @@ import type { Context, HttpMethod, RouteHandler } from '@nextrush/types';
6
6
  import { beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import { createRouter, Router } from '../router';
8
8
 
9
- /**
10
- * Create mock context for testing
11
- */
12
9
  function createMockContext(overrides: Partial<Context> = {}): Context {
13
10
  return {
14
11
  method: 'GET',
@@ -269,7 +266,6 @@ describe('Router', () => {
269
266
  // the path normalization removes trailing slashes during split
270
267
  expect(r.match('GET', '/users')).not.toBeNull();
271
268
  expect(r.match('GET', '/users/')).not.toBeNull();
272
- // Note: Full strict mode differentiation is a future enhancement
273
269
  });
274
270
  });
275
271
 
@@ -288,7 +284,6 @@ describe('Router', () => {
288
284
 
289
285
  await middleware(ctx, async () => {});
290
286
 
291
- // Handler is called with ctx and a next function
292
287
  expect(handler).toHaveBeenCalled();
293
288
  expect(handler.mock.calls[0]?.[0]).toBe(ctx);
294
289
  });
@@ -13,9 +13,7 @@ import type { RouteHandler } from '@nextrush/types';
13
13
  import { describe, expect, it } from 'vitest';
14
14
  import { createRouter } from '../router';
15
15
 
16
- const noop: RouteHandler = async () => {
17
- /* no-op */
18
- };
16
+ const noop: RouteHandler = async () => {};
19
17
 
20
18
  describe('HP-9 — reset() clears the method-nested static map fully', () => {
21
19
  it('clears static entries across every method and leaves no ghost matches', () => {