astro-routify 1.4.0 β†’ 1.5.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 CHANGED
@@ -16,6 +16,21 @@ Define API routes using clean, flat structures β€” no folders or boilerplate log
16
16
  npm install astro-routify
17
17
  ```
18
18
 
19
+ ## πŸ“‹ Contents
20
+ - [Installing](#installing)
21
+ - [Quickstart](#quickstart)
22
+ - [Mental Model](#mental-model)
23
+ - [Which API should I use?](#which-api-should-i-use)
24
+ - [Core Concepts](#core-concepts)
25
+ - [Advanced Matching](#advanced-matching)
26
+ - [Middleware & Security](#middleware--security)
27
+ - [Single-entry Routing](#single-entry-routing)
28
+ - [Auto-Discovery & Scaling](#auto-discovery--scaling)
29
+ - [Advanced Features](#advanced-features)
30
+ - [Response Helpers](#response-helpers)
31
+ - [Performance](#performance)
32
+ - [Non-goals](#non-goals)
33
+
19
34
  ## ⚑️ Quickstart
20
35
 
21
36
  ```ts
@@ -55,6 +70,40 @@ builder
55
70
  export const ALL = builder.build(); // catch-all
56
71
  ```
57
72
 
73
+ ## 🧠 Mental Model
74
+
75
+ `astro-routify` compiles your route definitions into a **Trie (prefix tree)** at startup.
76
+ At runtime, each request performs a deterministic path + method lookup and executes the matched handler inside Astro’s native API context.
77
+
78
+ ```text
79
+ [Astro Endpoint]
80
+ |
81
+ astro-routify
82
+ |
83
+ Route Trie
84
+ |
85
+ Handler
86
+ ```
87
+
88
+ > ⚑ **Cold-start performance**: Routes are compiled once at startup; there is no per-request route parsing. This makes it ideal for edge and serverless environments.
89
+
90
+ ### Which API should I use?
91
+
92
+ | Use case | Recommended |
93
+ |----------|-------------|
94
+ | Small / explicit APIs | `defineRouter()` |
95
+ | Single-entry / catch-all | `RouterBuilder` |
96
+ | Vertical slices (glob) | `addModules()` |
97
+ | Large apps / plugins | Global registry |
98
+
99
+ ### πŸ— Trie Invariants
100
+ For architectural stability and predictable performance, the following invariants are enforced:
101
+ - **Single Dynamic Branching**: A node has at most one dynamic parameter child (`paramChild`).
102
+ - **Unified Param Node**: The `paramChild` represents *any* `:param` at that depth; parameter names are bound from route-specific metadata during matching.
103
+ - **Structural Identity**: Two routes differing only by parameter name (e.g., `/u/:id` and `/u/:slug`) are considered structurally identical.
104
+ - **Deterministic Match Order**: Static > Regex (sorted by specificity) > Param > Wildcard > Catch-all.
105
+ - **Terminal Catch-all**: `**` matches are only allowed as the final segment.
106
+
58
107
  ## πŸ’‘ Full Example
59
108
 
60
109
  You can find an implementation example in the [astro-routify-example](https://github.com/oamm/astro-routify-example)
@@ -76,14 +125,10 @@ endpoint system.
76
125
  - 🧩 Use middleware, access cookies, headers, and request bodies exactly as you would in a normal Astro endpoint.
77
126
  - βœ… Flat-file, code-based routing (no folders required)
78
127
  - βœ… Dynamic segments (`:id`)
79
- - βœ… ALL-mode for monolithic routing (`RouterBuilder`)
128
+ - βœ… ALL-mode for single-entry routing (`RouterBuilder`)
80
129
  - βœ… Built-in response helpers (`ok`, `created`, etc.)
81
130
  - βœ… Trie-based matcher for fast route lookup
82
131
  - βœ… Fully typed β€” no magic strings
83
- - πŸ” **Streaming support**
84
- - `stream()` β€” raw streaming with backpressure support (e.g. SSE, logs, custom protocols)
85
- - `streamJsonND()` β€” newline-delimited JSON streaming (NDJSON)
86
- - `streamJsonArray()` β€” server-side streamed JSON arrays
87
132
 
88
133
  > πŸ”„ See [CHANGELOG.md](./CHANGELOG.md) for recent updates and improvements.
89
134
  ---
@@ -92,18 +137,33 @@ endpoint system.
92
137
 
93
138
  ### `defineRoute()`
94
139
 
95
- Declare a single route. Now supports middlewares and metadata:
140
+ Declare a single route. Now supports middlewares and metadata.
141
+
142
+ > πŸ’‘ `defineRoute` supports two signatures: you can pass a full `Route` object, or specify `method`, `path`, and `handler` as separate arguments.
143
+
144
+ ### `RoutifyContext` & `Context`
145
+
146
+ The context object passed to handlers and middlewares extends Astro's `APIContext`. For better ergonomics, you can use the `Context` type alias:
96
147
 
97
148
  ```ts
