astro-routify 1.2.2 β†’ 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,14 +137,33 @@ endpoint system.
92
137
 
93
138
  ### `defineRoute()`
94
139
 
95
- Declare a single route:
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(HttpMethod.GET, "/users/:id", ({params}) => {
99
- return ok({userId: params.id});
100
- });
149
+ import { type Context } from 'astro-routify';
150
+
151
+ const handler = (ctx: Context) => ok('hello');
101
152
  ```
102
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
+
103
167
  ### `defineRouter()`
104
168
 
105
169
  Group multiple routes under one HTTP method handler:
@@ -110,11 +174,115 @@ export const GET = defineRouter([
110
174
  ]);
111
175
  ```
112
176
 
177
+ ### Advanced Matching
178
+
179
+ `astro-routify` supports advanced routing patterns including wildcards and regex constraints.
180
+
181
+ #### 1. Wildcards
182
+ - `*` matches exactly one segment.
183
+ - `**` matches zero or more segments (catch-all). **Must be at the end of the path.**
184
+ - Captures the remaining path into `ctx.params['*']`.
185
+
186
+ ```ts
187
+ builder.addGet('/files/*/download', () => ok('one segment'));
188
+ builder.addGet('/static/**', () => ok('all segments'));
189
+ ```
190
+
191
+ #### 2. Regex Constraints
192
+ You can constrain parameters using regex by wrapping the pattern in parentheses: `:param(regex)`.
193
+
194
+ > ⚠️ **Specificity Note**: Regex sorting is deterministic (longer pattern first) but heuristic. Users should avoid overlapping regex constraints at the same depth.
195
+
196
+ ```ts
197
+ // Matches only numeric IDs
198
+ builder.addGet('/users/:id(\\d+)', ({ params }) => ok(params.id));
199
+
200
+ // Matches hex colors
201
+ builder.addGet('/color/:hex(^([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$)', ({ params }) => ok(params.hex));
202
+ ```
203
+
204
+ #### 3. Matching Priority
205
+ When multiple routes could match a path, the router follows a deterministic priority order:
206
+ 1. **Static Match** (e.g., `/p/static`)
207
+ 2. **Regex Match** (e.g., `/p/:id(\\d+)`)
208
+ 3. **Param Match** (e.g., `/p/:id`)
209
+ 4. **Wildcard Match** (e.g., `/p/*`)
210
+ 5. **Catch-all Match** (e.g., `/**`)
211
+
212
+ ### πŸ›‘οΈ Middleware & Security
213
+
214
+ `astro-routify` provides a powerful middleware system and built-in security helpers.
215
+
216
+ #### 1. Middleware Support
217
+ Middleware can be applied globally, to groups, or to individual routes.
218
+
219
+ ```ts
220
+ const builder = new RouterBuilder();
221
+
222
+ // Global middleware
223
+ builder.use(async (ctx, next) => {
224
+ const start = performance.now();
225
+ const res = await next();
226
+ console.log(`Duration: ${performance.now() - start}ms`);
227
+ return res;
228
+ });
229
+
230
+ // Group middleware
231
+ builder.group('/admin')
232
+ .use(checkAuth)
233
+ .addGet('/dashboard', (ctx) => ok('Admin only'));
234
+
235
+ // Route middleware
236
+ builder.addPost('/user', validate(UserSchema), (ctx) => {
237
+ return ok(ctx.state.body);
238
+ });
239
+ ```
240
+
241
+ #### 2. Request Validation
242
+ Built-in `validate()` middleware works with Zod, Valibot, or any library implementing a `safeParse` method.
243
+
244
+ ```ts
245
+ import { validate } from 'astro-routify';
246
+ import { z } from 'zod';
247
+
248
+ const UserSchema = z.object({
249
+ name: z.string(),
250
+ email: z.string().email()
251
+ });
252
+
253
+ builder.addPost('/register', validate({ body: UserSchema }), (ctx) => {
254
+ const user = ctx.state.body; // Fully typed if using TypeScript correctly
255
+ return ok(user);
256
+ });
257
+ ```
258
+
259
+ #### 3. Security Middlewares (CORS & Headers)
260
+ Protect your API with `cors()` and `securityHeaders()`.
261
+
262
+ ```ts
263
+ import { cors, securityHeaders } from 'astro-routify';
264
+
265
+ builder.use(cors({ origin: 'https://example.com' }));
266
+ builder.use(securityHeaders());
267
+ ```
268
+
269
+ ### Centralized Error Handling
270
+ Handle all API errors in one place:
271
+
272
+ ```ts
273
+ export const ALL = createRouter({
274
+ onError: (err, ctx) => {
275
+ console.error(err);
276
+ return json({ error: 'Something went wrong' }, 500);
277
+ }
278
+ });
279
+ ```
280
+
113
281
  > 🧠 `defineRouter()` supports all HTTP methods β€” but Astro only executes the method you export (`GET`, `POST`, etc.)
