From 6d5afe55db90c42050ce37433b64cf5436cf4602 Mon Sep 17 00:00:00 2001 From: jwcrystal <121911854+jwcrystal@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:12:20 +0800 Subject: [PATCH] fix: harden SSE compression exclusion and add Caddy reverse proxy docs (#939) The compression middleware filter runs before route handlers, so the res.getHeader('Content-Type') check in shouldSkipCompression is always undefined at decision time. SSE exclusion relied entirely on the Accept header, which non-standard clients (curl, fetch) may omit. Add deterministic path-based exclusion for all known SSE routes so compression is skipped regardless of client behavior. Also add a Caddy reverse proxy example and a CDN double-compression warning to docs. --- docs/REVERSE_PROXY.md | 46 +++++++++++++++++++- packages/docs/content/docs/reverse-proxy.mdx | 46 ++++++++++++++++++++ packages/web/server/index.js | 26 +++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/docs/REVERSE_PROXY.md b/docs/REVERSE_PROXY.md index 288aeb21..b0c368d8 100644 --- a/docs/REVERSE_PROXY.md +++ b/docs/REVERSE_PROXY.md @@ -290,6 +290,48 @@ Also enable `Websockets Support` in Nginx Proxy Manager for this host. - the proxy is compressing and buffering live traffic - the proxy is missing WebSocket support -## Website docs +## Example: Caddy -- Website version: `packages/docs/content/docs/reverse-proxy.mdx` +
+Show example config + +```caddy +reverse_proxy 127.0.0.1:3000 { + # WebSocket support is automatic in Caddy + + # Flush SSE responses immediately + flush_interval -1 + + # Pass through Host and proxy headers + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + + # Increase timeouts for long-lived streams + transport http { + read_timeout 3600s + write_timeout 3600s + } +} +``` + +
+ +Caddy handles WebSocket upgrades automatically — no extra configuration needed. The `flush_interval -1` directive ensures SSE chunks are forwarded immediately without buffering. + +## CDN and double-compression warning + +If you place a CDN (such as Cloudflare) in front of your reverse proxy, be aware of double compression: + +- OpenChamber compresses HTTP responses with gzip (threshold 1 KB). +- Cloudflare and other CDNs also compress responses by default. +- This can cause double-compressed responses or incorrect `Content-Encoding` headers. + +To avoid this, disable compression at **one** layer: + +- **Cloudflare:** Rules → Compression → disable (or use "Passthrough" mode). +- **Nginx:** `gzip off` (already shown in the examples above). +- **Caddy:** Caddy does not re-compress by default if the upstream already sends compressed content. + +SSE streaming routes are excluded from compression by OpenChamber, but the CDN may still buffer them. Check your CDN documentation for how to disable buffering on SSE paths. diff --git a/packages/docs/content/docs/reverse-proxy.mdx b/packages/docs/content/docs/reverse-proxy.mdx index 3951e35d..4085690a 100644 --- a/packages/docs/content/docs/reverse-proxy.mdx +++ b/packages/docs/content/docs/reverse-proxy.mdx @@ -295,6 +295,52 @@ Also enable `Websockets Support` in Nginx Proxy Manager for this host. - the proxy is compressing and buffering live traffic - the proxy is missing WebSocket support +## Example: Caddy + +
+Show example config + +```caddy +reverse_proxy 127.0.0.1:3000 { + # WebSocket support is automatic in Caddy + + # Flush SSE responses immediately + flush_interval -1 + + # Pass through Host and proxy headers + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + + # Increase timeouts for long-lived streams + transport http { + read_timeout 3600s + write_timeout 3600s + } +} +``` + +
+ +Caddy handles WebSocket upgrades automatically — no extra configuration needed. The `flush_interval -1` directive ensures SSE chunks are forwarded immediately without buffering. + +## CDN and double-compression warning + +If you place a CDN (such as Cloudflare) in front of your reverse proxy, be aware of double compression: + +- OpenChamber compresses HTTP responses with gzip (threshold 1 KB). +- Cloudflare and other CDNs also compress responses by default. +- This can cause double-compressed responses or incorrect `Content-Encoding` headers. + +To avoid this, disable compression at **one** layer: + +- **Cloudflare:** Rules → Compression → disable (or use "Passthrough" mode). +- **Nginx:** `gzip off` (already shown in the examples above). +- **Caddy:** Caddy does not re-compress by default if the upstream already sends compressed content. + +SSE streaming routes are excluded from compression by OpenChamber, but the CDN may still buffer them. Check your CDN documentation for how to disable buffering on SSE paths. + ## Related - [Tunnels](/tunnels/) diff --git a/packages/web/server/index.js b/packages/web/server/index.js index c3a5f8fa..4a5aea38 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -109,11 +109,37 @@ function headerIncludesEventStream(value) { return false; } +/** + * SSE endpoint paths that must never be compressed by the compression middleware. + * + * The compression middleware filter runs before route handlers, so + * `res.getHeader('Content-Type')` is still undefined at that point. + * This means the Accept-header check alone is not sufficient for + * non-standard clients (e.g. curl, fetch) that omit Accept. + * Path-based exclusion acts as a deterministic fallback. + */ +const SSE_PATH_PREFIXES = [ + '/api/event', + '/api/global/event', + '/api/notifications/stream', + '/api/openchamber/events', +]; + function shouldSkipCompression(req, res) { if (headerIncludesEventStream(req.headers.accept)) { return true; } + const pathname = req.path || req.url || ''; + if (pathname.startsWith('/api/terminal/') && pathname.endsWith('/stream')) { + return true; + } + for (const prefix of SSE_PATH_PREFIXES) { + if (pathname === prefix) { + return true; + } + } + return headerIncludesEventStream(res.getHeader('Content-Type')); }