@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
package/dist/index.d.ts CHANGED
@@ -106,6 +106,13 @@ declare class Router {
106
106
  private hasParamRoutes;
107
107
  /** Whether router-level middleware has already been sealed into executors (audit RT-7) */
108
108
  private _sealed;
109
+ /**
110
+ * Single-entry memo for {@link matchesMountPrefix}'s canonicalization of the
111
+ * mount prefix, which is fixed at registration time. Declared as two fields
112
+ * rather than an object so a miss stores without allocating.
113
+ */
114
+ private _prefixMemoRaw;
115
+ private _prefixMemoCanonical;
109
116
  /** Memoized state the extracted registration/matching functions read (see {@link createRouterState}). */
110
117
  private readonly state;
111
118
  constructor(options?: RouterOptions);
@@ -149,6 +156,21 @@ declare class Router {
149
156
  private mountRouter;
150
157
  /** Match a request to a route — delegates to {@link resolveMatch} (design.md D1). */
151
158
  match(method: HttpMethod, path: string): RouteMatch | null;
159
+ /**
160
+ * Test whether `path` falls under `prefix` using this router's OWN
161
+ * canonicalization (case folding per `caseSensitive`, structural
162
+ * normalization) — the mount-boundary counterpart to {@link match}, so a
163
+ * router mounted via `Application.route()` is tested with the identical
164
+ * rule it dispatches with (RFC-029, task 3.8). Implements the optional
165
+ * `Routable.matchesMountPrefix` contract from `@nextrush/core`.
166
+ *
167
+ * @param path - The full request path being tested for this mount.
168
+ * @param prefix - The normalized mount prefix (leading `/`, no trailing `/`).
169
+ * @returns The path's remainder past the prefix (e.g. `/users` for a
170
+ * `/ADMIN/Users` request mounted at `/admin`), or `undefined` when `path`
171
+ * is not under `prefix` per this router's canonicalization.
172
+ */
173
+ matchesMountPrefix(path: string, prefix: string): string | undefined;
152
174
  /**
153
175
  * Return the router's dispatch middleware — mount this on the application.
154
176
  * @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#routerroutes | README: router.routes()}
@@ -184,6 +206,68 @@ declare class Router {
184
206
  */
185
207
  declare function createRouter(options?: RouterOptions): Router;
186
208
 
209
+ /**
210
+ * @nextrush/router - Canonical Request Path
211
+ *
212
+ * The single normalization owner (RFC-029): every consumer that needs to know
213
+ * "what path does the router treat this request as" — the router's own match,
214
+ * a mounted-router prefix test, a CSRF exclude-path match, an adapter's
215
+ * published `ctx.path` — calls {@link canonicalizePath} instead of hand-rolling
216
+ * its own fold/collapse/strip, so there is exactly one definition of "the same
217
+ * path" across the framework (SEC-02, SEC-09, SEC-15).
218
+ *
219
+ * @see docs/RFC/request-data/029-canonical-request-path.md
220
+ * @packageDocumentation
221
+ */
222
+ /** Result of {@link canonicalizePath}. */
223
+ interface CanonicalPathResult {
224
+ /**
225
+ * `true` when the path contains a `.`/`..` path segment. A dot segment is
226
+ * rejected outright (400), never resolved — resolving it locally would
227
+ * diverge from how a front-end proxy already resolved (or didn't resolve)
228
+ * the same segment before forwarding, reopening the desync this RFC closes.
229
+ */
230
+ readonly rejected: boolean;
231
+ /**
232
+ * The canonical path: query-stripped, dot-segment-free, case-folded (unless
233
+ * `caseSensitive`), slash-collapsed, trailing-slash-stripped per `strict`.
234
+ * Meaningless when {@link rejected} is `true`.
235
+ */
236
+ readonly path: string;
237
+ }
238
+ /**
239
+ * True when `path` (already query-stripped) contains a `.` or `..` segment —
240
+ * a component that is exactly `.` or `..` between slash boundaries (or at the
241
+ * start/end of the string). A single linear scan, no backtracking regex
242
+ * (task 3.5): each character is visited at most once, so a pathological input
243
+ * cannot degrade to worse than O(n).
244
+ *
245
+ * A dot as filename content (`archive.tar.gz`, `..hidden.txt`) is NOT a dot
246
+ * segment — only a component whose entire text between slashes is `.` or `..`
247
+ * counts.
248
+ */
249
+ declare function hasDotSegment(path: string): boolean;
250
+ /**
251
+ * Canonicalize a raw request target into the one path value every consumer
252
+ * (router match, mount-prefix test, CSRF exclude match, published `ctx.path`)
253
+ * treats as "this request's path" (RFC-029).
254
+ *
255
+ * Order: strip the query string, reject a dot segment before any further
256
+ * normalization (a dot segment is a request-shape violation, not something to
257
+ * fold case on first), then fold case (unless `caseSensitive`) and collapse
258
+ * structure exactly as {@link import('./matching').normalizePathForMatch} does
259
+ * — this function IS that normalization, extended with dot-segment rejection
260
+ * and made a public, adapter-facing entry point.
261
+ *
262
+ * The most recent result is memoized; see the note above the memo fields for
263
+ * why that is sound. Treat the returned object as immutable.
264
+ *
265
+ * @param rawTarget - The raw request target, may include a query string.
266
+ * @param caseSensitive - Router case-sensitivity option.
267
+ * @param strict - Router strict-trailing-slash option.
268
+ */
269
+ declare function canonicalizePath(rawTarget: string, caseSensitive: boolean, strict: boolean): CanonicalPathResult;
270
+
187
271
  /**
188
272
  * @nextrush/router - Segment Trie Node
189
273
  *
@@ -233,6 +317,14 @@ interface HandlerEntry {
233
317
  middleware: Middleware[];
234
318
  /** Pre-compiled executor for fast dispatch (no closure per request) */
235
319
  executor?: (ctx: Context) => Promise<void>;
320
+ /**
321
+ * `true` only for a `HEAD` entry derived from a `GET` registration
322
+ * (RFC 9110 §9.3.2). A derived entry is replaced by an explicit `HEAD`
323
+ * registration instead of reporting a route conflict, is absent from
324
+ * `getRoutes()`, and is skipped when copying routes into a parent router —
325
+ * which re-derives it from the `GET` it copies.
326
+ */
327
+ autoHead: boolean;
236
328
  }
237
329
  /**
238
330
  * Create a new segment trie node
@@ -255,4 +347,4 @@ interface ParsedSegment {
255
347
  paramName?: string;
256
348
  }
257
349
 
258
- export { type HandlerEntry, NodeType, type ParsedSegment, type RouteGroup, Router, type TrieNode, createNode, createRouter, endpoint, parseSegments };
350
+ export { type CanonicalPathResult, type HandlerEntry, NodeType, type ParsedSegment, type RouteGroup, Router, type TrieNode, canonicalizePath, createNode, createRouter, endpoint, hasDotSegment, parseSegments };
package/dist/index.js CHANGED
@@ -271,7 +271,130 @@ function runRouteGroup(host, prefix, middlewareOrCallback, callback, inheritedMi
271
271
  __name(runRouteGroup, "runRouteGroup");
272
272
 
273
273
  // src/constants.ts
274
- var EMPTY_PARAMS = Object.freeze(/* @__PURE__ */ Object.create(null));
274
+ var NULL_PROTO = /* @__PURE__ */ Object.create(null);
275
+ var EMPTY_PARAMS = Object.freeze(Object.create(NULL_PROTO));
276
+
277
+ // src/walk-pool.ts
278
+ function createWalkPool(maxDepth) {
279
+ const frames = [];
280
+ for (let i = 0; i < maxDepth + 1; i++) {
281
+ frames.push({
282
+ node: void 0,
283
+ pos: 0,
284
+ stage: 0,
285
+ seg: "",
286
+ next: 0,
287
+ bound: false
288
+ });
289
+ }
290
+ return {
291
+ frames,
292
+ bindNames: [],
293
+ bindValues: []
294
+ };
295
+ }
296
+ __name(createWalkPool, "createWalkPool");
297
+ function matchNodeIndexedPooled(root, path, startPos, bindNames, bindValues, method, decode, pool, originalPath) {
298
+ const { frames } = pool;
299
+ let depth = 0;
300
+ const first = frames[0];
301
+ if (first === void 0) return null;
302
+ first.node = root;
303
+ first.pos = startPos;
304
+ first.stage = 0;
305
+ first.seg = "";
306
+ first.next = 0;
307
+ first.bound = false;
308
+ while (depth >= 0) {
309
+ const frame = frames[depth];
310
+ if (frame === void 0) break;
311
+ if (frame.stage === 0) {
312
+ if (frame.pos >= path.length) {
313
+ const handler = frame.node.handlers.get(method);
314
+ if (handler) return handler;
315
+ depth--;
316
+ continue;
317
+ }
318
+ const slashPos = path.indexOf("/", frame.pos);
319
+ if (slashPos === -1) {
320
+ frame.seg = path.slice(frame.pos);
321
+ frame.next = path.length;
322
+ } else {
323
+ frame.seg = path.slice(frame.pos, slashPos);
324
+ frame.next = slashPos + 1;
325
+ }
326
+ if (frame.seg === "") {
327
+ const handler = frame.node.handlers.get(method);
328
+ if (handler) return handler;
329
+ depth--;
330
+ continue;
331
+ }
332
+ frame.stage = 1;
333
+ const staticChild = frame.node.children.get(frame.seg);
334
+ if (staticChild) {
335
+ depth++;
336
+ const next = frames[depth];
337
+ if (next === void 0) {
338
+ depth--;
339
+ continue;
340
+ }
341
+ next.node = staticChild;
342
+ next.pos = frame.next;
343
+ next.stage = 0;
344
+ next.seg = "";
345
+ next.next = 0;
346
+ next.bound = false;
347
+ }
348
+ continue;
349
+ }
350
+ if (frame.stage === 1) {
351
+ frame.stage = 2;
352
+ const paramChild = frame.node.paramChild;
353
+ if (paramChild) {
354
+ const paramName = paramChild.paramName;
355
+ if (paramName === void 0) return null;
356
+ const value = originalPath !== void 0 ? decodeParam(segmentAt(originalPath, frame.pos), decode) : decodeParam(frame.seg, decode);
357
+ bindNames.push(paramName);
358
+ bindValues.push(value);
359
+ frame.bound = true;
360
+ depth++;
361
+ const next = frames[depth];
362
+ if (next === void 0) {
363
+ bindNames.pop();
364
+ bindValues.pop();
365
+ frame.bound = false;
366
+ depth--;
367
+ continue;
368
+ }
369
+ next.node = paramChild;
370
+ next.pos = frame.next;
371
+ next.stage = 0;
372
+ next.seg = "";
373
+ next.next = 0;
374
+ next.bound = false;
375
+ }
376
+ continue;
377
+ }
378
+ if (frame.bound) {
379
+ bindNames.pop();
380
+ bindValues.pop();
381
+ frame.bound = false;
382
+ }
383
+ const wildcardChild = frame.node.wildcardChild;
384
+ if (wildcardChild) {
385
+ const src = originalPath ?? path;
386
+ bindNames.push("*");
387
+ bindValues.push(decodeParam(src.slice(frame.pos), decode));
388
+ const handler = wildcardChild.handlers.get(method);
389
+ if (handler) return handler;
390
+ bindNames.pop();
391
+ bindValues.pop();
392
+ }
393
+ depth--;
394
+ }
395
+ return null;
396
+ }
397
+ __name(matchNodeIndexedPooled, "matchNodeIndexedPooled");
275
398
 
