2 Commits

Author SHA1 Message Date
15848b9c9c v1.3.0
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 15:38:43 +00:00
fec0770d55 feat(compression): Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API 2025-12-05 15:38:43 +00:00
8 changed files with 371 additions and 76 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # 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) ## 2025-12-05 - 1.2.0 - feat(compression)
Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartserve", "name": "@push.rocks/smartserve",
"version": "1.2.0", "version": "1.3.0",
"private": false, "private": false,
"description": "a cross platform server module for Node, Deno and Bun", "description": "a cross platform server module for Node, Deno and Bun",
"exports": { "exports": {
@@ -10,7 +10,7 @@
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "(tstest test/ --web)", "test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)", "build": "(tsbuild --web --allowimplicitany)",
"buildDocs": "(tsdoc)" "buildDocs": "(tsdoc)"
}, },

View File

@@ -7,9 +7,53 @@ import {
Guard, Guard,
Transform, Transform,
Intercept, Intercept,
Compress,
NoCompress,
HttpError, HttpError,
type IRequestContext, type IRequestContext,
} from '../ts/index.js'; } 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<string, string>;
}): 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 // Test controller
@Route('/api') @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 () => { tap.test('SmartServe should create server instance', async () => {
const server = new SmartServe({ port: 3456 }); const server = new SmartServe({ port: 3456 });
expect(server).toBeInstanceOf(SmartServe); expect(server).toBeInstanceOf(SmartServe);
@@ -205,4 +291,152 @@ tap.test('HttpError should create proper responses', async () => {
expect(body.details.id).toEqual('123'); 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(); export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartserve', name: '@push.rocks/smartserve',
version: '1.2.0', version: '1.3.0',
description: 'a cross platform server module for Node, Deno and Bun' description: 'a cross platform server module for Node, Deno and Bun'
} }

View File

@@ -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( export function shouldCompressResponse(
response: Response, response: Response,
@@ -97,15 +98,6 @@ export function shouldCompressResponse(
return false; 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 // Check excluded paths
if (config.exclude?.length) { if (config.exclude?.length) {
const url = new URL(request.url); const url = new URL(request.url);
@@ -166,28 +158,44 @@ export function selectCompressionAlgorithm(
/** /**
* Compress a Response object * Compress a Response object
* Uses buffered compression for reliability (streaming can have flushing issues)
*/ */
export async function compressResponse( export async function compressResponse(
response: Response, response: Response,
algorithm: TCompressionAlgorithm, algorithm: TCompressionAlgorithm,
level?: number level?: number,
threshold?: number
): Promise<Response> { ): Promise<Response> {
if (algorithm === 'identity' || !response.body) { if (algorithm === 'identity' || !response.body) {
return response; 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(); const provider = getCompressionProvider();
// Compress the body
const compressedBody = await provider.compress(originalBody, algorithm, level);
// Clone headers and modify // Clone headers and modify
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
headers.set('Content-Encoding', algorithm); headers.set('Content-Encoding', algorithm);
headers.set('Vary', appendVaryHeader(headers.get('Vary'), 'Accept-Encoding')); 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 return new Response(compressedBody as unknown as BodyInit, {
const compressedBody = provider.compressStream(response.body, algorithm, level);
return new Response(compressedBody, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers, headers,

View File

@@ -164,29 +164,38 @@ class NodeCompressionProvider implements ICompressionProvider {
// ============================================================================= // =============================================================================
class WebStandardCompressionProvider implements ICompressionProvider { class WebStandardCompressionProvider implements ICompressionProvider {
private brotliSupported: boolean | null = null; private _brotliSupported: boolean | null = null;
private _isDeno: boolean;
private checkBrotliSupport(): boolean { constructor() {
if (this.brotliSupported === null) { this._isDeno = typeof (globalThis as any).Deno !== 'undefined';
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 * Check if brotli is supported via Deno.compress API
this.brotliSupported = false; */
} catch { private hasDenoBrotli(): boolean {
this.brotliSupported = false; 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[] { getSupportedAlgorithms(): TCompressionAlgorithm[] {
// CompressionStream supports gzip and deflate in most runtimes // CompressionStream supports gzip and deflate
// Brotli support is limited
const algorithms: TCompressionAlgorithm[] = ['gzip', 'deflate']; const algorithms: TCompressionAlgorithm[] = ['gzip', 'deflate'];
if (this.checkBrotliSupport()) {
// Deno has native brotli via Deno.compress
if (this.hasDenoBrotli()) {
algorithms.unshift('br'); algorithms.unshift('br');
} }
return algorithms; return algorithms;
} }
@@ -199,46 +208,54 @@ class WebStandardCompressionProvider implements ICompressionProvider {
return data; return data;
} }
// Map algorithm to CompressionStream format // Use Deno's native brotli if available
// Brotli falls back to gzip if not supported if (algorithm === 'br' && this.hasDenoBrotli()) {
let format: CompressionFormat; try {
if (algorithm === 'br') { const Deno = (globalThis as any).Deno;
format = this.checkBrotliSupport() ? ('br' as CompressionFormat) : 'gzip'; return await Deno.compress(data, 'br');
} else { } catch {
format = algorithm as CompressionFormat; // Fall through to return original
return data;
}
} }
try { // Use CompressionStream for gzip/deflate
const stream = new CompressionStream(format); if (algorithm === 'gzip' || algorithm === 'deflate') {
const writer = stream.writable.getWriter(); try {
const reader = stream.readable.getReader(); const stream = new CompressionStream(algorithm);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Write data and close (cast for type compatibility) // Write data and close
await writer.write(data as unknown as BufferSource); await writer.write(data as unknown as BufferSource);
await writer.close(); await writer.close();
// Collect compressed chunks // Collect compressed chunks
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
chunks.push(value); 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( compressStream(
@@ -250,17 +267,14 @@ class WebStandardCompressionProvider implements ICompressionProvider {
return stream; return stream;
} }
// Map algorithm to CompressionStream format // Brotli streaming not supported in Web Standard (Deno.compress is not streaming)
let format: CompressionFormat; // Only gzip/deflate work with CompressionStream
if (algorithm === 'br') { if (algorithm !== 'gzip' && algorithm !== 'deflate') {
format = this.checkBrotliSupport() ? ('br' as CompressionFormat) : 'gzip'; return stream;
} else {
format = algorithm as CompressionFormat;
} }
try { try {
const compressionStream = new CompressionStream(format); const compressionStream = new CompressionStream(algorithm);
// Use type assertion for cross-runtime compatibility
return stream.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>); return stream.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
} catch { } catch {
// Compression not supported, return original stream // Compression not supported, return original stream

View File

@@ -516,7 +516,7 @@ export class SmartServe {
} }
// Apply compression // Apply compression
return compressResponse(response, algorithm, effectiveConfig.level); return compressResponse(response, algorithm, effectiveConfig.level, effectiveConfig.threshold);
} }
/** /**

View File

@@ -13,6 +13,7 @@ export class ControllerRegistry {
private static controllers: Map<Function, IControllerMetadata> = new Map(); private static controllers: Map<Function, IControllerMetadata> = new Map();
private static instances: Map<Function, any> = new Map(); private static instances: Map<Function, any> = new Map();
private static compiledRoutes: ICompiledRoute[] = []; private static compiledRoutes: ICompiledRoute[] = [];
private static dynamicRoutes: ICompiledRoute[] = [];
private static routesCompiled = false; private static routesCompiled = false;
/** /**
@@ -65,6 +66,29 @@ export class ControllerRegistry {
return result; return result;
} }
/**
* Add a dynamic route without needing a controller class
*/
static addRoute(
path: string,
method: THttpMethod,
handler: (ctx: IRequestContext) => Promise<Response | any>
): 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 * 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) // Sort routes by specificity (more specific paths first)
this.compiledRoutes.sort((a, b) => { this.compiledRoutes.sort((a, b) => {
// Routes without wildcards come first // Routes without wildcards come first
@@ -194,6 +221,7 @@ export class ControllerRegistry {
this.controllers.clear(); this.controllers.clear();
this.instances.clear(); this.instances.clear();
this.compiledRoutes = []; this.compiledRoutes = [];
this.dynamicRoutes = [];
this.routesCompiled = false; this.routesCompiled = false;
} }
} }