150 lines
4.7 KiB
JavaScript
150 lines
4.7 KiB
JavaScript
/**
|
|
* Compression Middleware for Proxy Responses
|
|
* Provides 30-70% bandwidth reduction with Brotli/Gzip
|
|
*/
|
|
import { brotliCompress, gzip, constants } from 'zlib';
|
|
import { promisify } from 'util';
|
|
import { logger } from './logger.js';
|
|
const brotliCompressAsync = promisify(brotliCompress);
|
|
const gzipAsync = promisify(gzip);
|
|
export class CompressionMiddleware {
|
|
config;
|
|
constructor(config = {}) {
|
|
this.config = {
|
|
minSize: config.minSize || 1024, // 1KB minimum
|
|
level: config.level ?? constants.BROTLI_DEFAULT_QUALITY,
|
|
preferredEncoding: config.preferredEncoding || 'br',
|
|
enableBrotli: config.enableBrotli ?? true,
|
|
enableGzip: config.enableGzip ?? true
|
|
};
|
|
}
|
|
/**
|
|
* Compress data using best available algorithm
|
|
*/
|
|
async compress(data, acceptedEncodings) {
|
|
const startTime = Date.now();
|
|
const originalSize = data.length;
|
|
// Skip compression for small payloads
|
|
if (originalSize < this.config.minSize) {
|
|
return {
|
|
compressed: data,
|
|
encoding: 'identity',
|
|
originalSize,
|
|
compressedSize: originalSize,
|
|
ratio: 1.0,
|
|
duration: 0
|
|
};
|
|
}
|
|
// Determine encoding based on accept-encoding header
|
|
const encoding = this.selectEncoding(acceptedEncodings);
|
|
let compressed;
|
|
try {
|
|
switch (encoding) {
|
|
case 'br':
|
|
compressed = await this.compressBrotli(data);
|
|
break;
|
|
case 'gzip':
|
|
compressed = await this.compressGzip(data);
|
|
break;
|
|
default:
|
|
compressed = data;
|
|
}
|
|
}
|
|
catch (error) {
|
|
logger.error('Compression failed, using uncompressed', {
|
|
error: error.message
|
|
});
|
|
compressed = data;
|
|
}
|
|
const duration = Date.now() - startTime;
|
|
const compressedSize = compressed.length;
|
|
const ratio = compressedSize / originalSize;
|
|
logger.debug('Compression complete', {
|
|
encoding,
|
|
originalSize,
|
|
compressedSize,
|
|
ratio: `${(ratio * 100).toFixed(2)}%`,
|
|
savings: `${((1 - ratio) * 100).toFixed(2)}%`,
|
|
duration
|
|
});
|
|
return {
|
|
compressed,
|
|
encoding,
|
|
originalSize,
|
|
compressedSize,
|
|
ratio,
|
|
duration
|
|
};
|
|
}
|
|
/**
|
|
* Compress using Brotli (best compression ratio)
|
|
*/
|
|
async compressBrotli(data) {
|
|
return brotliCompressAsync(data, {
|
|
params: {
|
|
[constants.BROTLI_PARAM_QUALITY]: this.config.level,
|
|
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Compress using Gzip (faster, broader support)
|
|
*/
|
|
async compressGzip(data) {
|
|
return gzipAsync(data, {
|
|
level: Math.min(this.config.level, 9) // Gzip max level is 9
|
|
});
|
|
}
|
|
/**
|
|
* Select best encoding based on client support
|
|
*/
|
|
selectEncoding(acceptedEncodings) {
|
|
if (!acceptedEncodings) {
|
|
return this.config.preferredEncoding === 'br' && this.config.enableBrotli
|
|
? 'br'
|
|
: this.config.enableGzip
|
|
? 'gzip'
|
|
: 'identity';
|
|
}
|
|
const encodings = acceptedEncodings.toLowerCase().split(',').map(e => e.trim());
|
|
// Prefer Brotli if supported and enabled
|
|
if (encodings.includes('br') && this.config.enableBrotli) {
|
|
return 'br';
|
|
}
|
|
// Fall back to Gzip if supported and enabled
|
|
if (encodings.includes('gzip') && this.config.enableGzip) {
|
|
return 'gzip';
|
|
}
|
|
return 'identity';
|
|
}
|
|
/**
|
|
* Check if compression is recommended for content type
|
|
*/
|
|
shouldCompress(contentType) {
|
|
if (!contentType)
|
|
return true;
|
|
const type = contentType.toLowerCase();
|
|
// Compressible types
|
|
const compressible = [
|
|
'text/',
|
|
'application/json',
|
|
'application/javascript',
|
|
'application/xml',
|
|
'application/x-www-form-urlencoded'
|
|
];
|
|
return compressible.some(prefix => type.includes(prefix));
|
|
}
|
|
/**
|
|
* Get compression statistics
|
|
*/
|
|
getStats() {
|
|
return {
|
|
config: this.config,
|
|
capabilities: {
|
|
brotli: this.config.enableBrotli,
|
|
gzip: this.config.enableGzip
|
|
}
|
|
};
|
|
}
|
|
}
|
|
//# sourceMappingURL=compression-middleware.js.map
|