@nextrush/router 3.0.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/src/router.ts ADDED
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * @nextrush/router - Router Implementation
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.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ import {
13
+ HTTP_METHODS,
14
+ type Context,
15
+ type HttpMethod,
16
+ type Middleware,
17
+ type RouteHandler,
18
+ type RouteMatch,
19
+ type RouterOptions,
20
+ } from '@nextrush/types';
21
+ 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
+ );
35
+
36
+ /**
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
+ * ```
53
+ */
54
+ export class Router {
55
+ private readonly root: RadixNode;
56
+ private readonly opts: Required<RouterOptions>;
57
+ private readonly routerMiddleware: Middleware[] = [];
58
+
59
+ /**
60
+ * Static route hash map for O(1) lookup.
61
+ * Key: "METHOD path" (e.g. "GET /users"), Value: HandlerEntry
62
+ */
63
+ private readonly staticRoutes = new Map<string, HandlerEntry>();
64
+
65
+ /** Whether any routes have params or wildcards (disables static-only fast path) */
66
+ private hasParamRoutes = false;
67
+
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;
88
+
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
+ }
98
+
99
+ return normalized.startsWith('/') ? normalized : '/' + normalized;
100
+ }
101
+
102
+ /**
103
+ * Add a route to the radix tree
104
+ */
105
+ private addRoute(
106
+ method: HttpMethod,
107
+ path: string,
108
+ handlers: RouteHandler[],
109
+ middleware: Middleware[] = []
110
+ ): 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.'
178
+ );
179
+ }
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) {
188
+ this.hasParamRoutes = true;
189
+ } else {
190
+ const normalizedKey = this.opts.caseSensitive ? normalized : normalized.toLowerCase();
191
+ this.staticRoutes.set(`${method} ${normalizedKey}`, entry);
192
+ }
193
+ }
194
+
195
+ // ===========================================================================
196
+ // HTTP Method Shortcuts
197
+ // ===========================================================================
198
+
199
+ get(path: string, ...handlers: RouteHandler[]): this {
200
+ this.addRoute('GET', path, handlers);
201
+ return this;
202
+ }
203
+
204
+ post(path: string, ...handlers: RouteHandler[]): this {
205
+ this.addRoute('POST', path, handlers);
206
+ return this;
207
+ }
208
+
209
+ put(path: string, ...handlers: RouteHandler[]): this {
210
+ this.addRoute('PUT', path, handlers);
211
+ return this;
212
+ }
213
+
214
+ delete(path: string, ...handlers: RouteHandler[]): this {
215
+ this.addRoute('DELETE', path, handlers);
216
+ return this;
217
+ }
218
+
219
+ patch(path: string, ...handlers: RouteHandler[]): this {
220
+ this.addRoute('PATCH', path, handlers);
221
+ return this;
222
+ }
223
+
224
+ head(path: string, ...handlers: RouteHandler[]): this {
225
+ this.addRoute('HEAD', path, handlers);
226
+ return this;
227
+ }
228
+
229
+ options(path: string, ...handlers: RouteHandler[]): this {
230
+ this.addRoute('OPTIONS', path, handlers);
231
+ return this;
232
+ }
233
+
234
+ all(path: string, ...handlers: RouteHandler[]): this {
235
+ for (const method of HTTP_METHODS) {
236
+ this.addRoute(method, path, handlers);
237
+ }
238
+ return this;
239
+ }
240
+
241
+ route(method: HttpMethod, path: string, ...handlers: RouteHandler[]): this {
242
+ this.addRoute(method, path, handlers);
243
+ return this;
244
+ }
245
+
246
+ /**
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
+ * ```
268
+ */
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
+ }
357
+
358
+ return this;
359
+ }
360
+
361
+ // ===========================================================================
362
+ // Router Composition
363
+ // ===========================================================================
364
+
365
+ use(pathOrMiddleware: string | Middleware | Router, routerOrUndefined?: Router): this {
366
+ if (typeof pathOrMiddleware === 'function') {
367
+ // Middleware function
368
+ this.routerMiddleware.push(pathOrMiddleware);
369
+ } else if (typeof pathOrMiddleware === 'string' && routerOrUndefined instanceof Router) {
370
+ // Mount sub-router at path
371
+ this.mountRouter(pathOrMiddleware, routerOrUndefined);
372
+ } else if (typeof pathOrMiddleware === 'string') {
373
+ // String prefix without a Router — unsupported, throw clear error
374
+ throw new Error(
375
+ `router.use('${pathOrMiddleware}', ...) requires a Router instance as the second argument. ` +
376
+ 'Use router.group(prefix, callback) for prefix-scoped middleware, ' +
377
+ 'or router.use(middlewareFn) to register middleware without a prefix.'
378
+ );
379
+ } else if (pathOrMiddleware instanceof Router) {
380
+ // Mount router at root
381
+ this.mountRouter('', pathOrMiddleware);
382
+ }
383
+ return this;
384
+ }
385
+
386
+ /**
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
+ * ```
417
+ */
418
+ mount(path: string, router: Router): this {
419
+ this.mountRouter(path, router);
420
+ return this;
421
+ }
422
+
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
+ */
429
+ 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
+ }
472
+ }
473
+
474
+ // ===========================================================================
475
+ // Route Matching
476
+ // ===========================================================================
477
+
478
+ /**
479
+ * Match a route and return handler + params
480
+ */
481
+ 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;
632
+ }
633
+
634
+ // ===========================================================================
635
+ // Middleware Generation
636
+ // ===========================================================================
637
+
638
+ /**
639
+ * Get routes middleware function
640
+ * Mount this on the application
641
+ *
642
+ * @example
643
+ * ```typescript
644
+ * app.use(router.routes());
645
+ * ```
646
+ */
647
+ 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);
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Generate allowed methods middleware
712
+ * Responds to OPTIONS and sets Allow header
713
+ */
714
+ 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
+ };
741
+ }
742
+
743
+ /**
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
+ * ```
832
+ */
833
+ group(
834
+ prefix: string,
835
+ middlewareOrCallback: Middleware[] | ((router: Router) => void),
836
+ callback?: (router: Router) => void
837
+ ): 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
+
857
+ return this;
858
+ }
859
+
860
+ /**
861
+ * Remove all registered routes and middleware, resetting the router to its initial state.
862
+ * Useful for plugin `destroy()` to cleanly un-register routes.
863
+ */
864
+ reset(): void {
865
+ this.root.children.clear();
866
+ this.root.handlers.clear();
867
+ this.root.paramChild = undefined;
868
+ this.root.wildcardChild = undefined;
869
+ this.staticRoutes.clear();
870
+ this.routerMiddleware.length = 0;
871
+ this.hasParamRoutes = false;
872
+ }
873
+
874
+ /**
875
+ * Internal method to add route with group context
876
+ * @internal
877
+ */
878
+ _addGroupRoute(
879
+ method: HttpMethod,
880
+ path: string,
881
+ handlers: RouteHandler[],
882
+ groupMiddleware: Middleware[]
883
+ ): 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;
955
+ }
956
+
957
+ /**
958
+ * Register a redirect within the group
959
+ */
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;
1012
+ }
1013
+ }
1014
+
1015
+ /**
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
+ * ```
1026
+ */
1027
+ export function createRouter(options?: RouterOptions): Router {
1028
+ return new Router(options);
1029
+ }