@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,246 @@
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
+ import { matchNodeIndexedPooled, type WalkFrame, type WalkPool } from './walk-pool';
17
+
18
+ export type { WalkFrame, WalkPool } from './walk-pool';
19
+ export { createWalkPool } from './walk-pool';
20
+
21
+ /**
22
+ * Percent-decode an extracted param/wildcard value when `decode` is enabled.
23
+ * Fast-paths values with no `%`, and falls back to the raw value on malformed
24
+ * encoding (decodeURIComponent throws a URIError) so a bad request never crashes
25
+ * routing.
26
+ */
27
+ export function decodeParam(value: string, decode: boolean): string {
28
+ if (!decode || !value.includes('%')) return value;
29
+ try {
30
+ return decodeURIComponent(value);
31
+ } catch {
32
+ return value;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Return the path segment starting at `start`, up to the next `/` (or end).
38
+ * Scalar — no tuple array (the iterative walk tracks the next position itself).
39
+ * Used to recover an original-case param value from the original-case path at a
40
+ * position already validated against the (folded) lookup path, and by the
41
+ * {@link findNode} allowed-methods walk to scan segments off the lookup path.
42
+ */
43
+ export function segmentAt(path: string, start: number): string {
44
+ const slashPos = path.indexOf('/', start);
45
+ return slashPos === -1 ? path.slice(start) : path.slice(start, slashPos);
46
+ }
47
+
48
+ /**
49
+ * True only when `path.toLowerCase()` is provably a no-op: every character is
50
+ * ASCII (`<= 0x7F`) and none is an ASCII uppercase letter (`A`–`Z`). Any
51
+ * non-ASCII byte is treated as UNCERTAIN (it could be uppercase like `Ü`), so
52
+ * this returns `false` and the caller folds — never skipping unicode case
53
+ * folding (design.md D3). This lets `normalizePathForMatch` skip the
54
+ * `toLowerCase()` allocation for the overwhelmingly common all-lowercase-ASCII
55
+ * request path while staying byte-identical to always folding.
56
+ */
57
+ export function isProvablyLowerAscii(path: string): boolean {
58
+ for (let i = 0; i < path.length; i++) {
59
+ const c = path.charCodeAt(i);
60
+ if ((c >= 0x41 && c <= 0x5a) || c > 0x7f) return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ /**
66
+ * Structural match-time normalization WITHOUT case folding: collapse repeated
67
+ * slashes and strip a single trailing slash (unless `strict`). Split out of
68
+ * {@link normalizePathForMatch} so the case-fold decision and the structural
69
+ * pass are separable — `matchRoute` reuses this to build the original-case path
70
+ * only when a fold actually happened (HP-12), rather than running the full
71
+ * normalize twice.
72
+ */
73
+ export function collapseAndStrip(path: string, strict: boolean): string {
74
+ let normalized = path;
75
+
76
+ // Fast-path: skip the regex when there are no double slashes (99%+ of requests).
77
+ if (normalized.includes('//')) {
78
+ normalized = normalized.replace(/\/+/g, '/');
79
+ }
80
+
81
+ // Non-strict mode treats a trailing slash as insignificant; strict keeps it.
82
+ if (!strict && normalized.length > 1 && normalized.endsWith('/')) {
83
+ normalized = normalized.slice(0, -1);
84
+ }
85
+
86
+ return normalized;
87
+ }
88
+
89
+ /**
90
+ * Normalize a request path for segment-trie matching: fold case (unless
91
+ * `caseSensitive`), collapse repeated slashes, and strip a single trailing
92
+ * slash (unless `strict`). This is the one definition of the match-time
93
+ * normalization rules, shared by `matchRoute` (in `match-route.ts`) and
94
+ * {@link findAllowedMethods}.
95
+ *
96
+ * The `toLowerCase()` call is skipped when the path is provably case-stable
97
+ * ({@link isProvablyLowerAscii}) — byte-identical to always folding, but with
98
+ * no throwaway string on the common all-lowercase-ASCII path (HP-12 / D3).
99
+ *
100
+ * Query-string removal is intentionally NOT done here — it is caller-specific:
101
+ * `matchRoute` strips the query before calling, while `findAllowedMethods`
102
+ * receives an already query-free `ctx.path`. Pass `caseSensitive: true` to
103
+ * normalize while preserving case (used by `matchRoute` to build the
104
+ * original-case path from which param values are extracted).
105
+ *
106
+ * NOTE: registration-time normalization (`Router.normalizePath`) is a separate
107
+ * concern — it joins the router prefix, guarantees a leading slash, and never
108
+ * folds case — so it deliberately does not share this helper.
109
+ */
110
+ export function normalizePathForMatch(
111
+ path: string,
112
+ caseSensitive: boolean,
113
+ strict: boolean
114
+ ): string {
115
+ const folded = caseSensitive || isProvablyLowerAscii(path) ? path : path.toLowerCase();
116
+ return collapseAndStrip(folded, strict);
117
+ }
118
+
119
+ /**
120
+ * One node in the iterative walk's explicit stack — see the full doc comment
121
+ * on {@link WalkFrame} in `walk-pool.ts` (re-exported here for callers that
122
+ * only import from `matching.ts`).
123
+ */
124
+
125
+ /**
126
+ * Iterative, index-based segment-trie match (HP-11 / HP-13, design.md D4).
127
+ *
128
+ * Walks the trie with an EXPLICIT stack instead of recursion, so a pathological
129
+ * segment count cannot overflow the call stack (DoS safety) — behavior is
130
+ * otherwise byte-identical to the former recursive matcher: precedence is
131
+ * static > param > wildcard at each node, a partially-matching branch backtracks
132
+ * cleanly, and the first accepted terminal wins.
133
+ *
134
+ * Param/wildcard bindings are DEFERRED onto the caller-owned `bindNames` /
135
+ * `bindValues` stacks and popped on backtrack, so params are materialized ONCE
136
+ * on the accepted terminal by the caller (no eager bind + backtrack
137
+ * `Reflect.deleteProperty`, and no `Object.keys` post-loop — the caller reads
138
+ * the bind count). `decode` runs strictly on the already-split segment/remainder
139
+ * (design.md D9), so an encoded slash/dot decodes into the value only and can
140
+ * never create new path segments.
141
+ *
142
+ * `originalPath` (present only when case-folding actually occurred) supplies the
143
+ * original-case param value at the same position as the folded lookup path.
144
+ */
145
+ export function matchNodeIndexed(
146
+ root: TrieNode,
147
+ path: string,
148
+ startPos: number,
149
+ bindNames: string[],
150
+ bindValues: string[],
151
+ method: HttpMethod,
152
+ decode: boolean,
153
+ originalPath?: string,
154
+ /**
155
+ * When provided, the walk indexes into `pool.frames` by depth (reusing
156
+ * each pre-allocated frame object in place) instead of `push`/`pop`-ing
157
+ * fresh frame literals onto a fresh array — see `walk-pool.ts`'s
158
+ * `matchNodeIndexedPooled`. Omitted, the walk behaves exactly as before (a
159
+ * fresh `stack: WalkFrame[]`) — every other caller of `matchNodeIndexed`
160
+ * keeps its current behavior unchanged.
161
+ */
162
+ pool?: WalkPool
163
+ ): HandlerEntry | null {
164
+ if (pool) {
165
+ return matchNodeIndexedPooled(root, path, startPos, bindNames, bindValues, method, decode, pool, originalPath);
166
+ }
167
+ const stack: WalkFrame[] = [
168
+ { node: root, pos: startPos, stage: 0, seg: '', next: 0, bound: false },
169
+ ];
170
+
171
+ while (stack.length > 0) {
172
+ const frame = stack[stack.length - 1];
173
+ if (frame === undefined) break;
174
+
175
+ // Stage 0 — first visit: terminal checks, then try the static child.
176
+ if (frame.stage === 0) {
177
+ if (frame.pos >= path.length) {
178
+ const handler = frame.node.handlers.get(method);
179
+ if (handler) return handler; // accepted — bind stacks hold this path's params
180
+ stack.pop();
181
+ continue;
182
+ }
183
+ const slashPos = path.indexOf('/', frame.pos);
184
+ if (slashPos === -1) {
185
+ frame.seg = path.slice(frame.pos);
186
+ frame.next = path.length;
187
+ } else {
188
+ frame.seg = path.slice(frame.pos, slashPos);
189
+ frame.next = slashPos + 1;
190
+ }
191
+ if (frame.seg === '') {
192
+ const handler = frame.node.handlers.get(method);
193
+ if (handler) return handler;
194
+ stack.pop();
195
+ continue;
196
+ }
197
+ frame.stage = 1;
198
+ const staticChild = frame.node.children.get(frame.seg);
199
+ if (staticChild) {
200
+ stack.push({ node: staticChild, pos: frame.next, stage: 0, seg: '', next: 0, bound: false });
201
+ }
202
+ continue;
203
+ }
204
+
205
+ // Stage 1 — static child (if any) has failed: try the param child, deferring
206
+ // its binding onto the shared stacks.
207
+ if (frame.stage === 1) {
208
+ frame.stage = 2;
209
+ const paramChild = frame.node.paramChild;
210
+ if (paramChild) {
211
+ const paramName = paramChild.paramName;
212
+ if (paramName === undefined) return null; // degenerate param node → whole walk fails (as before)
213
+ const value =
214
+ originalPath !== undefined
215
+ ? decodeParam(segmentAt(originalPath, frame.pos), decode)
216
+ : decodeParam(frame.seg, decode);
217
+ bindNames.push(paramName);
218
+ bindValues.push(value);
219
+ frame.bound = true;
220
+ stack.push({ node: paramChild, pos: frame.next, stage: 0, seg: '', next: 0, bound: false });
221
+ }
222
+ continue;
223
+ }
224
+
225
+ // Stage 2 — param branch (if taken) failed: undo its deferred bind, then try
226
+ // the wildcard child (a terminal — it captures the original-case remainder).
227
+ if (frame.bound) {
228
+ bindNames.pop();
229
+ bindValues.pop();
230
+ frame.bound = false;
231
+ }
232
+ const wildcardChild = frame.node.wildcardChild;
233
+ if (wildcardChild) {
234
+ const src = originalPath ?? path;
235
+ bindNames.push('*');
236
+ bindValues.push(decodeParam(src.slice(frame.pos), decode));
237
+ const handler = wildcardChild.handlers.get(method);
238
+ if (handler) return handler;
239
+ bindNames.pop();
240
+ bindValues.pop();
241
+ }
242
+ stack.pop();
243
+ }
244
+
245
+ return null;
246
+ }
@@ -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
+ }