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.
This commit is contained in:
+44
-2
@@ -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 compressing and buffering live traffic
|
||||||
- the proxy is missing WebSocket support
|
- the proxy is missing WebSocket support
|
||||||
|
|
||||||
## Website docs
|
## Example: Caddy
|
||||||
|
|
||||||
- Website version: `packages/docs/content/docs/reverse-proxy.mdx`
|
<details>
|
||||||
|
<summary>Show example config</summary>
|
||||||
|
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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 compressing and buffering live traffic
|
||||||
- the proxy is missing WebSocket support
|
- the proxy is missing WebSocket support
|
||||||
|
|
||||||
|
## Example: Caddy
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show example config</summary>
|
||||||
|
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
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
|
## Related
|
||||||
|
|
||||||
- [Tunnels](/tunnels/)
|
- [Tunnels](/tunnels/)
|
||||||
|
|||||||
@@ -109,11 +109,37 @@ function headerIncludesEventStream(value) {
|
|||||||
return false;
|
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) {
|
function shouldSkipCompression(req, res) {
|
||||||
if (headerIncludesEventStream(req.headers.accept)) {
|
if (headerIncludesEventStream(req.headers.accept)) {
|
||||||
return true;
|
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'));
|
return headerIncludesEventStream(res.getHeader('Content-Type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user