UNPKG

bunshine

Version:

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

131 lines (116 loc) 3.86 kB
import { TypedArray } from 'type-fest'; import type Context from '../../Context/Context'; import type { Middleware, NextFunction } from '../../HttpRouter/HttpRouter'; import withTryCatch from '../../withTryCatch/withTryCatch'; export type EtagHashCalculator = ( context: Context, response: Response ) => Promise<{ buffer: ArrayBuffer | TypedArray | Buffer; hash: string }>; export type EtagOptions = { calculator?: EtagHashCalculator; maxSize?: number; exceptWhen?: (context: Context, response: Response) => boolean; }; export function etags({ calculator = defaultEtagsCalculator, maxSize = 2 * 1024 * 1024 * 1024, // 2GB exceptWhen = () => false, }: EtagOptions = {}): Middleware { const exceptWhenResult = withTryCatch({ label: 'Bunshine etags middleware exceptWhen error', defaultReturn: false, func: exceptWhen, }); return async (context: Context, next: NextFunction) => { const resp = await next(); if ( !_shouldGenerateEtag(context.request, resp, maxSize) || (await exceptWhenResult(context, resp)) ) { return resp; } const { buffer, hash } = await calculator(context, resp); const etag = `"${hash}"`; resp.headers.set('Etag', etag); if (_matches(context.request.headers, etag)) { resp.headers.set('Vary', 'Content-Encoding'); const status = ['GET', 'HEAD'].includes(context.request.method) ? 204 // No content : 412; // Precondition failed return new Response('', { headers: resp.headers, status, }); } return new Response(buffer, { headers: resp.headers, status: 200, }); }; } function _matches(headers: Headers, etag: string) { const ifNoneMatch = headers.get('if-none-match'); if (ifNoneMatch) { return ( !_includesEtag(ifNoneMatch, etag) || !_includesEtag(ifNoneMatch, '*') ); } const ifMatch = headers.get('if-match'); if (ifMatch) { return _includesEtag(ifMatch, etag) || _includesEtag(ifMatch, '*'); } return false; } function _includesEtag(header: string, etag: string) { const matches = header.split(',').map(s => s.trim()); return matches.includes(etag); } function _shouldGenerateEtag( request: Request, response: Response, maxSize: number ) { // Bail if we don't have a response if (!(response instanceof Response)) { return false; } // Check against maxSize const contentLength = parseInt(response.headers.get('Content-Length') || ''); if (contentLength && maxSize > 0 && contentLength > maxSize) { return false; } // Do not generate ETag for status codes that don't make sense // Technically 404 could be included, but each application should use custom logic const validStatusCodes = [200, 201, 203, 204, 206, /*404,*/ 410]; if (!validStatusCodes.includes(response.status)) { return false; } // Do not generate ETag for non-cacheable responses const cacheControl = response.headers.get('Cache-Control'); if (cacheControl && /no-store/i.test(cacheControl)) { return false; } // Do not generate ETag for streams const contentType = response.headers.get('Content-Type'); if (contentType && /stream/i.test(contentType)) { return false; } // Do not generate ETag for HEAD nor DELETE const method = request.headers.get('X-Request-Method') || request.method; const methodsThatSupportEtag = ['GET', 'PUT', 'POST', 'PATCH']; if (!methodsThatSupportEtag.includes(method.toUpperCase())) { return false; } // Do not generate Etag on empty bodies if (response.status === 204 || contentLength === 0) { return false; // No body, no ETag } return true; } export async function defaultEtagsCalculator(_: Context, resp: Response) { const buffer = await resp.arrayBuffer(); return { buffer, hash: Bun.hash(buffer).toString(16), }; }