Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.2.1",
"class-transformer": "^0.5.1",
"compression": "^1.7.4",
"class-validator": "^0.15.1",
"cron": "4.4.0",
"dataloader": "^2.2.3",
Expand All @@ -82,6 +83,7 @@
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/compression": "^1.7.5",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
Expand Down
164 changes: 164 additions & 0 deletions backend/src/common/middleware/compression.middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CompressionMiddleware } from './compression.middleware';
import { Request, Response, NextFunction } from 'express';

describe('CompressionMiddleware', () => {
let middleware: CompressionMiddleware;
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: NextFunction;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CompressionMiddleware],
}).compile();

middleware = module.get<CompressionMiddleware>(CompressionMiddleware);

mockRequest = {
headers: {},
};

mockResponse = {
getHeader: jest.fn(),
setHeader: jest.fn(),
writeHead: jest.fn(),
write: jest.fn(),
end: jest.fn(),
};

mockNext = jest.fn();
});

it('should be defined', () => {
expect(middleware).toBeDefined();
});

describe('use', () => {
it('should call next function', () => {
middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);
expect(mockNext).toHaveBeenCalled();
});

it('should not compress when x-no-compression header is present', () => {
mockRequest.headers = { 'x-no-compression': '1' };

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});

it('should handle JSON content-type', () => {
mockResponse.getHeader = jest.fn().mockReturnValue('application/json');

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});

it('should handle HTML content-type', () => {
mockResponse.getHeader = jest.fn().mockReturnValue('text/html');

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});

it('should handle XML content-type', () => {
mockResponse.getHeader = jest.fn().mockReturnValue('application/xml');

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});

it('should handle JavaScript content-type', () => {
mockResponse.getHeader = jest
.fn()
.mockReturnValue('application/javascript');

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});

it('should handle SVG content-type', () => {
mockResponse.getHeader = jest.fn().mockReturnValue('image/svg+xml');

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});
});

describe('compression configuration', () => {
it('should use 1KB threshold', () => {
// This is implicit in the middleware configuration
expect(middleware).toBeDefined();
});

it('should support gzip encoding', () => {
mockRequest.headers = { 'accept-encoding': 'gzip' };

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});

it('should support brotli encoding', () => {
mockRequest.headers = { 'accept-encoding': 'br' };

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});

it('should support multiple encodings', () => {
mockRequest.headers = { 'accept-encoding': 'gzip, deflate, br' };

middleware.use(
mockRequest as Request,
mockResponse as Response,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});
});
});
97 changes: 97 additions & 0 deletions backend/src/common/middleware/compression.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as compression from 'compression';

/**
* Compression Middleware
*
* Provides response compression using Gzip/Brotli algorithms
* Features:
* - Automatic algorithm selection based on Accept-Encoding header
* - Configurable size threshold
* - Content-type filtering
* - Performance optimization for API responses
*/
@Injectable()
export class CompressionMiddleware implements NestMiddleware {
private compressionHandler: ReturnType<typeof compression>;

constructor() {
this.compressionHandler = compression({
// Only compress responses above 1KB threshold
threshold: 1024,

// Compression level (0-9, where 6 is default balance between speed and size)
level: 6,

// Filter function to determine which responses should be compressed
filter: (req: Request, res: Response) => {
// Don't compress if client explicitly requests no compression
if (req.headers['x-no-compression']) {
return false;
}

// Check if response should be compressed based on content-type
const contentType = res.getHeader('Content-Type');
if (typeof contentType === 'string') {
return this.shouldCompress(contentType);
}

// Use compression's default filter function
return compression.filter(req, res);
},

// Memory level (1-9, affects memory usage vs speed tradeoff)
memLevel: 8,

// Brotli quality (0-11, higher = better compression but slower)
brotliOptions: {
params: {
[require('zlib').constants.BROTLI_PARAM_QUALITY]: 4,
[require('zlib').constants.BROTLI_PARAM_SIZE_HINT]: 0,
},
},
});
}

/**
* Determines if a content-type should be compressed
* @param contentType - The response content-type header
* @returns true if content should be compressed
*/
private shouldCompress(contentType: string): boolean {
// List of compressible content types
const compressibleTypes = [
'text/html',
'text/css',
'text/plain',
'text/xml',
'text/javascript',
'application/javascript',
'application/json',
'application/xml',
'application/ld+json',
'application/graphql+json',
'application/x-javascript',
'application/vnd.api+json',
'image/svg+xml',
'image/x-icon',
'font/ttf',
'font/otf',
'font/woff',
'font/woff2',
];

// Check if content-type matches any compressible types
return compressibleTypes.some((type) =>
contentType.toLowerCase().includes(type.toLowerCase()),
);
}

/**
* Middleware handler
*/
use(req: Request, res: Response, next: NextFunction): void {
this.compressionHandler(req, res, next);
}
}
5 changes: 5 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { CompressionMiddleware } from './common/middleware/compression.middleware';

import { Logger } from 'nestjs-pino';

Expand All @@ -18,6 +19,10 @@ async function bootstrap() {
});
app.useLogger(app.get(Logger));

// Apply compression middleware
const compressionMiddleware = new CompressionMiddleware();
app.use(compressionMiddleware.use.bind(compressionMiddleware));

// Credentials cannot be used with a wildcard origin. Keep the allowlist
// explicit and configurable for local, staging, and production frontends.
const corsConfig = app.get(ConfigService);
Expand Down
Loading