@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.
- package/README.md +250 -492
- package/dist/index.d.ts +142 -185
- package/dist/index.js +816 -665
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/allowed-methods.test.ts +144 -0
- package/src/__tests__/audit-fixes.test.ts +71 -0
- package/src/__tests__/dispatch-deasync.test.ts +312 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/helpers/differential-corpus.ts +316 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +66 -0
- package/src/__tests__/match-safety.test.ts +177 -0
- package/src/__tests__/match-single-alloc.test.ts +84 -0
- package/src/__tests__/middleware-pipeline.test.ts +306 -0
- package/src/__tests__/param-decoding.test.ts +78 -0
- package/src/__tests__/public-surface.test.ts +59 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +326 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -2
- package/src/__tests__/router.test.ts +148 -2
- package/src/__tests__/static-map-reset.test.ts +50 -0
- package/src/composition.ts +75 -0
- package/src/constants.ts +25 -0
- package/src/dispatch.ts +117 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +8 -5
- package/src/match-route.ts +178 -0
- package/src/matching.ts +240 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +291 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +150 -881
- package/src/segment-trie.ts +219 -0
- package/src/state.ts +52 -0
- 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
|
+
});
|