From b47326e24bab236319e2cae9a82640265161a491 Mon Sep 17 00:00:00 2001 From: Indra Gunawan Date: Fri, 16 Oct 2026 07:20:13 +0700 Subject: [PATCH] perf: gzip text responses (chi Compress level 5, skip bodies under 1 KB) Dockify-served cold transfer 136 -> 44 KB per page (-67%); see docs/ui-revamp/perf-gzip.md. Co-Authored-By: Claude Opus 5.5 --- docs/ui-revamp/perf-gzip.md | 33 +++++++++++ internal/http/compress.go | 102 +++++++++++++++++++++++++++++++++ internal/http/compress_test.go | 48 ++++++++++++++++ internal/http/router.go | 1 + 4 files changed, 184 insertions(+) create mode 100644 docs/ui-revamp/perf-gzip.md create mode 100644 internal/http/compress.go create mode 100644 internal/http/compress_test.go diff --git a/docs/ui-revamp/perf-gzip.md b/docs/ui-revamp/perf-gzip.md new file mode 100644 index 0000000..fb137b7 --- /dev/null +++ b/docs/ui-revamp/perf-gzip.md @@ -0,0 +1,33 @@ +# Perf gzip (respons HTTP), cabang `feat/gzip` + +`chi middleware.Compress` level 5 (`internal/http/compress.go`) untuk text/html, text/css, text/javascript, +application/javascript, application/json, image/svg+xml, application/manifest+json. Badan < 1 KB dikirim apa adanya +(`minSizeWriter`), PNG tidak dikompres, WebSocket (101 tanpa Content-Type, di-hijack) lewat tanpa diubah. +`Cache-Control` tetap: `sw.js` `no-cache`, aset lain `public, max-age=604800` (diuji di `compress_test.go`). +Aset statis tidak punya ETag (embed FS tanpa modtime); perilaku itu tidak berubah. + +Build mock, port 18090, auth aktif, konteks baru (cache dingin). Sebelum = `origin/main` d7fa1d8 (sama dengan `perf-s9.md`). + +## Transfer dingin, laporan `tests/e2e/ui.mjs` (total di kawat, termasuk Google Fonts) + +| Halaman | KB sebelum | KB sesudah | Turun | +| --- | --- | --- | --- | +| dashboard | 209 | 105 | 50% | +| servers | 199 | 104 | 48% | +| apps | 205 | 105 | 49% | +| app-detail | 209 | 107 | 49% | + +## Dipisah per asal (body + header, skrip Playwright yang sama polanya) + +| Halaman | Dockify sebelum → sesudah | Google Fonts (tidak berubah) | +| --- | --- | --- | +| dashboard | 136,2 → 44,5 KB (−67%) | 28,5 KB | +| servers | 134,9 → 44,3 KB (−67%) | 28,5 KB | +| apps | 136,0 → 44,6 KB (−67%) | 28,5 KB | +| app-detail | 135,0 → 44,4 KB (−67%) | 28,5 KB | + +Target ≥50% tercapai untuk semua byte yang dilayani Dockify (−67%). Total halaman turun 48–50% karena sisanya font +woff2 dari fonts.gstatic.com (sudah terkompresi, bukan dari server ini). + +Navigasi tidak berubah (median DCL dashboard/servers/apps/app-detail 192/178/191/206 ms vs 192/178/186/199 ms; selisih +dalam derau run). ui.mjs PASS. diff --git a/internal/http/compress.go b/internal/http/compress.go new file mode 100644 index 0000000..b23bee9 --- /dev/null +++ b/internal/http/compress.go @@ -0,0 +1,102 @@ +package http + +import ( + "bufio" + "errors" + "net" + "net/http" + + chimw "github.com/go-chi/chi/v5/middleware" +) + +// compressMinSize: smaller bodies are sent as-is; gzip framing would cost more than it saves. +const compressMinSize = 1024 + +// compress gzips text responses of at least compressMinSize bytes. WebSockets pass through: +// the 101 response has no Content-Type, so chi never wraps the hijacked connection. +func compress(next http.Handler) http.Handler { + gz := chimw.Compress(5, "text/html", "text/css", "text/javascript", "application/javascript", + "application/json", "image/svg+xml", "application/manifest+json") + return gz(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mw := &minSizeWriter{ResponseWriter: w, code: http.StatusOK} + next.ServeHTTP(mw, r) + mw.finish() + })) +} + +// minSizeWriter holds the first compressMinSize bytes back. If the handler ends before that, +// the body goes to the writer under chi's compressor, which never decides to compress. +type minSizeWriter struct { + http.ResponseWriter // chi's compress writer + code int + buf []byte + committed bool +} + +func (m *minSizeWriter) WriteHeader(code int) { + if m.committed { + m.ResponseWriter.WriteHeader(code) + return + } + m.code = code + if code < 200 || code == http.StatusNoContent || code == http.StatusNotModified { + m.commit() // no body follows + } +} + +func (m *minSizeWriter) Write(p []byte) (int, error) { + if m.committed { + return m.ResponseWriter.Write(p) + } + m.buf = append(m.buf, p...) + if len(m.buf) >= compressMinSize { + if err := m.commit(); err != nil { + return 0, err + } + } + return len(p), nil +} + +// commit hands the status and buffered bytes to chi, which then picks gzip by Content-Type. +func (m *minSizeWriter) commit() error { + m.committed = true + m.ResponseWriter.WriteHeader(m.code) + if len(m.buf) == 0 { + return nil + } + _, err := m.ResponseWriter.Write(m.buf) + m.buf = nil + return err +} + +func (m *minSizeWriter) finish() { + if m.committed { + return + } + m.committed = true + raw := m.ResponseWriter + if u, ok := raw.(interface{ Unwrap() http.ResponseWriter }); ok { + raw = u.Unwrap() + } + raw.WriteHeader(m.code) + raw.Write(m.buf) +} + +func (m *minSizeWriter) Flush() { + if !m.committed { + m.commit() + } + if f, ok := m.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (m *minSizeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + m.committed = true // the handler owns the connection now + if hj, ok := m.ResponseWriter.(http.Hijacker); ok { + return hj.Hijack() + } + return nil, nil, errors.New("http.Hijacker unavailable") +} + +func (m *minSizeWriter) Unwrap() http.ResponseWriter { return m.ResponseWriter } diff --git a/internal/http/compress_test.go b/internal/http/compress_test.go new file mode 100644 index 0000000..e6bb5bc --- /dev/null +++ b/internal/http/compress_test.go @@ -0,0 +1,48 @@ +package http + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCompress(t *testing.T) { + r, _, _ := newTestRouter(t) + get := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec + } + for _, p := range []string{"/static/htmx.min.js", "/static/app.css", "/static/sw.js", "/"} { + rec := get(p) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "gzip" { + t.Fatalf("%s: %d, Content-Encoding %q", p, rec.Code, rec.Header().Get("Content-Encoding")) + } + zr, err := gzip.NewReader(rec.Body) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + if b, err := io.ReadAll(zr); err != nil || len(b) < compressMinSize { + t.Errorf("%s: %d bytes, %v", p, len(b), err) + } + } + if cc := get("/static/sw.js").Header().Get("Cache-Control"); cc != "no-cache" { + t.Errorf("sw.js Cache-Control %q", cc) + } + if cc := get("/static/app.css").Header().Get("Cache-Control"); cc != "public, max-age=604800" { + t.Errorf("app.css Cache-Control %q", cc) + } + // Under 1 KB: sent plain. Images are never compressed. + for _, p := range []string{"/health", "/static/icon-192.png"} { + if rec := get(p); rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "" { + t.Errorf("%s: %d, Content-Encoding %q", p, rec.Code, rec.Header().Get("Content-Encoding")) + } + } + if b := get("/health").Body.String(); b != "ok" { + t.Errorf("/health body %q", b) + } +} diff --git a/internal/http/router.go b/internal/http/router.go index b3582ef..f9fbdfb 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -23,6 +23,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv r.Use(chimw.Logger) r.Use(chimw.Recoverer) + r.Use(compress) if trustProxy { r.Use(chimw.RealIP) // X-Forwarded-For/X-Real-IP from anyone otherwise }