Skip to content

Gateway never compresses its server-rendered pages or API responses because compression is mounted after the routes #1729

Open

Description

@joshunrau

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

  1. Build and start the gateway in production mode, and create an assignment for the Enhanced Demographics Questionnaire.
  2. 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.ts mocks BaseServer with a bare Express app, which is why it only proves compression for static assets. Add it('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 compression at 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

  1. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Gatewayapps/gateway (patient-facing app for remote assignments)
    PerformanceSpeed, memory or scalability
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing has touched apps/gateway/src/server since this was filed, and no open PR touches server.base.ts, server.production.ts or their tests.

    The repro needs a GET

    curl -sI sends HEAD, and compression@1.8.2 never encodes a HEAD response (index.js:205-206), so step 2 prints no Content-Encoding even after the fix. Use curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' <url>. With the repo's express@5.2.1 and compression() mounted before the router, that GET returns gzip and the HEAD still returns nothing.

    Most real clients will get br, not gzip: compression prefers br whenever it is offered (index.js:45-46). The API's axios@1.20.0 client offers gzip, compress, deflate, br and decompresses on its own, and createGatewayHttpOptions does not turn that off, so the API needs no change. Playwright's request context offers gzip,deflate,br by default, so a test that asserts gzip must send Accept-Encoding: gzip.

    Mount it in BaseServer, in both modes

    Make this.app.use(compression()) the first middleware in the BaseServer constructor and remove it from ProductionServer. apps/gateway/AGENTS.md:44 already makes BaseServer the 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 a DevelopmentServer override would find this.vite undefined.

    This also means the e2e test does apply. Playwright starts the gateway with pnpm dev:test (testing/playwright.config.ts:49-54), and apps/gateway/scripts/dev.ts:29 defines import.meta.env.DEV as true, so the suite runs DevelopmentServer. 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 (id must match a stored row, and lang resolves to a closed Language in src/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, plus it('should gzip an api response, so the sync response is not sent uncompressed') through the mocked POST /api/echo. The page case cannot go in server.production.test.ts, which replaces BaseServer with a bare Express app (lines 33-39). Two traps:
      • Both bodies must be over the 1 KB default threshold (index.js:78). TEMPLATE (line 57) is far smaller, so pass a larger template as TestServer's second constructor argument.
      • The mocked rootRouter (lines 48-55) calls res.end() with no Content-Type, and the default filter skips a response without a compressible type. Set Content-Type: text/html in the mock, as root.router.ts:54 does.
    • server.production.test.ts: delete the static-asset case at lines 97-100. It fails against the mocked base once ProductionServer stops mounting compression().
    • e2e, in testing/src/specs/gateway-assignment.spec.ts: seedAssignment(api), then apiRequestContext.get(assignment.url, { headers: { 'Accept-Encoding': 'gzip' } }), and assert that content-encoding is gzip. Playwright keeps the raw response headers, and no Cap solve is needed. Cancel the assignment afterwards, as lines 177-181 do.

    Related

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Gatewayapps/gateway (patient-facing app for remote assignments)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPerformanceSpeed, memory or scalabilityPriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions