|
1 | | -import { BunFile } from 'bun'; |
2 | | -import path from 'node:path'; |
3 | | -import Context from '../Context/Context'; |
4 | | -import getMimeType from '../getMimeType/getMimeType'; |
5 | | - |
6 | | -export type Factory = (body: string, init?: ResponseInit) => Response; |
7 | | - |
8 | | -const textEncoder = new TextEncoder(); |
9 | | - |
10 | | -export function json(this: Context, data: any, init: ResponseInit = {}) { |
11 | | - let body: string | Uint8Array = JSON.stringify(data); |
12 | | - init.headers = new Headers(init.headers || {}); |
13 | | - if (!init.headers.has('Content-Type')) { |
14 | | - init.headers.set('Content-Type', `application/json; charset=utf-8`); |
15 | | - } |
16 | | - return new Response(body, init); |
17 | | -} |
18 | | - |
19 | | -export function factory(contentType: string): Factory { |
20 | | - return function (this: Context, body: string, init: ResponseInit = {}) { |
21 | | - init.headers = new Headers(init.headers || {}); |
22 | | - if (!init.headers.has('Content-Type')) { |
23 | | - init.headers.set('Content-Type', `${contentType}; charset=utf-8`); |
24 | | - } |
25 | | - init.headers.set('Content-Length', String(body.length)); |
26 | | - return new Response(body, init); |
27 | | - }; |
28 | | -} |
29 | | - |
30 | | -export const redirect = (url: string, status = 302) => { |
31 | | - return new Response('', { |
32 | | - status, |
33 | | - headers: { |
34 | | - Location: url, |
35 | | - }, |
36 | | - }); |
37 | | -}; |
38 | | - |
39 | | -export type FileResponseOptions = { |
40 | | - range?: string; |
41 | | - chunkSize?: number; |
42 | | - disposition?: 'inline' | 'attachment'; |
43 | | - acceptRanges?: boolean; |
44 | | -}; |
45 | | -export const file = async ( |
46 | | - filenameOrBunFile: string | BunFile, |
47 | | - fileOptions: FileResponseOptions = {} |
48 | | -) => { |
49 | | - let file = |
50 | | - typeof filenameOrBunFile === 'string' |
51 | | - ? Bun.file(filenameOrBunFile) |
52 | | - : filenameOrBunFile; |
53 | | - if (!(await file.exists())) { |
54 | | - return new Response('File not found', { status: 404 }); |
55 | | - } |
56 | | - const resp = await buildFileResponse({ |
57 | | - file, |
58 | | - acceptRanges: true, |
59 | | - chunkSize: fileOptions.chunkSize, |
60 | | - rangeHeader: fileOptions.range, |
61 | | - method: 'GET', |
62 | | - }); |
63 | | - if (fileOptions.acceptRanges !== false) { |
64 | | - // tell the client that we are capable of handling range requests |
65 | | - resp.headers.set('Accept-Ranges', 'bytes'); |
66 | | - } |
67 | | - if (fileOptions.disposition === 'attachment') { |
68 | | - const filename = path.basename(file.name!); |
69 | | - resp.headers.set( |
70 | | - 'Content-Disposition', |
71 | | - `${fileOptions.disposition}; filename="${filename}"` |
72 | | - ); |
73 | | - } else if (fileOptions.disposition === 'inline') { |
74 | | - resp.headers.set('Content-Disposition', 'inline'); |
75 | | - } |
76 | | - return resp; |
77 | | -}; |
78 | | - |
79 | | -export type SseSend = ( |
80 | | - eventName: string, |
81 | | - data?: string | object, |
82 | | - id?: string, |
83 | | - retry?: number |
84 | | -) => void | Promise<void>; |
85 | | -export type SseClose = () => void | Promise<void>; |
86 | | -export type SseSetupFunction = ( |
87 | | - send: SseSend, |
88 | | - close: SseClose |
89 | | -) => void | (() => void); |
90 | | - |
91 | | -export const sse = ( |
92 | | - signal: AbortSignal, |
93 | | - setup: SseSetupFunction, |
94 | | - init: ResponseInit = {} |
95 | | -) => { |
96 | | - const stream = new ReadableStream({ |
97 | | - async start(controller: ReadableStreamDefaultController) { |
98 | | - function send( |
99 | | - eventName: string, |
100 | | - data?: string | object, |
101 | | - id?: string, |
102 | | - retry?: number |
103 | | - ) { |
104 | | - let encoded: Uint8Array; |
105 | | - if (arguments.length === 1) { |
106 | | - encoded = textEncoder.encode(`data: ${eventName}\n\n`); |
107 | | - } else { |
108 | | - if (data && typeof data !== 'string') { |
109 | | - data = JSON.stringify(data); |
110 | | - } |
111 | | - let message = `event: ${eventName}\ndata:${String(data)}`; |
112 | | - if (id) { |
113 | | - message += `\nid: ${id}`; |
114 | | - } |
115 | | - if (retry) { |
116 | | - message += `\nretry: ${retry}`; |
117 | | - } |
118 | | - message += '\n\n'; |
119 | | - encoded = textEncoder.encode(message); |
120 | | - } |
121 | | - if (signal.aborted) { |
122 | | - // client disconnected already |
123 | | - close(); |
124 | | - } else { |
125 | | - controller.enqueue(encoded); |
126 | | - } |
127 | | - } |
128 | | - function close() { |
129 | | - if (closed) { |
130 | | - return; |
131 | | - } |
132 | | - closed = true; |
133 | | - cleanup?.(); |
134 | | - signal.removeEventListener('abort', close); |
135 | | - controller.close(); |
136 | | - } |
137 | | - |
138 | | - // setup and listen for abort signal |
139 | | - const cleanup = setup(send, close); |
140 | | - let closed = false; |
141 | | - signal.addEventListener('abort', close); |
142 | | - // close now if somehow it is already aborted |
143 | | - if (signal.aborted) { |
144 | | - /* c8 ignore next */ |
145 | | - close(); |
146 | | - } |
147 | | - }, |
148 | | - }); |
149 | | - |
150 | | - let headers = new Headers(init.headers); |
151 | | - if ( |
152 | | - headers.has('Content-Type') && |
153 | | - !/^text\/event-stream/.test(headers.get('Content-Type')!) |
154 | | - ) { |
155 | | - console.warn( |
156 | | - 'Overriding Content-Type header to `text/event-stream; charset=utf-8`' |
157 | | - ); |
158 | | - } |
159 | | - if ( |
160 | | - headers.has('Cache-Control') && |
161 | | - headers.get('Cache-Control') !== 'no-cache' |
162 | | - ) { |
163 | | - console.warn('Overriding Cache-Control header to `no-cache`'); |
164 | | - } |
165 | | - if (headers.has('Connection') && headers.get('Connection') !== 'keep-alive') { |
166 | | - console.warn('Overriding Connection header to `keep-alive`'); |
167 | | - } |
168 | | - headers.set('Content-Type', 'text/event-stream; charset=utf-8'); |
169 | | - headers.set('Cache-Control', 'no-cache'); |
170 | | - headers.set('Connection', 'keep-alive'); |
171 | | - // @ts-ignore |
172 | | - return new Response(stream, { ...init, headers }); |
173 | | -}; |
174 | | - |
175 | | -export async function buildFileResponse({ |
176 | | - file, |
177 | | - acceptRanges, |
178 | | - chunkSize, |
179 | | - rangeHeader, |
180 | | - method, |
181 | | -}: { |
182 | | - file: BunFile; |
183 | | - acceptRanges: boolean; |
184 | | - chunkSize?: number; |
185 | | - rangeHeader?: string | null; |
186 | | - method: string; |
187 | | -}) { |
188 | | - let response: Response; |
189 | | - const rangeMatch = String(rangeHeader).match(/^bytes=(\d*)-(\d*)$/); |
190 | | - if (acceptRanges && rangeMatch) { |
191 | | - const totalFileSize = file.size; |
192 | | - const start = parseInt(rangeMatch[1]) || 0; |
193 | | - let end = parseInt(rangeMatch[2]); |
194 | | - if (isNaN(end)) { |
195 | | - // Initial request: some browsers use "Range: bytes=0-" |
196 | | - end = Math.min(start + (chunkSize || 3 * 1024 ** 2), totalFileSize - 1); |
197 | | - } |
198 | | - if (start > totalFileSize - 1) { |
199 | | - return new Response('416 Range not satisfiable', { status: 416 }); |
200 | | - } |
201 | | - // Bun has a bug when setting content-length and content-range automatically |
202 | | - // so convert file to buffer |
203 | | - let buffer = await file.arrayBuffer(); |
204 | | - let status = 200; |
205 | | - // the range is less than the entire file |
206 | | - if (end - 1 < totalFileSize) { |
207 | | - buffer = buffer.slice(start, end + 1); |
208 | | - status = 206; |
209 | | - } |
210 | | - response = new Response(buffer, { status }); |
211 | | - if (!response.headers.has('Content-Type')) { |
212 | | - response.headers.set('Content-Type', 'application/octet-stream'); |
213 | | - } |
214 | | - response.headers.set('Content-Length', String(buffer.byteLength)); |
215 | | - response.headers.set( |
216 | | - 'Content-Range', |
217 | | - `bytes ${start}-${end}/${totalFileSize}` |
218 | | - ); |
219 | | - } else { |
220 | | - let body: null | ArrayBuffer; |
221 | | - if (method === 'HEAD') { |
222 | | - body = null; |
223 | | - } else { |
224 | | - body = await file.arrayBuffer(); |
225 | | - } |
226 | | - response = new Response(body, { |
227 | | - headers: { |
228 | | - 'Content-Length': String(body ? body.byteLength : 0), |
229 | | - 'Content-Type': getMimeType(file), |
230 | | - }, |
231 | | - status: method === 'HEAD' ? 204 : 200, |
232 | | - }); |
233 | | - } |
234 | | - if (acceptRanges) { |
235 | | - response.headers.set('Accept-Ranges', 'bytes'); |
236 | | - } |
237 | | - return response; |
238 | | -} |
0 commit comments