@nextrush/router 4.0.0-beta.0 → 4.0.0-beta.2
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
|
@@ -29,9 +29,7 @@ import { createRouter, Router } from '../../router';
|
|
|
29
29
|
|
|
30
30
|
/** A tagged no-op handler so a match result's `handler` is identifiable. */
|
|
31
31
|
function h(tag: string): RouteHandler {
|
|
32
|
-
const fn = (async (_ctx: Context) => {
|
|
33
|
-
/* no-op */
|
|
34
|
-
}) as RouteHandler & { tag: string };
|
|
32
|
+
const fn = (async (_ctx: Context) => {}) as RouteHandler & { tag: string };
|
|
35
33
|
fn.tag = tag;
|
|
36
34
|
return fn;
|
|
37
35
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - matchNodeIndexed's unpooled fallback stays correct
|
|
3
|
+
* (`reduce-router-match-allocations`)
|
|
4
|
+
*
|
|
5
|
+
* `Router` always supplies a `WalkPool` once it has any param/wildcard route
|
|
6
|
+
* (via `Router.addRoute`'s pool-rebuild hook), so the unpooled branch of
|
|
7
|
+
* `matchNodeIndexed` has no reachable caller through the public API today.
|
|
8
|
+
* It is kept as an explicit, documented fallback for any future direct caller
|
|
9
|
+
* that doesn't have a pool — this file is that caller, proving the fallback
|
|
10
|
+
* itself is still correct (same results as the pooled path) rather than
|
|
11
|
+
* leaving it as untested dead code.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { matchNodeIndexed } from '../matching';
|
|
16
|
+
import { createNode, NodeType, type HandlerEntry } from '../segment-trie';
|
|
17
|
+
import { compileExecutor } from '../segment-trie';
|
|
18
|
+
|
|
19
|
+
const noop = async (): Promise<void> => {};
|
|
20
|
+
|
|
21
|
+
function buildTrie() {
|
|
22
|
+
const root = createNode('');
|
|
23
|
+
const files = createNode('files');
|
|
24
|
+
root.children.set('files', files);
|
|
25
|
+
const executor = compileExecutor(noop, []);
|
|
26
|
+
const entry: HandlerEntry = { handler: noop, middleware: [], executor };
|
|
27
|
+
|
|
28
|
+
const idNode = createNode(':id', NodeType.PARAM);
|
|
29
|
+
idNode.paramName = 'id';
|
|
30
|
+
idNode.handlers.set('GET', entry);
|
|
31
|
+
files.paramChild = idNode;
|
|
32
|
+
|
|
33
|
+
const wildcard = createNode('*', NodeType.WILDCARD);
|
|
34
|
+
wildcard.handlers.set('GET', entry);
|
|
35
|
+
root.wildcardChild = wildcard;
|
|
36
|
+
|
|
37
|
+
return { root, entry };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('matchNodeIndexed — unpooled fallback (no WalkPool argument)', () => {
|
|
41
|
+
it('matches a param route without a pool', () => {
|
|
42
|
+
const { root, entry } = buildTrie();
|
|
43
|
+
const bindNames: string[] = [];
|
|
44
|
+
const bindValues: string[] = [];
|
|
45
|
+
const result = matchNodeIndexed(root, '/files/42', 1, bindNames, bindValues, 'GET', true);
|
|
46
|
+
expect(result).toBe(entry);
|
|
47
|
+
expect(bindNames).toEqual(['id']);
|
|
48
|
+
expect(bindValues).toEqual(['42']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns the empty-segment terminal handler on a bare trailing slash', () => {
|
|
52
|
+
const root = createNode('');
|
|
53
|
+
const executor = compileExecutor(noop, []);
|
|
54
|
+
const entry: HandlerEntry = { handler: noop, middleware: [], executor };
|
|
55
|
+
root.handlers.set('GET', entry);
|
|
56
|
+
const bindNames: string[] = [];
|
|
57
|
+
const bindValues: string[] = [];
|
|
58
|
+
// Position 1 on '/' lands on an empty segment at the root itself.
|
|
59
|
+
const result = matchNodeIndexed(root, '/', 1, bindNames, bindValues, 'GET', true);
|
|
60
|
+
expect(result).toBe(entry);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('backtracks from a failed param branch to a wildcard without a pool', () => {
|
|
64
|
+
const { root, entry } = buildTrie();
|
|
65
|
+
const bindNames: string[] = [];
|
|
66
|
+
const bindValues: string[] = [];
|
|
67
|
+
// No 'files' segment here — falls through directly to the root's own wildcard.
|
|
68
|
+
const result = matchNodeIndexed(root, '/anything/here', 1, bindNames, bindValues, 'GET', true);
|
|
69
|
+
expect(result).toBe(entry);
|
|
70
|
+
expect(bindNames).toEqual(['*']);
|
|
71
|
+
expect(bindValues).toEqual(['anything/here']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns null cleanly on a miss without a pool', () => {
|
|
75
|
+
const root = createNode('');
|
|
76
|
+
const bindNames: string[] = [];
|
|
77
|
+
const bindValues: string[] = [];
|
|
78
|
+
const result = matchNodeIndexed(root, '/nope', 1, bindNames, bindValues, 'GET', true);
|
|
79
|
+
expect(result).toBeNull();
|
|
80
|
+
expect(bindNames).toEqual([]);
|
|
81
|
+
expect(bindValues).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -14,9 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
14
14
|
import { normalizePathForMatch } from '../matching';
|
|
15
15
|
import { createRouter } from '../router';
|
|
16
16
|
|
|
17
|
-
const noop: RouteHandler = async () => {
|
|
18
|
-
/* no-op */
|
|
19
|
-
};
|
|
17
|
+
const noop: RouteHandler = async () => {};
|
|
20
18
|
|
|
21
19
|
afterEach(() => {
|
|
22
20
|
vi.restoreAllMocks();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - F-10 preNormalized fast-path
|
|
3
|
+
*
|
|
4
|
+
* Pins the fix for F-10 (reconciliation report, Week 2): on the real HTTP
|
|
5
|
+
* dispatch path (`Router.routes()` -> `createRoutesMiddleware`), the request
|
|
6
|
+
* path is already canonicalized via `canonicalizePath()` before `match()` is
|
|
7
|
+
* ever called, so `matchRoute`'s own independent fold+collapse re-derivation
|
|
8
|
+
* (HP-12's `caseStable`/`isProvablyLowerAscii` decision) is pure repeated
|
|
9
|
+
* work on that path. `resolveMatch`/`matchRoute` gain a `preNormalized`
|
|
10
|
+
* parameter (default `false`) that, when `true`, trusts the input as already
|
|
11
|
+
* canonical and skips the fold+collapse re-derivation entirely. `Router.
|
|
12
|
+
* match()` — the public two-argument method used by tests and direct API
|
|
13
|
+
* callers, none of which canonicalize first — is unaffected: it still omits
|
|
14
|
+
* the argument and gets today's behavior exactly.
|
|
15
|
+
*
|
|
16
|
+
* Note: this test intentionally does not assert on path-parameter casing —
|
|
17
|
+
* a pre-existing, separate behavior (confirmed present before this change,
|
|
18
|
+
* unrelated to F-10) where `Router.routes()`'s dispatch path already
|
|
19
|
+
* publishes a case-folded `ctx.path` upstream of `match()` for a
|
|
20
|
+
* case-insensitive router, regardless of this fix. F-10 is about redundant
|
|
21
|
+
* computation, not about that param-casing question — out of this change's
|
|
22
|
+
* scope.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { RouteHandler } from '@nextrush/types';
|
|
26
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
27
|
+
import { resolveMatch, type MatchState } from '../match-route';
|
|
28
|
+
import { createRouter } from '../router';
|
|
29
|
+
|
|
30
|
+
const noop: RouteHandler = async () => {};
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function buildState(overrides: Partial<MatchState> = {}): MatchState {
|
|
37
|
+
const router = createRouter();
|
|
38
|
+
router.get('/users/:id', noop);
|
|
39
|
+
const state = (router as unknown as { state: MatchState }).state;
|
|
40
|
+
return { ...state, ...overrides };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("F-10 — preNormalized skips matchRoute's redundant fold+collapse re-derivation", () => {
|
|
44
|
+
it('preNormalized: true skips the fold entirely, even on an uppercase path a fold would normally catch', () => {
|
|
45
|
+
const state = buildState();
|
|
46
|
+
const spy = vi.spyOn(String.prototype, 'toLowerCase');
|
|
47
|
+
|
|
48
|
+
// A caller passing preNormalized: true is asserting "I already
|
|
49
|
+
// canonicalized this" — matchRoute must trust that and never re-fold,
|
|
50
|
+
// even though '/USERS/ID' looks like it would otherwise trigger HP-12's
|
|
51
|
+
// fold branch. This is the direct, unambiguous proof the flag suppresses
|
|
52
|
+
// the fold step itself, independent of any caller's actual behavior.
|
|
53
|
+
resolveMatch(state, true, 'GET', '/USERS/ID', true);
|
|
54
|
+
|
|
55
|
+
expect(spy).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('preNormalized omitted (default false) behaves identically to today — still folds when needed', () => {
|
|
59
|
+
const state = buildState();
|
|
60
|
+
const match = resolveMatch(state, true, 'GET', '/Users/AbC');
|
|
61
|
+
|
|
62
|
+
expect(match).not.toBeNull();
|
|
63
|
+
expect(match?.params).toEqual({ id: 'AbC' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("Router.routes()'s internal dispatch wires preNormalized: true (no toLowerCase inside matchRoute for an already-canonical path)", async () => {
|
|
67
|
+
const router = createRouter();
|
|
68
|
+
router.get('/users/:id', noop);
|
|
69
|
+
const middleware = router.routes();
|
|
70
|
+
|
|
71
|
+
const ctx = {
|
|
72
|
+
path: '/users/abc',
|
|
73
|
+
originalPath: '/users/abc',
|
|
74
|
+
method: 'GET',
|
|
75
|
+
params: {},
|
|
76
|
+
set: () => undefined,
|
|
77
|
+
} as unknown as import('@nextrush/types').Context;
|
|
78
|
+
|
|
79
|
+
const spy = vi.spyOn(String.prototype, 'toLowerCase');
|
|
80
|
+
await middleware(ctx, undefined);
|
|
81
|
+
|
|
82
|
+
// canonicalizePath's own upstream fold call (for ctx.path) is the only
|
|
83
|
+
// toLowerCase attributable to this dispatch — matchRoute must add none.
|
|
84
|
+
expect(spy.mock.calls.length).toBeLessThanOrEqual(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("Router.match() — the public two-argument method — is unaffected: still folds its own raw input", () => {
|
|
88
|
+
const router = createRouter();
|
|
89
|
+
router.get('/users/:id', noop);
|
|
90
|
+
|
|
91
|
+
const match = router.match('GET', '/Users/AbC');
|
|
92
|
+
expect(match).not.toBeNull();
|
|
93
|
+
expect(match?.params).toEqual({ id: 'AbC' });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -19,21 +19,68 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
19
19
|
import { EMPTY_PARAMS } from '../constants';
|
|
20
20
|
import { createRouter } from '../router';
|
|
21
21
|
|
|
22
|
-
const noop: RouteHandler = async () => {
|
|
23
|
-
/* no-op */
|
|
24
|
-
};
|
|
22
|
+
const noop: RouteHandler = async () => {};
|
|
25
23
|
|
|
26
24
|
afterEach(() => {
|
|
27
25
|
vi.restoreAllMocks();
|
|
28
26
|
});
|
|
29
27
|
|
|
30
|
-
describe('HP-11 —
|
|
31
|
-
it('materializes a populated params object
|
|
28
|
+
describe('HP-11 — params expose no Object.prototype (D8)', () => {
|
|
29
|
+
it('materializes a populated params object whose chain excludes Object.prototype', () => {
|
|
32
30
|
const router = createRouter();
|
|
33
31
|
router.get('/users/:id', noop);
|
|
34
32
|
const match = router.match('GET', '/users/42');
|
|
35
33
|
expect(match?.params).toEqual({ id: '42' });
|
|
36
|
-
|
|
34
|
+
|
|
35
|
+
// The requirement is that `Object.prototype` is unreachable, NOT that the
|
|
36
|
+
// immediate prototype is literally `null` — params are built on a shared
|
|
37
|
+
// null-prototype base so V8 keeps them in fast-property mode. Walk the whole
|
|
38
|
+
// chain: it must terminate, and never pass through `Object.prototype`.
|
|
39
|
+
let proto: unknown = Object.getPrototypeOf(match?.params);
|
|
40
|
+
let hops = 0;
|
|
41
|
+
while (proto !== null) {
|
|
42
|
+
expect(proto).not.toBe(Object.prototype);
|
|
43
|
+
proto = Object.getPrototypeOf(proto as object);
|
|
44
|
+
expect(++hops).toBeLessThan(10);
|
|
45
|
+
}
|
|
46
|
+
expect(match?.params instanceof Object).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps the params container in V8 fast-property mode', () => {
|
|
50
|
+
// %HasFastProperties needs --allow-natives-syntax, which vitest's threads
|
|
51
|
+
// pool cannot enable; the enforced gate is
|
|
52
|
+
// apps/benchmark/scripts/alloc/params-shape-gate.mjs. This asserts the
|
|
53
|
+
// structural precondition that gate depends on: params derive from a shared
|
|
54
|
+
// base object, not from `null`.
|
|
55
|
+
const router = createRouter();
|
|
56
|
+
router.get('/a/:x', noop);
|
|
57
|
+
router.get('/b/:y', noop);
|
|
58
|
+
|
|
59
|
+
const first = Object.getPrototypeOf(router.match('GET', '/a/1')?.params);
|
|
60
|
+
const second = Object.getPrototypeOf(router.match('GET', '/b/2')?.params);
|
|
61
|
+
expect(first).not.toBeNull();
|
|
62
|
+
expect(first).toBe(second);
|
|
63
|
+
expect(Object.getPrototypeOf(first as object)).toBeNull();
|
|
64
|
+
expect(Object.keys(first as object)).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('behaves identically to a plain object under every enumeration path', () => {
|
|
68
|
+
const router = createRouter();
|
|
69
|
+
router.get('/u/:id/:tab', noop);
|
|
70
|
+
const params = router.match('GET', '/u/42/profile')?.params as Record<string, string>;
|
|
71
|
+
|
|
72
|
+
expect(JSON.stringify(params)).toBe('{"id":"42","tab":"profile"}');
|
|
73
|
+
expect({ ...params }).toEqual({ id: '42', tab: 'profile' });
|
|
74
|
+
expect(Object.keys(params)).toEqual(['id', 'tab']);
|
|
75
|
+
expect(Object.entries(params)).toEqual([
|
|
76
|
+
['id', '42'],
|
|
77
|
+
['tab', 'profile'],
|
|
78
|
+
]);
|
|
79
|
+
const seen: string[] = [];
|
|
80
|
+
for (const k in params) seen.push(k);
|
|
81
|
+
expect(seen).toEqual(['id', 'tab']);
|
|
82
|
+
expect(structuredClone(params)).toEqual({ id: '42', tab: 'profile' });
|
|
83
|
+
expect(Object.assign({}, params)).toEqual({ id: '42', tab: 'profile' });
|
|
37
84
|
});
|
|
38
85
|
|
|
39
86
|
it('exposes no inherited Object.prototype members on params', () => {
|
|
@@ -51,7 +98,6 @@ describe('HP-11 — null-prototype params (D8)', () => {
|
|
|
51
98
|
expect(match).not.toBeNull();
|
|
52
99
|
expect(Object.prototype.hasOwnProperty.call(match?.params, '__proto__')).toBe(true);
|
|
53
100
|
expect((match?.params as Record<string, string>)['__proto__']).toBe('danger');
|
|
54
|
-
// No global prototype pollution.
|
|
55
101
|
expect(({} as Record<string, unknown>)['danger']).toBeUndefined();
|
|
56
102
|
expect((Object.prototype as Record<string, unknown>)['danger']).toBeUndefined();
|
|
57
103
|
});
|
|
@@ -18,9 +18,7 @@ import { matchRoute } from '../match-route';
|
|
|
18
18
|
import { compileExecutor, createNode, NodeType, type HandlerEntry, type StaticRouteMap } from '../segment-trie';
|
|
19
19
|
import { createRouter } from '../router';
|
|
20
20
|
|
|
21
|
-
const noop: RouteHandler = async () => {
|
|
22
|
-
/* no-op */
|
|
23
|
-
};
|
|
21
|
+
const noop: RouteHandler = async () => {};
|
|
24
22
|
|
|
25
23
|
describe('HP-10 — single RouteMatch allocation', () => {
|
|
26
24
|
it('matchRoute attaches routerMiddleware to its own returned object (param walk)', () => {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - reused walk-frame pool safety (`reduce-router-match-allocations`)
|
|
3
|
+
*
|
|
4
|
+
* `matchNodeIndexed`'s `WalkFrame[]` stack and `matchRoute`'s `bindNames`/
|
|
5
|
+
* `bindValues` arrays are pooled per-router-instance to avoid a fresh
|
|
6
|
+
* allocation on every call (F-02). Pinned here, per the new `router`
|
|
7
|
+
* capability requirement ("Reused internal walk state is never shared across
|
|
8
|
+
* concurrent in-flight matches"): pooling must never let one match observe or
|
|
9
|
+
* corrupt another's in-progress walk, and the walk must stay fully
|
|
10
|
+
* synchronous end-to-end for that guarantee to hold.
|
|
11
|
+
*
|
|
12
|
+
* `match-safety.test.ts`'s existing "concurrency isolation (D10)" describe
|
|
13
|
+
* block already proves the returned `params` object is never shared across
|
|
14
|
+
* matches — that stays true unchanged by this pool, since `params` is still
|
|
15
|
+
* materialized fresh via `Object.create(null)` regardless of pooling. This
|
|
16
|
+
* file adds the pool-specific contract the existing suite doesn't cover:
|
|
17
|
+
* sequential reuse safety and synchronous-only execution.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { RouteHandler } from '@nextrush/types';
|
|
21
|
+
import { describe, expect, it } from 'vitest';
|
|
22
|
+
import { createRouter } from '../router';
|
|
23
|
+
|
|
24
|
+
const noop: RouteHandler = async () => {};
|
|
25
|
+
|
|
26
|
+
describe('reused walk state — sequential matches reuse pooled scratch safely', () => {
|
|
27
|
+
it('a second match on the same router is unaffected by the first match’s path or params', () => {
|
|
28
|
+
const router = createRouter();
|
|
29
|
+
router.get('/users/:id', noop);
|
|
30
|
+
router.get('/orgs/:orgId/teams/:teamId', noop);
|
|
31
|
+
|
|
32
|
+
const first = router.match('GET', '/orgs/42/teams/7');
|
|
33
|
+
const second = router.match('GET', '/users/99');
|
|
34
|
+
|
|
35
|
+
expect(first?.params).toEqual({ orgId: '42', teamId: '7' });
|
|
36
|
+
expect(second?.params).toEqual({ id: '99' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('a miss followed by a hit on the same router produces a clean, uncontaminated result', () => {
|
|
40
|
+
const router = createRouter();
|
|
41
|
+
router.get('/a/:x/c', noop);
|
|
42
|
+
router.get('/a/b/d', noop);
|
|
43
|
+
|
|
44
|
+
const miss = router.match('GET', '/a/zz/zz');
|
|
45
|
+
const hit = router.match('GET', '/a/b/c');
|
|
46
|
+
|
|
47
|
+
expect(miss).toBeNull();
|
|
48
|
+
expect(hit?.params).toEqual({ x: 'b' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('many interleaved matches at varying depth never bleed state into each other', () => {
|
|
52
|
+
const router = createRouter();
|
|
53
|
+
router.get('/x/:a', noop);
|
|
54
|
+
router.get('/y/:a/:b/:c', noop);
|
|
55
|
+
router.get('/z', noop);
|
|
56
|
+
|
|
57
|
+
const results = Array.from({ length: 500 }, (_, i) => {
|
|
58
|
+
const variant = i % 3;
|
|
59
|
+
if (variant === 0) return router.match('GET', `/x/v${i}`);
|
|
60
|
+
if (variant === 1) return router.match('GET', `/y/p${i}/q${i}/r${i}`);
|
|
61
|
+
return router.match('GET', '/z');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
results.forEach((m, i) => {
|
|
65
|
+
const variant = i % 3;
|
|
66
|
+
if (variant === 0) expect(m?.params).toEqual({ a: `v${i}` });
|
|
67
|
+
else if (variant === 1) expect(m?.params).toEqual({ a: `p${i}`, b: `q${i}`, c: `r${i}` });
|
|
68
|
+
else expect(m).not.toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('reused walk state — the walk never suspends mid-frame', () => {
|
|
74
|
+
it('router.match returns synchronously (never a Promise) for a hit', () => {
|
|
75
|
+
const router = createRouter();
|
|
76
|
+
router.get('/users/:id', noop);
|
|
77
|
+
const result = router.match('GET', '/users/1');
|
|
78
|
+
expect(result).not.toBeInstanceOf(Promise);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('router.match returns synchronously (never a Promise) for a miss', () => {
|
|
82
|
+
const router = createRouter();
|
|
83
|
+
router.get('/users/:id', noop);
|
|
84
|
+
const result = router.match('GET', '/nope');
|
|
85
|
+
expect(result).not.toBeInstanceOf(Promise);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('a deep param match completes entirely within one synchronous call (no microtask boundary)', () => {
|
|
89
|
+
const router = createRouter();
|
|
90
|
+
router.get('/' + Array.from({ length: 50 }, () => ':p').join('/'), noop);
|
|
91
|
+
const path = '/' + Array.from({ length: 50 }, (_, i) => `x${i}`).join('/');
|
|
92
|
+
|
|
93
|
+
let sawMicrotask = false;
|
|
94
|
+
queueMicrotask(() => {
|
|
95
|
+
sawMicrotask = true;
|
|
96
|
+
});
|
|
97
|
+
const result = router.match('GET', path);
|
|
98
|
+
// If the walk had suspended on a promise, the queued microtask above
|
|
99
|
+
// would have had a chance to run before match() returned.
|
|
100
|
+
expect(sawMicrotask).toBe(false);
|
|
101
|
+
expect(result).not.toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -30,7 +30,20 @@ describe('Public API surface (runtime exports)', () => {
|
|
|
30
30
|
// `createNode`/`NodeType`/`parseSegments` are internal segment-trie
|
|
31
31
|
// helpers exposed for advanced usage (see the barrel's own comment) —
|
|
32
32
|
// locked as-is here; renaming any of them is a separate breaking change.
|
|
33
|
-
|
|
33
|
+
// `canonicalizePath`/`hasDotSegment` are RFC-029's canonical-path surface:
|
|
34
|
+
// the single normalization owner every path-based consumer (mount-prefix
|
|
35
|
+
// matching, CSRF exclude paths, a hand-written policy guard) should call
|
|
36
|
+
// instead of re-deriving its own fold/collapse/strip.
|
|
37
|
+
const expectedRuntime = [
|
|
38
|
+
'createRouter',
|
|
39
|
+
'endpoint',
|
|
40
|
+
'Router',
|
|
41
|
+
'createNode',
|
|
42
|
+
'NodeType',
|
|
43
|
+
'parseSegments',
|
|
44
|
+
'canonicalizePath',
|
|
45
|
+
'hasDotSegment',
|
|
46
|
+
].sort();
|
|
34
47
|
|
|
35
48
|
expect(actualExports).toEqual(expectedRuntime);
|
|
36
49
|
expect(typeof NodeType).toBe('object');
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - registration-time max-depth tracking
|
|
3
|
+
*
|
|
4
|
+
* `addRoute` inserts each route's `:param`/static segments as a chain of trie
|
|
5
|
+
* nodes. The reused walk-frame pool (`reduce-router-match-allocations`) needs
|
|
6
|
+
* this chain's worst-case length known at registration time, not guessed —
|
|
7
|
+
* an attacker's request path can never make the walk descend deeper than the
|
|
8
|
+
* trie's own real depth (a mismatched segment backtracks, it never pushes),
|
|
9
|
+
* so sizing the pool from registered-route depth preserves the walk's
|
|
10
|
+
* existing recursion-depth DoS guard rather than reintroducing it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { addRoute, type RegistrationState } from '../registration';
|
|
15
|
+
import { createNode } from '../segment-trie';
|
|
16
|
+
|
|
17
|
+
function createState(): RegistrationState & { maxDepth: number } {
|
|
18
|
+
return {
|
|
19
|
+
root: createNode(''),
|
|
20
|
+
caseSensitive: false,
|
|
21
|
+
staticRoutes: new Map(),
|
|
22
|
+
routeDefinitions: [],
|
|
23
|
+
maxDepth: 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const noop = async (): Promise<void> => {};
|
|
28
|
+
|
|
29
|
+
describe('registration tracks the deepest registered route', () => {
|
|
30
|
+
it('a single-segment route has depth 1', () => {
|
|
31
|
+
const state = createState();
|
|
32
|
+
addRoute('GET', '/a', [noop], [], state);
|
|
33
|
+
expect(state.maxDepth).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('tracks the deepest of several routes, not the last registered', () => {
|
|
37
|
+
const state = createState();
|
|
38
|
+
addRoute('GET', '/a', [noop], [], state);
|
|
39
|
+
addRoute('GET', '/a/b/c/:id', [noop], [], state);
|
|
40
|
+
addRoute('GET', '/x', [noop], [], state);
|
|
41
|
+
expect(state.maxDepth).toBe(4);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('a wildcard segment counts toward depth but never pushes beyond it', () => {
|
|
45
|
+
const state = createState();
|
|
46
|
+
addRoute('GET', '/files/*', [noop], [], state);
|
|
47
|
+
expect(state.maxDepth).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('registering a shallower route after a deeper one does not shrink maxDepth', () => {
|
|
51
|
+
const state = createState();
|
|
52
|
+
addRoute('GET', '/a/b/c', [noop], [], state);
|
|
53
|
+
addRoute('GET', '/x', [noop], [], state);
|
|
54
|
+
expect(state.maxDepth).toBe(3);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -19,7 +19,6 @@ describe('Router audit', () => {
|
|
|
19
19
|
router = createRouter();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
// ── Phase 1: route path syntax ──────────────────────────────────────────
|
|
23
22
|
describe('phase 1 — path syntax support', () => {
|
|
24
23
|
it('supports the colon param syntax /users/:id', () => {
|
|
25
24
|
router.get('/users/:id', h());
|
|
@@ -28,7 +27,6 @@ describe('Router audit', () => {
|
|
|
28
27
|
|
|
29
28
|
it('CHARACTERIZATION: brace syntax /users/{id} is a LITERAL static segment (not a param)', () => {
|
|
30
29
|
router.get('/users/{id}', h());
|
|
31
|
-
// Only the literal path matches; it does NOT capture a param.
|
|
32
30
|
expect(router.match('GET', '/users/{id}')).not.toBeNull();
|
|
33
31
|
expect(router.match('GET', '/users/{id}')?.params).toEqual({});
|
|
34
32
|
expect(router.match('GET', '/users/42')).toBeNull();
|
|
@@ -43,7 +41,6 @@ describe('Router audit', () => {
|
|
|
43
41
|
});
|
|
44
42
|
});
|
|
45
43
|
|
|
46
|
-
// ── Phase 2: routing correctness ─────────────────────────────────────────
|
|
47
44
|
describe('phase 2 — correctness', () => {
|
|
48
45
|
it('matches the root route', () => {
|
|
49
46
|
router.get('/', h());
|
|
@@ -106,7 +103,6 @@ describe('Router audit', () => {
|
|
|
106
103
|
});
|
|
107
104
|
});
|
|
108
105
|
|
|
109
|
-
// ── Phase 3: priority ────────────────────────────────────────────────────
|
|
110
106
|
describe('phase 3 — priority (static > param > wildcard)', () => {
|
|
111
107
|
it('prefers a static route over a param route', () => {
|
|
112
108
|
const staticH = h();
|
|
@@ -147,7 +143,6 @@ describe('Router audit', () => {
|
|
|
147
143
|
});
|
|
148
144
|
});
|
|
149
145
|
|
|
150
|
-
// ── Phase 4: nested / deep routes ────────────────────────────────────────
|
|
151
146
|
describe('phase 4 — nested & deep routes', () => {
|
|
152
147
|
it('matches deeply nested param chains', () => {
|
|
153
148
|
router.get('/api/v1/orgs/:orgId/teams/:teamId/members/:memberId', h());
|
|
@@ -171,7 +166,6 @@ describe('Router audit', () => {
|
|
|
171
166
|
});
|
|
172
167
|
});
|
|
173
168
|
|
|
174
|
-
// ── Phase 6: query strings must not affect matching ──────────────────────
|
|
175
169
|
describe('phase 6 — query strings are ignored during matching', () => {
|
|
176
170
|
it('matches a static route with a query string', () => {
|
|
177
171
|
router.get('/users', h());
|
|
@@ -189,7 +183,6 @@ describe('Router audit', () => {
|
|
|
189
183
|
});
|
|
190
184
|
});
|
|
191
185
|
|
|
192
|
-
// ── Phase 7: decoding & unicode ──────────────────────────────────────────
|
|
193
186
|
describe('phase 7 — decoding & unicode', () => {
|
|
194
187
|
it('matches unicode path segments (raw)', () => {
|
|
195
188
|
router.get('/u/:name', h());
|
|
@@ -207,7 +200,6 @@ describe('Router audit', () => {
|
|
|
207
200
|
});
|
|
208
201
|
});
|
|
209
202
|
|
|
210
|
-
// ── Phase 8: HTTP methods ────────────────────────────────────────────────
|
|
211
203
|
describe('phase 8 — HTTP methods', () => {
|
|
212
204
|
it('registers and matches all standard methods', () => {
|
|
213
205
|
const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
@@ -244,7 +236,6 @@ describe('Router audit', () => {
|
|
|
244
236
|
});
|
|
245
237
|
});
|
|
246
238
|
|
|
247
|
-
// ── Phase 9: large scale ─────────────────────────────────────────────────
|
|
248
239
|
describe('phase 9 — large scale', () => {
|
|
249
240
|
it('stays correct with 1000 mixed static/param routes', () => {
|
|
250
241
|
for (let i = 0; i < 1000; i++) {
|
|
@@ -258,7 +249,6 @@ describe('Router audit', () => {
|
|
|
258
249
|
});
|
|
259
250
|
});
|
|
260
251
|
|
|
261
|
-
// ── Phase 10: failure cases ──────────────────────────────────────────────
|
|
262
252
|
describe('phase 10 — failure & graceful handling', () => {
|
|
263
253
|
it('throws a clear error on duplicate route registration', () => {
|
|
264
254
|
router.get('/dup', h());
|
|
@@ -286,7 +276,6 @@ describe('Router audit', () => {
|
|
|
286
276
|
});
|
|
287
277
|
});
|
|
288
278
|
|
|
289
|
-
// ── Phase 12: historical routing bugs from other frameworks ──────────────
|
|
290
279
|
describe('phase 12 — hardening against known routing bugs', () => {
|
|
291
280
|
it('does not confuse a param value with a similarly-named static route', () => {
|
|
292
281
|
router.get('/search', h());
|
|
@@ -9,9 +9,6 @@ import type { Context, RouteHandler } from '@nextrush/types';
|
|
|
9
9
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { createRouter, Router } from '../router';
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Create mock context for testing
|
|
14
|
-
*/
|
|
15
12
|
function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
16
13
|
return {
|
|
17
14
|
method: 'GET',
|
|
@@ -260,7 +257,6 @@ describe('Router Edge Cases', () => {
|
|
|
260
257
|
|
|
261
258
|
it('should not match wildcard if path ends at parent', () => {
|
|
262
259
|
router.get('/files/*', vi.fn());
|
|
263
|
-
// Path doesn't have anything after /files
|
|
264
260
|
expect(router.match('GET', '/files')).toBeNull();
|
|
265
261
|
});
|
|
266
262
|
});
|
|
@@ -387,7 +383,6 @@ describe('Router Edge Cases', () => {
|
|
|
387
383
|
const ctx = createMockContext({ method: 'GET', path: '/chain' });
|
|
388
384
|
await router.routes()(ctx, async () => {});
|
|
389
385
|
|
|
390
|
-
// Middleware should execute before handler
|
|
391
386
|
expect(order).toEqual([1, 2, 0, 3, 4]);
|
|
392
387
|
});
|
|
393
388
|
});
|
|
@@ -6,9 +6,6 @@ import type { Context, HttpMethod, RouteHandler } from '@nextrush/types';
|
|
|
6
6
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
7
|
import { createRouter, Router } from '../router';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Create mock context for testing
|
|
11
|
-
*/
|
|
12
9
|
function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
13
10
|
return {
|
|
14
11
|
method: 'GET',
|
|
@@ -269,7 +266,6 @@ describe('Router', () => {
|
|
|
269
266
|
// the path normalization removes trailing slashes during split
|
|
270
267
|
expect(r.match('GET', '/users')).not.toBeNull();
|
|
271
268
|
expect(r.match('GET', '/users/')).not.toBeNull();
|
|
272
|
-
// Note: Full strict mode differentiation is a future enhancement
|
|
273
269
|
});
|
|
274
270
|
});
|
|
275
271
|
|
|
@@ -288,7 +284,6 @@ describe('Router', () => {
|
|
|
288
284
|
|
|
289
285
|
await middleware(ctx, async () => {});
|
|
290
286
|
|
|
291
|
-
// Handler is called with ctx and a next function
|
|
292
287
|
expect(handler).toHaveBeenCalled();
|
|
293
288
|
expect(handler.mock.calls[0]?.[0]).toBe(ctx);
|
|
294
289
|
});
|
|
@@ -13,9 +13,7 @@ import type { RouteHandler } from '@nextrush/types';
|
|
|
13
13
|
import { describe, expect, it } from 'vitest';
|
|
14
14
|
import { createRouter } from '../router';
|
|
15
15
|
|
|
16
|
-
const noop: RouteHandler = async () => {
|
|
17
|
-
/* no-op */
|
|
18
|
-
};
|
|
16
|
+
const noop: RouteHandler = async () => {};
|
|
19
17
|
|
|
20
18
|
describe('HP-9 — reset() clears the method-nested static map fully', () => {
|
|
21
19
|
it('clears static entries across every method and leaves no ghost matches', () => {
|