From 57d7fd6483012e2bb0fb25e9494510b22af90337 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 5 Dec 2025 12:27:41 +0000 Subject: [PATCH] feat(compression): Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support --- changelog.md | 11 + readme.hints.md | 78 ++++++ ts/00_commitinfo_data.ts | 2 +- ts/compression/compression.middleware.ts | 229 +++++++++++++++++ ts/compression/compression.runtime.ts | 309 +++++++++++++++++++++++ ts/compression/index.ts | 19 ++ ts/core/smartserve.classes.smartserve.ts | 75 +++++- ts/core/smartserve.interfaces.ts | 5 + ts/decorators/decorators.compress.ts | 115 +++++++++ ts/decorators/decorators.registry.ts | 1 + ts/decorators/decorators.types.ts | 15 ++ ts/decorators/index.ts | 4 + ts/files/file.server.ts | 91 ++++++- ts/index.ts | 3 + ts/plugins.ts | 3 +- ts/utils/index.ts | 8 + ts/utils/utils.encoding.ts | 166 ++++++++++++ 17 files changed, 1116 insertions(+), 18 deletions(-) create mode 100644 ts/compression/compression.middleware.ts create mode 100644 ts/compression/compression.runtime.ts create mode 100644 ts/compression/index.ts create mode 100644 ts/decorators/decorators.compress.ts create mode 100644 ts/utils/utils.encoding.ts diff --git a/changelog.md b/changelog.md index 71513ba..4a0e563 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-12-05 - 1.2.0 - feat(compression) +Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support + +- Introduce a cross-runtime compression provider (Node zlib + Web CompressionStream fallback) with create/get provider APIs (ts/compression/compression.runtime.ts). +- Add compression middleware utilities (normalize config, shouldCompressResponse, algorithm selection, streaming/full-body compression) and default configuration (ts/compression/compression.middleware.ts). +- Implement Accept-Encoding parsing, encoding selection, and compressibility checks (ts/utils/utils.encoding.ts) and export types/utilities from utils/index.ts. +- Add @Compress and @NoCompress decorators and route-level compression metadata support (ts/decorators/decorators.compress.ts, decorators.types.ts, registry updates, and exports). +- Integrate compression into SmartServe core: global compression config, applyCompression for custom handlers, WebDAV, static files, and route responses (ts/core/smartserve.classes.smartserve.ts, smartserve.interfaces.ts). +- Enhance FileServer to serve pre-compressed variants (.br/.gz) when available, adjust headers/ETag/Length, and avoid using pre-compressed files for range requests (ts/files/file.server.ts). +- Expose compression APIs from package entry point and export zlib via plugins for Node provider; update readme.hints.md with configuration examples and notes. + ## 2025-12-03 - 1.1.2 - fix(deps) Bump dependency versions for build and runtime tools diff --git a/readme.hints.md b/readme.hints.md index ded55b8..2808a3d 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -31,6 +31,12 @@ SmartServe is a cross-platform HTTP server for Node.js, Deno, and Bun using: - `ts/utils/utils.mime.ts` - MIME type detection - `ts/utils/utils.etag.ts` - ETag generation +### Compression +- `ts/compression/compression.runtime.ts` - Cross-runtime compression (Node.js zlib, Web CompressionStream) +- `ts/compression/compression.middleware.ts` - Compression config and helpers +- `ts/utils/utils.encoding.ts` - Accept-Encoding parsing +- `ts/decorators/decorators.compress.ts` - @Compress and @NoCompress decorators + ### Protocols - `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler - `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses) @@ -149,9 +155,81 @@ const connections = server.getWebSocketConnections(); - Bun adapter stores peer ID/tags in `ws.data` for persistence across events - Internal `_connectionCallbacks` passed to adapters for registry communication +## Compression + +SmartServe supports automatic response compression with Brotli and gzip. + +### Configuration + +```typescript +const server = new SmartServe({ + port: 3000, + + // Simple: enable with defaults (compression is ON by default) + compression: true, + + // Detailed configuration + compression: { + enabled: true, + algorithms: ['br', 'gzip'], // Brotli preferred, gzip fallback + threshold: 1024, // Don't compress < 1KB + level: 4, // Compression level + compressibleTypes: [ // Custom MIME types + 'text/', + 'application/json', + 'application/javascript', + ], + exclude: ['/api/stream/*'], // Skip certain paths + }, + + // Pre-compressed static files + static: { + root: './public', + precompressed: true, // Serve index.html.br, index.html.gz if available + }, +}); +``` + +### Per-Route Control + +```typescript +@Controller('/api') +class ApiController { + @Get('/data') + getData() { + return { large: 'json' }; // Compressed by default + } + + @Get('/stream') + @NoCompress() // Skip compression for SSE/streaming + getStream() { + return new Response(eventStream, { + headers: { 'Content-Type': 'text/event-stream' } + }); + } + + @Get('/heavy') + @Compress({ level: 11 }) // Force max brotli compression + getHeavy() { + return massiveData; + } +} +``` + +### Cross-Runtime Support + +| Runtime | Brotli | gzip | Implementation | +|---------|--------|------|----------------| +| Node.js | ✅ | ✅ | Native zlib module | +| Deno | ⚠️ | ✅ | CompressionStream API | +| Bun | ⚠️ | ✅ | CompressionStream API | + +Note: Brotli in Deno/Bun falls back to gzip if CompressionStream doesn't support it. + ## TODO - [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK) - [x] TypedRouter WebSocket integration +- [x] Brotli/gzip compression with per-route control - [ ] HTTP/2 support investigation - [ ] Performance benchmarks diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f1b5301..4c09d54 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartserve', - version: '1.1.2', + version: '1.2.0', description: 'a cross platform server module for Node, Deno and Bun' } diff --git a/ts/compression/compression.middleware.ts b/ts/compression/compression.middleware.ts new file mode 100644 index 0000000..0fc1d1b --- /dev/null +++ b/ts/compression/compression.middleware.ts @@ -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 = { + 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 { + 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 { + if (algorithm === 'identity') { + return body; + } + + const provider = getCompressionProvider(); + return provider.compress(body, algorithm, level); +} diff --git a/ts/compression/compression.runtime.ts b/ts/compression/compression.runtime.ts new file mode 100644 index 0000000..35544e2 --- /dev/null +++ b/ts/compression/compression.runtime.ts @@ -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; + + /** + * Compress a ReadableStream + */ + compressStream( + stream: ReadableStream, + algorithm: TCompressionAlgorithm, + level?: number + ): ReadableStream; + + /** + * 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 { + 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, + algorithm: TCompressionAlgorithm, + level?: number + ): ReadableStream { + 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({ + 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 { + 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, + algorithm: TCompressionAlgorithm, + _level?: number + ): ReadableStream { + 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); + } 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; +} diff --git a/ts/compression/index.ts b/ts/compression/index.ts new file mode 100644 index 0000000..0118556 --- /dev/null +++ b/ts/compression/index.ts @@ -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'; diff --git a/ts/core/smartserve.classes.smartserve.ts b/ts/core/smartserve.classes.smartserve.ts index 178124a..58f5691 100644 --- a/ts/core/smartserve.classes.smartserve.ts +++ b/ts/core/smartserve.classes.smartserve.ts @@ -18,9 +18,16 @@ import type { } from './smartserve.interfaces.js'; import { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js'; import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js'; -import { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js'; +import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js'; import { FileServer } from '../files/index.js'; import { WebDAVHandler } from '../protocols/index.js'; +import { + normalizeCompressionConfig, + shouldCompressResponse, + selectCompressionAlgorithm, + compressResponse, + type ICompressionConfig, +} from '../compression/index.js'; /** * SmartServe - Cross-platform HTTP server @@ -43,6 +50,7 @@ export class SmartServe { private customHandler: TRequestHandler | null = null; private fileServer: FileServer | null = null; private webdavHandler: WebDAVHandler | null = null; + private compressionConfig: ICompressionConfig; /** WebSocket connection registry (only active when typedRouter is set) */ private wsConnections: Map | null = null; @@ -64,6 +72,9 @@ export class SmartServe { ...options, }; + // Initialize compression config (enabled by default) + this.compressionConfig = normalizeCompressionConfig(options.compression); + // Initialize connection registry only when typedRouter is configured if (this.options.websocket?.typedRouter) { this.wsConnections = new Map(); @@ -248,7 +259,9 @@ export class SmartServe { return async (request: Request, connectionInfo: IConnectionInfo): Promise => { // Use custom handler if set if (this.customHandler) { - return this.customHandler(request, connectionInfo); + const response = await this.customHandler(request, connectionInfo); + // Apply compression to custom handler responses + return this.applyCompression(response, request); } // Parse URL and method @@ -258,7 +271,8 @@ export class SmartServe { // Handle WebDAV requests first if handler is configured if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) { try { - return await this.webdavHandler.handle(request); + const response = await this.webdavHandler.handle(request); + return this.applyCompression(response, request); } catch (error) { return this.handleError(error as Error, request); } @@ -271,7 +285,8 @@ export class SmartServe { // No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles) if (this.webdavHandler) { try { - return await this.webdavHandler.handle(request); + const response = await this.webdavHandler.handle(request); + return this.applyCompression(response, request); } catch (error) { return this.handleError(error as Error, request); } @@ -282,7 +297,8 @@ export class SmartServe { try { const staticResponse = await this.fileServer.serve(request); if (staticResponse) { - return staticResponse; + // Apply compression to static file responses + return this.applyCompression(staticResponse, request); } } catch (error) { return this.handleError(error as Error, request); @@ -304,7 +320,10 @@ export class SmartServe { const context = await this.createContext(request, url, params, connectionInfo); // Run interceptors and handler - return await this.executeRoute(route, context); + const response = await this.executeRoute(route, context); + + // Apply compression with route-specific settings + return this.applyCompression(response, request, route.compression); } catch (error) { return this.handleError(error as Error, request); } @@ -456,6 +475,50 @@ export class SmartServe { }); } + /** + * Apply compression to response if applicable + */ + private async applyCompression( + response: Response, + request: Request, + routeCompression?: IRouteCompressionOptions + ): Promise { + // Check route-level override first + if (routeCompression?.enabled === false) { + return response; + } + + // Build effective config (merge route settings with global) + const effectiveConfig: ICompressionConfig = { + ...this.compressionConfig, + }; + + // Route-level compression settings override global + if (routeCompression?.level !== undefined) { + effectiveConfig.level = routeCompression.level; + } + + // If route forces compression, ensure it's enabled + if (routeCompression?.enabled === true) { + effectiveConfig.enabled = true; + } + + // Check if compression should be applied + if (!shouldCompressResponse(response, request, effectiveConfig)) { + return response; + } + + // Select best algorithm + const algorithm = selectCompressionAlgorithm(request, effectiveConfig); + + if (algorithm === 'identity') { + return response; + } + + // Apply compression + return compressResponse(response, algorithm, effectiveConfig.level); + } + /** * Handle errors */ diff --git a/ts/core/smartserve.interfaces.ts b/ts/core/smartserve.interfaces.ts index e8588b2..e57f4c1 100644 --- a/ts/core/smartserve.interfaces.ts +++ b/ts/core/smartserve.interfaces.ts @@ -4,6 +4,7 @@ */ import type { TypedRouter } from '@api.global/typedrequest'; +import type { ICompressionConfig } from '../compression/index.js'; // ============================================================================= // HTTP Types @@ -272,6 +273,8 @@ export interface IStaticOptions { extensions?: string[]; /** Enable directory listing */ directoryListing?: boolean | IDirectoryListingOptions; + /** Serve pre-compressed files (.br, .gz) when available */ + precompressed?: boolean; } /** @@ -333,6 +336,8 @@ export interface ISmartServeOptions { keepAlive?: IKeepAliveConfig; /** Global error handler */ onError?: (error: Error, request?: Request) => Response | Promise; + /** Compression configuration (enabled by default) */ + compression?: ICompressionConfig | boolean; } // ============================================================================= diff --git a/ts/decorators/decorators.compress.ts b/ts/decorators/decorators.compress.ts new file mode 100644 index 0000000..ea54241 --- /dev/null +++ b/ts/decorators/decorators.compress.ts @@ -0,0 +1,115 @@ +/** + * Compression control decorators (@Compress, @NoCompress) + * + * These decorators allow per-route control over compression: + * - @Compress(options?) - Force compression with optional settings + * - @NoCompress() - Disable compression for this route (useful for SSE, streaming) + */ + +import { getControllerMetadata } from './decorators.metadata.js'; +import type { IRouteCompressionOptions } from './decorators.types.js'; + +/** + * Set compression options for a route + */ +function setRouteCompression( + target: any, + methodName: string | symbol, + options: IRouteCompressionOptions +): void { + const metadata = getControllerMetadata(target.constructor); + + let route = metadata.routes.get(methodName); + if (!route) { + // Create placeholder route (will be completed by @Get/@Post/etc.) + route = { + method: 'GET', + path: '', + methodName, + interceptors: [], + options: {}, + }; + metadata.routes.set(methodName, route); + } + + route.compression = options; +} + +/** + * @Compress decorator - Force compression on a route with optional settings + * + * @example + * ```typescript + * @Controller('/api') + * class ApiController { + * @Get('/heavy-data') + * @Compress({ level: 11 }) // Max brotli compression + * getHeavyData() { + * return massiveJsonData; + * } + * + * @Get('/data') + * @Compress() // Use default settings + * getData() { + * return jsonData; + * } + * } + * ``` + */ +export function Compress(options?: { level?: number }) { + return function ( + target: (this: This, ...args: Args) => Return, + context: ClassMethodDecoratorContext Return> + ) { + context.addInitializer(function (this: This) { + setRouteCompression(this, context.name, { + enabled: true, + level: options?.level, + }); + }); + return target; + }; +} + +/** + * @NoCompress decorator - Disable compression for a route + * + * Useful for: + * - Server-Sent Events (SSE) + * - Streaming responses + * - Already-compressed content + * - Real-time data where latency is critical + * + * @example + * ```typescript + * @Controller('/api') + * class EventController { + * @Get('/events') + * @NoCompress() // SSE should not be compressed + * getEvents() { + * return new Response(eventStream, { + * headers: { 'Content-Type': 'text/event-stream' } + * }); + * } + * + * @Get('/video/:id') + * @NoCompress() // Already compressed media + * getVideo() { + * return videoStream; + * } + * } + * ``` + */ +export function NoCompress() { + return function ( + target: (this: This, ...args: Args) => Return, + context: ClassMethodDecoratorContext Return> + ) { + context.addInitializer(function (this: This) { + setRouteCompression(this, context.name, { + enabled: false, + }); + }); + return target; + }; +} diff --git a/ts/decorators/decorators.registry.ts b/ts/decorators/decorators.registry.ts index 4a63a33..cfc0f66 100644 --- a/ts/decorators/decorators.registry.ts +++ b/ts/decorators/decorators.registry.ts @@ -102,6 +102,7 @@ export class ControllerRegistry { method: route.method, handler, interceptors, + compression: route.compression, }); } } diff --git a/ts/decorators/decorators.types.ts b/ts/decorators/decorators.types.ts index eb7161f..f2160b1 100644 --- a/ts/decorators/decorators.types.ts +++ b/ts/decorators/decorators.types.ts @@ -10,6 +10,7 @@ import type { IMethodOptions, IRouteOptions, } from '../core/smartserve.interfaces.js'; +import type { ICompressionConfig } from '../compression/index.js'; // ============================================================================= // Metadata Types @@ -29,6 +30,16 @@ export interface IControllerMetadata { target?: new (...args: any[]) => any; } +/** + * Route compression options + */ +export interface IRouteCompressionOptions { + /** Whether compression is enabled for this route (undefined = use default) */ + enabled?: boolean; + /** Override compression level */ + level?: number; +} + /** * Metadata for individual route methods */ @@ -45,6 +56,8 @@ export interface IRouteMetadata { methodName: string | symbol; /** Handler function reference */ handler?: Function; + /** Route-specific compression settings */ + compression?: IRouteCompressionOptions; } /** @@ -73,4 +86,6 @@ export interface ICompiledRoute { handler: (ctx: IRequestContext) => Promise; /** Combined interceptors (class + method) */ interceptors: IInterceptOptions[]; + /** Route-specific compression settings */ + compression?: IRouteCompressionOptions; } diff --git a/ts/decorators/index.ts b/ts/decorators/index.ts index cc44197..772cef3 100644 --- a/ts/decorators/index.ts +++ b/ts/decorators/index.ts @@ -2,6 +2,7 @@ export type { IControllerMetadata, IRouteMetadata, + IRouteCompressionOptions, IRegisteredController, ICompiledRoute, } from './decorators.types.js'; @@ -35,6 +36,9 @@ export { addTimestamp, } from './decorators.interceptors.js'; +// Compression decorators +export { Compress, NoCompress } from './decorators.compress.js'; + // Registry export { ControllerRegistry } from './decorators.registry.js'; diff --git a/ts/files/file.server.ts b/ts/files/file.server.ts index c643cf9..7b35ebc 100644 --- a/ts/files/file.server.ts +++ b/ts/files/file.server.ts @@ -10,6 +10,16 @@ import type { } from '../core/smartserve.interfaces.js'; import { getMimeType } from '../utils/utils.mime.js'; import { generateETag } from '../utils/utils.etag.js'; +import { parseAcceptEncoding } from '../utils/utils.encoding.js'; + +/** + * Pre-compressed file variant info + */ +interface IPrecompressedVariant { + path: string; + stat: plugins.fs.Stats; + encoding: string; +} /** * Static file server @@ -112,22 +122,43 @@ export class FileServer { ): Promise { const headers = new Headers(); - // Content-Type + // Check for pre-compressed variants + let actualFilePath = filePath; + let actualStat = stat; + let contentEncoding: string | undefined; + + if (this.options.precompressed) { + const variant = await this.findPrecompressedVariant(filePath, request); + if (variant) { + actualFilePath = variant.path; + actualStat = variant.stat; + contentEncoding = variant.encoding; + } + } + + // Content-Type (always use original file's MIME type) const mimeType = getMimeType(filePath); headers.set('Content-Type', mimeType); - // Content-Length - headers.set('Content-Length', stat.size.toString()); + // Content-Encoding (if serving pre-compressed) + if (contentEncoding) { + headers.set('Content-Encoding', contentEncoding); + headers.set('Vary', 'Accept-Encoding'); + } - // Last-Modified + // Content-Length (use actual file size, which may differ for compressed) + headers.set('Content-Length', actualStat.size.toString()); + + // Last-Modified (use original file's time for consistency) if (this.options.lastModified) { headers.set('Last-Modified', stat.mtime.toUTCString()); } - // ETag + // ETag (include encoding in ETag if compressed) let etag: string | undefined; if (this.options.etag) { - etag = generateETag(stat); + const baseEtag = generateETag(stat); + etag = contentEncoding ? `${baseEtag}-${contentEncoding}` : baseEtag; headers.set('ETag', etag); } @@ -153,9 +184,9 @@ export class FileServer { } } - // Handle Range requests + // Handle Range requests (don't use pre-compressed for range requests) const rangeHeader = request.headers.get('Range'); - if (rangeHeader) { + if (rangeHeader && !contentEncoding) { return this.servePartial(filePath, stat, rangeHeader, headers); } @@ -164,8 +195,8 @@ export class FileServer { return new Response(null, { status: 200, headers }); } - // Stream the file - const stream = plugins.fs.createReadStream(filePath); + // Stream the file (use actualFilePath for pre-compressed) + const stream = plugins.fs.createReadStream(actualFilePath); const readableStream = this.nodeStreamToWebStream(stream); return new Response(readableStream, { status: 200, headers }); @@ -361,6 +392,46 @@ export class FileServer { `; } + /** + * Find pre-compressed variant of a file if it exists + * Checks for .br and .gz variants based on client Accept-Encoding + */ + private async findPrecompressedVariant( + filePath: string, + request: Request + ): Promise { + const acceptEncoding = request.headers.get('Accept-Encoding'); + const preferences = parseAcceptEncoding(acceptEncoding); + + // Supported pre-compressed variants in preference order + const variants: Array<{ encoding: string; extension: string }> = [ + { encoding: 'br', extension: '.br' }, + { encoding: 'gzip', extension: '.gz' }, + ]; + + // Check variants in client preference order + for (const pref of preferences) { + const variant = variants.find((v) => v.encoding === pref.encoding); + if (!variant) continue; + + const variantPath = filePath + variant.extension; + try { + const variantStat = await plugins.fs.promises.stat(variantPath); + if (variantStat.isFile()) { + return { + path: variantPath, + stat: variantStat, + encoding: variant.encoding, + }; + } + } catch { + // Variant doesn't exist, continue to next + } + } + + return null; + } + /** * Convert Node.js stream to Web ReadableStream */ diff --git a/ts/index.ts b/ts/index.ts index da4cd90..55c881b 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -6,6 +6,9 @@ export * from './core/index.js'; // Decorator exports export * from './decorators/index.js'; +// Compression exports +export * from './compression/index.js'; + // File server exports export * from './files/index.js'; diff --git a/ts/plugins.ts b/ts/plugins.ts index f8dc606..d267b21 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -3,8 +3,9 @@ import * as path from 'path'; import * as http from 'http'; import * as https from 'https'; import * as fs from 'fs'; +import * as zlib from 'zlib'; -export { path, http, https, fs }; +export { path, http, https, fs, zlib }; // @push.rocks scope import * as smartpath from '@push.rocks/smartpath'; diff --git a/ts/utils/index.ts b/ts/utils/index.ts index 817b213..ef8e77d 100644 --- a/ts/utils/index.ts +++ b/ts/utils/index.ts @@ -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'; diff --git a/ts/utils/utils.encoding.ts b/ts/utils/utils.encoding.ts new file mode 100644 index 0000000..0383c6d --- /dev/null +++ b/ts/utils/utils.encoding.ts @@ -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]; +}