feat(compression): Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support
This commit is contained in:
229
ts/compression/compression.middleware.ts
Normal file
229
ts/compression/compression.middleware.ts
Normal 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);
|
||||
}
|
||||
309
ts/compression/compression.runtime.ts
Normal file
309
ts/compression/compression.runtime.ts
Normal 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
19
ts/compression/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user