@nextrush/router 3.0.6 → 4.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +142 -185
  3. package/dist/index.js +816 -665
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -6
  6. package/src/__tests__/allowed-methods.test.ts +144 -0
  7. package/src/__tests__/audit-fixes.test.ts +71 -0
  8. package/src/__tests__/dispatch-deasync.test.ts +312 -0
  9. package/src/__tests__/find-node-differential.test.ts +220 -0
  10. package/src/__tests__/fixtures/match-golden.json +556 -0
  11. package/src/__tests__/helpers/differential-corpus.ts +316 -0
  12. package/src/__tests__/match-differential.test.ts +46 -0
  13. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  14. package/src/__tests__/match-normalize-fastpath.test.ts +66 -0
  15. package/src/__tests__/match-safety.test.ts +177 -0
  16. package/src/__tests__/match-single-alloc.test.ts +84 -0
  17. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  18. package/src/__tests__/param-decoding.test.ts +78 -0
  19. package/src/__tests__/public-surface.test.ts +59 -0
  20. package/src/__tests__/route-metadata.test.ts +172 -0
  21. package/src/__tests__/router-audit.test.ts +326 -0
  22. package/src/__tests__/router-edge-cases.test.ts +2 -2
  23. package/src/__tests__/router.test.ts +148 -2
  24. package/src/__tests__/static-map-reset.test.ts +50 -0
  25. package/src/composition.ts +75 -0
  26. package/src/constants.ts +25 -0
  27. package/src/dispatch.ts +117 -0
  28. package/src/find-node.ts +125 -0
  29. package/src/group-router.ts +208 -0
  30. package/src/index.ts +8 -5
  31. package/src/match-route.ts +178 -0
  32. package/src/matching.ts +240 -0
  33. package/src/middleware-adapter.ts +59 -0
  34. package/src/redirect.ts +97 -0
  35. package/src/registration.ts +291 -0
  36. package/src/route-metadata.ts +68 -0
  37. package/src/router.ts +150 -881
  38. package/src/segment-trie.ts +219 -0
  39. package/src/state.ts +52 -0
  40. package/src/radix-tree.ts +0 -184
package/src/router.ts CHANGED
@@ -1,1028 +1,297 @@
1
1
  /**
2
2
  * @nextrush/router - Router Implementation
3
3
  *
4
- * High-performance router using a segment trie for route matching.
5
- * Routes are keyed by full path segments (e.g. "users", ":id"), not by
6
- * individual characters this is a segment-based trie, not a compressed
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
- HTTP_METHODS,
14
- type Context,
15
- type HttpMethod,
16
- type Middleware,
17
- type RouteHandler,
18
- type RouteMatch,
19
- type RouterOptions,
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
- compileExecutor,
23
- createNode,
24
- NodeType,
25
- NOOP_NEXT,
26
- parseSegments,
27
- type HandlerEntry,
28
- type RadixNode,
29
- } from './radix-tree';
30
-
31
- /** Frozen empty params for static routes avoids allocation per request */
32
- const EMPTY_PARAMS: Record<string, string> = Object.freeze(
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
- * Router class high-performance segment trie router
38
- *
39
- * Routes are indexed by path segment, giving O(d) lookup where d is the
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: RadixNode;
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
- * Static route hash map for O(1) lookup.
61
- * Key: "METHOD path" (e.g. "GET /users"), Value: HandlerEntry
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 staticRoutes = new Map<string, HandlerEntry>();
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
- constructor(options: RouterOptions = {}) {
69
- this.root = createNode('');
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
- // Fast-path: skip regex when no double slashes (99%+ of requests)
90
- if (normalized.includes('//')) {
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
- return normalized.startsWith('/') ? normalized : '/' + normalized;
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
- * Add a route to the radix tree
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
- handlers: RouteHandler[],
109
- middleware: Middleware[] = []
87
+ entries: RouteEntry[],
88
+ middleware: Middleware[] = [],
89
+ recordIntrospection = true
110
90
  ): void {
111
- const normalized = this.normalizePath(path);
112
- const segments = parseSegments(normalized, this.opts.caseSensitive);
113
-
114
- let node = this.root;
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
- node.handlers.set(method, entry);
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
- // HTTP Method Shortcuts
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, ...handlers: RouteHandler[]): this {
205
- this.addRoute('POST', path, handlers);
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, ...handlers: RouteHandler[]): this {
210
- this.addRoute('PUT', path, handlers);
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, ...handlers: RouteHandler[]): this {
215
- this.addRoute('DELETE', path, handlers);
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, ...handlers: RouteHandler[]): this {
220
- this.addRoute('PATCH', path, handlers);
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, ...handlers: RouteHandler[]): this {
225
- this.addRoute('HEAD', path, handlers);
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, ...handlers: RouteHandler[]): this {
230
- this.addRoute('OPTIONS', path, handlers);
134
+ options(path: string, ...entries: RouteEntry[]): this {
135
+ this.addRoute('OPTIONS', path, entries);
231
136
  return this;
232
137
  }
233
138
 
234
- all(path: string, ...handlers: RouteHandler[]): this {
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, handlers);
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, ...handlers: RouteHandler[]): this {
242
- this.addRoute(method, path, handlers);
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
- * Register a redirect route from one path to another
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
- redirect(from: string, to: string, status: 301 | 302 | 303 | 307 | 308 = 301): this {
270
- // Precompile the target template at registration time.
271
- // If `to` contains route-style `:param` placeholders, build a parts
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
- * This is the explicit API for mounting sub-routers.
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
- this.copyRoutes(router.root, prefix, [], router.routerMiddleware);
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
- const isCaseInsensitive = !this.opts.caseSensitive;
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
- * Get routes middleware function
640
- * Mount this on the application
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-level middleware into route executors at routes() call time
649
- // This avoids per-request closure creation
650
- const hasRouterMiddleware = this.routerMiddleware.length > 0;
651
- if (hasRouterMiddleware) {
652
- this.sealRouterMiddleware();
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 methods middleware
712
- * Responds to OPTIONS and sets Allow header
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 async (ctx: Context, next?: () => Promise<void>): Promise<void> => {
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
- * Find all HTTP methods registered for a given path via single tree walk
745
- * @internal
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: Router) => void),
836
- callback?: (router: Router) => void
247
+ middlewareOrCallback: Middleware[] | ((router: RouteGroup) => void),
248
+ callback?: (router: RouteGroup) => void
837
249
  ): this {
838
- let middleware: Middleware[] = [];
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 registered routes and middleware, resetting the router to its initial state.
862
- * Useful for plugin `destroy()` to cleanly un-register routes.
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.children.clear();
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
- * Register a redirect within the group
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
- redirect(from: string, to: string, status: 301 | 302 | 303 | 307 | 308 = 301): this {
961
- const redirectHandler: RouteHandler = (ctx: Context) => {
962
- let targetPath = to;
963
-
964
- if (targetPath.includes(':')) {
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);