@nextrush/router 4.0.0-beta.0 → 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/dist/index.d.ts +93 -1
- package/dist/index.js +294 -27
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/allowed-methods.test.ts +2 -2
- package/src/__tests__/audit-fixes.test.ts +0 -12
- 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 +0 -20
- package/src/__tests__/find-node-differential.test.ts +6 -6
- package/src/__tests__/head-auto-registration.test.ts +197 -0
- package/src/__tests__/helpers/differential-corpus.ts +1 -3
- package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +1 -3
- package/src/__tests__/match-prenormalized.test.ts +95 -0
- package/src/__tests__/match-safety.test.ts +53 -7
- package/src/__tests__/match-single-alloc.test.ts +1 -3
- package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
- package/src/__tests__/public-surface.test.ts +14 -1
- package/src/__tests__/registration-max-depth.test.ts +56 -0
- package/src/__tests__/router-audit.test.ts +0 -11
- package/src/__tests__/router-edge-cases.test.ts +0 -5
- package/src/__tests__/router.test.ts +0 -5
- package/src/__tests__/static-map-reset.test.ts +1 -3
- 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 +4 -0
- package/src/constants.ts +24 -6
- package/src/dispatch.ts +34 -6
- package/src/index.ts +6 -0
- package/src/match-route.ts +82 -19
- package/src/matching.ts +20 -14
- package/src/registration.ts +62 -10
- package/src/router.ts +79 -1
- package/src/segment-trie.ts +8 -0
- package/src/state.ts +1 -0
- package/src/walk-pool.ts +208 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextrush/router",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.1",
|
|
4
4
|
"description": "High-performance segment trie router for NextRush",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@nextrush/types": "4.0.0-beta.
|
|
21
|
+
"@nextrush/types": "4.0.0-beta.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"tsup": "^8.5.1",
|
|
25
25
|
"typescript": "^6.0.3",
|
|
26
|
-
"@nextrush/core": "4.0.0-beta.
|
|
26
|
+
"@nextrush/core": "4.0.0-beta.1"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@nextrush/core": "4.0.0-beta.
|
|
29
|
+
"@nextrush/core": "4.0.0-beta.1"
|
|
30
30
|
},
|
|
31
31
|
"peerDependenciesMeta": {
|
|
32
32
|
"@nextrush/core": {
|
|
@@ -93,7 +93,7 @@ describe('allowedMethods() middleware — end-to-end characterization', () => {
|
|
|
93
93
|
await middleware(ctx, async () => {});
|
|
94
94
|
|
|
95
95
|
expect(ctx.status).toBe(200);
|
|
96
|
-
expect(ctx.set).toHaveBeenCalledWith('Allow', 'GET');
|
|
96
|
+
expect(ctx.set).toHaveBeenCalledWith('Allow', 'GET, HEAD');
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it('resolves a wildcard route via findNode (wildcardChild branch)', async () => {
|
|
@@ -104,7 +104,7 @@ describe('allowedMethods() middleware — end-to-end characterization', () => {
|
|
|
104
104
|
await middleware(ctx, async () => {});
|
|
105
105
|
|
|
106
106
|
expect(ctx.status).toBe(200);
|
|
107
|
-
expect(ctx.set).toHaveBeenCalledWith('Allow', 'GET');
|
|
107
|
+
expect(ctx.set).toHaveBeenCalledWith('Allow', 'GET, HEAD');
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
it('resolves a static-child branch deeper than one segment (findNode recursion)', async () => {
|
|
@@ -9,10 +9,6 @@ import type { Context } from '@nextrush/types';
|
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { createRouter } from '../router';
|
|
11
11
|
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// RT-1 — reset() must also clear routeDefinitions (getRoutes introspection)
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
12
|
describe('RT-1: reset() clears the introspection registry', () => {
|
|
17
13
|
it('getRoutes() is empty after reset()', () => {
|
|
18
14
|
const router = createRouter();
|
|
@@ -25,10 +21,6 @@ describe('RT-1: reset() clears the introspection registry', () => {
|
|
|
25
21
|
});
|
|
26
22
|
});
|
|
27
23
|
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// RT-5 — conflicting param names at the same position must throw, not warn
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
24
|
describe('RT-5: param-name conflict throws at registration', () => {
|
|
33
25
|
it('throws when the same position uses two different param names', () => {
|
|
34
26
|
const router = createRouter();
|
|
@@ -43,10 +35,6 @@ describe('RT-5: param-name conflict throws at registration', () => {
|
|
|
43
35
|
});
|
|
44
36
|
});
|
|
45
37
|
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// RT-7 — routes() called twice must not seal router middleware twice
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
38
|
describe('RT-7: routes() is idempotent w.r.t. router middleware sealing', () => {
|
|
51
39
|
it('runs router-level middleware exactly once even if routes() is called twice', async () => {
|
|
52
40
|
const router = createRouter();
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - SEC-02 / SEC-09 canonical-path security regression suite
|
|
3
|
+
*
|
|
4
|
+
* RED tests (tasks 3.1-3.3) for RFC-029: the router folds case and collapses
|
|
5
|
+
* structure for its own lookup, but never publishes that decision, and never
|
|
6
|
+
* rejects a dot segment. A path-based policy comparing raw `ctx.path` (the
|
|
7
|
+
* idiomatic prefix-guard pattern shown in this framework's own README) sees a
|
|
8
|
+
* different string than the one the router matched on.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/RFC/request-data/029-canonical-request-path.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context, RouteHandler } from '@nextrush/types';
|
|
14
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import { createRouter, Router } from '../router';
|
|
16
|
+
|
|
17
|
+
function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
18
|
+
return {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
path: '/',
|
|
21
|
+
params: {},
|
|
22
|
+
query: {},
|
|
23
|
+
body: undefined,
|
|
24
|
+
headers: {},
|
|
25
|
+
status: 200,
|
|
26
|
+
state: {},
|
|
27
|
+
json: vi.fn(),
|
|
28
|
+
send: vi.fn(),
|
|
29
|
+
html: vi.fn(),
|
|
30
|
+
redirect: vi.fn(),
|
|
31
|
+
set: vi.fn(),
|
|
32
|
+
get: vi.fn(),
|
|
33
|
+
next: vi.fn(),
|
|
34
|
+
raw: {
|
|
35
|
+
req: {} as never,
|
|
36
|
+
res: {} as never,
|
|
37
|
+
},
|
|
38
|
+
...overrides,
|
|
39
|
+
} as Context;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('SEC-02: case-fold path-prefix authorization bypass (P1)', () => {
|
|
43
|
+
let router: Router;
|
|
44
|
+
let handler: RouteHandler;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
router = createRouter();
|
|
48
|
+
handler = vi.fn();
|
|
49
|
+
router.get('/admin/users', handler);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('3.1: dispatches a mixed-case request while a naive raw-ctx.path prefix guard never fires', () => {
|
|
53
|
+
// The exact PoC from report/security-review.md SEC-02: a policy comparing
|
|
54
|
+
// ctx.path.startsWith('/admin') against the RAW request target, guarding a
|
|
55
|
+
// route the router matches after folding.
|
|
56
|
+
const rawPath = '/ADMIN/users';
|
|
57
|
+
const guardWouldFire = rawPath.startsWith('/admin');
|
|
58
|
+
|
|
59
|
+
const match = router.match('GET', rawPath);
|
|
60
|
+
|
|
61
|
+
expect(guardWouldFire).toBe(false); // the guard never runs
|
|
62
|
+
expect(match).not.toBeNull(); // the router dispatches anyway
|
|
63
|
+
expect(match?.handler).toBe(handler);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('3.1: a trailing-slash variant also dispatches while failing a naive equality guard', () => {
|
|
67
|
+
const rawPath = '/admin/users/';
|
|
68
|
+
const guardWouldFire = rawPath === '/admin/users';
|
|
69
|
+
|
|
70
|
+
const match = router.match('GET', rawPath);
|
|
71
|
+
|
|
72
|
+
expect(guardWouldFire).toBe(false);
|
|
73
|
+
expect(match).not.toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('3.1: a repeated-slash variant also dispatches while failing a naive equality guard', () => {
|
|
77
|
+
const rawPath = '//admin//users';
|
|
78
|
+
const guardWouldFire = rawPath === '/admin/users';
|
|
79
|
+
|
|
80
|
+
const match = router.match('GET', rawPath);
|
|
81
|
+
|
|
82
|
+
expect(guardWouldFire).toBe(false);
|
|
83
|
+
expect(match).not.toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('SEC-09: no dot-segment normalization (P2)', () => {
|
|
88
|
+
let router: Router;
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
router = createRouter();
|
|
92
|
+
router.get('/api/webhooks/handler', vi.fn());
|
|
93
|
+
router.get('/admin', vi.fn());
|
|
94
|
+
router.get('/users/:id', vi.fn());
|
|
95
|
+
router.get('/files/*', vi.fn());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it.each([
|
|
99
|
+
['/api/webhooks/../admin', 'literal dot segment'],
|
|
100
|
+
['/api/./users', 'single-dot segment'],
|
|
101
|
+
['/../..', 'leading dot segments past root'],
|
|
102
|
+
])(
|
|
103
|
+
'3.2: %s (%s) is never rejected — it either 404s or resolves past its own directory, both unsafe for a canonical-path contract',
|
|
104
|
+
(path) => {
|
|
105
|
+
// TODAY: the router has no dot-segment concept at all — a dot segment is
|
|
106
|
+
// just an opaque path component. This test documents the RED state: no
|
|
107
|
+
// 400 is ever produced, because nothing in matching.ts/match-route.ts
|
|
108
|
+
// inspects segments for `.`/`..`. Once RFC-029 P0 lands, dot-segment
|
|
109
|
+
// paths must be rejected with 400 before reaching match() at all — this
|
|
110
|
+
// test's premise (the router matches or 404s, never rejects) must flip.
|
|
111
|
+
const match = router.match('GET', path);
|
|
112
|
+
|
|
113
|
+
// Current behavior: no route happens to be registered at the resolved
|
|
114
|
+
// literal segments, so this 404s (null) today — proving the absence of
|
|
115
|
+
// any dot-segment handling, not proving safety. A registered route at
|
|
116
|
+
// the resolved target would dispatch silently instead.
|
|
117
|
+
expect(match).toBeNull();
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
it('3.2: a percent-encoded double-dot segment is likewise passed through as an opaque literal segment', () => {
|
|
122
|
+
// %2e%2e is never decoded by matchRoute for structural purposes — only
|
|
123
|
+
// param VALUES are percent-decoded (decodeParam), and only after a param
|
|
124
|
+
// slot in the trie already matched. A dot segment used for traversal
|
|
125
|
+
// purposes is a static path segment, so it is compared literally as
|
|
126
|
+
// `%2e%2e`, which matches nothing today.
|
|
127
|
+
const match = router.match('GET', '/api/%2e%2e/admin');
|
|
128
|
+
expect(match).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('3.2: a two-character segment starting with a dot but not a double-dot is not flagged as a traversal segment', () => {
|
|
132
|
+
router.get('/files/.x', vi.fn());
|
|
133
|
+
const match = router.match('GET', '/files/.x');
|
|
134
|
+
expect(match).not.toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('3.2: a dot as filename content (not a traversal segment) is accepted and dispatches normally', () => {
|
|
138
|
+
router.get('/files/archive.tar.gz', vi.fn());
|
|
139
|
+
const match = router.match('GET', '/files/archive.tar.gz');
|
|
140
|
+
expect(match).not.toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('3.3: published-path contract (ctx.path / ctx.originalPath)', () => {
|
|
145
|
+
it('3.3: the router publishes the canonical path onto ctx.path and preserves the raw target on ctx.originalPath', async () => {
|
|
146
|
+
const router = createRouter();
|
|
147
|
+
let seenPath = '';
|
|
148
|
+
let seenOriginal: string | undefined;
|
|
149
|
+
router.get('/admin/users', (ctx) => {
|
|
150
|
+
seenPath = ctx.path;
|
|
151
|
+
seenOriginal = ctx.originalPath;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const ctx = createMockContext({ path: '/ADMIN/users?x=1' });
|
|
155
|
+
const middleware = router.routes();
|
|
156
|
+
await middleware(ctx, async () => {});
|
|
157
|
+
|
|
158
|
+
expect(seenPath).toBe('/admin/users');
|
|
159
|
+
expect(seenOriginal).toBe('/ADMIN/users?x=1');
|
|
160
|
+
expect(seenPath).not.toContain('?');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('3.3: both ctx.path and ctx.originalPath are populated on a 404 (no route matched)', async () => {
|
|
164
|
+
const router = createRouter();
|
|
165
|
+
router.get('/users', () => undefined);
|
|
166
|
+
|
|
167
|
+
const ctx = createMockContext({ path: '/USERS/999' });
|
|
168
|
+
const middleware = router.routes();
|
|
169
|
+
await middleware(ctx, async () => {});
|
|
170
|
+
|
|
171
|
+
expect(ctx.status).toBe(404);
|
|
172
|
+
expect(ctx.path).toBe('/users/999');
|
|
173
|
+
expect((ctx as unknown as { originalPath?: string }).originalPath).toBe('/USERS/999');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('a mocked ctx not yet touched by router dispatch has no originalPath — optional field, absent by default', () => {
|
|
177
|
+
const ctx = createMockContext({ path: '/ADMIN/users' });
|
|
178
|
+
expect((ctx as unknown as { originalPath?: string }).originalPath).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('3.3: a dot-segment request rejected by dispatch sets 400 and still populates ctx.originalPath', async () => {
|
|
182
|
+
const router = createRouter();
|
|
183
|
+
router.get('/admin', vi.fn());
|
|
184
|
+
|
|
185
|
+
const ctx = createMockContext({ path: '/../admin' });
|
|
186
|
+
const middleware = router.routes();
|
|
187
|
+
let nextCalled = false;
|
|
188
|
+
await middleware(ctx, async () => {
|
|
189
|
+
nextCalled = true;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(ctx.status).toBe(400);
|
|
193
|
+
expect((ctx as unknown as { originalPath?: string }).originalPath).toBe('/../admin');
|
|
194
|
+
// A dot-segment rejection stops the chain outright — it never falls
|
|
195
|
+
// through to a 404/allowedMethods handler the way a plain miss does.
|
|
196
|
+
expect(nextCalled).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Canonicalization memo correctness
|
|
3
|
+
*
|
|
4
|
+
* `canonicalizePath` memoizes its most recent result, and `matchesMountPrefix`
|
|
5
|
+
* memoizes the canonical form of its (registration-fixed) prefix. Both exist to
|
|
6
|
+
* remove the O(mounts) canonicalization cost a request paid falling through
|
|
7
|
+
* prefix mounts — measured at ~557 ns per mounted router before, ~190 ns after.
|
|
8
|
+
*
|
|
9
|
+
* A memo is only sound if it can never return an answer that differs from
|
|
10
|
+
* recomputing. The hazard is options: `caseSensitive` and `strict` change the
|
|
11
|
+
* result for the same input string, and two routers with different options can
|
|
12
|
+
* be mounted in the same application. These tests pin that.
|
|
13
|
+
*
|
|
14
|
+
* @see reports/investigations/post-audit-invariant-erosion-review.md F-1
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
import { canonicalizePath } from '../canonicalize';
|
|
19
|
+
import { createRouter } from '../router';
|
|
20
|
+
|
|
21
|
+
describe('canonicalizePath memo cannot leak across option combinations', () => {
|
|
22
|
+
it('returns the folded path for caseSensitive=false and the raw path for true', () => {
|
|
23
|
+
// Same input, alternating options — a memo keyed only on the string would
|
|
24
|
+
// return the first answer for both.
|
|
25
|
+
for (let i = 0; i < 3; i++) {
|
|
26
|
+
expect(canonicalizePath('/Users/List', false, false).path).toBe('/users/list');
|
|
27
|
+
expect(canonicalizePath('/Users/List', true, false).path).toBe('/Users/List');
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('respects strict for the same input string', () => {
|
|
32
|
+
for (let i = 0; i < 3; i++) {
|
|
33
|
+
expect(canonicalizePath('/users/', false, false).path).toBe('/users');
|
|
34
|
+
expect(canonicalizePath('/users/', false, true).path).toBe('/users/');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('still rejects a dot segment after a non-rejected call for another path', () => {
|
|
39
|
+
expect(canonicalizePath('/safe/path', false, false).rejected).toBe(false);
|
|
40
|
+
expect(canonicalizePath('/a/../b', false, false).rejected).toBe(true);
|
|
41
|
+
expect(canonicalizePath('/safe/path', false, false).rejected).toBe(false);
|
|
42
|
+
expect(canonicalizePath('/a/./b', false, false).rejected).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('is stable under repetition and equal to a first-call result', () => {
|
|
46
|
+
const first = canonicalizePath('/A//b/', false, false);
|
|
47
|
+
canonicalizePath('/something/else', true, true);
|
|
48
|
+
const again = canonicalizePath('/A//b/', false, false);
|
|
49
|
+
expect(again.path).toBe(first.path);
|
|
50
|
+
expect(again.rejected).toBe(first.rejected);
|
|
51
|
+
expect(again.path).toBe('/a/b');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('strips the query string consistently whether memoized or not', () => {
|
|
55
|
+
expect(canonicalizePath('/users?a=1', false, false).path).toBe('/users');
|
|
56
|
+
expect(canonicalizePath('/users?a=1', false, false).path).toBe('/users');
|
|
57
|
+
expect(canonicalizePath('/users?b=2', false, false).path).toBe('/users');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('matchesMountPrefix prefix memo', () => {
|
|
62
|
+
it('returns the correct remainder repeatedly for one prefix', () => {
|
|
63
|
+
const router = createRouter();
|
|
64
|
+
for (let i = 0; i < 3; i++) {
|
|
65
|
+
expect(router.matchesMountPrefix('/api/v1/users/42', '/api/v1')).toBe('/users/42');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles a router tested against two different prefixes (memo miss path)', () => {
|
|
70
|
+
const router = createRouter();
|
|
71
|
+
expect(router.matchesMountPrefix('/api/users', '/api')).toBe('/users');
|
|
72
|
+
expect(router.matchesMountPrefix('/admin/users', '/admin')).toBe('/users');
|
|
73
|
+
expect(router.matchesMountPrefix('/api/users', '/api')).toBe('/users');
|
|
74
|
+
// A prefix that does not match must still miss after the memo warmed.
|
|
75
|
+
expect(router.matchesMountPrefix('/api/users', '/admin')).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('folds the prefix per the router own caseSensitive option', () => {
|
|
79
|
+
const insensitive = createRouter();
|
|
80
|
+
expect(insensitive.matchesMountPrefix('/ADMIN/Users', '/admin')).toBe('/users');
|
|
81
|
+
|
|
82
|
+
const sensitive = createRouter({ caseSensitive: true });
|
|
83
|
+
expect(sensitive.matchesMountPrefix('/ADMIN/Users', '/admin')).toBeUndefined();
|
|
84
|
+
expect(sensitive.matchesMountPrefix('/admin/Users', '/admin')).toBe('/Users');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('enforces the segment boundary rather than a bare string prefix', () => {
|
|
88
|
+
const router = createRouter();
|
|
89
|
+
expect(router.matchesMountPrefix('/apifoo/bar', '/api')).toBeUndefined();
|
|
90
|
+
expect(router.matchesMountPrefix('/api/bar', '/api')).toBe('/bar');
|
|
91
|
+
expect(router.matchesMountPrefix('/api', '/api')).toBe('/');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects a dot-segment path', () => {
|
|
95
|
+
const router = createRouter();
|
|
96
|
+
expect(router.matchesMountPrefix('/api/../secret', '/api')).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('gives two routers with different options independent answers', () => {
|
|
100
|
+
const a = createRouter({ caseSensitive: false });
|
|
101
|
+
const b = createRouter({ caseSensitive: true });
|
|
102
|
+
// Interleaved, so a shared memo keyed only on the string would cross over.
|
|
103
|
+
for (let i = 0; i < 3; i++) {
|
|
104
|
+
expect(a.matchesMountPrefix('/API/x', '/api')).toBe('/x');
|
|
105
|
+
expect(b.matchesMountPrefix('/API/x', '/api')).toBeUndefined();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -68,10 +68,6 @@ const run = (router: Router, ctx: Context): Promise<void> =>
|
|
|
68
68
|
/** Settle a promise into its resolution or rejection reason without throwing. */
|
|
69
69
|
const settle = (p: Promise<unknown>): Promise<unknown> => p.then((v) => v, (e) => e);
|
|
70
70
|
|
|
71
|
-
// ===========================================================================
|
|
72
|
-
// §2.1 — no extra async frame (structural identity probes, RED before change)
|
|
73
|
-
// ===========================================================================
|
|
74
|
-
|
|
75
71
|
describe('NF-1 §2.1: dispatch forwards without an extra async frame', () => {
|
|
76
72
|
it('createRoutesMiddleware returns the executor promise directly (identity)', () => {
|
|
77
73
|
const sentinel = Promise.resolve();
|
|
@@ -113,10 +109,6 @@ describe('NF-1 §2.1: dispatch forwards without an extra async frame', () => {
|
|
|
113
109
|
});
|
|
114
110
|
});
|
|
115
111
|
|
|
116
|
-
// ===========================================================================
|
|
117
|
-
// §2.2–§2.5 — error / return-shape propagation (behavior contracts)
|
|
118
|
-
// ===========================================================================
|
|
119
|
-
|
|
120
112
|
describe('NF-1 §2.2: a synchronous throw becomes a rejected promise', () => {
|
|
121
113
|
it('the executor never throws synchronously out of dispatch', async () => {
|
|
122
114
|
const exec = compileExecutor((() => {
|
|
@@ -196,10 +188,6 @@ describe('NF-1 §2.5: a non-Error throw is wrapped as Error(String(thrown))', ()
|
|
|
196
188
|
});
|
|
197
189
|
});
|
|
198
190
|
|
|
199
|
-
// ===========================================================================
|
|
200
|
-
// §2.6 — miss → 404 → next() fall-through (allowedMethods 405)
|
|
201
|
-
// ===========================================================================
|
|
202
|
-
|
|
203
191
|
describe('NF-1 §2.6: a miss sets 404 and forwards next()', () => {
|
|
204
192
|
it('a known-path/unregistered-method miss becomes 405 with Allow via allowedMethods', async () => {
|
|
205
193
|
const router = createRouter();
|
|
@@ -223,10 +211,6 @@ describe('NF-1 §2.6: a miss sets 404 and forwards next()', () => {
|
|
|
223
211
|
});
|
|
224
212
|
});
|
|
225
213
|
|
|
226
|
-
// ===========================================================================
|
|
227
|
-
// §2.7 — load-bearing setNext(NOOP_NEXT) chain termination (NF-4a KEPT)
|
|
228
|
-
// ===========================================================================
|
|
229
|
-
|
|
230
214
|
describe('NF-1 §2.7: setNext(NOOP_NEXT) terminates the chain at the handler', () => {
|
|
231
215
|
it('a route handler calling ctx.next() does NOT advance into app middleware after the router', async () => {
|
|
232
216
|
const router = createRouter();
|
|
@@ -252,10 +236,6 @@ describe('NF-1 §2.7: setNext(NOOP_NEXT) terminates the chain at the handler', (
|
|
|
252
236
|
});
|
|
253
237
|
});
|
|
254
238
|
|
|
255
|
-
// ===========================================================================
|
|
256
|
-
// §2.8 — the len >= 1 per-route middleware chain is unchanged
|
|
257
|
-
// ===========================================================================
|
|
258
|
-
|
|
259
239
|
describe('NF-1 §2.8: the len >= 1 executor path is unchanged', () => {
|
|
260
240
|
it('preserves onion ordering across a 5-layer ctx.next() chain', async () => {
|
|
261
241
|
const router = createRouter();
|
|
@@ -27,9 +27,7 @@ import { normalizePathForMatch } from '../matching';
|
|
|
27
27
|
import { addRoute, type RegistrationState } from '../registration';
|
|
28
28
|
import { createNode, type TrieNode } from '../segment-trie';
|
|
29
29
|
|
|
30
|
-
const noop: RouteHandler = async () => {
|
|
31
|
-
/* no-op */
|
|
32
|
-
};
|
|
30
|
+
const noop: RouteHandler = async () => {};
|
|
33
31
|
|
|
34
32
|
/** Build a bare trie root from a route list, mirroring how `Router` inserts. */
|
|
35
33
|
function buildRoot(
|
|
@@ -42,6 +40,7 @@ function buildRoot(
|
|
|
42
40
|
caseSensitive,
|
|
43
41
|
staticRoutes: new Map(),
|
|
44
42
|
routeDefinitions: [],
|
|
43
|
+
maxDepth: 0,
|
|
45
44
|
};
|
|
46
45
|
for (const [method, path] of routes) {
|
|
47
46
|
addRoute(method, path, [noop], [], state);
|
|
@@ -166,8 +165,9 @@ describe('HP-17 — findNode iterative rewrite: differential parity with recursi
|
|
|
166
165
|
['GET', '/p/:id'],
|
|
167
166
|
['GET', '/p/*'],
|
|
168
167
|
]);
|
|
169
|
-
// Static wins.
|
|
170
|
-
|
|
168
|
+
// Static wins. HEAD is present because a GET registration derives it
|
|
169
|
+
// (RFC 9110 §9.3.2) — the assertion here is about precedence, not the method set.
|
|
170
|
+
expect(findAllowedMethods('/p/me', r, false, false)).toEqual(['GET', 'HEAD']);
|
|
171
171
|
// Param wins over wildcard for a single leftover segment.
|
|
172
172
|
const paramNode = prodNode('/p/other', r, false, false);
|
|
173
173
|
expect(paramNode).toBe(refNode('/p/other', r, false, false));
|
|
@@ -203,7 +203,7 @@ describe('HP-17 — deep-path 405/OPTIONS safety (RED before the iterative rewri
|
|
|
203
203
|
const root = buildRoot([['GET', '/' + Array.from({ length: depth }, () => ':p').join('/')]]);
|
|
204
204
|
const deepPath = '/' + Array.from({ length: depth }, (_, i) => `x${i}`).join('/');
|
|
205
205
|
expect(() => findAllowedMethods(deepPath, root, false, false)).not.toThrow();
|
|
206
|
-
expect(findAllowedMethods(deepPath, root, false, false)).toEqual(['GET']);
|
|
206
|
+
expect(findAllowedMethods(deepPath, root, false, false)).toEqual(['GET', 'HEAD']);
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
it('misses a deep-but-overshooting path without a stack overflow', () => {
|
|
@@ -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
|
+
});
|