Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
76 changes: 76 additions & 0 deletions package-lock.json

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

21 changes: 16 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"roots": ["<rootDir>/tests"],
"moduleFileExtensions": ["ts", "js", "json"],
"roots": [
"<rootDir>/tests"
],
"moduleFileExtensions": [
"ts",
"js",
"json"
],
"transform": {
"^.+\\.ts$": ["ts-jest", {
"tsconfig": "tsconfig.json"
}]
"^.+\\.ts$": [
"ts-jest",
{
"tsconfig": "tsconfig.json"
}
]
}
},
"prisma": {
Expand All @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions scripts/smoke-health.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────
//
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions src/middleware/compression.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
})
1 change: 1 addition & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { compressionMiddleware } from './compression';
export { logger } from '../utils/logger';
export { errorHandler } from './errorHandler';
export { rateLimiter } from './rateLimiter';
Expand Down
151 changes: 151 additions & 0 deletions tests/compression.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})