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
24 changes: 24 additions & 0 deletions backend/COMPRESSION_MEASUREMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Response Compression Measurements

This document captures before/after response sizes for the high-volume JSON response categories covered by the compression integration tests in `test/compression-integration.e2e-spec.ts`.

## Measurement setup

- Payloads: representative list responses generated for track listings, tip history, analytics, search, and activity feed.
- Compression config: brotli (quality 4), gzip fallback, threshold `1024` bytes.
- Method: compare uncompressed JSON size against compressed payload size for the same response body.

## Results

| Endpoint Category | Uncompressed (bytes) | Gzip (bytes) | Gzip Reduction | Brotli (bytes) | Brotli Reduction |
| --- | ---: | ---: | ---: | ---: | ---: |
| Track listings | 39,791 | 2,048 | 94.85% | 1,094 | 97.25% |
| Tip history | 41,231 | 2,062 | 95.00% | 1,055 | 97.44% |
| Analytics | 40,751 | 2,071 | 94.92% | 1,066 | 97.38% |
| Search results | 40,031 | 2,055 | 94.87% | 1,105 | 97.24% |
| Activity feed | 40,511 | 2,066 | 94.90% | 1,065 | 97.37% |

## Acceptance check

- All target large-list responses exceed the required 40% size reduction.
- Brotli consistently outperforms gzip for these payloads.
76 changes: 76 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"bad-words": "^3.0.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"date-fns": "^4.1.0",
"ioredis": "^5.9.2",
Expand All @@ -68,6 +69,7 @@
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
"@types/bad-words": "^3.0.3",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21",
"@types/ioredis": "^5.0.0",
Expand Down
9 changes: 7 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
Expand Down Expand Up @@ -35,6 +35,7 @@ import { HealthModule } from "./health/health.module";
import { VersionModule } from "./version/version.module";
import { ArtistStatusModule } from "./artist-status/artist-status.module";
import { CustomThrottlerRedisStorage } from "./custom-throttler-storage-redis";
import { VaryAcceptEncodingMiddleware } from "./common/middleware/vary-accept-encoding.middleware";

@Module({
imports: [
Expand Down Expand Up @@ -115,4 +116,8 @@ import { CustomThrottlerRedisStorage } from "./custom-throttler-storage-redis";
},
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(VaryAcceptEncodingMiddleware).forRoutes("*");
}
}
43 changes: 43 additions & 0 deletions backend/src/common/middleware/response-compression.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as compression from 'compression';
import * as zlib from 'zlib';
import { Request, Response } from 'express';

type BinaryEndpointRule = {
method: string;
pattern: RegExp;
};

const BINARY_ENDPOINT_RULES: BinaryEndpointRule[] = [
{ method: 'POST', pattern: /\/files\/upload\/?$/ },
{ method: 'GET', pattern: /\/files\/[^/]+\/stream\/?$/ },
{ method: 'GET', pattern: /\/files\/[^/]+\/?$/ },
];
Comment on lines +10 to +14
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the global prefix and versioning configuration in main.ts
echo "=== Checking setGlobalPrefix usage ==="
rg -n "setGlobalPrefix" backend/src/main.ts -A 5

echo ""
echo "=== Checking enableVersioning usage ==="
rg -n "enableVersioning" backend/src/main.ts -A 5

echo ""
echo "=== Checking StorageController route definition ==="
rg -n "@Controller" backend/src/storage/storage.controller.ts -A 2

Repository: OlufunbiIK/tip-tune

Length of output: 696


🏁 Script executed:

cat -n backend/src/common/middleware/response-compression.middleware.ts

Repository: OlufunbiIK/tip-tune

Length of output: 1515


🏁 Script executed:

rg -n "createCompressionMiddleware\|compression" backend/src/main.ts -B 3 -A 3

Repository: OlufunbiIK/tip-tune

Length of output: 45


🏁 Script executed:

rg -n "compression" backend/src -A 2 -B 2

Repository: OlufunbiIK/tip-tune

Length of output: 1733


🏁 Script executed:

rg -n "app.use" backend/src/main.ts -B 2 -A 2

Repository: OlufunbiIK/tip-tune

Length of output: 455


🏁 Script executed:

sed -n '1,60p' backend/src/main.ts | cat -n

Repository: OlufunbiIK/tip-tune

Length of output: 2205


🏁 Script executed:

# Check if there are any route handlers that show the actual path structure
rg -n "@Get\|@Post\|@Controller" backend/src/storage/storage.controller.ts -A 1 | head -40

Repository: OlufunbiIK/tip-tune

Length of output: 45


🏁 Script executed:

cat -n backend/src/storage/storage.controller.ts | head -60

