astro-routify 1.2.2 → 1.4.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.
@@ -1,4 +1,4 @@
1
- import { internalError, toAstroResponse } from './responseHelpers';
1
+ import { internalError, toAstroResponse, isReadableStream } from './responseHelpers';
2
2
  /**
3
3
  * Logs the incoming request method and path to the console.
4
4
  */
@@ -57,14 +57,3 @@ export function defineHandler(handler) {
57
57
  }
58
58
  };
59
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
- */
66
- export function isReadableStream(value) {
67
- return (typeof value === 'object' &&
68
- value !== null &&
69
- typeof value.getReader === 'function');
70
- }
@@ -1,5 +1,5 @@
1
1
  import { HttpMethod } from './HttpMethod';
2
- import type { Handler } from './defineHandler';
2
+ import type { Handler, Middleware } from './defineHandler';
3
3
  /**
4
4
  * Represents a single route definition.
5
5
  */
@@ -16,6 +16,14 @@ export interface Route {
16
16
  * The function that handles the request when matched.
17
17
  */
18
18
  handler: Handler;
19
+ /**
20
+ * Optional array of middlewares to run before the handler.
21
+ */
22
+ middlewares?: Middleware[];
23
+ /**
24
+ * Optional metadata for the route (e.g., for OpenAPI generation).
25
+ */
26
+ metadata?: Record<string, any>;
19
27
  }
20
28
  /**
21
29
  * Defines a route using a `Route` object.
@@ -24,9 +32,10 @@ export interface Route {
24
32
  * defineRoute({ method: 'GET', path: '/users', handler });
25
33
  *
26
34
  * @param route - A fully constructed route object
35
+ * @param autoRegister - If true, registers the route to the global registry
27
36
  * @returns The validated Route object
28
37
  */
29
- export declare function defineRoute(route: Route): Route;
38
+ export declare function defineRoute(route: Route, autoRegister?: boolean): Route;
30
39
  /**
31
40
  * Defines a route by specifying its method, path, and handler explicitly.
32
41
  *
@@ -36,6 +45,21 @@ export declare function defineRoute(route: Route): Route;
36
45
  * @param method - HTTP method to match
37
46
  * @param path - Route path (must start with `/`)
38
47
  * @param handler - Function to handle the matched request
48
+ * @param autoRegister - If true, registers the route to the global registry
39
49
  * @returns The validated Route object
40
50
  */
41
- export declare function defineRoute(method: HttpMethod, path: string, handler: Handler): Route;
51
+ export declare function defineRoute(method: HttpMethod, path: string, handler: Handler, autoRegister?: boolean): Route;
52
+ /**
53
+ * Ensures the route is properly formed and uses a valid method + path format.
54
+ *
55
+ * @param route - Route to validate
56
+ * @throws If method is unsupported or path doesn't start with `/`
57
+ */
58
+ export declare function validateRoute({ method, path }: Route): void;
59
+ /**
60
+ * Checks if an object implements the `Route` interface.
61
+ *
62
+ * @param obj - The object to check
63
+ * @returns True if the object is a valid Route
64
+ */
65
+ export declare function isRoute(obj: any): obj is Route;
@@ -1,18 +1,27 @@
1
1
  import { ALLOWED_HTTP_METHODS } from './HttpMethod';
2
+ import { globalRegistry } from './registry';
2
3
  /**
3
4
  * Internal route definition logic that supports both overloads.
4
5
  */