98
- defineRoute({
99
- method: 'GET',
100
- path: '/users/:id',
101
- middlewares: [authMiddleware],
102
- metadata: { summary: 'Get user by ID' },
103
- handler: ({params}) => ok({userId: params.id})
104
- });
149
+ import { type Context } from 'astro-routify';
150
+
151
+ const handler = (ctx: Context) => ok('hello');
105
152
  ```
106
153
 
154
+ Properties include:
155
+
156
+ - `params`: Route parameters (e.g., `:id`). Matching and capture operate on **decoded** path segments. Decoding occurs exactly once per segment prior to matching.
157
+ - `query`: A read-only snapshot of parsed query parameters. Supports multi-value keys (`string | string[]`).
158
+ - `searchParams`: The raw `URLSearchParams` object. Note: Mutations to `searchParams` do not reflect in `ctx.query`.
159
+ - `state`: A shared object container for passing data between middlewares and handlers.
160
+
161
+ #### Path normalization & basePath stripping
162
+ - `basePath` stripping occurs before decoding and normalization.
163
+ - Stripping happens only on segment boundaries (e.g., `/api/users` matches, `/apiusers` does not).
164
+ - Trailing slashes are normalized by segmentization; `/users` and `/users/` are equivalent.
165
+ - All matching and parameter capture operate on decoded segments.
166
+
107
167
  ### `defineRouter()`
108
168
 
109
169
  Group multiple routes under one HTTP method handler:
@@ -120,7 +180,8 @@ export const GET = defineRouter([
120
180
 
121
181
  #### 1. Wildcards
122
182
  - `*` matches exactly one segment.
123
- - `**` matches zero or more segments (catch-all).
183
+ - `**` matches zero or more segments (catch-all). **Must be at the end of the path.**
184
+ - Captures the remaining path into `ctx.params['*']`.
124
185
 
125
186
  ```ts
126
187
  builder.addGet('/files/*/download', () => ok('one segment'));
@@ -130,6 +191,8 @@ builder.addGet('/static/**', () => ok('all segments'));
130
191
  #### 2. Regex Constraints
131
192
  You can constrain parameters using regex by wrapping the pattern in parentheses: `:param(regex)`.
132
193
 
194
+ > ⚠️ **Specificity Note**: Regex sorting is deterministic (longer pattern first) but heuristic. Users should avoid overlapping regex constraints at the same depth.
195
+
133
196
  ```ts
134
197
  // Matches only numeric IDs
135
198
  builder.addGet('/users/:id(\\d+)', ({ params }) => ok(params.id));
@@ -171,7 +234,7 @@ builder.group('/admin')
171
234
 
172
235
  // Route middleware
173
236
  builder.addPost('/user', validate(UserSchema), (ctx) => {
174
- return ok(ctx.data.body);
237
+ return ok(ctx.state.body);
175
238
  });
176
239
  ```
