Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15848b9c9c | |||
| fec0770d55 |
11
changelog.md
11
changelog.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
},
|
},
|
||||||
|
|||||||
234
test/test.ts
234
test/test.ts
@@ -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();
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user