feat(compression): Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support

This commit is contained in:
2025-12-05 12:27:41 +00:00
parent cef6ce750e
commit 57d7fd6483
17 changed files with 1116 additions and 18 deletions

View File

@@ -0,0 +1,229 @@
/**
* 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);
}

View File

@@ -0,0 +1,309 @@
/**
* Cross-runtime compression abstraction
*
* Uses:
* - Node.js: zlib module (native, full brotli support)
* - Deno/Bun: CompressionStream API (Web Standard)
*/
import * as plugins from '../plugins.js';
import type { TCompressionAlgorithm } from '../utils/index.js';
import type { Transform } from 'stream';
// =============================================================================
// Compression Provider Interface
// =============================================================================
export interface ICompressionProvider {
/**
* Compress data to Uint8Array
*/
compress(
data: Uint8Array,
algorithm: TCompressionAlgorithm,
level?: number
): Promise<Uint8Array>;
/**
* Compress a ReadableStream
*/
compressStream(
stream: ReadableStream<Uint8Array>,
algorithm: TCompressionAlgorithm,
level?: number
): ReadableStream<Uint8Array>;
/**
* Get supported algorithms for this runtime
*/
getSupportedAlgorithms(): TCompressionAlgorithm[];
}
// =============================================================================
// Node.js Compression Provider (using zlib)
// =============================================================================
class NodeCompressionProvider implements ICompressionProvider {
getSupportedAlgorithms(): TCompressionAlgorithm[] {
return ['br', 'gzip', 'deflate'];
}
async compress(
data: Uint8Array,
algorithm: TCompressionAlgorithm,
level?: number
): Promise<Uint8Array> {
if (algorithm === 'identity') {
return data;
}
return new Promise((resolve, reject) => {
const buffer = Buffer.from(data);
const callback = (err: Error | null, result: Buffer) => {
if (err) reject(err);
else resolve(new Uint8Array(result));
};
switch (algorithm) {
case 'br':
plugins.zlib.brotliCompress(
buffer,
{
params: {
[plugins.zlib.constants.BROTLI_PARAM_QUALITY]: level ?? 4,
},
},
callback
);
break;
case 'gzip':
plugins.zlib.gzip(buffer, { level: level ?? 6 }, callback);
break;
case 'deflate':
plugins.zlib.deflate(buffer, { level: level ?? 6 }, callback);
break;
default:
resolve(data);
}
});
}
compressStream(
stream: ReadableStream<Uint8Array>,
algorithm: TCompressionAlgorithm,
level?: number
): ReadableStream<Uint8Array> {
if (algorithm === 'identity') {
return stream;
}
// Create zlib transform stream
let zlibStream: Transform;
switch (algorithm) {
case 'br':
zlibStream = plugins.zlib.createBrotliCompress({
params: {
[plugins.zlib.constants.BROTLI_PARAM_QUALITY]: level ?? 4,
},
});
break;
case 'gzip':
zlibStream = plugins.zlib.createGzip({ level: level ?? 6 });
break;
case 'deflate':
zlibStream = plugins.zlib.createDeflate({ level: level ?? 6 });
break;
default:
return stream;
}
// Convert Web ReadableStream to Node stream, compress, and back
const reader = stream.getReader();
return new ReadableStream<Uint8Array>({
async start(controller) {
// Pipe data through zlib
zlibStream.on('data', (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk));
});
zlibStream.on('end', () => {
controller.close();
});
zlibStream.on('error', (err) => {
controller.error(err);
});
// Read from source and write to zlib
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
zlibStream.end();
break;
}
zlibStream.write(Buffer.from(value));
}
} catch (err) {
controller.error(err);
}
},
cancel() {
reader.cancel();
zlibStream.destroy();
},
});
}
}
// =============================================================================
// Web Standard Compression Provider (Deno/Bun/Browser)
// =============================================================================
class WebStandardCompressionProvider implements ICompressionProvider {
private brotliSupported: boolean | null = null;
private checkBrotliSupport(): boolean {
if (this.brotliSupported === null) {
try {
// Try to create a brotli stream - not all runtimes support it
new CompressionStream('deflate');
// Note: CompressionStream doesn't support 'br' in most runtimes yet
this.brotliSupported = false;
} catch {
this.brotliSupported = false;
}
}
return this.brotliSupported;
}
getSupportedAlgorithms(): TCompressionAlgorithm[] {
// CompressionStream supports gzip and deflate in most runtimes
// Brotli support is limited
const algorithms: TCompressionAlgorithm[] = ['gzip', 'deflate'];
if (this.checkBrotliSupport()) {
algorithms.unshift('br');
}
return algorithms;
}
async compress(
data: Uint8Array,
algorithm: TCompressionAlgorithm,
_level?: number
): Promise<Uint8Array> {
if (algorithm === 'identity') {
return data;
}
// Map algorithm to CompressionStream format
// Brotli falls back to gzip if not supported
let format: CompressionFormat;
if (algorithm === 'br') {
format = this.checkBrotliSupport() ? ('br' as CompressionFormat) : 'gzip';
} else {
format = algorithm as CompressionFormat;
}
try {
const stream = new CompressionStream(format);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Write data and close (cast for type compatibility)
await writer.write(data as unknown as BufferSource);
await writer.close();
// Collect compressed chunks
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
// Concatenate chunks
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
} catch {
// Compression failed, return original
return data;
}
}
compressStream(
stream: ReadableStream<Uint8Array>,
algorithm: TCompressionAlgorithm,
_level?: number
): ReadableStream<Uint8Array> {
if (algorithm === 'identity') {
return stream;
}
// Map algorithm to CompressionStream format
let format: CompressionFormat;
if (algorithm === 'br') {
format = this.checkBrotliSupport() ? ('br' as CompressionFormat) : 'gzip';
} else {
format = algorithm as CompressionFormat;
}
try {
const compressionStream = new CompressionStream(format);
// Use type assertion for cross-runtime compatibility
return stream.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
} catch {
// Compression not supported, return original stream
return stream;
}
}
}
// =============================================================================
// Factory & Singleton
// =============================================================================
let compressionProvider: ICompressionProvider | null = null;
/**
* Create appropriate compression provider for the current runtime
*/
export function createCompressionProvider(): ICompressionProvider {
// Check for Node.js (has zlib module)
if (typeof process !== 'undefined' && process.versions?.node) {
return new NodeCompressionProvider();
}
// Check for Deno
if (typeof (globalThis as any).Deno !== 'undefined') {
return new WebStandardCompressionProvider();
}
// Check for Bun
if (typeof (globalThis as any).Bun !== 'undefined') {
return new WebStandardCompressionProvider();
}
// Fallback to Web Standard
return new WebStandardCompressionProvider();
}
/**
* Get the singleton compression provider
*/
export function getCompressionProvider(): ICompressionProvider {
if (!compressionProvider) {
compressionProvider = createCompressionProvider();
}
return compressionProvider;
}

19
ts/compression/index.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Compression module exports
*/
export {
getCompressionProvider,
createCompressionProvider,
type ICompressionProvider,
} from './compression.runtime.js';
export {
shouldCompressResponse,
selectCompressionAlgorithm,
compressResponse,
compressResponseBody,
normalizeCompressionConfig,
DEFAULT_COMPRESSION_CONFIG,
type ICompressionConfig,
} from './compression.middleware.js';