177
240
 
@@ -188,7 +251,7 @@ const UserSchema = z.object({
188
251
  });
189
252
 
190
253
  builder.addPost('/register', validate({ body: UserSchema }), (ctx) => {
191
- const user = ctx.data.body; // Fully typed if using TypeScript correctly
254
+ const user = ctx.state.body; // Fully typed if using TypeScript correctly
192
255
  return ok(user);
193
256
  });
194
257
  ```
@@ -203,9 +266,7 @@ builder.use(cors({ origin: 'https://example.com' }));
203
266
  builder.use(securityHeaders());
204
267
  ```
205
268
 
206
- ### πŸ›  Advanced Configuration
207
-
208
- #### Centralized Error Handling
269
+ ### Centralized Error Handling
209
270
  Handle all API errors in one place:
210
271
 
211
272
  ```ts
@@ -217,27 +278,11 @@ export const ALL = createRouter({
217
278
  });
218
279
  ```
219
280
 
220
- #### OpenAPI (Swagger) Generation
221
- Automatically generate API documentation:
222
-
223
- ```ts
224
- import { generateOpenAPI } from 'astro-routify';
225
-
226
- const router = builder.build();
227
- const spec = generateOpenAPI(router, {
228
- title: 'My API',
229
- version: '1.0.0'
230
- });
231
-
232
- // Serve the spec
233
- builder.addGet('/openapi.json', () => ok(spec));
234
- ```
235
-
236
281
  > 🧠 `defineRouter()` supports all HTTP methods β€” but Astro only executes the method you export (`GET`, `POST`, etc.)
237
282
 
238
- ### `RouterBuilder` (Catch-All & Fluent Builder)
283
+ ## 🧱 Single-entry Routing
239
284
 
240
- Use `RouterBuilder` when you want to build routes dynamically, catch all HTTP methods via `ALL`, or organize routes more
285
+ Use `RouterBuilder` when you want to build routes dynamically, catch-all HTTP methods via `ALL`, or organize routes more
241
286
  fluently with helpers.
242
287
 
243
288
  ```ts
@@ -253,11 +298,13 @@ builder
253
298
  export const ALL = builder.build();
254
299
  ```
255
300
 
256
- #### πŸ— Vertical Slices & Auto-Discovery
301
+ ## πŸ“‚ Auto-Discovery & Scaling
257
302
 
258
303
  To avoid a long list of manual registrations, you can use `addModules` combined with Vite's `import.meta.glob`. This allows
259
304
  you to define routes anywhere in your project (near your components) and have them automatically registered.
260
305
 
306
+ > πŸ’‘ When passing the glob result directly to the router, you **don't** need to set `autoRegister: true` in your routes. The router will automatically discover all exported routes from the modules.
307
+
261
308
  ```ts
262
309
  // src/pages/api/[...all].ts
263
310
  import { RouterBuilder, createRouter } from 'astro-routify';
@@ -274,7 +321,7 @@ export const ALL = createRouter(
274
321
  );
275
322
  ```
276
323
 
277
- #### πŸ›‘οΈ Agnostic Auto-Registration (Global Registry)
324
+ ### πŸ›‘οΈ Agnostic Auto-Registration (Global Registry)
278
325
 
279
326
  If you want to avoid passing glob results or knowing the relative path to your routes, you can use the **global registry**. By setting the `autoRegister` flag or using **decorators**, routes will register themselves as soon as their module is loaded.
280
327
 
