mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-24 07:25:05 +00:00
caf3851ff8
## Summary
- Adds `"compression": {"gzip": true, "websocket": true}` config option
(both `false` by default — no behavior change)
- HTTP gzip middleware wraps the entire router; skips WebSocket upgrade
requests and clients without `Accept-Encoding: gzip`
- WebSocket permessage-deflate enabled via
`hub.upgrader.EnableCompression` when `websocket: true`
- `CompressionConfig` struct and `GZipEnabled()` /
`WSCompressionEnabled()` helpers on `Config`
- `Hub.upgrader` moved from package-level var to struct field so tests
using `NewHub()` don't need changes
## Why opt-in / off by default
Operators behind a reverse proxy that already compresses (nginx, Caddy
with `encode gzip`) should leave this off to avoid double-compression.
Only enable when the proxy does **not** compress.
## Test plan
- [x] `TestCompressionConfigDefaults` — both helpers return false when
`Compression` is nil
- [x] `TestCompressionConfigExplicitFalse` — both helpers return false
when set to false
- [x] `TestCompressionConfigEnabled` — both helpers return true when set
to true
- [x] `TestGZipMiddlewareCompresses` — response body is valid gzip,
headers set correctly
- [x] `TestGZipMiddlewareSkipsNoAcceptEncoding` — passthrough when
client doesn't send Accept-Encoding: gzip
- [x] `TestGZipMiddlewareSkipsWebSocket` — WebSocket upgrades are never
gzip-wrapped
All 6 tests pass (`go test ./...` in `cmd/server`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: OpenClaw Bot <bot@openclaw.local>
Co-authored-by: efiten-bot <bot@efiten.dev>
249 lines
7.3 KiB
Go
249 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"compress/gzip"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// gzipWriterPool pools *gzip.Writer instances to avoid the ~256KB sliding
|
|
// window allocation on every compressed response. Writers are Reset() to the
|
|
// new underlying writer on Get and returned via gzipPut after Close.
|
|
//
|
|
// We use a bounded buffered channel rather than sync.Pool because sync.Pool
|
|
// is aggressively reaped by the GC (full clear after two GC cycles), which
|
|
// makes it lose its pooled entries under any workload that triggers GC —
|
|
// notably the -race-enabled test suite where allocations are inflated ~8x
|
|
// and GC fires repeatedly during a 200-request loop. A channel keeps the
|
|
// gzip.Writer instances live across GC cycles, which is exactly the
|
|
// guarantee `TestGZipMiddleware_PoolReusesWriters` asserts.
|
|
const gzipPoolCapacity = 64
|
|
|
|
var gzipWriterPool = make(chan *gzip.Writer, gzipPoolCapacity)
|
|
|
|
func gzipGet() *gzip.Writer {
|
|
select {
|
|
case gz := <-gzipWriterPool:
|
|
return gz
|
|
default:
|
|
// gzip.NewWriterLevel only errors on invalid level; DefaultCompression
|
|
// is always valid, so the error branch is unreachable. Fall back to
|
|
// the default writer (same level) so we always return a usable writer.
|
|
gz, err := gzip.NewWriterLevel(discardWriter{}, gzip.DefaultCompression)
|
|
if err != nil {
|
|
return gzip.NewWriter(discardWriter{})
|
|
}
|
|
return gz
|
|
}
|
|
}
|
|
|
|
func gzipPut(gz *gzip.Writer) {
|
|
// Reset to a no-op writer so the pooled instance does not retain a
|
|
// reference to the previous http.ResponseWriter (which would defeat GC
|
|
// of the request's allocations).
|
|
gz.Reset(discardWriter{})
|
|
select {
|
|
case gzipWriterPool <- gz:
|
|
default:
|
|
// Pool full; drop the writer and let GC reclaim it.
|
|
}
|
|
}
|
|
|
|
type discardWriter struct{}
|
|
|
|
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
|
|
// defaultCompressibleTypes is the conservative allow-list of MIME types the
|
|
// middleware will gzip-encode. Anything already compressed (images, video,
|
|
// fonts, octet-stream, x-gzip, …) bypasses the encoder entirely.
|
|
var defaultCompressibleTypes = []string{
|
|
"application/json",
|
|
"application/javascript",
|
|
"application/x-javascript",
|
|
"application/xml",
|
|
"text/html",
|
|
"text/css",
|
|
"text/plain",
|
|
"text/xml",
|
|
"image/svg+xml",
|
|
}
|
|
|
|
// gzipResponseWriter wraps http.ResponseWriter and compresses Write() output
|
|
// only when the response Content-Type matches the configured allow-list and
|
|
// no upstream handler has already set Content-Encoding. It also propagates
|
|
// Flush / Hijack to the underlying writer (required for SSE and WebSocket).
|
|
type gzipResponseWriter struct {
|
|
http.ResponseWriter
|
|
gz *gzip.Writer
|
|
level int
|
|
allowedTypes []string
|
|
wroteHeader bool
|
|
compressActive bool
|
|
}
|
|
|
|
// init lazily decides per response whether to compress, based on the response
|
|
// headers the inner handler has set. We must defer this until WriteHeader (or
|
|
// the first Write call) because Content-Type is set by the handler, not the
|
|
// middleware.
|
|
func (g *gzipResponseWriter) init() {
|
|
if g.wroteHeader {
|
|
return
|
|
}
|
|
g.wroteHeader = true
|
|
|
|
h := g.ResponseWriter.Header()
|
|
// Don't double-encode.
|
|
if h.Get("Content-Encoding") != "" {
|
|
g.compressActive = false
|
|
return
|
|
}
|
|
if !isCompressibleContentType(h.Get("Content-Type"), g.allowedTypes) {
|
|
g.compressActive = false
|
|
return
|
|
}
|
|
|
|
// Lease a writer from the pool and rebind it to the real ResponseWriter.
|
|
gz := gzipGet()
|
|
gz.Reset(g.ResponseWriter)
|
|
g.gz = gz
|
|
g.compressActive = true
|
|
|
|
h.Set("Content-Encoding", "gzip")
|
|
h.Add("Vary", "Accept-Encoding")
|
|
// gzip stream length is unknown — strip any precomputed length.
|
|
h.Del("Content-Length")
|
|
}
|
|
|
|
func (g *gzipResponseWriter) WriteHeader(code int) {
|
|
g.init()
|
|
g.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
|
|
g.init()
|
|
if !g.compressActive {
|
|
return g.ResponseWriter.Write(b)
|
|
}
|
|
return g.gz.Write(b)
|
|
}
|
|
|
|
// Flush propagates to the underlying writer so SSE / streaming handlers can
|
|
// push chunks to the client immediately. We must also flush the gzip writer
|
|
// when active, otherwise the buffered DEFLATE block never reaches the wire.
|
|
func (g *gzipResponseWriter) Flush() {
|
|
if g.compressActive && g.gz != nil {
|
|
_ = g.gz.Flush()
|
|
}
|
|
if f, ok := g.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// Hijack delegates to the underlying writer's Hijacker. We refuse to hijack a
|
|
// connection that has already started a gzip stream — that would leave the
|
|
// caller with a half-written DEFLATE block.
|
|
func (g *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
if h, ok := g.ResponseWriter.(http.Hijacker); ok {
|
|
return h.Hijack()
|
|
}
|
|
return nil, nil, http.ErrNotSupported
|
|
}
|
|
|
|
// close releases the pooled gzip.Writer back to the pool.
|
|
func (g *gzipResponseWriter) close() {
|
|
if g.gz == nil {
|
|
return
|
|
}
|
|
_ = g.gz.Close()
|
|
gzipPut(g.gz)
|
|
g.gz = nil
|
|
}
|
|
|
|
// isCompressibleContentType returns true if ct matches one of allow (which
|
|
// is the configured allow-list, or defaultCompressibleTypes). Matching is
|
|
// done on the bare MIME type, ignoring any "; charset=..." parameters.
|
|
func isCompressibleContentType(ct string, allow []string) bool {
|
|
if ct == "" {
|
|
// No content-type set → handler hasn't decided yet. Refuse to
|
|
// compress; we cannot guess. Most real handlers set Content-Type
|
|
// before the first Write.
|
|
return false
|
|
}
|
|
mt := ct
|
|
if idx := strings.Index(mt, ";"); idx >= 0 {
|
|
mt = mt[:idx]
|
|
}
|
|
mt = strings.TrimSpace(strings.ToLower(mt))
|
|
|
|
// Hard skip: anything that is already compressed.
|
|
if strings.HasPrefix(mt, "image/") && mt != "image/svg+xml" {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(mt, "video/") || strings.HasPrefix(mt, "audio/") {
|
|
return false
|
|
}
|
|
switch mt {
|
|
case "application/x-gzip", "application/gzip", "application/zip",
|
|
"application/x-bzip2", "application/x-7z-compressed",
|
|
"application/x-rar-compressed", "application/x-zstd",
|
|
"application/octet-stream", "application/pdf":
|
|
return false
|
|
}
|
|
|
|
if len(allow) == 0 {
|
|
allow = defaultCompressibleTypes
|
|
}
|
|
for _, a := range allow {
|
|
if strings.EqualFold(mt, a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// gzipMiddleware compresses HTTP responses when the client supports gzip and
|
|
// the response Content-Type is in the allow-list. WebSocket upgrade requests
|
|
// pass through unmodified. The middleware uses the default allow-list and
|
|
// gzip.DefaultCompression — for configurable behaviour use
|
|
// gzipMiddlewareWithConfig.
|
|
func gzipMiddleware(next http.Handler) http.Handler {
|
|
return gzipMiddlewareWithConfig(nil, next)
|
|
}
|
|
|
|
// gzipMiddlewareWithConfig is the configurable form of gzipMiddleware. When
|
|
// cfg is nil, defaults (gzip.DefaultCompression, defaultCompressibleTypes)
|
|
// are used.
|
|
func gzipMiddlewareWithConfig(cfg *CompressionConfig, next http.Handler) http.Handler {
|
|
level := gzip.DefaultCompression
|
|
var allow []string
|
|
if cfg != nil {
|
|
if cfg.Level >= gzip.BestSpeed && cfg.Level <= gzip.BestCompression {
|
|
level = cfg.Level
|
|
}
|
|
if len(cfg.ContentTypes) > 0 {
|
|
allow = cfg.ContentTypes
|
|
}
|
|
}
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
grw := &gzipResponseWriter{
|
|
ResponseWriter: w,
|
|
level: level,
|
|
allowedTypes: allow,
|
|
}
|
|
defer grw.close()
|
|
next.ServeHTTP(grw, r)
|
|
})
|
|
}
|