@nextrush/router 3.0.7 → 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 (51) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +227 -178
  3. package/dist/index.js +1075 -657
  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 +59 -0
  8. package/src/__tests__/canonical-path-security.test.ts +198 -0
  9. package/src/__tests__/canonicalize-memo.test.ts +108 -0
  10. package/src/__tests__/dispatch-deasync.test.ts +292 -0
  11. package/src/__tests__/find-node-differential.test.ts +220 -0
  12. package/src/__tests__/fixtures/match-golden.json +556 -0
  13. package/src/__tests__/head-auto-registration.test.ts +197 -0
  14. package/src/__tests__/helpers/differential-corpus.ts +314 -0
  15. package/src/__tests__/match-differential.test.ts +46 -0
  16. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  17. package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
  18. package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
  19. package/src/__tests__/match-prenormalized.test.ts +95 -0
  20. package/src/__tests__/match-safety.test.ts +223 -0
  21. package/src/__tests__/match-single-alloc.test.ts +82 -0
  22. package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
  23. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  24. package/src/__tests__/param-decoding.test.ts +78 -0
  25. package/src/__tests__/public-surface.test.ts +72 -0
  26. package/src/__tests__/registration-max-depth.test.ts +56 -0
  27. package/src/__tests__/route-metadata.test.ts +172 -0
  28. package/src/__tests__/router-audit.test.ts +315 -0
  29. package/src/__tests__/router-edge-cases.test.ts +2 -7
  30. package/src/__tests__/router.test.ts +148 -7
  31. package/src/__tests__/static-map-reset.test.ts +48 -0
  32. package/src/__tests__/walk-pool-sizing.test.ts +72 -0
  33. package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
  34. package/src/canonicalize.ts +137 -0
  35. package/src/composition.ts +79 -0
  36. package/src/constants.ts +43 -0
  37. package/src/dispatch.ts +145 -0
  38. package/src/find-node.ts +125 -0
  39. package/src/group-router.ts +208 -0
  40. package/src/index.ts +14 -5
  41. package/src/match-route.ts +241 -0
  42. package/src/matching.ts +246 -0
  43. package/src/middleware-adapter.ts +59 -0
  44. package/src/redirect.ts +97 -0
  45. package/src/registration.ts +343 -0
  46. package/src/route-metadata.ts +68 -0
  47. package/src/router.ts +219 -872
  48. package/src/segment-trie.ts +227 -0
  49. package/src/state.ts +53 -0
  50. package/src/walk-pool.ts +208 -0
  51. package/src/radix-tree.ts +0 -184
