UNPKG

bunshine

Version:

A Bun HTTP & WebSocket server that is a little ray of sunshine.

154 lines (149 loc) 4.59 kB
import Context from '../../Context/Context'; import parseRangeHeader from '../../parseRangeHeader/parseRangeHeader'; import { FileLike, getFileBaseName, getFileChunk, getFileFull, getFileMime, getFileStats, isFileLike, } from './file-io'; import isModifiedSince from './isModifiedSince'; export type FileResponseOptions = { chunkSize?: number; disposition?: 'inline' | 'attachment' | 'form-data'; acceptRanges?: boolean; sendLastModified?: boolean; headers?: HeadersInit; }; const headersWeAdd = [ 'content-type', 'content-length', 'x-content-length', 'content-range', 'accept-ranges', 'last-modified', 'content-disposition', ]; export default async function file( this: Context, fileLike: FileLike, fileOptions: FileResponseOptions = {} ) { const resp = await getFileResponse(this.request, fileLike, fileOptions); if (fileOptions.disposition && /^attachment$/.test(fileOptions.disposition)) { const filename = getFileBaseName(fileLike); let disposition = 'attachment'; if (filename) { disposition += `; filename="${filename}"`; } resp.headers.set('Content-Disposition', disposition); } else if ( fileOptions.disposition && /^inline|form-data$/.test(fileOptions.disposition) ) { resp.headers.set('Content-Disposition', fileOptions.disposition); } // optionally add headers if (fileOptions.headers) { const headers = new Headers(fileOptions.headers); for (const [name, value] of headers.entries()) { if (headersWeAdd.includes(name.toLowerCase())) { resp.headers.set(name, value); } else { resp.headers.append(name, value); } } } return resp; } async function getFileResponse( request: Request, file: FileLike, fileOptions: FileResponseOptions ) { if (!file || !isFileLike(file)) { return new Response('File not found', { status: 404 }); } const { size, lastModified, doesExist } = await getFileStats(file); if (!doesExist) { return new Response('File not found', { status: 404 }); } if (lastModified instanceof Date && !isModifiedSince(request, lastModified)) { return new Response(null, { status: 304 }); } const supportRangedRequest = fileOptions.acceptRanges !== false; const maybeModifiedHeader: ResponseInit['headers'] = lastModified instanceof Date && fileOptions.sendLastModified !== false ? { 'Last-Modified': lastModified.toUTCString() } : {}; const maybeAcceptRangesHeader: ResponseInit['headers'] = supportRangedRequest ? { 'Accept-Ranges': 'bytes' } : {}; if (request.method === 'HEAD') { const mime = await getFileMime(file); return new Response(null, { status: 200, headers: { 'Content-Type': mime, 'Content-Length': String(size), ...maybeModifiedHeader, ...maybeAcceptRangesHeader, // Currently Bun overrides the Content-Length header to be 0 // see https://github.com/oven-sh/bun/issues/15355 'X-Content-Length': String(size), }, }); } const rangeHeader = request.headers.get('Range'); if (supportRangedRequest && rangeHeader) { const { slice, status } = parseRangeHeader({ rangeHeader: rangeHeader, totalFileSize: size || 0, defaultChunkSize: fileOptions.chunkSize, }); if (status === 416) { return new Response( `Requested range is not satisfiable. Total size is ${size} bytes.`, { status: 416, headers: { 'Content-Type': await getFileMime(file), 'Content-Range': `bytes */${size}`, ...maybeModifiedHeader, 'Accept-Ranges': 'bytes', // Content-length set automatically based on the string length of error message }, } ); } if (slice) { const buffer = await getFileChunk( file, slice.start, slice.end - slice.start + 1 ); return new Response(buffer, { status: 206, headers: { 'Content-Type': await getFileMime(file), // Content-length will get sent automatically ...maybeModifiedHeader, 'Content-Range': `bytes ${slice.start}-${slice.end}/${size}`, 'Accept-Ranges': 'bytes', }, }); } } const buffer = await getFileFull(file); return new Response(buffer, { status: 200, headers: { 'Content-Type': await getFileMime(file, buffer), // Content-length will get sent automatically ...maybeModifiedHeader, ...maybeAcceptRangesHeader, }, }); }