@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.
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
@@ -39,12 +39,22 @@ import {
39
39
  import { mergeContributions, readContribution } from './route-metadata';
40
40
  import { createRedirectHandler, type RedirectStatus } from './redirect';
41
41
 
42
- /** Registration state `addRoute` reads, threaded explicitly. */
42
+ /**
43
+ * Registration state `addRoute` reads and writes, threaded explicitly.
44
+ *
45
+ * `maxDepth` is the deepest registered route's segment count seen so far —
46
+ * used to size the reused walk-frame pool (`reduce-router-match-allocations`)
47
+ * without guessing a constant. It only ever grows: a request path can never
48
+ * make the tree walk descend past the trie's own real depth (a mismatched
49
+ * segment backtracks rather than pushing a new frame), so this stays a
50
+ * registration-time-only figure, never influenced by request traffic.
51
+ */
43
52
  export interface RegistrationState {
44
53
  readonly root: TrieNode;
45
54
  readonly caseSensitive: boolean;
46
55
  readonly staticRoutes: StaticRouteMap;
47
56
  readonly routeDefinitions: RouteDefinition[];
57
+ maxDepth: number;
48
58
  }
49
59
 
50
60
  /**
@@ -81,6 +91,24 @@ export function normalizeRegistrationPath(path: string, prefix: string, strict:
81
91
  return normalized.startsWith('/') ? normalized : '/' + normalized;
82
92
  }
83
93
 
94
+ /**
95
+ * Insert one entry into the method-nested static-route fast-path map,
96
+ * creating the per-method inner map on first use.
97
+ */
98
+ function setStaticEntry(
99
+ staticRoutes: StaticRouteMap,
100
+ method: HttpMethod,
101
+ key: string,
102
+ entry: HandlerEntry
103
+ ): void {
104
+ let methodMap = staticRoutes.get(method);
105
+ if (!methodMap) {
106
+ methodMap = new Map<string, HandlerEntry>();
107
+ staticRoutes.set(method, methodMap);
108
+ }
109
+ methodMap.set(key, entry);
110
+ }
111
+
84
112
  /**
85
113
  * Insert one route into the segment trie, updating every side structure
86
114
  * (static-route fast-path map, introspection registry) that
@@ -115,6 +143,10 @@ export function addRoute(
115
143
  ): boolean {
116
144
  const segments = parseSegments(normalized, state.caseSensitive);
117
145
 
146
+ if (segments.length > state.maxDepth) {
147
+ state.maxDepth = segments.length;
148
+ }
149
+
118
150
  let node = state.root;
119
151
 
120
152
  for (const seg of segments) {
@@ -182,10 +214,14 @@ export function addRoute(
182
214
  handler: finalHandler,
183
215
  middleware: combinedMiddleware,
184
216
  executor,
217
+ autoHead: false,
185
218
  };
186
219
 
187
- // Detect duplicate route registration
188
- if (node.handlers.has(method)) {
220
+ // Detect duplicate route registration. A derived HEAD entry is not a
221
+ // duplicate — an explicit `router.head()` replaces it, in either
222
+ // registration order.
223
+ const existing = node.handlers.get(method);
224
+ if (existing && !(method === 'HEAD' && existing.autoHead)) {
189
225
  throw new Error(
190
226
  `Route conflict: ${method} ${normalized} is already registered. ` +
191
227
  'Remove the duplicate or use a different path.'
@@ -199,14 +235,30 @@ export function addRoute(
199
235
  // case-sensitive) path — so matching selects the inner map by method and
200
236
  // probes by the raw path with no per-request key-string concatenation.
201
237
  const hasParams = segments.some((s) => s.type === NodeType.PARAM || s.type === NodeType.WILDCARD);
202
- if (!hasParams) {
203
- const normalizedKey = state.caseSensitive ? normalized : normalized.toLowerCase();
204
- let methodMap = state.staticRoutes.get(method);
205
- if (!methodMap) {
206
- methodMap = new Map<string, HandlerEntry>();
207
- state.staticRoutes.set(method, methodMap);
238
+ const staticKey = hasParams
239
+ ? undefined
240
+ : state.caseSensitive
241
+ ? normalized
242
+ : normalized.toLowerCase();
243
+ if (staticKey !== undefined) {
244
+ setStaticEntry(state.staticRoutes, method, staticKey, handlerEntry);
245
+ }
246
+
247
+ // RFC 9110 §9.3.2: HEAD is GET without a body, so a GET registration answers
248
+ // HEAD too — matching Fastify/Express/Koa/Hono. Derived at registration time,
249
+ // so request dispatch is unchanged. An explicit HEAD already registered for
250
+ // this path wins and is never overwritten.
251
+ if (method === 'GET' && !node.handlers.has('HEAD')) {
252
+ const derived: HandlerEntry = {
253
+ handler: finalHandler,
254
+ middleware: combinedMiddleware,
255
+ executor,
256
+ autoHead: true,
257
+ };
258
+ node.handlers.set('HEAD', derived);
259
+ if (staticKey !== undefined) {
260
+ setStaticEntry(state.staticRoutes, 'HEAD', staticKey, derived);
208
261
  }
209
- methodMap.set(normalizedKey, handlerEntry);
210
262
  }
211
263
 
212
264
  // Record in the introspection registry (side structure — never touched by
package/src/router.ts CHANGED
@@ -22,6 +22,7 @@ import { clearNode, createNode, type StaticRouteMap, type TrieNode } from './seg
22
22
  import { type RedirectStatus } from './redirect';
23
23
  import { runRouteGroup, type RouteGroup } from './group-router';
24
24
  import { resolveMatch, type MatchState } from './match-route';
25
+ import { createWalkPool } from './walk-pool';
25
26
  import { copyRoutes } from './composition';
26
27
  import { sealRouterMiddleware as sealRouterMiddlewareImpl } from './middleware-adapter';
27
28
  import {
@@ -33,6 +34,10 @@ import {
33
34
  } from './registration';
34
35
  import { createAllowedMethodsMiddleware, createRoutesMiddleware } from './dispatch';
35
36
  import { createRouterState, resolveRouterOptions } from './state';
37
+ import { canonicalizePath } from './canonicalize';
38
+
39
+ /** '/'.charCodeAt(0) — used by {@link Router.matchesMountPrefix}'s boundary check. */
40
+ const SLASH_CHAR_CODE = 0x2f;
36
41
 
37
42
  /** Inline route metadata declaration — re-exported from its own module (RT-3). */
38
43
  export { endpoint } from './route-metadata';
@@ -62,6 +67,14 @@ export class Router {
62
67
  /** Whether router-level middleware has already been sealed into executors (audit RT-7) */
63
68
  private _sealed = false;
64
69
 
70
+ /**
71
+ * Single-entry memo for {@link matchesMountPrefix}'s canonicalization of the
72
+ * mount prefix, which is fixed at registration time. Declared as two fields
73
+ * rather than an object so a miss stores without allocating.
74
+ */
75
+ private _prefixMemoRaw: string | undefined = undefined;
76
+ private _prefixMemoCanonical = '';
77
+
65
78
  /** Memoized state the extracted registration/matching functions read (see {@link createRouterState}). */
66
79
  private readonly state: RegistrationState & MatchState;
67
80
 
@@ -96,9 +109,15 @@ export class Router {
96
109
  );
97
110
  }
98
111
  const normalized = normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict);
