Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/ui-revamp/perf-gzip.md
Original file line number Diff line number Diff line change
@@ -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.
102 changes: 102 additions & 0 deletions internal/http/compress.go
Original file line number Diff line number Diff line change
@@ -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 }
48 changes: 48 additions & 0 deletions internal/http/compress_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading