astro-routify 1.2.2 → 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.
@@ -47,22 +47,32 @@ 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
  *
57
- * @param content - The file data (Blob, ArrayBuffer, or stream)
66
+ * @param content - The file state (Blob, ArrayBuffer, or stream)
58
67
  * @param contentType - MIME type of the file
59
68
  * @param fileName - Optional download filename
60
69
  * @param headers - Optional extra headers
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,29 +84,120 @@ 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
- * Converts an internal `ResultResponse` into a native `Response` object
104
+ * Converts an internal `ResultResponse` or any `HandlerResult` into a native `Response` object
79
105
  * for use inside Astro API routes.
80
106
  *
81
- * Automatically applies `Content-Type: application/json` for object bodies.
107
+ * Automatically applies appropriate Content-Type headers.
82
108
  *
83
- * @param result - A ResultResponse returned from route handler
109
+ * @param result - A ResultResponse or other supported type returned from route handler
84
110
  * @returns A native Response
85
111
  */
86
112
  export function toAstroResponse(result) {
87
- if (!result)
113
+ if (result instanceof Response)
114
+ return result;
115
+ if (result === undefined) {
88
116
  return new Response(null, { status: 204 });
89
- const { status, body, headers } = result;
90
- if (body === undefined || body === null) {
91
- return new Response(null, { status, headers });
92
117
  }
93
- const isObject = typeof body === 'object' || Array.isArray(body);
94
- const finalHeaders = {
95
- ...(headers ?? {}),
96
- ...(isObject ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
97
- };
98
- return new Response(isObject ? JSON.stringify(body) : body, {
99
- status,
100
- headers: finalHeaders,
101
- });
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 });
102
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(() => {
@@ -35,8 +35,8 @@ export function stream(path, handler, options) {
35
35
  write: (chunk) => {
36
36
  if (closed || !controllerRef)
37
37
  return;
38
- const bytes = typeof chunk === 'string' ?
39
- encoder.encode(`data: ${chunk}\n\n`)
38
+ const bytes = typeof chunk === 'string'
39
+ ? (contentType === 'text/event-stream' ? encoder.encode(`state: ${chunk}\n\n`) : encoder.encode(chunk))
40
40
  : chunk;
41
41
  controllerRef.enqueue(bytes);
42
42
  },
@@ -56,17 +56,24 @@ export function stream(path, handler, options) {
56
56
  const body = new ReadableStream({
57
57
  start(controller) {
58
58
  controllerRef = controller;
59
+ const onAbort = () => {
60
+ closed = true;
61
+ try {
62
+ controller.close();
63
+ }
64
+ catch { /* noop */ }
65
+ console.debug('Request aborted — streaming stopped.');
66
+ };
67
+ ctx.request.signal.addEventListener('abort', onAbort, { once: true });
59
68
  Promise.resolve(handler({ ...ctx, response: writer }))
60
69
  .catch((err) => {
61
70
  try {
62
71
  controller.error(err);
63
72
  }
64
73
  catch { /* noop */ }
65
- });
66
- ctx.request.signal.addEventListener('abort', () => {
67
- closed = true;
68
- controller.close();
69
- console.debug('Request aborted — streaming stopped.');
74
+ })
75
+ .finally(() => {
76
+ ctx.request.signal.removeEventListener('abort', onAbort);
70
77
  });
71
78
  },
72
79
  cancel() {
@@ -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 `]`