astro-routify 1.1.0 → 1.2.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
@@ -21,50 +21,52 @@ npm install astro-routify
21
21
  ```ts
22
22
  // src/pages/api/index.ts
23
23
  import {
24
- defineRoute,
25
- defineRouter,
26
- defineGroup,
27
- HttpMethod,
28
- ok,
24
+ defineRoute,
25
+ defineRouter,
26
+ defineGroup,
27
+ HttpMethod,
28
+ ok,
29
29
  } from 'astro-routify';
30
30
 
31
31
  const userGroup = defineGroup('/users', (group) => {
32
- group.addGet('/:id', ({ params }) => ok({ id: params.id }));
32
+ group.addGet('/:id', ({params}) => ok({id: params.id}));
33
33
  });
34
34
 
35
35
  export const GET = defineRouter([
36
- defineRoute(HttpMethod.GET, '/ping', () => ok('pong')),
37
- ...userGroup.getRoutes(),
36
+ defineRoute(HttpMethod.GET, '/ping', () => ok('pong')),
37
+ ...userGroup.getRoutes(),
38
38
  ]);
39
39
  ```
40
40
 
41
41
  Or to handle everything in a single place:
42
42
 
43
43
  ```ts
44
- import { RouterBuilder, ok } from 'astro-routify';
44
+ import {RouterBuilder, ok} from 'astro-routify';
45
45
 
46
46
  const builder = new RouterBuilder();
47
47
 
48
48
  builder
49
- .addGet('/ping', () => ok('pong'))
50
- .addPost('/submit', async ({ request }) => {
51
- const body = await request.json();
52
- return ok({ received: body });
53
- });
49
+ .addGet('/ping', () => ok('pong'))
50
+ .addPost('/submit', async ({request}) => {
51
+ const body = await request.json();
52
+ return ok({received: body});
53
+ });
54
54
 
55
55
  export const ALL = builder.build(); // catch-all
56
56
  ```
57
57
 
58
58
  ## 💡 Full Example
59
59
 
