@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.
- 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,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
|
+
});
|