Repository navigation
Description
In production the gateway never compresses its server-rendered pages or its API responses, because ProductionServer mounts compression() only after BaseServer's constructor has already mounted every router. Express runs middleware in registration order. rootRouter ends the response for /assignments/:id and /, and apiRouter ends it for /api/*, so the request never reaches compression(). Only the static files served by sirv, which is mounted after it, are compressed. The assignment page is the largest thing a patient downloads from the gateway: it embeds the whole instrument bundle, JSON-stringified and then base64-encoded, in window.__ROOT_PROPS__. Every patient therefore downloads that page at full size, often over a mobile connection. In docker-compose.yaml the gateway is published directly on GATEWAY_PORT and not through Caddy, so nothing downstream compresses it either.
Where
apps/gateway/src/server/server.production.ts:14-20:
export class ProductionServer extends BaseServer {
constructor() {
super();
this.app.use(compression());
this.app.use('/', sirv(path.resolve(config.root, './dist/client'), { extensions: [] }));
this.app.use('/runtime', sirv(path.resolve(config.root, './dist/runtime'), { extensions: [] }));
}super() has already registered the routes, in apps/gateway/src/server/server.base.ts:65-68:
this.app.use('/api/auth', capRouter);
this.app.use('/api', apiKeyMiddleware, apiRouter);
this.app.use('/', this.rootLoader, rootRouter);
this.app.use(errorHandlerMiddleware);Reproduce
- Build and start the gateway in production mode, and create an assignment for the Enhanced Demographics Questionnaire.
curl -sI -H 'Accept-Encoding: gzip' http://localhost:<GATEWAY_PORT>/assignments/<id>.
Actual: no Content-Encoding header, and the full body is sent. I confirmed the mechanism with the repo's own express@5.2.1 and compression@1.8.2: a router mounted before compression() responds without Content-Encoding, and a route mounted after it responds with gzip. For this instrument the base64 ROOT_PROPS alone is 20.2 KB, and gzip shrinks it to 7.2 KB. Bundles with media inlined as data URLs by the instrument bundler are far larger. The GET /api/assignments sync response, which carries every stored bundle, is not compressed either.
Expected: Content-Encoding: gzip on the HTML and JSON responses.
Tests
apps/gateway/src/server/__tests__/server.production.test.tsmocksBaseServerwith a bare Express app, which is why it only proves compression for static assets. Addit('should gzip a server-rendered page, so the instrument bundle embedded in it is not sent uncompressed')with routes registered by the base constructor, the way the real class registers them.- An e2e test does not apply: the Playwright suite runs the gateway's development server, which does not use
compressionat all.
Suggested fix
Register compression() before the routers. For example, BaseServer can call a protected registerPreRouteMiddleware() hook (a no-op by default, and compression() in ProductionServer) at the top of its constructor. Or BaseServer can mount compression() itself, in both modes.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touchedapps/gateway/src/serversince this was filed, and no open PR touchesserver.base.ts,server.production.tsor their tests.The repro needs a GET
curl -sIsendsHEAD, andcompression@1.8.2never encodes aHEADresponse (index.js:205-206), so step 2 prints noContent-Encodingeven after the fix. Usecurl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' <url>. With the repo'sexpress@5.2.1andcompression()mounted before the router, that GET returnsgzipand the HEAD still returns nothing.Most real clients will get
br, notgzip:compressionprefersbrwhenever it is offered (index.js:45-46). The API'saxios@1.20.0client offersgzip, compress, deflate, brand decompresses on its own, andcreateGatewayHttpOptionsdoes not turn that off, so the API needs no change. Playwright's request context offersgzip,deflate,brby default, so a test that assertsgzipmust sendAccept-Encoding: gzip.Mount it in
BaseServer, in both modesMake
this.app.use(compression())the first middleware in theBaseServerconstructor and remove it fromProductionServer.apps/gateway/AGENTS.md:44already makesBaseServerthe owner of the middleware order, so note it in that section. I would skip the hook. It runs from the base constructor, before a subclass's fields are initialized, so aDevelopmentServeroverride would findthis.viteundefined.This also means the e2e test does apply. Playwright starts the gateway with
pnpm dev:test(testing/playwright.config.ts:49-54), andapps/gateway/scripts/dev.ts:29definesimport.meta.env.DEVastrue, so the suite runsDevelopmentServer. Compression is harmless there: in middleware mode Vite runs its HMR WebSocket on its own port (24678), which Express never sees.BREACH does not apply. The page reflects no request input (
idmust match a stored row, andlangresolves to a closedLanguageinsrc/lib/setup-state.ts:25-31), and its token is given to anyone who has the URL anyway.Tests
server.base.test.ts, against the real constructor: the issue's server-rendered-page case, plusit('should gzip an api response, so the sync response is not sent uncompressed')through the mockedPOST /api/echo. The page case cannot go inserver.production.test.ts, which replacesBaseServerwith a bare Express app (lines 33-39). Two traps:index.js:78).TEMPLATE(line 57) is far smaller, so pass a larger template asTestServer's second constructor argument.rootRouter(lines 48-55) callsres.end()with noContent-Type, and the default filter skips a response without a compressible type. SetContent-Type: text/htmlin the mock, asroot.router.ts:54does.server.production.test.ts: delete the static-asset case at lines 97-100. It fails against the mocked base onceProductionServerstops mountingcompression().testing/src/specs/gateway-assignment.spec.ts:seedAssignment(api), thenapiRequestContext.get(assignment.url, { headers: { 'Accept-Encoding': 'gzip' } }), and assert thatcontent-encodingisgzip. Playwright keeps the raw response headers, and no Cap solve is needed. Cancel the assignment afterwards, as lines 177-181 do.Related
RangeError, becauseres.jsonstringifies the whole body beforecompressionsees it.apps/apior libnest 9.0.0 mounts compression, and the rootCaddyfile's/api/*handler has noencode; onlyapps/web/Caddyfileandapps/playground/Caddyfiledo. That belongs in its own issue.Triage note written with Claude Code. Code references point at
main@d86740e.