@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,316 @@
1
+ /**
2
+ * @nextrush/router - Differential / characterization corpus
3
+ *
4
+ * Shared corpus + canonical serializer backing the golden-master regression
5
+ * harness for the `router-match-path-allocation-trim` change. The serializer
6
+ * reduces a `Router.match()` result to a stable, JSON-comparable shape so the
7
+ * pre-change matcher's output (captured once into `match-golden.json`) can be
8
+ * asserted byte-identical against the post-rewrite matcher across every trim.
9
+ *
10
+ * WHY a golden master rather than two live matchers: the trims land in-place on
11
+ * a single matcher, so the "old" implementation cannot coexist with the "new"
12
+ * one in the same process. Persisting the pre-change output as a committed
13
+ * fixture is the standard characterization technique for exactly this — the
14
+ * fixture IS the old matcher's behavior, frozen.
15
+ *
16
+ * Intentional-delta cases (null-prototype params, and `__proto__`/`constructor`/
17
+ * `prototype` param NAMES) are deliberately NOT in this corpus — they change
18
+ * observably by design (design.md D8) and are pinned by dedicated forward
19
+ * scenarios instead. This corpus covers only the behavior-PRESERVING contract:
20
+ * which route resolves, the own-enumerable param entries and their values, and
21
+ * whether an executor is present.
22
+ *
23
+ * @packageDocumentation
24
+ * @internal
25
+ */
26
+
27
+ import type { Context, RouteHandler } from '@nextrush/types';
28
+ import { createRouter, Router } from '../../router';
29
+
30
+ /** A tagged no-op handler so a match result's `handler` is identifiable. */
31
+ function h(tag: string): RouteHandler {
32
+ const fn = (async (_ctx: Context) => {
33
+ /* no-op */
34
+ }) as RouteHandler & { tag: string };
35
+ fn.tag = tag;
36
+ return fn;
37
+ }
38
+
39
+ /** A single (method, path) probe against a named route set. */
40
+ export interface Probe {
41
+ method: string;
42
+ path: string;
43
+ }
44
+
45
+ /** A named router plus the probes run against it. */
46
+ export interface RouteSet {
47
+ name: string;
48
+ build: () => Router;
49
+ probes: Probe[];
50
+ }
51
+
52
+ /**
53
+ * Canonical, JSON-stable serialization of one match result.
54
+ *
55
+ * Deliberately excludes the params [[Prototype]] from the golden diff: the
56
+ * prototype flips from `Object.prototype` to `null` for populated params by
57
+ * design (D8), so it is asserted as a forward invariant elsewhere, not baked
58
+ * into the byte-identical golden. Own-enumerable key OWNERSHIP and VALUES —
59
+ * the real behavioral contract — ARE captured here.
60
+ */
61
+ export interface SerializedMatch {
62
+ matched: boolean;
63
+ handlerTag: string | null;
64
+ params: Array<[string, string]>;
65
+ hasExecutor: boolean;
66
+ }
67
+
68
+ /** Run one probe against a built router and reduce it to the canonical shape. */
69
+ export function serializeMatch(router: Router, method: string, path: string): SerializedMatch {
70
+ const result = router.match(method as never, path);
71
+ if (!result) {
72
+ return { matched: false, handlerTag: null, params: [], hasExecutor: false };
73
+ }
74
+ const handler = result.handler as (RouteHandler & { tag?: string }) | undefined;
75
+ const params = Object.keys(result.params)
76
+ .sort()
77
+ .map((k) => [k, result.params[k]] as [string, string]);
78
+ return {
79
+ matched: true,
80
+ handlerTag: handler?.tag ?? null,
81
+ params,
82
+ hasExecutor: typeof result.executor === 'function',
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The default route set: exercises static, nested static, single/nested params,
88
+ * backtracking, static-over-param-over-wildcard precedence, wildcard (incl.
89
+ * empty capture), and param + trailing wildcard. Case-insensitive, non-strict,
90
+ * decode on (framework defaults).
91
+ */
92
+ function buildDefault(): Router {
93
+ const r = createRouter();
94
+ r.get('/', h('root'));
95
+ r.get('/users', h('users'));
96
+ r.get('/users/me', h('users.me')); // static beats param
97
+ r.get('/users/:id', h('users.id'));
98
+ r.get('/a/b/c', h('a.b.c')); // nested static
99
+ r.get('/a/:x/c', h('a.x.c')); // backtracking target
100
+ r.get('/a/b/d', h('a.b.d'));
101
+ r.get('/a/:x/b/:y', h('a.x.b.y')); // nested params
102
+ r.get('/p/me', h('p.me')); // static > param > wildcard at one node
103
+ r.get('/p/:id', h('p.id'));
104
+ r.get('/p/*', h('p.star'));
105
+ r.get('/files/*', h('files.star')); // wildcard incl. empty capture
106
+ r.get('/g/:x/*', h('g.x.star')); // param + trailing wildcard
107
+ r.post('/users', h('users.post')); // method variety on a static path
108
+ return r;
109
+ }
110
+
111
+ function buildCaseSensitive(): Router {
112
+ const r = createRouter({ caseSensitive: true });
113
+ r.get('/Users/:id', h('cs.users.id'));
114
+ r.get('/Static', h('cs.static'));
115
+ return r;
116
+ }
117
+
118
+ function buildStrict(): Router {
119
+ const r = createRouter({ strict: true });
120
+ r.get('/strict', h('strict.static'));
121
+ r.get('/strict/:id', h('strict.id'));
122
+ return r;
123
+ }
124
+
125
+ function buildDecodeOff(): Router {
126
+ const r = createRouter({ decode: false });
127
+ r.get('/raw/:id', h('raw.id'));
128
+ r.get('/raw/*', h('raw.star'));
129
+ return r;
130
+ }
131
+
132
+ function buildAll(): Router {
133
+ const r = createRouter();
134
+ r.all('/any', h('any'));
135
+ return r;
136
+ }
137
+
138
+ function buildPrefix(): Router {
139
+ const r = createRouter({ prefix: '/api' });
140
+ r.get('/health', h('api.health'));
141
+ r.get('/items/:id', h('api.items.id'));
142
+ return r;
143
+ }
144
+
145
+ function buildMount(): Router {
146
+ const child = createRouter();
147
+ child.get('/x', h('mount.x'));
148
+ child.get('/:p', h('mount.p'));
149
+ const parent = createRouter();
150
+ parent.mount('/sub', child);
151
+ return parent;
152
+ }
153
+
154
+ function buildGroup(): Router {
155
+ const r = createRouter();
156
+ r.group('/g', (g) => {
157
+ g.get('/y', h('group.y'));
158
+ g.get('/:z', h('group.z'));
159
+ });
160
+ return r;
161
+ }
162
+
163
+ /**
164
+ * Backtracking-specific set. `/bt/:x/c` + `/bt/b/d` forces the walk to try the
165
+ * static `b` branch, fail at `c`, and fall back to the param branch. `/w/:x/deep`
166
+ * + `/w/*` is the stale-param guard: the param branch binds `:x`, fails deeper,
167
+ * backtracks, and the wildcard must match with NO leftover `x` binding.
168
+ */
169
+ function buildBacktrack(): Router {
170
+ const r = createRouter();
171
+ r.get('/bt/:x/c', h('bt.x.c'));
172
+ r.get('/bt/b/d', h('bt.b.d'));
173
+ r.get('/w/:x/deep', h('w.x.deep'));
174
+ r.get('/w/*', h('w.star'));
175
+ return r;
176
+ }
177
+
178
+ /** Every route set + its probe corpus. */
179
+ export const ROUTE_SETS: RouteSet[] = [
180
+ {
181
+ name: 'default',
182
+ build: buildDefault,
183
+ probes: [
184
+ { method: 'GET', path: '/' },
185
+ { method: 'GET', path: '/users' },
186
+ { method: 'GET', path: '/users/' }, // trailing slash (static, non-strict)
187
+ { method: 'GET', path: '/users/me' }, // static beats param
188
+ { method: 'GET', path: '/users/42' }, // param
189
+ { method: 'GET', path: '/users/42/' }, // trailing slash (param, non-strict)
190
+ { method: 'GET', path: '/Users/AbC' }, // non-ASCII-free cased; value keeps case
191
+ { method: 'GET', path: '/users/a%20b' }, // percent-decoded space
192
+ { method: 'GET', path: '/users/a%2Fb' }, // encoded slash stays in value
193
+ { method: 'GET', path: '/users/a%2Eb' }, // encoded dot stays in value
194
+ { method: 'GET', path: '/users/%zz' }, // malformed encoding → raw
195
+ { method: 'GET', path: '/users/%' }, // malformed → raw
196
+ { method: 'GET', path: '/users/%2' }, // truncated → raw
197
+ { method: 'GET', path: '/users/\u00dcrl' }, // non-ASCII uppercase param value
198
+ { method: 'GET', path: '/a/b/c' }, // nested static
199
+ { method: 'GET', path: '/a/b/c/' },
200
+ { method: 'GET', path: '/a/b/d' },
201
+ { method: 'GET', path: '/a/b/x' }, // backtrack: static b fails at x → param :x=b
202
+ { method: 'GET', path: '/a/1/b/2' }, // nested params
203
+ { method: 'GET', path: '/p/me' }, // static
204
+ { method: 'GET', path: '/p/other' }, // param
205
+ { method: 'GET', path: '/p/a/b/c' }, // wildcard
206
+ { method: 'GET', path: '/files/A/b/c' }, // wildcard original-case remainder
207
+ { method: 'GET', path: '/files' }, // wildcard empty capture
208
+ { method: 'GET', path: '/files/' }, // wildcard empty capture (trailing)
209
+ { method: 'GET', path: '/g/1/b/c' }, // param + trailing wildcard
210
+ { method: 'GET', path: '/users?q=1' }, // query stripped
211
+ { method: 'GET', path: '/users/42?x=y' }, // query stripped, param
212
+ { method: 'GET', path: '//a//b//c' }, // repeated-slash collapse
213
+ { method: 'GET', path: '///' }, // all-slash
214
+ { method: 'GET', path: '/nope' }, // miss
215
+ { method: 'POST', path: '/users' }, // method variety
216
+ { method: 'POST', path: '/users/me' }, // method-miss on known path
217
+ { method: 'DELETE', path: '/users/42' }, // method-miss on param path
218
+ { method: 'GET', path: `/deep/${Array.from({ length: 200 }, (_, i) => `s${i}`).join('/')}` }, // deep miss
219
+ ],
220
+ },
221
+ {
222
+ name: 'caseSensitive',
223
+ build: buildCaseSensitive,
224
+ probes: [
225
+ { method: 'GET', path: '/Users/AbC' },
226
+ { method: 'GET', path: '/users/abc' }, // lowercase miss (case-sensitive)
227
+ { method: 'GET', path: '/Static' },
228
+ { method: 'GET', path: '/static' }, // miss
229
+ ],
230
+ },
231
+ {
232
+ name: 'strict',
233
+ build: buildStrict,
234
+ probes: [
235
+ { method: 'GET', path: '/strict' },
236
+ { method: 'GET', path: '/strict/' }, // strict: trailing slash NOT stripped → behavior as today
237
+ { method: 'GET', path: '/strict/42' },
238
+ { method: 'GET', path: '/strict/42/' },
239
+ ],
240
+ },
241
+ {
242
+ name: 'decodeOff',
243
+ build: buildDecodeOff,
244
+ probes: [
245
+ { method: 'GET', path: '/raw/a%20b' }, // raw, not decoded
246
+ { method: 'GET', path: '/raw/a%2Fb' },
247
+ { method: 'GET', path: '/raw/x/y/z' }, // wildcard raw remainder
248
+ ],
249
+ },
250
+ {
251
+ name: 'all',
252
+ build: buildAll,
253
+ probes: [
254
+ { method: 'GET', path: '/any' },
255
+ { method: 'POST', path: '/any' },
256
+ { method: 'PUT', path: '/any' },
257
+ { method: 'DELETE', path: '/any' },
258
+ { method: 'PATCH', path: '/any' },
259
+ { method: 'HEAD', path: '/any' },
260
+ { method: 'OPTIONS', path: '/any' },
261
+ ],
262
+ },
263
+ {
264
+ name: 'prefix',
265
+ build: buildPrefix,
266
+ probes: [
267
+ { method: 'GET', path: '/api/health' },
268
+ { method: 'GET', path: '/health' }, // miss (no prefix)
269
+ { method: 'GET', path: '/api/items/7' },
270
+ ],
271
+ },
272
+ {
273
+ name: 'mount',
274
+ build: buildMount,
275
+ probes: [
276
+ { method: 'GET', path: '/sub/x' }, // copied static
277
+ { method: 'GET', path: '/sub/other' }, // copied param
278
+ ],
279
+ },
280
+ {
281
+ name: 'group',
282
+ build: buildGroup,
283
+ probes: [
284
+ { method: 'GET', path: '/g/y' }, // group static
285
+ { method: 'GET', path: '/g/other' }, // group param
286
+ ],
287
+ },
288
+ {
289
+ name: 'backtrack',
290
+ build: buildBacktrack,
291
+ probes: [
292
+ { method: 'GET', path: '/bt/b/c' }, // static b fails at c → param :x=b matches
293
+ { method: 'GET', path: '/bt/b/d' }, // static b/d
294
+ { method: 'GET', path: '/bt/z/c' }, // param directly (no static z)
295
+ { method: 'GET', path: '/w/foo/other' }, // param :x binds, fails, backtracks → wildcard, NO stale x
296
+ { method: 'GET', path: '/w/foo/deep' }, // param :x=foo matches deep
297
+ { method: 'GET', path: '/w/a/b/c' }, // wildcard multi-segment
298
+ ],
299
+ },
300
+ ];
301
+
302
+ /**
303
+ * Compute the full golden map: `"<set> <METHOD> <path>" -> SerializedMatch`.
304
+ * Deterministic and side-effect-free (each route set is freshly built).
305
+ */
306
+ export function computeGolden(): Record<string, SerializedMatch> {
307
+ const out: Record<string, SerializedMatch> = {};
308
+ for (const set of ROUTE_SETS) {
309
+ const router = set.build();
310
+ for (const probe of set.probes) {
311
+ const key = `${set.name} ${probe.method} ${probe.path}`;
312
+ out[key] = serializeMatch(router, probe.method, probe.path);
313
+ }
314
+ }
315
+ return out;
316
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @nextrush/router - Differential / characterization harness
3
+ *
4
+ * Golden-master regression contract for `router-match-path-allocation-trim`.
5
+ * `match-golden.json` was captured from the PRE-CHANGE matcher; this asserts
6
+ * the current matcher reproduces it byte-for-byte across the broad corpus in
7
+ * `helpers/differential-corpus.ts`. Any behavioral drift on a preserved case
8
+ * (route resolution, own-param entries/values, executor presence) fails here.
9
+ *
10
+ * Regenerate intentionally (only when a delta is understood and approved):
11
+ * GEN_GOLDEN=1 pnpm --filter @nextrush/router test match-differential
12
+ *
13
+ * Intentional deltas (null-prototype params; `__proto__`/`constructor`/
14
+ * `prototype` param names) are covered by dedicated forward scenarios, NOT by
15
+ * regenerating this golden — see `match-safety.test.ts`.
16
+ */
17
+
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, join } from 'node:path';
21
+ import { describe, expect, it } from 'vitest';
22
+ import { computeGolden, type SerializedMatch } from './helpers/differential-corpus';
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const GOLDEN_PATH = join(HERE, 'fixtures', 'match-golden.json');
26
+
27
+ describe('Router match — differential / characterization harness', () => {
28
+ const computed = computeGolden();
29
+
30
+ if (process.env.GEN_GOLDEN) {
31
+ writeFileSync(GOLDEN_PATH, `${JSON.stringify(computed, null, 2)}\n`, 'utf-8');
32
+ }
33
+
34
+ const golden = JSON.parse(readFileSync(GOLDEN_PATH, 'utf-8')) as Record<string, SerializedMatch>;
35
+
36
+ it('reproduces the pre-change matcher output for every corpus probe', () => {
37
+ expect(computed).toEqual(golden);
38
+ });
39
+
40
+ it('covers the full corpus (no probe silently dropped)', () => {
41
+ const computedKeys = Object.keys(computed).sort();
42
+ const goldenKeys = Object.keys(golden).sort();
43
+ expect(computedKeys).toEqual(goldenKeys);
44
+ expect(computedKeys.length).toBeGreaterThan(50);
45
+ });
46
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @nextrush/router - HP-18 deopt-pattern regression guard
3
+ *
4
+ * Static source guard for OpenSpec change `router-context-final-cleanup` (HP-18).
5
+ * The P2 rewrite (`router-match-path-allocation-trim`) removed the two V8
6
+ * deopt/allocation patterns from the router match path:
7
+ *
8
+ * - the eager-bind + backtrack `Reflect.deleteProperty(params, name)` used to
9
+ * undo a stale param binding, and
10
+ * - the `Object.keys(...)`-based post-match loop that materialized params.
11
+ *
12
+ * Both are gone today (params are bound via deferred parallel stacks and a
13
+ * bind-count loop). This guard fails if EITHER is reintroduced into the router
14
+ * match sources, so a future edit cannot silently regress the hot path.
15
+ *
16
+ * WHY a source scan rather than a behavioral spy: the invariant is "this
17
+ * mechanism never comes back", not "one probe doesn't trigger it". A spy
18
+ * (see `match-safety.test.ts`) proves a specific match doesn't call them; this
19
+ * proves the identifiers are absent from the source entirely — a stronger,
20
+ * edit-resistant contract. Comments are stripped first because the source prose
21
+ * deliberately NAMES these removed patterns to explain why they're gone.
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { describe, expect, it } from 'vitest';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ const SRC = join(HERE, '..');
31
+
32
+ /**
33
+ * The files that make up the router match path (design.md / proposal). `find-node.ts`
34
+ * holds the method-agnostic walk split out of `matching.ts` during the HP-17
35
+ * rewrite, so it is guarded alongside the two match sources.
36
+ */
37
+ const MATCH_SOURCES = ['matching.ts', 'match-route.ts', 'find-node.ts'] as const;
38
+
39
+ /**
40
+ * Remove block (`/* ... *\/`) and line (`// ...`) comments so the guard scans
41
+ * only executable code — the source comments intentionally reference the very
42
+ * patterns this guard forbids, to document that they were removed.
43
+ */
44
+ function stripComments(source: string): string {
45
+ return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
46
+ }
47
+
48
+ function readCode(file: string): string {
49
+ return stripComments(readFileSync(join(SRC, file), 'utf-8'));
50
+ }
51
+
52
+ describe('HP-18 — router match path stays free of the removed deopt patterns', () => {
53
+ it.each(MATCH_SOURCES)('%s contains no backtrack Reflect.deleteProperty', (file) => {
54
+ expect(readCode(file)).not.toContain('Reflect.deleteProperty');
55
+ });
56
+
57
+ it.each(MATCH_SOURCES)('%s contains no Object.keys post-match loop', (file) => {
58
+ expect(readCode(file)).not.toContain('Object.keys');
59
+ });
60
+
61
+ it('actually scanned real, non-empty match sources (guard is not vacuous)', () => {
62
+ for (const file of MATCH_SOURCES) {
63
+ const code = readCode(file);
64
+ // Sanity: the file exists, has real content, and the comment-stripper
65
+ // left the actual matcher code (so the negative assertions above are
66
+ // scanning something, not an empty string).
67
+ expect(code.length).toBeGreaterThan(200);
68
+ expect(code).toContain('export function');
69
+ }
70
+ });
71
+ });
@@ -0,0 +1,66 @@
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
+ /* no-op */
19
+ };
20
+
21
+ afterEach(() => {
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ describe('HP-12 — unicode-correct case-normalization fast-path', () => {
26
+ it('does not call toLowerCase for a provably-lowercase ASCII path (case-insensitive)', () => {
27
+ const spy = vi.spyOn(String.prototype, 'toLowerCase');
28
+ const out = normalizePathForMatch('/users/abc', false, false);
29
+ expect(out).toBe('/users/abc');
30
+ expect(spy).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it('still folds an ASCII-uppercase path byte-identically to toLowerCase()', () => {
34
+ expect(normalizePathForMatch('/Users/AbC', false, false)).toBe('/Users/AbC'.toLowerCase());
35
+ });
36
+
37
+ it('still folds a NON-ASCII uppercase path byte-identically (fast-path does not wrongly skip)', () => {
38
+ // '/ÜRL' contains non-ASCII uppercase; the fast-path must NOT skip folding.
39
+ expect(normalizePathForMatch('/\u00dcRL', false, false)).toBe('/\u00dcRL'.toLowerCase());
40
+ expect(normalizePathForMatch('/caf\u00c9', false, false)).toBe('/caf\u00c9'.toLowerCase());
41
+ });
42
+
43
+ it('collapses double slashes and strips a trailing slash identically after the fast-path', () => {
44
+ expect(normalizePathForMatch('//a//b/', false, false)).toBe('/a/b');
45
+ expect(normalizePathForMatch('//A//B/', false, false)).toBe('//A//B/'.toLowerCase().replace(/\/+/g, '/').replace(/\/$/, ''));
46
+ });
47
+
48
+ it('a case-stable param match resolves without ANY toLowerCase call (no second normalize pass)', () => {
49
+ const router = createRouter();
50
+ router.get('/users/:id', noop);
51
+ // Warm registration is done; now spy only the match path.
52
+ const spy = vi.spyOn(String.prototype, 'toLowerCase');
53
+ const match = router.match('GET', '/users/42');
54
+ expect(match).not.toBeNull();
55
+ expect(match?.params).toEqual({ id: '42' });
56
+ expect(spy).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('a case-insensitive param match preserves original-case param values', () => {
60
+ const router = createRouter();
61
+ router.get('/users/:id', noop);
62
+ const match = router.match('GET', '/Users/AbC');
63
+ expect(match).not.toBeNull();
64
+ expect(match?.params).toEqual({ id: 'AbC' });
65
+ });
66
+ });
@@ -0,0 +1,177 @@
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
+ /* no-op */
24
+ };
25
+
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ describe('HP-11 — null-prototype params (D8)', () => {
31
+ it('materializes a populated params object with a null prototype', () => {
32
+ const router = createRouter();
33
+ router.get('/users/:id', noop);
34
+ const match = router.match('GET', '/users/42');
35
+ expect(match?.params).toEqual({ id: '42' });
36
+ expect(Object.getPrototypeOf(match?.params)).toBeNull();
37
+ });
38
+
39
+ it('exposes no inherited Object.prototype members on params', () => {
40
+ const router = createRouter();
41
+ router.get('/users/:id', noop);
42
+ const params = router.match('GET', '/users/42')?.params as Record<string, unknown>;
43
+ expect(params['toString']).toBeUndefined();
44
+ expect(params['hasOwnProperty']).toBeUndefined();
45
+ });
46
+
47
+ it('binds a __proto__ param as an OWN key without polluting Object.prototype', () => {
48
+ const router = createRouter();
49
+ router.get('/:__proto__', noop);
50
+ const match = router.match('GET', '/danger');
51
+ expect(match).not.toBeNull();
52
+ expect(Object.prototype.hasOwnProperty.call(match?.params, '__proto__')).toBe(true);
53
+ expect((match?.params as Record<string, string>)['__proto__']).toBe('danger');
54
+ // No global prototype pollution.
55
+ expect(({} as Record<string, unknown>)['danger']).toBeUndefined();
56
+ expect((Object.prototype as Record<string, unknown>)['danger']).toBeUndefined();
57
+ });
58
+
59
+ it('binds a constructor param as an OWN string key', () => {
60
+ const router = createRouter();
61
+ router.get('/:constructor', noop);
62
+ const match = router.match('GET', '/boom');
63
+ expect(Object.prototype.hasOwnProperty.call(match?.params, 'constructor')).toBe(true);
64
+ expect((match?.params as Record<string, string>)['constructor']).toBe('boom');
65
+ });
66
+ });
67
+
68
+ describe('HP-11 — traversal-safe decode (D9): encoded slash/dot never re-segments', () => {
69
+ it('keeps an encoded slash inside a single param value and matches /files/:name (not a structural /files/a/b)', () => {
70
+ const router = createRouter();
71
+ router.get('/files/:name', noop);
72
+ router.get('/files/a/b', (async () => {}) as RouteHandler);
73
+ const match = router.match('GET', '/files/a%2Fb');
74
+ expect(match?.params).toEqual({ name: 'a/b' });
75
+ });
76
+
77
+ it('keeps encoded dots inside the value (no `..` traversal segments)', () => {
78
+ const router = createRouter();
79
+ router.get('/files/:name', noop);
80
+ const match = router.match('GET', '/files/%2E%2E');
81
+ expect(match?.params).toEqual({ name: '..' });
82
+ });
83
+ });
84
+
85
+ describe('HP-11 — concurrency isolation (D10)', () => {
86
+ it('gives each match its own params; no cross-contamination across many matches', () => {
87
+ const router = createRouter();
88
+ router.get('/users/:id', noop);
89
+ const results = Array.from({ length: 1000 }, (_, i) => router.match('GET', `/users/u${i}`));
90
+ results.forEach((m, i) => {
91
+ expect(m?.params).toEqual({ id: `u${i}` });
92
+ });
93
+ // Distinct param objects per match (no shared mutable scratch reused).
94
+ expect(results[0]?.params).not.toBe(results[1]?.params);
95
+ });
96
+
97
+ it('shares only the frozen EMPTY_PARAMS for param-less matches', () => {
98
+ const router = createRouter();
99
+ router.get('/a/*', noop); // wildcard keeps hasParamRoutes true; /health walks & binds nothing
100
+ router.get('/health', noop);
101
+ // A static-through-trie param-less match returns the shared sentinel.
102
+ const m1 = router.match('GET', '/health');
103
+ expect(m1?.params).toBe(EMPTY_PARAMS);
104
+ });
105
+ });
106
+
107
+ describe('HP-11 — deep-path DoS safety (iterative walk)', () => {
108
+ it('resolves a very deep matching path without a stack overflow', () => {
109
+ const depth = 60000;
110
+ const router = createRouter();
111
+ // A deep param chain forces the walk to descend `depth` levels.
112
+ router.get('/' + Array.from({ length: depth }, () => ':p').join('/'), noop);
113
+ const path = '/' + Array.from({ length: depth }, (_, i) => `x${i}`).join('/');
114
+ expect(() => router.match('GET', path)).not.toThrow();
115
+ expect(router.match('GET', path)).not.toBeNull();
116
+ });
117
+
118
+ it('misses a very deep non-matching path without a stack overflow', () => {
119
+ const depth = 60000;
120
+ const router = createRouter();
121
+ router.get('/short/:id', noop);
122
+ const path = '/' + Array.from({ length: depth }, (_, i) => `x${i}`).join('/');
123
+ expect(() => router.match('GET', path)).not.toThrow();
124
+ expect(router.match('GET', path)).toBeNull();
125
+ });
126
+ });
127
+
128
+ describe('HP-11 — miss / 405 / executor critical flow', () => {
129
+ it('returns null cleanly on a miss', () => {
130
+ const router = createRouter();
131
+ router.get('/x/:id', noop);
132
+ expect(router.match('GET', '/nope')).toBeNull();
133
+ });
134
+
135
+ it('returns null for a known path with an unregistered method (so allowedMethods can 405)', () => {
136
+ const router = createRouter();
137
+ router.get('/x/:id', noop);
138
+ expect(router.match('POST', '/x/42')).toBeNull();
139
+ });
140
+
141
+ it('returns null for a wildcard path requested with an unregistered method', () => {
142
+ const router = createRouter();
143
+ router.get('/w/*', noop);
144
+ // Wildcard child exists but has no POST handler → the walk pops the deferred
145
+ // '*' bind and backtracks to a clean null (not a spurious match).
146
+ expect(router.match('POST', '/w/a/b')).toBeNull();
147
+ });
148
+
149
+ it('returns the pre-compiled executor on a matched param route (not re-composed)', () => {
150
+ const router = createRouter();
151
+ router.get('/x/:id', noop);
152
+ const match = router.match('GET', '/x/42');
153
+ expect(typeof match?.executor).toBe('function');
154
+ });
155
+ });
156
+
157
+ describe('HP-11/HP-13 — allocation mechanism removed (deterministic spies)', () => {
158
+ it('does not call Object.keys on the param match path (HP-13 post-loop gone)', () => {
159
+ const router = createRouter();
160
+ router.get('/x/:id', noop);
161
+ const spy = vi.spyOn(Object, 'keys');
162
+ router.match('GET', '/x/42');
163
+ expect(spy).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it('does not call Reflect.deleteProperty during a backtracking match (eager-bind/backtrack gone)', () => {
167
+ const router = createRouter();
168
+ router.get('/a/:x/c', noop);
169
+ router.get('/a/b/d', noop);
170
+ const spy = vi.spyOn(Reflect, 'deleteProperty');
171
+ // /a/b/c: static `b` tried, fails at `c`, backtracks to param :x — the exact
172
+ // path that used to call Reflect.deleteProperty on backtrack.
173
+ const match = router.match('GET', '/a/b/c');
174
+ expect(match?.params).toEqual({ x: 'b' });
175
+ expect(spy).not.toHaveBeenCalled();
176
+ });
177
+ });