5
- export function defineRoute(methodOrRoute, maybePath, maybeHandler) {
6
+ export function defineRoute(methodOrRoute, maybePathOrAutoRegister, maybeHandler, maybeAutoRegister) {
7
+ let autoRegister = false;
8
+ let route;
6
9
  if (typeof methodOrRoute === 'object') {
7
- validateRoute(methodOrRoute);
8
- return methodOrRoute;
10
+ route = methodOrRoute;
11
+ autoRegister = !!maybePathOrAutoRegister;
12
+ }
13
+ else {
14
+ route = {
15
+ method: methodOrRoute,
16
+ path: maybePathOrAutoRegister,
17
+ handler: maybeHandler,
18
+ };
19
+ autoRegister = !!maybeAutoRegister;
9
20
  }
10
- const route = {
11
- method: methodOrRoute,
12
- path: maybePath,
13
- handler: maybeHandler,
14
- };
15
21
  validateRoute(route);
22
+ if (autoRegister) {
23
+ globalRegistry.register(route);
24
+ }
16
25
  return route;
17
26
  }
18
27
  /**
@@ -21,7 +30,7 @@ export function defineRoute(methodOrRoute, maybePath, maybeHandler) {
21
30
  * @param route - Route to validate
22
31
  * @throws If method is unsupported or path doesn't start with `/`
23
32
  */
24
- function validateRoute({ method, path }) {
33
+ export function validateRoute({ method, path }) {
25
34
  if (!path.startsWith('/')) {
26
35
  throw new Error(`Route path must start with '/': ${path}`);
27
36
  }
@@ -29,3 +38,16 @@ function validateRoute({ method, path }) {
29
38
  throw new Error(`Unsupported HTTP method in route: ${method}`);
30
39
  }
31
40
  }
41
+ /**
42
+ * Checks if an object implements the `Route` interface.
43
+ *
44
+ * @param obj - The object to check
45
+ * @returns True if the object is a valid Route
46
+ */
47
+ export function isRoute(obj) {
48
+ return (obj &&
49
+ typeof obj === 'object' &&
50
+ typeof obj.method === 'string' &&
51
+ typeof obj.path === 'string' &&
52
+ typeof obj.handler === 'function');
53
+ }
@@ -1,5 +1,6 @@
1
1
  import type { APIRoute } from 'astro';
2
- import { notFound } from './responseHelpers';
2
+ import { type RoutifyContext } from './defineHandler';
3
+ import { internalError, notFound } from './responseHelpers';
3
4
  import type { Route } from './defineRoute';
4
5
  /**
5
6
  * Optional configuration for the router instance.
@@ -14,6 +15,15 @@ export interface RouterOptions {
14
15
  * Custom handler to return when no route is matched (404).
15
16
  */
16
17
  onNotFound?: () => ReturnType<typeof notFound>;
18
+ /**
19
+ * If true, enables debug logging for route matching.
20
+ * Useful during development to trace which route is being matched.
21
+ */
22
+ debug?: boolean;
23
+ /**
24
+ * Custom error handler for the router.
25
+ */
26
+ onError?: (error: unknown, ctx: RoutifyContext) => ReturnType<typeof internalError> | Response;
17
27
  }
18
28
  /**
19
29
  * Defines a router that dynamically matches registered routes based on method and path.
@@ -27,19 +27,40 @@ import { normalizeMethod } from './HttpMethod';
27
27
  export function defineRouter(routes, options = {}) {
28
28
  const trie = new RouteTrie();
29
29
  for (const route of routes) {
30
- trie.insert(route.path, route.method, route.handler);
30
+ trie.insert(route);
31
31
  }
32
- return async (ctx) => {
33
- const pathname = new URL(ctx.request.url).pathname;
34
- let basePath = options.basePath ?? '/api';
35
- if (!basePath.startsWith('/')) {
36
- basePath = '/' + basePath;
32
+ let basePath = options.basePath ?? '/api';
33
+ if (basePath !== '' && !basePath.startsWith('/')) {
34
+ basePath = '/' + basePath;
35
+ }
36
+ if (basePath.endsWith('/') && basePath !== '/') {
37
+ basePath = basePath.slice(0, -1);
38
+ }
39
+ if (basePath === '/') {
40
+ basePath = '';
41
+ }
42
+ const handler = defineHandler(async (routifyCtx) => {
43
+ const url = new URL(routifyCtx.request.url);
44
+ const pathname = url.pathname;
45
+ routifyCtx.query = Object.fromEntries(url.searchParams.entries());
46
+ routifyCtx.data = {};
47
+ let path = pathname;
48
+ if (basePath !== '') {
49
+ if (!pathname.startsWith(basePath)) {
50
+ return toAstroResponse(options.onNotFound ? options.onNotFound() : notFound('Not Found'));
51
+ }
52
+ const nextChar = pathname.charAt(basePath.length);
53
+ if (nextChar !== '' && nextChar !== '/') {
54
+ return toAstroResponse(options.onNotFound ? options.onNotFound() : notFound('Not Found'));
55
+ }
56
+ path = pathname.slice(basePath.length);
37
57
  }
38
- const basePathRegex = new RegExp(`^${basePath}`);
39
- const path = pathname.replace(basePathRegex, '');
40
- const method = normalizeMethod(ctx.request.method);
41
- const { handler, allowed, params } = trie.find(path, method);
42
- if (!handler) {
58
+ const method = normalizeMethod(routifyCtx.request.method);
59
+ const { route, allowed, params } = trie.find(path, method);
60
+ if (options.debug) {
61
+ console.log(`[RouterBuilder] ${method} ${path} -> ${route ? 'matched' : allowed && allowed.length ? '405' : '404'}`);
62
+ }
63
+ if (!route) {
43
64
  // Method exists but not allowed for this route
44
65
  if (allowed && allowed.length) {
45
66
  return toAstroResponse(methodNotAllowed('Method Not Allowed', {
@@ -47,10 +68,36 @@ export function defineRouter(routes, options = {}) {
47
68
  }));
48
69
  }
49
70
  // No route matched at all → 404
50
- const notFoundHandler = options.onNotFound ? options.onNotFound() : notFound('Not Found');
51
- return toAstroResponse(notFoundHandler);
71
+ return toAstroResponse(options.onNotFound ? options.onNotFound() : notFound('Not Found'));
72
+ }
73
+ // Match found → delegate to handler with middlewares
74
+ routifyCtx.params = { ...routifyCtx.params, ...params };
75
+ try {
76
+ const middlewares = route.middlewares || [];
77
+ let index = -1;
78
+ const next = async () => {
79
+ index++;
80
+ if (index < middlewares.length) {
81
+ const mw = middlewares[index];
82
+ const res = await mw(routifyCtx, next);
83
+ return res instanceof Response ? res : toAstroResponse(res);
84
+ }
85
+ else {
86
+ const result = await route.handler(routifyCtx);
87
+ return result instanceof Response ? result : toAstroResponse(result);
88
+ }
89
+ };
90
+ return await next();
91
+ }
92
+ catch (err) {
93
+ if (options.onError) {
94
+ const errorRes = options.onError(err, routifyCtx);
95
+ return errorRes instanceof Response ? errorRes : toAstroResponse(errorRes);
96
+ }
97
+ throw err;
52
98
  }
53
- // Match found → delegate to handler
54
- return defineHandler(handler)({ ...ctx, params: { ...ctx.params, ...params } });
55
- };
99
+ });
100
+ handler.routes = routes;
101
+ handler.options = options;
102
+ return handler;
56
103
  }
@@ -69,15 +69,23 @@ export function createJsonStreamRoute(path, handler, options) {
69
69
  const body = new ReadableStream({
70
70
  start(controller) {
71
71
  controllerRef = controller;
72
- ctx.request.signal.addEventListener('abort', () => {
72
+ const onAbort = () => {
73
73
  closed = true;
74
- controllerRef?.close();
75
- });
76
- Promise.resolve(handler({ ...ctx, response: writer })).catch((err) => {
74
+ try {
75
+ controllerRef?.close();
76
+ }
77
+ catch { /* noop */ }
78
+ };
79
+ ctx.request.signal.addEventListener('abort', onAbort, { once: true });
80
+ Promise.resolve(handler({ ...ctx, response: writer }))
81
+ .catch((err) => {
77
82
  try {
78
83
  controller.error(err);
79
84
  }
80
85
  catch { /* noop */ }
86
+ })
87
+ .finally(() => {
88
+ ctx.request.signal.removeEventListener('abort', onAbort);
81
89
  });
