/** * @fileoverview High-performance MIME type parser and content negotiator for Cloudflare Workers * @module mimeparser * @version 2.0.0 * * A complete rewrite optimized for edge computing with security hardening, * performance optimizations, and modern JavaScript practices. * * @author Nishad Thalhath (2024 - Complete rewrite and optimization) * @author J. Chris Anderson (2009 - Initial JavaScript port) * @inspired-by Joe Gregorio's Python mimeparse library * * @license MIT * * Implements HTTP Accept header parsing and content negotiation following RFC 9110 Section 12.5.1 * @see {@link https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1} * * Features: * - Security hardened with input validation and prototype pollution prevention * - Performance optimized with native methods and efficient algorithms * - Optional in-memory caching with LRU eviction * - Zero dependencies, edge-ready * - Comprehensive error handling with typed errors */ /** * Maximum allowed length for MIME type strings to prevent DoS attacks * RFC doesn't specify a limit, but 255 chars is more than sufficient for valid use cases * @constant {number} */ const MAX_MIME_LENGTH = 255; /** * Maximum allowed length for Accept header to prevent processing extremely long headers * Most browsers send Accept headers under 300 chars, 1000 is generous * @constant {number} */ const MAX_HEADER_LENGTH = 1000; /** * Maximum number of MIME types in Accept header to process * Prevents algorithmic complexity attacks * @constant {number} */ const MAX_ACCEPT_ITEMS = 50; /** * Regular expression for validating MIME type format * Matches: type/subtype with optional wildcards * @constant {RegExp} */ const MIME_TYPE_REGEX = /^[\w\-\+\.]+\/[\w\-\+\.\*]+$/; /** * Set of restricted parameter names to prevent prototype pollution * @constant {Set} */ const RESTRICTED_PARAMS = new Set(['__proto__', 'constructor', 'prototype']); /** * Error class for MIME parsing errors * @extends Error */ export class MimeParseError extends Error { /** * @param {string} message - Error message * @param {string} [code] - Error code for programmatic handling */ constructor(message, code) { super(message); this.name = 'MimeParseError'; this.code = code; } } /** * Validates input to ensure it's a safe string * @param {*} input - Input to validate * @param {number} maxLength - Maximum allowed length * @returns {string} Validated and trimmed string * @throws {MimeParseError} If input is invalid * @private */ function validateInput(input, maxLength) { if (typeof input !== 'string') { throw new MimeParseError('Input must be a string', 'INVALID_TYPE'); } const trimmed = input.trim(); if (trimmed.length === 0) { throw new MimeParseError('Input cannot be empty', 'EMPTY_INPUT'); } if (trimmed.length > maxLength) { throw new MimeParseError( `Input exceeds maximum length of ${maxLength} characters`, 'TOO_LONG' ); } return trimmed; } /** * Safely parses a MIME type string into components * @param {string} mimeType - MIME type string (e.g., "text/html;charset=utf-8") * @returns {{type: string, subtype: string, params: Object.}} * @throws {MimeParseError} If MIME type is malformed * * @example * parseMimeType("text/html;charset=utf-8") * // Returns: {type: "text", subtype: "html", params: {charset: "utf-8"}} * * @example * parseMimeType("application/json") * // Returns: {type: "application", subtype: "json", params: {}} */ export function parseMimeType(mimeType) { const validated = validateInput(mimeType, MAX_MIME_LENGTH); // Split by semicolon to separate type from parameters const parts = validated.split(';'); const fullType = parts[0].trim(); // Handle wildcard shorthand const normalizedType = fullType === '*' ? '*/*' : fullType; // Validate MIME type format if (!MIME_TYPE_REGEX.test(normalizedType)) { throw new MimeParseError( `Invalid MIME type format: ${normalizedType}`, 'INVALID_FORMAT' ); } // Split type and subtype const typeParts = normalizedType.split('/'); if (typeParts.length !== 2) { throw new MimeParseError( `MIME type must contain exactly one slash: ${normalizedType}`, 'INVALID_FORMAT' ); } const [type, subtype] = typeParts; const params = {}; // Parse parameters efficiently for (let i = 1; i < parts.length; i++) { const param = parts[i]; const eqIndex = param.indexOf('='); if (eqIndex === -1) continue; // Skip malformed parameters const key = param.substring(0, eqIndex).trim().toLowerCase(); const value = param.substring(eqIndex + 1).trim(); // Prevent prototype pollution if (RESTRICTED_PARAMS.has(key)) { throw new MimeParseError( `Restricted parameter name: ${key}`, 'RESTRICTED_PARAM' ); } // Remove quotes if present params[key] = value.replace(/^["']|["']$/g, ''); } return { type, subtype, params }; } /** * Parses a media range and ensures it has a quality value * @param {string} range - Media range string (e.g., "text/*;q=0.8") * @returns {{type: string, subtype: string, params: Object., quality: number}} * @throws {MimeParseError} If range is invalid * * @example * parseMediaRange("text/*;q=0.8") * // Returns: {type: "text", subtype: "*", params: {q: "0.8"}, quality: 0.8} * * @example * parseMediaRange("application/json") * // Returns: {type: "application", subtype: "json", params: {q: "1"}, quality: 1.0} */ export function parseMediaRange(range) { const parsed = parseMimeType(range); // Ensure quality parameter exists and is valid let quality = 1.0; if ('q' in parsed.params) { quality = parseFloat(parsed.params.q); // Validate quality value according to RFC if (isNaN(quality) || quality < 0 || quality > 1) { quality = 1.0; parsed.params.q = '1'; } } else { parsed.params.q = '1'; } return { ...parsed, quality }; } /** * Calculates fitness score between a MIME type and a media range * Higher scores indicate better matches * @param {Object} mimeType - Parsed MIME type * @param {Object} range - Parsed media range * @returns {number} Fitness score (higher is better, -1 for no match) * @private */ function calculateFitness(mimeType, range) { const { type: mType, subtype: mSubtype, params: mParams } = mimeType; const { type: rType, subtype: rSubtype, params: rParams } = range; // Check if types match (including wildcards) const typeMatches = rType === '*' || mType === '*' || rType === mType; const subtypeMatches = rSubtype === '*' || mSubtype === '*' || rSubtype === mSubtype; if (!typeMatches || !subtypeMatches) { return -1; // No match } // Calculate fitness score let fitness = 0; // Exact type match is worth 100 points if (rType === mType && rType !== '*') { fitness += 100; } // Exact subtype match is worth 10 points if (rSubtype === mSubtype && rSubtype !== '*') { fitness += 10; } // Each matching parameter (except q) is worth 1 point for (const param in mParams) { if (param !== 'q' && rParams[param] === mParams[param]) { fitness += 1; } } return fitness; } /** * Finds the best matching media range for a MIME type * @param {string} mimeType - MIME type to match * @param {Array} parsedRanges - Array of parsed media ranges * @returns {{fitness: number, quality: number}} Best match fitness and quality * @private */ function findBestMatch(mimeType, parsedRanges) { const parsed = parseMediaRange(mimeType); let bestFitness = -1; let bestQuality = 0; for (const range of parsedRanges) { const fitness = calculateFitness(parsed, range); if (fitness > bestFitness) { bestFitness = fitness; bestQuality = range.quality; } else if (fitness === bestFitness && range.quality > bestQuality) { // Same fitness but higher quality bestQuality = range.quality; } } return { fitness: bestFitness, quality: bestQuality }; } /** * Efficiently parses an Accept header into media ranges * @param {string} header - Accept header value * @returns {Array} Array of parsed media ranges * @throws {MimeParseError} If header is invalid * @private */ function parseAcceptHeader(header) { const validated = validateInput(header, MAX_HEADER_LENGTH); const ranges = validated.split(','); if (ranges.length > MAX_ACCEPT_ITEMS) { throw new MimeParseError( `Accept header contains too many items (max ${MAX_ACCEPT_ITEMS})`, 'TOO_MANY_ITEMS' ); } const parsed = []; for (const range of ranges) { try { parsed.push(parseMediaRange(range)); } catch (e) { // Skip invalid ranges rather than failing entirely // This follows the robustness principle continue; } } return parsed; } /** * Calculates the quality of a MIME type match against an Accept header * @param {string} mimeType - MIME type to evaluate * @param {string} acceptHeader - Accept header value * @returns {number} Quality value (0-1, where 0 means no match) * @throws {MimeParseError} If inputs are invalid * * @example * quality('text/html', 'text/*;q=0.3, text/html;q=0.7') * // Returns: 0.7 * * @example * quality('application/json', 'text/*') * // Returns: 0 (no match) */ export function quality(mimeType, acceptHeader) { const ranges = parseAcceptHeader(acceptHeader); const result = findBestMatch(mimeType, ranges); return result.fitness >= 0 ? result.quality : 0; } /** * Finds the best matching MIME type from supported types based on Accept header * @param {Array} supported - Array of supported MIME types * @param {string} acceptHeader - Accept header value * @returns {string} Best matching MIME type, or empty string if no match * @throws {MimeParseError} If inputs are invalid * * @example * bestMatch(['application/json', 'text/html'], 'text/*;q=0.5, *\/*;q=0.1') * // Returns: 'text/html' * * @example * bestMatch(['application/json', 'application/xml'], 'text/*') * // Returns: '' (no match) */ export function bestMatch(supported, acceptHeader) { if (!Array.isArray(supported)) { throw new MimeParseError('Supported types must be an array', 'INVALID_TYPE'); } if (supported.length === 0) { return ''; } // Fast path: if Accept header is */*, return first supported type const trimmedHeader = acceptHeader.trim(); if (trimmedHeader === '*/*' || trimmedHeader === '') { return supported[0]; } const ranges = parseAcceptHeader(acceptHeader); if (ranges.length === 0) { return ''; } let bestType = ''; let bestFitness = -1; let bestQuality = 0; // Find best match efficiently without sorting for (const type of supported) { try { const result = findBestMatch(type, ranges); // Better fitness always wins if (result.fitness > bestFitness) { bestType = type; bestFitness = result.fitness; bestQuality = result.quality; } // Same fitness: higher quality wins else if (result.fitness === bestFitness && result.quality > bestQuality) { bestType = type; bestQuality = result.quality; } } catch (e) { // Skip invalid types continue; } } return bestQuality > 0 ? bestType : ''; } /** * Main MIME parser class with caching support * * Provides a stateful parser with optional in-memory caching for improved performance * in scenarios with repeated MIME type operations. * * @class * * @example * // Basic usage with defaults * const parser = new MimeParser(); * const contentType = parser.negotiate(['application/json'], 'application/*'); * * @example * // High-performance configuration * const parser = new MimeParser({ cache: true, cacheSize: 200 }); * * @example * // Strict mode for development * const parser = new MimeParser({ strict: true, cache: false }); */ export class MimeParser { /** * Creates a new MimeParser instance * * @param {Object} [options] - Configuration options * @param {boolean} [options.cache=true] - Enable in-memory caching of parsed results * @param {number} [options.cacheSize=100] - Maximum number of cache entries (LRU eviction) * @param {boolean} [options.strict=false] - Throw errors instead of graceful fallbacks */ constructor(options = {}) { this.options = { cache: true, cacheSize: 100, strict: false, ...options }; if (this.options.cache) { /** @private */ this.cache = new Map(); } } /** * Clears the internal cache * Useful for long-running instances or testing * * @returns {void} * * @example * parser.clearCache(); */ clearCache() { if (this.cache) { this.cache.clear(); } } /** * Gets a value from cache or computes it * Implements simple LRU eviction when cache is full * * @param {string} key - Cache key * @param {Function} compute - Function to compute value if not cached * @returns {*} Cached or computed value * @private */ _cached(key, compute) { if (!this.cache) { return compute(); } if (this.cache.has(key)) { return this.cache.get(key); } const value = compute(); // LRU eviction when cache is full if (this.cache.size >= this.options.cacheSize) { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } this.cache.set(key, value); return value; } /** * Parses a MIME type with caching * * @param {string} mimeType - MIME type to parse * @returns {Object|null} Parsed MIME type or null if invalid (in non-strict mode) * @throws {MimeParseError} If MIME type is invalid and strict mode is enabled * * @example * const parsed = parser.parse('text/html;charset=utf-8'); * // Returns: {type: 'text', subtype: 'html', params: {charset: 'utf-8'}} */ parse(mimeType) { const cacheKey = `parse:${mimeType}`; try { return this._cached(cacheKey, () => parseMimeType(mimeType)); } catch (e) { if (this.options.strict) { throw e; } return null; } } /** * Negotiates the best content type with caching * * @param {Array} supported - Supported MIME types * @param {string} acceptHeader - Accept header from request * @returns {string} Best matching MIME type or first supported type as fallback * @throws {MimeParseError} If inputs are invalid and strict mode is enabled * * @example * const contentType = parser.negotiate( * ['application/json', 'text/html'], * request.headers.get('Accept') * ); */ negotiate(supported, acceptHeader) { const cacheKey = `negotiate:${supported.join(',')}:${acceptHeader}`; try { return this._cached(cacheKey, () => bestMatch(supported, acceptHeader)); } catch (e) { if (this.options.strict) { throw e; } return supported[0] || ''; } } /** * Calculates quality of a match with caching * * @param {string} mimeType - MIME type to evaluate * @param {string} acceptHeader - Accept header * @returns {number} Quality value (0-1) * @throws {MimeParseError} If inputs are invalid and strict mode is enabled * * @example * const q = parser.quality('text/html', 'text/*;q=0.8'); * // Returns: 0.8 */ quality(mimeType, acceptHeader) { const cacheKey = `quality:${mimeType}:${acceptHeader}`; try { return this._cached(cacheKey, () => quality(mimeType, acceptHeader)); } catch (e) { if (this.options.strict) { throw e; } return 0; } } } /** * Default parser instance for convenience * Pre-configured with sensible defaults for most use cases * * @type {MimeParser} */ export const defaultParser = new MimeParser(); /** * Convenience function for content negotiation using the default parser * * This is the recommended way to use the library for simple use cases * where you don't need custom configuration. * * @param {Array} supported - Supported MIME types * @param {string} acceptHeader - Accept header from request * @returns {string} Best matching MIME type * * @example * // In a Cloudflare Worker * export default { * async fetch(request) { * const accept = request.headers.get('Accept') || '*\/*'; * const contentType = negotiate( * ['application/json', 'text/html', 'text/plain'], * accept * ); * * switch(contentType) { * case 'application/json': * return Response.json({ message: 'Hello World' }); * case 'text/html': * return new Response('

Hello World

', { * headers: { 'Content-Type': 'text/html' } * }); * default: * return new Response('Hello World', { * headers: { 'Content-Type': 'text/plain' } * }); * } * } * }; */ export function negotiate(supported, acceptHeader) { return defaultParser.negotiate(supported, acceptHeader); }