114
282
 
115
- ### `RouterBuilder` (Catch-All & Fluent Builder)
283
+ ## 🧱 Single-entry Routing
116
284
 
117
- 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
118
286
  fluently with helpers.
119
287
 
120
288
  ```ts
@@ -130,47 +298,136 @@ builder
130
298
  export const ALL = builder.build();
131
299
  ```
132
300
 
133
- You can also group routes:
301
+ ## πŸ“‚ Auto-Discovery & Scaling
302
+
303
+ To avoid a long list of manual registrations, you can use `addModules` combined with Vite's `import.meta.glob`. This allows
304
+ you to define routes anywhere in your project (near your components) and have them automatically registered.
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.
134
307
 
135
308
  ```ts
136
- const users = defineGroup("/users")
137
- .addGet("/:id", ({params}) => ok({id: params.id}));
309
+ // src/pages/api/[...all].ts
310
+ import { RouterBuilder, createRouter } from 'astro-routify';
138
311
 
139
- builder.addGroup(users);
312
+ // 1. Using the builder
313
+ const builder = new RouterBuilder();
314
+ builder.addModules(import.meta.glob('../../**/*.routes.ts', { eager: true }));
315
+ export const ALL = builder.build();
316
+
317
+ // 2. Or using the one-liner helper
318
+ export const ALL = createRouter(
319
+ import.meta.glob('../../**/*.routes.ts', { eager: true }),
320
+ { debug: true } // optional: enable match logging
321
+ );
140
322
  ```
141
323
 
142
- > πŸ” While `.register()` is still available, it's **deprecated** in favor of `.addGroup()` and `.addRoute()` for better
143
- > structure and reusability.
324
+ ### πŸ›‘οΈ Agnostic Auto-Registration (Global Registry)
144
325
 
145
- ---
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.
146
327
 
147
- ## πŸ” Response Helpers
328
+ ##### 1. Enable Auto-Registration in your routes
329
+
330
+ ```ts
331
+ // src/components/User/User.routes.ts
332
+ import { defineRoute, defineGroup, ok, Get } from 'astro-routify';
333
+
334
+ // Option A: Using the flag
335
+ export const GET_USER = defineRoute('GET', '/users/:id', ({params}) => ok({id: params.id}), true);
336
+
337
+ // Option B: Using a group flag
338
+ defineGroup('/admin', (g) => {
339
+ g.addGet('/stats', () => ok({}));
340
+ }, true);
341
+
342
+ // Option C: Using Decorators (requires experimentalDecorators: true)
343
+ class UserRoutes {
344
+ @Get('/profile')
345
+ static getProfile() { return ok({ name: 'Alex' }); }
346
+ }
347
+ ```
348
+
349
+ ##### 2. Initialize the router agnostically
350
+
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)
148
357
 
149
- Avoid boilerplate `new Response(JSON.stringify(...))`:
358
+ The `createRouter` helper is the easiest way to get started. It automatically picks up everything from the global registry.
150
359
 
151
360
  ```ts
152
- import {fileResponse} from 'astro-routify';
361
+ // src/pages/api/[...slug].ts
362
+ import { createRouter } from 'astro-routify';
153
363
 
154
- ok(data); // 200 OK
155
- created(data); // 201 Created
156
- noContent(); // 204
157
- notFound("Missing"); // 404
158
- internalError(err); // 500
364
+ // 1. Trigger loading of all route files.
365
+ // We don't need to pass the result to createRouter, but we must call it.
366
+ import.meta.glob('/src/**/*.routes.ts', { eager: true });
367
+
368
+ // 2. createRouter() will automatically pick up everything.
369
+ export const ALL = createRouter({
370
+ debug: true,
371
+ basePath: '/api'
372
+ });
159
373
  ```
160
374
 
161
- ### πŸ“„ File downloads
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.
162
378
 
