mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 12:44:12 +00:00
feat(server): add opt-in HTTP gzip and WebSocket permessage-deflate compression (#934)
## 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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
OpenClaw Bot
efiten-bot
parent
ba6c2ac6ba
commit
caf3851ff8
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
// Tests added in response to PR #934 review findings. These tests demonstrate
|
||||
// the four behaviors the original implementation lacked:
|
||||
//
|
||||
// 1. gzipResponseWriter must implement http.Flusher (SSE / streaming).
|
||||
// 2. gzipResponseWriter must implement http.Hijacker (WebSocket / raw conn).
|
||||
// 3. gzip.Writer instances must be pooled (sync.Pool) to avoid the
|
||||
// ~256KB window allocation per request.
|
||||
// 4. A content-type allow-list must skip already-compressed payloads
|
||||
// (images, video, application/x-gzip, …) and must skip responses
|
||||
// whose handler already set its own Content-Encoding header.
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGZipResponseWriter_ImplementsFlusher(t *testing.T) {
|
||||
seen := false
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := w.(http.Flusher); ok {
|
||||
seen = true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/api/events", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if !seen {
|
||||
t.Error("gzipResponseWriter must implement http.Flusher (required for SSE / streaming endpoints)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipResponseWriter_ImplementsHijacker(t *testing.T) {
|
||||
seen := false
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := w.(http.Hijacker); ok {
|
||||
seen = true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
srv := httptest.NewServer(handler)
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest("GET", srv.URL+"/api/x", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if !seen {
|
||||
t.Error("gzipResponseWriter must implement http.Hijacker (required for raw conn / WebSocket upgrade)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddleware_SkipsImageContentType(t *testing.T) {
|
||||
payload := strings.Repeat("\x89PNGfakebinary", 64)
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write([]byte(payload))
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/tiles/1.png", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if got := rr.Header().Get("Content-Encoding"); got == "gzip" {
|
||||
t.Errorf("image/png responses must NOT be gzip-encoded, got Content-Encoding=%q", got)
|
||||
}
|
||||
if rr.Body.String() != payload {
|
||||
t.Errorf("image body was mutated; expected pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddleware_SkipsAlreadyEncodedResponses(t *testing.T) {
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Encoding", "br")
|
||||
w.Write([]byte("alreadybrotlied"))
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/api/x", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if got := rr.Header().Get("Content-Encoding"); got != "br" {
|
||||
t.Errorf("handler-set Content-Encoding must be preserved, got %q (gzip middleware double-wrapped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddleware_AllowsJSON(t *testing.T) {
|
||||
body := `{"nodes":[{"id":"abc"}]}`
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/api/nodes", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Fatalf("application/json must still be compressed, got %q", rr.Header().Get("Content-Encoding"))
|
||||
}
|
||||
gz, err := gzip.NewReader(rr.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid gzip: %v", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
decoded, _ := io.ReadAll(gz)
|
||||
if string(decoded) != body {
|
||||
t.Errorf("decoded=%q, want %q", string(decoded), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddleware_PoolReusesWriters(t *testing.T) {
|
||||
body := strings.Repeat("x", 1024)
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
// Warm the pool: first N requests pay the one-time allocation cost.
|
||||
for i := 0; i < 16; i++ {
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
runtime.GC()
|
||||
var before runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
|
||||
const N = 200
|
||||
for i := 0; i < N; i++ {
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
var after runtime.MemStats
|
||||
runtime.ReadMemStats(&after)
|
||||
allocBytes := after.TotalAlloc - before.TotalAlloc
|
||||
|
||||
// Each gzip.Writer carries a ~256KB sliding window. Without a sync.Pool,
|
||||
// N=200 requests allocate roughly N * 256KB = 50MB. With a pool the
|
||||
// per-request alloc footprint should be a tiny fraction of that.
|
||||
// 10MB ceiling gives generous headroom for testing.AllocsPerRun noise
|
||||
// while still catching a regression to the unpooled implementation.
|
||||
if allocBytes > 10*1024*1024 {
|
||||
t.Errorf("gzip.Writer not pooled: %d bytes allocated across %d requests (expected ≤10MB)", allocBytes, N)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompressionConfigDefaults(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if cfg.GZipEnabled() {
|
||||
t.Error("GZipEnabled should be false when compression is nil")
|
||||
}
|
||||
if cfg.WSCompressionEnabled() {
|
||||
t.Error("WSCompressionEnabled should be false when compression is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressionConfigExplicitFalse(t *testing.T) {
|
||||
cfg := &Config{Compression: &CompressionConfig{GZip: false, Websocket: false}}
|
||||
if cfg.GZipEnabled() {
|
||||
t.Error("GZipEnabled should be false")
|
||||
}
|
||||
if cfg.WSCompressionEnabled() {
|
||||
t.Error("WSCompressionEnabled should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressionConfigEnabled(t *testing.T) {
|
||||
cfg := &Config{Compression: &CompressionConfig{GZip: true, Websocket: true}}
|
||||
if !cfg.GZipEnabled() {
|
||||
t.Error("GZipEnabled should be true")
|
||||
}
|
||||
if !cfg.WSCompressionEnabled() {
|
||||
t.Error("WSCompressionEnabled should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddlewareCompresses(t *testing.T) {
|
||||
body := `{"nodes":[{"id":"abc"}]}`
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Errorf("expected Content-Encoding: gzip, got %q", rr.Header().Get("Content-Encoding"))
|
||||
}
|
||||
if rr.Header().Get("Vary") != "Accept-Encoding" {
|
||||
t.Errorf("expected Vary: Accept-Encoding, got %q", rr.Header().Get("Vary"))
|
||||
}
|
||||
gz, err := gzip.NewReader(rr.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("response is not valid gzip: %v", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
decoded, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
t.Fatalf("reading gzip: %v", err)
|
||||
}
|
||||
if string(decoded) != body {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddlewareSkipsNoAcceptEncoding(t *testing.T) {
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("hello"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Header().Get("Content-Encoding") != "" {
|
||||
t.Errorf("expected no Content-Encoding, got %q", rr.Header().Get("Content-Encoding"))
|
||||
}
|
||||
if rr.Body.String() != "hello" {
|
||||
t.Errorf("expected plain body, got %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddlewareSkipsWebSocket(t *testing.T) {
|
||||
called := false
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.Write([]byte("ws"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if !called {
|
||||
t.Error("expected next handler to be called")
|
||||
}
|
||||
if rr.Header().Get("Content-Encoding") != "" {
|
||||
t.Errorf("WebSocket should not be gzip-encoded, got %q", rr.Header().Get("Content-Encoding"))
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ type Config struct {
|
||||
obsBlacklistSetCached map[string]bool
|
||||
obsBlacklistOnce sync.Once
|
||||
|
||||
Compression *CompressionConfig `json:"compression,omitempty"`
|
||||
ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"`
|
||||
NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"`
|
||||
|
||||
@@ -127,6 +128,39 @@ func IsWeakAPIKey(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CompressionConfig controls HTTP gzip and WebSocket permessage-deflate compression.
|
||||
// Both are disabled by default — enable only when the upstream proxy does not already compress.
|
||||
type CompressionConfig struct {
|
||||
GZip bool `json:"gzip"`
|
||||
Websocket bool `json:"websocket"`
|
||||
|
||||
// Level is the gzip compression level (1=BestSpeed … 9=BestCompression).
|
||||
// 0 / out-of-range means "use compress/gzip.DefaultCompression".
|
||||
Level int `json:"level,omitempty"`
|
||||
|
||||
// MinSizeBytes is an advisory minimum response size below which gzip
|
||||
// would not pay off. Currently informational — kept here so operators
|
||||
// can express intent and so future small-body fast-paths can use it.
|
||||
MinSizeBytes int `json:"minSizeBytes,omitempty"`
|
||||
|
||||
// ContentTypes overrides the default compressible-MIME allow-list. When
|
||||
// empty, a conservative default (application/json, text/html, text/css,
|
||||
// application/javascript, text/plain, image/svg+xml, application/xml)
|
||||
// is used. Already-compressed types (image/*, video/*, application/zip,
|
||||
// application/x-gzip, …) are always skipped.
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
}
|
||||
|
||||
// GZipEnabled returns true when HTTP gzip compression is explicitly enabled.
|
||||
func (c *Config) GZipEnabled() bool {
|
||||
return c.Compression != nil && c.Compression.GZip
|
||||
}
|
||||
|
||||
// WSCompressionEnabled returns true when WebSocket permessage-deflate is explicitly enabled.
|
||||
func (c *Config) WSCompressionEnabled() bool {
|
||||
return c.Compression != nil && c.Compression.Websocket
|
||||
}
|
||||
|
||||
// ResolvedPathConfig controls async backfill behavior.
|
||||
type ResolvedPathConfig struct {
|
||||
BackfillHours int `json:"backfillHours"` // how far back (hours) to scan for NULL resolved_path (default 24)
|
||||
|
||||
+10
-1
@@ -270,6 +270,7 @@ func main() {
|
||||
|
||||
// WebSocket hub
|
||||
hub := NewHub()
|
||||
hub.upgrader.EnableCompression = cfg.WSCompressionEnabled()
|
||||
|
||||
// HTTP server
|
||||
srv := NewServer(database, cfg, hub)
|
||||
@@ -359,9 +360,17 @@ func main() {
|
||||
_ = cfg.NeighborMaxAgeDays() // ditto — owned by ingestor now
|
||||
|
||||
// Graceful shutdown
|
||||
var handler http.Handler = router
|
||||
if cfg.GZipEnabled() {
|
||||
handler = gzipMiddlewareWithConfig(cfg.Compression, router)
|
||||
log.Printf("[server] HTTP gzip compression enabled")
|
||||
}
|
||||
if cfg.WSCompressionEnabled() {
|
||||
log.Printf("[server] WebSocket permessage-deflate compression enabled")
|
||||
}
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: router,
|
||||
Handler: handler,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
|
||||
@@ -11,16 +11,11 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// Hub manages WebSocket clients and broadcasts.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// Client is a single WebSocket connection.
|
||||
@@ -33,6 +28,11 @@ type Client struct {
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*Client]bool),
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func (h *Hub) Broadcast(msg interface{}) {
|
||||
|
||||
// ServeWS handles the WebSocket upgrade and runs the client.
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade error: %v", err)
|
||||
return
|
||||
|
||||
@@ -244,6 +244,23 @@
|
||||
"_comment": "Voltage cutoffs (millivolts) for the per-node battery trend chart on /node-analytics. Latest sample below lowMv shows the node as ⚠️ Low; below criticalMv shows 🪫 Critical. Both default to 3300 / 3000 if omitted. Source data: observer_metrics.battery_mv populated from observer status messages; only nodes that are themselves observers (matching pubkey ↔ observer id) yield a series. Issue #663."
|
||||
},
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
|
||||
"compression": {
|
||||
"gzip": false,
|
||||
"websocket": false,
|
||||
"level": 6,
|
||||
"minSizeBytes": 1024,
|
||||
"contentTypes": [
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/xml",
|
||||
"text/html",
|
||||
"text/css",
|
||||
"text/plain",
|
||||
"image/svg+xml"
|
||||
]
|
||||
},
|
||||
"_comment_compression": "Opt-in HTTP gzip middleware + WebSocket permessage-deflate. Both default to false — enable ONLY when your upstream reverse proxy is NOT already compressing. gzip: enables the gzipMiddleware wrapper around the HTTP handler. websocket: sets gorilla websocket Upgrader.EnableCompression. level: gzip compression level 1..9 (1=BestSpeed, 9=BestCompression, default 6). minSizeBytes: advisory minimum response size below which compression would not pay off. contentTypes: MIME allow-list — only responses with these Content-Type values are compressed. Already-compressed types (image/*, video/*, audio/*, application/zip, application/x-gzip, application/pdf, application/octet-stream) are always skipped, as are responses whose handler already set Content-Encoding. Omit contentTypes to use the built-in default allow-list.",
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional).",
|
||||
"_comment_channelKeys": "Hex keys for decrypting channel messages. Key name = channel display name. public channel key is well-known.",
|
||||
"_comment_hashChannels": "Channel names whose keys are derived via SHA256. Key = SHA256(name)[:16]. Listed here so the ingestor can auto-derive keys.",
|
||||
"_comment_defaultRegion": "IATA code shown by default in region filters.",
|
||||
|
||||
Reference in New Issue
Block a user