bunshine
Version:
A Bun HTTP & WebSocket server that is a little ray of sunshine.
154 lines (149 loc) • 4.59 kB
text/typescript
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,
},
});
}