230 lines
6.0 KiB
TypeScript
230 lines
6.0 KiB
TypeScript
/**
|
|
* 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<ICompressionConfig> = {
|
|
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<Response> {
|
|
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<Uint8Array> {
|
|
if (algorithm === 'identity') {
|
|
return body;
|
|
}
|
|
|
|
const provider = getCompressionProvider();
|
|
return provider.compress(body, algorithm, level);
|
|
}
|