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.
@@ -13,6 +13,7 @@ import { Middleware } from "./defineHandler";
13
13
  * .addPost('/', createUser);
14
14
  */
15
15
  export declare class RouteGroup {
16
+ readonly _routifyType = "group";
16
17
  private basePath;
17
18
  private routes;
18
19
  private middlewares;
@@ -20,6 +20,7 @@ export class RouteGroup {
20
20
  * @param basePath - The common prefix for all routes in the group (e.g. "/users")
21
21
  */
22
22
  constructor(basePath) {
23
+ this._routifyType = 'group';
23
24
  this.routes = [];
24
25
  this.middlewares = [];
25
26
  this.basePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
@@ -1,29 +1,37 @@
1
1
  import type { APIContext, APIRoute } from 'astro';
2
- import { type ResultResponse } from './responseHelpers';
2
+ import { type HandlerResult } from './responseHelpers';
3
3
  /**
4
4
  * Enhanced Astro context for Routify.
5
5
  */
6
- export interface RoutifyContext extends APIContext {
6
+ export interface RoutifyContext<State = Record<string, any>> extends APIContext {
7
7
  /**
8
8
  * Parsed query parameters from the URL.
9
+ * Note: If multiple parameters have the same key, only the last one is included.
10
+ * Use `searchParams` for full multi-value support.
9
11
  */
10
- query: Record<string, string>;
12
+ query: Record<string, string | string[]>;
11
13
  /**
12
- * Shared data container for middlewares and handlers (e.g., validation results).
14
+ * Full URLSearchParams object for the incoming request.
13
15
  */
14
- data: Record<string, any>;
16
+ searchParams: URLSearchParams;
17
+ /**
18
+ * Shared state container for middlewares and handlers (e.g., validation results).
19
+ * This is where middlewares can store information for subsequent handlers.
20
+ */
21
+ state: State;
15
22
  }
23
+ /**
24
+ * Convenience type alias for RoutifyContext.
25
+ */
26
+ export type Context<State = Record<string, any>> = RoutifyContext<State>;
16
27
  /**
17
28
  * A middleware function that can modify the context or short-circuit the response.
18
29
  */
19
- export type Middleware = (ctx: RoutifyContext, next: () => Promise<Response>) => Promise<Response> | Response;
30
+ export type Middleware<State = any> = (ctx: RoutifyContext<State>, next: () => Promise<Response>) => Promise<Response> | Response;
20
31
  /**
21
- * A flexible route handler that can return:
22
- * - a native `Response` object,
23
- * - a structured `ResultResponse` object,
24
- * - or a file stream (Blob, ArrayBuffer, or ReadableStream).
32
+ * A flexible route handler that can return various types.
25
33
  */
26
- export type Handler = (ctx: RoutifyContext) => Promise<ResultResponse | Response> | ResultResponse | Response;
34
+ export type Handler<State = any> = (ctx: RoutifyContext<State>) => Promise<HandlerResult> | HandlerResult;
27
35
  /**
28
36
  * Wraps a `Handler` function into an `APIRoute` that:
29
37
  * - logs requests and responses,
@@ -1,4 +1,4 @@
1
- import { internalError, toAstroResponse, isReadableStream } from './responseHelpers';
1
+ import { internalError, toAstroResponse } from './responseHelpers';
2
2
  /**
3
3
  * Logs the incoming request method and path to the console.
4
4
  */
@@ -33,18 +33,7 @@ export function defineHandler(handler) {
33
33
  logResponse(result.status, start);
34
34
  return result;
35
35
  }
36
- // Direct binary or stream body (file download, etc.)
37
- if (result?.body instanceof Blob ||
38
- result?.body instanceof ArrayBuffer ||
39
- isReadableStream(result?.body)) {
40
- const res = new Response(result.body, {
41
- status: result.status,
42
- headers: result.headers,
43
- });
44
- logResponse(res.status, start);
45
- return res;
46
- }
47
- // Structured ResultResponse → native Astro Response
36
+ // Structured ResultResponse or other HandlerResult native Astro Response
48
37
  const finalResponse = toAstroResponse(result);
49
38
  logResponse(finalResponse.status, start);
50
39
  return finalResponse;
@@ -21,9 +21,14 @@ export interface Route {
21
21
  */
22
22
  middlewares?: Middleware[];
23
23
  /**
24
- * Optional metadata for the route (e.g., for OpenAPI generation).
24
+ * Optional metadata for the route (e.g. for OpenAPI generation).
25
25
  */
26
26
  metadata?: Record<string, any>;
27
+ /**
28
+ * Internal marker to identify the object type during module discovery.
29
+ * @internal
30
+ */
31
+ _routifyType?: 'route';
27
32
  }
28
33
  /**
29
34
  * Defines a route using a `Route` object.
@@ -18,6 +18,7 @@ export function defineRoute(methodOrRoute, maybePathOrAutoRegister, maybeHandler
18
18
  };
19
19
  autoRegister = !!maybeAutoRegister;
20
20
  }
21
+ route._routifyType = 'route';
21
22
  validateRoute(route);
22
23
  if (autoRegister) {
23
24
  globalRegistry.register(route);
@@ -37,6 +38,9 @@ export function validateRoute({ method, path }) {
37
38
  if (!ALLOWED_HTTP_METHODS.has(method)) {
38
39
  throw new Error(`Unsupported HTTP method in route: ${method}`);
39
40
  }
41
+ if (path.includes('**') && !path.endsWith('**')) {
42
+ throw new Error(`Catch-all '**' is only allowed at the end of a path: ${path}`);
43
+ }
40
44
  }
41
45
  /**
42
46
  * Checks if an object implements the `Route` interface.
@@ -47,7 +51,8 @@ export function validateRoute({ method, path }) {
47
51
  export function isRoute(obj) {
48
52
  return (obj &&
49
53
  typeof obj === 'object' &&
50
- typeof obj.method === 'string' &&
51
- typeof obj.path === 'string' &&
52
- typeof obj.handler === 'function');
54
+ (obj._routifyType === 'route' ||
55
+ (typeof obj.method === 'string' &&
56
+ typeof obj.path === 'string' &&
57
+ typeof obj.handler === 'function')));
53
58
  }
@@ -1,6 +1,6 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import { type RoutifyContext } from './defineHandler';
3
- import { internalError, notFound } from './responseHelpers';
3
+ import { notFound, type HandlerResult } from './responseHelpers';
4
4
  import type { Route } from './defineRoute';
5
5
  /**
6
6
  * Optional configuration for the router instance.
@@ -23,7 +23,7 @@ export interface RouterOptions {
23
23
  /**
24
24
  * Custom error handler for the router.
25
25
  */
26
- onError?: (error: unknown, ctx: RoutifyContext) => ReturnType<typeof internalError> | Response;
26
+ onError?: (error: unknown, ctx: RoutifyContext) => HandlerResult | Response;
27
27
  }
28
28
  /**
29
29
  * Defines a router that dynamically matches registered routes based on method and path.
@@ -42,8 +42,24 @@ export function defineRouter(routes, options = {}) {
42
42
  const handler = defineHandler(async (routifyCtx) => {
43
43
  const url = new URL(routifyCtx.request.url);
44
44
  const pathname = url.pathname;
45
- routifyCtx.query = Object.fromEntries(url.searchParams.entries());
46
- routifyCtx.data = {};
45
+ routifyCtx.searchParams = url.searchParams;
46
+ const query = {};
47
+ url.searchParams.forEach((value, key) => {
48
+ const existing = query[key];
49
+ if (existing !== undefined) {
50
+ if (Array.isArray(existing)) {
51
+ existing.push(value);
52
+ }
53
+ else {
54
+ query[key] = [existing, value];
55
+ }
56
+ }
57
+ else {
58
+ query[key] = value;
59
+ }
60
+ });
61
+ routifyCtx.query = query;
62
+ routifyCtx.state = {};
47
63
  let path = pathname;
48
64
  if (basePath !== '') {
49
65
  if (!pathname.startsWith(basePath)) {
@@ -3,7 +3,7 @@ import { HttpMethod } from '../HttpMethod';
3
3
  import { type Route } from '../defineRoute';
4
4
  type JsonValue = any;
5
5
  /**
6
- * A writer interface for streaming JSON data to the response body.
6
+ * A writer interface for streaming JSON state to the response body.
7
7
  * Supports both NDJSON and array formats.
8
8
  */
9
9
  export interface JsonStreamWriter {
@@ -42,6 +42,6 @@ export interface ValidationSchema {
42
42
  * Middleware for request validation.
43
43
  * Supports any schema library with a `safeParse` method (like Zod).
44
44
  *
45
- * Validated data is stored in `ctx.data.body`, `ctx.data.query`, etc.
45
+ * Validated state is stored in `ctx.state.body`, `ctx.state.query`, etc.
46
46
  */
47
47
  export declare function validate(schema: ValidationSchema): Middleware;
@@ -77,7 +77,7 @@ export function securityHeaders() {
77
77
  * Middleware for request validation.
78
78
  * Supports any schema library with a `safeParse` method (like Zod).
79
79
  *
80
- * Validated data is stored in `ctx.data.body`, `ctx.data.query`, etc.
80
+ * Validated state is stored in `ctx.state.body`, `ctx.state.query`, etc.
81
81
  */
82
82
  export function validate(schema) {
83
83
  return async (ctx, next) => {
@@ -85,13 +85,13 @@ export function validate(schema) {
85
85
  const result = schema.params.safeParse(ctx.params);
86
86
  if (!result.success)
87
87
  return toAstroResponse(badRequest({ error: 'Invalid parameters', details: result.error }));
88
- ctx.data.params = result.data;
88
+ ctx.state.params = result.data;
89
89
  }
90
90
  if (schema.query) {
91
91
  const result = schema.query.safeParse(ctx.query);
92
92
  if (!result.success)
93
93
  return toAstroResponse(badRequest({ error: 'Invalid query string', details: result.error }));
94
- ctx.data.query = result.data;
94
+ ctx.state.query = result.data;
95
95
  }
96
96
  if (schema.body) {
97
97
  try {
@@ -99,7 +99,7 @@ export function validate(schema) {
99
99
  const result = schema.body.safeParse(body);
100
100
  if (!result.success)
101
101
  return toAstroResponse(badRequest({ error: 'Invalid request body', details: result.error }));
102
- ctx.data.body = result.data;
102
+ ctx.state.body = result.data;
103
103
  }
104
104
  catch (e) {
105
105
  return toAstroResponse(badRequest({ error: 'Invalid JSON body' }));
@@ -25,7 +25,11 @@ export function generateOpenAPI(router, options) {
25
25
  const path = route.path;
26
26
  // OpenAPI paths must start with / and should not include the basePath if it's in servers
27
27
  // Convert :param or :param(regex) to {param}
28
- const openApiPath = path.replace(/:([a-zA-Z0-9_]+)(\(.*?\))?/g, '{$1}');
28
+ // Convert ** to {rest} and * to {any}
29
+ let openApiPath = path
30
+ .replace(/:([a-zA-Z0-9_]+)(\(.*?\))?/g, '{$1}')
31
+ .replace(/\*\*/g, '{rest}')
32
+ .replace(/\*/g, '{any}');
29
33
  if (!spec.paths[openApiPath])
30
34
  spec.paths[openApiPath] = {};
31
35
  const method = route.method.toLowerCase();
@@ -42,14 +46,37 @@ export function generateOpenAPI(router, options) {
42
46
  }
43
47
  };
44
48
  // Extract path parameters
45
- const pathParamMatches = path.matchAll(/:([a-zA-Z0-9_]+)/g);
46
- for (const match of pathParamMatches) {
47
- const name = match[1];
49
+ const paramRegex = /:([a-zA-Z0-9_]+)(?:\((.*?)\))?/g;
50
+ let pMatch;
51
+ while ((pMatch = paramRegex.exec(path)) !== null) {
52
+ const name = pMatch[1];
53
+ const pattern = pMatch[2];
48
54
  spec.paths[openApiPath][method].parameters.push({
49
55
  name,
50
56
  in: 'path',
51
57
  required: true,
52
- schema: { type: 'string' }
58
+ schema: {
59
+ type: 'string',
60
+ pattern: pattern ? pattern : undefined
61
+ }
62
+ });
63
+ }
64
+ if (path.includes('**')) {
65
+ spec.paths[openApiPath][method].parameters.push({
66
+ name: 'rest',
67
+ in: 'path',
68
+ required: true,
69
+ schema: { type: 'string' },
70
+ description: 'Catch-all path'
71
+ });
72
+ }
73
+ else if (path.includes('*')) {
74
+ spec.paths[openApiPath][method].parameters.push({
75
+ name: 'any',
76
+ in: 'path',
77
+ required: true,
78
+ schema: { type: 'string' },
79
+ description: 'Wildcard segment'
53
80
  });
54
81
  }
55
82
  }
@@ -1,4 +1,8 @@
1
1
  import { BodyInit, HeadersInit } from 'undici';
2
+ /**
3
+ * Supported types that can be returned from a route handler.
4
+ */
5
+ export type HandlerResult = Response | ResultResponse | string | number | boolean | object | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array> | Blob | FormData | URLSearchParams | null | undefined;
2
6
  /**
3
7
  * A standardized shape for internal route result objects.
4
8
  * These are later converted into native `Response` instances.
@@ -70,7 +74,7 @@ export declare const internalError: (err: unknown, headers?: HeadersInit) => Res
70
74
  /**
71
75
  * Sends a binary or stream-based file response with optional content-disposition.
72
76
  *
73
- * @param content - The file data (Blob, ArrayBuffer, or stream)
77
+ * @param content - The file state (Blob, ArrayBuffer, or stream)
74
78
  * @param contentType - MIME type of the file
75
79
  * @param fileName - Optional download filename
76
80
  * @param headers - Optional extra headers
@@ -85,12 +89,12 @@ export declare const fileResponse: (content: Blob | ArrayBuffer | ReadableStream
85
89
  */
86
90
  export declare function isReadableStream(value: unknown): value is ReadableStream<Uint8Array>;
87
91
  /**
88
- * Converts an internal `ResultResponse` into a native `Response` object
92
+ * Converts an internal `ResultResponse` or any `HandlerResult` into a native `Response` object
89
93
  * for use inside Astro API routes.
90
94
  *
91
- * Automatically applies `Content-Type: application/json` for object bodies.
95
+ * Automatically applies appropriate Content-Type headers.
92
96
  *
93
- * @param result - A ResultResponse returned from route handler
97
+ * @param result - A ResultResponse or other supported type returned from route handler
94
98
  * @returns A native Response
95
99
  */
96
- export declare function toAstroResponse(result: ResultResponse | undefined): Response;
100
+ export declare function toAstroResponse(result: HandlerResult): Response;
@@ -63,7 +63,7 @@ export const internalError = (err, headers) => {
63
63
  /**
64
64
  * Sends a binary or stream-based file response with optional content-disposition.
65
65
  *
66
- * @param content - The file data (Blob, ArrayBuffer, or stream)
66
+ * @param content - The file state (Blob, ArrayBuffer, or stream)
67
67
  * @param contentType - MIME type of the file
68
68
  * @param fileName - Optional download filename
69
69
  * @param headers - Optional extra headers
@@ -101,28 +101,103 @@ export function isReadableStream(value) {
101
101
  typeof value.getReader === 'function');
102
102
  }
103
103
  /**
104
- * Converts an internal `ResultResponse` into a native `Response` object
104
+ * Converts an internal `ResultResponse` or any `HandlerResult` into a native `Response` object
105
105
  * for use inside Astro API routes.
106
106
  *
107
- * Automatically applies `Content-Type: application/json` for object bodies.
107
+ * Automatically applies appropriate Content-Type headers.
108
108
  *
109
- * @param result - A ResultResponse returned from route handler
109
+ * @param result - A ResultResponse or other supported type returned from route handler
110
110
  * @returns A native Response
111
111
  */
112
112
  export function toAstroResponse(result) {
113
- if (!result)
113
+ if (result instanceof Response)
114
+ return result;
115
+ if (result === undefined) {
114
116
  return new Response(null, { status: 204 });
115
- const { status, body, headers } = result;
116
- if (body === undefined || body === null) {
117
- return new Response(null, { status, headers });
118
117
  }
119
- const isJson = isPlainObject(body) || Array.isArray(body);
120
- const finalHeaders = {
121
- ...(headers ?? {}),
122
- ...(isJson ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
123
- };
124
- return new Response(isJson ? JSON.stringify(body) : body, {
125
- status,
126
- headers: finalHeaders,
127
- });
118
+ if (result === null) {
119
+ return new Response('null', {
120
+ status: 200,
121
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
122
+ });
123
+ }
124
+ // If it's a ResultResponse object (has status)
125
+ if (typeof result === 'object' &&
126
+ 'status' in result &&
127
+ typeof result.status === 'number') {
128
+ const { status, body, headers } = result;
129
+ if (body === undefined) {
130
+ return new Response(null, { status, headers });
131
+ }
132
+ if (body === null) {
133
+ const finalHeaders = new Headers();
134
+ finalHeaders.set('Content-Type', 'application/json; charset=utf-8');
135
+ if (headers) {
136
+ const h = new Headers(headers);
137
+ h.forEach((value, key) => finalHeaders.set(key, value));
138
+ }
139
+ return new Response('null', { status, headers: finalHeaders });
140
+ }
141
+ if (body instanceof Response)
142
+ return body;
143
+ const isJson = typeof body === 'number' ||
144
+ typeof body === 'boolean' ||
145
+ isPlainObject(body) ||
146
+ Array.isArray(body);
147
+ const isBinary = body instanceof ArrayBuffer ||
148
+ body instanceof Uint8Array ||
149
+ body instanceof Blob ||
150
+ isReadableStream(body);
151
+ const finalHeaders = new Headers();
152
+ // 1. Apply inferred defaults
153
+ if (isJson) {
154
+ finalHeaders.set('Content-Type', 'application/json; charset=utf-8');
155
+ }
156
+ else if (isBinary) {
157
+ finalHeaders.set('Content-Type', 'application/octet-stream');
158
+ }
159
+ // 2. Explicit headers take precedence
160
+ if (headers) {
161
+ const h = new Headers(headers);
162
+ h.forEach((value, key) => {
163
+ finalHeaders.set(key, value);
164
+ });
165
+ }
166
+ return new Response(isJson ? JSON.stringify(body) : body, {
167
+ status,
168
+ headers: finalHeaders,
169
+ });
170
+ }
171
+ // Direct values
172
+ if (typeof result === 'string') {
173
+ return new Response(result, {
174
+ status: 200,
175
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
176
+ });
177
+ }
178
+ if (typeof result === 'number' || typeof result === 'boolean') {
179
+ return new Response(JSON.stringify(result), {
180
+ status: 200,
181
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
182
+ });
183
+ }
184
+ if (result instanceof ArrayBuffer ||
185
+ result instanceof Uint8Array ||
186
+ result instanceof Blob ||
187
+ isReadableStream(result)) {
188
+ return new Response(result, {
189
+ status: 200,
190
+ headers: { 'Content-Type': 'application/octet-stream' },
191
+ });
192
+ }
193
+ if (result instanceof FormData || result instanceof URLSearchParams) {
194
+ return new Response(result, { status: 200 });
195
+ }
196
+ if (Array.isArray(result) || isPlainObject(result)) {
197
+ return new Response(JSON.stringify(result), {
198
+ status: 200,
199
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
200
+ });
201
+ }
202
+ return new Response(null, { status: 204 });
128
203
  }
@@ -3,7 +3,7 @@ import { type Route } from './defineRoute';
3
3
  import { HttpMethod } from './HttpMethod';
4
4
  import { HeadersInit } from 'undici';
5
5
  /**
6
- * A writer for streaming raw data to the response body.
6
+ * A writer for streaming raw state to the response body.
7
7
  *
8
8
  * This is used inside the `stream()` route handler to emit bytes
9
9
  * or strings directly to the client with backpressure awareness.
@@ -47,7 +47,7 @@ export interface StreamOptions {
47
47
  keepAlive?: boolean;
48
48
  }
49
49
  /**
50
- * Defines a generic streaming route that can write raw chunks of data
50
+ * Defines a generic streaming route that can write raw chunks of state
51
51
  * to the response in real time using a `ReadableStream`.
52
52
  *
53
53
  * Suitable for Server-Sent Events (SSE), long-polling, streamed HTML,
@@ -56,7 +56,7 @@ export interface StreamOptions {
56
56
  * @example
57
57
  * stream('/clock', async ({ response }) => {
58
58
  * const timer = setInterval(() => {
59
- * response.write(`data: ${new Date().toISOString()}\n\n`);
59
+ * response.write(`state: ${new Date().toISOString()}\n\n`);
60
60
  * }, 1000);
61
61
  *
62
62
  * setTimeout(() => {
@@ -1,7 +1,7 @@
1
1
  import { defineRoute } from './defineRoute';
2
2
  import { HttpMethod } from './HttpMethod';
3
3
  /**
4
- * Defines a generic streaming route that can write raw chunks of data
4
+ * Defines a generic streaming route that can write raw chunks of state
5
5
  * to the response in real time using a `ReadableStream`.
6
6
  *
7
7
  * Suitable for Server-Sent Events (SSE), long-polling, streamed HTML,
@@ -10,7 +10,7 @@ import { HttpMethod } from './HttpMethod';
10
10
  * @example
11
11
  * stream('/clock', async ({ response }) => {
12
12
  * const timer = setInterval(() => {
13
- * response.write(`data: ${new Date().toISOString()}\n\n`);
13
+ * response.write(`state: ${new Date().toISOString()}\n\n`);
14
14
  * }, 1000);
15
15
  *
16
16
  * setTimeout(() => {
@@ -36,7 +36,7 @@ export function stream(path, handler, options) {
36
36
  if (closed || !controllerRef)
37
37
  return;
38
38
  const bytes = typeof chunk === 'string'
39
- ? (contentType === 'text/event-stream' ? encoder.encode(`data: ${chunk}\n\n`) : encoder.encode(chunk))
39
+ ? (contentType === 'text/event-stream' ? encoder.encode(`state: ${chunk}\n\n`) : encoder.encode(chunk))
40
40
  : chunk;
41
41
  controllerRef.enqueue(bytes);
42
42
  },
@@ -6,7 +6,7 @@ import { JsonStreamWriter } from './internal/createJsonStreamRoute';
6
6
  * Defines a JSON streaming route that emits a valid JSON array.
7
7
  *
8
8
  * This helper returns a valid `application/json` response containing
9
- * a streamable array of JSON values. Useful for large data exports
9
+ * a streamable array of JSON values. Useful for large state exports
10
10
  * or APIs where the full array can be streamed as it's generated.
11
11
  *
12
12
  * Unlike `streamJsonND()`, this wraps all values in `[` and `]`
@@ -4,7 +4,7 @@ import { createJsonStreamRoute } from './internal/createJsonStreamRoute';
4
4
  * Defines a JSON streaming route that emits a valid JSON array.
5
5
  *
6
6
  * This helper returns a valid `application/json` response containing
7
- * a streamable array of JSON values. Useful for large data exports
7
+ * a streamable array of JSON values. Useful for large state exports
8
8
  * or APIs where the full array can be streamed as it's generated.
9
9
  *
10
10
  * Unlike `streamJsonND()`, this wraps all values in `[` and `]`