@nextrush/router 3.0.7 → 4.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,71 @@
1
+ /**
2
+ * @nextrush/router - Audit Remediation Tests
3
+ *
4
+ * RT-1 (reset clears the introspection registry), RT-5 (param-name conflict
5
+ * throws at registration), RT-7 (routes() is idempotent — no double-seal).
6
+ */
7
+
8
+ import type { Context } from '@nextrush/types';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { createRouter } from '../router';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // RT-1 — reset() must also clear routeDefinitions (getRoutes introspection)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe('RT-1: reset() clears the introspection registry', () => {
17
+ it('getRoutes() is empty after reset()', () => {
18
+ const router = createRouter();
19
+ router.get('/users', vi.fn());
20
+ router.get('/users/:id', vi.fn());
21
+ expect(router.getRoutes().length).toBeGreaterThan(0);
22
+
23
+ router.reset();
24
+ expect(router.getRoutes()).toHaveLength(0);
25
+ });
26
+ });
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // RT-5 — conflicting param names at the same position must throw, not warn
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('RT-5: param-name conflict throws at registration', () => {
33
+ it('throws when the same position uses two different param names', () => {
34
+ const router = createRouter();
35
+ router.get('/users/:id', vi.fn());
36
+ expect(() => router.get('/users/:userId/posts', vi.fn())).toThrow(/param/i);
37
+ });
38
+
39
+ it('does not throw when the param name is consistent', () => {
40
+ const router = createRouter();
41
+ router.get('/users/:id', vi.fn());
42
+ expect(() => router.get('/users/:id/posts', vi.fn())).not.toThrow();
43
+ });
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // RT-7 — routes() called twice must not seal router middleware twice
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe('RT-7: routes() is idempotent w.r.t. router middleware sealing', () => {
51
+ it('runs router-level middleware exactly once even if routes() is called twice', async () => {
52
+ const router = createRouter();
53
+ const calls = { n: 0 };
54
+ router.use(async (_ctx: Context, next) => {
55
+ calls.n++;
56
+ if (next) await next();
57
+ });
58
+ router.get('/x', (ctx: Context) => {
59
+ ctx.body = 'ok';
60
+ });
61
+
62
+ router.routes();
63
+ router.routes(); // second call must not re-seal
64
+
65
+ const match = router.match('GET', '/x');
66
+ expect(match).not.toBeNull();
67
+ const ctx = { method: 'GET', path: '/x', params: {}, body: undefined } as unknown as Context;
68
+ await match!.executor!(ctx);
69
+ expect(calls.n).toBe(1);
70
+ });
71
+ });
@@ -0,0 +1,312 @@
1
+ /**
2
+ * @nextrush/router — Dispatch de-async regression contract (NF-1)
3
+ *
4
+ * Executable contract for OpenSpec change
5
+ * `hot-path-dispatch-deasync-and-lazy-state`. The router's primary dispatch
6
+ * middleware (`createRoutesMiddleware`, dispatch.ts) and the no-middleware
7
+ * (`len === 0`) compiled executor (`compileExecutor`, segment-trie.ts) forward
8
+ * the route's promise directly instead of crossing an extra `async` frame,
9
+ * while preserving every observable dispatch semantic.
10
+ *
11
+ * Two kinds of tests:
12
+ * - STRUCTURAL PROBES (RED before the change): the de-async is proven by
13
+ * PROMISE IDENTITY — a non-`async` `createRoutesMiddleware` returns the exact
14
+ * promise its executor returned, and a non-`async` `len === 0` executor
15
+ * returns a native handler promise unwrapped (`Promise.resolve(p) === p`).
16
+ * An `async` wrapper necessarily allocates a NEW promise, so these fail
17
+ * against the current code and pass only once the frames are removed.
18
+ * - BEHAVIOR CONTRACTS (guarding a *correct* de-async): sync-throw → reject,
19
+ * async/returned-promise rejection propagation, thenable adoption (RED
20
+ * against a naive `instanceof Promise` de-async that would drop it),
21
+ * non-`Error` wrapping, the 404 → `next()` fall-through, the load-bearing
22
+ * `setNext(NOOP_NEXT)` chain termination (RED against a de-async that drops
23
+ * the guard), and the untouched `len >= 1` chain.
24
+ */
25
+
26
+ import type { Context, RouteHandler } from '@nextrush/types';
27
+ import { compose } from '@nextrush/core';
28
+ import { describe, expect, it, vi } from 'vitest';
29
+ import { createRoutesMiddleware } from '../dispatch';
30
+ import { compileExecutor } from '../segment-trie';
31
+ import { createRouter, type Router } from '../router';
32
+
33
+ /**
34
+ * Context mock whose setNext/next behave like a real adapter context:
35
+ * setNext stores the wired next; ctx.next() invokes it. Mirrors the helper in
36
+ * middleware-pipeline.test.ts so dispatch behavior is exercised realistically.
37
+ */
38
+ function createCtx(overrides: Partial<Context> = {}): Context {
39
+ let stored: () => Promise<void> = () => Promise.resolve();
40
+ return {
41
+ method: 'GET',
42
+ path: '/',
43
+ params: {},
44
+ query: {},
45
+ body: undefined,
46
+ headers: {},
47
+ status: 200,
48
+ state: {},
49
+ responded: false,
50
+ json: vi.fn(),
51
+ send: vi.fn(),
52
+ html: vi.fn(),
53
+ redirect: vi.fn(),
54
+ set: vi.fn(),
55
+ get: vi.fn(),
56
+ setNext: (fn: () => Promise<void>) => {
57
+ stored = fn;
58
+ },
59
+ next: () => stored(),
60
+ raw: { req: {}, res: {} },
61
+ ...overrides,
62
+ } as unknown as Context;
63
+ }
64
+
65
+ const run = (router: Router, ctx: Context): Promise<void> =>
66
+ router.routes()(ctx, async () => {});
67
+
68
+ /** Settle a promise into its resolution or rejection reason without throwing. */
69
+ const settle = (p: Promise<unknown>): Promise<unknown> => p.then((v) => v, (e) => e);
70
+
71
+ // ===========================================================================
72
+ // §2.1 — no extra async frame (structural identity probes, RED before change)
73
+ // ===========================================================================
74
+
75
+ describe('NF-1 §2.1: dispatch forwards without an extra async frame', () => {
76
+ it('createRoutesMiddleware returns the executor promise directly (identity)', () => {
77
+ const sentinel = Promise.resolve();
78
+ const match = () => ({
79
+ params: {},
80
+ middleware: [],
81
+ executor: () => sentinel,
82
+ handler: (() => {}) as RouteHandler,
83
+ });
84
+ const mw = createRoutesMiddleware(match as never);
85
+
86
+ const returned = mw(createCtx());
87
+
88
+ // An `async` wrapper allocates a new promise; a direct forward returns the
89
+ // very promise the executor produced.
90
+ expect(returned).toBe(sentinel);
91
+ });
92
+
93
+ it('the len === 0 executor forwards a native handler promise unwrapped (identity)', () => {
94
+ const p = Promise.resolve();
95
+ const exec = compileExecutor((() => p) as RouteHandler, []);
96
+
97
+ // Promise.resolve(p) === p for a native promise; an `async` wrapper would
98
+ // return a distinct promise.
99
+ expect(exec(createCtx())).toBe(p);
100
+ });
101
+
102
+ it('a matched synchronous handler still produces a byte-identical response', async () => {
103
+ const router = createRouter();
104
+ router.get('/', ((ctx: Context) => {
105
+ ctx.json({ ok: true });
106
+ }) as RouteHandler);
107
+
108
+ const ctx = createCtx();
109
+ await run(router, ctx);
110
+
111
+ expect(ctx.json).toHaveBeenCalledWith({ ok: true });
112
+ expect(ctx.status).toBe(200);
113
+ });
114
+ });
115
+
116
+ // ===========================================================================
117
+ // §2.2–§2.5 — error / return-shape propagation (behavior contracts)
118
+ // ===========================================================================
119
+
120
+ describe('NF-1 §2.2: a synchronous throw becomes a rejected promise', () => {
121
+ it('the executor never throws synchronously out of dispatch', async () => {
122
+ const exec = compileExecutor((() => {
123
+ throw new Error('sync boom');
124
+ }) as RouteHandler, []);
125
+
126
+ let returned: Promise<void> | undefined;
127
+ expect(() => {
128
+ returned = exec(createCtx());
129
+ }).not.toThrow();
130
+ await expect(returned).rejects.toThrow('sync boom');
131
+ });
132
+
133
+ it('a synchronous throw reaching the composer still rejects (reaches error handler)', async () => {
134
+ const router = createRouter();
135
+ router.get('/error', (() => {
136
+ throw new Error('kaboom');
137
+ }) as RouteHandler);
138
+
139
+ // compose wraps the router like the app does; the rejection must surface.
140
+ const stack = compose([router.routes()]);
141
+ await expect(stack(createCtx({ path: '/error' }), async () => {})).rejects.toThrow('kaboom');
142
+ });
143
+ });
144
+
145
+ describe('NF-1 §2.3: async / returned-promise rejections propagate', () => {
146
+ it('an async handler rejection propagates', async () => {
147
+ const exec = compileExecutor((async () => {
148
+ throw new Error('async boom');
149
+ }) as RouteHandler, []);
150
+ await expect(exec(createCtx())).rejects.toThrow('async boom');
151
+ });
152
+
153
+ it('a handler returning a rejected promise propagates', async () => {
154
+ const exec = compileExecutor((() =>
155
+ Promise.reject(new Error('rejected promise'))) as RouteHandler, []);
156
+ await expect(exec(createCtx())).rejects.toThrow('rejected promise');
157
+ });
158
+ });
159
+
160
+ describe('NF-1 §2.4: a thenable handler return is adopted (not dropped)', () => {
161
+ it('awaits a non-Promise thenable so its async work completes', async () => {
162
+ let sideEffectRan = false;
163
+ const thenable = {
164
+ then(resolve: (v?: unknown) => void): void {
165
+ setTimeout(() => {
166
+ sideEffectRan = true;
167
+ resolve();
168
+ }, 5);
169
+ },
170
+ };
171
+ const exec = compileExecutor((() => thenable) as unknown as RouteHandler, []);
172
+
173
+ await exec(createCtx());
174
+
175
+ // A naive `x instanceof Promise ? x : RESOLVED` de-async would drop the
176
+ // thenable and resolve immediately, leaving this false.
177
+ expect(sideEffectRan).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe('NF-1 §2.5: a non-Error throw is wrapped as Error(String(thrown))', () => {
182
+ it.each([
183
+ ['a string', 'string-error', 'string-error'],
184
+ ['a number', 42, '42'],
185
+ ['null', null, 'null'],
186
+ ['undefined', undefined, 'undefined'],
187
+ ])('wraps %s', async (_label, thrown, expectedMessage) => {
188
+ const exec = compileExecutor((() => {
189
+ throw thrown;
190
+ }) as RouteHandler, []);
191
+
192
+ const err = await settle(exec(createCtx()));
193
+
194
+ expect(err).toBeInstanceOf(Error);
195
+ expect((err as Error).message).toBe(expectedMessage);
196
+ });
197
+ });
198
+
199
+ // ===========================================================================
200
+ // §2.6 — miss → 404 → next() fall-through (allowedMethods 405)
201
+ // ===========================================================================
202
+
203
+ describe('NF-1 §2.6: a miss sets 404 and forwards next()', () => {
204
+ it('a known-path/unregistered-method miss becomes 405 with Allow via allowedMethods', async () => {
205
+ const router = createRouter();
206
+ router.get('/users', ((ctx: Context) => ctx.json([])) as RouteHandler);
207
+
208
+ const ctx = createCtx({ method: 'POST', path: '/users' });
209
+ // routes() sets 404 + forwards next; allowedMethods() (as next) turns it into 405.
210
+ await router.routes()(ctx, () => router.allowedMethods()(ctx, async () => {}));
211
+
212
+ expect(ctx.status).toBe(405);
213
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('GET'));
214
+ });
215
+
216
+ it('a total miss with no next resolves and leaves 404', async () => {
217
+ const router = createRouter();
218
+ router.get('/users', (() => {}) as RouteHandler);
219
+
220
+ const ctx = createCtx({ method: 'GET', path: '/nope' });
221
+ await expect(router.routes()(ctx)).resolves.toBeUndefined();
222
+ expect(ctx.status).toBe(404);
223
+ });
224
+ });
225
+
226
+ // ===========================================================================
227
+ // §2.7 — load-bearing setNext(NOOP_NEXT) chain termination (NF-4a KEPT)
228
+ // ===========================================================================
229
+
230
+ describe('NF-1 §2.7: setNext(NOOP_NEXT) terminates the chain at the handler', () => {
231
+ it('a route handler calling ctx.next() does NOT advance into app middleware after the router', async () => {
232
+ const router = createRouter();
233
+ let handlerNextResolved = false;
234
+ router.get('/', (async (ctx: Context) => {
235
+ await ctx.next(); // must be a safe no-op, NOT advance into appMw
236
+ handlerNextResolved = true;
237
+ }) as RouteHandler);
238
+
239
+ const appMw = vi.fn(async (_ctx: Context, next: () => Promise<void>) => {
240
+ await next();
241
+ });
242
+
243
+ // General compose dispatch: middleware mounted AFTER the router. compose
244
+ // wires ctx._next to advance into appMw before running the router; the
245
+ // executor's setNext(NOOP_NEXT) must overwrite that so the handler's
246
+ // ctx.next() cannot leak into appMw.
247
+ const stack = compose([router.routes(), appMw]);
248
+ await stack(createCtx(), async () => {});
249
+
250
+ expect(appMw).not.toHaveBeenCalled();
251
+ expect(handlerNextResolved).toBe(true);
252
+ });
253
+ });
254
+
255
+ // ===========================================================================
256
+ // §2.8 — the len >= 1 per-route middleware chain is unchanged
257
+ // ===========================================================================
258
+
259
+ describe('NF-1 §2.8: the len >= 1 executor path is unchanged', () => {
260
+ it('preserves onion ordering across a 5-layer ctx.next() chain', async () => {
261
+ const router = createRouter();
262
+ const order: string[] = [];
263
+ const layer = (n: number) =>
264
+ (async (ctx: Context) => {
265
+ order.push(`${n}-before`);
266
+ await ctx.next();
267
+ order.push(`${n}-after`);
268
+ }) as never;
269
+
270
+ router.get(
271
+ '/',
272
+ layer(1),
273
+ layer(2),
274
+ layer(3),
275
+ layer(4),
276
+ layer(5),
277
+ (async () => {
278
+ order.push('handler');
279
+ }) as RouteHandler
280
+ );
281
+
282
+ await run(router, createCtx());
283
+
284
+ expect(order).toEqual([
285
+ '1-before',
286
+ '2-before',
287
+ '3-before',
288
+ '4-before',
289
+ '5-before',
290
+ 'handler',
291
+ '5-after',
292
+ '4-after',
293
+ '3-after',
294
+ '2-after',
295
+ '1-after',
296
+ ]);
297
+ });
298
+
299
+ it('still rejects when a layer calls next() more than once', async () => {
300
+ const router = createRouter();
301
+ router.get(
302
+ '/',
303
+ (async (_ctx: Context, next: () => Promise<void>) => {
304
+ await next();
305
+ await next();
306
+ }) as never,
307
+ (async () => {}) as RouteHandler
308
+ );
309
+
310
+ await expect(run(router, createCtx())).rejects.toThrow('next() called multiple times');
311
+ });
312
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @nextrush/router - HP-17 findNode iterative-rewrite differential contract
3
+ *
4
+ * Regression + safety contract for OpenSpec change `router-context-final-cleanup`
5
+ * (HP-17). `findNode` (used by `findAllowedMethods`, the 405/OPTIONS path) is
6
+ * being rewritten from recursion to an explicit-stack walk so a pathological
7
+ * segment count can't overflow the call stack — the same DoS class HP-11 closed
8
+ * for the match path.
9
+ *
10
+ * This file pins two things:
11
+ * 1. DIFFERENTIAL PARITY — the production `findNode` returns the byte-identical
12
+ * node (by reference) and `findAllowedMethods` the byte-identical method set
13
+ * as a FROZEN reference copy of the pre-rewrite recursive walk, across a
14
+ * corpus (static, nested static, param, wildcard, backtrack, trailing-slash,
15
+ * method-miss, miss, precedence). The reference recursion is embedded here
16
+ * because the old and new impls cannot coexist in one process (same
17
+ * characterization technique as `match-differential.test.ts`'s golden).
18
+ * 2. DEEP-PATH SAFETY (RED before the rewrite) — a many-segment path resolves
19
+ * without a stack overflow. This FAILS against the recursive form and PASSES
20
+ * once `findNode` is iterative.
21
+ */
22
+
23
+ import type { HttpMethod, RouteHandler } from '@nextrush/types';
24
+ import { describe, expect, it } from 'vitest';
25
+ import { findAllowedMethods, findNode } from '../find-node';
26
+ import { normalizePathForMatch } from '../matching';
27
+ import { addRoute, type RegistrationState } from '../registration';
28
+ import { createNode, type TrieNode } from '../segment-trie';
29
+
30
+ const noop: RouteHandler = async () => {
31
+ /* no-op */
32
+ };
33
+
34
+ /** Build a bare trie root from a route list, mirroring how `Router` inserts. */
35
+ function buildRoot(
36
+ routes: ReadonlyArray<readonly [HttpMethod, string]>,
37
+ caseSensitive = false
38
+ ): TrieNode {
39
+ const root = createNode('');
40
+ const state: RegistrationState = {
41
+ root,
42
+ caseSensitive,
43
+ staticRoutes: new Map(),
44
+ routeDefinitions: [],
45
+ };
46
+ for (const [method, path] of routes) {
47
+ addRoute(method, path, [noop], [], state);
48
+ }
49
+ return root;
50
+ }
51
+
52
+ /**
53
+ * FROZEN reference copy of the PRE-REWRITE recursive `findNode`. The production
54
+ * `findNode` must stay byte-identical to this for every non-pathological input;
55
+ * it diverges only by being iterative (stack-safe), which the deep-path test
56
+ * covers separately.
57
+ */
58
+ function findNodeRecursive(node: TrieNode, segments: string[], index: number): TrieNode | null {
59
+ if (index === segments.length) return node;
60
+ const segment = segments[index];
61
+ if (segment === undefined) return null;
62
+
63
+ const staticChild = node.children.get(segment);
64
+ if (staticChild) {
65
+ const result = findNodeRecursive(staticChild, segments, index + 1);
66
+ if (result) return result;
67
+ }
68
+ if (node.paramChild) {
69
+ const result = findNodeRecursive(node.paramChild, segments, index + 1);
70
+ if (result) return result;
71
+ }
72
+ if (node.wildcardChild) return node.wildcardChild;
73
+ return null;
74
+ }
75
+
76
+ /** Reference `findAllowedMethods` over the recursive walk (mirrors production). */
77
+ function refAllowedMethods(
78
+ path: string,
79
+ root: TrieNode,
80
+ caseSensitive: boolean,
81
+ strict: boolean
82
+ ): HttpMethod[] {
83
+ const normalized = normalizePathForMatch(path, caseSensitive, strict);
84
+ const segments = normalized.split('/').filter(Boolean);
85
+ const node = findNodeRecursive(root, segments, 0);
86
+ if (!node || node.handlers.size === 0) return [];
87
+ return Array.from(node.handlers.keys());
88
+ }
89
+
90
+ /** Split a request path exactly as the pre-rewrite `findAllowedMethods` did. */
91
+ function toSegments(path: string, caseSensitive: boolean, strict: boolean): string[] {
92
+ return normalizePathForMatch(path, caseSensitive, strict).split('/').filter(Boolean);
93
+ }
94
+
95
+ /** Production `findNode` over the normalized path (new path-based signature). */
96
+ function prodNode(
97
+ path: string,
98
+ root: TrieNode,
99
+ caseSensitive: boolean,
100
+ strict: boolean
101
+ ): TrieNode | null {
102
+ return findNode(root, normalizePathForMatch(path, caseSensitive, strict), 1);
103
+ }
104
+
105
+ /** Reference `findNode` over the recursive walk (pre-rewrite segments-based). */
106
+ function refNode(
107
+ path: string,
108
+ root: TrieNode,
109
+ caseSensitive: boolean,
110
+ strict: boolean
111
+ ): TrieNode | null {
112
+ return findNodeRecursive(root, toSegments(path, caseSensitive, strict), 0);
113
+ }
114
+
115
+ // A corpus exercising every findNode branch: static, nested static, param,
116
+ // wildcard, backtracking, trailing slash (non-strict), method variety, misses.
117
+ const ROUTES: ReadonlyArray<readonly [HttpMethod, string]> = [
118
+ ['GET', '/'],
119
+ ['GET', '/users'],
120
+ ['POST', '/users'],
121
+ ['GET', '/users/me'], // static beats param at this node
122
+ ['GET', '/users/:id'],
123
+ ['DELETE', '/users/:id'],
124
+ ['GET', '/a/b/c'], // nested static
125
+ ['GET', '/a/:x/c'], // backtracking target
126
+ ['GET', '/a/b/d'],
127
+ ['GET', '/files/*'], // wildcard (incl. empty capture)
128
+ ['GET', '/g/:x/*'], // param + trailing wildcard
129
+ ];
130
+
131
+ const PROBES: readonly string[] = [
132
+ '/',
133
+ '/users',
134
+ '/users/',
135
+ '/users/me',
136
+ '/users/42',
137
+ '/users/42/',
138
+ '/a/b/c',
139
+ '/a/b/d',
140
+ '/a/b/x', // backtrack: static b fails at x → param :x=b
141
+ '/files', // wildcard empty capture
142
+ '/files/a/b/c', // wildcard multi-segment
143
+ '/g/1/rest/of/path', // param + trailing wildcard
144
+ '/nope', // miss
145
+ '/users/1/2/3', // overshoot miss
146
+ '//a//b//c', // repeated-slash collapse
147
+ ];
148
+
149
+ describe('HP-17 — findNode iterative rewrite: differential parity with recursion', () => {
150
+ const root = buildRoot(ROUTES);
151
+
152
+ it.each(PROBES)('returns the identical node (by reference) for %s', (path) => {
153
+ expect(prodNode(path, root, false, false)).toBe(refNode(path, root, false, false));
154
+ });
155
+
156
+ it.each(PROBES)('findAllowedMethods returns the identical method set for %s', (path) => {
157
+ expect(findAllowedMethods(path, root, false, false)).toEqual(
158
+ refAllowedMethods(path, root, false, false)
159
+ );
160
+ });
161
+
162
+ it('preserves static > param > wildcard precedence at a shared node', () => {
163
+ // /p/me (static), /p/:id (param), /p/* (wildcard) all branch from /p.
164
+ const r = buildRoot([
165
+ ['GET', '/p/me'],
166
+ ['GET', '/p/:id'],
167
+ ['GET', '/p/*'],
168
+ ]);
169
+ // Static wins.
170
+ expect(findAllowedMethods('/p/me', r, false, false)).toEqual(['GET']);
171
+ // Param wins over wildcard for a single leftover segment.
172
+ const paramNode = prodNode('/p/other', r, false, false);
173
+ expect(paramNode).toBe(refNode('/p/other', r, false, false));
174
+ expect(paramNode?.paramName).toBe('id');
175
+ // Wildcard only when neither static nor param can complete.
176
+ const wildNode = prodNode('/p/a/b', r, false, false);
177
+ expect(wildNode).toBe(refNode('/p/a/b', r, false, false));
178
+ expect(wildNode?.type).toBe(2 /* NodeType.WILDCARD */);
179
+ });
180
+
181
+ it('returns null identically on a miss', () => {
182
+ expect(prodNode('/definitely/not/here', root, false, false)).toBeNull();
183
+ expect(refNode('/definitely/not/here', root, false, false)).toBeNull();
184
+ });
185
+
186
+ it('treats an empty segment as a terminal (defensive: startPos landing on a slash)', () => {
187
+ // Normalization collapses `//`, so findAllowedMethods never feeds an empty
188
+ // mid-segment; this exercises findNode's empty-segment guard directly. A
189
+ // walk position sitting on a `/` yields an empty segment, which the walker
190
+ // treats as "path exhausted here" and returns the current node.
191
+ const r = buildRoot([['GET', '/a']]);
192
+ // '/a/' walked from the leading slash: after 'a' the trailing slash makes
193
+ // the next position land at the end; feed a raw double-slash to hit the
194
+ // seg === '' branch at a non-terminal position.
195
+ const node = findNode(r, '/a//', 3);
196
+ expect(node).not.toBeNull();
197
+ });
198
+ });
199
+
200
+ describe('HP-17 — deep-path 405/OPTIONS safety (RED before the iterative rewrite)', () => {
201
+ it('resolves a very deep matching path without a stack overflow', () => {
202
+ const depth = 60_000;
203
+ const root = buildRoot([['GET', '/' + Array.from({ length: depth }, () => ':p').join('/')]]);
204
+ const deepPath = '/' + Array.from({ length: depth }, (_, i) => `x${i}`).join('/');
205
+ expect(() => findAllowedMethods(deepPath, root, false, false)).not.toThrow();
206
+ expect(findAllowedMethods(deepPath, root, false, false)).toEqual(['GET']);
207
+ });
208
+
209
+ it('misses a deep-but-overshooting path without a stack overflow', () => {
210
+ const depth = 60_000;
211
+ // A deep param chain that DOES descend `depth` levels, then the request
212
+ // overshoots by one segment — so the walk recurses the full depth before
213
+ // failing (unlike a shallow miss that bails at level 1). This is the real
214
+ // deep-recursion miss case, matching the DoS surface HP-11 closed.
215
+ const root = buildRoot([['GET', '/' + Array.from({ length: depth }, () => ':p').join('/')]]);
216
+ const overshoot = '/' + Array.from({ length: depth + 1 }, (_, i) => `x${i}`).join('/');
217
+ expect(() => findAllowedMethods(overshoot, root, false, false)).not.toThrow();
218
+ expect(findAllowedMethods(overshoot, root, false, false)).toEqual([]);
219
+ });
220
+ });