@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.
Files changed (38) hide show
  1. package/dist/index.d.ts +93 -1
  2. package/dist/index.js +294 -27
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/__tests__/allowed-methods.test.ts +2 -2
  6. package/src/__tests__/audit-fixes.test.ts +0 -12
  7. package/src/__tests__/canonical-path-security.test.ts +198 -0
  8. package/src/__tests__/canonicalize-memo.test.ts +108 -0
  9. package/src/__tests__/dispatch-deasync.test.ts +0 -20
  10. package/src/__tests__/find-node-differential.test.ts +6 -6
  11. package/src/__tests__/head-auto-registration.test.ts +197 -0
  12. package/src/__tests__/helpers/differential-corpus.ts +1 -3
  13. package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
  14. package/src/__tests__/match-normalize-fastpath.test.ts +1 -3
  15. package/src/__tests__/match-prenormalized.test.ts +95 -0
  16. package/src/__tests__/match-safety.test.ts +53 -7
  17. package/src/__tests__/match-single-alloc.test.ts +1 -3
  18. package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
  19. package/src/__tests__/public-surface.test.ts +14 -1
  20. package/src/__tests__/registration-max-depth.test.ts +56 -0
  21. package/src/__tests__/router-audit.test.ts +0 -11
  22. package/src/__tests__/router-edge-cases.test.ts +0 -5
  23. package/src/__tests__/router.test.ts +0 -5
  24. package/src/__tests__/static-map-reset.test.ts +1 -3
  25. package/src/__tests__/walk-pool-sizing.test.ts +72 -0
  26. package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
  27. package/src/canonicalize.ts +137 -0
  28. package/src/composition.ts +4 -0
  29. package/src/constants.ts +24 -6
  30. package/src/dispatch.ts +34 -6
  31. package/src/index.ts +6 -0
  32. package/src/match-route.ts +82 -19
  33. package/src/matching.ts +20 -14
  34. package/src/registration.ts +62 -10
  35. package/src/router.ts +79 -1
  36. package/src/segment-trie.ts +8 -0
  37. package/src/state.ts +1 -0
  38. package/src/walk-pool.ts +208 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @nextrush/router - walk pool sizing tracks maxDepth (`reduce-router-match-allocations`)
