From 70859994aad09cb14b5e35193d1e5c0c7110243f Mon Sep 17 00:00:00 2001 From: aojomo Date: Fri, 24 Jul 2026 08:10:32 +0000 Subject: [PATCH] feat: add compression middleware with brotli support - Install compression package v1.8.1 with native brotli support via Node zlib - Create src/middleware/compression.ts with 1 KB threshold and /metrics exclusion - Wire compression middleware into Express stack before route handlers - Add Content-Encoding and Vary header verification to smoke-health.sh - Brotli (br) preferred when client advertises Accept-Encoding: br Closes #218 --- package-lock.json | 76 +++++++++++++++++++++++++++++++++++ package.json | 21 +++++++--- scripts/smoke-health.sh | 25 ++++++++++++ src/index.ts | 6 +++ src/middleware/compression.ts | 37 +++++++++++++++++ 5 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 src/middleware/compression.ts 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..c5c5c43 100755 --- a/scripts/smoke-health.sh +++ b/scripts/smoke-health.sh @@ -51,4 +51,29 @@ done body="$(curl -sf "${BASE_URL}${HEALTH_PATH}")" echo "[smoke] ${HEALTH_PATH} → 200" echo "[smoke] Response: ${body}" + +# ── Compression middleware verification ──────────────────────────────────────── +# Verify the compression middleware is active by checking: +# 1. Vary: Accept-Encoding header (always set by middleware regardless of body size) +# 2. Content-Encoding header (only present when body exceeds 1 KB threshold) +# +# See issue #218. + +echo "[smoke] Verifying compression middleware (Vary: Accept-Encoding)..." +vary_header="$(curl -sf -I "${BASE_URL}/health/live" | grep -i '^Vary:' | tr -d '[:space:]')" +if echo "${vary_header}" | grep -qi 'accept-encoding'; then + echo "[smoke] ✓ Vary: Accept-Encoding confirmed — compression middleware active" +else + echo "[smoke] ⚠ Vary: Accept-Encoding not found — compression may not be active" +fi + +echo "[smoke] Checking Content-Encoding on large-response endpoints..." +# Use a query parameter or path that generates a larger response to test actual compression +encoding_header="$(curl -sf -H 'Accept-Encoding: gzip' -o /dev/null -w '%{content_encoding}' "${BASE_URL}/health/ready" 2>/dev/null || echo '')" +if [[ -n "${encoding_header}" && "${encoding_header}" != "identity" ]]; then + echo "[smoke] ✓ Content-Encoding: ${encoding_header}" +else + echo "[smoke] (content below 1 KB threshold — compression not expected)" +fi + echo "[smoke] ✓ Production startup smoke check passed" diff --git a/src/index.ts b/src/index.ts index 159af23..46789e8 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 ─────────────────────────────────────────────────────────── // @@ -79,6 +80,11 @@ app.use(requestLogger) app.use(trustedIpBypass) app.use(rateLimiter) +// ── Response compression (brotli/gzip) ──────────────────────────────────────── +// Compresses responses > 1 KB. Excludes /metrics (Prometheus scraper format). +// Must be before route handlers but after security/parsing/rate-limit middleware. +app.use(compressionMiddleware) + // ── Readiness / liveness probes ─────────────────────────────────────────────── // // Liveness — is the process running? Always 200 once the process is up. diff --git a/src/middleware/compression.ts b/src/middleware/compression.ts new file mode 100644 index 0000000..be085eb --- /dev/null +++ b/src/middleware/compression.ts @@ -0,0 +1,37 @@ +import compression from 'compression' +import type { Request, Response } from 'express' + +/** + * Compression middleware configuration. + * + * Features: + * - Brotli (br) preferred when the client advertises support (Accept-Encoding: br), + * falling back to gzip, then deflate. + * - Threshold: only compress responses larger than 1 KB. + * - Excludes the /metrics endpoint (Prometheus scraper handles its own format). + * + * The compression package (v1.7+) uses Node's native zlib.createBrotliCompress when + * available, so no additional dependencies are required for brotli support. + */ + +const EXCLUDED_PATHS = ['/metrics'] + +function compressionFilter(req: Request, res: Response): boolean { + // Never compress the Prometheus metrics endpoint + if (EXCLUDED_PATHS.includes(req.path)) { + return false + } + + // Fall back to the default filter (checks Content-Type is compressible) + return compression.filter(req, res) +} + +const compressionMiddleware = compression({ + // Compress responses larger than 1 KB + threshold: 1024, + + // Custom filter to exclude /metrics + filter: compressionFilter, +}) + +export default compressionMiddleware