60
- You can find an implementation example in the [astro-routify-example](https://github.com/oamm/astro-routify-example) repository.
60
+ You can find an implementation example in the [astro-routify-example](https://github.com/oamm/astro-routify-example)
61
+ repository.
61
62
  It showcases a minimal Astro app with API endpoints configured under:
62
63
 
63
64
  ```text
64
65
  /src/pages/api/[...path].ts
65
66
  ```
66
67
 
67
- This setup demonstrates how to route requests dynamically using astro-routify, while still leveraging Astro's native endpoint system.
68
+ This setup demonstrates how to route requests dynamically using astro-routify, while still leveraging Astro's native
69
+ endpoint system.
68
70
 
69
71
  ---
70
72
 
@@ -78,6 +80,10 @@ This setup demonstrates how to route requests dynamically using astro-routify, w
78
80
  - ✅ Built-in response helpers (`ok`, `created`, etc.)
79
81
  - ✅ Trie-based matcher for fast route lookup
80
82
  - ✅ 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
81
87
 
82
88
  > 🔄 See [CHANGELOG.md](./CHANGELOG.md) for recent updates and improvements.
83
89
 
@@ -91,7 +97,7 @@ Declare a single route:
91
97
 
92
98
  ```ts
93
99
  defineRoute(HttpMethod.GET, "/users/:id", ({params}) => {
94
- return ok({userId: params.id});
100
+ return ok({userId: params.id});
95
101
  });
96
102
  ```
97
103
 
@@ -101,7 +107,7 @@ Group multiple routes under one HTTP method handler:
101
107
 
102
108
  ```ts
103
109
  export const GET = defineRouter([
104
- defineRoute(HttpMethod.GET, "/health", () => ok("ok"))
110
+ defineRoute(HttpMethod.GET, "/health", () => ok("ok"))
105
111
  ]);
106
112
  ```
107
113
 
@@ -109,17 +115,18 @@ export const GET = defineRouter([
109
115
 
110
116
  ### `RouterBuilder` (Catch-All & Fluent Builder)
111
117
 
112
- Use `RouterBuilder` when you want to build routes dynamically, catch all HTTP methods via `ALL`, or organize routes more fluently with helpers.
118
+ Use `RouterBuilder` when you want to build routes dynamically, catch all HTTP methods via `ALL`, or organize routes more
119
+ fluently with helpers.
113
120
 
114
121
  ```ts
115
122
  const builder = new RouterBuilder();
116
123
 
117
124
  builder
118
- .addGet("/ping", () => ok("pong"))
119
- .addPost("/submit", async ({request}) => {
120
- const body = await request.json();
121
- return ok({received: body});
122
- });
125
+ .addGet("/ping", () => ok("pong"))
126
+ .addPost("/submit", async ({request}) => {
127
+ const body = await request.json();
128
+ return ok({received: body});
129
+ });
123
130
 
124
131
  export const ALL = builder.build();
125
132
  ```
@@ -128,12 +135,13 @@ You can also group routes:
128
135
 
129
136
  ```ts
130
137
  const users = defineGroup("/users")
131
- .addGet("/:id", ({params}) => ok({id: params.id}));
138
+ .addGet("/:id", ({params}) => ok({id: params.id}));
132
139
 
133
140
  builder.addGroup(users);
134
141
  ```
135
142
 
136
- > 🔁 While `.register()` is still available, it's **deprecated** in favor of `.addGroup()` and `.addRoute()` for better structure and reusability.
143
+ > 🔁 While `.register()` is still available, it's **deprecated** in favor of `.addGroup()` and `.addRoute()` for better
144
+ > structure and reusability.
137
145
 
138
146
  ---
139
147
 
@@ -151,12 +159,56 @@ notFound("Missing"); // 404
151
159
  internalError(err); // 500
152
160
  ```
153
161
 
154
- ### File downloads
162
+ ### 📄 File downloads
155
163
 
156
164
  ```ts
157
165
  fileResponse(content, "application/pdf", "report.pdf"); // sets Content-Type and Content-Disposition
158
166
  ```
159
167
 
168
+ ### 🔄 Streaming responses
169
+
170
+ #### Raw stream (e.g., Server-Sent Events)
171
+
172
+ ```ts
173
+ stream('/clock', async ({response}) => {
174
+ const timer = setInterval(() => {
175
+ response.write(`data: ${new Date().toISOString()}\n\n`);
176
+ }, 1000);
177
+
178
+ setTimeout(() => {
179
+ clearInterval(timer);
180
+ response.close();
181
+ }, 5000);
182
+ });
183
+
184
+ ```
185
+
186
+ #### JSON NDStream (newline-delimited)
187
+
188
+ ```ts
189
+
190
+ streamJsonND('/updates', async ({response}) => {
191
+ response.send({step: 1});
192
+ await delay(500);
193
+ response.send({step: 2});
194
+ response.close();
195
+ });
196
+
197
+ ```
198
+
199
+ #### JSON Array stream
200
+
201
+ ```ts
202
+
203
+ streamJsonArray('/items', async ({response}) => {
204
+ for (let i = 0; i < 3; i++) {
205
+ response.send({id: i});
206
+ }
207
+ response.close();
208
+ });
209
+
210
+ ```
211
+
160
212
  ---
161
213
 
162
214
  ## 🔍 Param Matching
@@ -166,7 +218,7 @@ Any route param like `:id` is extracted into `ctx.params`:
166
218
  ```ts
167
219
  const builder = new RouterBuilder();
168
220
 
169
- builder.addGet("/users/:id", ({params}) => ok({userId: params.id}));
221
+ builder.addGet("/users/:id", ({params}) => ok({userId: params.id}));
170
222
 
171
223
 
172
224
  //OR
@@ -186,33 +238,33 @@ defineRoute(HttpMethod.GET, "/items/:id", ({params}) => {
186
238
  ```ts
187
239
  // src/pages/api/[...slug].ts
188
240
  export const GET = async ({request}) => {
189
- const url = new URL(request.url);
190
- const path = url.pathname;
191
-
192
- if (path.startsWith('/api/users/')) {
193
- // Try to extract ID
194
- const id = path.split('/').pop();
195
- return new Response(JSON.stringify({id}), {
196
- status: 200,
197
- headers: {'Content-Type': 'application/json'},
198
- });
199
- }
200
-
201
- if (path === '/api/users') {
202
- return new Response(JSON.stringify([{id: 1}, {id: 2}]), {
203
- status: 200,
204
- headers: {'Content-Type': 'application/json'},
205
- });
206
- }
207
-
208
- if (path === '/api/ping') {
209
- return new Response(JSON.stringify({pong: true}), {
210
- status: 200,
211
- headers: {'Content-Type': 'application/json'}
212
- });
213
- }
214
-
215
- return new Response('Not Found', {status: 404});
241
+ const url = new URL(request.url);
242
+ const path = url.pathname;
243
+
244
+ if (path.startsWith('/api/users/')) {
245
+ // Try to extract ID
246
+ const id = path.split('/').pop();
247
+ return new Response(JSON.stringify({id}), {
248
+ status: 200,
249
+ headers: {'Content-Type': 'application/json'},
250
+ });
251
+ }
252
+
253
+ if (path === '/api/users') {
254
+ return new Response(JSON.stringify([{id: 1}, {id: 2}]), {
255
+ status: 200,
256
+ headers: {'Content-Type': 'application/json'},
257
+ });
258
+ }
259
+
260
+ if (path === '/api/ping') {
261
+ return new Response(JSON.stringify({pong: true}), {
262
+ status: 200,
263
+ headers: {'Content-Type': 'application/json'}
264
+ });
265
+ }
266
+
267
+ return new Response('Not Found', {status: 404});
216
268
  };
217
269
  ```
218
270
 
@@ -236,13 +288,13 @@ src/
236
288
 
237
289
  const builder = new RouterBuilder();
238
290
  builder.addGet("/ping", () => ok({pong: true}));
239
- builder.addGet("/users/:id", ({params}) => ok({userId: params.id}));
291
+ builder.addGet("/users/:id", ({params}) => ok({userId: params.id}));
240
292
 
241
293
  // OR
242
294
 
243
295
  export const ALL = defineRouter([
244
- defineRoute(HttpMethod.GET, "/ping", () => ok({pong: true})),
245
- defineRoute(HttpMethod.GET, "/users/:id", ({params}) => ok({id: params.id}))
296
+ defineRoute(HttpMethod.GET, "/ping", () => ok({pong: true})),
297
+ defineRoute(HttpMethod.GET, "/users/:id", ({params}) => ok({id: params.id}))
246
298
  ]);
247
299
 
248
300
  ```
@@ -1,5 +1,27 @@
1
1
  import type { APIContext, APIRoute } from 'astro';
2
2
  import { type ResultResponse } from './responseHelpers';
3
+ /**
4
+ * A flexible route handler that can return:
5
+ * - a native `Response` object,
6
+ * - a structured `ResultResponse` object,
7
+ * - or a file stream (Blob, ArrayBuffer, or ReadableStream).
8
+ */
3
9
  export type Handler = (ctx: APIContext) => Promise<ResultResponse | Response> | ResultResponse | Response;
10
+ /**
11
+ * Wraps a `Handler` function into an `APIRoute` that:
12
+ * - logs requests and responses,
13
+ * - supports all valid `ResultResponse` return formats,
14
+ * - auto-converts structured responses into Astro `Response`s,
15
+ * - handles errors with standardized 500 output.
16
+ *
17
+ * @param handler - A handler function returning a `Response` or `ResultResponse`
18
+ * @returns An Astro-compatible `APIRoute` function
19
+ */
4
20
  export declare function defineHandler(handler: Handler): APIRoute;
21
+ /**
22
+ * Type guard to detect ReadableStreams, used for streamed/binary responses.
23
+ *
24
+ * @param value - Any value to test
25
+ * @returns True if it looks like a ReadableStream
26
+ */
5
27
  export declare function isReadableStream(value: unknown): value is ReadableStream<Uint8Array>;
@@ -1,22 +1,39 @@
1
1
  import { internalError, toAstroResponse } from './responseHelpers';
2
+ /**
3
+ * Logs the incoming request method and path to the console.
4
+ */
2
5
  function logRequest(ctx) {
3
6
  const { method, url } = ctx.request;
4
7
  console.info(`[astro-routify] → ${method} ${new URL(url).pathname}`);
5
8
  }
9
+ /**
10
+ * Logs the response status and time taken.
11
+ */
6
12
  function logResponse(status, start) {
7
13
  console.info(`[astro-routify] ← responded ${status} in ${Math.round(performance.now() - start)}ms`);
8
14
  }
15
+ /**
16
+ * Wraps a `Handler` function into an `APIRoute` that:
17
+ * - logs requests and responses,
18
+ * - supports all valid `ResultResponse` return formats,
19
+ * - auto-converts structured responses into Astro `Response`s,
20
+ * - handles errors with standardized 500 output.
21
+ *
22
+ * @param handler - A handler function returning a `Response` or `ResultResponse`
23
+ * @returns An Astro-compatible `APIRoute` function
24
+ */
9
25
  export function defineHandler(handler) {
10
26
  return async (ctx) => {
11
27
  const start = performance.now();
12
28
  try {
13
29
  logRequest(ctx);
14
30
  const result = await handler(ctx);
31
+ // Native Response shortcut
15
32
  if (result instanceof Response) {
16
33
  logResponse(result.status, start);
17
34
  return result;
18
35
  }
19
- // If it's a file response (manual BodyInit with appropriate headers)
36
+ // Direct binary or stream body (file download, etc.)
20
37
  if (result?.body instanceof Blob ||
21
38
  result?.body instanceof ArrayBuffer ||
22
39
  isReadableStream(result?.body)) {
@@ -27,6 +44,7 @@ export function defineHandler(handler) {
27
44
  logResponse(res.status, start);
28
45
  return res;
29
46
  }
47
+ // Structured ResultResponse → native Astro Response
30
48
  const finalResponse = toAstroResponse(result);
31
49
  logResponse(finalResponse.status, start);
32
50
  return finalResponse;
@@ -39,6 +57,12 @@ export function defineHandler(handler) {
39
57
  }
40
58
  };
41
59
  }
60
+ /**
61
+ * Type guard to detect ReadableStreams, used for streamed/binary responses.
62
+ *
63
+ * @param value - Any value to test
64
+ * @returns True if it looks like a ReadableStream
65
+ */
42
66
  export function isReadableStream(value) {
43
67
  return (typeof value === 'object' &&
44
68
  value !== null &&
@@ -1,9 +1,41 @@
1
1
  import { HttpMethod } from './HttpMethod';
2
2
  import type { Handler } from './defineHandler';
3
+ /**
4
+ * Represents a single route definition.
5
+ */
3
6
  export interface Route {
7
+ /**
8
+ * HTTP method to match (GET, POST, PUT, etc.).
9
+ */
4
10
  method: HttpMethod;
11
+ /**
12
+ * Path pattern, starting with `/`, supporting static or param segments (e.g., `/users/:id`).
13
+ */
5
14
  path: string;
15
+ /**
16
+ * The function that handles the request when matched.
17
+ */
6
18
  handler: Handler;
7
19
  }
20
+ /**
21
+ * Defines a route using a `Route` object.
22
+ *
23
+ * @example
24
+ * defineRoute({ method: 'GET', path: '/users', handler });
25
+ *
26
+ * @param route - A fully constructed route object
27
+ * @returns The validated Route object
28
+ */
8
29
  export declare function defineRoute(route: Route): Route;
30
+ /**
31
+ * Defines a route by specifying its method, path, and handler explicitly.
32
+ *
33
+ * @example
34
+ * defineRoute('POST', '/login', handler);
35
+ *
36
+ * @param method - HTTP method to match
37
+ * @param path - Route path (must start with `/`)
38
+ * @param handler - Function to handle the matched request
39
+ * @returns The validated Route object
40
+ */
9
41
  export declare function defineRoute(method: HttpMethod, path: string, handler: Handler): Route;
@@ -1,13 +1,26 @@
1
1
  import { ALLOWED_HTTP_METHODS } from './HttpMethod';
2
+ /**
3
+ * Internal route definition logic that supports both overloads.
4
+ */
2
5
  export function defineRoute(methodOrRoute, maybePath, maybeHandler) {
3
6
  if (typeof methodOrRoute === 'object') {
4
7
  validateRoute(methodOrRoute);
5
8
  return methodOrRoute;
6
9
  }
7
- const route = { method: methodOrRoute, path: maybePath, handler: maybeHandler };
10
+ const route = {
11
+ method: methodOrRoute,
12
+ path: maybePath,
13
+ handler: maybeHandler,
14
+ };
8
15
  validateRoute(route);
9
16
  return route;
10
17
  }
18
+ /**
19
+ * Ensures the route is properly formed and uses a valid method + path format.
20
+ *
21
+ * @param route - Route to validate
22
+ * @throws If method is unsupported or path doesn't start with `/`
23
+ */
11
24
  function validateRoute({ method, path }) {
12
25
  if (!path.startsWith('/')) {
13
26
  throw new Error(`Route path must start with '/': ${path}`);
@@ -1,9 +1,40 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { notFound } from './responseHelpers';
3
3
  import type { Route } from './defineRoute';
4
+ /**
5
+ * Optional configuration for the router instance.
6
+ */
4
7
  export interface RouterOptions {
8
+ /**
9
+ * A base path to strip from the incoming request path (default: `/api`).
10
+ * Only routes beneath this prefix will be matched.
11
+ */
5
12
  basePath?: string;
6
- /** Custom 404 handler */
13
+ /**
14
+ * Custom handler to return when no route is matched (404).
15
+ */
7
16
  onNotFound?: () => ReturnType<typeof notFound>;
8
17
  }
18
+ /**
19
+ * Defines a router that dynamically matches registered routes based on method and path.
20
+ *
21
+ * This allows building a clean, centralized API routing system with features like:
22
+ * - Trie-based fast route lookup
23
+ * - Per-method matching with 405 fallback
24
+ * - Parameter extraction (e.g. `/users/:id`)
25
+ * - Customizable basePath and 404 behavior
26
+ *
27
+ * @example
28
+ * defineRouter([
29
+ * defineRoute('GET', '/users', handler),
30
+ * defineRoute('POST', '/login', loginHandler),
31
+ * ], {
32
+ * basePath: '/api',
33
+ * onNotFound: () => notFound('No such route')
34
+ * });
35
+ *
36
+ * @param routes - An array of route definitions (see `defineRoute`)
37
+ * @param options - Optional router config (basePath, custom 404)
38
+ * @returns An Astro-compatible APIRoute handler
39
+ */
9
40
  export declare function defineRouter(routes: Route[], options?: RouterOptions): APIRoute;
@@ -2,6 +2,28 @@ import { defineHandler } from './defineHandler';
2
2
  import { methodNotAllowed, notFound, toAstroResponse } from './responseHelpers';
3
3
  import { RouteTrie } from './RouteTrie';
4
4
  import { normalizeMethod } from './HttpMethod';
5
+ /**
6
+ * Defines a router that dynamically matches registered routes based on method and path.
7
+ *
8
+ * This allows building a clean, centralized API routing system with features like:
9
+ * - Trie-based fast route lookup
10
+ * - Per-method matching with 405 fallback
11
+ * - Parameter extraction (e.g. `/users/:id`)
12
+ * - Customizable basePath and 404 behavior
13
+ *
14
+ * @example
15
+ * defineRouter([
16
+ * defineRoute('GET', '/users', handler),
17
+ * defineRoute('POST', '/login', loginHandler),
18
+ * ], {
19
+ * basePath: '/api',
20
+ * onNotFound: () => notFound('No such route')
21
+ * });
22
+ *
23
+ * @param routes - An array of route definitions (see `defineRoute`)
24
+ * @param options - Optional router config (basePath, custom 404)
25
+ * @returns An Astro-compatible APIRoute handler
26
+ */
5
27
  export function defineRouter(routes, options = {}) {
6
28
  const trie = new RouteTrie();
7
29
  for (const route of routes) {
@@ -18,15 +40,17 @@ export function defineRouter(routes, options = {}) {
18
40
  const method = normalizeMethod(ctx.request.method);
19
41
  const { handler, allowed, params } = trie.find(path, method);
20
42
  if (!handler) {
21
- // No handler for this method – but maybe other methods exist → 405
43
+ // Method exists but not allowed for this route
22
44
  if (allowed && allowed.length) {
23
45
  return toAstroResponse(methodNotAllowed('Method Not Allowed', {
24
46
  Allow: allowed.join(', '),
25
47
  }));
26
48
  }
49
+ // No route matched at all → 404
27
50
  const notFoundHandler = options.onNotFound ? options.onNotFound() : notFound('Not Found');
28
51
  return toAstroResponse(notFoundHandler);
29
52
  }
53
+ // Match found → delegate to handler
30
54
  return defineHandler(handler)({ ...ctx, params: { ...ctx.params, ...params } });
31
55
  };
32
56
  }
@@ -0,0 +1,64 @@
1
+ import type { APIContext } from 'astro';
2
+ import { HttpMethod } from '../HttpMethod';
3
+ import { type Route } from '../defineRoute';
4
+ type JsonValue = any;
5
+ /**
6
+ * A writer interface for streaming JSON data to the response body.
7
+ * Supports both NDJSON and array formats.
8
+ */
9
+ export interface JsonStreamWriter {
10
+ /**
11
+ * Send a JSON-serializable value to the response stream.
12
+ * - In `ndjson` mode: appends a newline after each object.
13
+ * - In `array` mode: adds commas and wraps with brackets.
14
+ * @param value - Any serializable object or array item
15
+ */
16
+ send: (value: JsonValue) => void;
17
+ /**
18
+ * Write raw text or bytes to the stream.
19
+ * Used for low-level control if needed.
20
+ */
21
+ write: (chunk: string | Uint8Array) => void;
22
+ /**
23
+ * Close the stream. In `array` mode, it writes the closing `]`.
24
+ */
25
+ close: () => void;
26
+ /**
27
+ * Override response headers dynamically before the response is sent.
28
+ * Only safe before the first write.
29
+ * @param key - Header name
30
+ * @param value - Header value
31
+ */
32
+ setHeader: (key: string, value: string) => void;
33
+ }
34
+ /**
35
+ * Internal configuration options for `createJsonStreamRoute`.
36
+ */
37
+ interface JsonStreamOptions {
38
+ /**
39
+ * Streaming mode: 'ndjson' or 'array'.
40
+ */
41
+ mode: 'ndjson' | 'array';
42
+ /**
43
+ * HTTP method to define (e.g. GET, POST).
44
+ */
45
+ method: HttpMethod;
46
+ }
47
+ /**
48
+ * Creates a streaming JSON route that supports both NDJSON and JSON array formats.
49
+ *
50
+ * - Sets appropriate `Content-Type` headers
51
+ * - Supports cancellation via `AbortSignal`
52
+ * - Provides a `response` object with `send`, `close`, `write`, `setHeader` methods
53
+ *
54
+ * Use this function inside `streamJsonND()` or `streamJsonArray()` to avoid duplication.
55
+ *
56
+ * @param path - The route path (e.g. `/logs`)
57
+ * @param handler - A function that receives Astro `ctx` and a `JsonStreamWriter`
58
+ * @param options - Streaming options (`mode`, `method`)
59
+ * @returns A streaming-compatible Route
60
+ */
61
+ export declare function createJsonStreamRoute(path: string, handler: (ctx: APIContext & {
62
+ response: JsonStreamWriter;
63
+ }) => void | Promise<void>, options: JsonStreamOptions): Route;
64
+ export {};