diff --git a/backend/main.go b/backend/main.go index 323fa94..aa8f70d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -179,7 +179,7 @@ func newRouter(store *Store, cfg Config) http.Handler { web.register(mux) } - return withCORS(cfg.AllowedOrigins, guardEmptyUserscriptToken(mux)) + return withCORS(cfg.AllowedOrigins, withGzip(guardEmptyUserscriptToken(mux))) } // guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect: diff --git a/backend/main_test.go b/backend/main_test.go index dd87c8e..1d89df6 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -1,8 +1,10 @@ package main import ( + "compress/gzip" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -196,3 +198,46 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) { t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum) } } + +func TestGzipCompressesTextNotFonts(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + + cases := []struct { + path string + want bool + }{ + {"/static/style.css", true}, + {"/static/filter.js", true}, + {"/static/htmx.min.js", true}, + {"/static/fonts/dm-sans-var-latin.woff2", false}, + } + for _, tc := range cases { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.Header.Set("Accept-Encoding", "gzip") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200", tc.path, rr.Code) + } + got := rr.Header().Get("Content-Encoding") == "gzip" + if got != tc.want { + t.Errorf("GET %s Content-Encoding gzip = %v, want %v", tc.path, got, tc.want) + } + if got { + zr, err := gzip.NewReader(rr.Body) + if err != nil { + t.Fatalf("GET %s: body is not gzip: %v", tc.path, err) + } + if _, err := io.ReadAll(zr); err != nil { + t.Fatalf("GET %s: gzip body did not decode: %v", tc.path, err) + } + } + } + + // A client that does not ask still gets plain bytes. + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/static/style.css", nil)) + if enc := rr.Header().Get("Content-Encoding"); enc != "" { + t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc) + } +} diff --git a/backend/middleware.go b/backend/middleware.go index dad76d9..220e4bf 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -1,6 +1,7 @@ package main import ( + "compress/gzip" "crypto/subtle" "net/http" "strings" @@ -26,6 +27,71 @@ func withAuth(token string, next http.Handler) http.Handler { }) } +// gzipWriter compresses the body and drops Content-Length, which no longer +// describes what goes on the wire. WriteHeader is where the decision is made: +// only then is Content-Type known. +type gzipWriter struct { + http.ResponseWriter + gz *gzip.Writer + decided bool +} + +// compressible covers what this server actually serves in bulk: HTML, CSS, JS +// and JSON. Fonts are woff2, which is already compressed — gzipping them costs +// CPU to add bytes. +func compressible(contentType string) bool { + ct, _, _ := strings.Cut(contentType, ";") + switch strings.TrimSpace(ct) { + case "text/html", "text/css", "text/javascript", "application/javascript", + "application/json", "text/plain": + return true + } + return false +} + +func (w *gzipWriter) WriteHeader(status int) { + if !w.decided { + w.decided = true + if compressible(w.Header().Get("Content-Type")) { + w.Header().Set("Content-Encoding", "gzip") + w.Header().Del("Content-Length") + w.gz = gzip.NewWriter(w.ResponseWriter) + } + } + w.ResponseWriter.WriteHeader(status) +} + +func (w *gzipWriter) Write(b []byte) (int, error) { + if !w.decided { + w.WriteHeader(http.StatusOK) + } + if w.gz != nil { + return w.gz.Write(b) + } + return w.ResponseWriter.Write(b) +} + +// withGzip compresses text responses for clients that ask. The templates, +// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter +// of that gzipped, which is the difference between a fast and a slow first load +// on mobile data. +func withGzip(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + w.Header().Add("Vary", "Accept-Encoding") + gw := &gzipWriter{ResponseWriter: w} + defer func() { + if gw.gz != nil { + gw.gz.Close() + } + }() + next.ServeHTTP(gw, r) + }) +} + // withCORS reflects the request Origin only when it is in allowed, answers // preflight OPTIONS with 204, and passes everything else through. It wraps the // auth middleware so preflight (which carries no Authorization header) is never