163
379
  ```ts
164
- fileResponse(content, "application/pdf", "report.pdf"); // sets Content-Type and Content-Disposition
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.
385
+ import.meta.glob('/src/**/*.routes.ts', { eager: true });
386
+
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();
399
+ ```
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
+
405
+ You can also still manually add routes or groups:
406
+
407
+ ```ts
408
+ const users = defineGroup("/users")
409
+ .addGet("/:id", ({params}) => ok({id: params.id}));
410
+
411
+ builder.addGroup(users);
412
+ builder.addGet("/ping", () => ok("pong"));
165
413
  ```
166
414
 
415
+ ## ⚑ Advanced Features
416
+
167
417
  ### πŸ”„ Streaming responses
168
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
+
169
423
  #### Raw stream (e.g., Server-Sent Events)
170
424
 
425
+ `stream()` automatically handles SSE headers and auto-formats string chunks with a `state: ` prefix and double-newlines.
426
+
171
427
  ```ts
172
428
  stream('/clock', async ({response}) => {
173
429
  const timer = setInterval(() => {
430
+ // Automatically sent as "state: <iso-date>\n\n"
174
431
  response.write(new Date().toISOString());
175
432
  }, 1000);
176
433
 
@@ -208,6 +465,79 @@ streamJsonArray('/items', async ({response}) => {
208
465
 
209
466
  ```
210
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
+
489
+ > πŸ” While `.register()` is still available, it's **deprecated** in favor of `.addGroup()` and `.addRoute()` for better
490
+ > structure and reusability.
491
+
492
+ Your route files can export single routes, groups, or arrays:
493
+
494
+ ```ts
495
+ // src/components/User/UserList.routes.ts
496
+ import { defineRoute, defineGroup, ok } from 'astro-routify';
497
+
498
+ export const GET = defineRoute('GET', '/users', () => ok([]));
499
+
500
+ export const AdminRoutes = defineGroup('/admin')
501
+ .addPost('/reset', () => ok('done'));
502
+ ```
503
+
504
+ #### πŸ›  Development & Debugging
505
+
506
+ `astro-routify` provides built-in logging to help you see your route table during development.
507
+
508
+ - **Auto-logging**: In development mode (`NODE_ENV=development`), `RouterBuilder` automatically prints the registered routes to the console when `build()` is called.
509
+ - **Match Tracing**: Set `debug: true` in `RouterOptions` to see a log of every incoming request and which route it matched (or why it failed with 404/405).
510
+
511
+ ```ts
512
+ const router = new RouterBuilder({ debug: true });
513
+ ```
514
+
515
+ ---
516
+
517
+ ## πŸ” Response Helpers
518
+
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.
522
+
523
+ ```ts
524
+ import {fileResponse} from 'astro-routify';
525
+
526
+ ok(data); // 200 OK
527
+ created(data); // 201 Created
528
+ noContent(); // 204
529
+ notFound("Missing"); // 404
530
+ internalError(err); // 500
531
+ ```
532
+
533
+ ### πŸ“„ File downloads
534
+
535
+ ```ts
536
+ fileResponse(content, "application/pdf", "report.pdf"); // sets Content-Type and Content-Disposition
537
+ ```
538
+
539
+ ---
540
+
211
541
  ---
212
542
 
213
543
  ## πŸ” Param Matching
@@ -349,6 +679,15 @@ Results may vary slightly on different hardware.
349
679
 
350
680
  ---
351
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
+
352
691
  ## πŸ›  Designed to Scale
353
692
 
354
693
  While focused on simplicity and speed today, `astro-routify` is designed to evolve β€” enabling more advanced routing
@@ -1,14 +1,15 @@
1
1
  import { HttpMethod } from './HttpMethod';
2
- import type { Handler } from './defineHandler';
2
+ import type { Route } from './defineRoute';
3
3
  interface RouteMatch {
4
- handler: Handler | null;
4
+ route: Route | null;
5
5
  allowed?: HttpMethod[];
6
6
  params: Record<string, string | undefined>;
7
7
  }
8
8
  export declare class RouteTrie {
9
9
  private readonly root;
10
- insert(path: string, method: HttpMethod, handler: Handler): void;
10
+ insert(route: Route): void;
11
11
  find(path: string, method: HttpMethod): RouteMatch;
12
+ private matchNode;
12
13
  private segmentize;
13
14
  }
14
15
  export {};