112
+ const depthBefore = this.state.maxDepth;
99
113
  if (addRouteImpl(method, normalized, entries, middleware, this.state, recordIntrospection)) {
100
114
  this.hasParamRoutes = true;
101
115
  }
116
+ // Rebuild the pool only when maxDepth actually grew (F-02) — a cheap,
117
+ // registration-time-only check; never runs per-request.
118
+ if (this.state.maxDepth > depthBefore) {
119
+ this.state.walkPool = createWalkPool(this.state.maxDepth);
120
+ }
102
121
  }
103
122
 
104
123
  get(path: string, ...entries: RouteEntry[]): this {
@@ -215,6 +234,50 @@ export class Router {
215
234
  return resolveMatch(this.state, this.hasParamRoutes, method, path);
216
235
  }
217
236
 
237
+ /**
238
+ * Test whether `path` falls under `prefix` using this router's OWN
239
+ * canonicalization (case folding per `caseSensitive`, structural
240
+ * normalization) — the mount-boundary counterpart to {@link match}, so a
241
+ * router mounted via `Application.route()` is tested with the identical
242
+ * rule it dispatches with (RFC-029, task 3.8). Implements the optional
243
+ * `Routable.matchesMountPrefix` contract from `@nextrush/core`.
244
+ *
245
+ * @param path - The full request path being tested for this mount.
246
+ * @param prefix - The normalized mount prefix (leading `/`, no trailing `/`).
247
+ * @returns The path's remainder past the prefix (e.g. `/users` for a
248
+ * `/ADMIN/Users` request mounted at `/admin`), or `undefined` when `path`
249
+ * is not under `prefix` per this router's canonicalization.
250
+ */
251
+ matchesMountPrefix(path: string, prefix: string): string | undefined {
252
+ const canonical = canonicalizePath(path, this.opts.caseSensitive, this.opts.strict);
253
+ if (canonical.rejected) return undefined;
254
+
255
+ // The prefix is fixed at registration time, so canonicalizing it on every
256
+ // request is pure waste — it was ~150 ns of the ~557 ns each mounted router
257
+ // added to dispatch. Memoized rather than precomputed at `route()` time so
258
+ // the `Routable.matchesMountPrefix` contract still accepts any prefix
259
+ // string from any caller. One entry is enough: a router is normally mounted
260
+ // once, and a second prefix only costs a miss.
261
+ let canonicalPrefix: string;
262
+ if (this._prefixMemoRaw === prefix) {
263
+ canonicalPrefix = this._prefixMemoCanonical;
264
+ } else {
265
+ canonicalPrefix = canonicalizePath(prefix, this.opts.caseSensitive, this.opts.strict).path;
266
+ this._prefixMemoRaw = prefix;
267
+ this._prefixMemoCanonical = canonicalPrefix;
268
+ }
269
+
270
+ const prefixLen = canonicalPrefix.length;
271
+ if (!canonical.path.startsWith(canonicalPrefix)) return undefined;
272
+
273
+ const hasCharAfterPrefix = prefixLen < canonical.path.length;
274
+ if (hasCharAfterPrefix && canonical.path.charCodeAt(prefixLen) !== SLASH_CHAR_CODE) {
275
+ return undefined;
276
+ }
277
+
278
+ return canonical.path.slice(prefixLen) || '/';
279
+ }
280
+
218
281
  /**
219
282
  * Return the router's dispatch middleware — mount this on the application.
220
283
  * @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#routerroutes | README: router.routes()}
@@ -226,7 +289,17 @@ export class Router {
226
289
  this._sealed = true;
227
290
  sealRouterMiddlewareImpl(this.root, this.staticRoutes, this.routerMiddleware);
228
291
  }
229
- return createRoutesMiddleware((method, path) => this.match(method, path));
292
+ // F-10: the internal closure calls `resolveMatch` directly with
293
+ // `preNormalized: true` rather than going through the public `this.match()`
294
+ // — this is the one call path where the caller (`createRoutesMiddleware`)
295
+ // has already run `canonicalizePath()` on `path`, so `matchRoute`'s own
296
+ // fold+collapse re-derivation is skippable. `Router.match()` itself is
297
+ // untouched and still defaults to `false` for every other caller.
298
+ return createRoutesMiddleware(
299
+ (method, path) => resolveMatch(this.state, this.hasParamRoutes, method, path, true),
300
+ this.opts.caseSensitive,
301
+ this.opts.strict
302
+ );
230
303
  }
231
304
 
232
305
  /**
@@ -263,6 +336,11 @@ export class Router {
263
336
  // Clear the introspection registry too, or getRoutes()/OpenAPI would emit
264
337
  // ghost routes after a reset (audit RT-1).
265
338
  this.routeDefinitions.length = 0;
339
+ // Reset the walk-frame pool sizing too (F-02) — otherwise maxDepth would
340
+ // keep reporting a since-cleared route's depth, and the pool would stay
341
+ // needlessly oversized for whatever gets registered next.
342
+ this.state.maxDepth = 0;
343
+ this.state.walkPool = undefined;
266
344
  this._sealed = false;
267
345
  }
268
346
 
@@ -51,6 +51,14 @@ export interface HandlerEntry {
51
51
  middleware: Middleware[];
52
52
  /** Pre-compiled executor for fast dispatch (no closure per request) */
53
53
  executor?: (ctx: Context) => Promise<void>;
54
+ /**
55
+ * `true` only for a `HEAD` entry derived from a `GET` registration
56
+ * (RFC 9110 §9.3.2). A derived entry is replaced by an explicit `HEAD`
57
+ * registration instead of reporting a route conflict, is absent from
58
+ * `getRoutes()`, and is skipped when copying routes into a parent router —
59
+ * which re-derives it from the `GET` it copies.
60
+ */
61
+ autoHead: boolean;
54
62
  }
