From fec0770d55ac22b574de0b391962e0643c508294 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 5 Dec 2025 15:38:43 +0000 Subject: [PATCH] feat(compression): Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API --- changelog.md | 11 ++ package.json | 2 +- test/test.ts | 234 +++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/compression/compression.middleware.ts | 40 ++-- ts/compression/compression.runtime.ts | 126 ++++++------ ts/core/smartserve.classes.smartserve.ts | 2 +- ts/decorators/decorators.registry.ts | 28 +++ 8 files changed, 370 insertions(+), 75 deletions(-) diff --git a/changelog.md b/changelog.md index 4a0e563..8443e15 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-12-05 - 1.3.0 - feat(compression) +Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API + +- Buffer response bodies before compressing and perform size threshold check after buffering; return uncompressed responses when below threshold. +- Set Content-Length to the compressed size and use provider.compress to produce full compressed payloads instead of streaming compression from the middleware. +- Add Deno-native brotli support via Deno.compress and use CompressionStream for gzip/deflate; brotli streaming is not attempted in web runtime. +- Pass compression threshold from SmartServe configuration into compressResponse so route/global thresholds are honored. +- Expose ControllerRegistry.addRoute and dynamicRoutes to allow adding dynamic routes without controller classes. +- Add comprehensive compression tests (gzip and brotli) using raw HTTP requests to avoid Node fetch auto-decompression; tests cover large/small responses, @Compress/@NoCompress behavior, and global compression disable. +- Change test runner invocation to use verbose mode. + ## 2025-12-05 - 1.2.0 - feat(compression) Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support diff --git a/package.json b/package.json index 353f81b..9ea4028 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "author": "Task Venture Capital GmbH", "license": "MIT", "scripts": { - "test": "(tstest test/ --web)", + "test": "(tstest test/ --verbose)", "build": "(tsbuild --web --allowimplicitany)", "buildDocs": "(tsdoc)" }, diff --git a/test/test.ts b/test/test.ts index e798966..76b4614 100644 --- a/test/test.ts +++ b/test/test.ts @@ -7,9 +7,53 @@ import { Guard, Transform, Intercept, + Compress, + NoCompress, HttpError, type IRequestContext, } from '../ts/index.js'; +import * as zlib from 'zlib'; +import * as http from 'http'; +import { promisify } from 'util'; + +const gunzip = promisify(zlib.gunzip); +const brotliDecompress = promisify(zlib.brotliDecompress); + +/** + * Make a raw HTTP request without automatic decompression + * (Node.js fetch auto-decompresses, which breaks our tests) + */ +function rawRequest(options: { + port: number; + path: string; + headers?: Record; +}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: 'localhost', + port: options.port, + path: options.path, + method: 'GET', + headers: options.headers, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + status: res.statusCode ?? 200, + headers: res.headers, + body: Buffer.concat(chunks), + }); + }); + res.on('error', reject); + } + ); + req.on('error', reject); + req.end(); + }); +} // Test controller @Route('/api') @@ -56,6 +100,48 @@ class WrappedController { } } +// Controller with compression decorators +@Route('/compression') +class CompressionController { + @Get('/large') + getLargeData() { + // Return data larger than default threshold (1024 bytes) + return { + data: 'x'.repeat(2000), + items: Array.from({ length: 50 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + description: `This is a description for item ${i} with some extra text to make it longer`, + })), + }; + } + + @Get('/small') + getSmallData() { + // Return data smaller than threshold + return { ok: true }; + } + + @Get('/no-compress') + @NoCompress() + getNoCompress() { + // Should never be compressed + return { + data: 'x'.repeat(2000), + compressed: false, + }; + } + + @Get('/force-compress') + @Compress({ level: 9 }) + getForceCompress() { + return { + data: 'y'.repeat(2000), + compressed: true, + }; + } +} + tap.test('SmartServe should create server instance', async () => { const server = new SmartServe({ port: 3456 }); expect(server).toBeInstanceOf(SmartServe); @@ -205,4 +291,152 @@ tap.test('HttpError should create proper responses', async () => { expect(body.details.id).toEqual('123'); }); +// ============================================================================ +// Compression Tests +// ============================================================================ + +tap.test('Compression should apply gzip when Accept-Encoding is sent', async () => { + const server = new SmartServe({ port: 3470 }); + server.register(CompressionController); + await server.start(); + + try { + const response = await rawRequest({ + port: 3470, + path: '/compression/large', + headers: { 'Accept-Encoding': 'gzip' }, + }); + + expect(response.status).toEqual(200); + expect(response.headers['content-encoding']).toEqual('gzip'); + expect(response.headers['vary']).toInclude('Accept-Encoding'); + + // Decompress and verify content + const decompressed = await gunzip(response.body); + const data = JSON.parse(decompressed.toString()); + + expect(data.data).toStartWith('xxx'); + expect(data.items.length).toEqual(50); + } finally { + await server.stop(); + } +}); + +tap.test('Compression should apply brotli when preferred', async () => { + const server = new SmartServe({ port: 3471 }); + server.register(CompressionController); + await server.start(); + + try { + const response = await rawRequest({ + port: 3471, + path: '/compression/large', + headers: { 'Accept-Encoding': 'br, gzip' }, + }); + + expect(response.status).toEqual(200); + expect(response.headers['content-encoding']).toEqual('br'); + + // Decompress and verify content + const decompressed = await brotliDecompress(response.body); + const data = JSON.parse(decompressed.toString()); + + expect(data.data).toStartWith('xxx'); + } finally { + await server.stop(); + } +}); + +tap.test('Compression should skip small responses', async () => { + const server = new SmartServe({ port: 3472 }); + server.register(CompressionController); + await server.start(); + + try { + const response = await rawRequest({ + port: 3472, + path: '/compression/small', + headers: { 'Accept-Encoding': 'gzip, br' }, + }); + + expect(response.status).toEqual(200); + // Small response should NOT be compressed + expect(response.headers['content-encoding']).toBeUndefined(); + + const data = JSON.parse(response.body.toString()); + expect(data.ok).toBeTrue(); + } finally { + await server.stop(); + } +}); + +tap.test('Compression should respect @NoCompress decorator', async () => { + const server = new SmartServe({ port: 3473 }); + server.register(CompressionController); + await server.start(); + + try { + const response = await rawRequest({ + port: 3473, + path: '/compression/no-compress', + headers: { 'Accept-Encoding': 'gzip, br' }, + }); + + expect(response.status).toEqual(200); + // Should NOT be compressed due to @NoCompress + expect(response.headers['content-encoding']).toBeUndefined(); + + const data = JSON.parse(response.body.toString()); + expect(data.compressed).toBeFalse(); + } finally { + await server.stop(); + } +}); + +tap.test('Compression should skip when Accept-Encoding not sent', async () => { + const server = new SmartServe({ port: 3474 }); + server.register(CompressionController); + await server.start(); + + try { + const response = await rawRequest({ + port: 3474, + path: '/compression/large', + // No Accept-Encoding header + }); + + expect(response.status).toEqual(200); + // Should NOT be compressed when client doesn't accept it + expect(response.headers['content-encoding']).toBeUndefined(); + + const data = JSON.parse(response.body.toString()); + expect(data.data).toStartWith('xxx'); + } finally { + await server.stop(); + } +}); + +tap.test('Compression can be disabled globally', async () => { + const server = new SmartServe({ + port: 3475, + compression: false, + }); + server.register(CompressionController); + await server.start(); + + try { + const response = await rawRequest({ + port: 3475, + path: '/compression/large', + headers: { 'Accept-Encoding': 'gzip, br' }, + }); + + expect(response.status).toEqual(200); + // Should NOT be compressed when globally disabled + expect(response.headers['content-encoding']).toBeUndefined(); + } finally { + await server.stop(); + } +}); + export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4c09d54..c56669a 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.2.0', + version: '1.3.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 index 0fc1d1b..0faa3d8 100644 --- a/ts/compression/compression.middleware.ts +++ b/ts/compression/compression.middleware.ts @@ -68,7 +68,8 @@ export function normalizeCompressionConfig( // ============================================================================= /** - * Check if response should be compressed + * Check if response should be compressed (preliminary check) + * Note: Final threshold check happens in compressResponse after buffering */ export function shouldCompressResponse( response: Response, @@ -97,15 +98,6 @@ export function shouldCompressResponse( 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); @@ -166,28 +158,44 @@ export function selectCompressionAlgorithm( /** * Compress a Response object + * Uses buffered compression for reliability (streaming can have flushing issues) */ export async function compressResponse( response: Response, algorithm: TCompressionAlgorithm, - level?: number + level?: number, + threshold?: number ): Promise { if (algorithm === 'identity' || !response.body) { return response; } + // Read the entire body first (required for proper compression) + const originalBody = new Uint8Array(await response.arrayBuffer()); + + // Check threshold - if body is too small, return uncompressed + const effectiveThreshold = threshold ?? DEFAULT_COMPRESSION_CONFIG.threshold; + if (originalBody.byteLength < effectiveThreshold) { + // Return original response with the body we read + return new Response(originalBody as unknown as BodyInit, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + const provider = getCompressionProvider(); + // Compress the body + const compressedBody = await provider.compress(originalBody, algorithm, level); + // 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 + headers.set('Content-Length', compressedBody.byteLength.toString()); - // Compress the body stream - const compressedBody = provider.compressStream(response.body, algorithm, level); - - return new Response(compressedBody, { + return new Response(compressedBody as unknown as BodyInit, { status: response.status, statusText: response.statusText, headers, diff --git a/ts/compression/compression.runtime.ts b/ts/compression/compression.runtime.ts index 35544e2..d521793 100644 --- a/ts/compression/compression.runtime.ts +++ b/ts/compression/compression.runtime.ts @@ -164,29 +164,38 @@ class NodeCompressionProvider implements ICompressionProvider { // ============================================================================= class WebStandardCompressionProvider implements ICompressionProvider { - private brotliSupported: boolean | null = null; + private _brotliSupported: boolean | null = null; + private _isDeno: boolean; - 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; + constructor() { + this._isDeno = typeof (globalThis as any).Deno !== 'undefined'; + } + + /** + * Check if brotli is supported via Deno.compress API + */ + private hasDenoBrotli(): boolean { + if (this._brotliSupported === null) { + if (this._isDeno) { + // Deno 1.37+ has Deno.compress/decompress with brotli support + const Deno = (globalThis as any).Deno; + this._brotliSupported = typeof Deno?.compress === 'function'; + } else { + this._brotliSupported = false; } } - return this.brotliSupported; + return this._brotliSupported; } getSupportedAlgorithms(): TCompressionAlgorithm[] { - // CompressionStream supports gzip and deflate in most runtimes - // Brotli support is limited + // CompressionStream supports gzip and deflate const algorithms: TCompressionAlgorithm[] = ['gzip', 'deflate']; - if (this.checkBrotliSupport()) { + + // Deno has native brotli via Deno.compress + if (this.hasDenoBrotli()) { algorithms.unshift('br'); } + return algorithms; } @@ -199,46 +208,54 @@ class WebStandardCompressionProvider implements ICompressionProvider { 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; + // Use Deno's native brotli if available + if (algorithm === 'br' && this.hasDenoBrotli()) { + try { + const Deno = (globalThis as any).Deno; + return await Deno.compress(data, 'br'); + } catch { + // Fall through to return original + return data; + } } - try { - const stream = new CompressionStream(format); - const writer = stream.writable.getWriter(); - const reader = stream.readable.getReader(); + // Use CompressionStream for gzip/deflate + if (algorithm === 'gzip' || algorithm === 'deflate') { + try { + const stream = new CompressionStream(algorithm); + 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(); + // Write data and close + 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); + // 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; } - - // 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; } + + // Unsupported algorithm + return data; } compressStream( @@ -250,17 +267,14 @@ class WebStandardCompressionProvider implements ICompressionProvider { 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; + // Brotli streaming not supported in Web Standard (Deno.compress is not streaming) + // Only gzip/deflate work with CompressionStream + if (algorithm !== 'gzip' && algorithm !== 'deflate') { + return stream; } try { - const compressionStream = new CompressionStream(format); - // Use type assertion for cross-runtime compatibility + const compressionStream = new CompressionStream(algorithm); return stream.pipeThrough(compressionStream as unknown as TransformStream); } catch { // Compression not supported, return original stream diff --git a/ts/core/smartserve.classes.smartserve.ts b/ts/core/smartserve.classes.smartserve.ts index 58f5691..5162a72 100644 --- a/ts/core/smartserve.classes.smartserve.ts +++ b/ts/core/smartserve.classes.smartserve.ts @@ -516,7 +516,7 @@ export class SmartServe { } // Apply compression - return compressResponse(response, algorithm, effectiveConfig.level); + return compressResponse(response, algorithm, effectiveConfig.level, effectiveConfig.threshold); } /** diff --git a/ts/decorators/decorators.registry.ts b/ts/decorators/decorators.registry.ts index cfc0f66..76651af 100644 --- a/ts/decorators/decorators.registry.ts +++ b/ts/decorators/decorators.registry.ts @@ -13,6 +13,7 @@ export class ControllerRegistry { private static controllers: Map = new Map(); private static instances: Map = new Map(); private static compiledRoutes: ICompiledRoute[] = []; + private static dynamicRoutes: ICompiledRoute[] = []; private static routesCompiled = false; /** @@ -65,6 +66,29 @@ export class ControllerRegistry { return result; } + /** + * Add a dynamic route without needing a controller class + */ + static addRoute( + path: string, + method: THttpMethod, + handler: (ctx: IRequestContext) => Promise + ): void { + const { regex, paramNames } = this.pathToRegex(path); + + this.dynamicRoutes.push({ + pattern: path, + regex, + paramNames, + method, + handler: async (ctx: IRequestContext) => handler(ctx), + interceptors: [], + compression: undefined, + }); + + this.routesCompiled = false; + } + /** * Compile all routes for fast matching */ @@ -107,6 +131,9 @@ export class ControllerRegistry { } } + // Add dynamic routes + this.compiledRoutes.push(...this.dynamicRoutes); + // Sort routes by specificity (more specific paths first) this.compiledRoutes.sort((a, b) => { // Routes without wildcards come first @@ -194,6 +221,7 @@ export class ControllerRegistry { this.controllers.clear(); this.instances.clear(); this.compiledRoutes = []; + this.dynamicRoutes = []; this.routesCompiled = false; } }