@@ -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
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @nextrush/router - HP-12 unicode-correct case-normalization fast-path
3
+ *
4
+ * Pins design.md D3: `normalizePathForMatch` skips `toLowerCase()` only when the
5
+ * path is PROVABLY case-stable (all ASCII, no `A`–`Z`), folding on any non-ASCII
6
+ * or uppercase byte — so the result is byte-identical to always folding, and a
7
+ * non-ASCII uppercase path is never wrongly skipped. Also pins that a
8
+ * case-stable (lowercase-ASCII) request needs NO fold allocation and NO second
9
+ * original-case normalize pass, via a `String.prototype.toLowerCase` spy.
10
+ */
11
+
12
+ import type { RouteHandler } from '@nextrush/types';
13
+ import { afterEach, describe, expect, it, vi } from 'vitest';
14
+ import { normalizePathForMatch } from '../matching';
15
+ import { createRouter } from '../router';
16
+
17
+ const noop: RouteHandler = async () => {};
18
+
19
+ afterEach(() => {
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe('HP-12 — unicode-correct case-normalization fast-path', () => {
24
+ it('does not call toLowerCase for a provably-lowercase ASCII path (case-insensitive)', () => {
25
+ const spy = vi.spyOn(String.prototype, 'toLowerCase');
26
+ const out = normalizePathForMatch('/users/abc', false, false);
27
+ expect(out).toBe('/users/abc');
28
+ expect(spy).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it('still folds an ASCII-uppercase path byte-identically to toLowerCase()', () => {
32
+ expect(normalizePathForMatch('/Users/AbC', false, false)).toBe('/Users/AbC'.toLowerCase());
33
+ });
34
+
35
+ it('still folds a NON-ASCII uppercase path byte-identically (fast-path does not wrongly skip)', () => {
36
+ // '/ÜRL' contains non-ASCII uppercase; the fast-path must NOT skip folding.
37
+ expect(normalizePathForMatch('/\u00dcRL', false, false)).toBe('/\u00dcRL'.toLowerCase());
38
+ expect(normalizePathForMatch('/caf\u00c9', false, false)).toBe('/caf\u00c9'.toLowerCase());
39
+ });
40
+
41
+ it('collapses double slashes and strips a trailing slash identically after the fast-path', () => {
42
+ expect(normalizePathForMatch('//a//b/', false, false)).toBe('/a/b');
43
+ expect(normalizePathForMatch('//A//B/', false, false)).toBe('//A//B/'.toLowerCase().replace(/\/+/g, '/').replace(/\/$/, ''));
44
+ });
45
+
46
+ it('a case-stable param match resolves without ANY toLowerCase call (no second normalize pass)', () => {
47
+ const router = createRouter();
48
+ router.get('/users/:id', noop);
49
+ // Warm registration is done; now spy only the match path.
50
+ const spy = vi.spyOn(String.prototype, 'toLowerCase');
51
+ const match = router.match('GET', '/users/42');
52
+ expect(match).not.toBeNull();
53
+ expect(match?.params).toEqual({ id: '42' });
54
+ expect(spy).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it('a case-insensitive param match preserves original-case param values', () => {
58
+ const router = createRouter();
59
+ router.get('/users/:id', noop);
60
+ const match = router.match('GET', '/Users/AbC');
61
+ expect(match).not.toBeNull();
62
+ expect(match?.params).toEqual({ id: 'AbC' });
63
+ });
64
+ });
@@ -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
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * @nextrush/router - HP-11 safety & critical-flow contract (task 5.2)
3
+ *
4
+ * Forward-looking scenarios for the param-walk rewrite (design.md D4/D8/D9/D10):
5
+ * null-prototype params, `__proto__`-name binding without pollution,
6
+ * encoded-slash/dot never re-segmenting the path, concurrency isolation,
7
+ * deep-path DoS safety (iterative walk), the clean-null miss / 405 flow, the
8
+ * compiled-executor invariant, and the removal of the `Object.keys` post-loop
9
+ * and backtrack `Reflect.deleteProperty` (deterministic spies).
10
+ *
11
+ * The 5.1 core matching invariants (precedence, backtracking, casing, decode,
12
+ * wildcard, param+wildcard, empty-param, trailing-slash, param-less→EMPTY_PARAMS)
13
+ * are pinned by the 66-probe differential golden in `match-differential.test.ts`;
14
+ * this file adds only what the golden cannot express.
15
+ */
16
+
17
+ import type { RouteHandler } from '@nextrush/types';
18
+ import { afterEach, describe, expect, it, vi } from 'vitest';
19
+ import { EMPTY_PARAMS } from '../constants';
20
+ import { createRouter } from '../router';
21
+
22
+ const noop: RouteHandler = async () => {};
23
+
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ describe('HP-11 — params expose no Object.prototype (D8)', () => {
29
+ it('materializes a populated params object whose chain excludes Object.prototype', () => {
30
+ const router = createRouter();
31
+ router.get('/users/:id', noop);
32
+ const match = router.match('GET', '/users/42');
33
+ expect(match?.params).toEqual({ id: '42' });
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' });
84
+ });
85
+
86
+ it('exposes no inherited Object.prototype members on params', () => {
87
+ const router = createRouter();
88
+ router.get('/users/:id', noop);
89
+ const params = router.match('GET', '/users/42')?.params as Record<string, unknown>;
90
+ expect(params['toString']).toBeUndefined();
91
+ expect(params['hasOwnProperty']).toBeUndefined();
92
+ });
93
+
94
+ it('binds a __proto__ param as an OWN key without polluting Object.prototype', () => {
95
+ const router = createRouter();
96
+ router.get('/:__proto__', noop);
97
+ const match = router.match('GET', '/danger');
98
+ expect(match).not.toBeNull();
99
+ expect(Object.prototype.hasOwnProperty.call(match?.params, '__proto__')).toBe(true);
100
+ expect((match?.params as Record<string, string>)['__proto__']).toBe('danger');
101
+ expect(({} as Record<string, unknown>)['danger']).toBeUndefined();
102
+ expect((Object.prototype as Record<string, unknown>)['danger']).toBeUndefined();
103
+ });
104
+
105
+ it('binds a constructor param as an OWN string key', () => {
106
+ const router = createRouter();
107
+ router.get('/:constructor', noop);
108
+ const match = router.match('GET', '/boom');
109
+ expect(Object.prototype.hasOwnProperty.call(match?.params, 'constructor')).toBe(true);
110
+ expect((match?.params as Record<string, string>)['constructor']).toBe('boom');
111
+ });
112
+ });
113
+
114
+ describe('HP-11 — traversal-safe decode (D9): encoded slash/dot never re-segments', () => {
115
+ it('keeps an encoded slash inside a single param value and matches /files/:name (not a structural /files/a/b)', () => {
116
+ const router = createRouter();
117
+ router.get('/files/:name', noop);
118
+ router.get('/files/a/b', (async () => {}) as RouteHandler);
119
+ const match = router.match('GET', '/files/a%2Fb');
120
+ expect(match?.params).toEqual({ name: 'a/b' });
121
+ });
122
+
123
+ it('keeps encoded dots inside the value (no `..` traversal segments)', () => {
124
+ const router = createRouter();
125
+ router.get('/files/:name', noop);
126
+ const match = router.match('GET', '/files/%2E%2E');
127
+ expect(match?.params).toEqual({ name: '..' });
128
+ });
129
+ });
130
+
131
+ describe('HP-11 — concurrency isolation (D10)', () => {
132
+ it('gives each match its own params; no cross-contamination across many matches', () => {
133
+ const router = createRouter();
134
+ router.get('/users/:id', noop);
135
+ const results = Array.from({ length: 1000 }, (_, i) => router.match('GET', `/users/u${i}`));
136
+ results.forEach((m, i) => {
137
+ expect(m?.params).toEqual({ id: `u${i}` });
138
+ });
139
+ // Distinct param objects per match (no shared mutable scratch reused).
140
+ expect(results[0]?.params).not.toBe(results[1]?.params);
141
+ });
142
+
143
+ it('shares only the frozen EMPTY_PARAMS for param-less matches', () => {
144
+ const router = createRouter();
145
+ router.get('/a/*', noop); // wildcard keeps hasParamRoutes true; /health walks & binds nothing
146
+ router.get('/health', noop);
147
+ // A static-through-trie param-less match returns the shared sentinel.
148
+ const m1 = router.match('GET', '/health');
149
+ expect(m1?.params).toBe(EMPTY_PARAMS);
150
+ });
151
+ });
152
+
153
+ describe('HP-11 — deep-path DoS safety (iterative walk)', () => {
154
+ it('resolves a very deep matching path without a stack overflow', () => {
155
+ const depth = 60000;
156
+ const router = createRouter();
157
+ // A deep param chain forces the walk to descend `depth` levels.
158
+ router.get('/' + Array.from({ length: depth }, () => ':p').join('/'), noop);
159
+ const path = '/' + Array.from({ length: depth }, (_, i) => `x${i}`).join('/');
160
+ expect(() => router.match('GET', path)).not.toThrow();
161
+ expect(router.match('GET', path)).not.toBeNull();
162
+ });
163
+
164
+ it('misses a very deep non-matching path without a stack overflow', () => {
165
+ const depth = 60000;
166
+ const router = createRouter();
167
+ router.get('/short/:id', noop);
168
+ const path = '/' + Array.from({ length: depth }, (_, i) => `x${i}`).join('/');
169
+ expect(() => router.match('GET', path)).not.toThrow();
170
+ expect(router.match('GET', path)).toBeNull();
171
+ });
172
+ });
173
+
174
+ describe('HP-11 — miss / 405 / executor critical flow', () => {
175
+ it('returns null cleanly on a miss', () => {
176
+ const router = createRouter();
177
+ router.get('/x/:id', noop);
178
+ expect(router.match('GET', '/nope')).toBeNull();
179
+ });
180
+
181
+ it('returns null for a known path with an unregistered method (so allowedMethods can 405)', () => {
182
+ const router = createRouter();
183
+ router.get('/x/:id', noop);
184
+ expect(router.match('POST', '/x/42')).toBeNull();
185
+ });
186
+
187
+ it('returns null for a wildcard path requested with an unregistered method', () => {
188
+ const router = createRouter();
189
+ router.get('/w/*', noop);
190
+ // Wildcard child exists but has no POST handler → the walk pops the deferred
191
+ // '*' bind and backtracks to a clean null (not a spurious match).
192
+ expect(router.match('POST', '/w/a/b')).toBeNull();
193
+ });
194
+
195
+ it('returns the pre-compiled executor on a matched param route (not re-composed)', () => {
196
+ const router = createRouter();
197
+ router.get('/x/:id', noop);
198
+ const match = router.match('GET', '/x/42');
199
+ expect(typeof match?.executor).toBe('function');
200
+ });
201
+ });
202
+
203
+ describe('HP-11/HP-13 — allocation mechanism removed (deterministic spies)', () => {
204
+ it('does not call Object.keys on the param match path (HP-13 post-loop gone)', () => {
205
+ const router = createRouter();
206
+ router.get('/x/:id', noop);
207
+ const spy = vi.spyOn(Object, 'keys');
208
+ router.match('GET', '/x/42');
209
+ expect(spy).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it('does not call Reflect.deleteProperty during a backtracking match (eager-bind/backtrack gone)', () => {
213
+ const router = createRouter();
214
+ router.get('/a/:x/c', noop);
215
+ router.get('/a/b/d', noop);
216
+ const spy = vi.spyOn(Reflect, 'deleteProperty');
217
+ // /a/b/c: static `b` tried, fails at `c`, backtracks to param :x — the exact
218
+ // path that used to call Reflect.deleteProperty on backtrack.
219
+ const match = router.match('GET', '/a/b/c');
220
+ expect(match?.params).toEqual({ x: 'b' });
221
+ expect(spy).not.toHaveBeenCalled();
222
+ });
223
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @nextrush/router - HP-10 single-allocation contract
3
+ *
4
+ * Pins design.md D1: a matched request produces ONE `RouteMatch` object with
5
+ * `middleware` attached, rather than a `matchRoute` result later wrapped by
6
+ * `resolveMatch`. Asserted at the `matchRoute` boundary — `matchRoute` now
7
+ * returns the full `RouteMatch` shape (incl. `middleware === routerMiddleware`)
8
+ * for both the static-map fast path and a param walk — plus at the public
9
+ * `Router.match()` boundary (shape unchanged, middleware attached once).
10
+ *
11
+ * The "exactly one object" claim itself is proven by the allocation micro-bench
12
+ * (`bench:alloc:router`); this file pins the observable single-object contract.
13
+ */
14
+
15
+ import type { Middleware, RouteHandler } from '@nextrush/types';
16
+ import { describe, expect, it } from 'vitest';
17
+ import { matchRoute } from '../match-route';
18
+ import { compileExecutor, createNode, NodeType, type HandlerEntry, type StaticRouteMap } from '../segment-trie';
19
+ import { createRouter } from '../router';
20
+
21
+ const noop: RouteHandler = async () => {};
22
+
23
+ describe('HP-10 — single RouteMatch allocation', () => {
24
+ it('matchRoute attaches routerMiddleware to its own returned object (param walk)', () => {
25
+ const root = createNode('');
26
+ const users = createNode('users');
27
+ root.children.set('users', users);
28
+ const idNode = createNode(':id', NodeType.PARAM);
29
+ idNode.paramName = 'id';
30
+ users.paramChild = idNode;
31
+ const executor = compileExecutor(noop, []);
32
+ idNode.handlers.set('GET', { handler: noop, middleware: [], executor });
33
+
34
+ const routerMiddleware: Middleware[] = [async (_ctx, next) => next?.()];
35
+ const match = matchRoute(
36
+ 'GET',
37
+ '/users/42',
38
+ root,
39
+ new Map() as StaticRouteMap,
40
+ true,
41
+ false,
42
+ false,
43
+ true,
44
+ routerMiddleware
45
+ );
46
+
47
+ expect(match).not.toBeNull();
48
+ expect(match?.handler).toBe(noop);
49
+ expect(match?.executor).toBe(executor);
50
+ expect(match?.params).toEqual({ id: '42' });
51
+ // The single-object contract: middleware lives on matchRoute's own return.
52
+ expect(match?.middleware).toBe(routerMiddleware);
53
+ });
54
+
55
+ it('matchRoute attaches routerMiddleware on the static fast path too', () => {
56
+ const root = createNode('');
57
+ const executor = compileExecutor(noop, []);
58
+ const staticRoutes: StaticRouteMap = new Map([
59
+ ['GET', new Map<string, HandlerEntry>([['/s', { handler: noop, middleware: [], executor }]])],
60
+ ]);
61
+ const routerMiddleware: Middleware[] = [async (_ctx, next) => next?.()];
62
+
63
+ const match = matchRoute('GET', '/s', root, staticRoutes, false, false, false, true, routerMiddleware);
64
+
65
+ expect(match?.handler).toBe(noop);
66
+ expect(match?.middleware).toBe(routerMiddleware);
67
+ });
68
+
69
+ it('Router.match attaches the SAME routerMiddleware array on every match (attached once, not rebuilt)', () => {
70
+ const mw: Middleware = async (_ctx, next) => next?.();
71
+ const router = createRouter();
72
+ router.use(mw);
73
+ router.get('/x', noop);
74
+ router.get('/y/:id', noop);
75
+
76
+ const m1 = router.match('GET', '/x');
77
+ const m2 = router.match('GET', '/y/7');
78
+
79
+ expect(m1?.middleware).toBe(m2?.middleware);
80
+ expect(m1?.middleware).toContain(mw);
81
+ });
82
+ });
@@ -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
+ });