3
+ *
4
+ * `Router.addRoute` rebuilds `state.walkPool` whenever a newly registered
5
+ * route's depth exceeds the current pool's size, so the pool is never too
6
+ * small for the deepest route actually registered — never guessed, never
7
+ * left stale after a deeper route is added later.
8
+ */
9
+
10
+ import type { RouteHandler } from '@nextrush/types';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { createRouter } from '../router';
13
+
14
+ const noop: RouteHandler = async () => {};
15
+
16
+ describe('walk pool sizing follows registered route depth', () => {
17
+ it('has no pool for a router with only static routes', () => {
18
+ const router = createRouter();
19
+ router.get('/a', noop);
20
+ router.get('/b', noop);
21
+ // No param/wildcard route registered — matches still work via the static
22
+ // fast path, and there is nothing for a pool to help with.
23
+ expect(router.match('GET', '/a')?.handler).toBe(noop);
24
+ });
25
+
26
+ it('matches correctly immediately after the first param route is registered', () => {
27
+ const router = createRouter();
28
+ router.get('/users/:id', noop);
29
+ const match = router.match('GET', '/users/42');
30
+ expect(match?.params).toEqual({ id: '42' });
31
+ });
32
+
33
+ it('matches correctly after a deeper route is registered later, growing the pool', () => {
34
+ const router = createRouter();
35
+ router.get('/users/:id', noop);
36
+ // First match at the shallower depth, pool sized to depth 2.
37
+ expect(router.match('GET', '/users/1')?.params).toEqual({ id: '1' });
38
+
39
+ // Register a much deeper route — the pool must grow to cover it.
40
+ router.get('/orgs/:o/teams/:t/members/:m/roles/:r', noop);
41
+ const deep = router.match('GET', '/orgs/1/teams/2/members/3/roles/4');
42
+ expect(deep?.params).toEqual({ o: '1', t: '2', m: '3', r: '4' });
43
+
44
+ // The original shallow route still matches correctly after the pool grew.
45
+ expect(router.match('GET', '/users/2')?.params).toEqual({ id: '2' });
46
+ });
47
+
48
+ it('a router built with all routes registered up front matches every depth correctly', () => {
49
+ const router = createRouter();
50
+ router.get('/a/:x', noop);
51
+ router.get('/a/:x/b/:y', noop);
52
+ router.get('/a/:x/b/:y/c/:z', noop);
53
+
54
+ expect(router.match('GET', '/a/1')?.params).toEqual({ x: '1' });
55
+ expect(router.match('GET', '/a/1/b/2')?.params).toEqual({ x: '1', y: '2' });
56
+ expect(router.match('GET', '/a/1/b/2/c/3')?.params).toEqual({ x: '1', y: '2', z: '3' });
57
+ });
58
+
59
+ it('reset() clears pooled state so a shallower re-registration still matches correctly', () => {
60
+ const router = createRouter();
61
+ router.get('/orgs/:o/teams/:t/members/:m', noop);
62
+ expect(router.match('GET', '/orgs/1/teams/2/members/3')?.params).toEqual({
63
+ o: '1',
64
+ t: '2',
65
+ m: '3',
66
+ });
67
+
68
+ router.reset();
69
+ router.get('/x/:id', noop);
70
+ expect(router.match('GET', '/x/9')?.params).toEqual({ id: '9' });
71
+ });
72
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @nextrush/router - walk-pool fail-closed guard when undersized
3
+ * (`reduce-router-match-allocations`)
4
+ *
5
+ * `matchNodeIndexedPooled` defensively fails closed (treats the walk as a
6
+ * miss, never writes past the array) if a caller ever supplies a `WalkPool`
7
+ * smaller than the path actually being walked needs. `createWalkPool` sizes
8
+ * correctly today (`maxDepth + 1`), so this should be unreachable through the
9
+ * public API — this file proves the guard itself is correct if that
10
+ * invariant is ever violated by a future change, rather than leaving an
11
+ * untested "should be unreachable" comment as the only evidence.
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+ import { matchNodeIndexed } from '../matching';
16
+ import { createWalkPool } from '../walk-pool';
17
+ import { createNode, NodeType, type HandlerEntry } from '../segment-trie';
18
+ import { compileExecutor } from '../segment-trie';
19
+
20
+ const noop = async (): Promise<void> => {};
21
+
22
+ describe('walk pool — fails closed when undersized (defense-in-depth)', () => {
23
+ it('a static-descent pool undersized by one frame misses cleanly instead of writing past the array', () => {
24
+ const root = createNode('');
25
+ const a = createNode('a');
26
+ root.children.set('a', a);
27
+ const executor = compileExecutor(noop, []);
28
+ const entry: HandlerEntry = { handler: noop, middleware: [], executor };
29
+ a.handlers.set('GET', entry);
30
+
31
+ // Correctly sized for depth 0 only (createWalkPool(0) -> 1 frame) — one
32
+ // fewer frame than the descent into 'a' (depth 1) actually needs.
33
+ const undersizedPool = createWalkPool(0);
34
+ const bindNames: string[] = [];
35
+ const bindValues: string[] = [];
36
+
37
+ const result = matchNodeIndexed(root, '/a', 1, bindNames, bindValues, 'GET', true, undefined, undersizedPool);
38
+ expect(result).toBeNull();
39
+ });
40
+
41
+ it('a param-descent pool undersized by one frame misses cleanly and leaves no dangling bind', () => {
42
+ const root = createNode('');
43
+ const idNode = createNode(':id', NodeType.PARAM);
44
+ idNode.paramName = 'id';
45
+ root.paramChild = idNode;
46
+ const executor = compileExecutor(noop, []);
47
+ idNode.handlers.set('GET', { handler: noop, middleware: [], executor });
48
+
49
+ const undersizedPool = createWalkPool(0);
50
+ const bindNames: string[] = [];
51
+ const bindValues: string[] = [];
52
+
53
+ const result = matchNodeIndexed(root, '/42', 1, bindNames, bindValues, 'GET', true, undefined, undersizedPool);
54
+ expect(result).toBeNull();
55
+ // The guard pops its own speculative bind before failing closed — no leak.
56
+ expect(bindNames).toEqual([]);
57
+ expect(bindValues).toEqual([]);
58
+ });
59
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @nextrush/router - Canonical Request Path
3
+ *
4
+ * The single normalization owner (RFC-029): every consumer that needs to know
5
+ * "what path does the router treat this request as" — the router's own match,
6
+ * a mounted-router prefix test, a CSRF exclude-path match, an adapter's
7
+ * published `ctx.path` — calls {@link canonicalizePath} instead of hand-rolling
8
+ * its own fold/collapse/strip, so there is exactly one definition of "the same
9
+ * path" across the framework (SEC-02, SEC-09, SEC-15).
10
+ *
11
+ * @see docs/RFC/request-data/029-canonical-request-path.md
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import { collapseAndStrip, isProvablyLowerAscii } from './matching';
16
+
17
+ /** Result of {@link canonicalizePath}. */
18
+ export interface CanonicalPathResult {
19
+ /**
20
+ * `true` when the path contains a `.`/`..` path segment. A dot segment is
21
+ * rejected outright (400), never resolved — resolving it locally would
22
+ * diverge from how a front-end proxy already resolved (or didn't resolve)
23
+ * the same segment before forwarding, reopening the desync this RFC closes.
24
+ */
25
+ readonly rejected: boolean;
26
+ /**
27
+ * The canonical path: query-stripped, dot-segment-free, case-folded (unless
28
+ * `caseSensitive`), slash-collapsed, trailing-slash-stripped per `strict`.
29
+ * Meaningless when {@link rejected} is `true`.
30
+ */
31
+ readonly path: string;
32
+ }
33
+
34
+ const DOT = 0x2e; // '.'
35
+ const SLASH = 0x2f; // '/'
36
+
37
+ /**
38
+ * True when `path` (already query-stripped) contains a `.` or `..` segment —
39
+ * a component that is exactly `.` or `..` between slash boundaries (or at the
40
+ * start/end of the string). A single linear scan, no backtracking regex
41
+ * (task 3.5): each character is visited at most once, so a pathological input
42
+ * cannot degrade to worse than O(n).
43
+ *
44
+ * A dot as filename content (`archive.tar.gz`, `..hidden.txt`) is NOT a dot
45
+ * segment — only a component whose entire text between slashes is `.` or `..`
46
+ * counts.
47
+ */
48
+ export function hasDotSegment(path: string): boolean {
49
+ const len = path.length;
50
+ let segStart = 0;
51
+
52
+ for (let i = 0; i <= len; i++) {
53
+ const atBoundary = i === len || path.charCodeAt(i) === SLASH;
54
+ if (!atBoundary) continue;
55
+
56
+ const segLen = i - segStart;
57
+ if (segLen === 1 && path.charCodeAt(segStart) === DOT) return true;
58
+ if (segLen === 2 && path.charCodeAt(segStart) === DOT && path.charCodeAt(segStart + 1) === DOT) {
59
+ return true;
60
+ }
61
+ segStart = i + 1;
62
+ }
63
+
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Single-entry memo for {@link canonicalizePath}.
69
+ *
70
+ * Exists because a request falling through N prefix mounts asks for the
71
+ * canonical form of the *same* path N times, once per mount — the O(mounts)
72
+ * term that made a 10-module app spend most of its dispatch time deciding which
73
+ * mount to enter. Each call also allocated its own result object, so the memo
74
+ * removes an allocation as well as three string scans.
75
+ *
76
+ * Safe to share the result across callers: {@link CanonicalPathResult} declares
77
+ * both fields `readonly`, and no caller mutates it (verified across all three
78
+ * call sites — the mount test, the router's own dispatch, and the adapter).
79
+ * Keyed on all three inputs, so two routers with different `caseSensitive` or
80
+ * `strict` options can never read each other's answer. A single entry means
81
+ * interleaved requests simply miss rather than getting a wrong answer.
82
+ *
83
+ * This is a pure-function memo, not shared state: it cannot make
84
+ * `canonicalizePath` return anything other than what recomputing would return.
85
+ */
86
+ let memoTarget: string | undefined = undefined;
87
+ let memoCaseSensitive = false;
88
+ let memoStrict = false;
89
+ let memoResult: CanonicalPathResult = { rejected: false, path: '/' };
90
+
91
+ /**
92
+ * Canonicalize a raw request target into the one path value every consumer
93
+ * (router match, mount-prefix test, CSRF exclude match, published `ctx.path`)
94
+ * treats as "this request's path" (RFC-029).
95
+ *
96
+ * Order: strip the query string, reject a dot segment before any further
97
+ * normalization (a dot segment is a request-shape violation, not something to
98
+ * fold case on first), then fold case (unless `caseSensitive`) and collapse
99
+ * structure exactly as {@link import('./matching').normalizePathForMatch} does
100
+ * — this function IS that normalization, extended with dot-segment rejection
101
+ * and made a public, adapter-facing entry point.
102
+ *
103
+ * The most recent result is memoized; see the note above the memo fields for
104
+ * why that is sound. Treat the returned object as immutable.
105
+ *
106
+ * @param rawTarget - The raw request target, may include a query string.
107
+ * @param caseSensitive - Router case-sensitivity option.
108
+ * @param strict - Router strict-trailing-slash option.
109
+ */
110
+ export function canonicalizePath(
111
+ rawTarget: string,
112
+ caseSensitive: boolean,
113
+ strict: boolean
114
+ ): CanonicalPathResult {
115
+ if (rawTarget === memoTarget && caseSensitive === memoCaseSensitive && strict === memoStrict) {
116
+ return memoResult;
117
+ }
118
+
119
+ const queryIdx = rawTarget.indexOf('?');
120
+ const path = queryIdx === -1 ? rawTarget : rawTarget.slice(0, queryIdx);
121
+
122
+ const result: CanonicalPathResult = hasDotSegment(path)
123
+ ? { rejected: true, path }
124
+ : {
125
+ rejected: false,
126
+ path: collapseAndStrip(
127
+ caseSensitive || isProvablyLowerAscii(path) ? path : path.toLowerCase(),
128
+ strict
129
+ ),
130
+ };
131
+
132
+ memoTarget = rawTarget;
133
+ memoCaseSensitive = caseSensitive;
134
+ memoStrict = strict;
135
+ memoResult = result;
136
+ return result;
137
+ }
@@ -43,6 +43,10 @@ export function copyRoutes(
43
43
  ): void {
44
44
  // Copy handlers at this node
45
45
  for (const [method, entry] of node.handlers) {
46
+ // A derived HEAD entry is not copied — the parent re-derives it from the
47
+ // GET registration below, which keeps the parent's own explicit-HEAD
48
+ // precedence and introspection rows correct.
49
+ if (entry.autoHead) continue;
46
50
  const path = prefix + '/' + segments.join('/');
47
51
  // Prepend sub-router middleware so it runs before the route's own middleware
48
52
  const combined =
package/src/constants.ts CHANGED
@@ -12,14 +12,32 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Frozen, null-prototype empty params object for matches with no path
16
- * parameters (static-map hits and successful walks that bound no `:param`/`*`).
15
+ * Prototype for every per-request key/value bag the router hands to
16
+ * application code (`ctx.params`).
17
+ *
18
+ * Built once at module load, so instances are created with
19
+ * `Object.create(NULL_PROTO)` rather than `Object.create(null)`. Both satisfy
20
+ * the security requirement — `Object.prototype` stays unreachable, so a param
21
+ * named `__proto__`/`constructor`/`prototype` binds as an own key and no
22
+ * inherited member is visible — but `Object.create(null)` additionally puts the
23
+ * object into V8 DICTIONARY mode, where property loads cannot be inline-cached.
24
+ * That cost is paid by every `ctx.params.id` read in every handler, forever.
25
+ * Deriving from a null-prototype object instead keeps FAST properties.
26
+ *
27
+ * @see docs/adr/ADR-0021-fast-property-request-containers.md
28
+ */
29
+ export const NULL_PROTO: object = Object.create(null) as object;
30
+
31
+ /**
32
+ * Frozen empty params object for matches with no path parameters (static-map
33
+ * hits and successful walks that bound no `:param`/`*`).
17
34
  *
18
35
  * Shared as a single instance to avoid allocating a fresh object per request on
19
- * the hot path. Null-prototype so callers can never read an inherited
20
- * `Object.prototype` key as if it were a route param; frozen so the shared
21
- * instance can never be mutated by a handler.
36
+ * the hot path. Frozen so the shared instance can never be mutated by a
37
+ * handler. Built on {@link NULL_PROTO} because it is *read* on every
38
+ * static-route request a dictionary-mode miss-read (`ctx.params.id` on a
39
+ * route with no params) measured 2.2x slower than a fast-property one.
22
40
  */
23
41
  export const EMPTY_PARAMS: Record<string, string> = Object.freeze(
24
- Object.create(null) as Record<string, string>
42
+ Object.create(NULL_PROTO) as Record<string, string>
25
43
  );
package/src/dispatch.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import type { Context, HttpMethod, Middleware, RouteMatch } from '@nextrush/types';
22
22
  import { NOOP_NEXT, type TrieNode } from './segment-trie';
23
23
  import { findAllowedMethods } from './find-node';
24
+ import { canonicalizePath } from './canonicalize';
24
25
 
25
26
  /**
26
27
  * Shared resolved promise for the no-`next` miss path (NF-1). Reused rather than
@@ -32,19 +33,46 @@ const RESOLVED: Promise<void> = Promise.resolve();
32
33
  /**
33
34
  * Build the router's primary dispatch middleware.
34
35
  *
35
- * On each request it resolves the route via the injected `match` function,
36
- * sets `ctx.params`, and runs the pre-compiled executor (which already bakes
37
- * in any router-level middleware). A miss sets `ctx.status = 404` and yields
38
- * to the next middleware so `allowedMethods()`/a 404 handler can act.
36
+ * On each request it canonicalizes the path (RFC-029): a dot segment
37
+ * (`.`/`..`) is rejected with 400 before any route match or path-based
38
+ * middleware runs resolving it locally would diverge from how a front-end
39
+ * proxy already handled the same segment, so it is never resolved, only
40
+ * rejected. Otherwise it resolves the route via the injected `match`
41
+ * function, publishes the canonical path onto `ctx.path` (preserving the raw
42
+ * target as `ctx.originalPath`), sets `ctx.params`, and runs the
43
+ * pre-compiled executor (which already bakes in any router-level
44
+ * middleware). A miss sets `ctx.status = 404` and yields to the next
45
+ * middleware so `allowedMethods()`/a 404 handler can act.
39
46
  *
40
47
  * @param match - Route resolver, supplied by `Router.match` so this factory
41
48
  * never touches `Router` internals directly.
49
+ * @param caseSensitive - Router case-sensitivity option, forwarded to
50
+ * canonicalization so the published `ctx.path` matches what `match` used.
51
+ * @param strict - Router strict-trailing-slash option, forwarded likewise.
42
52
  */
43
53
  export function createRoutesMiddleware(
44
- match: (method: HttpMethod, path: string) => RouteMatch | null
54
+ match: (method: HttpMethod, path: string) => RouteMatch | null,
55
+ caseSensitive: boolean,
56
+ strict: boolean
45
57
  ): Middleware {
46
58
  return (ctx: Context, next?: () => Promise<void>): Promise<void> => {
47
- const routeMatch = match(ctx.method, ctx.path);
59
+ const originalPath = ctx.path;
60
+ const canonical = canonicalizePath(originalPath, caseSensitive, strict);
61
+
62
+ if (canonical.rejected) {
63
+ // A dot segment is a request-shape violation, not a 404 — it stops the
64
+ // chain outright rather than falling through to a 404/allowedMethods
65
+ // handler, which would still leak information about the un-normalized
66
+ // target via ctx.path.
67
+ ctx.status = 400;
68
+ (ctx as { originalPath: string }).originalPath = originalPath;
69
+ return RESOLVED;
70
+ }
71
+
72
+ (ctx as { path: string }).path = canonical.path;
73
+ (ctx as { originalPath: string }).originalPath = originalPath;
74
+
75
+ const routeMatch = match(ctx.method, canonical.path);
48
76
 
49
77
  if (!routeMatch) {
50
78
  // No route matched — set 404 so allowedMethods()/notFoundHandler() can act,
package/src/index.ts CHANGED
@@ -15,6 +15,12 @@
15
15
  // Router
16
16
  export { createRouter, endpoint, Router } from './router';
17
17
 
18
+ // Canonical request path (RFC-029) — the single normalization owner shared by
19
+ // the router's own match, mounted-router prefix tests, and any consumer that
20
+ // needs to know "what path does the router treat this request as".
21
+ export { canonicalizePath, hasDotSegment } from './canonicalize';
22
+ export type { CanonicalPathResult } from './canonicalize';
23
+
18
24
  // Route groups
19
25
  export type { RouteGroup } from './group-router';
20
26
 
@@ -14,8 +14,9 @@
14
14
  */
15
15
 
16
16
  import type { HttpMethod, Middleware, RouteMatch } from '@nextrush/types';
17
- import { EMPTY_PARAMS } from './constants';
17
+ import { EMPTY_PARAMS, NULL_PROTO } from './constants';
18
18
  import { matchNodeIndexed, collapseAndStrip, isProvablyLowerAscii } from './matching';
19
+ import type { WalkPool } from './walk-pool';
19
20
  import type { StaticRouteMap, TrieNode } from './segment-trie';
20
21
 
21
22
  /**
@@ -43,7 +44,25 @@ export function matchRoute(
43
44
  caseSensitive: boolean,
44
45
  strict: boolean,
45
46
  decode: boolean,
46
- routerMiddleware: Middleware[]
47
+ routerMiddleware: Middleware[],
48
+ /**
49
+ * When `true`, `rawPath` is trusted as already the output of
50
+ * `canonicalizePath()` (same `caseSensitive`/`strict` options) — the fold
51
+ * and structural-collapse steps below are skipped entirely rather than
52
+ * re-derived. Only valid when the caller has actually run
53
+ * `canonicalizePath()` on this exact input first (F-10); the router's own
54
+ * `routes()` dispatch path is the only caller that does. Defaults to
55
+ * `false`, preserving every other caller's existing behavior — including
56
+ * `Router.match()`, which never canonicalizes first.
57
+ */
58
+ preNormalized = false,
59
+ /**
60
+ * When supplied, the param walk reuses this router instance's pooled
61
+ * `WalkFrame[]`/binding-array scratch space instead of allocating fresh
62
+ * arrays on this call (F-02, `reduce-router-match-allocations`). Omitted,
63
+ * `matchRoute` behaves exactly as before.
64
+ */
65
+ walkPool?: WalkPool
47
66
  ): RouteMatch | null {
48
67
  let path = rawPath;
49
68
  // Query string must not affect path matching (RFC 3986 §3.4). Strip it here,
@@ -51,16 +70,30 @@ export function matchRoute(
51
70
  // exclude it. This strip is caller-specific: `findAllowedMethods` receives an
52
71
  // already query-free `ctx.path`, so the shared `normalizePathForMatch` does
53
72
  // not strip — only `matchRoute` does.
54
- const queryIdx = path.indexOf('?');
55
- if (queryIdx !== -1) path = path.slice(0, queryIdx);
73
+ if (!preNormalized) {
74
+ const queryIdx = path.indexOf('?');
75
+ if (queryIdx !== -1) path = path.slice(0, queryIdx);
76
+ }
56
77
 
57
78
  // HP-12: decide case-stability ONCE. When the path is provably case-stable
58
79
  // (case-sensitive router, or an all-lowercase-ASCII path), folding is a no-op
59
80
  // and the original-case path equals the normalized one — so we skip both the
60
81
  // `toLowerCase()` allocation and the second original-case normalize pass.
61
- const caseStable = caseSensitive || isProvablyLowerAscii(path);
62
- const folded = caseStable ? path : path.toLowerCase();
63
- const normalized = collapseAndStrip(folded, strict);
82
+ //
83
+ // F-10: when `preNormalized` is true, the caller already ran this exact
84
+ // fold+collapse via `canonicalizePath()` — trust `path` as both the
85
+ // normalized AND original-case string, skipping the fold+collapse
86
+ // re-derivation entirely rather than repeating work already done upstream.
87
+ let normalized: string;
88
+ let caseStable: boolean;
89
+ if (preNormalized) {
90
+ normalized = path;
91
+ caseStable = true;
92
+ } else {
93
+ caseStable = caseSensitive || isProvablyLowerAscii(path);
94
+ const folded = caseStable ? path : path.toLowerCase();
95
+ normalized = collapseAndStrip(folded, strict);
96
+ }
64
97
 
65
98
  // FAST PATH: O(1) static route lookup (no tree traversal). Method-nested map
66
99
  // (HP-9): select the inner map by method, then probe by the normalized path —
@@ -94,9 +127,19 @@ export function matchRoute(
94
127
  // Deferred param binding (HP-11): the walk records the accepted path's
95
128
  // `:param`/`*` bindings onto these parallel stacks (pushed on descent, popped
96
129
  // on backtrack) so params are materialized ONCE here on the accepted terminal
97
- // — no eager bind + backtrack `Reflect.deleteProperty`.
98
- const bindNames: string[] = [];
99
- const bindValues: string[] = [];
130
+ // — no eager bind + backtrack `Reflect.deleteProperty`. Reused from the
131
+ // router's pool when supplied (F-02) instead of allocated fresh.
132
+ const bindNames: string[] = walkPool ? walkPool.bindNames : [];
133
+ const bindValues: string[] = walkPool ? walkPool.bindValues : [];
134
+ // A pooled bindNames/bindValues array persists across calls — clear any
135
+ // leftover entries from a prior match before this walk starts (the walk
136
+ // itself pops everything it pushes on a clean miss or match, but a defensive
137
+ // clear here costs nothing on the common empty case and closes any future
138
+ // edit that might leave a stale entry behind).
139
+ if (walkPool) {
140
+ bindNames.length = 0;
141
+ bindValues.length = 0;
142
+ }
100
143
 
101
144
  const entry = matchNodeIndexed(
102
145
  root,
@@ -106,21 +149,25 @@ export function matchRoute(
106
149
  bindValues,
107
150
  method,
108
151
  decode,
109
- originalPath
152
+ originalPath,
153
+ walkPool
110
154
  );
111
155
  if (!entry) return null;
112
156
 
113
- // Materialize params once on a null-prototype object (design.md D8): a param
114
- // named `__proto__`/`constructor`/`prototype` binds as an OWN key with no
115
- // prototype mutation, and no inherited member is visible on `ctx.params`. The
116
- // bind count replaces the former `Object.keys` post-loop (HP-13); zero binds
117
- // returns the shared frozen `EMPTY_PARAMS`.
157
+ // Materialize params once on a bag whose prototype chain excludes
158
+ // `Object.prototype` (design.md D8): a param named
159
+ // `__proto__`/`constructor`/`prototype` binds as an OWN key with no prototype
160
+ // mutation, and no inherited member is visible on `ctx.params`. Derived from
161
+ // `NULL_PROTO` rather than `Object.create(null)` so the object keeps V8 fast
162
+ // properties and handler reads stay inline-cacheable. The bind count replaces
163
+ // the former `Object.keys` post-loop (HP-13); zero binds returns the shared
164
+ // frozen `EMPTY_PARAMS`.
118
165
  const count = bindNames.length;
119
166
  let params: Record<string, string>;
120
167
  if (count === 0) {
121
168
  params = EMPTY_PARAMS;
122
169
  } else {
123
- params = Object.create(null) as Record<string, string>;
170
+ params = Object.create(NULL_PROTO) as Record<string, string>;
124
171
  for (let i = 0; i < count; i++) {
125
172
  const name = bindNames[i];
126
173
  const value = bindValues[i];
@@ -140,6 +187,14 @@ export function matchRoute(
140
187
  * Stable router state `resolveMatch` reads on every request. All fields are
141
188
  * fixed references for the router's lifetime, so the caller memoizes this once
142
189
  * rather than rebuilding it per request.
190
+ *
191
+ * `walkPool` is the one field that is itself mutable (its contents, not the
192
+ * reference) — the reused `WalkFrame[]`/binding-array scratch space (F-02,
193
+ * `reduce-router-match-allocations`). It is `undefined` until the router has
194
+ * at least one param/wildcard route (no pool needed for a static-only router,
195
+ * per `createWalkPool(0)` never being called) and is rebuilt whenever the
196
+ * router's `maxDepth` grows past what the current pool was sized for — see
197
+ * `Router`'s own wiring, not this interface, for when that rebuild happens.
143
198
  */
144
199
  export interface MatchState {
145
200
  readonly root: TrieNode;
@@ -148,6 +203,7 @@ export interface MatchState {
148
203
  readonly strict: boolean;
149
204
  readonly decode: boolean;
150
205
  readonly routerMiddleware: Middleware[];
206
+ walkPool?: WalkPool;
151
207
  }
152
208
 
153
209
  /**
@@ -162,7 +218,12 @@ export function resolveMatch(
162
218
  state: MatchState,
163
219
  hasParamRoutes: boolean,
164
220
  method: HttpMethod,
165
- path: string
221
+ path: string,
222
+ /**
223
+ * Forwarded to {@link matchRoute} — see its own doc comment for the
224
+ * caller contract. Defaults to `false`.
225
+ */
226
+ preNormalized = false
166
227
  ): RouteMatch | null {
167
228
  return matchRoute(
168
229
  method,
@@ -173,6 +234,8 @@ export function resolveMatch(
173
234
  state.caseSensitive,
174
235
  state.strict,
175
236
  state.decode,
176
- state.routerMiddleware
237
+ state.routerMiddleware,
238
+ preNormalized,
239
+ state.walkPool
177
240
  );
178
241
  }
package/src/matching.ts CHANGED
@@ -13,6 +13,10 @@
13
13
 
14
14
  import type { HttpMethod } from '@nextrush/types';
15
15
  import type { HandlerEntry, TrieNode } from './segment-trie';
16
+ import { matchNodeIndexedPooled, type WalkFrame, type WalkPool } from './walk-pool';
17
+
18
+ export type { WalkFrame, WalkPool } from './walk-pool';
19
+ export { createWalkPool } from './walk-pool';
16
20
 
17
21
  /**
18
22
  * Percent-decode an extracted param/wildcard value when `decode` is enabled.
@@ -113,20 +117,10 @@ export function normalizePathForMatch(
113
117
  }
114
118
 
115
119
  /**
116
- * One node in the iterative walk's explicit stack. `stage` is a small state
117
- * machine (0 = extract + try static, 1 = try param, 2 = try wildcard/backtrack)
118
- * so a single frame can be revisited on backtrack without recursion. `bound`
119
- * records whether this frame pushed a deferred param binding, so backtracking
120
- * can pop it without an object-property delete.
120
+ * One node in the iterative walk's explicit stack see the full doc comment
121
+ * on {@link WalkFrame} in `walk-pool.ts` (re-exported here for callers that
122
+ * only import from `matching.ts`).
121
123
  */
122
- interface WalkFrame {
123
- node: TrieNode;
124
- pos: number;
125
- stage: 0 | 1 | 2;
126
- seg: string;
127
- next: number;
128
- bound: boolean;
129
- }
130
124
 
131
125
  /**
132
126
  * Iterative, index-based segment-trie match (HP-11 / HP-13, design.md D4).
@@ -156,8 +150,20 @@ export function matchNodeIndexed(
156
150
  bindValues: string[],
157
151
  method: HttpMethod,
158
152
  decode: boolean,
159
- originalPath?: string
153
+ originalPath?: string,
154
+ /**
155
+ * When provided, the walk indexes into `pool.frames` by depth (reusing
156
+ * each pre-allocated frame object in place) instead of `push`/`pop`-ing
157
+ * fresh frame literals onto a fresh array — see `walk-pool.ts`'s
158
+ * `matchNodeIndexedPooled`. Omitted, the walk behaves exactly as before (a
159
+ * fresh `stack: WalkFrame[]`) — every other caller of `matchNodeIndexed`
160
+ * keeps its current behavior unchanged.
161
+ */
162
+ pool?: WalkPool
160
163
  ): HandlerEntry | null {
164
+ if (pool) {
165
+ return matchNodeIndexedPooled(root, path, startPos, bindNames, bindValues, method, decode, pool, originalPath);
166
+ }
161
167
  const stack: WalkFrame[] = [
162
168
  { node: root, pos: startPos, stage: 0, seg: '', next: 0, bound: false },
163
169
  ];