diff --git a/package-lock.json b/package-lock.json index 4341e624..58c3fd82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6603,6 +6603,17 @@ "assertion-error": "^2.0.1" } }, + "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", @@ -8875,6 +8886,60 @@ "node": ">= 12.0.0" } }, + "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", @@ -14776,6 +14841,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", @@ -20480,6 +20554,7 @@ "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", + "compression": "^1.8.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.1", @@ -20512,6 +20587,7 @@ "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", + "@types/compression": "^1.8.0", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", diff --git a/server/package.json b/server/package.json index 14fc1944..96fca009 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", + "compression": "^1.8.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.1", @@ -72,6 +73,7 @@ "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", + "@types/compression": "^1.8.0", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 5def1bd4..96cdd59f 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -1,4 +1,5 @@ import express, { Request, Response, NextFunction } from 'express'; +import compression from 'compression'; import cors from 'cors'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; @@ -28,6 +29,21 @@ export function applyGlobalMiddleware( app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1); } + // Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country + // GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED) + // behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB. + // SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so + // they are excluded explicitly. + app.use( + compression({ + filter: (req, res) => { + const type = res.getHeader('Content-Type'); + if (typeof type === 'string' && type.includes('text/event-stream')) return false; + return compression.filter(req, res); + }, + }), + ); + const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean) : null; diff --git a/server/tests/integration/bootstrap.test.ts b/server/tests/integration/bootstrap.test.ts index 7f32b2e4..3292f88b 100644 --- a/server/tests/integration/bootstrap.test.ts +++ b/server/tests/integration/bootstrap.test.ts @@ -122,4 +122,17 @@ describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () => else process.env.NODE_ENV = prev; } }); + + it('BOOT-008 — large responses are gzip-compressed (Atlas country GeoJSON, #1254)', async () => { + // The admin-0 country GeoJSON is multi-MB; without compression it stalls + // behind reverse proxies / Cloudflare Tunnel. Proves applyGlobalMiddleware + // gzips it on the wire. + const { user } = createUser(testDb); + const res = await request(instance) + .get('/api/addons/atlas/countries/geo') + .set('Accept-Encoding', 'gzip') + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + expect(res.headers['content-encoding']).toBe('gzip'); + }); });