Skip to content

Commit bedc545

Browse files
committed
feat(web): negotiate response compression via zstd and gzip
The UI ships 167 kB of assets and polls a 5.8 kB payload every 5 seconds, all of it uncompressed text. Responses now go through gzhttp as the innermost middleware. The wrapper enables zstd next to gzip and prefers it at equal q-values, so Chrome, Edge and Firefox — which send "gzip, deflate, br, zstd" — receive zstd, and clients without zstd fall back to gzip. First page load drops from ~140 kB to ~28 kB, a dashboard poll from 5.8 kB to 1.6 kB. Accept-Encoding qvalues, bodiless statuses, ranged requests and content sniffing are the library's problem, not ours. The middleware chain moves into wrapMiddleware so the two construction sites cannot drift apart. Handlers that call WriteHeader before their first body write (LivenessHandler, logoutHandler) now set Content-Type explicitly: the wrapper can only sniff a missing type on the first write, and that sniff would otherwise run on the compressed bytes and answer application/x-gzip. Signed-off-by: Kamil Pešek <pesek.kamil@seznam.cz>
1 parent 294a216 commit bedc545

7 files changed

Lines changed: 349 additions & 14 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
github.com/go-viper/mapstructure/v2 v2.5.0
1919
github.com/gobs/args v0.0.0-20210311043657-b8c0b223be93
2020
github.com/jessevdk/go-flags v1.6.1
21+
github.com/klauspost/compress v1.19.2
2122
github.com/manifoldco/promptui v0.9.0
2223
github.com/moby/moby/api v1.55.0
2324
github.com/moby/moby/client v0.5.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7979
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
8080
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
8181
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
82+
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
83+
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
8284
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
8385
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
8486
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

web/compress.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025-2026 Netresearch DTT GmbH
2+
// SPDX-License-Identifier: MIT
3+
4+
package web
5+
6+
import (
7+
"net/http"
8+
9+
"github.com/klauspost/compress/gzhttp"
10+
)
11+
12+
// compressMiddleware compresses responses for clients that advertise a
13+
// codec it supports; clients that advertise none get identity responses
14+
// untouched.
15+
//
16+
// The negotiated codec is zstd or gzip, not gzip alone:
17+
// gzhttp.NewWrapper enables zstd and prefers it over gzip at equal
18+
// q-values, so Chrome, Edge and Firefox — which all send
19+
// "gzip, deflate, br, zstd" — receive zstd, while Safari and anything
20+
// else without zstd falls back to gzip. Both directions are pinned in
21+
// web/compress_test.go.
22+
//
23+
// The edge cases — Accept-Encoding qvalues, Content-Type sniffing of the
24+
// uncompressed bytes, bodiless statuses (204/304), ranged requests,
25+
// writer pooling — are delegated to gzhttp instead of being maintained
26+
// by hand. MinSize(0) keeps the previous contract: everything this
27+
// server produces is textual, so compression applies across the board.
28+
var compressWrap = func() func(http.Handler) http.HandlerFunc {
29+
wrapper, err := gzhttp.NewWrapper(gzhttp.MinSize(0))
30+
if err != nil {
31+
// Static configuration; can only fail on an invalid option.
32+
panic(err)
33+
}
34+
return wrapper
35+
}()
36+
37+
func compressMiddleware(next http.Handler) http.Handler {
38+
return compressWrap(next)
39+
}

web/compress_internal_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025-2026 Netresearch DTT GmbH
2+
// SPDX-License-Identifier: MIT
3+
4+
package web
5+
6+
import (
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
)
11+
12+
// TestCompressMiddlewareBodilessResponse pins the bodiless-status guard: a
13+
// 304 (or 204) must not advertise Content-Encoding: gzip and must not
14+
// grow an empty gzip stream as a body — httptest.ResponseRecorder,
15+
// unlike net/http, records every stray write, so a leaked ~23-byte
16+
// footer would show up here.
17+
func TestCompressMiddlewareBodilessResponse(t *testing.T) {
18+
t.Parallel()
19+
20+
for _, code := range []int{http.StatusNoContent, http.StatusNotModified} {
21+
h := compressMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
22+
w.WriteHeader(code)
23+
}))
24+
req := httptest.NewRequest(http.MethodGet, "/", nil)
25+
req.Header.Set("Accept-Encoding", "gzip")
26+
rec := httptest.NewRecorder()
27+
h.ServeHTTP(rec, req)
28+
29+
if rec.Code != code {
30+
t.Fatalf("status = %d, want %d", rec.Code, code)
31+
}
32+
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
33+
t.Fatalf("%d response carries Content-Encoding %q", code, enc)
34+
}
35+
if rec.Body.Len() != 0 {
36+
t.Fatalf("%d response has %d body bytes (leaked gzip framing?)", code, rec.Body.Len())
37+
}
38+
}
39+
}

