@nextrush/router 3.0.6 → 4.0.0-beta.0

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 (40) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +142 -185
  3. package/dist/index.js +816 -665
  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 +71 -0
  8. package/src/__tests__/dispatch-deasync.test.ts +312 -0
  9. package/src/__tests__/find-node-differential.test.ts +220 -0
  10. package/src/__tests__/fixtures/match-golden.json +556 -0
  11. package/src/__tests__/helpers/differential-corpus.ts +316 -0
  12. package/src/__tests__/match-differential.test.ts +46 -0
  13. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  14. package/src/__tests__/match-normalize-fastpath.test.ts +66 -0
  15. package/src/__tests__/match-safety.test.ts +177 -0
  16. package/src/__tests__/match-single-alloc.test.ts +84 -0
  17. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  18. package/src/__tests__/param-decoding.test.ts +78 -0
  19. package/src/__tests__/public-surface.test.ts +59 -0
  20. package/src/__tests__/route-metadata.test.ts +172 -0
  21. package/src/__tests__/router-audit.test.ts +326 -0
  22. package/src/__tests__/router-edge-cases.test.ts +2 -2
  23. package/src/__tests__/router.test.ts +148 -2
  24. package/src/__tests__/static-map-reset.test.ts +50 -0
  25. package/src/composition.ts +75 -0
  26. package/src/constants.ts +25 -0
  27. package/src/dispatch.ts +117 -0
  28. package/src/find-node.ts +125 -0
  29. package/src/group-router.ts +208 -0
  30. package/src/index.ts +8 -5
  31. package/src/match-route.ts +178 -0
  32. package/src/matching.ts +240 -0
  33. package/src/middleware-adapter.ts +59 -0
  34. package/src/redirect.ts +97 -0
  35. package/src/registration.ts +291 -0
  36. package/src/route-metadata.ts +68 -0
  37. package/src/router.ts +150 -881
  38. package/src/segment-trie.ts +219 -0
  39. package/src/state.ts +52 -0
  40. package/src/radix-tree.ts +0 -184
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @nextrush/router - Route Matching Engine
3
+ *
4
+ * The segment-trie lookup logic extracted from the `Router` class (T014,
5
+ * design.md D1). These are standalone pure functions taking the trie root and
6
+ * other needed state as explicit parameters — not methods — so the matching
7
+ * engine has no implicit dependency on broader `Router` instance state beyond
8
+ * what's passed in. `Router` becomes a thin delegating shell around these.
9
+ *
10
+ * @packageDocumentation
11
+ * @internal
12
+ */
13
+
14
+ import type { HttpMethod } from '@nextrush/types';
15
+ import type { HandlerEntry, TrieNode } from './segment-trie';
16
+
17
+ /**
18
+ * Percent-decode an extracted param/wildcard value when `decode` is enabled.
19
+ * Fast-paths values with no `%`, and falls back to the raw value on malformed
20
+ * encoding (decodeURIComponent throws a URIError) so a bad request never crashes
21
+ * routing.
22
+ */
23
+ export function decodeParam(value: string, decode: boolean): string {
24
+ if (!decode || !value.includes('%')) return value;
25
+ try {
26
+ return decodeURIComponent(value);
27
+ } catch {
28
+ return value;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Return the path segment starting at `start`, up to the next `/` (or end).
34
+ * Scalar — no tuple array (the iterative walk tracks the next position itself).
35
+ * Used to recover an original-case param value from the original-case path at a
36
+ * position already validated against the (folded) lookup path, and by the
37
+ * {@link findNode} allowed-methods walk to scan segments off the lookup path.
38
+ */
39
+ export function segmentAt(path: string, start: number): string {
40
+ const slashPos = path.indexOf('/', start);
41
+ return slashPos === -1 ? path.slice(start) : path.slice(start, slashPos);
42
+ }
43
+
44
+ /**
45
+ * True only when `path.toLowerCase()` is provably a no-op: every character is
46
+ * ASCII (`<= 0x7F`) and none is an ASCII uppercase letter (`A`–`Z`). Any
47
+ * non-ASCII byte is treated as UNCERTAIN (it could be uppercase like `Ü`), so
48
+ * this returns `false` and the caller folds — never skipping unicode case
49
+ * folding (design.md D3). This lets `normalizePathForMatch` skip the
50
+ * `toLowerCase()` allocation for the overwhelmingly common all-lowercase-ASCII
51
+ * request path while staying byte-identical to always folding.
52
+ */
53
+ export function isProvablyLowerAscii(path: string): boolean {
54
+ for (let i = 0; i < path.length; i++) {
55
+ const c = path.charCodeAt(i);
56
+ if ((c >= 0x41 && c <= 0x5a) || c > 0x7f) return false;
57
+ }
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Structural match-time normalization WITHOUT case folding: collapse repeated
63
+ * slashes and strip a single trailing slash (unless `strict`). Split out of
64
+ * {@link normalizePathForMatch} so the case-fold decision and the structural
65
+ * pass are separable — `matchRoute` reuses this to build the original-case path
66
+ * only when a fold actually happened (HP-12), rather than running the full
67
+ * normalize twice.
68
+ */
69
+ export function collapseAndStrip(path: string, strict: boolean): string {
70
+ let normalized = path;
71
+
72
+ // Fast-path: skip the regex when there are no double slashes (99%+ of requests).
73
+ if (normalized.includes('//')) {
74
+ normalized = normalized.replace(/\/+/g, '/');
75
+ }
76
+
77
+ // Non-strict mode treats a trailing slash as insignificant; strict keeps it.
78
+ if (!strict && normalized.length > 1 && normalized.endsWith('/')) {
79
+ normalized = normalized.slice(0, -1);
80
+ }
81
+
82
+ return normalized;
83
+ }
84
+
85
+ /**
86
+ * Normalize a request path for segment-trie matching: fold case (unless
87
+ * `caseSensitive`), collapse repeated slashes, and strip a single trailing
88
+ * slash (unless `strict`). This is the one definition of the match-time
89
+ * normalization rules, shared by `matchRoute` (in `match-route.ts`) and
90
+ * {@link findAllowedMethods}.
91
+ *
92
+ * The `toLowerCase()` call is skipped when the path is provably case-stable
93
+ * ({@link isProvablyLowerAscii}) — byte-identical to always folding, but with
94
+ * no throwaway string on the common all-lowercase-ASCII path (HP-12 / D3).
95
+ *
96
+ * Query-string removal is intentionally NOT done here — it is caller-specific:
97
+ * `matchRoute` strips the query before calling, while `findAllowedMethods`
98
+ * receives an already query-free `ctx.path`. Pass `caseSensitive: true` to
99
+ * normalize while preserving case (used by `matchRoute` to build the
100
+ * original-case path from which param values are extracted).
101
+ *
102
+ * NOTE: registration-time normalization (`Router.normalizePath`) is a separate
103
+ * concern — it joins the router prefix, guarantees a leading slash, and never
104
+ * folds case — so it deliberately does not share this helper.
105
+ */
106
+ export function normalizePathForMatch(
107
+ path: string,
108
+ caseSensitive: boolean,
109
+ strict: boolean
110
+ ): string {
111
+ const folded = caseSensitive || isProvablyLowerAscii(path) ? path : path.toLowerCase();
112
+ return collapseAndStrip(folded, strict);
113
+ }
114
+
115
+ /**
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.
121
+ */
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
+
131
+ /**
132
+ * Iterative, index-based segment-trie match (HP-11 / HP-13, design.md D4).
133
+ *
134
+ * Walks the trie with an EXPLICIT stack instead of recursion, so a pathological
135
+ * segment count cannot overflow the call stack (DoS safety) — behavior is
136
+ * otherwise byte-identical to the former recursive matcher: precedence is
137
+ * static > param > wildcard at each node, a partially-matching branch backtracks
138
+ * cleanly, and the first accepted terminal wins.
139
+ *
140
+ * Param/wildcard bindings are DEFERRED onto the caller-owned `bindNames` /
141
+ * `bindValues` stacks and popped on backtrack, so params are materialized ONCE
142
+ * on the accepted terminal by the caller (no eager bind + backtrack
143
+ * `Reflect.deleteProperty`, and no `Object.keys` post-loop — the caller reads
144
+ * the bind count). `decode` runs strictly on the already-split segment/remainder
145
+ * (design.md D9), so an encoded slash/dot decodes into the value only and can
146
+ * never create new path segments.
147
+ *
148
+ * `originalPath` (present only when case-folding actually occurred) supplies the
149
+ * original-case param value at the same position as the folded lookup path.
150
+ */
151
+ export function matchNodeIndexed(
152
+ root: TrieNode,
153
+ path: string,
154
+ startPos: number,
155
+ bindNames: string[],
156
+ bindValues: string[],
157
+ method: HttpMethod,
158
+ decode: boolean,
159
+ originalPath?: string
160
+ ): HandlerEntry | null {
161
+ const stack: WalkFrame[] = [
162
+ { node: root, pos: startPos, stage: 0, seg: '', next: 0, bound: false },
163
+ ];
164
+
165
+ while (stack.length > 0) {
166
+ const frame = stack[stack.length - 1];
167
+ if (frame === undefined) break;
168
+
169
+ // Stage 0 — first visit: terminal checks, then try the static child.
170
+ if (frame.stage === 0) {
171
+ if (frame.pos >= path.length) {
172
+ const handler = frame.node.handlers.get(method);
173
+ if (handler) return handler; // accepted — bind stacks hold this path's params
174
+ stack.pop();
175
+ continue;
176
+ }
177
+ const slashPos = path.indexOf('/', frame.pos);
178
+ if (slashPos === -1) {
179
+ frame.seg = path.slice(frame.pos);
180
+ frame.next = path.length;
181
+ } else {
182
+ frame.seg = path.slice(frame.pos, slashPos);
183
+ frame.next = slashPos + 1;
184
+ }
185
+ if (frame.seg === '') {
186
+ const handler = frame.node.handlers.get(method);
187
+ if (handler) return handler;
188
+ stack.pop();
189
+ continue;
190
+ }
191
+ frame.stage = 1;
192
+ const staticChild = frame.node.children.get(frame.seg);
193
+ if (staticChild) {
194
+ stack.push({ node: staticChild, pos: frame.next, stage: 0, seg: '', next: 0, bound: false });
195
+ }
196
+ continue;
197
+ }
198
+
199
+ // Stage 1 — static child (if any) has failed: try the param child, deferring
200
+ // its binding onto the shared stacks.
201
+ if (frame.stage === 1) {
202
+ frame.stage = 2;
203
+ const paramChild = frame.node.paramChild;
204
+ if (paramChild) {
205
+ const paramName = paramChild.paramName;
206
+ if (paramName === undefined) return null; // degenerate param node → whole walk fails (as before)
207
+ const value =
208
+ originalPath !== undefined
209
+ ? decodeParam(segmentAt(originalPath, frame.pos), decode)
210
+ : decodeParam(frame.seg, decode);
211
+ bindNames.push(paramName);
212
+ bindValues.push(value);
213
+ frame.bound = true;
214
+ stack.push({ node: paramChild, pos: frame.next, stage: 0, seg: '', next: 0, bound: false });
215
+ }
216
+ continue;
217
+ }
218
+
219
+ // Stage 2 — param branch (if taken) failed: undo its deferred bind, then try
220
+ // the wildcard child (a terminal — it captures the original-case remainder).
221
+ if (frame.bound) {
222
+ bindNames.pop();
223
+ bindValues.pop();
224
+ frame.bound = false;
225
+ }
226
+ const wildcardChild = frame.node.wildcardChild;
227
+ if (wildcardChild) {
228
+ const src = originalPath ?? path;
229
+ bindNames.push('*');
230
+ bindValues.push(decodeParam(src.slice(frame.pos), decode));
231
+ const handler = wildcardChild.handlers.get(method);
232
+ if (handler) return handler;
233
+ bindNames.pop();
234
+ bindValues.pop();
235
+ }
236
+ stack.pop();
237
+ }
238
+
239
+ return null;
240
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @nextrush/router - Middleware Adaptation
3
+ *
4
+ * Router-level-middleware sealing logic extracted from the `Router` class
5
+ * (T014, design.md D3 — extracted after the matching-engine and composition
6
+ * splits left `router.ts` still over the 300-line ceiling).
7
+ *
8
+ * The tree-walk that re-compiles every handler's executor is structurally
9
+ * pure (it only mutates the `TrieNode`/`HandlerEntry` structures passed in,
10
+ * same shape as `copyRoutes` in `composition.ts`) — it doesn't touch
11
+ * `Router`-only state like `_sealed`, so it extracts cleanly. `routes()` and
12
+ * `allowedMethods()` stay on `Router`: both return closures that capture
13
+ * `this` (for `this.match`, `this.opts`, `this.root`) and are the router's
14
+ * actual public middleware-producing API, not internal tree mechanics.
15
+ *
16
+ * @packageDocumentation
17
+ * @internal
18
+ */
19
+
20
+ import { compileExecutor, type StaticRouteMap, type TrieNode } from './segment-trie';
21
+ import type { Middleware } from '@nextrush/types';
22
+
23
+ /**
24
+ * Re-compile every route executor in the trie (plus the static-route hash
25
+ * map) to include router-level middleware ahead of each route's own
26
+ * middleware. Idempotency (guarding against a double seal) is the caller's
27
+ * responsibility — this function always re-walks and re-compiles.
28
+ */
29
+ export function sealRouterMiddleware(
30
+ root: TrieNode,
31
+ staticRoutes: StaticRouteMap,
32
+ routerMiddleware: Middleware[]
33
+ ): void {
34
+ const routerMw = [...routerMiddleware];
35
+
36
+ const walk = (node: TrieNode): void => {
37
+ for (const [method, entry] of node.handlers) {
38
+ const combinedMw = [...routerMw, ...entry.middleware];
39
+ entry.executor = compileExecutor(entry.handler, combinedMw);
40
+ node.handlers.set(method, entry);
41
+ }
42
+ for (const [, child] of node.children) {
43
+ walk(child);
44
+ }
45
+ if (node.paramChild) walk(node.paramChild);
46
+ if (node.wildcardChild) walk(node.wildcardChild);
47
+ };
48
+
49
+ walk(root);
50
+
51
+ // Also update static route entries (method-nested map, HP-9).
52
+ for (const [, methodMap] of staticRoutes) {
53
+ for (const [key, entry] of methodMap) {
54
+ const combinedMw = [...routerMw, ...entry.middleware];
55
+ entry.executor = compileExecutor(entry.handler, combinedMw);
56
+ methodMap.set(key, entry);
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @nextrush/router - Redirect Handler Compilation
3
+ *
4
+ * Single, shared redirect implementation used by both `Router.redirect()` and
5
+ * route groups (audit RT-4). Previously the group variant used a naive
6
+ * `replaceAll(':key', value)` that could mis-substitute overlapping param names
7
+ * and re-scanned the string per request; this precompiles the target template
8
+ * once and only substitutes route-style `:param` slots (a `:` at position 0 or
9
+ * preceded by `/`), so `https://` and other literal colons are never touched.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import type { Context, RouteHandler } from '@nextrush/types';
15
+
16
+ /** HTTP redirect status codes supported by `redirect()`. */
17
+ export type RedirectStatus = 301 | 302 | 303 | 307 | 308;
18
+
19
+ /**
20
+ * Precompile a redirect target into alternating literal / param-name parts.
21
+ *
22
+ * @param to - Target path/URL, possibly containing `:param` placeholders.
23
+ * @returns A parts array (`[literal, paramName, literal, …]`) when `to` has
24
+ * route-style params, otherwise `undefined` (a static target).
25
+ */
26
+ export function compileRedirectTarget(to: string): string[] | undefined {
27
+ const parts: string[] = [];
28
+ let pos = 0;
29
+ let found = false;
30
+
31
+ while (pos < to.length) {
32
+ let idx = -1;
33
+ for (let i = pos; i < to.length; i++) {
34
+ if (to[i] === ':' && (i === 0 || to[i - 1] === '/') && i + 1 < to.length && to[i + 1] !== '/') {
35
+ idx = i;
36
+ break;
37
+ }
38
+ }
39
+ if (idx === -1) break;
40
+
41
+ found = true;
42
+ parts.push(to.slice(pos, idx)); // literal before ':'
43
+ const end = to.indexOf('/', idx + 1);
44
+ if (end === -1) {
45
+ parts.push(to.slice(idx + 1)); // param name (rest of string)
46
+ pos = to.length;
47
+ } else {
48
+ parts.push(to.slice(idx + 1, end)); // param name
49
+ pos = end;
50
+ }
51
+ }
52
+
53
+ if (found) {
54
+ parts.push(to.slice(pos)); // trailing literal
55
+ return parts;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * Build the redirect route handler for a `to`/`status` pair. Substitutes any
62
+ * `:param` placeholders from `ctx.params` using the precompiled template.
63
+ *
64
+ * @param to - Target path/URL.
65
+ * @param status - Redirect status code.
66
+ * @returns A {@link RouteHandler} that sets `Location` and the status.
67
+ */
68
+ export function createRedirectHandler(to: string, status: RedirectStatus): RouteHandler {
69
+ const compiledParts = compileRedirectTarget(to);
70
+
71
+ return (ctx: Context) => {
72
+ let targetPath: string;
73
+
74
+ if (compiledParts) {
75
+ const params = ctx.params;
76
+ const head = compiledParts[0];
77
+ if (head === undefined) {
78
+ targetPath = to;
79
+ } else {
80
+ let result = head;
81
+ for (let i = 1; i < compiledParts.length - 1; i += 2) {
82
+ const paramKey = compiledParts[i];
83
+ const tail = compiledParts[i + 1];
84
+ if (paramKey === undefined || tail === undefined) break;
85
+ result += (params[paramKey] ?? '') + tail;
86
+ }
87
+ targetPath = result;
88
+ }
89
+ } else {
90
+ targetPath = to;
91
+ }
92
+
93
+ ctx.status = status;
94
+ ctx.set('Location', targetPath);
95
+ ctx.body = '';
96
+ };
97
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * @nextrush/router - Route Registration
3
+ *
4
+ * Trie-insertion logic extracted from the `Router` class (T014, design.md D2 —
5
+ * D2 keeps HTTP-verb shortcuts (`get`/`post`/etc.) on `Router` itself, but
6
+ * explicitly permits extracting their shared internal, `addRoute`, "if
7
+ * `addRoute` itself is large enough to warrant it." At 116 lines and the
8
+ * highest cyclomatic/cognitive complexity in the file, it is.
9
+ *
10
+ * `addRoute` touches five pieces of `Router` instance state (the trie root,
11
+ * `caseSensitive`, the `hasParamRoutes` flag, the static-route hash map, and
12
+ * the introspection registry) as both reads and writes. All five are threaded
13
+ * explicitly rather than read/written through an implicit `this`, so this
14
+ * function has no hidden dependency on `Router` beyond what's passed in —
15
+ * same principle as the matching-engine extraction (design.md D1).
16
+ *
17
+ * @packageDocumentation
18
+ * @internal
19
+ */
20
+
21
+ import { HTTP_METHODS } from '@nextrush/types';
22
+ import type {
23
+ HttpMethod,
24
+ MetadataContribution,
25
+ Middleware,
26
+ RouteDefinition,
27
+ RouteEntry,
28
+ RouteHandler,
29
+ } from '@nextrush/types';
30
+ import {
31
+ compileExecutor,
32
+ createNode,
33
+ NodeType,
34
+ parseSegments,
35
+ type HandlerEntry,
36
+ type StaticRouteMap,
37
+ type TrieNode,
38
+ } from './segment-trie';
39
+ import { mergeContributions, readContribution } from './route-metadata';
40
+ import { createRedirectHandler, type RedirectStatus } from './redirect';
41
+
42
+ /** Registration state `addRoute` reads, threaded explicitly. */
43
+ export interface RegistrationState {
44
+ readonly root: TrieNode;
45
+ readonly caseSensitive: boolean;
46
+ readonly staticRoutes: StaticRouteMap;
47
+ readonly routeDefinitions: RouteDefinition[];
48
+ }
49
+
50
+ /**
51
+ * Normalize a route path at registration time: join the router prefix,
52
+ * collapse duplicate slashes, drop a trailing slash in non-strict mode, and
53
+ * guarantee a leading slash.
54
+ *
55
+ * @remarks
56
+ * This is registration-time normalization — it joins the router `prefix` and
57
+ * never case-folds (case handling belongs to trie insertion / matching). It is
58
+ * a distinct concern from `normalizePathForMatch` in `matching.ts`, which
59
+ * normalizes an *incoming request* path for lookup; both were extracted from
60
+ * `Router` to keep each single-definition (design.md D2/D3).
61
+ */
62
+ export function normalizeRegistrationPath(path: string, prefix: string, strict: boolean): string {
63
+ // Handle prefix with trailing slash and path with leading slash
64
+ let joinedPrefix = prefix;
65
+ if (joinedPrefix.endsWith('/') && path.startsWith('/')) {
66
+ joinedPrefix = joinedPrefix.slice(0, -1);
67
+ }
68
+
69
+ let normalized = joinedPrefix + path;
70
+
71
+ // Fast-path: skip regex when no double slashes (99%+ of requests)
72
+ if (normalized.includes('//')) {
73
+ normalized = normalized.replace(/\/+/g, '/');
74
+ }
75
+
76
+ // For non-strict mode during registration, remove trailing slash
77
+ if (!strict && normalized.length > 1 && normalized.endsWith('/')) {
78
+ normalized = normalized.slice(0, -1);
79
+ }
80
+
81
+ return normalized.startsWith('/') ? normalized : '/' + normalized;
82
+ }
83
+
84
+ /**
85
+ * Insert one route into the segment trie, updating every side structure
86
+ * (static-route fast-path map, introspection registry) that
87
+ * `Router.match`/`Router.getRoutes` depend on.
88
+ *
89
+ * @param normalized - Already-normalized path (caller applies `Router`'s own
90
+ * prefix/strict-mode normalization before calling this — normalization
91
+ * itself stays a `Router` method since it only touches `opts`, not the trie).
92
+ * @param recordIntrospection - When `false`, skips pushing this call's row
93
+ * into `state.routeDefinitions` while still inserting the concrete
94
+ * per-method trie handler (matching is completely unaffected either way).
95
+ * `Router.all()` (T016) sets this to `false` on each of its 7 per-method
96
+ * `addRoute` calls, then pushes exactly one consolidated
97
+ * `isAnyMethod: true` row itself — so an any-method route yields a single
98
+ * `getRoutes()` entry instead of one row per enumerated method, without
99
+ * touching how any individual method is matched. Every other call site
100
+ * (`get`/`post`/etc., `redirect`, sub-router mounting) omits this parameter
101
+ * and keeps the original one-row-per-call behavior unchanged.
102
+ * @returns `true` if the registered route has a param or wildcard segment —
103
+ * the caller (`Router.addRoute`) uses this to flip its own `hasParamRoutes`
104
+ * flag. Returned rather than mutated through `state` because it's a
105
+ * primitive: a struct field can't carry a boolean mutation back to the
106
+ * caller by reference the way `root`/`staticRoutes`/`routeDefinitions` do.
107
+ */
108
+ export function addRoute(
109
+ method: HttpMethod,
110
+ normalized: string,
111
+ entries: RouteEntry[],
112
+ middleware: Middleware[],
113
+ state: RegistrationState,
114
+ recordIntrospection = true
115
+ ): boolean {
116
+ const segments = parseSegments(normalized, state.caseSensitive);
117
+
118
+ let node = state.root;
119
+
120
+ for (const seg of segments) {
121
+ if (seg.type === NodeType.PARAM) {
122
+ if (!node.paramChild) {
123
+ node.paramChild = createNode(seg.segment, NodeType.PARAM);
124
+ node.paramChild.paramName = seg.paramName;
125
+ } else if (node.paramChild.paramName !== seg.paramName) {
126
+ // Same position, different param names — this silently loses one name
127
+ // at runtime (params[newName] is undefined), so fail fast at
128
+ // registration rather than warn (audit RT-5). Also removes the
129
+ // process.env / console.warn usage that was here.
130
+ throw new Error(
131
+ `Route param name conflict at "${normalized}": ":${String(seg.paramName)}" ` +
132
+ `conflicts with existing ":${String(node.paramChild.paramName)}" at the same ` +
133
+ `position. Use the same param name for this segment across all routes.`
134
+ );
135
+ }
136
+ node = node.paramChild;
137
+ } else if (seg.type === NodeType.WILDCARD) {
138
+ node.wildcardChild ??= createNode('*', NodeType.WILDCARD);
139
+ node = node.wildcardChild;
140
+ break; // Wildcard must be last
141
+ } else {
142
+ const key = seg.segment;
143
+ let child = node.children.get(key);
144
+ if (!child) {
145
+ child = createNode(seg.segment, NodeType.STATIC);
146
+ node.children.set(key, child);
147
+ }
148
+ node = child;
149
+ }
150
+ }
151
+
152
+ // Partition entries: functions are behavior (inline middleware + the final
153
+ // handler); pure markers (endpoint()) contribute metadata only and never
154
+ // execute. Every entry may also carry a metadata contribution (e.g. a
155
+ // function like validate() that both runs and contributes its schema).
156
+ const functions: Middleware[] = [];
157
+ const contributions: MetadataContribution[] = [];
158
+ for (const routeEntry of entries) {
159
+ const contribution = readContribution(routeEntry);
160
+ if (contribution) contributions.push(contribution);
161
+ if (typeof routeEntry === 'function') functions.push(routeEntry);
162
+ }
163
+
164
+ // Combine functions into a single handler with inline middleware
165
+ const combinedMiddleware = [...middleware];
166
+ const finalHandler: RouteHandler | undefined = functions[functions.length - 1];
167
+
168
+ if (!finalHandler) {
169
+ throw new Error('At least one handler is required');
170
+ }
171
+
172
+ // Inline middleware = every function before the last
173
+ for (let i = 0; i < functions.length - 1; i++) {
174
+ const fn = functions[i];
175
+ if (fn) combinedMiddleware.push(fn);
176
+ }
177
+
178
+ // Pre-compile executor at registration time (not per-request!)
179
+ const executor = compileExecutor(finalHandler, combinedMiddleware);
180
+
181
+ const handlerEntry: HandlerEntry = {
182
+ handler: finalHandler,
183
+ middleware: combinedMiddleware,
184
+ executor,
185
+ };
186
+
187
+ // Detect duplicate route registration
188
+ if (node.handlers.has(method)) {
189
+ throw new Error(
190
+ `Route conflict: ${method} ${normalized} is already registered. ` +
191
+ 'Remove the duplicate or use a different path.'
192
+ );
193
+ }
194
+
195
+ node.handlers.set(method, handlerEntry);
196
+
197
+ // Populate static route hash map for O(1) lookup. Method-nested (HP-9): the
198
+ // outer map is keyed by method, the inner by the (lowercased, unless
199
+ // case-sensitive) path — so matching selects the inner map by method and
200
+ // probes by the raw path with no per-request key-string concatenation.
201
+ 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);
208
+ }
209
+ methodMap.set(normalizedKey, handlerEntry);
210
+ }
211
+
212
+ // Record in the introspection registry (side structure — never touched by
213
+ // request dispatch). key is the canonical `${METHOD} ${pathPattern}`. Skipped
214
+ // when the caller is consolidating multiple addRoute calls into a single row
215
+ // itself (Router.all(), T016) — the concrete trie handler above is still
216
+ // inserted regardless, so matching for this method is unaffected.
217
+ if (recordIntrospection) {
218
+ state.routeDefinitions.push({
219
+ key: `${method} ${normalized}`,
220
+ method,
221
+ path: normalized,
222
+ metadata: mergeContributions(contributions),
223
+ });
224
+ }
225
+
226
+ return hasParams;
227
+ }
228
+
229
+ /**
230
+ * Callback signature matching `Router['addRoute']` (the validating/
231
+ * normalizing wrapper, not the extracted `addRoute` above) — injected so
232
+ * `registerRedirect` doesn't need `Router` instance access, same pattern as
233
+ * `copyRoutes`'s `AddRouteFn` in `composition.ts`.
234
+ */
235
+ export type RegisterRouteFn = (method: HttpMethod, path: string, entries: RouteEntry[]) => void;
236
+
237
+ /**
238
+ * Register a redirect route from one path to another across every HTTP
239
+ * method the target status code requires (GET/HEAD always; POST/PUT/PATCH/
240
+ * DELETE additionally for 307/308, which must preserve the original method).
241
+ *
242
+ * Extracted from `Router.redirect()` — thematically closer to "register N
243
+ * routes for one call" than to composition or matching, and, like
244
+ * `copyRoutes`, only needs `addRoute` injected rather than full `Router`
245
+ * instance access.
246
+ */
247
+ export function registerRedirect(
248
+ from: string,
249
+ to: string,
250
+ status: RedirectStatus,
251
+ addRoute: RegisterRouteFn
252
+ ): void {
253
+ const redirectHandler = createRedirectHandler(to, status);
254
+
255
+ // Register for common methods. 307/308 preserve the original method,
256
+ // so register all standard methods for those status codes.
257
+ addRoute('GET', from, [redirectHandler]);
258
+ addRoute('HEAD', from, [redirectHandler]);
259
+ if (status === 307 || status === 308) {
260
+ addRoute('POST', from, [redirectHandler]);
261
+ addRoute('PUT', from, [redirectHandler]);
262
+ addRoute('PATCH', from, [redirectHandler]);
263
+ addRoute('DELETE', from, [redirectHandler]);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Push a single consolidated any-method (`isAnyMethod: true`) introspection
269
+ * row (T016) into the registry.
270
+ *
271
+ * @remarks
272
+ * `Router.all()` / `GroupRouter.all()` insert one concrete per-method trie
273
+ * handler each (so every method still matches) with `recordIntrospection`
274
+ * off, then call this exactly once — so `getRoutes()` yields a single row per
275
+ * `.all()`/`@All()` route instead of one row per enumerated HTTP method,
276
+ * without changing how any individual method is matched.
277
+ *
278
+ * @param routeDefinitions - The router's introspection registry to append to.
279
+ * @param normalized - The already-normalized route path.
280
+ */
281
+ export function pushAnyMethodDefinition(
282
+ routeDefinitions: RouteDefinition[],
283
+ normalized: string
284
+ ): void {
285
+ routeDefinitions.push({
286
+ key: `${HTTP_METHODS[0]} ${normalized}`,
287
+ method: HTTP_METHODS[0],
288
+ path: normalized,
289
+ isAnyMethod: true,
290
+ });
291
+ }