276
399
  // src/matching.ts
277
400
  function decodeParam(value, decode) {
@@ -312,7 +435,10 @@ function normalizePathForMatch(path, caseSensitive, strict) {
312
435
  return collapseAndStrip(folded, strict);
313
436
  }
314
437
  __name(normalizePathForMatch, "normalizePathForMatch");
315
- function matchNodeIndexed(root, path, startPos, bindNames, bindValues, method, decode, originalPath) {
438
+ function matchNodeIndexed(root, path, startPos, bindNames, bindValues, method, decode, originalPath, pool) {
439
+ if (pool) {
440
+ return matchNodeIndexedPooled(root, path, startPos, bindNames, bindValues, method, decode, pool, originalPath);
441
+ }
316
442
  const stack = [
317
443
  {
318
444
  node: root,
@@ -404,13 +530,22 @@ function matchNodeIndexed(root, path, startPos, bindNames, bindValues, method, d
404
530
  __name(matchNodeIndexed, "matchNodeIndexed");
405
531
 
406
532
  // src/match-route.ts
407
- function matchRoute(method, rawPath, root, staticRoutes, hasParamRoutes, caseSensitive, strict, decode, routerMiddleware) {
533
+ function matchRoute(method, rawPath, root, staticRoutes, hasParamRoutes, caseSensitive, strict, decode, routerMiddleware, preNormalized = false, walkPool) {
408
534
  let path = rawPath;
409
- const queryIdx = path.indexOf("?");
410
- if (queryIdx !== -1) path = path.slice(0, queryIdx);
411
- const caseStable = caseSensitive || isProvablyLowerAscii(path);
412
- const folded = caseStable ? path : path.toLowerCase();
413
- const normalized = collapseAndStrip(folded, strict);
535
+ if (!preNormalized) {
536
+ const queryIdx = path.indexOf("?");
537
+ if (queryIdx !== -1) path = path.slice(0, queryIdx);
538
+ }
539
+ let normalized;
540
+ let caseStable;
541
+ if (preNormalized) {
542
+ normalized = path;
543
+ caseStable = true;
544
+ } else {
545
+ caseStable = caseSensitive || isProvablyLowerAscii(path);
546
+ const folded = caseStable ? path : path.toLowerCase();
547
+ normalized = collapseAndStrip(folded, strict);
548
+ }
414
549
  const methodMap = staticRoutes.get(method);
415
550
  if (methodMap) {
416
551
  const staticKey = normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
@@ -426,16 +561,20 @@ function matchRoute(method, rawPath, root, staticRoutes, hasParamRoutes, caseSen
426
561
  }
427
562
  if (!hasParamRoutes) return null;
428
563
  const originalPath = caseStable ? void 0 : collapseAndStrip(path, strict);
429
- const bindNames = [];
430
- const bindValues = [];
431
- const entry = matchNodeIndexed(root, normalized, 1, bindNames, bindValues, method, decode, originalPath);
564
+ const bindNames = walkPool ? walkPool.bindNames : [];
565
+ const bindValues = walkPool ? walkPool.bindValues : [];
566
+ if (walkPool) {
567
+ bindNames.length = 0;
568
+ bindValues.length = 0;
569
+ }
570
+ const entry = matchNodeIndexed(root, normalized, 1, bindNames, bindValues, method, decode, originalPath, walkPool);
432
571
  if (!entry) return null;
433
572
  const count = bindNames.length;
434
573
  let params;
435
574
  if (count === 0) {
436
575
  params = EMPTY_PARAMS;
437
576
  } else {
438
- params = /* @__PURE__ */ Object.create(null);
577
+ params = Object.create(NULL_PROTO);
439
578
  for (let i = 0; i < count; i++) {
440
579
  const name = bindNames[i];
441
580
  const value = bindValues[i];
@@ -450,14 +589,15 @@ function matchRoute(method, rawPath, root, staticRoutes, hasParamRoutes, caseSen
450
589
  };
451
590
  }
452
591
  __name(matchRoute, "matchRoute");
453
- function resolveMatch(state, hasParamRoutes, method, path) {
454
- return matchRoute(method, path, state.root, state.staticRoutes, hasParamRoutes, state.caseSensitive, state.strict, state.decode, state.routerMiddleware);
592
+ function resolveMatch(state, hasParamRoutes, method, path, preNormalized = false) {
593
+ return matchRoute(method, path, state.root, state.staticRoutes, hasParamRoutes, state.caseSensitive, state.strict, state.decode, state.routerMiddleware, preNormalized, state.walkPool);
455
594
  }
456
595
  __name(resolveMatch, "resolveMatch");
457
596
 
458
597
  // src/composition.ts
459
598
  function copyRoutes(node, prefix, segments, subRouterMiddleware, addRoute2) {
460
599
  for (const [method, entry] of node.handlers) {
600
+ if (entry.autoHead) continue;
461
601
  const path = prefix + "/" + segments.join("/");
462
602
  const combined = subRouterMiddleware.length > 0 ? [
463
603
  ...subRouterMiddleware,
@@ -575,8 +715,20 @@ function normalizeRegistrationPath(path, prefix, strict) {
575
715
  return normalized.startsWith("/") ? normalized : "/" + normalized;
576
716
  }
577
717
  __name(normalizeRegistrationPath, "normalizeRegistrationPath");
718
+ function setStaticEntry(staticRoutes, method, key, entry) {
719
+ let methodMap = staticRoutes.get(method);
720
+ if (!methodMap) {
721
+ methodMap = /* @__PURE__ */ new Map();
722
+ staticRoutes.set(method, methodMap);
723
+ }
724
+ methodMap.set(key, entry);
725
+ }
726
+ __name(setStaticEntry, "setStaticEntry");
578
727
  function addRoute(method, normalized, entries, middleware, state, recordIntrospection = true) {
579
728
  const segments = parseSegments(normalized, state.caseSensitive);
729
+ if (segments.length > state.maxDepth) {
730
+ state.maxDepth = segments.length;
731
+ }
580
732
  let node = state.root;
581
733
  for (const seg of segments) {
582
734
  if (seg.type === NodeType.PARAM) {
@@ -623,21 +775,30 @@ function addRoute(method, normalized, entries, middleware, state, recordIntrospe
623
775
  const handlerEntry = {
624
776
  handler: finalHandler,
625
777
  middleware: combinedMiddleware,
626
- executor
778
+ executor,
779
+ autoHead: false
627
780
  };
628
- if (node.handlers.has(method)) {
781
+ const existing = node.handlers.get(method);
782
+ if (existing && !(method === "HEAD" && existing.autoHead)) {
629
783
  throw new Error(`Route conflict: ${method} ${normalized} is already registered. Remove the duplicate or use a different path.`);
630
784
  }
631
785
  node.handlers.set(method, handlerEntry);
632
786
  const hasParams = segments.some((s) => s.type === NodeType.PARAM || s.type === NodeType.WILDCARD);
633
- if (!hasParams) {
634
- const normalizedKey = state.caseSensitive ? normalized : normalized.toLowerCase();
635
- let methodMap = state.staticRoutes.get(method);
636
- if (!methodMap) {
637
- methodMap = /* @__PURE__ */ new Map();
638
- state.staticRoutes.set(method, methodMap);
787
+ const staticKey = hasParams ? void 0 : state.caseSensitive ? normalized : normalized.toLowerCase();
788
+ if (staticKey !== void 0) {
789
+ setStaticEntry(state.staticRoutes, method, staticKey, handlerEntry);
790
+ }
791
+ if (method === "GET" && !node.handlers.has("HEAD")) {
792
+ const derived = {
793
+ handler: finalHandler,
794
+ middleware: combinedMiddleware,
795
+ executor,
796
+ autoHead: true
797
+ };
798
+ node.handlers.set("HEAD", derived);
799
+ if (staticKey !== void 0) {
800
+ setStaticEntry(state.staticRoutes, "HEAD", staticKey, derived);
639
801
  }
640
- methodMap.set(normalizedKey, handlerEntry);
641
802
  }
642
803
  if (recordIntrospection) {
643
804
  state.routeDefinitions.push({
@@ -747,11 +908,67 @@ function findAllowedMethods(path, root, caseSensitive, strict) {
747
908
  }
748
909
  __name(findAllowedMethods, "findAllowedMethods");
749
910
 
911
+ // src/canonicalize.ts
912
+ var DOT = 46;
913
+ var SLASH = 47;
914
+ function hasDotSegment(path) {
915
+ const len = path.length;
916
+ let segStart = 0;
917
+ for (let i = 0; i <= len; i++) {
918
+ const atBoundary = i === len || path.charCodeAt(i) === SLASH;
919
+ if (!atBoundary) continue;
920
+ const segLen = i - segStart;
921
+ if (segLen === 1 && path.charCodeAt(segStart) === DOT) return true;
922
+ if (segLen === 2 && path.charCodeAt(segStart) === DOT && path.charCodeAt(segStart + 1) === DOT) {
923
+ return true;
924
+ }
925
+ segStart = i + 1;
926
+ }
927
+ return false;
928
+ }
929
+ __name(hasDotSegment, "hasDotSegment");
930
+ var memoTarget = void 0;
931
+ var memoCaseSensitive = false;
932
+ var memoStrict = false;
933
+ var memoResult = {
934
+ rejected: false,
935
+ path: "/"
936
+ };
937
+ function canonicalizePath(rawTarget, caseSensitive, strict) {
938
+ if (rawTarget === memoTarget && caseSensitive === memoCaseSensitive && strict === memoStrict) {
939
+ return memoResult;
940
+ }
941
+ const queryIdx = rawTarget.indexOf("?");
942
+ const path = queryIdx === -1 ? rawTarget : rawTarget.slice(0, queryIdx);
943
+ const result = hasDotSegment(path) ? {
944
+ rejected: true,
945
+ path
946
+ } : {
947
+ rejected: false,
948
+ path: collapseAndStrip(caseSensitive || isProvablyLowerAscii(path) ? path : path.toLowerCase(), strict)
949
+ };
950
+ memoTarget = rawTarget;
951
+ memoCaseSensitive = caseSensitive;
952
+ memoStrict = strict;
953
+ memoResult = result;
954
+ return result;
955
+ }
956
+ __name(canonicalizePath, "canonicalizePath");
957
+
750
958
  // src/dispatch.ts
751
959
  var RESOLVED = Promise.resolve();
752
- function createRoutesMiddleware(match) {
960
+ function createRoutesMiddleware(match, caseSensitive, strict) {
753
961
  return (ctx, next) => {
754
- const routeMatch = match(ctx.method, ctx.path);
962
+ const originalPath = ctx.path;
963
+ const canonical = canonicalizePath(originalPath, caseSensitive, strict);
964
+ if (canonical.rejected) {
965
+ ctx.status = 400;
966
+ ctx.originalPath = originalPath;
967
+ return RESOLVED;
968
+ }
969
+ ctx.path = canonical.path;
970
+ ctx.originalPath = originalPath;
971
+ const routeMatch = match(ctx.method, canonical.path);
755
972
  if (!routeMatch) {
756
973
  ctx.status = 404;
757
974
  return next ? next() : RESOLVED;
@@ -803,12 +1020,14 @@ function createRouterState(root, opts, staticRoutes, routeDefinitions, routerMid
803
1020
  caseSensitive: opts.caseSensitive,
804
1021
  strict: opts.strict,
805
1022
  decode: opts.decode,
806
- routerMiddleware
1023
+ routerMiddleware,
1024
+ maxDepth: 0
807
1025
  };
808
1026
  }
809
1027
  __name(createRouterState, "createRouterState");
810
1028
 
811
1029
  // src/router.ts
1030
+ var SLASH_CHAR_CODE = 47;
812
1031
  var Router = class _Router {
813
1032
  static {
814
1033
  __name(this, "Router");
@@ -827,6 +1046,13 @@ var Router = class _Router {
827
1046
  hasParamRoutes = false;
828
1047
  /** Whether router-level middleware has already been sealed into executors (audit RT-7) */
829
1048
  _sealed = false;
1049
+ /**
1050
+ * Single-entry memo for {@link matchesMountPrefix}'s canonicalization of the
1051
+ * mount prefix, which is fixed at registration time. Declared as two fields
1052
+ * rather than an object so a miss stores without allocating.
1053
+ */
1054
+ _prefixMemoRaw = void 0;
1055
+ _prefixMemoCanonical = "";
830
1056
  /** Memoized state the extracted registration/matching functions read (see {@link createRouterState}). */
831
1057
  state;
832
1058
  constructor(options = {}) {
@@ -844,9 +1070,13 @@ var Router = class _Router {
844
1070
  throw new TypeError(`Route path must be a string, received ${rawPath === null ? "null" : typeof rawPath}.`);
845
1071
  }
846
1072
  const normalized = normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict);
1073
+ const depthBefore = this.state.maxDepth;
847
1074
  if (addRoute(method, normalized, entries, middleware, this.state, recordIntrospection)) {
848
1075
  this.hasParamRoutes = true;
849
1076
  }
1077
+ if (this.state.maxDepth > depthBefore) {
1078
+ this.state.walkPool = createWalkPool(this.state.maxDepth);
1079
+ }
850
1080
  }
851
1081
  get(path, ...entries) {
852
1082
  this.addRoute("GET", path, entries);
@@ -939,6 +1169,39 @@ var Router = class _Router {
939
1169
  return resolveMatch(this.state, this.hasParamRoutes, method, path);
940
1170
  }
941
1171
  /**
1172
+ * Test whether `path` falls under `prefix` using this router's OWN
1173
+ * canonicalization (case folding per `caseSensitive`, structural
1174
+ * normalization) — the mount-boundary counterpart to {@link match}, so a
1175
+ * router mounted via `Application.route()` is tested with the identical
1176
+ * rule it dispatches with (RFC-029, task 3.8). Implements the optional
1177
+ * `Routable.matchesMountPrefix` contract from `@nextrush/core`.
1178
+ *
1179
+ * @param path - The full request path being tested for this mount.
1180
+ * @param prefix - The normalized mount prefix (leading `/`, no trailing `/`).
1181
+ * @returns The path's remainder past the prefix (e.g. `/users` for a
1182
+ * `/ADMIN/Users` request mounted at `/admin`), or `undefined` when `path`
1183
+ * is not under `prefix` per this router's canonicalization.
1184
+ */
1185
+ matchesMountPrefix(path, prefix) {
1186
+ const canonical = canonicalizePath(path, this.opts.caseSensitive, this.opts.strict);
1187
+ if (canonical.rejected) return void 0;
1188
+ let canonicalPrefix;
1189
+ if (this._prefixMemoRaw === prefix) {
1190
+ canonicalPrefix = this._prefixMemoCanonical;
1191
+ } else {
1192
+ canonicalPrefix = canonicalizePath(prefix, this.opts.caseSensitive, this.opts.strict).path;
1193
+ this._prefixMemoRaw = prefix;
1194
+ this._prefixMemoCanonical = canonicalPrefix;
1195
+ }
1196
+ const prefixLen = canonicalPrefix.length;
1197
+ if (!canonical.path.startsWith(canonicalPrefix)) return void 0;
1198
+ const hasCharAfterPrefix = prefixLen < canonical.path.length;
1199
+ if (hasCharAfterPrefix && canonical.path.charCodeAt(prefixLen) !== SLASH_CHAR_CODE) {
1200
+ return void 0;
1201
+ }
1202
+ return canonical.path.slice(prefixLen) || "/";
1203
+ }
1204
+ /**
942
1205
  * Return the router's dispatch middleware — mount this on the application.
943
1206
  * @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#routerroutes | README: router.routes()}
944
1207
  */
@@ -947,7 +1210,7 @@ var Router = class _Router {
947
1210
  this._sealed = true;
948
1211
  sealRouterMiddleware(this.root, this.staticRoutes, this.routerMiddleware);
949
1212
  }
950
- return createRoutesMiddleware((method, path) => this.match(method, path));
1213
+ return createRoutesMiddleware((method, path) => resolveMatch(this.state, this.hasParamRoutes, method, path, true), this.opts.caseSensitive, this.opts.strict);
951
1214
  }
952
1215
  /**
953
1216
  * Generate allowed-methods middleware. Responds to OPTIONS with an `Allow`
@@ -975,6 +1238,8 @@ var Router = class _Router {
975
1238
  this.routerMiddleware.length = 0;
976
1239
  this.hasParamRoutes = false;
977
1240
  this.routeDefinitions.length = 0;
1241
+ this.state.maxDepth = 0;
1242
+ this.state.walkPool = void 0;
978
1243
  this._sealed = false;
979
1244
  }
980
1245
  /** Register a route on behalf of a {@link RouteGroup} (group context). @internal */
@@ -996,9 +1261,11 @@ __name(createRouter, "createRouter");
996
1261
  export {
997
1262
  NodeType,
998
1263
  Router,
1264
+ canonicalizePath,
999
1265
  createNode,
1000
1266
  createRouter,
1001
1267
  endpoint,
1268
+ hasDotSegment,
1002
1269
  parseSegments
1003
1270
  };
1004
1271
  //# sourceMappingURL=index.js.map