@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,292 @@
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
+ describe('NF-1 §2.1: dispatch forwards without an extra async frame', () => {
72
+ it('createRoutesMiddleware returns the executor promise directly (identity)', () => {
73
+ const sentinel = Promise.resolve();
74
+ const match = () => ({
75
+ params: {},
76
+ middleware: [],
77
+ executor: () => sentinel,
78
+ handler: (() => {}) as RouteHandler,
79
+ });
80
+ const mw = createRoutesMiddleware(match as never);
81
+
82
+ const returned = mw(createCtx());
83
+
84
+ // An `async` wrapper allocates a new promise; a direct forward returns the
85
+ // very promise the executor produced.
86
+ expect(returned).toBe(sentinel);
87
+ });
88
+
89
+ it('the len === 0 executor forwards a native handler promise unwrapped (identity)', () => {
90
+ const p = Promise.resolve();
91
+ const exec = compileExecutor((() => p) as RouteHandler, []);
92
+
93
+ // Promise.resolve(p) === p for a native promise; an `async` wrapper would
94
+ // return a distinct promise.
95
+ expect(exec(createCtx())).toBe(p);
96
+ });
97
+
98
+ it('a matched synchronous handler still produces a byte-identical response', async () => {
99
+ const router = createRouter();
100
+ router.get('/', ((ctx: Context) => {
101
+ ctx.json({ ok: true });
102
+ }) as RouteHandler);
103
+
104
+ const ctx = createCtx();
105
+ await run(router, ctx);
106
+
107
+ expect(ctx.json).toHaveBeenCalledWith({ ok: true });
108
+ expect(ctx.status).toBe(200);
109
+ });
110
+ });
111
+
112
+ describe('NF-1 §2.2: a synchronous throw becomes a rejected promise', () => {
113
+ it('the executor never throws synchronously out of dispatch', async () => {
114
+ const exec = compileExecutor((() => {
115
+ throw new Error('sync boom');
116
+ }) as RouteHandler, []);
117
+
118
+ let returned: Promise<void> | undefined;
119
+ expect(() => {
120
+ returned = exec(createCtx());
121
+ }).not.toThrow();
122
+ await expect(returned).rejects.toThrow('sync boom');
123
+ });
124
+
125
+ it('a synchronous throw reaching the composer still rejects (reaches error handler)', async () => {
126
+ const router = createRouter();
127
+ router.get('/error', (() => {
128
+ throw new Error('kaboom');
129
+ }) as RouteHandler);
130
+
131
+ // compose wraps the router like the app does; the rejection must surface.
132
+ const stack = compose([router.routes()]);
133
+ await expect(stack(createCtx({ path: '/error' }), async () => {})).rejects.toThrow('kaboom');
134
+ });
135
+ });
136
+
137
+ describe('NF-1 §2.3: async / returned-promise rejections propagate', () => {
138
+ it('an async handler rejection propagates', async () => {
139
+ const exec = compileExecutor((async () => {
140
+ throw new Error('async boom');
141
+ }) as RouteHandler, []);
142
+ await expect(exec(createCtx())).rejects.toThrow('async boom');
143
+ });
144
+
145
+ it('a handler returning a rejected promise propagates', async () => {
146
+ const exec = compileExecutor((() =>
147
+ Promise.reject(new Error('rejected promise'))) as RouteHandler, []);
148
+ await expect(exec(createCtx())).rejects.toThrow('rejected promise');
149
+ });
150
+ });
151
+
152
+ describe('NF-1 §2.4: a thenable handler return is adopted (not dropped)', () => {
153
+ it('awaits a non-Promise thenable so its async work completes', async () => {
154
+ let sideEffectRan = false;
155
+ const thenable = {
156
+ then(resolve: (v?: unknown) => void): void {
157
+ setTimeout(() => {
158
+ sideEffectRan = true;
159
+ resolve();
160
+ }, 5);
161
+ },
162
+ };
163
+ const exec = compileExecutor((() => thenable) as unknown as RouteHandler, []);
164
+
165
+ await exec(createCtx());
166
+
167
+ // A naive `x instanceof Promise ? x : RESOLVED` de-async would drop the
168
+ // thenable and resolve immediately, leaving this false.
169
+ expect(sideEffectRan).toBe(true);
170
+ });
171
+ });
172
+
173
+ describe('NF-1 §2.5: a non-Error throw is wrapped as Error(String(thrown))', () => {
174
+ it.each([
175
+ ['a string', 'string-error', 'string-error'],
176
+ ['a number', 42, '42'],
177
+ ['null', null, 'null'],
178
+ ['undefined', undefined, 'undefined'],
179
+ ])('wraps %s', async (_label, thrown, expectedMessage) => {
180
+ const exec = compileExecutor((() => {
181
+ throw thrown;
182
+ }) as RouteHandler, []);
183
+
184
+ const err = await settle(exec(createCtx()));
185
+
186
+ expect(err).toBeInstanceOf(Error);
187
+ expect((err as Error).message).toBe(expectedMessage);
188
+ });
189
+ });
190
+
191
+ describe('NF-1 §2.6: a miss sets 404 and forwards next()', () => {
192
+ it('a known-path/unregistered-method miss becomes 405 with Allow via allowedMethods', async () => {
193
+ const router = createRouter();
194
+ router.get('/users', ((ctx: Context) => ctx.json([])) as RouteHandler);
195
+
196
+ const ctx = createCtx({ method: 'POST', path: '/users' });
197
+ // routes() sets 404 + forwards next; allowedMethods() (as next) turns it into 405.
198
+ await router.routes()(ctx, () => router.allowedMethods()(ctx, async () => {}));
199
+
200
+ expect(ctx.status).toBe(405);
201
+ expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('GET'));
202
+ });
203
+
204
+ it('a total miss with no next resolves and leaves 404', async () => {
205
+ const router = createRouter();
206
+ router.get('/users', (() => {}) as RouteHandler);
207
+
208
+ const ctx = createCtx({ method: 'GET', path: '/nope' });
209
+ await expect(router.routes()(ctx)).resolves.toBeUndefined();
210
+ expect(ctx.status).toBe(404);
211
+ });
212
+ });
213
+
214
+ describe('NF-1 §2.7: setNext(NOOP_NEXT) terminates the chain at the handler', () => {
215
+ it('a route handler calling ctx.next() does NOT advance into app middleware after the router', async () => {
216
+ const router = createRouter();
217
+ let handlerNextResolved = false;
218
+ router.get('/', (async (ctx: Context) => {
219
+ await ctx.next(); // must be a safe no-op, NOT advance into appMw
220
+ handlerNextResolved = true;
221
+ }) as RouteHandler);
222
+
223
+ const appMw = vi.fn(async (_ctx: Context, next: () => Promise<void>) => {
224
+ await next();
225
+ });
226
+
227
+ // General compose dispatch: middleware mounted AFTER the router. compose
228
+ // wires ctx._next to advance into appMw before running the router; the
229
+ // executor's setNext(NOOP_NEXT) must overwrite that so the handler's
230
+ // ctx.next() cannot leak into appMw.
231
+ const stack = compose([router.routes(), appMw]);
232
+ await stack(createCtx(), async () => {});
233
+
234
+ expect(appMw).not.toHaveBeenCalled();
235
+ expect(handlerNextResolved).toBe(true);
236
+ });
237
+ });
238
+
239
+ describe('NF-1 §2.8: the len >= 1 executor path is unchanged', () => {
240
+ it('preserves onion ordering across a 5-layer ctx.next() chain', async () => {
241
+ const router = createRouter();
242
+ const order: string[] = [];
243
+ const layer = (n: number) =>
244
+ (async (ctx: Context) => {
245
+ order.push(`${n}-before`);
246
+ await ctx.next();
247
+ order.push(`${n}-after`);
248
+ }) as never;
249
+
250
+ router.get(
251
+ '/',
252
+ layer(1),
253
+ layer(2),
254
+ layer(3),
255
+ layer(4),
256
+ layer(5),
257
+ (async () => {
258
+ order.push('handler');
259
+ }) as RouteHandler
260
+ );
261
+
262
+ await run(router, createCtx());
263
+
264
+ expect(order).toEqual([
265
+ '1-before',
266
+ '2-before',
267
+ '3-before',
268
+ '4-before',
269
+ '5-before',
270
+ 'handler',
271
+ '5-after',
272
+ '4-after',
273
+ '3-after',
274
+ '2-after',
275
+ '1-after',
276
+ ]);
277
+ });
278
+
279
+ it('still rejects when a layer calls next() more than once', async () => {
280
+ const router = createRouter();
281
+ router.get(
282
+ '/',
283
+ (async (_ctx: Context, next: () => Promise<void>) => {
284
+ await next();
285
+ await next();
286
+ }) as never,
287
+ (async () => {}) as RouteHandler
288
+ );
289
+
290
+ await expect(run(router, createCtx())).rejects.toThrow('next() called multiple times');
291
+ });
292
+ });
@@ -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
+
32
+ /** Build a bare trie root from a route list, mirroring how `Router` inserts. */
33
+ function buildRoot(
34
+ routes: ReadonlyArray<readonly [HttpMethod, string]>,
35
+ caseSensitive = false
36
+ ): TrieNode {
37
+ const root = createNode('');
38
+ const state: RegistrationState = {
39
+ root,
40
+ caseSensitive,
41
+ staticRoutes: new Map(),
42
+ routeDefinitions: [],
43
+ maxDepth: 0,
44
+ };
45
+ for (const [method, path] of routes) {
46
+ addRoute(method, path, [noop], [], state);
47
+ }
48
+ return root;
49
+ }
50
+
51
+ /**
52
+ * FROZEN reference copy of the PRE-REWRITE recursive `findNode`. The production
53
+ * `findNode` must stay byte-identical to this for every non-pathological input;
54
+ * it diverges only by being iterative (stack-safe), which the deep-path test
55
+ * covers separately.
56
+ */
57
+ function findNodeRecursive(node: TrieNode, segments: string[], index: number): TrieNode | null {
58
+ if (index === segments.length) return node;
59
+ const segment = segments[index];
60
+ if (segment === undefined) return null;
61
+
62
+ const staticChild = node.children.get(segment);
63
+ if (staticChild) {
64
+ const result = findNodeRecursive(staticChild, segments, index + 1);
65
+ if (result) return result;
66
+ }
67
+ if (node.paramChild) {
68
+ const result = findNodeRecursive(node.paramChild, segments, index + 1);
69
+ if (result) return result;
70
+ }
71
+ if (node.wildcardChild) return node.wildcardChild;
72
+ return null;
73
+ }
74
+
75
+ /** Reference `findAllowedMethods` over the recursive walk (mirrors production). */
76
+ function refAllowedMethods(
77
+ path: string,
78
+ root: TrieNode,
79
+ caseSensitive: boolean,
80
+ strict: boolean
81
+ ): HttpMethod[] {
82
+ const normalized = normalizePathForMatch(path, caseSensitive, strict);
83
+ const segments = normalized.split('/').filter(Boolean);
84
+ const node = findNodeRecursive(root, segments, 0);
85
+ if (!node || node.handlers.size === 0) return [];
86
+ return Array.from(node.handlers.keys());
87
+ }
88
+
89
+ /** Split a request path exactly as the pre-rewrite `findAllowedMethods` did. */
90
+ function toSegments(path: string, caseSensitive: boolean, strict: boolean): string[] {
91
+ return normalizePathForMatch(path, caseSensitive, strict).split('/').filter(Boolean);
92
+ }
93
+
94
+ /** Production `findNode` over the normalized path (new path-based signature). */
95
+ function prodNode(
96
+ path: string,
97
+ root: TrieNode,
98
+ caseSensitive: boolean,
99
+ strict: boolean
100
+ ): TrieNode | null {
101
+ return findNode(root, normalizePathForMatch(path, caseSensitive, strict), 1);
102
+ }
103
+
104
+ /** Reference `findNode` over the recursive walk (pre-rewrite segments-based). */
105
+ function refNode(
106
+ path: string,
107
+ root: TrieNode,
108
+ caseSensitive: boolean,
109
+ strict: boolean
110
+ ): TrieNode | null {
111
+ return findNodeRecursive(root, toSegments(path, caseSensitive, strict), 0);
112
+ }
113
+
114
+ // A corpus exercising every findNode branch: static, nested static, param,
115
+ // wildcard, backtracking, trailing slash (non-strict), method variety, misses.
116
+ const ROUTES: ReadonlyArray<readonly [HttpMethod, string]> = [
117
+ ['GET', '/'],
118
+ ['GET', '/users'],
119
+ ['POST', '/users'],
120
+ ['GET', '/users/me'], // static beats param at this node
121
+ ['GET', '/users/:id'],
122
+ ['DELETE', '/users/:id'],
123
+ ['GET', '/a/b/c'], // nested static
124
+ ['GET', '/a/:x/c'], // backtracking target
125
+ ['GET', '/a/b/d'],
126
+ ['GET', '/files/*'], // wildcard (incl. empty capture)
127
+ ['GET', '/g/:x/*'], // param + trailing wildcard
128
+ ];
129
+
130
+ const PROBES: readonly string[] = [
131
+ '/',
132
+ '/users',
133
+ '/users/',
134
+ '/users/me',
135
+ '/users/42',
136
+ '/users/42/',
137
+ '/a/b/c',
138
+ '/a/b/d',
139
+ '/a/b/x', // backtrack: static b fails at x → param :x=b
140
+ '/files', // wildcard empty capture
141
+ '/files/a/b/c', // wildcard multi-segment
142
+ '/g/1/rest/of/path', // param + trailing wildcard
143
+ '/nope', // miss
144
+ '/users/1/2/3', // overshoot miss
145
+ '//a//b//c', // repeated-slash collapse
146
+ ];
147
+
148
+ describe('HP-17 — findNode iterative rewrite: differential parity with recursion', () => {
149
+ const root = buildRoot(ROUTES);
150
+
151
+ it.each(PROBES)('returns the identical node (by reference) for %s', (path) => {
152
+ expect(prodNode(path, root, false, false)).toBe(refNode(path, root, false, false));
153
+ });
154
+
155
+ it.each(PROBES)('findAllowedMethods returns the identical method set for %s', (path) => {
156
+ expect(findAllowedMethods(path, root, false, false)).toEqual(
157
+ refAllowedMethods(path, root, false, false)
158
+ );
159
+ });
160
+
161
+ it('preserves static > param > wildcard precedence at a shared node', () => {
162
+ // /p/me (static), /p/:id (param), /p/* (wildcard) all branch from /p.
163
+ const r = buildRoot([
164
+ ['GET', '/p/me'],
165
+ ['GET', '/p/:id'],
166
+ ['GET', '/p/*'],
167
+ ]);
168
+ // Static wins. HEAD is present because a GET registration derives it
169
+ // (RFC 9110 §9.3.2) — the assertion here is about precedence, not the method set.
170
+ expect(findAllowedMethods('/p/me', r, false, false)).toEqual(['GET', 'HEAD']);
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', 'HEAD']);
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
+ });