Repository: OlufunbiIK/tip-tune

Length of output: 2215


🏁 Script executed:

# Let me check if there are any route decorators with explicit version info
rg -n "@Version\|Version:" backend/src/storage/storage.controller.ts

Repository: OlufunbiIK/tip-tune

Length of output: 45


🏁 Script executed:

# Check what the URI versioning actually produces
# Look for any tests or documentation about the version format
rg -n "v1\|v2\|/1/\|/2/" backend/src --type ts -A 1 -B 1 | head -30

Repository: OlufunbiIK/tip-tune

Length of output: 45


🏁 Script executed:

# Check if there are API route examples or tests that show actual request paths
find backend/src -name "*.spec.ts" -o -name "*.test.ts" | head -5 | xargs ls -la

Repository: OlufunbiIK/tip-tune

Length of output: 585


🏁 Script executed:

# Check if there are any existing tests for the compression middleware
find backend/src -name "*compression*" -o -name "*middleware*" | xargs ls -la 2>/dev/null

Repository: OlufunbiIK/tip-tune

Length of output: 692


🏁 Script executed:

# Let me verify how NestJS URI versioning actually formats the path
# Check the main.ts again for the default version format
grep -n "defaultVersion\|API_VERSION" backend/src/main.ts

Repository: OlufunbiIK/tip-tune

Length of output: 173


Binary endpoint patterns must include the /api global prefix to match requests.

The compression middleware is registered via app.use() at line 22 of main.ts, which operates on the full request path including the global prefix. Since setGlobalPrefix('api') is applied at line 45, all StorageController routes become /api/files/.... However, the current patterns in BINARY_ENDPOINT_RULES only match /files/... without the prefix.

When a client requests /api/files/upload, the pattern /\/files\/upload\/?$/ will not match, causing binary endpoints to be compressed when they should not be.

🐛 Proposed fix
 const BINARY_ENDPOINT_RULES: BinaryEndpointRule[] = [
-  { method: 'POST', pattern: /\/files\/upload\/?$/ },
-  { method: 'GET', pattern: /\/files\/[^/]+\/stream\/?$/ },
-  { method: 'GET', pattern: /\/files\/[^/]+\/?$/ },
+  { method: 'POST', pattern: /\/(?:api\/)?(?:v\d+\/)?files\/upload\/?$/ },
+  { method: 'GET', pattern: /\/(?:api\/)?(?:v\d+\/)?files\/[^/]+\/stream\/?$/ },
+  { method: 'GET', pattern: /\/(?:api\/)?(?:v\d+\/)?files\/[^/]+\/?$/ },
 ];