web/compress_test.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2025-2026 Netresearch DTT GmbH
2+
// SPDX-License-Identifier: MIT
3+
4+
package web_test
5+
6+
import (
7+
"compress/gzip"
8+
"encoding/json"
9+
"io"
10+
"net/http"
11+
"net/http/httptest"
12+
"strings"
13+
"testing"
14+
15+
"github.com/klauspost/compress/zstd"
16+
"github.com/netresearch/ofelia/core"
17+
webpkg "github.com/netresearch/ofelia/web"
18+
)
19+
20+
// TestResponseCompression pins the response compression: clients
21+
// advertising a supported codec get compressed pages and API payloads
22+
// (with Content-Length dropped and Vary set), clients advertising none
23+
// get identity responses untouched. This function covers the gzip
24+
// branch — Safari and other clients without zstd; TestZstdCompression
25+
// covers what the mainstream browsers actually receive.
26+
func TestResponseCompression(t *testing.T) {
27+
t.Parallel()
28+
29+
sched := &core.Scheduler{Jobs: []core.Job{}, Logger: stubDiscardLogger()}
30+
srv := webpkg.NewServer("", sched, nil, nil)
31+
handler := srv.HTTPServer().Handler
32+
33+
get := func(url string, acceptGzip bool) *httptest.ResponseRecorder {
34+
t.Helper()
35+
req := httptest.NewRequest(http.MethodGet, url, nil)
36+
if acceptGzip {
37+
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
38+
}
39+
w := httptest.NewRecorder()
40+
handler.ServeHTTP(w, req)
41+
if w.Code != http.StatusOK {
42+
t.Fatalf("%s: unexpected status %d", url, w.Code)
43+
}
44+
return w
45+
}
46+
47+
// The rendered page, compressed.
48+
w := get("/", true)
49+
if enc := w.Header().Get("Content-Encoding"); enc != "gzip" {
50+
t.Fatalf("expected gzip encoding, got %q", enc)
51+
}
52+
if vary := w.Header().Get("Vary"); !strings.Contains(vary, "Accept-Encoding") {
53+
t.Fatalf("Vary must include Accept-Encoding, got %q", vary)
54+
}
55+
if cl := w.Header().Get("Content-Length"); cl != "" {
56+
t.Fatalf("stale Content-Length %q on a compressed response", cl)
57+
}
58+
zr, err := gzip.NewReader(w.Body)
59+
if err != nil {
60+
t.Fatalf("gzip reader: %v", err)
61+
}
62+
body, err := io.ReadAll(zr)
63+
if err != nil {
64+
t.Fatalf("gunzip: %v", err)
65+
}
66+
if !strings.Contains(string(body), "<title>Ofelia</title>") {
67+
t.Fatalf("gunzipped page does not look like the UI")
68+
}
69+
70+
// An API response, compressed and still valid JSON.
71+
w = get("/api/dashboard", true)
72+
if enc := w.Header().Get("Content-Encoding"); enc != "gzip" {
73+
t.Fatalf("expected gzip on /api/dashboard, got %q", enc)
74+
}
75+
zr, err = gzip.NewReader(w.Body)
76+
if err != nil {
77+
t.Fatalf("gzip reader: %v", err)
78+
}
79+
var dash map[string]json.RawMessage
80+
if err := json.NewDecoder(zr).Decode(&dash); err != nil {
81+
t.Fatalf("decode gunzipped dashboard: %v", err)
82+
}
83+
if _, ok := dash["jobs"]; !ok {
84+
t.Fatalf("gunzipped dashboard missing jobs section")
85+
}
86+
87+
// No Accept-Encoding: identity, readable directly.
88+
w = get("/", false)
89+
if enc := w.Header().Get("Content-Encoding"); enc != "" {
90+
t.Fatalf("client without gzip support got Content-Encoding %q", enc)
91+
}
92+
if !strings.Contains(w.Body.String(), "<title>Ofelia</title>") {
93+
t.Fatalf("identity response does not look like the UI")
94+
}
95+
}
96+
97+
// TestZstdCompression pins what real browsers receive. Chrome, Edge and
98+
// Firefox send "gzip, deflate, br, zstd"; the wrapper enables zstd and
99+
// prefers it at equal q-values, so those clients get zstd, not gzip.
100+
// Dropping zstd from the header must fall back to gzip — that is the
101+
// Safari path.
102+
func TestZstdCompression(t *testing.T) {
103+
t.Parallel()
104+
105+
sched := &core.Scheduler{Jobs: []core.Job{}, Logger: stubDiscardLogger()}
106+
srv := webpkg.NewServer("", sched, nil, nil)
107+
handler := srv.HTTPServer().Handler
108+
109+
get := func(url, acceptEncoding string) *httptest.ResponseRecorder {
110+
t.Helper()
111+
req := httptest.NewRequest(http.MethodGet, url, nil)
112+
req.Header.Set("Accept-Encoding", acceptEncoding)
113+
w := httptest.NewRecorder()
114+
handler.ServeHTTP(w, req)
115+
if w.Code != http.StatusOK {
116+
t.Fatalf("%s: unexpected status %d", url, w.Code)
117+
}
118+
return w
119+
}
120+
121+
// The header every mainstream browser sends.
122+
w := get("/", "gzip, deflate, br, zstd")
123+
if enc := w.Header().Get("Content-Encoding"); enc != "zstd" {
124+
t.Fatalf("expected zstd for a browser Accept-Encoding, got %q", enc)
125+
}
126+
if vary := w.Header().Get("Vary"); !strings.Contains(vary, "Accept-Encoding") {
127+
t.Fatalf("Vary must include Accept-Encoding, got %q", vary)
128+
}
129+
if cl := w.Header().Get("Content-Length"); cl != "" {
130+
t.Fatalf("stale Content-Length %q on a compressed response", cl)
131+
}
132+
zr, err := zstd.NewReader(w.Body)
133+
if err != nil {
134+
t.Fatalf("zstd reader: %v", err)
135+
}
136+
defer zr.Close()
137+
body, err := io.ReadAll(zr)
138+
if err != nil {
139+
t.Fatalf("zstd decode: %v", err)
140+
}
141+
if !strings.Contains(string(body), "<title>Ofelia</title>") {
142+
t.Fatalf("decoded page does not look like the UI")
143+
}
144+
145+
// An API payload over zstd is still valid JSON.
146+
w = get("/api/dashboard", "gzip, deflate, br, zstd")
147+
if enc := w.Header().Get("Content-Encoding"); enc != "zstd" {
148+
t.Fatalf("expected zstd on /api/dashboard, got %q", enc)
149+
}
150+
dr, err := zstd.NewReader(w.Body)
151+
if err != nil {
152+
t.Fatalf("zstd reader: %v", err)
153+
}
154+
defer dr.Close()
155+
var dash map[string]json.RawMessage
156+
if err := json.NewDecoder(dr).Decode(&dash); err != nil {
157+
t.Fatalf("decode zstd dashboard: %v", err)
158+
}
159+
if _, ok := dash["jobs"]; !ok {
160+
t.Fatalf("decoded dashboard missing jobs section")
161+
}
162+
163+
// Without zstd in the header the client gets gzip.
164+
w = get("/", "gzip, deflate, br")
165+
if enc := w.Header().Get("Content-Encoding"); enc != "gzip" {
166+
t.Fatalf("client without zstd got %q, want gzip", enc)
167+
}
168+
}
169+
170+
// TestCompressNegotiationEdgeCases pins the paths where compression must
171+
// NOT happen and the sniffing of a Content-Type from uncompressed bytes.
172+
func TestCompressNegotiationEdgeCases(t *testing.T) {
173+
t.Parallel()
174+
175+
sched := &core.Scheduler{Jobs: []core.Job{}, Logger: stubDiscardLogger()}
176+
srv := webpkg.NewServer("", sched, nil, nil)
177+
// The probe routes (/live) exist only after health registration.
178+
srv.RegisterHealthEndpoints(webpkg.NewHealthChecker(nil, nil, "test"))
179+
handler := srv.HTTPServer().Handler
180+
181+
do := func(mutate func(*http.Request)) *httptest.ResponseRecorder {
182+
t.Helper()
183+
req := httptest.NewRequest(http.MethodGet, "/", nil)
184+
mutate(req)
185+
w := httptest.NewRecorder()
186+
handler.ServeHTTP(w, req)
187+
return w
188+
}
189+
190+
// "gzip;q=0" is an explicit refusal, not consent.
191+
w := do(func(r *http.Request) { r.Header.Set("Accept-Encoding", "gzip;q=0") })
192+
if enc := w.Header().Get("Content-Encoding"); enc != "" {
193+
t.Fatalf("client refusing gzip via q=0 got Content-Encoding %q", enc)
194+
}
195+
if !strings.Contains(w.Body.String(), "<title>Ofelia</title>") {
196+
t.Fatalf("q=0 response is not readable identity")
197+
}
198+
199+
// A bare wildcard permits gzip but does not name it; gzhttp answers
200+
// identity, which is always RFC-compliant (compression is optional —
201+
// the hard requirement is only the refusal direction below). This
202+
// pin documents the library's behavior, not a contract of ours.
203+
w = do(func(r *http.Request) { r.Header.Set("Accept-Encoding", "*") })
204+
if enc := w.Header().Get("Content-Encoding"); enc != "" {
205+
t.Fatalf("bare wildcard got Content-Encoding %q, want identity", enc)
206+
}
207+
208+
// An explicitly named refusal must never receive gzip, wildcard or
209+
// not (RFC 9110 §12.5.3).
210+
w = do(func(r *http.Request) { r.Header.Set("Accept-Encoding", "*, gzip;q=0") })
211+
if enc := w.Header().Get("Content-Encoding"); enc != "" {
212+
t.Fatalf("wildcard followed by gzip;q=0 got Content-Encoding %q", enc)
213+
}
214+
215+
// Range requests pass through identity: http.FileServer slices
216+
// identity bytes and gzipping the slice would corrupt the download.
217+
w = do(func(r *http.Request) {
218+
r.URL.Path = "/app.js"
219+
r.Header.Set("Accept-Encoding", "gzip")
220+
r.Header.Set("Range", "bytes=0-9")
221+
})
222+
if w.Code != http.StatusPartialContent {
223+
t.Fatalf("expected 206 for ranged asset, got %d", w.Code)
224+
}
225+
if enc := w.Header().Get("Content-Encoding"); enc != "" {
226+
t.Fatalf("ranged response must stay identity, got Content-Encoding %q", enc)
227+
}
228+
if w.Body.Len() != 10 {
229+
t.Fatalf("expected 10 identity bytes, got %d", w.Body.Len())
230+
}
231+
232+
// A handler that sets no Content-Type must be sniffed on the
233+
// uncompressed bytes — not answer application/x-gzip.
234+
w = do(func(r *http.Request) {
235+
r.URL.Path = "/live"
236+
r.Header.Set("Accept-Encoding", "gzip")
237+
})
238+
if w.Code != http.StatusOK {
239+
t.Fatalf("/live status %d", w.Code)
240+
}
241+
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
242+
t.Fatalf("/live under gzip answered Content-Type %q", ct)
243+
}
244+
}

web/health.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,11 @@ func (hc *HealthChecker) GetHealth() HealthResponse {
316316
// LivenessHandler returns a simple liveness check
317317
func (hc *HealthChecker) LivenessHandler() http.HandlerFunc {
318318
return func(w http.ResponseWriter, r *http.Request) {
319-
// Liveness just checks if the service is running
319+
// Liveness just checks if the service is running. Explicit
320+
// Content-Type: without it, a compressed response would be
321+
// content-sniffed on the compressed bytes and answer with the
322+
// codec's own type (application/x-gzip, application/zstd).
323+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
320324
w.WriteHeader(http.StatusOK)
321325
_, _ = w.Write([]byte("OK"))
322326
}

0 commit comments

Comments
 (0)