Files
sencho/backend/src/middleware/normalizeAcceptEncoding.ts
T
Anso ed49ed6165 fix(http): strip zstd from Accept-Encoding so compression sets Content-Encoding (#1092)
compression@1.8.1's negotiator silently fails to set the Content-Encoding
response header when Accept-Encoding carries an unknown token like zstd,
even though the body is still compressed with brotli. Chromium 123+ sends
"Accept-Encoding: zstd, gzip, br" by default, including Playwright's
bundled Chromium over plain HTTP, so any homelab Sencho viewed without a
TLS terminator triggered ERR_CONTENT_DECODING_FAILED in the browser and
rendered as a blank page.

Insert a tiny middleware (step 4 of the canonical pipeline) that drops
zstd tokens from Accept-Encoding before compression's negotiator runs.
The negotiator then picks br or gzip and writes the matching header. The
fix is no-op when zstd is absent. If only zstd was offered, the header
falls back to identity so the response is served uncompressed.

Renumber the canonical-order JSDoc in app.ts from 17 to 18 steps and
update the step-number references in hub-only-guard and proxy-mount-order
test docstrings to match.

11 new tests (8 unit + 3 supertest integration against real compression
with a 3000-byte body) lock in the behavior: zstd-stripping across casing
and q-values, surviving tokens preserved verbatim, identity fallback when
only zstd was offered, Content-Encoding header always written when at
least one supported codec remains.
2026-05-17 20:11:44 -04:00

25 lines
1.1 KiB
TypeScript

import type { RequestHandler } from 'express';
// compression@1.8.1's encoding negotiation (via negotiator@1.0.0) silently
// breaks when Accept-Encoding carries an unknown token such as `zstd`, which
// Chromium 123+ sends by default and which Playwright's bundled Chromium sends
// even over plain HTTP. In that path the response body is still compressed
// (brotli) but the Content-Encoding response header is never set, so the
// browser decode-fails with ERR_CONTENT_DECODING_FAILED and the Sencho UI
// renders blank. Drop the offending token before compression sees it so the
// negotiator falls back to a supported encoding and the header is written.
export const normalizeAcceptEncoding: RequestHandler = (req, _res, next) => {
const raw = req.headers['accept-encoding'];
if (typeof raw !== 'string' || !/\bzstd\b/i.test(raw)) {
next();
return;
}
const filtered = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0 && !/^zstd(\s*;\s*q\s*=.*)?$/i.test(s))
.join(', ');
req.headers['accept-encoding'] = filtered || 'identity';
next();
};