82
90
  },
83
91
  cancel() {
@@ -0,0 +1,47 @@
1
+ import { type Middleware } from './defineHandler';
2
+ /**
3
+ * CORS options.
4
+ */
5
+ export interface CorsOptions {
6
+ origin?: string | string[] | ((origin: string) => boolean | string);
7
+ methods?: string[];
8
+ allowedHeaders?: string[];
9
+ exposedHeaders?: string[];
10
+ credentials?: boolean;
11
+ maxAge?: number;
12
+ }
13
+ /**
14
+ * Middleware to enable Cross-Origin Resource Sharing (CORS).
15
+ */
16
+ export declare function cors(options?: CorsOptions): Middleware;
17
+ /**
18
+ * Middleware to add common security headers (Helmet-like).
19
+ */
20
+ export declare function securityHeaders(): Middleware;
21
+ /**
22
+ * Interface for schemas compatible with Zod, Valibot, etc.
23
+ */
24
+ export interface Validatable {
25
+ safeParse: (data: any) => {
26
+ success: true;
27
+ data: any;
28
+ } | {
29
+ success: false;
30
+ error: any;
31
+ };
32
+ }
33
+ /**
34
+ * Validation schema for request components.
35
+ */
36
+ export interface ValidationSchema {
37
+ body?: Validatable;
38
+ query?: Validatable;
39
+ params?: Validatable;
40
+ }
41
+ /**
42
+ * Middleware for request validation.
43
+ * Supports any schema library with a `safeParse` method (like Zod).
44
+ *
45
+ * Validated data is stored in `ctx.data.body`, `ctx.data.query`, etc.
46
+ */
47
+ export declare function validate(schema: ValidationSchema): Middleware;
@@ -0,0 +1,110 @@
1
+ import { badRequest, toAstroResponse } from './responseHelpers';
2
+ /**
3
+ * Middleware to enable Cross-Origin Resource Sharing (CORS).
4
+ */
5
+ export function cors(options = {}) {
6
+ return async (ctx, next) => {
7
+ const origin = ctx.request.headers.get('Origin');
8
+ // Handle preflight
9
+ if (ctx.request.method === 'OPTIONS') {
10
+ const headers = new Headers();
11
+ if (origin) {
12
+ headers.set('Access-Control-Allow-Origin', origin);
13
+ }
14
+ if (options.methods) {
15
+ headers.set('Access-Control-Allow-Methods', options.methods.join(','));
16
+ }
17
+ else {
18
+ headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
19
+ }
20
+ if (options.allowedHeaders) {
21
+ headers.set('Access-Control-Allow-Headers', options.allowedHeaders.join(','));
22
+ }
23
+ else {
24
+ const requestedHeaders = ctx.request.headers.get('Access-Control-Request-Headers');
25
+ if (requestedHeaders)
26
+ headers.set('Access-Control-Allow-Headers', requestedHeaders);
27
+ }
28
+ if (options.credentials) {
29
+ headers.set('Access-Control-Allow-Credentials', 'true');
30
+ }
31
+ if (options.maxAge) {
32
+ headers.set('Access-Control-Max-Age', String(options.maxAge));
33
+ }
34
+ return new Response(null, { status: 204, headers });
35
+ }
36
+ const res = await next();
37
+ const newHeaders = new Headers(res.headers);
38
+ if (origin) {
39
+ newHeaders.set('Access-Control-Allow-Origin', origin);
40
+ }
41
+ else {
42
+ newHeaders.set('Access-Control-Allow-Origin', '*');
43
+ }
44
+ if (options.credentials) {
45
+ newHeaders.set('Access-Control-Allow-Credentials', 'true');
46
+ }
47
+ if (options.exposedHeaders) {
48
+ newHeaders.set('Access-Control-Expose-Headers', options.exposedHeaders.join(','));
49
+ }
50
+ return new Response(res.body, {
51
+ status: res.status,
52
+ statusText: res.statusText,
53
+ headers: newHeaders
54
+ });
55
+ };
56
+ }
57
+ /**
58
+ * Middleware to add common security headers (Helmet-like).
59
+ */
60
+ export function securityHeaders() {
61
+ return async (ctx, next) => {
62
+ const res = await next();
63
+ const headers = new Headers(res.headers);
64
+ headers.set('X-Content-Type-Options', 'nosniff');
65
+ headers.set('X-Frame-Options', 'SAMEORIGIN');
66
+ headers.set('X-XSS-Protection', '1; mode=block');
67
+ headers.set('Referrer-Policy', 'no-referrer-when-downgrade');
68
+ headers.set('Content-Security-Policy', "default-src 'self'");
69
+ return new Response(res.body, {
70
+ status: res.status,
71
+ statusText: res.statusText,
72
+ headers
73
+ });
74
+ };
75
+ }
76
+ /**
77
+ * Middleware for request validation.
78
+ * Supports any schema library with a `safeParse` method (like Zod).
79
+ *
80
+ * Validated data is stored in `ctx.data.body`, `ctx.data.query`, etc.
81
+ */
82
+ export function validate(schema) {
83
+ return async (ctx, next) => {
84
+ if (schema.params) {
85
+ const result = schema.params.safeParse(ctx.params);
86
+ if (!result.success)
87
+ return toAstroResponse(badRequest({ error: 'Invalid parameters', details: result.error }));
88
+ ctx.data.params = result.data;
89
+ }
90
+ if (schema.query) {
91
+ const result = schema.query.safeParse(ctx.query);
92
+ if (!result.success)
93
+ return toAstroResponse(badRequest({ error: 'Invalid query string', details: result.error }));
94
+ ctx.data.query = result.data;
95
+ }
96
+ if (schema.body) {
97
+ try {
98
+ const body = await ctx.request.clone().json();
99
+ const result = schema.body.safeParse(body);
100
+ if (!result.success)
101
+ return toAstroResponse(badRequest({ error: 'Invalid request body', details: result.error }));
102
+ ctx.data.body = result.data;
103
+ }
104
+ catch (e) {
105
+ return toAstroResponse(badRequest({ error: 'Invalid JSON body' }));
106
+ }
107
+ }
108
+ return next();
109
+ };
110
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Basic options for OpenAPI generation.
3
+ */
4
+ export interface OpenAPIOptions {
5
+ title: string;
6
+ version: string;
7
+ description?: string;
8
+ basePath?: string;
9
+ }
10
+ /**
11
+ * Generates an OpenAPI 3.0.0 specification from a list of Routify routes.
12
+ *
13
+ * @param router - The router handler function (returned by builder.build() or createRouter())
14
+ * @param options - Basic info for the OpenAPI spec
15
+ * @returns A JSON object representing the OpenAPI specification
16
+ */
17
+ export declare function generateOpenAPI(router: any, options: OpenAPIOptions): any;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Generates an OpenAPI 3.0.0 specification from a list of Routify routes.
3
+ *
4
+ * @param router - The router handler function (returned by builder.build() or createRouter())
5
+ * @param options - Basic info for the OpenAPI spec
6
+ * @returns A JSON object representing the OpenAPI specification
7
+ */
8
+ export function generateOpenAPI(router, options) {
9
+ const routes = router.routes || [];
10
+ const routerOptions = router.options || {};
11
+ const basePath = options.basePath ?? routerOptions.basePath ?? '/api';
12
+ const spec = {
13
+ openapi: '3.0.0',
14
+ info: {
15
+ title: options.title,
16
+ version: options.version,
17
+ description: options.description
18
+ },
19
+ servers: [
20
+ { url: basePath }
21
+ ],
22
+ paths: {}
23
+ };
24
+ for (const route of routes) {
25
+ const path = route.path;
26
+ // OpenAPI paths must start with / and should not include the basePath if it's in servers
27
+ // Convert :param or :param(regex) to {param}
28
+ const openApiPath = path.replace(/:([a-zA-Z0-9_]+)(\(.*?\))?/g, '{$1}');
29
+ if (!spec.paths[openApiPath])
30
+ spec.paths[openApiPath] = {};
31
+ const method = route.method.toLowerCase();
32
+ const metadata = route.metadata || {};
33
+ spec.paths[openApiPath][method] = {
34
+ summary: metadata.summary || `${route.method} ${path}`,
35
+ description: metadata.description,
36
+ tags: metadata.tags,
37
+ parameters: [],
38
+ responses: {
39
+ '200': {
40
+ description: 'Successful response'
41
+ }
42
+ }
43
+ };
44
+ // Extract path parameters
45
+ const pathParamMatches = path.matchAll(/:([a-zA-Z0-9_]+)/g);
46
+ for (const match of pathParamMatches) {
47
+ const name = match[1];
48
+ spec.paths[openApiPath][method].parameters.push({
49
+ name,
50
+ in: 'path',
51
+ required: true,
52
+ schema: { type: 'string' }
53
+ });
54
+ }
55
+ }
56
+ return spec;
57
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A global registry for routes and groups to support "agnostic" auto-registration.
3
+ * This allows routes to be defined anywhere in the project and automatically
4
+ * picked up by the router.
5
+ */
6
+ export declare class InternalRegistry {
7
+ private static instance;
8
+ private items;
9
+ private constructor();
10
+ static getInstance(): InternalRegistry;
11
+ register(item: any): void;
12
+ getItems(): any[];
13
+ clear(): void;
14
+ }
15
+ export declare const globalRegistry: InternalRegistry;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A global registry for routes and groups to support "agnostic" auto-registration.
3
+ * This allows routes to be defined anywhere in the project and automatically
4
+ * picked up by the router.
5
+ */
6
+ export class InternalRegistry {
7
+ constructor() {
8
+ this.items = [];
9
+ }
10
+ static getInstance() {
11
+ if (!InternalRegistry.instance) {
12
+ InternalRegistry.instance = new InternalRegistry();
13
+ }
14
+ return InternalRegistry.instance;
15
+ }
16
+ register(item) {
17
+ this.items.push(item);
18
+ }
19
+ getItems() {
20
+ return [...this.items];
21
+ }
22
+ clear() {
23
+ this.items = [];
24
+ }
25
+ }
26
+ export const globalRegistry = InternalRegistry.getInstance();
@@ -57,8 +57,14 @@ export declare const methodNotAllowed: <T = string>(body?: T, headers?: HeadersI
57
57
  * 429 Too Many Requests
58
58
  */
59
59
  export declare const tooManyRequests: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
60
+ /**
61
+ * Returns a response with JSON body and specified status.
62
+ */
63
+ export declare const json: (body: any, status?: number, headers?: HeadersInit) => ResultResponse<any>;
60
64
  /**
61
65
  * 500 Internal Server Error
66
+ *
67
+ * In production, you might want to avoid leaking error details.
62
68
  */
63
69
  export declare const internalError: (err: unknown, headers?: HeadersInit) => ResultResponse<string>;
64
70
  /**
@@ -71,6 +77,13 @@ export declare const internalError: (err: unknown, headers?: HeadersInit) => Res
71
77
  * @returns A ResultResponse for download-ready content
72
78
  */
73
79
  export declare const fileResponse: (content: Blob | ArrayBuffer | ReadableStream<Uint8Array>, contentType: string, fileName?: string, headers?: HeadersInit) => ResultResponse<BodyInit>;
80
+ /**
81
+ * Type guard to detect ReadableStreams, used for streamed/binary responses.
82
+ *
83
+ * @param value - Any value to test
84
+ * @returns True if it looks like a ReadableStream
85
+ */
86
+ export declare function isReadableStream(value: unknown): value is ReadableStream<Uint8Array>;
74
87
  /**
75
88
  * Converts an internal `ResultResponse` into a native `Response` object
76
89
  * for use inside Astro API routes.
@@ -47,10 +47,19 @@ export const methodNotAllowed = (body = 'Method Not Allowed', headers) => create
47
47
  * 429 Too Many Requests
48
48
  */
49
49
  export const tooManyRequests = (body = 'Too Many Requests', headers) => createResponse(429, body, headers);
50
+ /**
51
+ * Returns a response with JSON body and specified status.
52
+ */
53
+ export const json = (body, status = 200, headers) => createResponse(status, body, headers);
50
54
  /**
51
55
  * 500 Internal Server Error
56
+ *
57
+ * In production, you might want to avoid leaking error details.
52
58
  */
53
- export const internalError = (err, headers) => createResponse(500, err instanceof Error ? err.message : String(err), headers);
59
+ export const internalError = (err, headers) => {
60
+ const message = err instanceof Error ? err.message : String(err);
61
+ return createResponse(500, message, headers);
62
+ };
54
63
  /**
55
64
  * Sends a binary or stream-based file response with optional content-disposition.
56
65
  *
@@ -61,8 +70,9 @@ export const internalError = (err, headers) => createResponse(500, err instanceo
61
70
  * @returns A ResultResponse for download-ready content
62
71
  */
63
72
  export const fileResponse = (content, contentType, fileName, headers) => {
64
- const disposition = fileName
65
- ? { 'Content-Disposition': `attachment; filename="${fileName}"` }
73
+ const sanitizedFileName = fileName?.replace(/"/g, '\\"');
74
+ const disposition = sanitizedFileName
75
+ ? { 'Content-Disposition': `attachment; filename="${sanitizedFileName}"` }
66
76
  : {};
67
77
  return {
68
78
  status: 200,
@@ -74,6 +84,22 @@ export const fileResponse = (content, contentType, fileName, headers) => {
74
84
  },
75
85
  };
76
86
  };
87
+ function isPlainObject(value) {
88
+ return (typeof value === 'object' &&
89
+ value !== null &&
90
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null));
91
+ }
92
+ /**
93
+ * Type guard to detect ReadableStreams, used for streamed/binary responses.
94
+ *
95
+ * @param value - Any value to test
96
+ * @returns True if it looks like a ReadableStream
97
+ */
98
+ export function isReadableStream(value) {
99
+ return (typeof value === 'object' &&
100
+ value !== null &&
101
+ typeof value.getReader === 'function');
102
+ }
77
103
  /**
78
104
  * Converts an internal `ResultResponse` into a native `Response` object
79
105
  * for use inside Astro API routes.
@@ -90,12 +116,12 @@ export function toAstroResponse(result) {
90
116
  if (body === undefined || body === null) {
91
117
  return new Response(null, { status, headers });
92
118
  }
93
- const isObject = typeof body === 'object' || Array.isArray(body);
119
+ const isJson = isPlainObject(body) || Array.isArray(body);
94
120
  const finalHeaders = {
95
121
  ...(headers ?? {}),
96
- ...(isObject ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
122
+ ...(isJson ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
97
123
  };
98
- return new Response(isObject ? JSON.stringify(body) : body, {
124
+ return new Response(isJson ? JSON.stringify(body) : body, {
99
125
  status,
100
126
  headers: finalHeaders,
101
127
  });