@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.
- package/README.md +250 -492
- package/dist/index.d.ts +227 -178
- package/dist/index.js +1075 -657
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/allowed-methods.test.ts +144 -0
- package/src/__tests__/audit-fixes.test.ts +59 -0
- package/src/__tests__/canonical-path-security.test.ts +198 -0
- package/src/__tests__/canonicalize-memo.test.ts +108 -0
- package/src/__tests__/dispatch-deasync.test.ts +292 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/head-auto-registration.test.ts +197 -0
- package/src/__tests__/helpers/differential-corpus.ts +314 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
- package/src/__tests__/match-prenormalized.test.ts +95 -0
- package/src/__tests__/match-safety.test.ts +223 -0
- package/src/__tests__/match-single-alloc.test.ts +82 -0
- package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
- package/src/__tests__/middleware-pipeline.test.ts +306 -0
- package/src/__tests__/param-decoding.test.ts +78 -0
- package/src/__tests__/public-surface.test.ts +72 -0
- package/src/__tests__/registration-max-depth.test.ts +56 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +315 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -7
- package/src/__tests__/router.test.ts +148 -7
- package/src/__tests__/static-map-reset.test.ts +48 -0
- package/src/__tests__/walk-pool-sizing.test.ts +72 -0
- package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
- package/src/canonicalize.ts +137 -0
- package/src/composition.ts +79 -0
- package/src/constants.ts +43 -0
- package/src/dispatch.ts +145 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +14 -5
- package/src/match-route.ts +241 -0
- package/src/matching.ts +246 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +343 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +219 -872
- package/src/segment-trie.ts +227 -0
- package/src/state.ts +53 -0
- package/src/walk-pool.ts +208 -0
- package/src/radix-tree.ts +0 -184
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
* `true` only for a `HEAD` entry derived from a `GET` registration
|
|
56
|
+
* (RFC 9110 §9.3.2). A derived entry is replaced by an explicit `HEAD`
|
|
57
|
+
* registration instead of reporting a route conflict, is absent from
|
|
58
|
+
* `getRoutes()`, and is skipped when copying routes into a parent router —
|
|
59
|
+
* which re-derives it from the `GET` it copies.
|
|
60
|
+
*/
|
|
61
|
+
autoHead: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Method-nested static-route fast-path map (HP-9): the OUTER map selects an
|
|
66
|
+
* inner map by HTTP method, the INNER map probes by the trailing-slash-
|
|
67
|
+
* normalized path. This replaces the former `Map<"METHOD path", HandlerEntry>`
|
|
68
|
+
* so a static lookup no longer builds a `` `${method} ${path}` `` key string per
|
|
69
|
+
* request — it selects the inner map by `method` and probes by the raw path.
|
|
70
|
+
*/
|
|
71
|
+
export type StaticRouteMap = Map<HttpMethod, Map<string, HandlerEntry>>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* No-op next function - reusable, zero allocation
|
|
75
|
+
* Caches the resolved Promise to avoid per-call allocation
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
78
|
+
const RESOLVED_PROMISE = Promise.resolve();
|
|
79
|
+
export const NOOP_NEXT = (): Promise<void> => RESOLVED_PROMISE;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compile an executor for a route handler with middleware
|
|
83
|
+
* This creates the executor ONCE at registration time, not per-request
|
|
84
|
+
* @internal
|
|
85
|
+
*/
|
|
86
|
+
export function compileExecutor(
|
|
87
|
+
handler: RouteHandler,
|
|
88
|
+
middleware: Middleware[]
|
|
89
|
+
): (ctx: Context) => Promise<void> {
|
|
90
|
+
const len = middleware.length;
|
|
91
|
+
|
|
92
|
+
// FAST PATH: No middleware — direct handler call, no extra async frame (NF-1).
|
|
93
|
+
if (len === 0) {
|
|
94
|
+
return (ctx: Context): Promise<void> => {
|
|
95
|
+
// `setNext(NOOP_NEXT)` is LOAD-BEARING, not redundant (NF-4a): it
|
|
96
|
+
// terminates the middleware chain at the handler so a handler that calls
|
|
97
|
+
// `ctx.next()` is a safe no-op and cannot leak into app-level middleware
|
|
98
|
+
// mounted AFTER the router (the general `compose` dispatch wires ctx._next
|
|
99
|
+
// to advance into that middleware before running the router).
|
|
100
|
+
if (ctx.setNext) ctx.setNext(NOOP_NEXT);
|
|
101
|
+
try {
|
|
102
|
+
// `Promise.resolve(...)` — NOT `x instanceof Promise ? x : RESOLVED` — so
|
|
103
|
+
// a non-Promise THENABLE return is adopted (its async work awaited), not
|
|
104
|
+
// dropped: byte-identical to the former `await handler(...)`, minus the
|
|
105
|
+
// async form's internal microtask hop. A native promise is returned as-is
|
|
106
|
+
// (`Promise.resolve(p) === p`); a void return yields a resolved promise.
|
|
107
|
+
return Promise.resolve(handler(ctx, NOOP_NEXT));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// Self-contained "never throw synchronously" contract, identical to the
|
|
110
|
+
// len >= 1 branch: a sync throw becomes a rejection; a non-Error is wrapped.
|
|
111
|
+
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Middleware present: guarded recursive dispatch. This mirrors core `compose()`
|
|
117
|
+
// so per-route middleware behave identically to application middleware:
|
|
118
|
+
// - `ctx.next()` (modern) and the `(ctx, next)` argument advance the SAME chain
|
|
119
|
+
// (ctx.setNext is wired before each layer),
|
|
120
|
+
// - a synchronous throw is turned into a rejected promise (proper propagation),
|
|
121
|
+
// - calling next() more than once in a layer rejects with a clear error.
|
|
122
|
+
return (ctx: Context): Promise<void> => {
|
|
123
|
+
let index = -1;
|
|
124
|
+
|
|
125
|
+
const dispatch = (i: number): Promise<void> => {
|
|
126
|
+
if (i <= index) {
|
|
127
|
+
return Promise.reject(new Error('next() called multiple times'));
|
|
128
|
+
}
|
|
129
|
+
index = i;
|
|
130
|
+
|
|
131
|
+
if (i < len) {
|
|
132
|
+
const mw = middleware[i];
|
|
133
|
+
if (mw === undefined) return Promise.reject(new Error('middleware length mismatch'));
|
|
134
|
+
const next = (): Promise<void> => dispatch(i + 1);
|
|
135
|
+
if (ctx.setNext) ctx.setNext(next);
|
|
136
|
+
try {
|
|
137
|
+
return Promise.resolve(mw(ctx, next));
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Final handler. A handler calling next() is a safe no-op.
|
|
144
|
+
if (ctx.setNext) ctx.setNext(NOOP_NEXT);
|
|
145
|
+
try {
|
|
146
|
+
return Promise.resolve(handler(ctx, NOOP_NEXT));
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return dispatch(0);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create a new segment trie node
|
|
158
|
+
*/
|
|
159
|
+
export function createNode(segment: string, type: NodeType = NodeType.STATIC): TrieNode {
|
|
160
|
+
return {
|
|
161
|
+
segment,
|
|
162
|
+
type,
|
|
163
|
+
children: new Map(),
|
|
164
|
+
handlers: new Map(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Reset a trie node to its empty state — clears children/handlers and drops the
|
|
170
|
+
* param/wildcard branches. Used by `Router.reset()` for test isolation and
|
|
171
|
+
* hot-reload re-registration.
|
|
172
|
+
*/
|
|
173
|
+
export function clearNode(node: TrieNode): void {
|
|
174
|
+
node.children.clear();
|
|
175
|
+
node.handlers.clear();
|
|
176
|
+
node.paramChild = undefined;
|
|
177
|
+
node.wildcardChild = undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Parse path segments
|
|
182
|
+
* Splits path into segments and identifies param/wildcard types
|
|
183
|
+
*
|
|
184
|
+
* @param path - Route path to parse
|
|
185
|
+
* @param caseSensitive - If false, lowercase static segments for case-insensitive matching
|
|
186
|
+
*/
|
|
187
|
+
export function parseSegments(path: string, caseSensitive = true): ParsedSegment[] {
|
|
188
|
+
const normalized = path.startsWith('/') ? path.slice(1) : path;
|
|
189
|
+
if (normalized === '') return [];
|
|
190
|
+
|
|
191
|
+
const parts = normalized.split('/');
|
|
192
|
+
const segments: ParsedSegment[] = [];
|
|
193
|
+
|
|
194
|
+
for (const part of parts) {
|
|
195
|
+
if (part.startsWith(':')) {
|
|
196
|
+
// Preserve the original parameter name case
|
|
197
|
+
const paramName = part.slice(1);
|
|
198
|
+
segments.push({
|
|
199
|
+
segment: part, // Preserve original case — param nodes match any segment
|
|
200
|
+
type: NodeType.PARAM,
|
|
201
|
+
paramName, // Keep original case
|
|
202
|
+
});
|
|
203
|
+
} else if (part === '*') {
|
|
204
|
+
segments.push({
|
|
205
|
+
segment: '*',
|
|
206
|
+
type: NodeType.WILDCARD,
|
|
207
|
+
});
|
|
208
|
+
break; // Wildcard must be last
|
|
209
|
+
} else {
|
|
210
|
+
segments.push({
|
|
211
|
+
segment: caseSensitive ? part : part.toLowerCase(),
|
|
212
|
+
type: NodeType.STATIC,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return segments;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parsed segment structure
|
|
222
|
+
*/
|
|
223
|
+
export interface ParsedSegment {
|
|
224
|
+
segment: string;
|
|
225
|
+
type: NodeType;
|
|
226
|
+
paramName?: string;
|
|
227
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
maxDepth: 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/walk-pool.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Reused walk-frame pool (F-02, `reduce-router-match-allocations`)
|
|
3
|
+
*
|
|
4
|
+
* Split out of `matching.ts` once the pool implementation pushed it over the
|
|
5
|
+
* 300-line file cap — same reasoning `match-route.ts`'s own extraction used.
|
|
6
|
+
* Holds the pooled scratch structure and its indexed-cursor variant of the
|
|
7
|
+
* tree walk; `matching.ts`'s `matchNodeIndexed` delegates here when a pool is
|
|
8
|
+
* supplied and keeps its own fresh-allocation behavior otherwise.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HttpMethod } from '@nextrush/types';
|
|
15
|
+
import type { HandlerEntry, TrieNode } from './segment-trie';
|
|
16
|
+
import { decodeParam, segmentAt } from './matching';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One node in the iterative walk's explicit stack. `stage` is a small state
|
|
20
|
+
* machine (0 = extract + try static, 1 = try param, 2 = try wildcard/backtrack)
|
|
21
|
+
* so a single frame can be revisited on backtrack without recursion. `bound`
|
|
22
|
+
* records whether this frame pushed a deferred param binding, so backtracking
|
|
23
|
+
* can pop it without an object-property delete.
|
|
24
|
+
*/
|
|
25
|
+
export interface WalkFrame {
|
|
26
|
+
node: TrieNode;
|
|
27
|
+
pos: number;
|
|
28
|
+
stage: 0 | 1 | 2;
|
|
29
|
+
seg: string;
|
|
30
|
+
next: number;
|
|
31
|
+
bound: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-router-instance reused scratch space for the tree walk — the
|
|
36
|
+
* `WalkFrame[]` stack and the `bindNames`/`bindValues` binding arrays,
|
|
37
|
+
* pre-sized to the deepest currently registered route
|
|
38
|
+
* (`RegistrationState.maxDepth`) instead of allocated fresh on every
|
|
39
|
+
* `matchNodeIndexed` call.
|
|
40
|
+
*
|
|
41
|
+
* Safe under the router's synchronous-walk invariant only: a request path
|
|
42
|
+
* can never make the walk descend past `maxDepth` (a mismatched segment
|
|
43
|
+
* backtracks — the depth cursor decrements — rather than growing past the
|
|
44
|
+
* pool), and the walk never awaits mid-frame, so no two in-flight matches on
|
|
45
|
+
* the same router instance can observe each other's frames. See the
|
|
46
|
+
* `router` capability's "Reused internal walk state is never shared across
|
|
47
|
+
* concurrent in-flight matches" requirement.
|
|
48
|
+
*/
|
|
49
|
+
export interface WalkPool {
|
|
50
|
+
frames: WalkFrame[];
|
|
51
|
+
bindNames: string[];
|
|
52
|
+
bindValues: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a `WalkPool` sized to hold `maxDepth` matched segments. The walk's
|
|
57
|
+
* frame at index 0 always represents the trie ROOT itself (mirroring the
|
|
58
|
+
* unpooled walk's `stack[0] = { node: root, ... }`), so a route with
|
|
59
|
+
* `maxDepth` segments needs `maxDepth + 1` frames — one for the root plus one
|
|
60
|
+
* per descended segment. Called once when a router's `maxDepth` grows
|
|
61
|
+
* (registration time), never per-request.
|
|
62
|
+
*/
|
|
63
|
+
export function createWalkPool(maxDepth: number): WalkPool {
|
|
64
|
+
const frames: WalkFrame[] = [];
|
|
65
|
+
for (let i = 0; i < maxDepth + 1; i++) {
|
|
66
|
+
frames.push({
|
|
67
|
+
node: undefined as unknown as TrieNode,
|
|
68
|
+
pos: 0,
|
|
69
|
+
stage: 0,
|
|
70
|
+
seg: '',
|
|
71
|
+
next: 0,
|
|
72
|
+
bound: false,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { frames, bindNames: [], bindValues: [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pooled variant of `matchNodeIndexed` — same stage-machine, same precedence,
|
|
80
|
+
* same backtrack semantics, byte-identical results — but indexes into
|
|
81
|
+
* `pool.frames` by depth (mutating each pre-allocated frame in place) instead
|
|
82
|
+
* of `push`/`pop`-ing fresh frame objects onto a fresh array. `depth` is a
|
|
83
|
+
* local cursor, never shared across calls; only the underlying frame OBJECTS
|
|
84
|
+
* are reused. Safe only because the walk never awaits mid-frame and a
|
|
85
|
+
* mismatched segment decrements `depth` (backtrack) rather than growing past
|
|
86
|
+
* `pool.frames.length` (bounded by the router's `maxDepth` at registration
|
|
87
|
+
* time — see {@link createWalkPool}).
|
|
88
|
+
*/
|
|
89
|
+
export function matchNodeIndexedPooled(
|
|
90
|
+
root: TrieNode,
|
|
91
|
+
path: string,
|
|
92
|
+
startPos: number,
|
|
93
|
+
bindNames: string[],
|
|
94
|
+
bindValues: string[],
|
|
95
|
+
method: HttpMethod,
|
|
96
|
+
decode: boolean,
|
|
97
|
+
pool: WalkPool,
|
|
98
|
+
originalPath?: string
|
|
99
|
+
): HandlerEntry | null {
|
|
100
|
+
const { frames } = pool;
|
|
101
|
+
let depth = 0;
|
|
102
|
+
const first = frames[0];
|
|
103
|
+
if (first === undefined) return null; // maxDepth 0 — no param routes registered, nothing to walk
|
|
104
|
+
first.node = root;
|
|
105
|
+
first.pos = startPos;
|
|
106
|
+
first.stage = 0;
|
|
107
|
+
first.seg = '';
|
|
108
|
+
first.next = 0;
|
|
109
|
+
first.bound = false;
|
|
110
|
+
|
|
111
|
+
while (depth >= 0) {
|
|
112
|
+
const frame = frames[depth];
|
|
113
|
+
if (frame === undefined) break;
|
|
114
|
+
|
|
115
|
+
if (frame.stage === 0) {
|
|
116
|
+
if (frame.pos >= path.length) {
|
|
117
|
+
const handler = frame.node.handlers.get(method);
|
|
118
|
+
if (handler) return handler;
|
|
119
|
+
depth--;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const slashPos = path.indexOf('/', frame.pos);
|
|
123
|
+
if (slashPos === -1) {
|
|
124
|
+
frame.seg = path.slice(frame.pos);
|
|
125
|
+
frame.next = path.length;
|
|
126
|
+
} else {
|
|
127
|
+
frame.seg = path.slice(frame.pos, slashPos);
|
|
128
|
+
frame.next = slashPos + 1;
|
|
129
|
+
}
|
|
130
|
+
if (frame.seg === '') {
|
|
131
|
+
const handler = frame.node.handlers.get(method);
|
|
132
|
+
if (handler) return handler;
|
|
133
|
+
depth--;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
frame.stage = 1;
|
|
137
|
+
const staticChild = frame.node.children.get(frame.seg);
|
|
138
|
+
if (staticChild) {
|
|
139
|
+
depth++;
|
|
140
|
+
const next = frames[depth];
|
|
141
|
+
if (next === undefined) {
|
|
142
|
+
// Should be unreachable — pool sized to maxDepth — but fail closed
|
|
143
|
+
// (treat as a miss) rather than write past the pooled array.
|
|
144
|
+
depth--;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
next.node = staticChild;
|
|
148
|
+
next.pos = frame.next;
|
|
149
|
+
next.stage = 0;
|
|
150
|
+
next.seg = '';
|
|
151
|
+
next.next = 0;
|
|
152
|
+
next.bound = false;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (frame.stage === 1) {
|
|
158
|
+
frame.stage = 2;
|
|
159
|
+
const paramChild = frame.node.paramChild;
|
|
160
|
+
if (paramChild) {
|
|
161
|
+
const paramName = paramChild.paramName;
|
|
162
|
+
if (paramName === undefined) return null;
|
|
163
|
+
const value =
|
|
164
|
+
originalPath !== undefined
|
|
165
|
+
? decodeParam(segmentAt(originalPath, frame.pos), decode)
|
|
166
|
+
: decodeParam(frame.seg, decode);
|
|
167
|
+
bindNames.push(paramName);
|
|
168
|
+
bindValues.push(value);
|
|
169
|
+
frame.bound = true;
|
|
170
|
+
depth++;
|
|
171
|
+
const next = frames[depth];
|
|
172
|
+
if (next === undefined) {
|
|
173
|
+
bindNames.pop();
|
|
174
|
+
bindValues.pop();
|
|
175
|
+
frame.bound = false;
|
|
176
|
+
depth--;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
next.node = paramChild;
|
|
180
|
+
next.pos = frame.next;
|
|
181
|
+
next.stage = 0;
|
|
182
|
+
next.seg = '';
|
|
183
|
+
next.next = 0;
|
|
184
|
+
next.bound = false;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (frame.bound) {
|
|
190
|
+
bindNames.pop();
|
|
191
|
+
bindValues.pop();
|
|
192
|
+
frame.bound = false;
|
|
193
|
+
}
|
|
194
|
+
const wildcardChild = frame.node.wildcardChild;
|
|
195
|
+
if (wildcardChild) {
|
|
196
|
+
const src = originalPath ?? path;
|
|
197
|
+
bindNames.push('*');
|
|
198
|
+
bindValues.push(decodeParam(src.slice(frame.pos), decode));
|
|
199
|
+
const handler = wildcardChild.handlers.get(method);
|
|
200
|
+
if (handler) return handler;
|
|
201
|
+
bindNames.pop();
|
|
202
|
+
bindValues.pop();
|
|
203
|
+
}
|
|
204
|
+
depth--;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
}
|
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
|
-
}
|