astro-routify 1.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/LICENSE +21 -0
- package/README.md +244 -0
- package/dist/core/HttpMethod.d.ts +13 -0
- package/dist/core/HttpMethod.js +20 -0
- package/dist/core/RouteTrie.d.ts +14 -0
- package/dist/core/RouteTrie.js +56 -0
- package/dist/core/RouterBuilder.d.ts +8 -0
- package/dist/core/RouterBuilder.js +17 -0
- package/dist/core/defineHandler.d.ts +4 -0
- package/dist/core/defineHandler.js +30 -0
- package/dist/core/defineRoute.d.ts +9 -0
- package/dist/core/defineRoute.js +18 -0
- package/dist/core/defineRouter.d.ts +8 -0
- package/dist/core/defineRouter.js +27 -0
- package/dist/core/responseHelpers.d.ts +17 -0
- package/dist/core/responseHelpers.js +30 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +7 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alex Mora
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# astro-routify
|
|
2
|
+
|
|
3
|
+
**A high-performance API router for [Astro](https://astro.build/) built on a Trie matcher.**
|
|
4
|
+
Define API routes using clean, flat structures β no folders or boilerplate logic.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## β‘οΈ Quickstart
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// src/pages/api/index.ts
|
|
12
|
+
import { defineRoute, defineRouter, HttpMethod, ok } from "astro-routify";
|
|
13
|
+
|
|
14
|
+
export const GET = defineRouter([
|
|
15
|
+
defineRoute(HttpMethod.GET, "/ping", () => ok("pong")),
|
|
16
|
+
defineRoute(HttpMethod.GET, "/users/:id", ({ params }) => ok({ id: params.id }))
|
|
17
|
+
]);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or to handle everything in a single place:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { RouterBuilder, defineRoute, HttpMethod, ok } from "astro-routify";
|
|
24
|
+
|
|
25
|
+
const builder = new RouterBuilder();
|
|
26
|
+
builder.register([
|
|
27
|
+
defineRoute(HttpMethod.GET, "/ping", () => ok("pong")),
|
|
28
|
+
defineRoute(HttpMethod.POST, "/submit", async ({ request }) => {
|
|
29
|
+
const body = await request.json();
|
|
30
|
+
return ok({ received: body });
|
|
31
|
+
})
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const ALL = builder.build(); // catch-all
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## π Features
|
|
40
|
+
|
|
41
|
+
- β‘ Fully compatible with Astroβs native APIContext β no extra setup needed.
|
|
42
|
+
- π§© Use middleware, access cookies, headers, and request bodies exactly as you would in a normal Astro endpoints.
|
|
43
|
+
|
|
44
|
+
- β
Flat-file, code-based routing (no folders required)
|
|
45
|
+
- β
Dynamic segments (`:id`)
|
|
46
|
+
- β
ALL-mode for monolithic routing (`RouterBuilder`)
|
|
47
|
+
- β
Built-in response helpers (`ok`, `created`, etc.)
|
|
48
|
+
- β
Trie-based matcher for fast route lookup
|
|
49
|
+
- β
Fully typed β no magic strings
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## π§ Core Concepts
|
|
54
|
+
|
|
55
|
+
### `defineRoute()`
|
|
56
|
+
|
|
57
|
+
Declare a single route:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
defineRoute(HttpMethod.GET, "/users/:id", ({ params }) => {
|
|
61
|
+
return ok({ userId: params.id });
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `defineRouter()`
|
|
66
|
+
|
|
67
|
+
Group multiple routes under one HTTP method handler:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
export const GET = defineRouter([
|
|
71
|
+
defineRoute(HttpMethod.GET, "/health", () => ok("ok"))
|
|
72
|
+
]);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> π§ `defineRouter()` supports all HTTP methods β but Astro only executes the method you export (`GET`, `POST`, etc.)
|
|
76
|
+
|
|
77
|
+
### `RouterBuilder` (Catch-All)
|
|
78
|
+
|
|
79
|
+
Designed specifically for `ALL`:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const builder = new RouterBuilder();
|
|
83
|
+
builder.register([
|
|
84
|
+
defineRoute(HttpMethod.GET, "/info", () => ok({ app: "astro-routify" }))
|
|
85
|
+
]);
|
|
86
|
+
export const ALL = builder.build();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## π Response Helpers
|
|
92
|
+
|
|
93
|
+
Avoid boilerplate `new Response(JSON.stringify(...))`:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
ok(data); // 200 OK
|
|
97
|
+
created(data); // 201 Created
|
|
98
|
+
noContent(); // 204
|
|
99
|
+
notFound("Missing"); // 404
|
|
100
|
+
internalError(err); // 500
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## π Param Matching
|
|
106
|
+
|
|
107
|
+
Any route param like `:id` is extracted into `ctx.params`:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
defineRoute(HttpMethod.GET, "/items/:id", ({ params }) => {
|
|
111
|
+
return ok({ itemId: params.id });
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## π€― Why Use astro-routify?
|
|
118
|
+
|
|
119
|
+
### β Without it
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// src/pages/api/[...slug].ts
|
|
123
|
+
export const GET = async ({request}) => {
|
|
124
|
+
const url = new URL(request.url);
|
|
125
|
+
const path = url.pathname;
|
|
126
|
+
|
|
127
|
+
if (path.startsWith('/api/users/')) {
|
|
128
|
+
// Try to extract ID
|
|
129
|
+
const id = path.split('/').pop();
|
|
130
|
+
return new Response(JSON.stringify({id}), {
|
|
131
|
+
status: 200,
|
|
132
|
+
headers: {'Content-Type': 'application/json'},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (path === '/api/users') {
|
|
137
|
+
return new Response(JSON.stringify([{id: 1}, {id: 2}]), {
|
|
138
|
+
status: 200,
|
|
139
|
+
headers: {'Content-Type': 'application/json'},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if (path === '/api/ping') {
|
|
145
|
+
return new Response(JSON.stringify({pong: true}), {
|
|
146
|
+
status: 200,
|
|
147
|
+
headers: {'Content-Type': 'application/json'}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
return new Response('Not Found', {status: 404});
|
|
153
|
+
};
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### π And then there's folder hell...
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
src/
|
|
160
|
+
ββ pages/
|
|
161
|
+
β ββ api/
|
|
162
|
+
β β ββ users/
|
|
163
|
+
β β β ββ index.ts // GET all users
|
|
164
|
+
β β β ββ [id]/
|
|
165
|
+
β β β β ββ index.ts // GET / POST / DELETE for a user
|
|
166
|
+
β β ββ ping.ts
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### β
With `astro-routify`
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
//src/pages/api/[...slug].ts
|
|
173
|
+
export const ALL = defineRouter([
|
|
174
|
+
defineRoute(HttpMethod.GET, "/ping", () => ok({ pong: true })),
|
|
175
|
+
defineRoute(HttpMethod.GET, "/users/:id", ({ params }) => ok({ id: params.id }))
|
|
176
|
+
]);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## π Performance
|
|
182
|
+
|
|
183
|
+
`astro-routify` uses a Trie structure for fast route and method matching.
|
|
184
|
+
Itβs optimized for real-world route hierarchies, and avoids nested `if` chains.
|
|
185
|
+
|
|
186
|
+
## π§ͺ Benchmarks
|
|
187
|
+
|
|
188
|
+
Realistic and synthetic benchmarks using `vitest bench`.
|
|
189
|
+
|
|
190
|
+
### π₯ Benchmark Machine
|
|
191
|
+
|
|
192
|
+
Tests ran on a mid-range development setup:
|
|
193
|
+
|
|
194
|
+
- **CPU**: Intel Core i5-7600K @ 3.80GHz (4 cores)
|
|
195
|
+
- **RAM**: 16 GB DDR4
|
|
196
|
+
- **GPU**: NVIDIA GeForce GTX 1080 (8 GB)
|
|
197
|
+
- **OS**: Windows 10 Pro 64-bit
|
|
198
|
+
- **Node.js**: v20.x
|
|
199
|
+
- **Benchmark Tool**: [Vitest Bench](https://vitest.dev/guide/benchmarks.html)
|
|
200
|
+
|
|
201
|
+
Results may vary slightly on different hardware.
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
### π¬ Realistic route shapes (5000 registered routes):
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
β RouteTrie performance - realistic route shapes
|
|
208
|
+
|
|
209
|
+
Β· Static route lookup (5000) 1,819,681 req/sec
|
|
210
|
+
Β· Param route: /users/:userId 1,708,264 req/sec
|
|
211
|
+
Β· Nested param route: /users/:id/orders/:oid 1,326,324 req/sec
|
|
212
|
+
Β· Blog route: /blog/:year/:month/:slug 1,220,882 req/sec
|
|
213
|
+
Β· Nonexistent path 1,621,934 req/sec
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### π Route scaling test:
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
β RouteTrie performance
|
|
220
|
+
|
|
221
|
+
Β· Lookup in SMALL (100 routes) 1,948,385 req/sec
|
|
222
|
+
Β· Lookup in MEDIUM (1000 routes) 1,877,248 req/sec
|
|
223
|
+
Β· Lookup in LARGE (10000 routes) 1,908,279 req/sec
|
|
224
|
+
Β· Lookup non-existent route in LARGE 1,962,051 req/sec
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
> β‘ Performance stays consistently fast even with 10k+ routes
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## π Designed to Scale
|
|
231
|
+
|
|
232
|
+
While focused on simplicity and speed today, `astro-routify` is designed to evolve β enabling more advanced routing patterns in the future.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## π License
|
|
237
|
+
|
|
238
|
+
MIT β Β© 2025 [Alex Mora](https://github.com/oamm)
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## β Support
|
|
243
|
+
|
|
244
|
+
If this project helps you, consider [buying me a coffee](https://coff.ee/alex_mora). Every drop keeps the code flowing!
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare enum HttpMethod {
|
|
2
|
+
GET = "GET",
|
|
3
|
+
POST = "POST",
|
|
4
|
+
PUT = "PUT",
|
|
5
|
+
DELETE = "DELETE",
|
|
6
|
+
PATCH = "PATCH",
|
|
7
|
+
OPTIONS = "OPTIONS"
|
|
8
|
+
}
|
|
9
|
+
export declare const ALLOWED_HTTP_METHODS: Set<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Normalises an incoming method string and throws if it is unsupported.
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizeMethod(method: string): HttpMethod;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export var HttpMethod;
|
|
2
|
+
(function (HttpMethod) {
|
|
3
|
+
HttpMethod["GET"] = "GET";
|
|
4
|
+
HttpMethod["POST"] = "POST";
|
|
5
|
+
HttpMethod["PUT"] = "PUT";
|
|
6
|
+
HttpMethod["DELETE"] = "DELETE";
|
|
7
|
+
HttpMethod["PATCH"] = "PATCH";
|
|
8
|
+
HttpMethod["OPTIONS"] = "OPTIONS";
|
|
9
|
+
})(HttpMethod || (HttpMethod = {}));
|
|
10
|
+
export const ALLOWED_HTTP_METHODS = new Set(Object.values(HttpMethod));
|
|
11
|
+
/**
|
|
12
|
+
* Normalises an incoming method string and throws if it is unsupported.
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeMethod(method) {
|
|
15
|
+
const upper = method.toUpperCase();
|
|
16
|
+
if (!ALLOWED_HTTP_METHODS.has(upper)) {
|
|
17
|
+
throw new Error(`Unsupported HTTP method: ${method}`);
|
|
18
|
+
}
|
|
19
|
+
return upper;
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { HttpMethod } from './HttpMethod';
|
|
2
|
+
import type { Handler } from './defineHandler';
|
|
3
|
+
interface RouteMatch {
|
|
4
|
+
handler: Handler | null;
|
|
5
|
+
allowed?: HttpMethod[];
|
|
6
|
+
params: Record<string, string | undefined>;
|
|
7
|
+
}
|
|
8
|
+
export declare class RouteTrie {
|
|
9
|
+
private readonly root;
|
|
10
|
+
insert(path: string, method: HttpMethod, handler: Handler): void;
|
|
11
|
+
find(path: string, method: HttpMethod): RouteMatch;
|
|
12
|
+
private segmentize;
|
|
13
|
+
}
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export class RouteTrie {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.root = { children: new Map(), handlers: new Map() };
|
|
4
|
+
}
|
|
5
|
+
insert(path, method, handler) {
|
|
6
|
+
const segments = this.segmentize(path);
|
|
7
|
+
let node = this.root;
|
|
8
|
+
for (const segment of segments) {
|
|
9
|
+
if (segment.startsWith(':')) {
|
|
10
|
+
if (!node.paramChild) {
|
|
11
|
+
node.paramChild = {
|
|
12
|
+
children: new Map(),
|
|
13
|
+
handlers: new Map(),
|
|
14
|
+
paramName: segment.slice(1),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
node = node.paramChild;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
if (!node.children.has(segment)) {
|
|
21
|
+
node.children.set(segment, { children: new Map(), handlers: new Map() });
|
|
22
|
+
}
|
|
23
|
+
node = node.children.get(segment);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
node.handlers.set(method, handler);
|
|
27
|
+
}
|
|
28
|
+
find(path, method) {
|
|
29
|
+
const segments = this.segmentize(path);
|
|
30
|
+
let node = this.root;
|
|
31
|
+
const params = {};
|
|
32
|
+
for (const segment of segments) {
|
|
33
|
+
if (node?.children.has(segment)) {
|
|
34
|
+
node = node.children.get(segment);
|
|
35
|
+
}
|
|
36
|
+
else if (node?.paramChild) {
|
|
37
|
+
params[node.paramChild.paramName] = segment;
|
|
38
|
+
node = node.paramChild;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
return { handler: null, params };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!node)
|
|
45
|
+
return { handler: null, params };
|
|
46
|
+
const handler = node.handlers.get(method) ?? null;
|
|
47
|
+
return {
|
|
48
|
+
handler,
|
|
49
|
+
allowed: handler ? undefined : [...node.handlers.keys()],
|
|
50
|
+
params,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
segmentize(path) {
|
|
54
|
+
return path.replace(/(^\/|\/$)/g, '').split('/');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Route } from './defineRoute';
|
|
2
|
+
import { type RouterOptions } from './defineRouter';
|
|
3
|
+
export declare class RouterBuilder {
|
|
4
|
+
private routes;
|
|
5
|
+
register(route: Route): void;
|
|
6
|
+
register(routes: Route[]): void;
|
|
7
|
+
build(options?: RouterOptions): import("astro").APIRoute;
|
|
8
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineRouter } from './defineRouter';
|
|
2
|
+
export class RouterBuilder {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.routes = [];
|
|
5
|
+
}
|
|
6
|
+
register(routeOrRoutes) {
|
|
7
|
+
if (Array.isArray(routeOrRoutes)) {
|
|
8
|
+
this.routes.push(...routeOrRoutes);
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
this.routes.push(routeOrRoutes);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
build(options) {
|
|
15
|
+
return defineRouter(this.routes, options);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { APIContext, APIRoute } from 'astro';
|
|
2
|
+
import { type ResultResponse } from './responseHelpers';
|
|
3
|
+
export type Handler = (ctx: APIContext) => Promise<ResultResponse | Response> | ResultResponse | Response;
|
|
4
|
+
export declare function defineHandler(handler: Handler): APIRoute;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { internalError, toAstroResponse } from './responseHelpers';
|
|
2
|
+
function logRequest(ctx) {
|
|
3
|
+
const { method, url } = ctx.request;
|
|
4
|
+
console.info(`[astro-routify] β ${method} ${new URL(url).pathname}`);
|
|
5
|
+
}
|
|
6
|
+
function logResponse(status, start) {
|
|
7
|
+
console.info(`[astro-routify] β responded ${status} in ${Math.round(performance.now() - start)}ms`);
|
|
8
|
+
}
|
|
9
|
+
export function defineHandler(handler) {
|
|
10
|
+
return async (ctx) => {
|
|
11
|
+
const start = performance.now();
|
|
12
|
+
try {
|
|
13
|
+
logRequest(ctx);
|
|
14
|
+
const result = await handler(ctx);
|
|
15
|
+
if (result instanceof Response) {
|
|
16
|
+
logResponse(result.status, start);
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
const finalResponse = toAstroResponse(result);
|
|
20
|
+
logResponse(finalResponse.status, start);
|
|
21
|
+
return finalResponse;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.error('[astro-routify] handler error', err);
|
|
25
|
+
const res = toAstroResponse(internalError(err));
|
|
26
|
+
logResponse(res.status, start);
|
|
27
|
+
return res;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { HttpMethod } from './HttpMethod';
|
|
2
|
+
import type { Handler } from './defineHandler';
|
|
3
|
+
export interface Route {
|
|
4
|
+
method: HttpMethod;
|
|
5
|
+
path: string;
|
|
6
|
+
handler: Handler;
|
|
7
|
+
}
|
|
8
|
+
export declare function defineRoute(route: Route): Route;
|
|
9
|
+
export declare function defineRoute(method: HttpMethod, path: string, handler: Handler): Route;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ALLOWED_HTTP_METHODS } from './HttpMethod';
|
|
2
|
+
export function defineRoute(methodOrRoute, maybePath, maybeHandler) {
|
|
3
|
+
if (typeof methodOrRoute === 'object') {
|
|
4
|
+
validateRoute(methodOrRoute);
|
|
5
|
+
return methodOrRoute;
|
|
6
|
+
}
|
|
7
|
+
const route = { method: methodOrRoute, path: maybePath, handler: maybeHandler };
|
|
8
|
+
validateRoute(route);
|
|
9
|
+
return route;
|
|
10
|
+
}
|
|
11
|
+
function validateRoute({ method, path }) {
|
|
12
|
+
if (!path.startsWith('/')) {
|
|
13
|
+
throw new Error(`Route path must start with '/': ${path}`);
|
|
14
|
+
}
|
|
15
|
+
if (!ALLOWED_HTTP_METHODS.has(method)) {
|
|
16
|
+
throw new Error(`Unsupported HTTP method in route: ${method}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { notFound } from './responseHelpers';
|
|
3
|
+
import type { Route } from './defineRoute';
|
|
4
|
+
export interface RouterOptions {
|
|
5
|
+
/** Custom 404 handler */
|
|
6
|
+
onNotFound?: () => ReturnType<typeof notFound>;
|
|
7
|
+
}
|
|
8
|
+
export declare function defineRouter(routes: Route[], options?: RouterOptions): APIRoute;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineHandler } from './defineHandler';
|
|
2
|
+
import { methodNotAllowed, notFound, toAstroResponse } from './responseHelpers';
|
|
3
|
+
import { RouteTrie } from './RouteTrie';
|
|
4
|
+
import { normalizeMethod } from './HttpMethod';
|
|
5
|
+
export function defineRouter(routes, options = {}) {
|
|
6
|
+
const trie = new RouteTrie();
|
|
7
|
+
for (const route of routes) {
|
|
8
|
+
trie.insert(route.path, route.method, route.handler);
|
|
9
|
+
}
|
|
10
|
+
// Wrap every user handler through defineHandler for uniform logging & error handling
|
|
11
|
+
return async (ctx) => {
|
|
12
|
+
const path = new URL(ctx.request.url).pathname.replace(/^\/api/, '');
|
|
13
|
+
const method = normalizeMethod(ctx.request.method);
|
|
14
|
+
const { handler, allowed, params } = trie.find(path, method);
|
|
15
|
+
if (!handler) {
|
|
16
|
+
// No handler for this method β but maybe other methods exist β 405
|
|
17
|
+
if (allowed && allowed.length) {
|
|
18
|
+
return toAstroResponse(methodNotAllowed('Method Not Allowed', {
|
|
19
|
+
Allow: allowed.join(', '),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
const notFoundHandler = options.onNotFound ? options.onNotFound() : notFound('Not Found');
|
|
23
|
+
return toAstroResponse(notFoundHandler);
|
|
24
|
+
}
|
|
25
|
+
return defineHandler(handler)({ ...ctx, params: { ...ctx.params, ...params } });
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { HeadersInit } from "undici";
|
|
2
|
+
export interface ResultResponse<T = unknown> {
|
|
3
|
+
body?: T;
|
|
4
|
+
status: number;
|
|
5
|
+
headers?: HeadersInit;
|
|
6
|
+
}
|
|
7
|
+
export declare const ok: <T>(body: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
8
|
+
export declare const created: <T>(body: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
9
|
+
export declare const noContent: (headers?: HeadersInit) => ResultResponse<undefined>;
|
|
10
|
+
export declare const notModified: (headers?: HeadersInit) => ResultResponse<undefined>;
|
|
11
|
+
export declare const badRequest: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
12
|
+
export declare const unauthorized: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
13
|
+
export declare const forbidden: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
14
|
+
export declare const notFound: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
15
|
+
export declare const methodNotAllowed: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
16
|
+
export declare const internalError: (err: unknown, headers?: HeadersInit) => ResultResponse<string>;
|
|
17
|
+
export declare function toAstroResponse(result: ResultResponse | undefined): Response;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function createResponse(status, body, headers) {
|
|
2
|
+
return { status, body, headers };
|
|
3
|
+
}
|
|
4
|
+
export const ok = (body, headers) => createResponse(200, body, headers);
|
|
5
|
+
export const created = (body, headers) => createResponse(201, body, headers);
|
|
6
|
+
export const noContent = (headers) => createResponse(204, undefined, headers);
|
|
7
|
+
export const notModified = (headers) => createResponse(304, undefined, headers);
|
|
8
|
+
export const badRequest = (body = 'Bad Request', headers) => createResponse(400, body, headers);
|
|
9
|
+
export const unauthorized = (body = 'Unauthorized', headers) => createResponse(401, body, headers);
|
|
10
|
+
export const forbidden = (body = 'Forbidden', headers) => createResponse(403, body, headers);
|
|
11
|
+
export const notFound = (body = 'Not Found', headers) => createResponse(404, body, headers);
|
|
12
|
+
export const methodNotAllowed = (body = 'Method Not Allowed', headers) => createResponse(405, body, headers);
|
|
13
|
+
export const internalError = (err, headers) => createResponse(500, err instanceof Error ? err.message : String(err), headers);
|
|
14
|
+
export function toAstroResponse(result) {
|
|
15
|
+
if (!result)
|
|
16
|
+
return new Response(null, { status: 204 });
|
|
17
|
+
const { status, body, headers } = result;
|
|
18
|
+
if (body === undefined || body === null) {
|
|
19
|
+
return new Response(null, { status, headers });
|
|
20
|
+
}
|
|
21
|
+
const isObject = typeof body === 'object' || Array.isArray(body);
|
|
22
|
+
const finalHeaders = {
|
|
23
|
+
...(headers ?? {}),
|
|
24
|
+
...(isObject ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
|
|
25
|
+
};
|
|
26
|
+
return new Response(isObject ? JSON.stringify(body) : body, {
|
|
27
|
+
status,
|
|
28
|
+
headers: finalHeaders,
|
|
29
|
+
});
|
|
30
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as astro from 'astro';
|
|
2
|
+
import { APIContext, APIRoute } from 'astro';
|
|
3
|
+
import { HeadersInit } from 'undici';
|
|
4
|
+
|
|
5
|
+
declare enum HttpMethod {
|
|
6
|
+
GET = "GET",
|
|
7
|
+
POST = "POST",
|
|
8
|
+
PUT = "PUT",
|
|
9
|
+
DELETE = "DELETE",
|
|
10
|
+
PATCH = "PATCH",
|
|
11
|
+
OPTIONS = "OPTIONS"
|
|
12
|
+
}
|
|
13
|
+
declare const ALLOWED_HTTP_METHODS: Set<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Normalises an incoming method string and throws if it is unsupported.
|
|
16
|
+
*/
|
|
17
|
+
declare function normalizeMethod(method: string): HttpMethod;
|
|
18
|
+
|
|
19
|
+
interface ResultResponse<T = unknown> {
|
|
20
|
+
body?: T;
|
|
21
|
+
status: number;
|
|
22
|
+
headers?: HeadersInit;
|
|
23
|
+
}
|
|
24
|
+
declare const ok: <T>(body: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
25
|
+
declare const created: <T>(body: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
26
|
+
declare const noContent: (headers?: HeadersInit) => ResultResponse<undefined>;
|
|
27
|
+
declare const notModified: (headers?: HeadersInit) => ResultResponse<undefined>;
|
|
28
|
+
declare const badRequest: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
29
|
+
declare const unauthorized: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
30
|
+
declare const forbidden: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
31
|
+
declare const notFound: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
32
|
+
declare const methodNotAllowed: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
33
|
+
declare const internalError: (err: unknown, headers?: HeadersInit) => ResultResponse<string>;
|
|
34
|
+
declare function toAstroResponse(result: ResultResponse | undefined): Response;
|
|
35
|
+
|
|
36
|
+
type Handler = (ctx: APIContext) => Promise<ResultResponse | Response> | ResultResponse | Response;
|
|
37
|
+
declare function defineHandler(handler: Handler): APIRoute;
|
|
38
|
+
|
|
39
|
+
interface Route {
|
|
40
|
+
method: HttpMethod;
|
|
41
|
+
path: string;
|
|
42
|
+
handler: Handler;
|
|
43
|
+
}
|
|
44
|
+
declare function defineRoute(route: Route): Route;
|
|
45
|
+
declare function defineRoute(method: HttpMethod, path: string, handler: Handler): Route;
|
|
46
|
+
|
|
47
|
+
interface RouterOptions {
|
|
48
|
+
/** Custom 404 handler */
|
|
49
|
+
onNotFound?: () => ReturnType<typeof notFound>;
|
|
50
|
+
}
|
|
51
|
+
declare function defineRouter(routes: Route[], options?: RouterOptions): APIRoute;
|
|
52
|
+
|
|
53
|
+
interface RouteMatch {
|
|
54
|
+
handler: Handler | null;
|
|
55
|
+
allowed?: HttpMethod[];
|
|
56
|
+
params: Record<string, string | undefined>;
|
|
57
|
+
}
|
|
58
|
+
declare class RouteTrie {
|
|
59
|
+
private readonly root;
|
|
60
|
+
insert(path: string, method: HttpMethod, handler: Handler): void;
|
|
61
|
+
find(path: string, method: HttpMethod): RouteMatch;
|
|
62
|
+
private segmentize;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
declare class RouterBuilder {
|
|
66
|
+
private routes;
|
|
67
|
+
register(route: Route): void;
|
|
68
|
+
register(routes: Route[]): void;
|
|
69
|
+
build(options?: RouterOptions): astro.APIRoute;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { ALLOWED_HTTP_METHODS, HttpMethod, RouteTrie, RouterBuilder, badRequest, created, defineHandler, defineRoute, defineRouter, forbidden, internalError, methodNotAllowed, noContent, normalizeMethod, notFound, notModified, ok, toAstroResponse, unauthorized };
|
|
73
|
+
export type { Handler, ResultResponse, Route, RouterOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './core/defineRoute';
|
|
2
|
+
export * from './core/defineRouter';
|
|
3
|
+
export * from './core/defineHandler';
|
|
4
|
+
export * from './core/RouteTrie';
|
|
5
|
+
export * from './core/HttpMethod';
|
|
6
|
+
export * from './core/responseHelpers';
|
|
7
|
+
export * from './core/RouterBuilder';
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "astro-routify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A high-performance API router for Astro using a Trie-based matcher.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"astro",
|
|
19
|
+
"router",
|
|
20
|
+
"api-router",
|
|
21
|
+
"typescript",
|
|
22
|
+
"routing",
|
|
23
|
+
"astrojs",
|
|
24
|
+
"esm",
|
|
25
|
+
"trie"
|
|
26
|
+
],
|
|
27
|
+
"author": "Alex Mora",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"astro": "^4.0.0 || ^5.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"rimraf": "^6.0.1",
|
|
34
|
+
"rollup": "^4.45.0",
|
|
35
|
+
"rollup-plugin-dts": "^6.2.1",
|
|
36
|
+
"typescript": "^5.3.3",
|
|
37
|
+
"undici": "^7.11.0",
|
|
38
|
+
"vitest": "^3.2.4"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"registry": "https://registry.npmjs.org/"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git@github.com:oamm/astro-routify.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/oamm/astro-routify/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/oamm/astro-routify#readme",
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsc --project tsconfig.json",
|
|
56
|
+
"types:bundle": "rollup -c rollup.config.mjs",
|
|
57
|
+
"clean": "rimraf dist",
|
|
58
|
+
"dev": "tsc --watch --project tsconfig.json",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"test:watch": "vitest",
|
|
61
|
+
"bench": "vitest bench run bench/"
|
|
62
|
+
}
|
|
63
|
+
}
|