@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,219 @@
1
+ /**
2
+ * @nextrush/router - Segment Trie Node
3
+ *
4
+ * Internal segment trie implementation for high-performance route matching.
5
+ * Segments are split by `/` and matched one trie level per segment for O(k)
6
+ * lookups where k is path length.
7
+ *
8
+ * @packageDocumentation
9
+ * @internal
10
+ */
11
+
12
+ import type { Context, HttpMethod, Middleware, RouteHandler } from '@nextrush/types';
13
+
14
+ /**
15
+ * Node type enumeration
16
+ */
17
+ export const enum NodeType {
18
+ /** Static path segment: /users */
19
+ STATIC = 0,
20
+ /** Named parameter: /:id */
21
+ PARAM = 1,
22
+ /** Wildcard: /* */
23
+ WILDCARD = 2,
24
+ }
25
+
26
+ /**
27
+ * Segment trie node
28
+ */
29
+ export interface TrieNode {
30
+ /** Path segment for this node */
31
+ segment: string;
32
+ /** Node type */
33
+ type: NodeType;
34
+ /** Static child nodes keyed by whole path segment (e.g. `users`), not the first character */
35
+ children: Map<string, TrieNode>;
36
+ /** Parameter name if this is a param node */
37
+ paramName?: string;
38
+ /** Handlers keyed by HTTP method */
39
+ handlers: Map<HttpMethod, HandlerEntry>;
40
+ /** Wildcard child if any */
41
+ wildcardChild?: TrieNode;
42
+ /** Parameter child if any */
43
+ paramChild?: TrieNode;
44
+ }
45
+
46
+ /**
47
+ * Handler entry with middleware and pre-compiled executor
48
+ */
49
+ export interface HandlerEntry {
50
+ handler: RouteHandler;
51
+ middleware: Middleware[];
52
+ /** Pre-compiled executor for fast dispatch (no closure per request) */
53
+ executor?: (ctx: Context) => Promise<void>;
54
+ }
55
+
56
+ /**
57
+ * Method-nested static-route fast-path map (HP-9): the OUTER map selects an
58
+ * inner map by HTTP method, the INNER map probes by the trailing-slash-
59
+ * normalized path. This replaces the former `Map<"METHOD path", HandlerEntry>`
60
+ * so a static lookup no longer builds a `` `${method} ${path}` `` key string per
61
+ * request — it selects the inner map by `method` and probes by the raw path.
62
+ */
63
+ export type StaticRouteMap = Map<HttpMethod, Map<string, HandlerEntry>>;
64
+
65
+ /**
66
+ * No-op next function - reusable, zero allocation
67
+ * Caches the resolved Promise to avoid per-call allocation
68
+ * @internal
69
+ */
70
+ const RESOLVED_PROMISE = Promise.resolve();
71
+ export const NOOP_NEXT = (): Promise<void> => RESOLVED_PROMISE;
72
+
73
+ /**
74
+ * Compile an executor for a route handler with middleware
75
+ * This creates the executor ONCE at registration time, not per-request
76
+ * @internal
77
+ */
78
+ export function compileExecutor(
79
+ handler: RouteHandler,
80
+ middleware: Middleware[]
81
+ ): (ctx: Context) => Promise<void> {
82
+ const len = middleware.length;
83
+
84
+ // FAST PATH: No middleware — direct handler call, no extra async frame (NF-1).
85
+ if (len === 0) {
86
+ return (ctx: Context): Promise<void> => {
87
+ // `setNext(NOOP_NEXT)` is LOAD-BEARING, not redundant (NF-4a): it
88
+ // terminates the middleware chain at the handler so a handler that calls
89
+ // `ctx.next()` is a safe no-op and cannot leak into app-level middleware
90
+ // mounted AFTER the router (the general `compose` dispatch wires ctx._next
91
+ // to advance into that middleware before running the router).
92
+ if (ctx.setNext) ctx.setNext(NOOP_NEXT);
93
+ try {
94
+ // `Promise.resolve(...)` — NOT `x instanceof Promise ? x : RESOLVED` — so
95
+ // a non-Promise THENABLE return is adopted (its async work awaited), not
96
+ // dropped: byte-identical to the former `await handler(...)`, minus the
97
+ // async form's internal microtask hop. A native promise is returned as-is
98
+ // (`Promise.resolve(p) === p`); a void return yields a resolved promise.
99
+ return Promise.resolve(handler(ctx, NOOP_NEXT));
100
+ } catch (err) {
101
+ // Self-contained "never throw synchronously" contract, identical to the
102
+ // len >= 1 branch: a sync throw becomes a rejection; a non-Error is wrapped.
103
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
104
+ }
105
+ };
106
+ }
107
+
108
+ // Middleware present: guarded recursive dispatch. This mirrors core `compose()`
109
+ // so per-route middleware behave identically to application middleware:
110
+ // - `ctx.next()` (modern) and the `(ctx, next)` argument advance the SAME chain
111
+ // (ctx.setNext is wired before each layer),
112
+ // - a synchronous throw is turned into a rejected promise (proper propagation),
113
+ // - calling next() more than once in a layer rejects with a clear error.
114
+ return (ctx: Context): Promise<void> => {
115
+ let index = -1;
116
+
117
+ const dispatch = (i: number): Promise<void> => {
118
+ if (i <= index) {
119
+ return Promise.reject(new Error('next() called multiple times'));
120
+ }
121
+ index = i;
122
+
123
+ if (i < len) {
124
+ const mw = middleware[i];
125
+ if (mw === undefined) return Promise.reject(new Error('middleware length mismatch'));
126
+ const next = (): Promise<void> => dispatch(i + 1);
127
+ if (ctx.setNext) ctx.setNext(next);
128
+ try {
129
+ return Promise.resolve(mw(ctx, next));
130
+ } catch (err) {
131
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
132
+ }
133
+ }
134
+
135
+ // Final handler. A handler calling next() is a safe no-op.
136
+ if (ctx.setNext) ctx.setNext(NOOP_NEXT);
137
+ try {
138
+ return Promise.resolve(handler(ctx, NOOP_NEXT));
139
+ } catch (err) {
140
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
141
+ }
142
+ };
143
+
144
+ return dispatch(0);
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Create a new segment trie node
150
+ */
151
+ export function createNode(segment: string, type: NodeType = NodeType.STATIC): TrieNode {
152
+ return {
153
+ segment,
154
+ type,
155
+ children: new Map(),
156
+ handlers: new Map(),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Reset a trie node to its empty state — clears children/handlers and drops the
162
+ * param/wildcard branches. Used by `Router.reset()` for test isolation and
163
+ * hot-reload re-registration.
164
+ */
165
+ export function clearNode(node: TrieNode): void {
166
+ node.children.clear();
167
+ node.handlers.clear();
168
+ node.paramChild = undefined;
169
+ node.wildcardChild = undefined;
170
+ }
171
+
172
+ /**
173
+ * Parse path segments
174
+ * Splits path into segments and identifies param/wildcard types
175
+ *
176
+ * @param path - Route path to parse
177
+ * @param caseSensitive - If false, lowercase static segments for case-insensitive matching
178
+ */
179
+ export function parseSegments(path: string, caseSensitive = true): ParsedSegment[] {
180
+ const normalized = path.startsWith('/') ? path.slice(1) : path;
181
+ if (normalized === '') return [];
182
+
183
+ const parts = normalized.split('/');
184
+ const segments: ParsedSegment[] = [];
185
+
186
+ for (const part of parts) {
187
+ if (part.startsWith(':')) {
188
+ // Preserve the original parameter name case
189
+ const paramName = part.slice(1);
190
+ segments.push({
191
+ segment: part, // Preserve original case — param nodes match any segment
192
+ type: NodeType.PARAM,
193
+ paramName, // Keep original case
194
+ });
195
+ } else if (part === '*') {
196
+ segments.push({
197
+ segment: '*',
198
+ type: NodeType.WILDCARD,
199
+ });
200
+ break; // Wildcard must be last
201
+ } else {
202
+ segments.push({
203
+ segment: caseSensitive ? part : part.toLowerCase(),
204
+ type: NodeType.STATIC,
205
+ });
206
+ }
207
+ }
208
+
209
+ return segments;
210
+ }
211
+
212
+ /**
213
+ * Parsed segment structure
214
+ */
215
+ export interface ParsedSegment {
216
+ segment: string;
217
+ type: NodeType;
218
+ paramName?: string;
219
+ }
package/src/state.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @nextrush/router - Router State Construction
3
+ *
4
+ * Pure builders for the `Router`'s resolved options and its memoized state
5
+ * struct, extracted from the constructor (design.md D2) so `Router` stays a
6
+ * thin shell that delegates even its own initialization.
7
+ *
8
+ * @packageDocumentation
9
+ * @internal
10
+ */
11
+
12
+ import type { Middleware, RouteDefinition, RouterOptions } from '@nextrush/types';
13
+ import type { StaticRouteMap, TrieNode } from './segment-trie';
14
+ import type { RegistrationState } from './registration';
15
+ import type { MatchState } from './match-route';
16
+
17
+ /** Apply defaults to user-supplied router options. */
18
+ export function resolveRouterOptions(options: RouterOptions): Required<RouterOptions> {
19
+ return {
20
+ prefix: options.prefix ?? '',
21
+ caseSensitive: options.caseSensitive ?? false,
22
+ strict: options.strict ?? false,
23
+ decode: options.decode ?? true,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Build the state struct the extracted registration/matching functions read.
29
+ *
30
+ * @remarks
31
+ * Every field is a stable reference for the router's lifetime, so the caller
32
+ * memoizes this once — `reset()` mutates the referenced structures in place, it
33
+ * never reassigns them. A single struct satisfies both `RegistrationState`
34
+ * (what `addRoute` reads) and `MatchState` (what `resolveMatch` reads).
35
+ */
36
+ export function createRouterState(
37
+ root: TrieNode,
38
+ opts: Required<RouterOptions>,
39
+ staticRoutes: StaticRouteMap,
40
+ routeDefinitions: RouteDefinition[],
41
+ routerMiddleware: Middleware[]
42
+ ): RegistrationState & MatchState {
43
+ return {
44
+ root,
45
+ staticRoutes,
46
+ routeDefinitions,
47
+ caseSensitive: opts.caseSensitive,
48
+ strict: opts.strict,
49
+ decode: opts.decode,
50
+ routerMiddleware,
51
+ };
52
+ }
package/src/radix-tree.ts DELETED
@@ -1,184 +0,0 @@
1
- /**
2
- * @nextrush/router - Segment Trie Node
3
- *
4
- * Internal segment trie implementation for high-performance route matching.
5
- * Uses a compressed trie structure for O(k) lookups where k is path length.
6
- *
7
- * @packageDocumentation
8
- * @internal
9
- */
10
-
11
- import type { Context, HttpMethod, Middleware, RouteHandler } from '@nextrush/types';
12
-
13
- /**
14
- * Node type enumeration
15
- */
16
- export const enum NodeType {
17
- /** Static path segment: /users */
18
- STATIC = 0,
19
- /** Named parameter: /:id */
20
- PARAM = 1,
21
- /** Wildcard: /* */
22
- WILDCARD = 2,
23
- }
24
-
25
- /**
26
- * Radix tree node
27
- */
28
- export interface RadixNode {
29
- /** Path segment for this node */
30
- segment: string;
31
- /** Node type */
32
- type: NodeType;
33
- /** Children nodes keyed by first character */
34
- children: Map<string, RadixNode>;
35
- /** Parameter name if this is a param node */
36
- paramName?: string;
37
- /** Handlers keyed by HTTP method */
38
- handlers: Map<HttpMethod, HandlerEntry>;
39
- /** Wildcard child if any */
40
- wildcardChild?: RadixNode;
41
- /** Parameter child if any */
42
- paramChild?: RadixNode;
43
- }
44
-
45
- /**
46
- * Handler entry with middleware and pre-compiled executor
47
- */
48
- export interface HandlerEntry {
49
- handler: RouteHandler;
50
- middleware: Middleware[];
51
- /** Pre-compiled executor for fast dispatch (no closure per request) */
52
- executor?: (ctx: Context) => Promise<void>;
53
- }
54
-
55
- /**
56
- * No-op next function - reusable, zero allocation
57
- * Caches the resolved Promise to avoid per-call allocation
58
- * @internal
59
- */
60
- const RESOLVED_PROMISE = Promise.resolve();
61
- export const NOOP_NEXT = (): Promise<void> => RESOLVED_PROMISE;
62
-
63
- /**
64
- * Compile an executor for a route handler with middleware
65
- * This creates the executor ONCE at registration time, not per-request
66
- * @internal
67
- */
68
- export function compileExecutor(
69
- handler: RouteHandler,
70
- middleware: Middleware[]
71
- ): (ctx: Context) => Promise<void> {
72
- const len = middleware.length;
73
-
74
- // FAST PATH: No middleware - direct handler call
75
- if (len === 0) {
76
- return async (ctx: Context) => {
77
- await handler(ctx, NOOP_NEXT);
78
- };
79
- }
80
-
81
- // FAST PATH: Single middleware
82
- if (len === 1) {
83
- const mw = middleware[0];
84
- if (mw === undefined) throw new Error('middleware length mismatch');
85
- return async (ctx: Context) => {
86
- await mw(ctx, async () => {
87
- await handler(ctx, NOOP_NEXT);
88
- });
89
- };
90
- }
91
-
92
- // FAST PATH: Two middleware (very common)
93
- if (len === 2) {
94
- const mw0 = middleware[0];
95
- const mw1 = middleware[1];
96
- if (mw0 === undefined || mw1 === undefined) throw new Error('middleware length mismatch');
97
- return async (ctx: Context) => {
98
- await mw0(ctx, async () => {
99
- await mw1(ctx, async () => {
100
- await handler(ctx, NOOP_NEXT);
101
- });
102
- });
103
- };
104
- }
105
-
106
- // General case: Build recursive dispatch
107
- // Note: This closure is created ONCE at registration, not per request
108
- return async (ctx: Context): Promise<void> => {
109
- let index = 0;
110
-
111
- const dispatch = async (): Promise<void> => {
112
- if (index < len) {
113
- const mw = middleware[index++];
114
- if (mw === undefined) throw new Error('middleware length mismatch');
115
- await mw(ctx, dispatch);
116
- } else {
117
- await handler(ctx, NOOP_NEXT);
118
- }
119
- };
120
-
121
- await dispatch();
122
- };
123
- }
124
-
125
- /**
126
- * Create a new radix node
127
- */
128
- export function createNode(segment: string, type: NodeType = NodeType.STATIC): RadixNode {
129
- return {
130
- segment,
131
- type,
132
- children: new Map(),
133
- handlers: new Map(),
134
- };
135
- }
136
-
137
- /**
138
- * Parse path segments
139
- * Splits path into segments and identifies param/wildcard types
140
- *
141
- * @param path - Route path to parse
142
- * @param caseSensitive - If false, lowercase static segments for case-insensitive matching
143
- */
144
- export function parseSegments(path: string, caseSensitive = true): ParsedSegment[] {
145
- const normalized = path.startsWith('/') ? path.slice(1) : path;
146
- if (normalized === '') return [];
147
-
148
- const parts = normalized.split('/');
149
- const segments: ParsedSegment[] = [];
150
-
151
- for (const part of parts) {
152
- if (part.startsWith(':')) {
153
- // Preserve the original parameter name case
154
- const paramName = part.slice(1);
155
- segments.push({
156
- segment: part, // Preserve original case — param nodes match any segment
157
- type: NodeType.PARAM,
158
- paramName, // Keep original case
159
- });
160
- } else if (part === '*') {
161
- segments.push({
162
- segment: '*',
163
- type: NodeType.WILDCARD,
164
- });
165
- break; // Wildcard must be last
166
- } else {
167
- segments.push({
168
- segment: caseSensitive ? part : part.toLowerCase(),
169
- type: NodeType.STATIC,
170
- });
171
- }
172
- }
173
-
174
- return segments;
175
- }
176
-
177
- /**
178
- * Parsed segment structure
179
- */
180
- export interface ParsedSegment {
181
- segment: string;
182
- type: NodeType;
183
- paramName?: string;
184
- }