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 +213 -91
- package/dist/core/RouteTrie.js +52 -24
- package/dist/core/RouterBuilder.js +42 -9
- package/dist/core/defineGroup.d.ts +1 -0
- package/dist/core/defineGroup.js +1 -0
- package/dist/core/defineHandler.d.ts +19 -11
- package/dist/core/defineHandler.js +2 -13
- package/dist/core/defineRoute.d.ts +6 -1
- package/dist/core/defineRoute.js +8 -3
- package/dist/core/defineRouter.d.ts +2 -2
- package/dist/core/defineRouter.js +18 -2
- package/dist/core/internal/createJsonStreamRoute.d.ts +1 -1
- package/dist/core/middlewares.d.ts +1 -1
- package/dist/core/middlewares.js +4 -4
- package/dist/core/openapi.js +32 -5
- package/dist/core/responseHelpers.d.ts +9 -5
- package/dist/core/responseHelpers.js +92 -17
- package/dist/core/stream.d.ts +3 -3
- package/dist/core/stream.js +3 -3
- package/dist/core/streamJsonArray.d.ts +1 -1
- package/dist/core/streamJsonArray.js +1 -1
- package/dist/index.d.ts +42 -24
- package/package.json +1 -1
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
|
|
283
|
+
## π§± Single-entry Routing
|
|
239
284
|
|
|
240
|
-
Use `RouterBuilder` when you want to build routes dynamically, catch
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/[...
|
|
361
|
+
// src/pages/api/[...slug].ts
|
|
308
362
|
import { createRouter } from 'astro-routify';
|
|
309
363
|
|
|
310
|
-
// Trigger loading of all route files
|
|
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
|
|
314
|
-
export const ALL = createRouter({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/core/RouteTrie.js
CHANGED
|
@@ -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
|
-
|
|
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()
|
|
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
|
|
67
|
-
let allowed =
|
|
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 (!
|
|
70
|
-
|
|
71
|
-
allowed =
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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()
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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.
|
|
105
|
-
|
|
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
|
|
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
|
|
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,
|