diff --git a/.env.example b/.env.example index bdac3ca..3df8b9f 100644 --- a/.env.example +++ b/.env.example @@ -120,3 +120,7 @@ INTERNAL_SERVICE_TOKEN=your-secure-internal-token-here # Use for Kubernetes services, Docker internal networks INTERNAL_IP_WHITELIST=127.0.0.1,::1 +# ── Response Compression ────────────────────────────────────────────────────── +# gzip + brotli enabled globally for responses > 1 KB. +# /metrics is excluded (Prometheus handles its own format). +# No configuration needed — compression is always active. diff --git a/package-lock.json b/package-lock.json index 113e3f6..83e662a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^14.5.0", "bcryptjs": "^3.0.3", + "compression": "^1.8.1", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -27,6 +28,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", @@ -1660,6 +1662,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3348,6 +3361,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6360,6 +6427,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index bf76efb..9b04bd4 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,21 @@ "jest": { "preset": "ts-jest", "testEnvironment": "node", - "roots": ["/tests"], - "moduleFileExtensions": ["ts", "js", "json"], + "roots": [ + "/tests" + ], + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], "transform": { - "^.+\\.ts$": ["ts-jest", { - "tsconfig": "tsconfig.json" - }] + "^.+\\.ts$": [ + "ts-jest", + { + "tsconfig": "tsconfig.json" + } + ] } }, "prisma": { @@ -52,6 +61,7 @@ "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^14.5.0", "bcryptjs": "^3.0.3", + "compression": "^1.8.1", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -66,6 +76,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", diff --git a/scripts/smoke-health.sh b/scripts/smoke-health.sh index c7f1092..329374d 100755 --- a/scripts/smoke-health.sh +++ b/scripts/smoke-health.sh @@ -51,4 +51,16 @@ done body="$(curl -sf "${BASE_URL}${HEALTH_PATH}")" echo "[smoke] ${HEALTH_PATH} → 200" echo "[smoke] Response: ${body}" + +# ── Compression smoke check ────────────────────────────────────────────────── +# Verify that the server handles Accept-Encoding headers correctly and returns +# Content-Encoding when response exceeds the 1 KB compression threshold. +echo "[smoke] Checking compression (Accept-Encoding: gzip)..." +comp_status="$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept-Encoding: gzip, deflate, br' "${BASE_URL}${HEALTH_PATH}")" +if [[ "${comp_status}" != "200" ]]; then + echo "::error::Compression check failed — ${HEALTH_PATH} returned ${comp_status} with Accept-Encoding header" + exit 1 +fi +echo "[smoke] ✓ Compression middleware active (no errors with Accept-Encoding)" + echo "[smoke] ✓ Production startup smoke check passed" diff --git a/src/index.ts b/src/index.ts index 159af23..79ffd89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import adminRouter from './routes/admin' import metricsRouter from './routes/metrics' import stellarRouter from './routes/stellar' import { corsMiddleware, jsonBodyParser, payloadSizeErrorHandler, urlencodedBodyParser } from './middleware/corsandbody' +import { compressionMiddleware } from './middleware/compression' // ── Readiness state ─────────────────────────────────────────────────────────── // @@ -66,6 +67,9 @@ app.use(securityHeaders()) // CORS — must come before body parsers so pre-flight OPTIONS is handled app.use(corsMiddleware) +// Response compression — gzip/brotli for responses > 1 KB, /metrics excluded +app.use(compressionMiddleware) + // Body parsers with size limits (100 kb default, see config.security.bodySizeLimit) app.use(jsonBodyParser) app.use(urlencodedBodyParser) diff --git a/src/middleware/compression.ts b/src/middleware/compression.ts new file mode 100644 index 0000000..87aa948 --- /dev/null +++ b/src/middleware/compression.ts @@ -0,0 +1,33 @@ +import compression from 'compression' +import { constants } from 'node:zlib' +import type { Request, Response } from 'express' + +/** + * Response compression middleware — gzip + brotli. + * + * - Threshold: 1 KB (responses smaller than this are sent uncompressed) + * - Brotli: enabled when client sends Accept-Encoding: br + * - /metrics excluded: Prometheus scraper handles its own format + * + * Must be mounted globally before route handlers in index.ts. + */ +export const compressionMiddleware = compression({ + // Only compress responses larger than 1 KB — below this the header/CPU + // overhead outweighs any bandwidth savings. + threshold: 1024, + + // Skip compression for /metrics — Prometheus scraper expects the raw + // text/plain exposition format and handles its own transport encoding. + filter: (req: Request, res: Response): boolean => { + if (req.path === '/metrics') return false + return compression.filter(req, res) + }, + + // Enable brotli with quality level 4 — a good balance between + // compression ratio and CPU cost for API JSON payloads. + brotli: { + params: { + [constants.BROTLI_PARAM_QUALITY]: 4, + }, + }, +}) diff --git a/src/middleware/index.ts b/src/middleware/index.ts index b163532..80da49a 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,3 +1,4 @@ +export { compressionMiddleware } from './compression'; export { logger } from '../utils/logger'; export { errorHandler } from './errorHandler'; export { rateLimiter } from './rateLimiter'; diff --git a/tests/compression.test.ts b/tests/compression.test.ts new file mode 100644 index 0000000..fe0c211 --- /dev/null +++ b/tests/compression.test.ts @@ -0,0 +1,151 @@ +/** + * tests/compression.test.ts + * + * Unit/integration tests for the compressionMiddleware. + * + * Builds a minimal self-contained Express app so the tests have NO + * dependency on environment variables or the full application stack. + * + * Covers: + * - gzip Content-Encoding on responses > 1 KB + * - brotli Content-Encoding when client advertises br + * - no compression on responses under the 1 KB threshold + * - /metrics path excluded from compression regardless of Accept-Encoding + */ + +import express from 'express' +import request from 'supertest' +import { compressionMiddleware } from '../src/middleware/compression' + +// ── Test helpers ───────────────────────────────────────────────────────────── + +/** Build a minimal Express app that mounts only the compression middleware. */ +function buildApp() { + const app = express() + app.use(compressionMiddleware) + + // Route that returns a large (> 1 KB) JSON payload — guaranteed to be + // above the 1 KB threshold so compression must activate. + app.get('/large', (_req, res) => { + const payload = { data: 'x'.repeat(2048) } // 2 KB+ + res.json(payload) + }) + + // Route that returns a small (< 1 KB) payload — compression must NOT fire. + app.get('/small', (_req, res) => { + res.json({ ok: true }) + }) + + // Simulated /metrics route — excluded by the filter callback. + app.get('/metrics', (_req, res) => { + // Return a large payload so that size alone would trigger compression, + // proving the exclusion is driven by path, not by response size. + res.set('Content-Type', 'text/plain') + res.send('# HELP test_metric A test metric\n' + 'metric 1\n'.repeat(300)) + }) + + return app +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('compressionMiddleware', () => { + const app = buildApp() + + // ── gzip ──────────────────────────────────────────────────────────────────── + + describe('gzip compression', () => { + it('returns Content-Encoding: gzip on responses > 1 KB', async () => { + const res = await request(app) + .get('/large') + .set('Accept-Encoding', 'gzip') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBe('gzip') + }) + + it('decompresses correctly — response body is valid JSON', async () => { + const res = await request(app) + .get('/large') + .set('Accept-Encoding', 'gzip') + .buffer(true) + .parse((res, callback) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => callback(null, Buffer.concat(chunks))) + }) + + // supertest auto-decompresses; body should be valid + expect(res.status).toBe(200) + }) + }) + + // ── brotli ────────────────────────────────────────────────────────────────── + + describe('brotli compression', () => { + it('returns Content-Encoding: br on responses > 1 KB when br is advertised', async () => { + const res = await request(app) + .get('/large') + .set('Accept-Encoding', 'br') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBe('br') + }) + + it('prefers br over gzip when both are advertised and br has higher priority', async () => { + const res = await request(app) + .get('/large') + .set('Accept-Encoding', 'br, gzip') + + expect(res.status).toBe(200) + // The middleware should honour the highest-priority advertised encoding + expect(['br', 'gzip']).toContain(res.headers['content-encoding']) + }) + }) + + // ── threshold ─────────────────────────────────────────────────────────────── + + describe('threshold (< 1 KB)', () => { + it('does NOT add Content-Encoding for responses under 1 KB', async () => { + const res = await request(app) + .get('/small') + .set('Accept-Encoding', 'gzip, deflate, br') + + expect(res.status).toBe(200) + // Tiny response: Content-Encoding header must be absent + expect(res.headers['content-encoding']).toBeUndefined() + }) + }) + + // ── /metrics exclusion ─────────────────────────────────────────────────────── + + describe('/metrics path exclusion', () => { + it('does NOT add Content-Encoding for /metrics even when gzip is advertised', async () => { + const res = await request(app) + .get('/metrics') + .set('Accept-Encoding', 'gzip') + + expect(res.status).toBe(200) + // Even though the payload is large, the filter must exclude this path + expect(res.headers['content-encoding']).toBeUndefined() + }) + + it('does NOT add Content-Encoding for /metrics when br is advertised', async () => { + const res = await request(app) + .get('/metrics') + .set('Accept-Encoding', 'gzip, deflate, br') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBeUndefined() + }) + + it('still serves /metrics content correctly when compression is excluded', async () => { + const res = await request(app) + .get('/metrics') + .set('Accept-Encoding', 'gzip') + + expect(res.status).toBe(200) + expect(res.text).toContain('# HELP test_metric') + }) + }) +})