@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
package/src/router.ts
CHANGED
|
@@ -1,1028 +1,375 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @nextrush/router - Router Implementation
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* radix tree. Supports parameters, wildcards, and method-based routing.
|
|
4
|
+
* The public `Router` shell: a thin, chainable facade delegating registration,
|
|
5
|
+
* matching, dispatch, composition, and grouping to focused sibling modules
|
|
6
|
+
* (design.md D1/D2). Segment trie keyed by whole path segments, not a radix tree.
|
|
8
7
|
*
|
|
9
8
|
* @packageDocumentation
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
11
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
HTTP_METHODS,
|
|
13
|
+
type HttpMethod,
|
|
14
|
+
type Middleware,
|
|
15
|
+
type RouteDefinition,
|
|
16
|
+
type RouteEntry,
|
|
17
|
+
type RouteHandler,
|
|
18
|
+
type RouteMatch,
|
|
19
|
+
type RouterOptions,
|
|
20
20
|
} from '@nextrush/types';
|
|
21
|
+
import { clearNode, createNode, type StaticRouteMap, type TrieNode } from './segment-trie';
|
|
22
|
+
import { type RedirectStatus } from './redirect';
|
|
23
|
+
import { runRouteGroup, type RouteGroup } from './group-router';
|
|
24
|
+
import { resolveMatch, type MatchState } from './match-route';
|
|
25
|
+
import { createWalkPool } from './walk-pool';
|
|
26
|
+
import { copyRoutes } from './composition';
|
|
27
|
+
import { sealRouterMiddleware as sealRouterMiddlewareImpl } from './middleware-adapter';
|
|
21
28
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
} from './
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
addRoute as addRouteImpl,
|
|
30
|
+
normalizeRegistrationPath,
|
|
31
|
+
pushAnyMethodDefinition,
|
|
32
|
+
registerRedirect,
|
|
33
|
+
type RegistrationState,
|
|
34
|
+
} from './registration';
|
|
35
|
+
import { createAllowedMethodsMiddleware, createRoutesMiddleware } from './dispatch';
|
|
36
|
+
import { createRouterState, resolveRouterOptions } from './state';
|
|
37
|
+
import { canonicalizePath } from './canonicalize';
|
|
38
|
+
|
|
39
|
+
/** '/'.charCodeAt(0) — used by {@link Router.matchesMountPrefix}'s boundary check. */
|
|
40
|
+
const SLASH_CHAR_CODE = 0x2f;
|
|
41
|
+
|
|
42
|
+
/** Inline route metadata declaration — re-exported from its own module (RT-3). */
|
|
43
|
+
export { endpoint } from './route-metadata';
|
|
35
44
|
|
|
36
45
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* number of segments. Static routes are additionally stored in a hash map
|
|
41
|
-
* for O(1) fast-path lookup.
|
|
42
|
-
*
|
|
43
|
-
* @example
|
|
44
|
-
* ```typescript
|
|
45
|
-
* const router = createRouter();
|
|
46
|
-
*
|
|
47
|
-
* router.get('/users', listUsers);
|
|
48
|
-
* router.get('/users/:id', getUser);
|
|
49
|
-
* router.post('/users', createUser);
|
|
50
|
-
*
|
|
51
|
-
* app.use(router.routes());
|
|
52
|
-
* ```
|
|
46
|
+
* High-performance segment-trie router: O(d) lookup by path segment, with a
|
|
47
|
+
* static-route hash map for an O(1) fast path.
|
|
48
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md | @nextrush/router README}
|
|
53
49
|
*/
|
|
54
50
|
export class Router {
|
|
55
|
-
private readonly root:
|
|
51
|
+
private readonly root: TrieNode;
|
|
56
52
|
private readonly opts: Required<RouterOptions>;
|
|
57
53
|
private readonly routerMiddleware: Middleware[] = [];
|
|
58
54
|
|
|
55
|
+
/** Static-route fast path: method-nested map for O(1) lookup with no per-request key string (HP-9). */
|
|
56
|
+
private readonly staticRoutes: StaticRouteMap = new Map();
|
|
57
|
+
|
|
59
58
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
59
|
+
* Introspection registry, kept SEPARATE from the hot-path trie/staticRoutes
|
|
60
|
+
* so request dispatch never reads metadata — only getRoutes() touches it.
|
|
62
61
|
*/
|
|
63
|
-
private readonly
|
|
62
|
+
private readonly routeDefinitions: RouteDefinition[] = [];
|
|
64
63
|
|
|
65
64
|
/** Whether any routes have params or wildcards (disables static-only fast path) */
|
|
66
65
|
private hasParamRoutes = false;
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.opts = {
|
|
71
|
-
prefix: options.prefix ?? '',
|
|
72
|
-
caseSensitive: options.caseSensitive ?? false,
|
|
73
|
-
strict: options.strict ?? false,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
67
|
+
/** Whether router-level middleware has already been sealed into executors (audit RT-7) */
|
|
68
|
+
private _sealed = false;
|
|
76
69
|
|
|
77
70
|
/**
|
|
78
|
-
*
|
|
71
|
+
* Single-entry memo for {@link matchesMountPrefix}'s canonicalization of the
|
|
72
|
+
* mount prefix, which is fixed at registration time. Declared as two fields
|
|
73
|
+
* rather than an object so a miss stores without allocating.
|
|
79
74
|
*/
|
|
80
|
-
private
|
|
81
|
-
|
|
82
|
-
let prefix = this.opts.prefix;
|
|
83
|
-
if (prefix.endsWith('/') && path.startsWith('/')) {
|
|
84
|
-
prefix = prefix.slice(0, -1);
|
|
85
|
-
}
|
|
75
|
+
private _prefixMemoRaw: string | undefined = undefined;
|
|
76
|
+
private _prefixMemoCanonical = '';
|
|
86
77
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// Fast-path: skip regex when no double slashes (99%+ of requests)
|
|
90
|
-
if (normalized.includes('//')) {
|
|
91
|
-
normalized = normalized.replace(/\/+/g, '/');
|
|
92
|
-
}
|
|
78
|
+
/** Memoized state the extracted registration/matching functions read (see {@link createRouterState}). */
|
|
79
|
+
private readonly state: RegistrationState & MatchState;
|
|
93
80
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
81
|
+
constructor(options: RouterOptions = {}) {
|
|
82
|
+
this.root = createNode('');
|
|
83
|
+
this.opts = resolveRouterOptions(options);
|
|
84
|
+
this.state = createRouterState(
|
|
85
|
+
this.root,
|
|
86
|
+
this.opts,
|
|
87
|
+
this.staticRoutes,
|
|
88
|
+
this.routeDefinitions,
|
|
89
|
+
this.routerMiddleware
|
|
90
|
+
);
|
|
100
91
|
}
|
|
101
92
|
|
|
102
93
|
/**
|
|
103
|
-
*
|
|
94
|
+
* Validate + normalize a raw path, then delegate trie insertion to the
|
|
95
|
+
* extracted `addRoute` (design.md D2); flips `hasParamRoutes` from its return.
|
|
104
96
|
*/
|
|
105
97
|
private addRoute(
|
|
106
98
|
method: HttpMethod,
|
|
107
99
|
path: string,
|
|
108
|
-
|
|
109
|
-
middleware: Middleware[] = []
|
|
100
|
+
entries: RouteEntry[],
|
|
101
|
+
middleware: Middleware[] = [],
|
|
102
|
+
recordIntrospection = true
|
|
110
103
|
): void {
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
for (const seg of segments) {
|
|
117
|
-
if (seg.type === NodeType.PARAM) {
|
|
118
|
-
if (!node.paramChild) {
|
|
119
|
-
node.paramChild = createNode(seg.segment, NodeType.PARAM);
|
|
120
|
-
node.paramChild.paramName = seg.paramName;
|
|
121
|
-
} else if (node.paramChild.paramName !== seg.paramName) {
|
|
122
|
-
// Warn about param name collision — same position, different names
|
|
123
|
-
// This helps catch accidental mismatches like :id vs :userId
|
|
124
|
-
if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
|
|
125
|
-
const existing = node.paramChild.paramName;
|
|
126
|
-
console.warn(
|
|
127
|
-
`[nextrush:router] Route param name conflict at "${normalized}": ` +
|
|
128
|
-
`":${String(seg.paramName)}" conflicts with existing ":${String(existing)}". ` +
|
|
129
|
-
`The existing name ":${String(existing)}" will be used.`
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
node = node.paramChild;
|
|
134
|
-
} else if (seg.type === NodeType.WILDCARD) {
|
|
135
|
-
node.wildcardChild ??= createNode('*', NodeType.WILDCARD);
|
|
136
|
-
node = node.wildcardChild;
|
|
137
|
-
break; // Wildcard must be last
|
|
138
|
-
} else {
|
|
139
|
-
const key = seg.segment;
|
|
140
|
-
let child = node.children.get(key);
|
|
141
|
-
if (!child) {
|
|
142
|
-
child = createNode(seg.segment, NodeType.STATIC);
|
|
143
|
-
node.children.set(key, child);
|
|
144
|
-
}
|
|
145
|
-
node = child;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Combine multiple handlers into single handler with inline middleware
|
|
150
|
-
const combinedMiddleware = [...middleware];
|
|
151
|
-
const finalHandler = handlers[handlers.length - 1];
|
|
152
|
-
|
|
153
|
-
if (!finalHandler) {
|
|
154
|
-
throw new Error('At least one handler is required');
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const inlineMiddleware = handlers.slice(0, -1);
|
|
158
|
-
|
|
159
|
-
// Add inline middleware (handlers before the last one)
|
|
160
|
-
for (const mw of inlineMiddleware) {
|
|
161
|
-
combinedMiddleware.push(mw);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Pre-compile executor at registration time (not per-request!)
|
|
165
|
-
const executor = compileExecutor(finalHandler, combinedMiddleware);
|
|
166
|
-
|
|
167
|
-
const entry: HandlerEntry = {
|
|
168
|
-
handler: finalHandler,
|
|
169
|
-
middleware: combinedMiddleware,
|
|
170
|
-
executor,
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
// Detect duplicate route registration
|
|
174
|
-
if (node.handlers.has(method)) {
|
|
175
|
-
throw new Error(
|
|
176
|
-
`Route conflict: ${method} ${normalized} is already registered. ` +
|
|
177
|
-
'Remove the duplicate or use a different path.'
|
|
104
|
+
// Guard untyped-JS callers: a non-string path would coerce to a bogus literal route.
|
|
105
|
+
const rawPath: unknown = path;
|
|
106
|
+
if (typeof rawPath !== 'string') {
|
|
107
|
+
throw new TypeError(
|
|
108
|
+
`Route path must be a string, received ${rawPath === null ? 'null' : typeof rawPath}.`
|
|
178
109
|
);
|
|
179
110
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// Populate static route hash map for O(1) lookup
|
|
184
|
-
const hasParams = segments.some(
|
|
185
|
-
(s) => s.type === NodeType.PARAM || s.type === NodeType.WILDCARD
|
|
186
|
-
);
|
|
187
|
-
if (hasParams) {
|
|
111
|
+
const normalized = normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict);
|
|
112
|
+
const depthBefore = this.state.maxDepth;
|
|
113
|
+
if (addRouteImpl(method, normalized, entries, middleware, this.state, recordIntrospection)) {
|
|
188
114
|
this.hasParamRoutes = true;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
115
|
+
}
|
|
116
|
+
// Rebuild the pool only when maxDepth actually grew (F-02) — a cheap,
|
|
117
|
+
// registration-time-only check; never runs per-request.
|
|
118
|
+
if (this.state.maxDepth > depthBefore) {
|
|
119
|
+
this.state.walkPool = createWalkPool(this.state.maxDepth);
|
|
192
120
|
}
|
|
193
121
|
}
|
|
194
122
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// ===========================================================================
|
|
198
|
-
|
|
199
|
-
get(path: string, ...handlers: RouteHandler[]): this {
|
|
200
|
-
this.addRoute('GET', path, handlers);
|
|
123
|
+
get(path: string, ...entries: RouteEntry[]): this {
|
|
124
|
+
this.addRoute('GET', path, entries);
|
|
201
125
|
return this;
|
|
202
126
|
}
|
|
203
127
|
|
|
204
|
-
post(path: string, ...
|
|
205
|
-
this.addRoute('POST', path,
|
|
128
|
+
post(path: string, ...entries: RouteEntry[]): this {
|
|
129
|
+
this.addRoute('POST', path, entries);
|
|
206
130
|
return this;
|
|
207
131
|
}
|
|
208
132
|
|
|
209
|
-
put(path: string, ...
|
|
210
|
-
this.addRoute('PUT', path,
|
|
133
|
+
put(path: string, ...entries: RouteEntry[]): this {
|
|
134
|
+
this.addRoute('PUT', path, entries);
|
|
211
135
|
return this;
|
|
212
136
|
}
|
|
213
137
|
|
|
214
|
-
delete(path: string, ...
|
|
215
|
-
this.addRoute('DELETE', path,
|
|
138
|
+
delete(path: string, ...entries: RouteEntry[]): this {
|
|
139
|
+
this.addRoute('DELETE', path, entries);
|
|
216
140
|
return this;
|
|
217
141
|
}
|
|
218
142
|
|
|
219
|
-
patch(path: string, ...
|
|
220
|
-
this.addRoute('PATCH', path,
|
|
143
|
+
patch(path: string, ...entries: RouteEntry[]): this {
|
|
144
|
+
this.addRoute('PATCH', path, entries);
|
|
221
145
|
return this;
|
|
222
146
|
}
|
|
223
147
|
|
|
224
|
-
head(path: string, ...
|
|
225
|
-
this.addRoute('HEAD', path,
|
|
148
|
+
head(path: string, ...entries: RouteEntry[]): this {
|
|
149
|
+
this.addRoute('HEAD', path, entries);
|
|
226
150
|
return this;
|
|
227
151
|
}
|
|
228
152
|
|
|
229
|
-
options(path: string, ...
|
|
230
|
-
this.addRoute('OPTIONS', path,
|
|
153
|
+
options(path: string, ...entries: RouteEntry[]): this {
|
|
154
|
+
this.addRoute('OPTIONS', path, entries);
|
|
231
155
|
return this;
|
|
232
156
|
}
|
|
233
157
|
|
|
234
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Register a route for every HTTP method under one consolidated `isAnyMethod`
|
|
160
|
+
* introspection row (T016) — matching is unchanged (see {@link pushAnyMethodDefinition}).
|
|
161
|
+
*/
|
|
162
|
+
all(path: string, ...entries: RouteEntry[]): this {
|
|
163
|
+
// recordIntrospection=false: insert each per-method handler without its own
|
|
164
|
+
// introspection row; the single consolidated row below replaces all 7.
|
|
235
165
|
for (const method of HTTP_METHODS) {
|
|
236
|
-
this.addRoute(method, path,
|
|
166
|
+
this.addRoute(method, path, entries, [], false);
|
|
237
167
|
}
|
|
168
|
+
pushAnyMethodDefinition(
|
|
169
|
+
this.routeDefinitions,
|
|
170
|
+
normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict)
|
|
171
|
+
);
|
|
238
172
|
return this;
|
|
239
173
|
}
|
|
240
174
|
|
|
241
|
-
route(method: HttpMethod, path: string, ...
|
|
242
|
-
this.addRoute(method, path,
|
|
175
|
+
route(method: HttpMethod, path: string, ...entries: RouteEntry[]): this {
|
|
176
|
+
this.addRoute(method, path, entries);
|
|
243
177
|
return this;
|
|
244
178
|
}
|
|
245
179
|
|
|
246
180
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* @param from - Source path to redirect from
|
|
250
|
-
* @param to - Target path or URL to redirect to
|
|
251
|
-
* @param status - HTTP status code (default: 301 permanent redirect)
|
|
252
|
-
* @returns this for chaining
|
|
253
|
-
*
|
|
254
|
-
* @example
|
|
255
|
-
* ```typescript
|
|
256
|
-
* // Permanent redirect (301)
|
|
257
|
-
* router.redirect('/old-page', '/new-page');
|
|
258
|
-
*
|
|
259
|
-
* // Temporary redirect (302)
|
|
260
|
-
* router.redirect('/temp', '/destination', 302);
|
|
261
|
-
*
|
|
262
|
-
* // Redirect to external URL
|
|
263
|
-
* router.redirect('/docs', 'https://docs.example.com');
|
|
264
|
-
*
|
|
265
|
-
* // With parameters - redirects /users/:id to /profiles/:id
|
|
266
|
-
* router.redirect('/users/:id', '/profiles/:id');
|
|
267
|
-
* ```
|
|
181
|
+
* Every registered route as a read-only list, for renderers (`@nextrush/openapi`,
|
|
182
|
+
* SDK/RPC generators). Doc-generation-time only — never on the request path.
|
|
268
183
|
*/
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// array of alternating literal / param-name entries so the per-request
|
|
273
|
-
// handler can substitute without sorting or scanning the string.
|
|
274
|
-
//
|
|
275
|
-
// Only `:` preceded by `/` or at position 0 is a param slot. This
|
|
276
|
-
// avoids misinterpreting `https://` or other non-route colons.
|
|
277
|
-
let compiledParts: string[] | undefined;
|
|
278
|
-
|
|
279
|
-
{
|
|
280
|
-
const parts: string[] = [];
|
|
281
|
-
let pos = 0;
|
|
282
|
-
let found = false;
|
|
283
|
-
|
|
284
|
-
while (pos < to.length) {
|
|
285
|
-
// Find next `:` that looks like a route param
|
|
286
|
-
let idx = -1;
|
|
287
|
-
for (let i = pos; i < to.length; i++) {
|
|
288
|
-
if (
|
|
289
|
-
to[i] === ':' &&
|
|
290
|
-
(i === 0 || to[i - 1] === '/') &&
|
|
291
|
-
i + 1 < to.length &&
|
|
292
|
-
to[i + 1] !== '/'
|
|
293
|
-
) {
|
|
294
|
-
idx = i;
|
|
295
|
-
break;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (idx === -1) break;
|
|
299
|
-
|
|
300
|
-
found = true;
|
|
301
|
-
parts.push(to.slice(pos, idx)); // literal before ':'
|
|
302
|
-
const end = to.indexOf('/', idx + 1);
|
|
303
|
-
if (end === -1) {
|
|
304
|
-
parts.push(to.slice(idx + 1)); // param name (rest of string)
|
|
305
|
-
pos = to.length;
|
|
306
|
-
} else {
|
|
307
|
-
parts.push(to.slice(idx + 1, end)); // param name
|
|
308
|
-
pos = end;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (found) {
|
|
313
|
-
parts.push(to.slice(pos)); // trailing literal
|
|
314
|
-
compiledParts = parts;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const redirectHandler: RouteHandler = (ctx: Context) => {
|
|
319
|
-
let targetPath: string;
|
|
320
|
-
|
|
321
|
-
if (compiledParts) {
|
|
322
|
-
// Fast path: build from precompiled template
|
|
323
|
-
const params = ctx.params;
|
|
324
|
-
const parts = compiledParts;
|
|
325
|
-
const head = parts[0];
|
|
326
|
-
if (head === undefined) {
|
|
327
|
-
targetPath = to;
|
|
328
|
-
} else {
|
|
329
|
-
let result = head;
|
|
330
|
-
for (let i = 1; i < parts.length - 1; i += 2) {
|
|
331
|
-
const paramKey = parts[i];
|
|
332
|
-
const tail = parts[i + 1];
|
|
333
|
-
if (paramKey === undefined || tail === undefined) break;
|
|
334
|
-
result += (params[paramKey] ?? '') + tail;
|
|
335
|
-
}
|
|
336
|
-
targetPath = result;
|
|
337
|
-
}
|
|
338
|
-
} else {
|
|
339
|
-
targetPath = to;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
ctx.status = status;
|
|
343
|
-
ctx.set('Location', targetPath);
|
|
344
|
-
ctx.body = '';
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
// Register for common methods. 307/308 preserve the original method,
|
|
348
|
-
// so register all standard methods for those status codes.
|
|
349
|
-
this.addRoute('GET', from, [redirectHandler]);
|
|
350
|
-
this.addRoute('HEAD', from, [redirectHandler]);
|
|
351
|
-
if (status === 307 || status === 308) {
|
|
352
|
-
this.addRoute('POST', from, [redirectHandler]);
|
|
353
|
-
this.addRoute('PUT', from, [redirectHandler]);
|
|
354
|
-
this.addRoute('PATCH', from, [redirectHandler]);
|
|
355
|
-
this.addRoute('DELETE', from, [redirectHandler]);
|
|
356
|
-
}
|
|
184
|
+
getRoutes(): readonly RouteDefinition[] {
|
|
185
|
+
return this.routeDefinitions;
|
|
186
|
+
}
|
|
357
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Register a redirect from one path to another (301 by default). 307/308
|
|
190
|
+
* additionally register POST/PUT/PATCH/DELETE to preserve the method.
|
|
191
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#redirects | README: Redirects}
|
|
192
|
+
*/
|
|
193
|
+
redirect(from: string, to: string, status: RedirectStatus = 301): this {
|
|
194
|
+
registerRedirect(from, to, status, (method, path, entries) => {
|
|
195
|
+
this.addRoute(method, path, entries);
|
|
196
|
+
});
|
|
358
197
|
return this;
|
|
359
198
|
}
|
|
360
199
|
|
|
361
|
-
// ===========================================================================
|
|
362
|
-
// Router Composition
|
|
363
|
-
// ===========================================================================
|
|
364
|
-
|
|
365
200
|
use(pathOrMiddleware: string | Middleware | Router, routerOrUndefined?: Router): this {
|
|
366
201
|
if (typeof pathOrMiddleware === 'function') {
|
|
367
|
-
// Middleware function
|
|
368
202
|
this.routerMiddleware.push(pathOrMiddleware);
|
|
369
203
|
} else if (typeof pathOrMiddleware === 'string' && routerOrUndefined instanceof Router) {
|
|
370
|
-
// Mount sub-router at path
|
|
371
204
|
this.mountRouter(pathOrMiddleware, routerOrUndefined);
|
|
372
205
|
} else if (typeof pathOrMiddleware === 'string') {
|
|
373
|
-
// String prefix without a Router — unsupported, throw clear error
|
|
374
206
|
throw new Error(
|
|
375
207
|
`router.use('${pathOrMiddleware}', ...) requires a Router instance as the second argument. ` +
|
|
376
208
|
'Use router.group(prefix, callback) for prefix-scoped middleware, ' +
|
|
377
209
|
'or router.use(middlewareFn) to register middleware without a prefix.'
|
|
378
210
|
);
|
|
379
211
|
} else if (pathOrMiddleware instanceof Router) {
|
|
380
|
-
// Mount router at root
|
|
381
212
|
this.mountRouter('', pathOrMiddleware);
|
|
382
213
|
}
|
|
383
214
|
return this;
|
|
384
215
|
}
|
|
385
216
|
|
|
386
217
|
/**
|
|
387
|
-
* Mount a sub-router at a path prefix (Hono-style)
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* Equivalent to `router.use(path, subRouter)` but more semantic.
|
|
391
|
-
*
|
|
392
|
-
* @param path - Path prefix for the sub-router
|
|
393
|
-
* @param router - Router instance to mount
|
|
394
|
-
* @returns this for chaining
|
|
395
|
-
*
|
|
396
|
-
* @example
|
|
397
|
-
* ```typescript
|
|
398
|
-
* // Create modular routers
|
|
399
|
-
* const users = createRouter();
|
|
400
|
-
* users.get('/', listUsers);
|
|
401
|
-
* users.get('/:id', getUser);
|
|
402
|
-
*
|
|
403
|
-
* const posts = createRouter();
|
|
404
|
-
* posts.get('/', listPosts);
|
|
405
|
-
*
|
|
406
|
-
* // Mount sub-routers
|
|
407
|
-
* const api = createRouter();
|
|
408
|
-
* api.mount('/users', users);
|
|
409
|
-
* api.mount('/posts', posts);
|
|
410
|
-
*
|
|
411
|
-
* // Or use on main router
|
|
412
|
-
* const router = createRouter();
|
|
413
|
-
* router.mount('/api', api);
|
|
414
|
-
*
|
|
415
|
-
* app.use(router.routes());
|
|
416
|
-
* ```
|
|
218
|
+
* Mount a sub-router at a path prefix (Hono-style) — the explicit, more
|
|
219
|
+
* semantic equivalent of `router.use(path, subRouter)`.
|
|
220
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#sub-router-mounting | README: Sub-Router Mounting}
|
|
417
221
|
*/
|
|
418
222
|
mount(path: string, router: Router): this {
|
|
419
223
|
this.mountRouter(path, router);
|
|
420
224
|
return this;
|
|
421
225
|
}
|
|
422
226
|
|
|
423
|
-
/**
|
|
424
|
-
* Mount a sub-router (internal)
|
|
425
|
-
*
|
|
426
|
-
* Carries the sub-router's own `routerMiddleware` forward so that
|
|
427
|
-
* `subrouter.use(mw)` middleware applies to every copied route.
|
|
428
|
-
*/
|
|
227
|
+
/** Mount a sub-router, carrying its own `routerMiddleware` onto every copied route. */
|
|
429
228
|
private mountRouter(prefix: string, router: Router): void {
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Recursively copy routes from another router
|
|
435
|
-
*/
|
|
436
|
-
private copyRoutes(
|
|
437
|
-
node: RadixNode,
|
|
438
|
-
prefix: string,
|
|
439
|
-
segments: string[],
|
|
440
|
-
subRouterMiddleware: Middleware[]
|
|
441
|
-
): void {
|
|
442
|
-
// Copy handlers at this node
|
|
443
|
-
for (const [method, entry] of node.handlers) {
|
|
444
|
-
const path = prefix + '/' + segments.join('/');
|
|
445
|
-
// Prepend sub-router middleware so it runs before the route's own middleware
|
|
446
|
-
const combined =
|
|
447
|
-
subRouterMiddleware.length > 0
|
|
448
|
-
? [...subRouterMiddleware, ...entry.middleware]
|
|
449
|
-
: entry.middleware;
|
|
450
|
-
this.addRoute(method, path || '/', [entry.handler], combined);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Copy static children
|
|
454
|
-
for (const [, child] of node.children) {
|
|
455
|
-
this.copyRoutes(child, prefix, [...segments, child.segment], subRouterMiddleware);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Copy param child
|
|
459
|
-
if (node.paramChild) {
|
|
460
|
-
this.copyRoutes(
|
|
461
|
-
node.paramChild,
|
|
462
|
-
prefix,
|
|
463
|
-
[...segments, node.paramChild.segment],
|
|
464
|
-
subRouterMiddleware
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Copy wildcard child
|
|
469
|
-
if (node.wildcardChild) {
|
|
470
|
-
this.copyRoutes(node.wildcardChild, prefix, [...segments, '*'], subRouterMiddleware);
|
|
471
|
-
}
|
|
229
|
+
copyRoutes(router.root, prefix, [], router.routerMiddleware, this.addRoute.bind(this));
|
|
472
230
|
}
|
|
473
231
|
|
|
474
|
-
|
|
475
|
-
// Route Matching
|
|
476
|
-
// ===========================================================================
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Match a route and return handler + params
|
|
480
|
-
*/
|
|
232
|
+
/** Match a request to a route — delegates to {@link resolveMatch} (design.md D1). */
|
|
481
233
|
match(method: HttpMethod, path: string): RouteMatch | null {
|
|
482
|
-
|
|
483
|
-
let normalized = isCaseInsensitive ? path.toLowerCase() : path;
|
|
484
|
-
|
|
485
|
-
// Fast-path: skip regex when no double slashes (99%+ of requests)
|
|
486
|
-
if (normalized.includes('//')) {
|
|
487
|
-
normalized = normalized.replace(/\/+/g, '/');
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// For strict mode, keep trailing slash; otherwise remove it
|
|
491
|
-
if (!this.opts.strict && normalized.length > 1 && normalized.endsWith('/')) {
|
|
492
|
-
normalized = normalized.slice(0, -1);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// FAST PATH: O(1) static route lookup (no tree traversal)
|
|
496
|
-
// For static routes, trailing slash is irrelevant — always strip for lookup
|
|
497
|
-
const staticKey =
|
|
498
|
-
normalized.length > 1 && normalized.endsWith('/')
|
|
499
|
-
? `${method} ${normalized.slice(0, -1)}`
|
|
500
|
-
: `${method} ${normalized}`;
|
|
501
|
-
const staticEntry = this.staticRoutes.get(staticKey);
|
|
502
|
-
if (staticEntry) {
|
|
503
|
-
return {
|
|
504
|
-
handler: staticEntry.handler,
|
|
505
|
-
params: EMPTY_PARAMS,
|
|
506
|
-
middleware: this.routerMiddleware,
|
|
507
|
-
executor: staticEntry.executor,
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Only walk tree if we have param/wildcard routes
|
|
512
|
-
if (!this.hasParamRoutes) return null;
|
|
513
|
-
|
|
514
|
-
// Use index-based path scanning instead of split('/').filter(Boolean)
|
|
515
|
-
const params: Record<string, string> = {};
|
|
516
|
-
|
|
517
|
-
// For case-insensitive mode, preserve original-case path for param values
|
|
518
|
-
let originalPath: string | undefined;
|
|
519
|
-
if (isCaseInsensitive) {
|
|
520
|
-
originalPath = path;
|
|
521
|
-
if (originalPath.includes('//')) {
|
|
522
|
-
originalPath = originalPath.replace(/\/+/g, '/');
|
|
523
|
-
}
|
|
524
|
-
if (!this.opts.strict && originalPath.length > 1 && originalPath.endsWith('/')) {
|
|
525
|
-
originalPath = originalPath.slice(0, -1);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const result = this.matchNodeIndexed(
|
|
530
|
-
this.root,
|
|
531
|
-
normalized,
|
|
532
|
-
1, // Start after leading '/'
|
|
533
|
-
params,
|
|
534
|
-
method,
|
|
535
|
-
originalPath
|
|
536
|
-
);
|
|
537
|
-
if (!result) return null;
|
|
538
|
-
|
|
539
|
-
// Check if any params were actually set
|
|
540
|
-
let hasParams = false;
|
|
541
|
-
for (const key of Object.keys(params)) {
|
|
542
|
-
if (params[key] === undefined) {
|
|
543
|
-
Reflect.deleteProperty(params, key);
|
|
544
|
-
} else {
|
|
545
|
-
hasParams = true;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
return {
|
|
550
|
-
handler: result.handler,
|
|
551
|
-
params: hasParams ? params : EMPTY_PARAMS,
|
|
552
|
-
middleware: this.routerMiddleware,
|
|
553
|
-
executor: result.executor,
|
|
554
|
-
};
|
|
234
|
+
return resolveMatch(this.state, this.hasParamRoutes, method, path);
|
|
555
235
|
}
|
|
556
236
|
|
|
557
237
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
* Index-based recursive node matching (avoids array allocation)
|
|
238
|
+
* Test whether `path` falls under `prefix` using this router's OWN
|
|
239
|
+
* canonicalization (case folding per `caseSensitive`, structural
|
|
240
|
+
* normalization) — the mount-boundary counterpart to {@link match}, so a
|
|
241
|
+
* router mounted via `Application.route()` is tested with the identical
|
|
242
|
+
* rule it dispatches with (RFC-029, task 3.8). Implements the optional
|
|
243
|
+
* `Routable.matchesMountPrefix` contract from `@nextrush/core`.
|
|
244
|
+
*
|
|
245
|
+
* @param path - The full request path being tested for this mount.
|
|
246
|
+
* @param prefix - The normalized mount prefix (leading `/`, no trailing `/`).
|
|
247
|
+
* @returns The path's remainder past the prefix (e.g. `/users` for a
|
|
248
|
+
* `/ADMIN/Users` request mounted at `/admin`), or `undefined` when `path`
|
|
249
|
+
* is not under `prefix` per this router's canonicalization.
|
|
571
250
|
*/
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const staticChild = node.children.get(segment);
|
|
590
|
-
if (staticChild) {
|
|
591
|
-
const result = this.matchNodeIndexed(
|
|
592
|
-
staticChild,
|
|
593
|
-
path,
|
|
594
|
-
nextPos,
|
|
595
|
-
params,
|
|
596
|
-
method,
|
|
597
|
-
originalPath
|
|
598
|
-
);
|
|
599
|
-
if (result) return result;
|
|
251
|
+
matchesMountPrefix(path: string, prefix: string): string | undefined {
|
|
252
|
+
const canonical = canonicalizePath(path, this.opts.caseSensitive, this.opts.strict);
|
|
253
|
+
if (canonical.rejected) return undefined;
|
|
254
|
+
|
|
255
|
+
// The prefix is fixed at registration time, so canonicalizing it on every
|
|
256
|
+
// request is pure waste — it was ~150 ns of the ~557 ns each mounted router
|
|
257
|
+
// added to dispatch. Memoized rather than precomputed at `route()` time so
|
|
258
|
+
// the `Routable.matchesMountPrefix` contract still accepts any prefix
|
|
259
|
+
// string from any caller. One entry is enough: a router is normally mounted
|
|
260
|
+
// once, and a second prefix only costs a miss.
|
|
261
|
+
let canonicalPrefix: string;
|
|
262
|
+
if (this._prefixMemoRaw === prefix) {
|
|
263
|
+
canonicalPrefix = this._prefixMemoCanonical;
|
|
264
|
+
} else {
|
|
265
|
+
canonicalPrefix = canonicalizePath(prefix, this.opts.caseSensitive, this.opts.strict).path;
|
|
266
|
+
this._prefixMemoRaw = prefix;
|
|
267
|
+
this._prefixMemoCanonical = canonicalPrefix;
|
|
600
268
|
}
|
|
601
269
|
|
|
602
|
-
|
|
603
|
-
if (
|
|
604
|
-
const paramName = node.paramChild.paramName;
|
|
605
|
-
if (paramName === undefined) return null;
|
|
606
|
-
if (originalPath) {
|
|
607
|
-
const [origSeg] = this.extractSegment(originalPath, pos);
|
|
608
|
-
params[paramName] = origSeg;
|
|
609
|
-
} else {
|
|
610
|
-
params[paramName] = segment;
|
|
611
|
-
}
|
|
612
|
-
const result = this.matchNodeIndexed(
|
|
613
|
-
node.paramChild,
|
|
614
|
-
path,
|
|
615
|
-
nextPos,
|
|
616
|
-
params,
|
|
617
|
-
method,
|
|
618
|
-
originalPath
|
|
619
|
-
);
|
|
620
|
-
if (result) return result;
|
|
621
|
-
Reflect.deleteProperty(params, paramName);
|
|
622
|
-
}
|
|
270
|
+
const prefixLen = canonicalPrefix.length;
|
|
271
|
+
if (!canonical.path.startsWith(canonicalPrefix)) return undefined;
|
|
623
272
|
|
|
624
|
-
|
|
625
|
-
if (
|
|
626
|
-
|
|
627
|
-
params['*'] = src.slice(pos);
|
|
628
|
-
return node.wildcardChild.handlers.get(method) ?? null;
|
|
273
|
+
const hasCharAfterPrefix = prefixLen < canonical.path.length;
|
|
274
|
+
if (hasCharAfterPrefix && canonical.path.charCodeAt(prefixLen) !== SLASH_CHAR_CODE) {
|
|
275
|
+
return undefined;
|
|
629
276
|
}
|
|
630
277
|
|
|
631
|
-
return
|
|
278
|
+
return canonical.path.slice(prefixLen) || '/';
|
|
632
279
|
}
|
|
633
280
|
|
|
634
|
-
// ===========================================================================
|
|
635
|
-
// Middleware Generation
|
|
636
|
-
// ===========================================================================
|
|
637
|
-
|
|
638
281
|
/**
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
* @example
|
|
643
|
-
* ```typescript
|
|
644
|
-
* app.use(router.routes());
|
|
645
|
-
* ```
|
|
282
|
+
* Return the router's dispatch middleware — mount this on the application.
|
|
283
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#routerroutes | README: router.routes()}
|
|
646
284
|
*/
|
|
647
285
|
routes(): Middleware {
|
|
648
|
-
// Seal router
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
this.
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
// Set params on context
|
|
666
|
-
ctx.params = match.params;
|
|
667
|
-
|
|
668
|
-
// Use pre-compiled executor (includes router middleware if any)
|
|
669
|
-
if (match.executor) {
|
|
670
|
-
await match.executor(ctx);
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// Fallback: No executor (shouldn't happen but be safe)
|
|
675
|
-
await match.handler(ctx, NOOP_NEXT);
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* Re-compile all route executors to include router-level middleware.
|
|
681
|
-
* Called once when routes() is invoked, not per-request.
|
|
682
|
-
*/
|
|
683
|
-
private sealRouterMiddleware(): void {
|
|
684
|
-
const routerMw = [...this.routerMiddleware];
|
|
685
|
-
|
|
686
|
-
// Walk the tree and re-compile every handler entry
|
|
687
|
-
const walk = (node: RadixNode): void => {
|
|
688
|
-
for (const [method, entry] of node.handlers) {
|
|
689
|
-
const combinedMw = [...routerMw, ...entry.middleware];
|
|
690
|
-
entry.executor = compileExecutor(entry.handler, combinedMw);
|
|
691
|
-
node.handlers.set(method, entry);
|
|
692
|
-
}
|
|
693
|
-
for (const [, child] of node.children) {
|
|
694
|
-
walk(child);
|
|
695
|
-
}
|
|
696
|
-
if (node.paramChild) walk(node.paramChild);
|
|
697
|
-
if (node.wildcardChild) walk(node.wildcardChild);
|
|
698
|
-
};
|
|
699
|
-
|
|
700
|
-
walk(this.root);
|
|
701
|
-
|
|
702
|
-
// Also update static route entries
|
|
703
|
-
for (const [key, entry] of this.staticRoutes) {
|
|
704
|
-
const combinedMw = [...routerMw, ...entry.middleware];
|
|
705
|
-
entry.executor = compileExecutor(entry.handler, combinedMw);
|
|
706
|
-
this.staticRoutes.set(key, entry);
|
|
707
|
-
}
|
|
286
|
+
// Seal router middleware into every executor once (audit RT-7 idempotency):
|
|
287
|
+
// routes() may run more than once, so the _sealed guard prevents re-prepend.
|
|
288
|
+
if (this.routerMiddleware.length > 0 && !this._sealed) {
|
|
289
|
+
this._sealed = true;
|
|
290
|
+
sealRouterMiddlewareImpl(this.root, this.staticRoutes, this.routerMiddleware);
|
|
291
|
+
}
|
|
292
|
+
// F-10: the internal closure calls `resolveMatch` directly with
|
|
293
|
+
// `preNormalized: true` rather than going through the public `this.match()`
|
|
294
|
+
// — this is the one call path where the caller (`createRoutesMiddleware`)
|
|
295
|
+
// has already run `canonicalizePath()` on `path`, so `matchRoute`'s own
|
|
296
|
+
// fold+collapse re-derivation is skippable. `Router.match()` itself is
|
|
297
|
+
// untouched and still defaults to `false` for every other caller.
|
|
298
|
+
return createRoutesMiddleware(
|
|
299
|
+
(method, path) => resolveMatch(this.state, this.hasParamRoutes, method, path, true),
|
|
300
|
+
this.opts.caseSensitive,
|
|
301
|
+
this.opts.strict
|
|
302
|
+
);
|
|
708
303
|
}
|
|
709
304
|
|
|
710
305
|
/**
|
|
711
|
-
* Generate allowed
|
|
712
|
-
*
|
|
306
|
+
* Generate allowed-methods middleware. Responds to OPTIONS with an `Allow`
|
|
307
|
+
* header and returns 405 for a known path hit with an unregistered method.
|
|
713
308
|
*/
|
|
714
309
|
allowedMethods(): Middleware {
|
|
715
|
-
return
|
|
716
|
-
if (next) {
|
|
717
|
-
await next();
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
if (ctx.status !== 404) return;
|
|
721
|
-
|
|
722
|
-
// Single tree walk to find all allowed methods instead of N×match()
|
|
723
|
-
const allowed = this.findAllowedMethods(ctx.path);
|
|
724
|
-
|
|
725
|
-
if (allowed.length === 0) return;
|
|
726
|
-
|
|
727
|
-
const allowHeader = allowed.join(', ');
|
|
728
|
-
|
|
729
|
-
// If OPTIONS request, respond with allowed methods
|
|
730
|
-
if (ctx.method === 'OPTIONS') {
|
|
731
|
-
ctx.status = 200;
|
|
732
|
-
ctx.set('Allow', allowHeader);
|
|
733
|
-
ctx.body = '';
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Otherwise, return 405 Method Not Allowed
|
|
738
|
-
ctx.status = 405;
|
|
739
|
-
ctx.set('Allow', allowHeader);
|
|
740
|
-
};
|
|
310
|
+
return createAllowedMethodsMiddleware(this.root, this.opts.caseSensitive, this.opts.strict);
|
|
741
311
|
}
|
|
742
312
|
|
|
743
313
|
/**
|
|
744
|
-
*
|
|
745
|
-
* @
|
|
746
|
-
|
|
747
|
-
private findAllowedMethods(path: string): HttpMethod[] {
|
|
748
|
-
let normalized = this.opts.caseSensitive ? path : path.toLowerCase();
|
|
749
|
-
|
|
750
|
-
if (normalized.includes('//')) {
|
|
751
|
-
normalized = normalized.replace(/\/+/g, '/');
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
if (!this.opts.strict && normalized.length > 1 && normalized.endsWith('/')) {
|
|
755
|
-
normalized = normalized.slice(0, -1);
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
const segments = normalized.split('/').filter(Boolean);
|
|
759
|
-
const node = this.findNode(this.root, segments, 0);
|
|
760
|
-
if (!node || node.handlers.size === 0) return [];
|
|
761
|
-
|
|
762
|
-
return Array.from(node.handlers.keys());
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/**
|
|
766
|
-
* Walk the tree to find the node matching a path (ignoring HTTP method)
|
|
767
|
-
* @internal
|
|
768
|
-
*/
|
|
769
|
-
private findNode(node: RadixNode, segments: string[], index: number): RadixNode | null {
|
|
770
|
-
if (index === segments.length) {
|
|
771
|
-
return node;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
const segment = segments[index];
|
|
775
|
-
if (segment === undefined) return null;
|
|
776
|
-
|
|
777
|
-
// Static match
|
|
778
|
-
const staticChild = node.children.get(segment);
|
|
779
|
-
if (staticChild) {
|
|
780
|
-
const result = this.findNode(staticChild, segments, index + 1);
|
|
781
|
-
if (result) return result;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Param match
|
|
785
|
-
if (node.paramChild) {
|
|
786
|
-
const result = this.findNode(node.paramChild, segments, index + 1);
|
|
787
|
-
if (result) return result;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// Wildcard match
|
|
791
|
-
if (node.wildcardChild) {
|
|
792
|
-
return node.wildcardChild;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
return null;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// ===========================================================================
|
|
799
|
-
// Route Groups
|
|
800
|
-
// ===========================================================================
|
|
801
|
-
|
|
802
|
-
/**
|
|
803
|
-
* Create a route group with shared prefix and middleware
|
|
804
|
-
*
|
|
805
|
-
* @param prefix - Path prefix for all routes in the group
|
|
806
|
-
* @param middlewareOrCallback - Middleware array or callback function
|
|
807
|
-
* @param callback - Callback function if middleware is provided
|
|
808
|
-
* @returns this for chaining
|
|
809
|
-
*
|
|
810
|
-
* @example
|
|
811
|
-
* ```typescript
|
|
812
|
-
* // Simple group with prefix
|
|
813
|
-
* router.group('/api', (r) => {
|
|
814
|
-
* r.get('/users', listUsers);
|
|
815
|
-
* r.get('/posts', listPosts);
|
|
816
|
-
* });
|
|
817
|
-
*
|
|
818
|
-
* // Group with middleware
|
|
819
|
-
* router.group('/admin', [authMiddleware], (r) => {
|
|
820
|
-
* r.get('/dashboard', dashboard);
|
|
821
|
-
* r.post('/settings', updateSettings);
|
|
822
|
-
* });
|
|
823
|
-
*
|
|
824
|
-
* // Nested groups
|
|
825
|
-
* router.group('/api/v1', (r) => {
|
|
826
|
-
* r.group('/users', [rateLimit], (ur) => {
|
|
827
|
-
* ur.get('/', listUsers);
|
|
828
|
-
* ur.get('/:id', getUser);
|
|
829
|
-
* });
|
|
830
|
-
* });
|
|
831
|
-
* ```
|
|
314
|
+
* Create a route group with a shared prefix and middleware. The callback
|
|
315
|
+
* receives a {@link RouteGroup} to register routes against.
|
|
316
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#route-groups | README: Route Groups}
|
|
832
317
|
*/
|
|
833
318
|
group(
|
|
834
319
|
prefix: string,
|
|
835
|
-
middlewareOrCallback: Middleware[] | ((router:
|
|
836
|
-
callback?: (router:
|
|
320
|
+
middlewareOrCallback: Middleware[] | ((router: RouteGroup) => void),
|
|
321
|
+
callback?: (router: RouteGroup) => void
|
|
837
322
|
): this {
|
|
838
|
-
|
|
839
|
-
let cb: (router: Router) => void;
|
|
840
|
-
|
|
841
|
-
if (Array.isArray(middlewareOrCallback)) {
|
|
842
|
-
middleware = middlewareOrCallback;
|
|
843
|
-
if (!callback) {
|
|
844
|
-
throw new Error('Callback function is required when providing middleware array');
|
|
845
|
-
}
|
|
846
|
-
cb = callback;
|
|
847
|
-
} else {
|
|
848
|
-
cb = middlewareOrCallback;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// Create a temporary router to collect routes
|
|
852
|
-
const groupRouter = new GroupRouter(this, prefix, middleware);
|
|
853
|
-
|
|
854
|
-
// Execute callback with the group router
|
|
855
|
-
cb(groupRouter as unknown as Router);
|
|
856
|
-
|
|
323
|
+
runRouteGroup(this, prefix, middlewareOrCallback, callback);
|
|
857
324
|
return this;
|
|
858
325
|
}
|
|
859
326
|
|
|
860
327
|
/**
|
|
861
|
-
* Remove all
|
|
862
|
-
*
|
|
328
|
+
* Remove all routes and middleware, resetting the router to its initial
|
|
329
|
+
* state — for test isolation or hot-reload re-registration.
|
|
863
330
|
*/
|
|
864
331
|
reset(): void {
|
|
865
|
-
this.root
|
|
866
|
-
this.root.handlers.clear();
|
|
867
|
-
this.root.paramChild = undefined;
|
|
868
|
-
this.root.wildcardChild = undefined;
|
|
332
|
+
clearNode(this.root);
|
|
869
333
|
this.staticRoutes.clear();
|
|
870
334
|
this.routerMiddleware.length = 0;
|
|
871
335
|
this.hasParamRoutes = false;
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
336
|
+
// Clear the introspection registry too, or getRoutes()/OpenAPI would emit
|
|
337
|
+
// ghost routes after a reset (audit RT-1).
|
|
338
|
+
this.routeDefinitions.length = 0;
|
|
339
|
+
// Reset the walk-frame pool sizing too (F-02) — otherwise maxDepth would
|
|
340
|
+
// keep reporting a since-cleared route's depth, and the pool would stay
|
|
341
|
+
// needlessly oversized for whatever gets registered next.
|
|
342
|
+
this.state.maxDepth = 0;
|
|
343
|
+
this.state.walkPool = undefined;
|
|
344
|
+
this._sealed = false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Register a route on behalf of a {@link RouteGroup} (group context). @internal */
|
|
878
348
|
_addGroupRoute(
|
|
879
349
|
method: HttpMethod,
|
|
880
350
|
path: string,
|
|
881
351
|
handlers: RouteHandler[],
|
|
882
|
-
groupMiddleware: Middleware[]
|
|
352
|
+
groupMiddleware: Middleware[],
|
|
353
|
+
recordIntrospection = true
|
|
883
354
|
): void {
|
|
884
|
-
this.addRoute(method, path, handlers, groupMiddleware);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/**
|
|
889
|
-
* Internal router class for handling route groups
|
|
890
|
-
* Wraps the parent router and adds prefix/middleware to all routes
|
|
891
|
-
* @internal
|
|
892
|
-
*/
|
|
893
|
-
class GroupRouter {
|
|
894
|
-
private readonly parent: Router;
|
|
895
|
-
private readonly prefix: string;
|
|
896
|
-
private readonly middleware: Middleware[];
|
|
897
|
-
|
|
898
|
-
constructor(parent: Router, prefix: string, middleware: Middleware[]) {
|
|
899
|
-
this.parent = parent;
|
|
900
|
-
this.prefix = prefix;
|
|
901
|
-
this.middleware = middleware;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
private fullPath(path: string): string {
|
|
905
|
-
// Handle root path in group
|
|
906
|
-
if (path === '/' || path === '') {
|
|
907
|
-
return this.prefix;
|
|
908
|
-
}
|
|
909
|
-
// Combine prefix and path
|
|
910
|
-
const cleanPrefix = this.prefix.endsWith('/') ? this.prefix.slice(0, -1) : this.prefix;
|
|
911
|
-
const cleanPath = path.startsWith('/') ? path : '/' + path;
|
|
912
|
-
return cleanPrefix + cleanPath;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
get(path: string, ...handlers: RouteHandler[]): this {
|
|
916
|
-
this.parent._addGroupRoute('GET', this.fullPath(path), handlers, this.middleware);
|
|
917
|
-
return this;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
post(path: string, ...handlers: RouteHandler[]): this {
|
|
921
|
-
this.parent._addGroupRoute('POST', this.fullPath(path), handlers, this.middleware);
|
|
922
|
-
return this;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
put(path: string, ...handlers: RouteHandler[]): this {
|
|
926
|
-
this.parent._addGroupRoute('PUT', this.fullPath(path), handlers, this.middleware);
|
|
927
|
-
return this;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
delete(path: string, ...handlers: RouteHandler[]): this {
|
|
931
|
-
this.parent._addGroupRoute('DELETE', this.fullPath(path), handlers, this.middleware);
|
|
932
|
-
return this;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
patch(path: string, ...handlers: RouteHandler[]): this {
|
|
936
|
-
this.parent._addGroupRoute('PATCH', this.fullPath(path), handlers, this.middleware);
|
|
937
|
-
return this;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
head(path: string, ...handlers: RouteHandler[]): this {
|
|
941
|
-
this.parent._addGroupRoute('HEAD', this.fullPath(path), handlers, this.middleware);
|
|
942
|
-
return this;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
options(path: string, ...handlers: RouteHandler[]): this {
|
|
946
|
-
this.parent._addGroupRoute('OPTIONS', this.fullPath(path), handlers, this.middleware);
|
|
947
|
-
return this;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
all(path: string, ...handlers: RouteHandler[]): this {
|
|
951
|
-
for (const method of HTTP_METHODS) {
|
|
952
|
-
this.parent._addGroupRoute(method, this.fullPath(path), handlers, this.middleware);
|
|
953
|
-
}
|
|
954
|
-
return this;
|
|
355
|
+
this.addRoute(method, path, handlers, groupMiddleware, recordIntrospection);
|
|
955
356
|
}
|
|
956
357
|
|
|
957
358
|
/**
|
|
958
|
-
*
|
|
359
|
+
* Group-facing entry point to {@link pushAnyMethodDefinition} — a group's `.all()`
|
|
360
|
+
* records its consolidated row here since group routes live on the parent. @internal
|
|
959
361
|
*/
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
const entries = Object.entries(ctx.params).sort((a, b) => b[0].length - a[0].length);
|
|
966
|
-
for (const [key, value] of entries) {
|
|
967
|
-
targetPath = targetPath.replaceAll(`:${key}`, value);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
ctx.status = status;
|
|
972
|
-
ctx.set('Location', targetPath);
|
|
973
|
-
ctx.body = '';
|
|
974
|
-
};
|
|
975
|
-
|
|
976
|
-
this.parent._addGroupRoute('GET', this.fullPath(from), [redirectHandler], this.middleware);
|
|
977
|
-
this.parent._addGroupRoute('HEAD', this.fullPath(from), [redirectHandler], this.middleware);
|
|
978
|
-
|
|
979
|
-
return this;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* Nested group support
|
|
984
|
-
*/
|
|
985
|
-
group(
|
|
986
|
-
prefix: string,
|
|
987
|
-
middlewareOrCallback: Middleware[] | ((router: GroupRouter) => void),
|
|
988
|
-
callback?: (router: GroupRouter) => void
|
|
989
|
-
): this {
|
|
990
|
-
let nestedMiddleware: Middleware[] = [];
|
|
991
|
-
let cb: (router: GroupRouter) => void;
|
|
992
|
-
|
|
993
|
-
if (Array.isArray(middlewareOrCallback)) {
|
|
994
|
-
nestedMiddleware = middlewareOrCallback;
|
|
995
|
-
if (!callback) {
|
|
996
|
-
throw new Error('Callback function is required when providing middleware array');
|
|
997
|
-
}
|
|
998
|
-
cb = callback;
|
|
999
|
-
} else {
|
|
1000
|
-
cb = middlewareOrCallback;
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// Create nested group with combined prefix and middleware
|
|
1004
|
-
const nestedRouter = new GroupRouter(this.parent, this.fullPath(prefix), [
|
|
1005
|
-
...this.middleware,
|
|
1006
|
-
...nestedMiddleware,
|
|
1007
|
-
]);
|
|
1008
|
-
|
|
1009
|
-
cb(nestedRouter);
|
|
1010
|
-
|
|
1011
|
-
return this;
|
|
362
|
+
_pushAnyMethodRouteDefinition(path: string): void {
|
|
363
|
+
pushAnyMethodDefinition(
|
|
364
|
+
this.routeDefinitions,
|
|
365
|
+
normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict)
|
|
366
|
+
);
|
|
1012
367
|
}
|
|
1013
368
|
}
|
|
1014
369
|
|
|
1015
370
|
/**
|
|
1016
|
-
* Create a new Router instance
|
|
1017
|
-
*
|
|
1018
|
-
* @param options - Router options
|
|
1019
|
-
* @returns New Router instance
|
|
1020
|
-
*
|
|
1021
|
-
* @example
|
|
1022
|
-
* ```typescript
|
|
1023
|
-
* const router = createRouter();
|
|
1024
|
-
* const apiRouter = createRouter({ prefix: '/api/v1' });
|
|
1025
|
-
* ```
|
|
371
|
+
* Create a new {@link Router} instance.
|
|
372
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#quick-start | README: Quick Start}
|
|
1026
373
|
*/
|
|
1027
374
|
export function createRouter(options?: RouterOptions): Router {
|
|
1028
375
|
return new Router(options);
|