Skip to content

Search docs

Jump between documentation pages.

Browse docs

Compression middleware

Think of it like…vacuum-sealing parcels for shipping: smaller, cheaper, faster to deliver. But you never vacuum-seal anything with a return address visible through the wrap (cookies, auth headers, CSRF tokens), because a thief watching the loading dock could measure the bulge and figure out what's inside. That's the BREACH attack, and that's why the middleware skips compression on sensitive headers and small responses by default.

Daloy ships a focused compression slice: a first-party compression() middleware that uses the web-standard CompressionStream API instead of a Node-only compression package.

ts
import { App, compression } from "@daloyjs/core";

const app = new App({ env: "production" });

app.use(compression());

What it compresses

The middleware negotiates br, gzip, and deflate from the request Accept-Encodingheader and the runtime codecs available through CompressionStream. Runtime support is probed once and cached. If the platform has no supported codec, the middleware becomes a silent no-op instead of breaking older runtimes.

The default minimumSize is 1024 bytes. Small responses are left alone, and Daloy also checks the compressed byte length after encoding. If compression made the payload larger, the original response is kept.

Security skip rules

Compression can become an oracle when secrets and attacker-controlled bytes share the same compressed response. Daloy keeps those guards built in rather than asking every app to remember the same list.

BREACH-aware skip decision
  1. onSendResponse readynegotiate br / gzip / deflate
  2. secretsSet-Cookie / Authorization / session cookie?skip, send identity
  3. not worth itBelow minimumSize or already encoded?skip, keep original
  4. safeCompress the bodyGET / HEAD, 2xx, text-like content
Responses that mix secrets with attacker-influenced bytes (Set-Cookie, Authorization, session/CSRF cookies) are never compressed, which closes the BREACH oracle. Tiny, already-encoded, or already-compressed payloads are skipped too. Vary: Accept-Encoding is still added either way.
  • Skips responses with Set-Cookie.
  • Skips requests with Authorization.
  • Skips requests carrying session, CSRF, XSRF, __Host-, or __Secure- cookies.
  • Skips any response that already has Content-Encoding.
  • Skips non-GET / non-HEAD requests and non-2xx responses.
  • Skips already-compressed content types such as images, video, audio, archives, fonts, WebAssembly, and PDFs. image/svg+xml is carved back in because it is XML text.

Cache and ETag behavior

Every response that reaches compression() gets Vary: Accept-Encoding appended (de-duplicated against any existing Varyvalue), even when Daloy decides not to compress that specific response. That keeps downstream caches keyed by the negotiation surface from the first response onward, so a cache can't serve a gzipped body to a client that only advertised identity.

If a compressed response already has a strong ETag such as "abc", Daloy downgrades it to W/"abc". The ETag was computed over the upstream body, not the compressed wire bytes, so a weak validator is the honest one, RFC 9110 §8.8.1 requires strong validators to be byte-equal to the representation on the wire, and the wire bytes change per encoding.

Interaction with etag()

compression() and etag() are safe to combine in either order. Both run as onSend hooks:

  • If etag() runs first it sets a strong ETag over the uncompressed body. compression() then encodes the body and downgrades the strong ETag to weak so the validator stays consistent with what actually leaves the server.
  • If compression() runs first it encodes the body. etag() then hashes the already-compressed bytes, which is also valid, the strong tag still byte-matches the wire bytes the client receives.

Either way, conditional GETs using If-None-Match stay correct across br, gzip, deflate, and identity clients because the Vary: Accept-Encodingheader forces per-encoding cache keys. You don't have to manage the weak/strong downgrade yourself, even if you set ETag manually from a route handler, compression() performs the downgrade for you on the way out.

No compression level knob

CompressionStream uses the runtime default. Daloy refuses any compressLevel option at construction, including 6, because exposing the knob invites expensive level-9 compression for tiny byte savings under load.

ts
app.use(compression({
  minimumSize: 2 * 1024,
  encodings: ["gzip"],
  authCookieNames: ["tenant-auth"],
  excludeContentTypes: ["application/x-parquet"],
}));

Ordering

Register compression() after middleware that may add Set-Cookie, Content-Encoding, or ETag headers. Daloy runs onSend hooks in registration order, so the compression hook should see the final response headers before it decides whether to encode the body.