@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.
- package/README.md +250 -492
- package/dist/index.d.ts +227 -178
- package/dist/index.js +1075 -657
- 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 +59 -0
- package/src/__tests__/canonical-path-security.test.ts +198 -0
- package/src/__tests__/canonicalize-memo.test.ts +108 -0
- package/src/__tests__/dispatch-deasync.test.ts +292 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/head-auto-registration.test.ts +197 -0
- package/src/__tests__/helpers/differential-corpus.ts +314 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
- package/src/__tests__/match-prenormalized.test.ts +95 -0
- package/src/__tests__/match-safety.test.ts +223 -0
- package/src/__tests__/match-single-alloc.test.ts +82 -0
- package/src/__tests__/match-walk-pool-safety.test.ts +103 -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 +72 -0
- package/src/__tests__/registration-max-depth.test.ts +56 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +315 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -7
- package/src/__tests__/router.test.ts +148 -7
- package/src/__tests__/static-map-reset.test.ts +48 -0
- package/src/__tests__/walk-pool-sizing.test.ts +72 -0
- package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
- package/src/canonicalize.ts +137 -0
- package/src/composition.ts +79 -0
- package/src/constants.ts +43 -0
- package/src/dispatch.ts +145 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +14 -5
- package/src/match-route.ts +241 -0
- package/src/matching.ts +246 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +343 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +219 -872
- package/src/segment-trie.ts +227 -0
- package/src/state.ts +53 -0
- package/src/walk-pool.ts +208 -0
- package/src/radix-tree.ts +0 -184
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - HEAD-on-GET auto-registration (RFC 9110 §9.3.2)
|
|
3
|
+
*
|
|
4
|
+
* A `GET` route must also answer `HEAD`. Fastify, Express, Koa and Hono all do
|
|
5
|
+
* this; before this suite NextRush returned 404 for `HEAD` on every `GET` route,
|
|
6
|
+
* making conditional-request revalidation, CDN HEAD probes and HEAD-configured
|
|
7
|
+
* health checks fail.
|
|
8
|
+
*
|
|
9
|
+
* @see reports/investigations/2026-07-31-measured-floor-params-compliance/04-http-compliance-head.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { HttpMethod } from '@nextrush/types';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { createRouter, Router } from '../router';
|
|
15
|
+
|
|
16
|
+
const noop = (): void => {};
|
|
17
|
+
|
|
18
|
+
describe('HEAD auto-registration for GET routes', () => {
|
|
19
|
+
it('matches HEAD on a static GET route', () => {
|
|
20
|
+
const router = createRouter();
|
|
21
|
+
router.get('/health', noop);
|
|
22
|
+
|
|
23
|
+
expect(router.match('GET', '/health')).not.toBeNull();
|
|
24
|
+
expect(router.match('HEAD', '/health')).not.toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('matches HEAD on a param GET route and extracts the same params', () => {
|
|
28
|
+
const router = createRouter();
|
|
29
|
+
router.get('/users/:id', noop);
|
|
30
|
+
|
|
31
|
+
const get = router.match('GET', '/users/42');
|
|
32
|
+
const head = router.match('HEAD', '/users/42');
|
|
33
|
+
|
|
34
|
+
expect(head).not.toBeNull();
|
|
35
|
+
expect(head?.params).toEqual({ id: '42' });
|
|
36
|
+
expect(head?.params).toEqual(get?.params);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('matches HEAD on a wildcard GET route', () => {
|
|
40
|
+
const router = createRouter();
|
|
41
|
+
router.get('/static/*', noop);
|
|
42
|
+
|
|
43
|
+
const head = router.match('HEAD', '/static/a/b.txt');
|
|
44
|
+
expect(head).not.toBeNull();
|
|
45
|
+
expect(head?.params['*']).toBe('a/b.txt');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reuses the GET handler and executor rather than compiling a second one', () => {
|
|
49
|
+
const router = createRouter();
|
|
50
|
+
router.get('/x', noop);
|
|
51
|
+
|
|
52
|
+
const get = router.match('GET', '/x');
|
|
53
|
+
const head = router.match('HEAD', '/x');
|
|
54
|
+
|
|
55
|
+
expect(head?.handler).toBe(get?.handler);
|
|
56
|
+
expect(head?.executor).toBe(get?.executor);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not auto-register HEAD for non-GET methods', () => {
|
|
60
|
+
const router = createRouter();
|
|
61
|
+
router.post('/submit', noop);
|
|
62
|
+
router.put('/replace', noop);
|
|
63
|
+
|
|
64
|
+
expect(router.match('HEAD', '/submit')).toBeNull();
|
|
65
|
+
expect(router.match('HEAD', '/replace')).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('an explicit router.head() always wins', () => {
|
|
69
|
+
it('when registered AFTER the GET route', () => {
|
|
70
|
+
const router = createRouter();
|
|
71
|
+
const getHandler = (): void => {};
|
|
72
|
+
const headHandler = (): void => {};
|
|
73
|
+
|
|
74
|
+
router.get('/x', getHandler);
|
|
75
|
+
expect(() => router.head('/x', headHandler)).not.toThrow();
|
|
76
|
+
|
|
77
|
+
expect(router.match('HEAD', '/x')?.handler).toBe(headHandler);
|
|
78
|
+
expect(router.match('GET', '/x')?.handler).toBe(getHandler);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('when registered BEFORE the GET route', () => {
|
|
82
|
+
const router = createRouter();
|
|
83
|
+
const getHandler = (): void => {};
|
|
84
|
+
const headHandler = (): void => {};
|
|
85
|
+
|
|
86
|
+
router.head('/x', headHandler);
|
|
87
|
+
expect(() => router.get('/x', getHandler)).not.toThrow();
|
|
88
|
+
|
|
89
|
+
expect(router.match('HEAD', '/x')?.handler).toBe(headHandler);
|
|
90
|
+
expect(router.match('GET', '/x')?.handler).toBe(getHandler);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('still rejects a genuine duplicate HEAD registration', () => {
|
|
95
|
+
const router = createRouter();
|
|
96
|
+
router.head('/x', noop);
|
|
97
|
+
expect(() => router.head('/x', noop)).toThrow(/already registered/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('still rejects a genuine duplicate GET registration', () => {
|
|
101
|
+
const router = createRouter();
|
|
102
|
+
router.get('/x', noop);
|
|
103
|
+
expect(() => router.get('/x', noop)).toThrow(/already registered/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('does not emit a duplicate introspection row for the derived HEAD', () => {
|
|
107
|
+
const router = createRouter();
|
|
108
|
+
router.get('/users/:id', noop);
|
|
109
|
+
|
|
110
|
+
const rows = router.getRoutes();
|
|
111
|
+
expect(rows).toHaveLength(1);
|
|
112
|
+
expect(rows[0]?.method).toBe('GET');
|
|
113
|
+
expect(rows.some((r) => r.method === 'HEAD')).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('emits exactly one introspection row when HEAD is registered explicitly', () => {
|
|
117
|
+
const router = createRouter();
|
|
118
|
+
router.get('/x', noop);
|
|
119
|
+
router.head('/x', noop);
|
|
120
|
+
|
|
121
|
+
const rows = router.getRoutes();
|
|
122
|
+
expect(rows.filter((r) => r.method === 'HEAD')).toHaveLength(1);
|
|
123
|
+
expect(rows.filter((r) => r.method === 'GET')).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('leaves router.all() working (its own HEAD registration must not conflict)', () => {
|
|
127
|
+
const router = createRouter();
|
|
128
|
+
expect(() => router.all('/any', noop)).not.toThrow();
|
|
129
|
+
|
|
130
|
+
const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
131
|
+
for (const method of methods) {
|
|
132
|
+
expect(router.match(method, '/any'), `expected ${method} to match`).not.toBeNull();
|
|
133
|
+
}
|
|
134
|
+
// .all() still consolidates to a single introspection row
|
|
135
|
+
expect(router.getRoutes()).toHaveLength(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('leaves redirect() working (it registers GET and HEAD itself)', () => {
|
|
139
|
+
const router = createRouter();
|
|
140
|
+
expect(() => router.redirect('/old', '/new')).not.toThrow();
|
|
141
|
+
|
|
142
|
+
expect(router.match('GET', '/old')).not.toBeNull();
|
|
143
|
+
expect(router.match('HEAD', '/old')).not.toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('survives a mounted sub-router without a route conflict or duplicate rows', () => {
|
|
147
|
+
const sub = createRouter();
|
|
148
|
+
sub.get('/list', noop);
|
|
149
|
+
sub.get('/:id', noop);
|
|
150
|
+
|
|
151
|
+
const parent = createRouter();
|
|
152
|
+
expect(() => parent.mount('/users', sub)).not.toThrow();
|
|
153
|
+
|
|
154
|
+
expect(parent.match('GET', '/users/list')).not.toBeNull();
|
|
155
|
+
expect(parent.match('HEAD', '/users/list')).not.toBeNull();
|
|
156
|
+
expect(parent.match('HEAD', '/users/7')).not.toBeNull();
|
|
157
|
+
|
|
158
|
+
// one row per copied GET route, no HEAD rows
|
|
159
|
+
const rows = parent.getRoutes();
|
|
160
|
+
expect(rows.filter((r) => r.method === 'HEAD')).toHaveLength(0);
|
|
161
|
+
expect(rows.filter((r) => r.method === 'GET')).toHaveLength(2);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('reports HEAD in the Allow set for a GET-only route', () => {
|
|
165
|
+
const router = createRouter();
|
|
166
|
+
router.get('/only-get', noop);
|
|
167
|
+
|
|
168
|
+
// allowedMethods() walks the trie; HEAD is now a registered method there.
|
|
169
|
+
const middleware = router.allowedMethods();
|
|
170
|
+
expect(typeof middleware).toBe('function');
|
|
171
|
+
|
|
172
|
+
// Direct trie assertion via match(), which is the observable contract.
|
|
173
|
+
expect(router.match('HEAD', '/only-get')).not.toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('clears derived HEAD entries on reset()', () => {
|
|
177
|
+
const router = new Router();
|
|
178
|
+
router.get('/x', noop);
|
|
179
|
+
expect(router.match('HEAD', '/x')).not.toBeNull();
|
|
180
|
+
|
|
181
|
+
router.reset();
|
|
182
|
+
expect(router.match('GET', '/x')).toBeNull();
|
|
183
|
+
expect(router.match('HEAD', '/x')).toBeNull();
|
|
184
|
+
|
|
185
|
+
// re-registering after reset must not report a conflict
|
|
186
|
+
expect(() => router.get('/x', noop)).not.toThrow();
|
|
187
|
+
expect(router.match('HEAD', '/x')).not.toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('honours a case-insensitive static route for HEAD too', () => {
|
|
191
|
+
const router = createRouter();
|
|
192
|
+
router.get('/Health', noop);
|
|
193
|
+
|
|
194
|
+
expect(router.match('HEAD', '/health')).not.toBeNull();
|
|
195
|
+
expect(router.match('HEAD', '/HEALTH')).not.toBeNull();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
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) => {}) as RouteHandler & { tag: string };
|
|
33
|
+
fn.tag = tag;
|
|
34
|
+
return fn;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A single (method, path) probe against a named route set. */
|
|
38
|
+
export interface Probe {
|
|
39
|
+
method: string;
|
|
40
|
+
path: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A named router plus the probes run against it. */
|
|
44
|
+
export interface RouteSet {
|
|
45
|
+
name: string;
|
|
46
|
+
build: () => Router;
|
|
47
|
+
probes: Probe[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Canonical, JSON-stable serialization of one match result.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately excludes the params [[Prototype]] from the golden diff: the
|
|
54
|
+
* prototype flips from `Object.prototype` to `null` for populated params by
|
|
55
|
+
* design (D8), so it is asserted as a forward invariant elsewhere, not baked
|
|
56
|
+
* into the byte-identical golden. Own-enumerable key OWNERSHIP and VALUES —
|
|
57
|
+
* the real behavioral contract — ARE captured here.
|
|
58
|
+
*/
|
|
59
|
+
export interface SerializedMatch {
|
|
60
|
+
matched: boolean;
|
|
61
|
+
handlerTag: string | null;
|
|
62
|
+
params: Array<[string, string]>;
|
|
63
|
+
hasExecutor: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run one probe against a built router and reduce it to the canonical shape. */
|
|
67
|
+
export function serializeMatch(router: Router, method: string, path: string): SerializedMatch {
|
|
68
|
+
const result = router.match(method as never, path);
|
|
69
|
+
if (!result) {
|
|
70
|
+
return { matched: false, handlerTag: null, params: [], hasExecutor: false };
|
|
71
|
+
}
|
|
72
|
+
const handler = result.handler as (RouteHandler & { tag?: string }) | undefined;
|
|
73
|
+
const params = Object.keys(result.params)
|
|
74
|
+
.sort()
|
|
75
|
+
.map((k) => [k, result.params[k]] as [string, string]);
|
|
76
|
+
return {
|
|
77
|
+
matched: true,
|
|
78
|
+
handlerTag: handler?.tag ?? null,
|
|
79
|
+
params,
|
|
80
|
+
hasExecutor: typeof result.executor === 'function',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The default route set: exercises static, nested static, single/nested params,
|
|
86
|
+
* backtracking, static-over-param-over-wildcard precedence, wildcard (incl.
|
|
87
|
+
* empty capture), and param + trailing wildcard. Case-insensitive, non-strict,
|
|
88
|
+
* decode on (framework defaults).
|
|
89
|
+
*/
|
|
90
|
+
function buildDefault(): Router {
|
|
91
|
+
const r = createRouter();
|
|
92
|
+
r.get('/', h('root'));
|
|
93
|
+
r.get('/users', h('users'));
|
|
94
|
+
r.get('/users/me', h('users.me')); // static beats param
|
|
95
|
+
r.get('/users/:id', h('users.id'));
|
|
96
|
+
r.get('/a/b/c', h('a.b.c')); // nested static
|
|
97
|
+
r.get('/a/:x/c', h('a.x.c')); // backtracking target
|
|
98
|
+
r.get('/a/b/d', h('a.b.d'));
|
|
99
|
+
r.get('/a/:x/b/:y', h('a.x.b.y')); // nested params
|
|
100
|
+
r.get('/p/me', h('p.me')); // static > param > wildcard at one node
|
|
101
|
+
r.get('/p/:id', h('p.id'));
|
|
102
|
+
r.get('/p/*', h('p.star'));
|
|
103
|
+
r.get('/files/*', h('files.star')); // wildcard incl. empty capture
|
|
104
|
+
r.get('/g/:x/*', h('g.x.star')); // param + trailing wildcard
|
|
105
|
+
r.post('/users', h('users.post')); // method variety on a static path
|
|
106
|
+
return r;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildCaseSensitive(): Router {
|
|
110
|
+
const r = createRouter({ caseSensitive: true });
|
|
111
|
+
r.get('/Users/:id', h('cs.users.id'));
|
|
112
|
+
r.get('/Static', h('cs.static'));
|
|
113
|
+
return r;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildStrict(): Router {
|
|
117
|
+
const r = createRouter({ strict: true });
|
|
118
|
+
r.get('/strict', h('strict.static'));
|
|
119
|
+
r.get('/strict/:id', h('strict.id'));
|
|
120
|
+
return r;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildDecodeOff(): Router {
|
|
124
|
+
const r = createRouter({ decode: false });
|
|
125
|
+
r.get('/raw/:id', h('raw.id'));
|
|
126
|
+
r.get('/raw/*', h('raw.star'));
|
|
127
|
+
return r;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildAll(): Router {
|
|
131
|
+
const r = createRouter();
|
|
132
|
+
r.all('/any', h('any'));
|
|
133
|
+
return r;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildPrefix(): Router {
|
|
137
|
+
const r = createRouter({ prefix: '/api' });
|
|
138
|
+
r.get('/health', h('api.health'));
|
|
139
|
+
r.get('/items/:id', h('api.items.id'));
|
|
140
|
+
return r;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildMount(): Router {
|
|
144
|
+
const child = createRouter();
|
|
145
|
+
child.get('/x', h('mount.x'));
|
|
146
|
+
child.get('/:p', h('mount.p'));
|
|
147
|
+
const parent = createRouter();
|
|
148
|
+
parent.mount('/sub', child);
|
|
149
|
+
return parent;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildGroup(): Router {
|
|
153
|
+
const r = createRouter();
|
|
154
|
+
r.group('/g', (g) => {
|
|
155
|
+
g.get('/y', h('group.y'));
|
|
156
|
+
g.get('/:z', h('group.z'));
|
|
157
|
+
});
|
|
158
|
+
return r;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Backtracking-specific set. `/bt/:x/c` + `/bt/b/d` forces the walk to try the
|
|
163
|
+
* static `b` branch, fail at `c`, and fall back to the param branch. `/w/:x/deep`
|
|
164
|
+
* + `/w/*` is the stale-param guard: the param branch binds `:x`, fails deeper,
|
|
165
|
+
* backtracks, and the wildcard must match with NO leftover `x` binding.
|
|
166
|
+
*/
|
|
167
|
+
function buildBacktrack(): Router {
|
|
168
|
+
const r = createRouter();
|
|
169
|
+
r.get('/bt/:x/c', h('bt.x.c'));
|
|
170
|
+
r.get('/bt/b/d', h('bt.b.d'));
|
|
171
|
+
r.get('/w/:x/deep', h('w.x.deep'));
|
|
172
|
+
r.get('/w/*', h('w.star'));
|
|
173
|
+
return r;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every route set + its probe corpus. */
|
|
177
|
+
export const ROUTE_SETS: RouteSet[] = [
|
|
178
|
+
{
|
|
179
|
+
name: 'default',
|
|
180
|
+
build: buildDefault,
|
|
181
|
+
probes: [
|
|
182
|
+
{ method: 'GET', path: '/' },
|
|
183
|
+
{ method: 'GET', path: '/users' },
|
|
184
|
+
{ method: 'GET', path: '/users/' }, // trailing slash (static, non-strict)
|
|
185
|
+
{ method: 'GET', path: '/users/me' }, // static beats param
|
|
186
|
+
{ method: 'GET', path: '/users/42' }, // param
|
|
187
|
+
{ method: 'GET', path: '/users/42/' }, // trailing slash (param, non-strict)
|
|
188
|
+
{ method: 'GET', path: '/Users/AbC' }, // non-ASCII-free cased; value keeps case
|
|
189
|
+
{ method: 'GET', path: '/users/a%20b' }, // percent-decoded space
|
|
190
|
+
{ method: 'GET', path: '/users/a%2Fb' }, // encoded slash stays in value
|
|
191
|
+
{ method: 'GET', path: '/users/a%2Eb' }, // encoded dot stays in value
|
|
192
|
+
{ method: 'GET', path: '/users/%zz' }, // malformed encoding → raw
|
|
193
|
+
{ method: 'GET', path: '/users/%' }, // malformed → raw
|
|
194
|
+
{ method: 'GET', path: '/users/%2' }, // truncated → raw
|
|
195
|
+
{ method: 'GET', path: '/users/\u00dcrl' }, // non-ASCII uppercase param value
|
|
196
|
+
{ method: 'GET', path: '/a/b/c' }, // nested static
|
|
197
|
+
{ method: 'GET', path: '/a/b/c/' },
|
|
198
|
+
{ method: 'GET', path: '/a/b/d' },
|
|
199
|
+
{ method: 'GET', path: '/a/b/x' }, // backtrack: static b fails at x → param :x=b
|
|
200
|
+
{ method: 'GET', path: '/a/1/b/2' }, // nested params
|
|
201
|
+
{ method: 'GET', path: '/p/me' }, // static
|
|
202
|
+
{ method: 'GET', path: '/p/other' }, // param
|
|
203
|
+
{ method: 'GET', path: '/p/a/b/c' }, // wildcard
|
|
204
|
+
{ method: 'GET', path: '/files/A/b/c' }, // wildcard original-case remainder
|
|
205
|
+
{ method: 'GET', path: '/files' }, // wildcard empty capture
|
|
206
|
+
{ method: 'GET', path: '/files/' }, // wildcard empty capture (trailing)
|
|
207
|
+
{ method: 'GET', path: '/g/1/b/c' }, // param + trailing wildcard
|
|
208
|
+
{ method: 'GET', path: '/users?q=1' }, // query stripped
|
|
209
|
+
{ method: 'GET', path: '/users/42?x=y' }, // query stripped, param
|
|
210
|
+
{ method: 'GET', path: '//a//b//c' }, // repeated-slash collapse
|
|
211
|
+
{ method: 'GET', path: '///' }, // all-slash
|
|
212
|
+
{ method: 'GET', path: '/nope' }, // miss
|
|
213
|
+
{ method: 'POST', path: '/users' }, // method variety
|
|
214
|
+
{ method: 'POST', path: '/users/me' }, // method-miss on known path
|
|
215
|
+
{ method: 'DELETE', path: '/users/42' }, // method-miss on param path
|
|
216
|
+
{ method: 'GET', path: `/deep/${Array.from({ length: 200 }, (_, i) => `s${i}`).join('/')}` }, // deep miss
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'caseSensitive',
|
|
221
|
+
build: buildCaseSensitive,
|
|
222
|
+
probes: [
|
|
223
|
+
{ method: 'GET', path: '/Users/AbC' },
|
|
224
|
+
{ method: 'GET', path: '/users/abc' }, // lowercase miss (case-sensitive)
|
|
225
|
+
{ method: 'GET', path: '/Static' },
|
|
226
|
+
{ method: 'GET', path: '/static' }, // miss
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'strict',
|
|
231
|
+
build: buildStrict,
|
|
232
|
+
probes: [
|
|
233
|
+
{ method: 'GET', path: '/strict' },
|
|
234
|
+
{ method: 'GET', path: '/strict/' }, // strict: trailing slash NOT stripped → behavior as today
|
|
235
|
+
{ method: 'GET', path: '/strict/42' },
|
|
236
|
+
{ method: 'GET', path: '/strict/42/' },
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'decodeOff',
|
|
241
|
+
build: buildDecodeOff,
|
|
242
|
+
probes: [
|
|
243
|
+
{ method: 'GET', path: '/raw/a%20b' }, // raw, not decoded
|
|
244
|
+
{ method: 'GET', path: '/raw/a%2Fb' },
|
|
245
|
+
{ method: 'GET', path: '/raw/x/y/z' }, // wildcard raw remainder
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'all',
|
|
250
|
+
build: buildAll,
|
|
251
|
+
probes: [
|
|
252
|
+
{ method: 'GET', path: '/any' },
|
|
253
|
+
{ method: 'POST', path: '/any' },
|
|
254
|
+
{ method: 'PUT', path: '/any' },
|
|
255
|
+
{ method: 'DELETE', path: '/any' },
|
|
256
|
+
{ method: 'PATCH', path: '/any' },
|
|
257
|
+
{ method: 'HEAD', path: '/any' },
|
|
258
|
+
{ method: 'OPTIONS', path: '/any' },
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: 'prefix',
|
|
263
|
+
build: buildPrefix,
|
|
264
|
+
probes: [
|
|
265
|
+
{ method: 'GET', path: '/api/health' },
|
|
266
|
+
{ method: 'GET', path: '/health' }, // miss (no prefix)
|
|
267
|
+
{ method: 'GET', path: '/api/items/7' },
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: 'mount',
|
|
272
|
+
build: buildMount,
|
|
273
|
+
probes: [
|
|
274
|
+
{ method: 'GET', path: '/sub/x' }, // copied static
|
|
275
|
+
{ method: 'GET', path: '/sub/other' }, // copied param
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'group',
|
|
280
|
+
build: buildGroup,
|
|
281
|
+
probes: [
|
|
282
|
+
{ method: 'GET', path: '/g/y' }, // group static
|
|
283
|
+
{ method: 'GET', path: '/g/other' }, // group param
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'backtrack',
|
|
288
|
+
build: buildBacktrack,
|
|
289
|
+
probes: [
|
|
290
|
+
{ method: 'GET', path: '/bt/b/c' }, // static b fails at c → param :x=b matches
|
|
291
|
+
{ method: 'GET', path: '/bt/b/d' }, // static b/d
|
|
292
|
+
{ method: 'GET', path: '/bt/z/c' }, // param directly (no static z)
|
|
293
|
+
{ method: 'GET', path: '/w/foo/other' }, // param :x binds, fails, backtracks → wildcard, NO stale x
|
|
294
|
+
{ method: 'GET', path: '/w/foo/deep' }, // param :x=foo matches deep
|
|
295
|
+
{ method: 'GET', path: '/w/a/b/c' }, // wildcard multi-segment
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Compute the full golden map: `"<set> <METHOD> <path>" -> SerializedMatch`.
|
|
302
|
+
* Deterministic and side-effect-free (each route set is freshly built).
|
|
303
|
+
*/
|
|
304
|
+
export function computeGolden(): Record<string, SerializedMatch> {
|
|
305
|
+
const out: Record<string, SerializedMatch> = {};
|
|
306
|
+
for (const set of ROUTE_SETS) {
|
|
307
|
+
const router = set.build();
|
|
308
|
+
for (const probe of set.probes) {
|
|
309
|
+
const key = `${set.name} ${probe.method} ${probe.path}`;
|
|
310
|
+
out[key] = serializeMatch(router, probe.method, probe.path);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
@@ -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
|
+
});
|