/** * Compression middleware for HTTP responses */ import type { TCompressionAlgorithm } from '../utils/index.js'; import { selectEncoding, isCompressible } from '../utils/index.js'; import { getCompressionProvider } from './compression.runtime.js'; // ============================================================================= // Configuration Interface // ============================================================================= /** * Compression configuration */ export interface ICompressionConfig { /** Enable compression (default: true) */ enabled?: boolean; /** Preferred algorithms in order (default: ['br', 'gzip']) */ algorithms?: TCompressionAlgorithm[]; /** Minimum size in bytes to compress (default: 1024) */ threshold?: number; /** Compression level (1-11 for brotli, 1-9 for gzip, default: 4 for brotli, 6 for gzip) */ level?: number; /** MIME types to compress (default: text/*, application/json, etc.) */ compressibleTypes?: string[]; /** Skip compression for these paths (glob patterns) */ exclude?: string[]; } /** * Default compression configuration */ export const DEFAULT_COMPRESSION_CONFIG: Required = { enabled: true, algorithms: ['br', 'gzip'], threshold: 1024, level: 4, compressibleTypes: [], // Empty means use defaults from isCompressible exclude: [], }; /** * Normalize compression config from boolean or partial config */ export function normalizeCompressionConfig( config: ICompressionConfig | boolean | undefined ): ICompressionConfig { if (config === false) { return { enabled: false }; } if (config === true || config === undefined) { return { ...DEFAULT_COMPRESSION_CONFIG }; } return { ...DEFAULT_COMPRESSION_CONFIG, ...config, }; } // ============================================================================= // Compression Helpers // ============================================================================= /** * Check if response should be compressed */ export function shouldCompressResponse( response: Response, request: Request, config: ICompressionConfig ): boolean { // Disabled if (config.enabled === false) { return false; } // Already compressed if (response.headers.has('Content-Encoding')) { return false; } // No body to compress if (!response.body) { return false; } // Check content type const contentType = response.headers.get('Content-Type'); const customTypes = config.compressibleTypes?.length ? config.compressibleTypes : undefined; if (!isCompressible(contentType, customTypes)) { return false; } // Check size threshold const contentLength = response.headers.get('Content-Length'); if (contentLength) { const size = parseInt(contentLength, 10); if (size < (config.threshold ?? DEFAULT_COMPRESSION_CONFIG.threshold)) { return false; } } // Check excluded paths if (config.exclude?.length) { const url = new URL(request.url); const pathname = url.pathname; for (const pattern of config.exclude) { if (matchGlobPattern(pattern, pathname)) { return false; } } } // Check client accepts compression const acceptEncoding = request.headers.get('Accept-Encoding'); if (!acceptEncoding) { return false; } return true; } /** * Simple glob pattern matching (supports * and **) */ function matchGlobPattern(pattern: string, path: string): boolean { const regexPattern = pattern .replace(/\*\*/g, '.*') .replace(/\*/g, '[^/]*') .replace(/\?/g, '.'); const regex = new RegExp(`^${regexPattern}$`); return regex.test(path); } /** * Select the best compression algorithm based on client and server support */ export function selectCompressionAlgorithm( request: Request, config: ICompressionConfig ): TCompressionAlgorithm { const acceptEncoding = request.headers.get('Accept-Encoding'); const serverAlgorithms = config.algorithms ?? DEFAULT_COMPRESSION_CONFIG.algorithms; // Get runtime-supported algorithms const provider = getCompressionProvider(); const runtimeAlgorithms = provider.getSupportedAlgorithms(); // Filter server config to only include runtime-supported algorithms const supported = serverAlgorithms.filter((alg) => runtimeAlgorithms.includes(alg) ); if (supported.length === 0) { return 'identity'; } return selectEncoding(acceptEncoding, supported); } /** * Compress a Response object */ export async function compressResponse( response: Response, algorithm: TCompressionAlgorithm, level?: number ): Promise { if (algorithm === 'identity' || !response.body) { return response; } const provider = getCompressionProvider(); // Clone headers and modify const headers = new Headers(response.headers); headers.set('Content-Encoding', algorithm); headers.set('Vary', appendVaryHeader(headers.get('Vary'), 'Accept-Encoding')); headers.delete('Content-Length'); // Size changes after compression // Compress the body stream const compressedBody = provider.compressStream(response.body, algorithm, level); return new Response(compressedBody, { status: response.status, statusText: response.statusText, headers, }); } /** * Append value to Vary header */ function appendVaryHeader(existing: string | null, value: string): string { if (!existing) { return value; } const values = existing.split(',').map((v) => v.trim().toLowerCase()); if (values.includes(value.toLowerCase())) { return existing; } return `${existing}, ${value}`; } // ============================================================================= // Full-Body Compression (for small responses) // ============================================================================= /** * Compress entire response body at once (for small/known-size responses) */ export async function compressResponseBody( body: Uint8Array, algorithm: TCompressionAlgorithm, level?: number ): Promise { if (algorithm === 'identity') { return body; } const provider = getCompressionProvider(); return provider.compress(body, algorithm, level); }