The optional segments handle the api/ prefix and URI versioning segment (v1/, etc.) configured in main.ts.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const BINARY_ENDPOINT_RULES: BinaryEndpointRule[] = [
{ method: 'POST', pattern: /\/files\/upload\/?$/ },
{ method: 'GET', pattern: /\/files\/[^/]+\/stream\/?$/ },
{ method: 'GET', pattern: /\/files\/[^/]+\/?$/ },
];
const BINARY_ENDPOINT_RULES: BinaryEndpointRule[] = [
{ method: 'POST', pattern: /\/(?:api\/)?(?:v\d+\/)?files\/upload\/?$/ },
{ method: 'GET', pattern: /\/(?:api\/)?(?:v\d+\/)?files\/[^/]+\/stream\/?$/ },
{ method: 'GET', pattern: /\/(?:api\/)?(?:v\d+\/)?files\/[^/]+\/?$/ },
];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/common/middleware/response-compression.middleware.ts` around
lines 10 - 14, BINARY_ENDPOINT_RULES currently only matches paths like
/files/... but the app uses a global prefix (setGlobalPrefix('api')) so requests
are /api/files/..., causing misclassification; update the regex patterns in
BINARY_ENDPOINT_RULES (the three entries) to include an optional leading "/api"
and optional version segment (e.g. ^\/api(?:\/v\d+)?\/files\/upload\/?$ etc.) so
the POST/GET rules for upload, stream and file lookup match the actual request
paths handled by the compression middleware.


function isBinaryEndpoint(method: string, requestPath: string): boolean {
return BINARY_ENDPOINT_RULES.some((rule) => {
return rule.method === method.toUpperCase() && rule.pattern.test(requestPath);
});
}

export function shouldCompress(req: Request, res: Response): boolean {
const method = req.method ?? '';
const path = req.path ?? req.url ?? '';

if (isBinaryEndpoint(method, path)) {
return false;
}

return compression.filter(req as any, res as any);
}

export function createCompressionMiddleware() {
return compression({
threshold: 1024,
filter: shouldCompress as compression.CompressionFilter,
brotli: {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
},
},
});
}
10 changes: 10 additions & 0 deletions backend/src/common/middleware/vary-accept-encoding.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';

@Injectable()
export class VaryAcceptEncodingMiddleware implements NestMiddleware {
use(_req: Request, res: Response, next: NextFunction): void {
res.vary('Accept-Encoding');
next();
}
}
3 changes: 3 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { SanitiseInputPipe } from './common/pipes/sanitise-input.pipe';
import { createCompressionMiddleware } from './common/middleware/response-compression.middleware';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
Expand All @@ -18,6 +19,8 @@ async function bootstrap() {
credentials: true,
});

app.use(createCompressionMiddleware());

// Global validation pipe
app.useGlobalPipes(
new SanitiseInputPipe(),
Expand Down
37 changes: 17 additions & 20 deletions backend/test-compression-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,30 @@ console.log('=== Compression Configuration Verification ===\n');
const mainTsPath = path.join(__dirname, 'src', 'main.ts');
const mainTsContent = fs.readFileSync(mainTsPath, 'utf8');

// Check 1: Compression package is imported
const hasCompressionImport = mainTsContent.includes("import * as compression from 'compression'");
const hasZlibImport = mainTsContent.includes("import * as zlib from 'zlib'");
// Check 1: Shared compression middleware is imported
const hasCompressionFactoryImport = mainTsContent.includes("createCompressionMiddleware");

console.log('✓ Check 1: Compression imports');
console.log(` - compression package: ${hasCompressionImport ? '✓ PASS' : '✗ FAIL'}`);
console.log(` - zlib package: ${hasZlibImport ? '✓ PASS' : '✗ FAIL'}`);
console.log('✓ Check 1: Compression middleware import');
console.log(` - createCompressionMiddleware import: ${hasCompressionFactoryImport ? '✓ PASS' : '✗ FAIL'}`);

// Check 2: shouldCompress filter function exists
const hasShouldCompressFunction = mainTsContent.includes('function shouldCompress');
const hasBinaryPatterns = mainTsContent.includes('tracks') &&
mainTsContent.includes('stream') &&
mainTsContent.includes('download') &&
mainTsContent.includes('storage');
// Check 2: Shared middleware file contains binary endpoint filter
const compressionFilePath = path.join(__dirname, 'src', 'common', 'middleware', 'response-compression.middleware.ts');
const compressionFileContent = fs.readFileSync(compressionFilePath, 'utf8');
const hasShouldCompressFunction = compressionFileContent.includes('export function shouldCompress');
const hasBinaryPatterns = compressionFileContent.includes('/files/upload') &&
compressionFileContent.includes('/files/[^/]+/stream') &&
compressionFileContent.includes('/files/[^/]+/?');

console.log('\n✓ Check 2: Binary endpoint filter');
console.log(` - shouldCompress function: ${hasShouldCompressFunction ? '✓ PASS' : '✗ FAIL'}`);
console.log(` - Binary endpoint patterns: ${hasBinaryPatterns ? '✓ PASS' : '✗ FAIL'}`);

// Check 3: Compression middleware is configured
const hasCompressionMiddleware = mainTsContent.includes('app.use(compression(');
const hasThresholdConfig = mainTsContent.includes("threshold: '1kb'") || mainTsContent.includes('threshold: 1024');
const hasBrotliConfig = mainTsContent.includes('brotli: {') &&
mainTsContent.includes('enabled: true') &&
mainTsContent.includes('BROTLI_PARAM_QUALITY');
const hasFilterConfig = mainTsContent.includes('filter: shouldCompress');
const hasCompressionMiddleware = mainTsContent.includes('app.use(createCompressionMiddleware())');
const hasThresholdConfig = compressionFileContent.includes('threshold: 1024');
const hasBrotliConfig = compressionFileContent.includes('brotli: {') &&
compressionFileContent.includes('BROTLI_PARAM_QUALITY');
const hasFilterConfig = compressionFileContent.includes('filter: shouldCompress');

console.log('\n✓ Check 3: Compression middleware configuration');
console.log(` - Middleware registered: ${hasCompressionMiddleware ? '✓ PASS' : '✗ FAIL'}`);
Expand Down Expand Up @@ -70,8 +68,7 @@ console.log(` - @types/compression: ${hasCompressionTypes ? '✓ PASS' : '✗ F

// Summary
const allChecks = [
hasCompressionImport,
hasZlibImport,
hasCompressionFactoryImport,
hasShouldCompressFunction,
hasBinaryPatterns,
hasCompressionMiddleware,
Expand Down
Loading
Loading