55
63
 
56
64
  /**
package/src/state.ts CHANGED
@@ -48,5 +48,6 @@ export function createRouterState(
48
48
  strict: opts.strict,
49
49
  decode: opts.decode,
50
50
  routerMiddleware,
51
+ maxDepth: 0,
51
52
  };
52
53
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * @nextrush/router - Reused walk-frame pool (F-02, `reduce-router-match-allocations`)
3
+ *
4
+ * Split out of `matching.ts` once the pool implementation pushed it over the
5
+ * 300-line file cap — same reasoning `match-route.ts`'s own extraction used.
6
+ * Holds the pooled scratch structure and its indexed-cursor variant of the
7
+ * tree walk; `matching.ts`'s `matchNodeIndexed` delegates here when a pool is
8
+ * supplied and keeps its own fresh-allocation behavior otherwise.
9
+ *
10
+ * @packageDocumentation
11
+ * @internal
12
+ */
13
+
14
+ import type { HttpMethod } from '@nextrush/types';
15
+ import type { HandlerEntry, TrieNode } from './segment-trie';
16
+ import { decodeParam, segmentAt } from './matching';
17
+
18
+ /**
19
+ * One node in the iterative walk's explicit stack. `stage` is a small state
20
+ * machine (0 = extract + try static, 1 = try param, 2 = try wildcard/backtrack)
21
+ * so a single frame can be revisited on backtrack without recursion. `bound`
22
+ * records whether this frame pushed a deferred param binding, so backtracking
23
+ * can pop it without an object-property delete.
24
+ */
25
+ export interface WalkFrame {
26
+ node: TrieNode;
27
+ pos: number;
28
+ stage: 0 | 1 | 2;
29
+ seg: string;
30
+ next: number;
31
+ bound: boolean;
32
+ }
33
+
34
+ /**
35
+ * Per-router-instance reused scratch space for the tree walk — the
36
+ * `WalkFrame[]` stack and the `bindNames`/`bindValues` binding arrays,
37
+ * pre-sized to the deepest currently registered route
38
+ * (`RegistrationState.maxDepth`) instead of allocated fresh on every
39
+ * `matchNodeIndexed` call.
40
+ *
41
+ * Safe under the router's synchronous-walk invariant only: a request path
42
+ * can never make the walk descend past `maxDepth` (a mismatched segment
43
+ * backtracks — the depth cursor decrements — rather than growing past the
44
+ * pool), and the walk never awaits mid-frame, so no two in-flight matches on
45
+ * the same router instance can observe each other's frames. See the
46
+ * `router` capability's "Reused internal walk state is never shared across
47
+ * concurrent in-flight matches" requirement.
48
+ */
49
+ export interface WalkPool {
50
+ frames: WalkFrame[];
51
+ bindNames: string[];
52
+ bindValues: string[];
53
+ }
54
+
55
+ /**
56
+ * Build a `WalkPool` sized to hold `maxDepth` matched segments. The walk's
57
+ * frame at index 0 always represents the trie ROOT itself (mirroring the
58
+ * unpooled walk's `stack[0] = { node: root, ... }`), so a route with
59
+ * `maxDepth` segments needs `maxDepth + 1` frames — one for the root plus one
60
+ * per descended segment. Called once when a router's `maxDepth` grows
61
+ * (registration time), never per-request.
62
+ */
63
+ export function createWalkPool(maxDepth: number): WalkPool {
64
+ const frames: WalkFrame[] = [];
65
+ for (let i = 0; i < maxDepth + 1; i++) {
66
+ frames.push({
67
+ node: undefined as unknown as TrieNode,
68
+ pos: 0,
69
+ stage: 0,
70
+ seg: '',
71
+ next: 0,
72
+ bound: false,
73
+ });
74
+ }
75
+ return { frames, bindNames: [], bindValues: [] };
76
+ }
77
+
78
+ /**
79
+ * Pooled variant of `matchNodeIndexed` — same stage-machine, same precedence,
80
+ * same backtrack semantics, byte-identical results — but indexes into
81
+ * `pool.frames` by depth (mutating each pre-allocated frame in place) instead
82
+ * of `push`/`pop`-ing fresh frame objects onto a fresh array. `depth` is a
83
+ * local cursor, never shared across calls; only the underlying frame OBJECTS
84
+ * are reused. Safe only because the walk never awaits mid-frame and a
85
+ * mismatched segment decrements `depth` (backtrack) rather than growing past
86
+ * `pool.frames.length` (bounded by the router's `maxDepth` at registration
87
+ * time — see {@link createWalkPool}).
88
+ */
89
+ export function matchNodeIndexedPooled(
90
+ root: TrieNode,
91
+ path: string,
92
+ startPos: number,
93
+ bindNames: string[],
94
+ bindValues: string[],
95
+ method: HttpMethod,
96
+ decode: boolean,
97
+ pool: WalkPool,
98
+ originalPath?: string
99
+ ): HandlerEntry | null {
100
+ const { frames } = pool;
101
+ let depth = 0;
102
+ const first = frames[0];
103
+ if (first === undefined) return null; // maxDepth 0 — no param routes registered, nothing to walk
104
+ first.node = root;
105
+ first.pos = startPos;
106
+ first.stage = 0;
107
+ first.seg = '';
108
+ first.next = 0;
109
+ first.bound = false;
110
+
111
+ while (depth >= 0) {
112
+ const frame = frames[depth];
113
+ if (frame === undefined) break;
114
+
115
+ if (frame.stage === 0) {
116
+ if (frame.pos >= path.length) {
117
+ const handler = frame.node.handlers.get(method);
118
+ if (handler) return handler;
119
+ depth--;
120
+ continue;
121
+ }
122
+ const slashPos = path.indexOf('/', frame.pos);
123
+ if (slashPos === -1) {
124
+ frame.seg = path.slice(frame.pos);
125
+ frame.next = path.length;
126
+ } else {
127
+ frame.seg = path.slice(frame.pos, slashPos);
128
+ frame.next = slashPos + 1;
129
+ }
130
+ if (frame.seg === '') {
131
+ const handler = frame.node.handlers.get(method);
132
+ if (handler) return handler;
133
+ depth--;
134
+ continue;
135
+ }
136
+ frame.stage = 1;
137
+ const staticChild = frame.node.children.get(frame.seg);
138
+ if (staticChild) {
139
+ depth++;
140
+ const next = frames[depth];
141
+ if (next === undefined) {
142
+ // Should be unreachable — pool sized to maxDepth — but fail closed
143
+ // (treat as a miss) rather than write past the pooled array.
144
+ depth--;
145
+ continue;
146
+ }
147
+ next.node = staticChild;
148
+ next.pos = frame.next;
149
+ next.stage = 0;
150
+ next.seg = '';
151
+ next.next = 0;
152
+ next.bound = false;
153
+ }
154
+ continue;
155
+ }
156
+
157
+ if (frame.stage === 1) {
158
+ frame.stage = 2;
159
+ const paramChild = frame.node.paramChild;
160
+ if (paramChild) {
161
+ const paramName = paramChild.paramName;
162
+ if (paramName === undefined) return null;
163
+ const value =
164
+ originalPath !== undefined
165
+ ? decodeParam(segmentAt(originalPath, frame.pos), decode)
166
+ : decodeParam(frame.seg, decode);
167
+ bindNames.push(paramName);
168
+ bindValues.push(value);
169
+ frame.bound = true;
170
+ depth++;
171
+ const next = frames[depth];
172
+ if (next === undefined) {
173
+ bindNames.pop();
174
+ bindValues.pop();
175
+ frame.bound = false;
176
+ depth--;
177
+ continue;
178
+ }
179
+ next.node = paramChild;
180
+ next.pos = frame.next;
181
+ next.stage = 0;
182
+ next.seg = '';
183
+ next.next = 0;
184
+ next.bound = false;
185
+ }
186
+ continue;
187
+ }
188
+
189
+ if (frame.bound) {
190
+ bindNames.pop();
191
+ bindValues.pop();
192
+ frame.bound = false;
193
+ }
194
+ const wildcardChild = frame.node.wildcardChild;
195
+ if (wildcardChild) {
196
+ const src = originalPath ?? path;
197
+ bindNames.push('*');
198
+ bindValues.push(decodeParam(src.slice(frame.pos), decode));
199
+ const handler = wildcardChild.handlers.get(method);
200
+ if (handler) return handler;
201
+ bindNames.pop();
202
+ bindValues.pop();
203
+ }
204
+ depth--;
205
+ }
206
+
207
+ return null;
208
+ }