@nextrush/router 3.0.7 → 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 (51) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +227 -178
  3. package/dist/index.js +1075 -657
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -6
  6. package/src/__tests__/allowed-methods.test.ts +144 -0
  7. package/src/__tests__/audit-fixes.test.ts +59 -0
  8. package/src/__tests__/canonical-path-security.test.ts +198 -0
  9. package/src/__tests__/canonicalize-memo.test.ts +108 -0
  10. package/src/__tests__/dispatch-deasync.test.ts +292 -0
  11. package/src/__tests__/find-node-differential.test.ts +220 -0
  12. package/src/__tests__/fixtures/match-golden.json +556 -0
  13. package/src/__tests__/head-auto-registration.test.ts +197 -0
  14. package/src/__tests__/helpers/differential-corpus.ts +314 -0
  15. package/src/__tests__/match-differential.test.ts +46 -0
  16. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  17. package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
  18. package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
  19. package/src/__tests__/match-prenormalized.test.ts +95 -0
  20. package/src/__tests__/match-safety.test.ts +223 -0
  21. package/src/__tests__/match-single-alloc.test.ts +82 -0
  22. package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
  23. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  24. package/src/__tests__/param-decoding.test.ts +78 -0
  25. package/src/__tests__/public-surface.test.ts +72 -0
  26. package/src/__tests__/registration-max-depth.test.ts +56 -0
  27. package/src/__tests__/route-metadata.test.ts +172 -0
  28. package/src/__tests__/router-audit.test.ts +315 -0
  29. package/src/__tests__/router-edge-cases.test.ts +2 -7
  30. package/src/__tests__/router.test.ts +148 -7
  31. package/src/__tests__/static-map-reset.test.ts +48 -0
  32. package/src/__tests__/walk-pool-sizing.test.ts +72 -0
  33. package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
  34. package/src/canonicalize.ts +137 -0
  35. package/src/composition.ts +79 -0
  36. package/src/constants.ts +43 -0
  37. package/src/dispatch.ts +145 -0
  38. package/src/find-node.ts +125 -0
  39. package/src/group-router.ts +208 -0
  40. package/src/index.ts +14 -5
  41. package/src/match-route.ts +241 -0
  42. package/src/matching.ts +246 -0
  43. package/src/middleware-adapter.ts +59 -0
  44. package/src/redirect.ts +97 -0
  45. package/src/registration.ts +343 -0
  46. package/src/route-metadata.ts +68 -0
  47. package/src/router.ts +219 -872
  48. package/src/segment-trie.ts +227 -0
  49. package/src/state.ts +53 -0
  50. package/src/walk-pool.ts +208 -0
  51. package/src/radix-tree.ts +0 -184
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @nextrush/router - Router Composition
3
+ *
4
+ * Sub-router mounting/copying logic extracted from the `Router` class (T014,
5
+ * design.md D3 — extracted after the matching-engine split left `router.ts`
6
+ * still over the 300-line ceiling).
7
+ *
8
+ * `copyRoutes`'s tree walk is structurally pure (segments/prefix/middleware are
9
+ * all explicit parameters), but its side effect — registering the copied route —
10
+ * needs `Router.addRoute`, a private method. Rather than reach into `Router`
11
+ * internals or promote `addRoute` to `public` (a public-API change out of scope
12
+ * for this refactor), the effect is injected explicitly as an `addRoute`
13
+ * callback parameter. `use`/`mount`/`mountRouter` stay on `Router` itself:
14
+ * they return `this` for fluent chaining and read `Router` instance state
15
+ * (`root`, `routerMiddleware`) that isn't naturally expressible as function
16
+ * parameters without more ceremony than the win justifies.
17
+ *
18
+ * @packageDocumentation
19
+ * @internal
20
+ */
21
+
22
+ import type { HttpMethod, Middleware, RouteHandler } from '@nextrush/types';
23
+ import type { TrieNode } from './segment-trie';
24
+
25
+ /** Callback signature matching `Router['addRoute']` — injected, not imported. */
26
+ export type AddRouteFn = (
27
+ method: HttpMethod,
28
+ path: string,
29
+ handlers: RouteHandler[],
30
+ middleware: Middleware[]
31
+ ) => void;
32
+
33
+ /**
34
+ * Recursively copy routes from one router's trie into another via the
35
+ * supplied `addRoute` callback.
36
+ */
37
+ export function copyRoutes(
38
+ node: TrieNode,
39
+ prefix: string,
40
+ segments: string[],
41
+ subRouterMiddleware: Middleware[],
42
+ addRoute: AddRouteFn
43
+ ): void {
44
+ // Copy handlers at this node
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;
50
+ const path = prefix + '/' + segments.join('/');
51
+ // Prepend sub-router middleware so it runs before the route's own middleware
52
+ const combined =
53
+ subRouterMiddleware.length > 0
54
+ ? [...subRouterMiddleware, ...entry.middleware]
55
+ : entry.middleware;
56
+ addRoute(method, path || '/', [entry.handler], combined);
57
+ }
58
+
59
+ // Copy static children
60
+ for (const [, child] of node.children) {
61
+ copyRoutes(child, prefix, [...segments, child.segment], subRouterMiddleware, addRoute);
62
+ }
63
+
64
+ // Copy param child
65
+ if (node.paramChild) {
66
+ copyRoutes(
67
+ node.paramChild,
68
+ prefix,
69
+ [...segments, node.paramChild.segment],
70
+ subRouterMiddleware,
71
+ addRoute
72
+ );
73
+ }
74
+
75
+ // Copy wildcard child
76
+ if (node.wildcardChild) {
77
+ copyRoutes(node.wildcardChild, prefix, [...segments, '*'], subRouterMiddleware, addRoute);
78
+ }
79
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @nextrush/router - Shared internal constants
3
+ *
4
+ * A leaf module that imports nothing, so any router module can depend on it
5
+ * without risking an import cycle. This is the resolution of the former
6
+ * `EMPTY_PARAMS` duplication: the constant was previously copied across modules
7
+ * to dodge a `router.ts` <-> `match-route.ts` cycle, which a dependency-free
8
+ * leaf module makes impossible by construction.
9
+ *
10
+ * @packageDocumentation
11
+ * @internal
12
+ */
13
+
14
+ /**
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`/`*`).
34
+ *
35
+ * Shared as a single instance to avoid allocating a fresh object per request on
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.
40
+ */
41
+ export const EMPTY_PARAMS: Record<string, string> = Object.freeze(
42
+ Object.create(NULL_PROTO) as Record<string, string>
43
+ );
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @nextrush/router - Dispatch Middleware Generation
3
+ *
4
+ * The two app-facing `Middleware` factories extracted from the `Router` class
5
+ * (design.md D2 — finishing T014's split along the same seam: the composition,
6
+ * matching-engine, and sealing clusters were already extracted; this is the
7
+ * dispatch/allowed-methods generation cluster).
8
+ *
9
+ * Both closures are structurally pure — every value they read (the `match`
10
+ * function, the trie root, the case-sensitivity/strict flags) is passed in
11
+ * explicitly rather than captured off `this`, so they carry no hidden
12
+ * dependency on `Router` beyond their parameters (same principle as the
13
+ * matching-engine extraction, design.md D1). `Router.routes()` and
14
+ * `Router.allowedMethods()` stay as thin public methods that supply that
15
+ * state and return these closures.
16
+ *
17
+ * @packageDocumentation
18
+ * @internal
19
+ */
20
+
21
+ import type { Context, HttpMethod, Middleware, RouteMatch } from '@nextrush/types';
22
+ import { NOOP_NEXT, type TrieNode } from './segment-trie';
23
+ import { findAllowedMethods } from './find-node';
24
+ import { canonicalizePath } from './canonicalize';
25
+
26
+ /**
27
+ * Shared resolved promise for the no-`next` miss path (NF-1). Reused rather than
28
+ * allocating a fresh `Promise.resolve()` per miss, mirroring the router's
29
+ * existing `NOOP_NEXT`/`RESOLVED_PROMISE` sentinels.
30
+ */
31
+ const RESOLVED: Promise<void> = Promise.resolve();
32
+
33
+ /**
34
+ * Build the router's primary dispatch middleware.
35
+ *
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.
46
+ *
47
+ * @param match - Route resolver, supplied by `Router.match` so this factory
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.
52
+ */
53
+ export function createRoutesMiddleware(
54
+ match: (method: HttpMethod, path: string) => RouteMatch | null,
55
+ caseSensitive: boolean,
56
+ strict: boolean
57
+ ): Middleware {
58
+ return (ctx: Context, next?: () => Promise<void>): Promise<void> => {
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);
76
+
77
+ if (!routeMatch) {
78
+ // No route matched — set 404 so allowedMethods()/notFoundHandler() can act,
79
+ // then forward to the next middleware (the allowedMethods fall-through).
80
+ ctx.status = 404;
81
+ return next ? next() : RESOLVED;
82
+ }
83
+
84
+ ctx.params = routeMatch.params;
85
+
86
+ // NF-1: forward the executor's promise DIRECTLY instead of `await`-ing it in
87
+ // an extra `async` frame. The executor already returns a `Promise<void>`,
88
+ // converts synchronous throws to rejections, and terminates the chain at the
89
+ // handler, so ordering, rejection propagation, and the `setNext(NOOP_NEXT)`
90
+ // guard are unchanged — one state machine + one microtask hop removed. A
91
+ // synchronous throw from `match()` itself is still converted to a rejection
92
+ // by the composer's `try/catch` that wraps this middleware call.
93
+ return routeMatch.executor
94
+ ? routeMatch.executor(ctx)
95
+ : // Fallback (no pre-compiled executor — shouldn't happen): wrap so a void
96
+ // or thenable return still yields a Promise<void> and never a sync throw.
97
+ Promise.resolve(routeMatch.handler(ctx, NOOP_NEXT));
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Build the allowed-methods middleware.
103
+ *
104
+ * Runs after the dispatch middleware: if the request was a 404, it does a
105
+ * single tree walk to collect every method registered for the path. An
106
+ * `OPTIONS` request gets a `200` with an `Allow` header; any other method
107
+ * gets a `405` with `Allow`. If no method is registered for the path it
108
+ * leaves the 404 untouched.
109
+ *
110
+ * @param root - Trie root to walk for allowed methods.
111
+ * @param caseSensitive - Router case-sensitivity option.
112
+ * @param strict - Router strict-trailing-slash option.
113
+ */
114
+ export function createAllowedMethodsMiddleware(
115
+ root: TrieNode,
116
+ caseSensitive: boolean,
117
+ strict: boolean
118
+ ): Middleware {
119
+ return async (ctx: Context, next?: () => Promise<void>): Promise<void> => {
120
+ if (next) {
121
+ await next();
122
+ }
123
+
124
+ if (ctx.status !== 404) return;
125
+
126
+ // Single tree walk to find all allowed methods instead of N×match()
127
+ const allowed = findAllowedMethods(ctx.path, root, caseSensitive, strict);
128
+
129
+ if (allowed.length === 0) return;
130
+
131
+ const allowHeader = allowed.join(', ');
132
+
133
+ // If OPTIONS request, respond with allowed methods
134
+ if (ctx.method === 'OPTIONS') {
135
+ ctx.status = 200;
136
+ ctx.set('Allow', allowHeader);
137
+ ctx.body = '';
138
+ return;
139
+ }
140
+
141
+ // Otherwise, return 405 Method Not Allowed
142
+ ctx.status = 405;
143
+ ctx.set('Allow', allowHeader);
144
+ };
145
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @nextrush/router - Method-agnostic trie walk (allowed-methods / 405 path)
3
+ *
4
+ * `findNode` + `findAllowedMethods` were split out of `matching.ts` when the
5
+ * iterative rewrite of `findNode` (HP-17, OpenSpec change
6
+ * `router-context-final-cleanup`) pushed that file past the 300-line ceiling.
7
+ * They form one cohesive concern — resolving the *node* for a path regardless
8
+ * of HTTP method, to answer OPTIONS/405 — distinct from `matching.ts`'s
9
+ * method-aware handler match (`matchNodeIndexed`) and its normalization
10
+ * primitives, which are reused here via import (`segmentAt`,
11
+ * `normalizePathForMatch`).
12
+ *
13
+ * @packageDocumentation
14
+ * @internal
15
+ */
16
+
17
+ import type { HttpMethod } from '@nextrush/types';
18
+ import { normalizePathForMatch, segmentAt } from './matching';
19
+ import type { TrieNode } from './segment-trie';
20
+
21
+ /**
22
+ * One node in {@link findNode}'s explicit-stack walk. `stage` is a small state
23
+ * machine (0 = extract segment + try static, 1 = try param, 2 = try
24
+ * wildcard/backtrack) so a single frame can be revisited on backtrack without
25
+ * recursion. `next` is the start position of the following segment, captured
26
+ * once in stage 0 and reused when descending into the param branch.
27
+ */
28
+ interface FindFrame {
29
+ node: TrieNode;
30
+ pos: number;
31
+ stage: 0 | 1 | 2;
32
+ next: number;
33
+ }
34
+
35
+ /**
36
+ * Walk the trie to find the node matching a path (ignoring HTTP method), the
37
+ * method-agnostic walker used by {@link findAllowedMethods} for the 405/OPTIONS
38
+ * path (design.md D3 / HP-17).
39
+ *
40
+ * Walks with an EXPLICIT stack instead of recursion — mirroring
41
+ * `matchNodeIndexed` — so a pathological segment count cannot overflow the call
42
+ * stack (the same DoS class HP-11 closed for the match path). Behavior is
43
+ * byte-identical to the former recursive walker: precedence is static > param >
44
+ * wildcard at each node, a partially-matching branch backtracks cleanly, the
45
+ * wildcard child is a terminal (it captures the remainder), and the first
46
+ * accepted terminal wins. The scalar {@link segmentAt} scan is reused so the
47
+ * traversal shares one segment-extraction helper rather than duplicating it.
48
+ *
49
+ * `path` is the already-normalized lookup path; `startPos` skips the leading
50
+ * `/` (callers pass `1`, matching `matchNodeIndexed`).
51
+ */
52
+ export function findNode(root: TrieNode, path: string, startPos: number): TrieNode | null {
53
+ const stack: FindFrame[] = [{ node: root, pos: startPos, stage: 0, next: 0 }];
54
+
55
+ while (stack.length > 0) {
56
+ const frame = stack[stack.length - 1];
57
+ if (frame === undefined) break;
58
+
59
+ // Stage 0 — first visit: terminal check, then try the static child.
60
+ if (frame.stage === 0) {
61
+ // Whole path consumed at this node → it is the matching node.
62
+ if (frame.pos >= path.length) {
63
+ return frame.node;
64
+ }
65
+ const seg = segmentAt(path, frame.pos);
66
+ // An empty segment means the path is exhausted here (e.g. a strict-mode
67
+ // trailing slash) — treat this node as the terminal, as the recursive
68
+ // walk did after `split('/').filter(Boolean)` dropped empty segments.
69
+ if (seg === '') {
70
+ return frame.node;
71
+ }
72
+ // `segmentAt` already scanned to the next `/`; derive the following
73
+ // position from the segment length (no second indexOf) — a slash follows
74
+ // iff the segment ended before the path did.
75
+ const segEnd = frame.pos + seg.length;
76
+ frame.next = segEnd < path.length ? segEnd + 1 : path.length;
77
+ frame.stage = 1;
78
+ const staticChild = frame.node.children.get(seg);
79
+ if (staticChild) {
80
+ stack.push({ node: staticChild, pos: frame.next, stage: 0, next: 0 });
81
+ }
82
+ continue;
83
+ }
84
+
85
+ // Stage 1 — static child (if any) failed: try the param child.
86
+ if (frame.stage === 1) {
87
+ frame.stage = 2;
88
+ if (frame.node.paramChild) {
89
+ stack.push({ node: frame.node.paramChild, pos: frame.next, stage: 0, next: 0 });
90
+ }
91
+ continue;
92
+ }
93
+
94
+ // Stage 2 — static and param branches exhausted: the wildcard child is a
95
+ // terminal (matches the remainder). Otherwise this branch fails; backtrack.
96
+ if (frame.node.wildcardChild) {
97
+ return frame.node.wildcardChild;
98
+ }
99
+ stack.pop();
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Find all HTTP methods registered for a given path via a single tree walk.
107
+ * `caseSensitive`/`strict` (formerly `this.opts.*`) and `root` (formerly
108
+ * `this.root`) are threaded explicitly.
109
+ */
110
+ export function findAllowedMethods(
111
+ path: string,
112
+ root: TrieNode,
113
+ caseSensitive: boolean,
114
+ strict: boolean
115
+ ): HttpMethod[] {
116
+ const normalized = normalizePathForMatch(path, caseSensitive, strict);
117
+
118
+ // Walk from position 1 to skip the leading '/', matching `matchNodeIndexed`'s
119
+ // start offset. The iterative `findNode` scans segments off the path in place,
120
+ // so no `split('/')` array is allocated here.
121
+ const node = findNode(root, normalized, 1);
122
+ if (!node || node.handlers.size === 0) return [];
123
+
124
+ return Array.from(node.handlers.keys());
125
+ }