@@ -301,28 +348,60 @@ class UserRoutes {
301
348
 
302
349
  ##### 2. Initialize the router agnostically
303
350
 
304
- In your catch-all endpoint, simply call `import.meta.glob` to trigger the loading of your route files, and then call `createRouter()` without module arguments.
351
+ In your catch-all endpoint (e.g., `src/pages/api/[...slug].ts`), you need to trigger the loading of your route files.
352
+
353
+ > **Why is the glob needed?**
354
+ > Even with auto-registration, Vite only executes files that are explicitly imported. The `import.meta.glob` call below tells Vite to find and execute your route files so they can register themselves in the global registry. Without this, the registry would remain empty.
355
+
356
+ ###### Using `createRouter()` (Recommended)
357
+
358
+ The `createRouter` helper is the easiest way to get started. It automatically picks up everything from the global registry.
305
359
 
306
360
  ```ts
307
- // src/pages/api/[...all].ts
361
+ // src/pages/api/[...slug].ts
308
362
  import { createRouter } from 'astro-routify';
309
363
 
310
- // Trigger loading of all route files (agnostic of relative path using /src root alias)
364
+ // 1. Trigger loading of all route files.
365
+ // We don't need to pass the result to createRouter, but we must call it.
311
366
  import.meta.glob('/src/**/*.routes.ts', { eager: true });
312
367
 
313
- // createRouter() will automatically include all auto-registered routes
314
- export const ALL = createRouter({ debug: true });
368
+ // 2. createRouter() will automatically pick up everything.
369
+ export const ALL = createRouter({
370
+ debug: true,
371
+ basePath: '/api'
372
+ });
315
373
  ```
316
374
 
317
- You can also use the global `RouterBuilder` instance directly:
375
+ ###### Using `RouterBuilder`
376
+
377
+ If you need more control, you can use `RouterBuilder` manually. You must explicitly call `.addRegistered()` to pull in routes from the global registry.
318
378
 
319
379
  ```ts
320
- import { RouterBuilder } from 'astro-routify';
380
+ // src/pages/api/[...slug].ts
381
+ import { RouterBuilder, notFound, internalError } from 'astro-routify';
382
+
383
+ // 1. Load your routes
384
+ // This triggers Vite to execute the files and populate the global registry.
321
385
  import.meta.glob('/src/**/*.routes.ts', { eager: true });
322
386
 
323
- export const ALL = RouterBuilder.global.build();
387
+ // 2. Construct the builder with optional configuration
388
+ const builder = new RouterBuilder({
389
+ basePath: '/api',
390
+ debug: true, // Enable logging
391
+ onNotFound: () => notFound('Custom 404'),
392
+ onError: (err) => internalError(err)
393
+ });
394
+
395
+ // 3. Add auto-registered routes and build
396
+ export const ALL = builder
397
+ .addRegistered()
398
+ .build();
324
399
  ```
325
400
 
401
+ > πŸ’‘ **The Catch-All Slug**: The filename `[...slug].ts` tells Astro to match any path under that directory. For example, if placed in `src/pages/api/[...slug].ts`, it matches `/api/users`, `/api/ping`, etc. `astro-routify` then takes over and matches the rest of the path against your defined routes.
402
+
403
+ > ⚠️ In production (non-HMR) builds, duplicate route registrations with the same `method:path` MAY emit warnings and the last registration wins. In development/HMR flows, the registry intentionally preserves history and the builder deduplicates using a last-wins policy.
404
+
326
405
  You can also still manually add routes or groups:
327
406
 
328
407
  ```ts
@@ -333,6 +412,80 @@ builder.addGroup(users);
333
412
  builder.addGet("/ping", () => ok("pong"));
334
413
  ```
335
414
 
415
+ ## ⚑ Advanced Features
416
+
417
+ ### πŸ”„ Streaming responses
418
+
419
+ #### Lifecycle & Guarantees
420
+ - **Short-circuiting**: Returning a stream result (e.g., from `stream()`) short-circuits the middleware chain; `next()` must not be called after the response starts.
421
+ - **Abort Semantics**: If the client disconnects, the stream closes silently. Any internal controllers are closed via `AbortSignal`. Cleanup hooks should be idempotent.
422
+
423
+ #### Raw stream (e.g., Server-Sent Events)
424
+
425
+ `stream()` automatically handles SSE headers and auto-formats string chunks with a `state: ` prefix and double-newlines.
426
+
427
+ ```ts
428
+ stream('/clock', async ({response}) => {
429
+ const timer = setInterval(() => {
430
+ // Automatically sent as "state: <iso-date>\n\n"
431
+ response.write(new Date().toISOString());
432
+ }, 1000);
433
+
434
+ setTimeout(() => {
435
+ clearInterval(timer);
436
+ response.close();
437
+ }, 5000);
438
+ });
439
+
440
+ ```
441
+
442
+ #### JSON NDStream (newline-delimited)
443
+
444
+ ```ts
445
+
446
+ streamJsonND('/updates', async ({response}) => {
447
+ response.send({step: 1});
448
+ await delay(500);
449
+ response.send({step: 2});
450
+ response.close();
451
+ });
452
+
453
+ ```
454
+
455
+ #### JSON Array stream
456
+
457
+ ```ts
458
+
459
+ streamJsonArray('/items', async ({response}) => {
460
+ for (let i = 0; i < 3; i++) {
461
+ response.send({id: i});
462
+ }
463
+ response.close();
464
+ });
465
+
466
+ ```
467
+
468
+ ### πŸ“– OpenAPI (Swagger) Generation
469
+
470
+ Automatically generate API documentation from your router instance.
471
+
472
+ - **Catch-all (`**`)**: Represented as `{rest}` parameter.
473
+ - **Wildcard (`*`)**: Represented as `{any}` parameter.
474
+ - **Regex**: Mapped to a string schema with a `pattern` constraint.
475
+
476
+ ```ts
477
+ import { generateOpenAPI } from 'astro-routify';
478
+
479
+ const router = builder.build();
480
+ const spec = generateOpenAPI(router, {
481
+ title: 'My API',
482
+ version: '1.0.0'
483
+ });
484
+
485
+ // Serve the spec
486
+ builder.addGet('/openapi.json', () => ok(spec));
487
+ ```
488
+
336
489
  > πŸ” While `.register()` is still available, it's **deprecated** in favor of `.addGroup()` and `.addRoute()` for better
337
490
  > structure and reusability.
338
491
 
@@ -363,7 +516,9 @@ const router = new RouterBuilder({ debug: true });
363
516
 
364
517
  ## πŸ” Response Helpers
365
518
 
366
- Avoid boilerplate `new Response(JSON.stringify(...))`:
519
+ Avoid boilerplate `new Response(JSON.stringify(...))`.
520
+
521
+ > πŸ’‘ **Header Precedence**: Explicit headers provided via `ResultResponse` (e.g., `ok(data, { 'Content-Type': '...' })`) always take precedence over inferred defaults.
367
522
 
368
523
  ```ts
369
524
  import {fileResponse} from 'astro-routify';
@@ -381,49 +536,7 @@ internalError(err); // 500
381
536
  fileResponse(content, "application/pdf", "report.pdf"); // sets Content-Type and Content-Disposition
382
537
  ```
383
538
 
384
- ### πŸ”„ Streaming responses
385
-
386
- #### Raw stream (e.g., Server-Sent Events)
387
-
388
- ```ts
389
- stream('/clock', async ({response}) => {
390
- const timer = setInterval(() => {
391
- response.write(new Date().toISOString());
392
- }, 1000);
393
-
394
- setTimeout(() => {
395
- clearInterval(timer);
396
- response.close();
397
- }, 5000);
398
- });
399
-
400
- ```
401
-
402
- #### JSON NDStream (newline-delimited)
403
-
404
- ```ts
405
-
406
- streamJsonND('/updates', async ({response}) => {
407
- response.send({step: 1});
408
- await delay(500);
409
- response.send({step: 2});
410
- response.close();
411
- });
412
-
413
- ```
414
-
415
- #### JSON Array stream
416
-
417
- ```ts
418
-
419
- streamJsonArray('/items', async ({response}) => {
420
- for (let i = 0; i < 3; i++) {
421
- response.send({id: i});
422
- }
423
- response.close();
424
- });
425
-
426
- ```
539
+ ---
427
540
 
428
541
  ---
429
542
 
@@ -566,6 +679,15 @@ Results may vary slightly on different hardware.
566
679
 
567
680
  ---
568
681
 
682
+ ## 🎯 Non-goals
683
+
684
+ - **Not a replacement for Astro pages**: Use it for APIs, not for HTML rendering.
685
+ - **No runtime file watching**: Route discovery happens at startup.
686
+ - **No opinionated auth or ORM layer**: It's a router, not a framework.
687
+ - **No framework lock-in**: Works with any library (Zod, Valibot, etc.).
688
+
689
+ ---
690
+
569
691
  ## πŸ›  Designed to Scale
570
692
 
571
693
  While focused on simplicity and speed today, `astro-routify` is designed to evolve β€” enabling more advanced routing
@@ -6,9 +6,11 @@ export class RouteTrie {
6
6
  if (!route || typeof route.path !== 'string') {
7
7
  return;
8
8
  }
9
- const segments = this.segmentize(route.path);
9
+ const segments = this.segmentize(route.path, true);
10
10
  let node = this.root;
11
- for (const segment of segments) {
11
+ const paramNames = {};
12
+ for (let i = 0; i < segments.length; i++) {
13
+ const segment = segments[i];
12
14
  if (segment === '**') {
13
15
  if (!node.catchAllChild) {
14
16
  node.catchAllChild = { children: new Map(), routes: new Map() };
@@ -37,13 +39,16 @@ export class RouteTrie {
37
39
  paramName,
38
40
  node: regexNode,
39
41
  });
42
+ // Sort regex by specificity (longer source first)
43
+ node.regexChildren.sort((a, b) => b.regex.source.length - a.regex.source.length);
40
44
  }
41
45
  node = regexNode;
42
46
  }
43
47
  else {
44
48
  const paramName = segment.slice(1);
49
+ paramNames[i] = paramName;
45
50
  if (!node.paramChild) {
46
- node.paramChild = { children: new Map(), routes: new Map(), paramName };
51
+ node.paramChild = { children: new Map(), routes: new Map() };
47
52
  }
48
53
  node = node.paramChild;
49
54
  }
@@ -55,28 +60,38 @@ export class RouteTrie {
55
60
  node = node.children.get(segment);
56
61
  }
57
62
  }
58
- node.routes.set(route.method, route);
63
+ node.routes.set(route.method, { route, paramNames });
59
64
  }
60
65
  find(path, method) {
61
- const segments = this.segmentize(path);
62
- return this.matchNode(this.root, segments, 0, method);
66
+ const segments = this.segmentize(path, true);
67
+ return this.matchNode(this.root, segments, 0, method, {});
63
68
  }
64
- matchNode(node, segments, index, method) {
69
+ matchNode(node, segments, index, method, capturedValues) {
65
70
  if (index === segments.length) {
66
- let route = node.routes.get(method) ?? null;
67
- let allowed = route ? undefined : [...node.routes.keys()];
71
+ let info = node.routes.get(method) ?? null;
72
+ let allowed = info ? undefined : [...node.routes.keys()];
68
73
  // If no route here, check if there's a catch-all that matches "empty" remaining
69
- if (!route && node.catchAllChild) {
70
- route = node.catchAllChild.routes.get(method) ?? null;
71
- allowed = route ? undefined : [...node.catchAllChild.routes.keys()];
74
+ if (!info && node.catchAllChild) {
75
+ info = node.catchAllChild.routes.get(method) ?? null;
76
+ allowed = info ? undefined : [...node.catchAllChild.routes.keys()];
77
+ if (info) {
78
+ return { route: info.route, allowed, params: { '*': '' } };
79
+ }
80
+ }
81
+ if (info) {
82
+ const params = {};
83
+ for (const [depth, name] of Object.entries(info.paramNames)) {
84
+ params[name] = capturedValues[Number(depth)];
85
+ }
86
+ return { route: info.route, params };
72
87
  }
73
- return { route, allowed, params: {} };
88
+ return { route: null, allowed, params: {} };
74
89
  }
75
90
  const segment = segments[index];
76
91
  // 1. Static Match
77
92
  const staticChild = node.children.get(segment);
78
93
  if (staticChild) {
79
- const match = this.matchNode(staticChild, segments, index + 1, method);
94
+ const match = this.matchNode(staticChild, segments, index + 1, method, capturedValues);
80
95
  if (match.route || (match.allowed && match.allowed.length > 0))
81
96
  return match;
82
97
  }
@@ -84,7 +99,7 @@ export class RouteTrie {
84
99
  if (node.regexChildren) {
85
100
  for (const rc of node.regexChildren) {
86
101
  if (rc.regex.test(segment)) {
87
- const match = this.matchNode(rc.node, segments, index + 1, method);
102
+ const match = this.matchNode(rc.node, segments, index + 1, method, capturedValues);
88
103
  if (match.route || (match.allowed && match.allowed.length > 0)) {
89
104
  match.params[rc.paramName] = segment;
90
105
  return match;
@@ -94,32 +109,45 @@ export class RouteTrie {
94
109
  }
95
110
  // 3. Param Match
96
111
  if (node.paramChild) {
97
- const match = this.matchNode(node.paramChild, segments, index + 1, method);
112
+ capturedValues[index] = segment;
113
+ const match = this.matchNode(node.paramChild, segments, index + 1, method, capturedValues);
98
114
  if (match.route || (match.allowed && match.allowed.length > 0)) {
99
- match.params[node.paramChild.paramName] = segment;
100
115
  return match;
101
116
  }
117
+ delete capturedValues[index];
102
118
  }
103
119
  // 4. Wildcard Match (*)
104
120
  if (node.wildcardChild) {
105
- const match = this.matchNode(node.wildcardChild, segments, index + 1, method);
121
+ const match = this.matchNode(node.wildcardChild, segments, index + 1, method, capturedValues);
106
122
  if (match.route || (match.allowed && match.allowed.length > 0))
107
123
  return match;
108
124
  }
109
125
  // 5. Catch-all Match (**)
110
126
  if (node.catchAllChild) {
111
- const route = node.catchAllChild.routes.get(method) ?? null;
127
+ const info = node.catchAllChild.routes.get(method) ?? null;
128
+ const params = {};
129
+ // Capture the rest of the path
130
+ const remainder = segments.slice(index).join('/');
131
+ params['*'] = remainder;
112
132
  return {
113
- route,
114
- allowed: route ? undefined : [...node.catchAllChild.routes.keys()],
115
- params: {},
133
+ route: info ? info.route : null,
134
+ allowed: info ? undefined : [...node.catchAllChild.routes.keys()],
135
+ params,
116
136
  };
117
137
  }
118
138
  return { route: null, params: {} };
119
139
  }
120
- segmentize(path) {
140
+ segmentize(path, decode = false) {
121
141
  if (typeof path !== 'string')
122
142
  return [];
123
- return path.split('/').filter(Boolean);
143
+ const segments = path.split('/').filter(Boolean);
144
+ return decode ? segments.map(s => {
145
+ try {
146
+ return decodeURIComponent(s);
147
+ }
148
+ catch {
149
+ return s;
150
+ }
151
+ }) : segments;
124
152
  }
125
153
  }
@@ -1,6 +1,6 @@
1
1
  import { defineRoute, isRoute } from './defineRoute';
2
2
  import { defineRouter } from './defineRouter';
3
- import { defineGroup, RouteGroup } from './defineGroup';
3
+ import { defineGroup } from './defineGroup';
4
4
  import { HttpMethod } from './HttpMethod';
5
5
  import { globalRegistry } from './registry';
6
6
  /**
@@ -81,12 +81,33 @@ export class RouterBuilder {
81
81
  * @returns The current builder instance.
82
82
  */
83
83
  addRegistered() {
84
- globalRegistry.getItems().forEach((item) => {
85
- if (item instanceof RouteGroup) {
86
- this.addGroup(item);
84
+ const items = globalRegistry.getItems();
85
+ const lastRouteIndex = new Map();
86
+ const routesByKey = new Map();
87
+ const lastGroupIndex = new Map();
88
+ const groupsByKey = new Map();
89
+ items.forEach((item, index) => {
90
+ if (item && typeof item === 'object' && item._routifyType === 'group') {
91
+ const key = item.getBasePath();
92
+ lastGroupIndex.set(key, index);
93
+ groupsByKey.set(key, item);
87
94
  }
88
95
  else if (isRoute(item)) {
89
- this.addRoute(item);
96
+ const key = `${item.method}:${item.path}`;
97
+ lastRouteIndex.set(key, index);
98
+ routesByKey.set(key, item);
99
+ }
100
+ });
101
+ items.forEach((item, index) => {
102
+ if (item && typeof item === 'object' && item._routifyType === 'group') {
103
+ const key = item.getBasePath();
104
+ if (lastGroupIndex.get(key) === index)
105
+ this.addGroup(groupsByKey.get(key));
106
+ }
107
+ else if (isRoute(item)) {
108
+ const key = `${item.method}:${item.path}`;
109
+ if (lastRouteIndex.get(key) === index)
110
+ this.addRoute(routesByKey.get(key));
90
111
  }
91
112
  });
92
113
  return this;
@@ -101,8 +122,9 @@ export class RouterBuilder {
101
122
  * @returns The current builder instance.
102
123
  */
103
124
  addModules(modules) {
104
- Object.values(modules).forEach((m) => {
105
- if (m instanceof RouteGroup) {
125
+ Object.keys(modules).sort().forEach((key) => {
126
+ const m = modules[key];
127
+ if (m && typeof m === 'object' && m._routifyType === 'group') {
106
128
  this.addGroup(m);
107
129
  }
108
130
  else if (isRoute(m)) {
@@ -110,7 +132,7 @@ export class RouterBuilder {
110
132
  }
111
133
  else if (typeof m === 'object' && m !== null) {
112
134
  Object.values(m).forEach((val) => {
113
- if (val instanceof RouteGroup) {
135
+ if (val && typeof val === 'object' && val._routifyType === 'group') {
114
136
  this.addGroup(val);
115
137
  }
116
138
  else if (isRoute(val)) {
@@ -121,7 +143,7 @@ export class RouterBuilder {
121
143
  if (isRoute(item)) {
122
144
  this.addRoute(item);
123
145
  }
124
- else if (item instanceof RouteGroup) {
146
+ else if (item && typeof item === 'object' && item._routifyType === 'group') {
125
147
  this.addGroup(item);
126
148
  }
127
149
  });
@@ -280,6 +302,17 @@ export class RouterBuilder {
280
302
  for (const group of this._groups) {
281
303
  allRoutes.push(...group.getRoutes());
282
304
  }
305
+ // Detect duplicates in production (warnings)
306
+ if (!this._shouldLog) {
307
+ const seen = new Set();
308
+ for (const route of allRoutes) {
309
+ const key = `${route.method}:${route.path}`;
310
+ if (seen.has(key)) {
311
+ console.warn(`[RouterBuilder] Duplicate route detected: ${key}. The last one will be used.`);
312
+ }
313
+ seen.add(key);
314
+ }
315
+ }
283
316
  // Apply global middlewares to all routes before building
284
317
  const finalRoutes = allRoutes.map(route => ({
285
318
  ...route,