@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.
- package/README.md +250 -492
- package/dist/index.d.ts +142 -185
- package/dist/index.js +816 -665
- 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 +71 -0
- package/src/__tests__/dispatch-deasync.test.ts +312 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/helpers/differential-corpus.ts +316 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +66 -0
- package/src/__tests__/match-safety.test.ts +177 -0
- package/src/__tests__/match-single-alloc.test.ts +84 -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 +59 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +326 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -2
- package/src/__tests__/router.test.ts +148 -2
- package/src/__tests__/static-map-reset.test.ts +50 -0
- package/src/composition.ts +75 -0
- package/src/constants.ts +25 -0
- package/src/dispatch.ts +117 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +8 -5
- package/src/match-route.ts +178 -0
- package/src/matching.ts +240 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +291 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +150 -881
- package/src/segment-trie.ts +219 -0
- package/src/state.ts +52 -0
- package/src/radix-tree.ts +0 -184
package/src/router.ts
CHANGED
|
@@ -1,1028 +1,297 @@
|
|
|
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 { copyRoutes } from './composition';
|
|
26
|
+
import { sealRouterMiddleware as sealRouterMiddlewareImpl } from './middleware-adapter';
|
|
21
27
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
} from './
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
Object.create(null) as Record<string, string>
|
|
34
|
-
);
|
|
28
|
+
addRoute as addRouteImpl,
|
|
29
|
+
normalizeRegistrationPath,
|
|
30
|
+
pushAnyMethodDefinition,
|
|
31
|
+
registerRedirect,
|
|
32
|
+
type RegistrationState,
|
|
33
|
+
} from './registration';
|
|
34
|
+
import { createAllowedMethodsMiddleware, createRoutesMiddleware } from './dispatch';
|
|
35
|
+
import { createRouterState, resolveRouterOptions } from './state';
|
|
36
|
+
|
|
37
|
+
/** Inline route metadata declaration — re-exported from its own module (RT-3). */
|
|
38
|
+
export { endpoint } from './route-metadata';
|
|
35
39
|
|
|
36
40
|
/**
|
|
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
|
-
* ```
|
|
41
|
+
* High-performance segment-trie router: O(d) lookup by path segment, with a
|
|
42
|
+
* static-route hash map for an O(1) fast path.
|
|
43
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md | @nextrush/router README}
|
|
53
44
|
*/
|
|
54
45
|
export class Router {
|
|
55
|
-
private readonly root:
|
|
46
|
+
private readonly root: TrieNode;
|
|
56
47
|
private readonly opts: Required<RouterOptions>;
|
|
57
48
|
private readonly routerMiddleware: Middleware[] = [];
|
|
58
49
|
|
|
50
|
+
/** Static-route fast path: method-nested map for O(1) lookup with no per-request key string (HP-9). */
|
|
51
|
+
private readonly staticRoutes: StaticRouteMap = new Map();
|
|
52
|
+
|
|
59
53
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
54
|
+
* Introspection registry, kept SEPARATE from the hot-path trie/staticRoutes
|
|
55
|
+
* so request dispatch never reads metadata — only getRoutes() touches it.
|
|
62
56
|
*/
|
|
63
|
-
private readonly
|
|
57
|
+
private readonly routeDefinitions: RouteDefinition[] = [];
|
|
64
58
|
|
|
65
59
|
/** Whether any routes have params or wildcards (disables static-only fast path) */
|
|
66
60
|
private hasParamRoutes = false;
|
|
67
61
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.opts = {
|
|
71
|
-
prefix: options.prefix ?? '',
|
|
72
|
-
caseSensitive: options.caseSensitive ?? false,
|
|
73
|
-
strict: options.strict ?? false,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Normalize path based on router options
|
|
79
|
-
*/
|
|
80
|
-
private normalizePath(path: string): string {
|
|
81
|
-
// Handle prefix with trailing slash and path with leading slash
|
|
82
|
-
let prefix = this.opts.prefix;
|
|
83
|
-
if (prefix.endsWith('/') && path.startsWith('/')) {
|
|
84
|
-
prefix = prefix.slice(0, -1);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
let normalized = prefix + path;
|
|
62
|
+
/** Whether router-level middleware has already been sealed into executors (audit RT-7) */
|
|
63
|
+
private _sealed = false;
|
|
88
64
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
normalized = normalized.replace(/\/+/g, '/');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// For non-strict mode during registration, remove trailing slash
|
|
95
|
-
if (!this.opts.strict && normalized.length > 1 && normalized.endsWith('/')) {
|
|
96
|
-
normalized = normalized.slice(0, -1);
|
|
97
|
-
}
|
|
65
|
+
/** Memoized state the extracted registration/matching functions read (see {@link createRouterState}). */
|
|
66
|
+
private readonly state: RegistrationState & MatchState;
|
|
98
67
|
|
|
99
|
-
|
|
68
|
+
constructor(options: RouterOptions = {}) {
|
|
69
|
+
this.root = createNode('');
|
|
70
|
+
this.opts = resolveRouterOptions(options);
|
|
71
|
+
this.state = createRouterState(
|
|
72
|
+
this.root,
|
|
73
|
+
this.opts,
|
|
74
|
+
this.staticRoutes,
|
|
75
|
+
this.routeDefinitions,
|
|
76
|
+
this.routerMiddleware
|
|
77
|
+
);
|
|
100
78
|
}
|
|
101
79
|
|
|
102
80
|
/**
|
|
103
|
-
*
|
|
81
|
+
* Validate + normalize a raw path, then delegate trie insertion to the
|
|
82
|
+
* extracted `addRoute` (design.md D2); flips `hasParamRoutes` from its return.
|
|
104
83
|
*/
|
|
105
84
|
private addRoute(
|
|
106
85
|
method: HttpMethod,
|
|
107
86
|
path: string,
|
|
108
|
-
|
|
109
|
-
middleware: Middleware[] = []
|
|
87
|
+
entries: RouteEntry[],
|
|
88
|
+
middleware: Middleware[] = [],
|
|
89
|
+
recordIntrospection = true
|
|
110
90
|
): 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.'
|
|
91
|
+
// Guard untyped-JS callers: a non-string path would coerce to a bogus literal route.
|
|
92
|
+
const rawPath: unknown = path;
|
|
93
|
+
if (typeof rawPath !== 'string') {
|
|
94
|
+
throw new TypeError(
|
|
95
|
+
`Route path must be a string, received ${rawPath === null ? 'null' : typeof rawPath}.`
|
|
178
96
|
);
|
|
179
97
|
}
|
|
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) {
|
|
98
|
+
const normalized = normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict);
|
|
99
|
+
if (addRouteImpl(method, normalized, entries, middleware, this.state, recordIntrospection)) {
|
|
188
100
|
this.hasParamRoutes = true;
|
|
189
|
-
} else {
|
|
190
|
-
const normalizedKey = this.opts.caseSensitive ? normalized : normalized.toLowerCase();
|
|
191
|
-
this.staticRoutes.set(`${method} ${normalizedKey}`, entry);
|
|
192
101
|
}
|
|
193
102
|
}
|
|
194
103
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// ===========================================================================
|
|
198
|
-
|
|
199
|
-
get(path: string, ...handlers: RouteHandler[]): this {
|
|
200
|
-
this.addRoute('GET', path, handlers);
|
|
104
|
+
get(path: string, ...entries: RouteEntry[]): this {
|
|
105
|
+
this.addRoute('GET', path, entries);
|
|
201
106
|
return this;
|
|
202
107
|
}
|
|
203
108
|
|
|
204
|
-
post(path: string, ...
|
|
205
|
-
this.addRoute('POST', path,
|
|
109
|
+
post(path: string, ...entries: RouteEntry[]): this {
|
|
110
|
+
this.addRoute('POST', path, entries);
|
|
206
111
|
return this;
|
|
207
112
|
}
|
|
208
113
|
|
|
209
|
-
put(path: string, ...
|
|
210
|
-
this.addRoute('PUT', path,
|
|
114
|
+
put(path: string, ...entries: RouteEntry[]): this {
|
|
115
|
+
this.addRoute('PUT', path, entries);
|
|
211
116
|
return this;
|
|
212
117
|
}
|
|
213
118
|
|
|
214
|
-
delete(path: string, ...
|
|
215
|
-
this.addRoute('DELETE', path,
|
|
119
|
+
delete(path: string, ...entries: RouteEntry[]): this {
|
|
120
|
+
this.addRoute('DELETE', path, entries);
|
|
216
121
|
return this;
|
|
217
122
|
}
|
|
218
123
|
|
|
219
|
-
patch(path: string, ...
|
|
220
|
-
this.addRoute('PATCH', path,
|
|
124
|
+
patch(path: string, ...entries: RouteEntry[]): this {
|
|
125
|
+
this.addRoute('PATCH', path, entries);
|
|
221
126
|
return this;
|
|
222
127
|
}
|
|
223
128
|
|
|
224
|
-
head(path: string, ...
|
|
225
|
-
this.addRoute('HEAD', path,
|
|
129
|
+
head(path: string, ...entries: RouteEntry[]): this {
|
|
130
|
+
this.addRoute('HEAD', path, entries);
|
|
226
131
|
return this;
|
|
227
132
|
}
|
|
228
133
|
|
|
229
|
-
options(path: string, ...
|
|
230
|
-
this.addRoute('OPTIONS', path,
|
|
134
|
+
options(path: string, ...entries: RouteEntry[]): this {
|
|
135
|
+
this.addRoute('OPTIONS', path, entries);
|
|
231
136
|
return this;
|
|
232
137
|
}
|
|
233
138
|
|
|
234
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Register a route for every HTTP method under one consolidated `isAnyMethod`
|
|
141
|
+
* introspection row (T016) — matching is unchanged (see {@link pushAnyMethodDefinition}).
|
|
142
|
+
*/
|
|
143
|
+
all(path: string, ...entries: RouteEntry[]): this {
|
|
144
|
+
// recordIntrospection=false: insert each per-method handler without its own
|
|
145
|
+
// introspection row; the single consolidated row below replaces all 7.
|
|
235
146
|
for (const method of HTTP_METHODS) {
|
|
236
|
-
this.addRoute(method, path,
|
|
147
|
+
this.addRoute(method, path, entries, [], false);
|
|
237
148
|
}
|
|
149
|
+
pushAnyMethodDefinition(
|
|
150
|
+
this.routeDefinitions,
|
|
151
|
+
normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict)
|
|
152
|
+
);
|
|
238
153
|
return this;
|
|
239
154
|
}
|
|
240
155
|
|
|
241
|
-
route(method: HttpMethod, path: string, ...
|
|
242
|
-
this.addRoute(method, path,
|
|
156
|
+
route(method: HttpMethod, path: string, ...entries: RouteEntry[]): this {
|
|
157
|
+
this.addRoute(method, path, entries);
|
|
243
158
|
return this;
|
|
244
159
|
}
|
|
245
160
|
|
|
246
161
|
/**
|
|
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
|
-
* ```
|
|
162
|
+
* Every registered route as a read-only list, for renderers (`@nextrush/openapi`,
|
|
163
|
+
* SDK/RPC generators). Doc-generation-time only — never on the request path.
|
|
268
164
|
*/
|
|
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
|
-
}
|
|
165
|
+
getRoutes(): readonly RouteDefinition[] {
|
|
166
|
+
return this.routeDefinitions;
|
|
167
|
+
}
|
|
357
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Register a redirect from one path to another (301 by default). 307/308
|
|
171
|
+
* additionally register POST/PUT/PATCH/DELETE to preserve the method.
|
|
172
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#redirects | README: Redirects}
|
|
173
|
+
*/
|
|
174
|
+
redirect(from: string, to: string, status: RedirectStatus = 301): this {
|
|
175
|
+
registerRedirect(from, to, status, (method, path, entries) => {
|
|
176
|
+
this.addRoute(method, path, entries);
|
|
177
|
+
});
|
|
358
178
|
return this;
|
|
359
179
|
}
|
|
360
180
|
|
|
361
|
-
// ===========================================================================
|
|
362
|
-
// Router Composition
|
|
363
|
-
// ===========================================================================
|
|
364
|
-
|
|
365
181
|
use(pathOrMiddleware: string | Middleware | Router, routerOrUndefined?: Router): this {
|
|
366
182
|
if (typeof pathOrMiddleware === 'function') {
|
|
367
|
-
// Middleware function
|
|
368
183
|
this.routerMiddleware.push(pathOrMiddleware);
|
|
369
184
|
} else if (typeof pathOrMiddleware === 'string' && routerOrUndefined instanceof Router) {
|
|
370
|
-
// Mount sub-router at path
|
|
371
185
|
this.mountRouter(pathOrMiddleware, routerOrUndefined);
|
|
372
186
|
} else if (typeof pathOrMiddleware === 'string') {
|
|
373
|
-
// String prefix without a Router — unsupported, throw clear error
|
|
374
187
|
throw new Error(
|
|
375
188
|
`router.use('${pathOrMiddleware}', ...) requires a Router instance as the second argument. ` +
|
|
376
189
|
'Use router.group(prefix, callback) for prefix-scoped middleware, ' +
|
|
377
190
|
'or router.use(middlewareFn) to register middleware without a prefix.'
|
|
378
191
|
);
|
|
379
192
|
} else if (pathOrMiddleware instanceof Router) {
|
|
380
|
-
// Mount router at root
|
|
381
193
|
this.mountRouter('', pathOrMiddleware);
|
|
382
194
|
}
|
|
383
195
|
return this;
|
|
384
196
|
}
|
|
385
197
|
|
|
386
198
|
/**
|
|
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
|
-
* ```
|
|
199
|
+
* Mount a sub-router at a path prefix (Hono-style) — the explicit, more
|
|
200
|
+
* semantic equivalent of `router.use(path, subRouter)`.
|
|
201
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#sub-router-mounting | README: Sub-Router Mounting}
|
|
417
202
|
*/
|
|
418
203
|
mount(path: string, router: Router): this {
|
|
419
204
|
this.mountRouter(path, router);
|
|
420
205
|
return this;
|
|
421
206
|
}
|
|
422
207
|
|
|
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
|
-
*/
|
|
208
|
+
/** Mount a sub-router, carrying its own `routerMiddleware` onto every copied route. */
|
|
429
209
|
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
|
-
}
|
|
210
|
+
copyRoutes(router.root, prefix, [], router.routerMiddleware, this.addRoute.bind(this));
|
|
472
211
|
}
|
|
473
212
|
|
|
474
|
-
|
|
475
|
-
// Route Matching
|
|
476
|
-
// ===========================================================================
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Match a route and return handler + params
|
|
480
|
-
*/
|
|
213
|
+
/** Match a request to a route — delegates to {@link resolveMatch} (design.md D1). */
|
|
481
214
|
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
|
-
};
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Extract the next segment from path at given position without allocating arrays.
|
|
559
|
-
* Returns [segment, nextIndex] where nextIndex is position after the trailing '/'.
|
|
560
|
-
*/
|
|
561
|
-
private extractSegment(path: string, start: number): [segment: string, nextIndex: number] {
|
|
562
|
-
const slashPos = path.indexOf('/', start);
|
|
563
|
-
if (slashPos === -1) {
|
|
564
|
-
return [path.slice(start), path.length];
|
|
565
|
-
}
|
|
566
|
-
return [path.slice(start, slashPos), slashPos + 1];
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Index-based recursive node matching (avoids array allocation)
|
|
571
|
-
*/
|
|
572
|
-
private matchNodeIndexed(
|
|
573
|
-
node: RadixNode,
|
|
574
|
-
path: string,
|
|
575
|
-
pos: number,
|
|
576
|
-
params: Record<string, string>,
|
|
577
|
-
method: HttpMethod,
|
|
578
|
-
originalPath?: string
|
|
579
|
-
): HandlerEntry | null {
|
|
580
|
-
// Reached end of path
|
|
581
|
-
if (pos >= path.length) {
|
|
582
|
-
return node.handlers.get(method) ?? null;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const [segment, nextPos] = this.extractSegment(path, pos);
|
|
586
|
-
if (segment === '') return node.handlers.get(method) ?? null;
|
|
587
|
-
|
|
588
|
-
// Try static match first (most specific)
|
|
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;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// Try parameter match — use original-case segment for param value
|
|
603
|
-
if (node.paramChild) {
|
|
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
|
-
}
|
|
623
|
-
|
|
624
|
-
// Try wildcard match (catches remaining path) — use original-case path
|
|
625
|
-
if (node.wildcardChild) {
|
|
626
|
-
const src = originalPath ?? path;
|
|
627
|
-
params['*'] = src.slice(pos);
|
|
628
|
-
return node.wildcardChild.handlers.get(method) ?? null;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
return null;
|
|
215
|
+
return resolveMatch(this.state, this.hasParamRoutes, method, path);
|
|
632
216
|
}
|
|
633
217
|
|
|
634
|
-
// ===========================================================================
|
|
635
|
-
// Middleware Generation
|
|
636
|
-
// ===========================================================================
|
|
637
|
-
|
|
638
218
|
/**
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
* @example
|
|
643
|
-
* ```typescript
|
|
644
|
-
* app.use(router.routes());
|
|
645
|
-
* ```
|
|
219
|
+
* Return the router's dispatch middleware — mount this on the application.
|
|
220
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#routerroutes | README: router.routes()}
|
|
646
221
|
*/
|
|
647
222
|
routes(): Middleware {
|
|
648
|
-
// Seal router
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
this.
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
return async (ctx: Context, next?: () => Promise<void>): Promise<void> => {
|
|
656
|
-
const match = this.match(ctx.method, ctx.path);
|
|
657
|
-
|
|
658
|
-
if (!match) {
|
|
659
|
-
// No route matched — set 404 so allowedMethods() and notFoundHandler() can act
|
|
660
|
-
ctx.status = 404;
|
|
661
|
-
if (next) await next();
|
|
662
|
-
return;
|
|
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);
|
|
223
|
+
// Seal router middleware into every executor once (audit RT-7 idempotency):
|
|
224
|
+
// routes() may run more than once, so the _sealed guard prevents re-prepend.
|
|
225
|
+
if (this.routerMiddleware.length > 0 && !this._sealed) {
|
|
226
|
+
this._sealed = true;
|
|
227
|
+
sealRouterMiddlewareImpl(this.root, this.staticRoutes, this.routerMiddleware);
|
|
707
228
|
}
|
|
229
|
+
return createRoutesMiddleware((method, path) => this.match(method, path));
|
|
708
230
|
}
|
|
709
231
|
|
|
710
232
|
/**
|
|
711
|
-
* Generate allowed
|
|
712
|
-
*
|
|
233
|
+
* Generate allowed-methods middleware. Responds to OPTIONS with an `Allow`
|
|
234
|
+
* header and returns 405 for a known path hit with an unregistered method.
|
|
713
235
|
*/
|
|
714
236
|
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
|
-
};
|
|
237
|
+
return createAllowedMethodsMiddleware(this.root, this.opts.caseSensitive, this.opts.strict);
|
|
741
238
|
}
|
|
742
239
|
|
|
743
240
|
/**
|
|
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
|
-
* ```
|
|
241
|
+
* Create a route group with a shared prefix and middleware. The callback
|
|
242
|
+
* receives a {@link RouteGroup} to register routes against.
|
|
243
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#route-groups | README: Route Groups}
|
|
832
244
|
*/
|
|
833
245
|
group(
|
|
834
246
|
prefix: string,
|
|
835
|
-
middlewareOrCallback: Middleware[] | ((router:
|
|
836
|
-
callback?: (router:
|
|
247
|
+
middlewareOrCallback: Middleware[] | ((router: RouteGroup) => void),
|
|
248
|
+
callback?: (router: RouteGroup) => void
|
|
837
249
|
): 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
|
-
|
|
250
|
+
runRouteGroup(this, prefix, middlewareOrCallback, callback);
|
|
857
251
|
return this;
|
|
858
252
|
}
|
|
859
253
|
|
|
860
254
|
/**
|
|
861
|
-
* Remove all
|
|
862
|
-
*
|
|
255
|
+
* Remove all routes and middleware, resetting the router to its initial
|
|
256
|
+
* state — for test isolation or hot-reload re-registration.
|
|
863
257
|
*/
|
|
864
258
|
reset(): void {
|
|
865
|
-
this.root
|
|
866
|
-
this.root.handlers.clear();
|
|
867
|
-
this.root.paramChild = undefined;
|
|
868
|
-
this.root.wildcardChild = undefined;
|
|
259
|
+
clearNode(this.root);
|
|
869
260
|
this.staticRoutes.clear();
|
|
870
261
|
this.routerMiddleware.length = 0;
|
|
871
262
|
this.hasParamRoutes = false;
|
|
263
|
+
// Clear the introspection registry too, or getRoutes()/OpenAPI would emit
|
|
264
|
+
// ghost routes after a reset (audit RT-1).
|
|
265
|
+
this.routeDefinitions.length = 0;
|
|
266
|
+
this._sealed = false;
|
|
872
267
|
}
|
|
873
268
|
|
|
874
|
-
/**
|
|
875
|
-
* Internal method to add route with group context
|
|
876
|
-
* @internal
|
|
877
|
-
*/
|
|
269
|
+
/** Register a route on behalf of a {@link RouteGroup} (group context). @internal */
|
|
878
270
|
_addGroupRoute(
|
|
879
271
|
method: HttpMethod,
|
|
880
272
|
path: string,
|
|
881
273
|
handlers: RouteHandler[],
|
|
882
|
-
groupMiddleware: Middleware[]
|
|
274
|
+
groupMiddleware: Middleware[],
|
|
275
|
+
recordIntrospection = true
|
|
883
276
|
): 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;
|
|
277
|
+
this.addRoute(method, path, handlers, groupMiddleware, recordIntrospection);
|
|
955
278
|
}
|
|
956
279
|
|
|
957
280
|
/**
|
|
958
|
-
*
|
|
281
|
+
* Group-facing entry point to {@link pushAnyMethodDefinition} — a group's `.all()`
|
|
282
|
+
* records its consolidated row here since group routes live on the parent. @internal
|
|
959
283
|
*/
|
|
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;
|
|
284
|
+
_pushAnyMethodRouteDefinition(path: string): void {
|
|
285
|
+
pushAnyMethodDefinition(
|
|
286
|
+
this.routeDefinitions,
|
|
287
|
+
normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict)
|
|
288
|
+
);
|
|
1012
289
|
}
|
|
1013
290
|
}
|
|
1014
291
|
|
|
1015
292
|
/**
|
|
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
|
-
* ```
|
|
293
|
+
* Create a new {@link Router} instance.
|
|
294
|
+
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#quick-start | README: Quick Start}
|
|
1026
295
|
*/
|
|
1027
296
|
export function createRouter(options?: RouterOptions): Router {
|
|
1028
297
|
return new Router(options);
|