feat(compression): Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support
This commit is contained in:
@@ -1,2 +1,10 @@
|
||||
export { getMimeType, isTextMimeType } from './utils.mime.js';
|
||||
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';
|
||||
export {
|
||||
parseAcceptEncoding,
|
||||
selectEncoding,
|
||||
isCompressible,
|
||||
getDefaultCompressibleTypes,
|
||||
type TCompressionAlgorithm,
|
||||
type IEncodingPreference,
|
||||
} from './utils.encoding.js';
|
||||
|
||||
166
ts/utils/utils.encoding.ts
Normal file
166
ts/utils/utils.encoding.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
Reference in New Issue
Block a user