167 lines
4.2 KiB
TypeScript
167 lines
4.2 KiB
TypeScript
/**
|
|
* HTTP encoding utilities for Accept-Encoding parsing and compression algorithm selection
|
|
*/
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Supported compression algorithms
|
|
*/
|
|
export type TCompressionAlgorithm = 'br' | 'gzip' | 'deflate' | 'identity';
|
|
|
|
/**
|
|
* Parsed encoding preference with quality value
|
|
*/
|
|
export interface IEncodingPreference {
|
|
encoding: string;
|
|
q: number;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Accept-Encoding Parsing
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Parse Accept-Encoding header with quality values
|
|
*
|
|
* @example
|
|
* parseAcceptEncoding('gzip, deflate, br;q=1.0, identity;q=0.5')
|
|
* // Returns: [{ encoding: 'br', q: 1.0 }, { encoding: 'gzip', q: 1.0 }, { encoding: 'deflate', q: 1.0 }, { encoding: 'identity', q: 0.5 }]
|
|
*/
|
|
export function parseAcceptEncoding(header: string | null): IEncodingPreference[] {
|
|
if (!header) {
|
|
return [{ encoding: 'identity', q: 1.0 }];
|
|
}
|
|
|
|
const preferences: IEncodingPreference[] = [];
|
|
|
|
for (const part of header.split(',')) {
|
|
const trimmed = part.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const [encoding, ...params] = trimmed.split(';');
|
|
let q = 1.0;
|
|
|
|
for (const param of params) {
|
|
const [key, value] = param.trim().split('=');
|
|
if (key?.toLowerCase() === 'q' && value) {
|
|
q = parseFloat(value) || 0;
|
|
}
|
|
}
|
|
|
|
if (q > 0) {
|
|
preferences.push({ encoding: encoding.trim().toLowerCase(), q });
|
|
}
|
|
}
|
|
|
|
// Sort by quality (descending)
|
|
return preferences.sort((a, b) => b.q - a.q);
|
|
}
|
|
|
|
/**
|
|
* Select best encoding from client preferences and server support
|
|
*/
|
|
export function selectEncoding(
|
|
acceptEncoding: string | null,
|
|
supported: TCompressionAlgorithm[]
|
|
): TCompressionAlgorithm {
|
|
const preferences = parseAcceptEncoding(acceptEncoding);
|
|
|
|
for (const pref of preferences) {
|
|
if (pref.encoding === '*') {
|
|
// Wildcard - use first supported (brotli preferred)
|
|
return supported[0] ?? 'identity';
|
|
}
|
|
if (supported.includes(pref.encoding as TCompressionAlgorithm)) {
|
|
return pref.encoding as TCompressionAlgorithm;
|
|
}
|
|
}
|
|
|
|
return 'identity';
|
|
}
|
|
|
|
// =============================================================================
|
|
// Compressibility Check
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Default MIME type patterns that should be compressed
|
|
*/
|
|
const DEFAULT_COMPRESSIBLE_TYPES = [
|
|
'text/',
|
|
'application/json',
|
|
'application/javascript',
|
|
'application/xml',
|
|
'application/xhtml+xml',
|
|
'application/rss+xml',
|
|
'application/atom+xml',
|
|
'application/x-javascript',
|
|
'application/ld+json',
|
|
'application/manifest+json',
|
|
'application/vnd.api+json',
|
|
'image/svg+xml',
|
|
'image/x-icon',
|
|
'font/ttf',
|
|
'font/otf',
|
|
];
|
|
|
|
/**
|
|
* MIME types that should never be compressed (already compressed)
|
|
*/
|
|
const NEVER_COMPRESS = [
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/gif',
|
|
'image/webp',
|
|
'image/avif',
|
|
'video/',
|
|
'audio/',
|
|
'application/zip',
|
|
'application/gzip',
|
|
'application/x-gzip',
|
|
'application/x-bzip2',
|
|
'application/x-7z-compressed',
|
|
'application/x-rar-compressed',
|
|
'application/wasm',
|
|
'font/woff',
|
|
'font/woff2',
|
|
];
|
|
|
|
/**
|
|
* Check if content type should be compressed
|
|
*/
|
|
export function isCompressible(
|
|
contentType: string | null,
|
|
customTypes?: string[]
|
|
): boolean {
|
|
if (!contentType) return false;
|
|
|
|
const lowerType = contentType.toLowerCase().split(';')[0].trim();
|
|
|
|
// Never compress already-compressed formats
|
|
for (const pattern of NEVER_COMPRESS) {
|
|
if (pattern.endsWith('/')) {
|
|
if (lowerType.startsWith(pattern)) return false;
|
|
} else {
|
|
if (lowerType === pattern) return false;
|
|
}
|
|
}
|
|
|
|
const types = customTypes ?? DEFAULT_COMPRESSIBLE_TYPES;
|
|
|
|
return types.some(pattern =>
|
|
pattern.endsWith('/')
|
|
? lowerType.startsWith(pattern)
|
|
: lowerType.includes(pattern)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get default compressible types (for configuration reference)
|
|
*/
|
|
export function getDefaultCompressibleTypes(): string[] {
|
|
return [...DEFAULT_COMPRESSIBLE_TYPES];
|
|
}
|