appview: gzip UI and static responses, leaving /v2 alone

Go's net/http compresses nothing by default and the appview is served by a
raw Go server behind a load balancer that does not compress either, so
everything went out uncompressed: style.css at 180 KB, bundle.min.js at
105 KB, and the homepage HTML at 95 KB. Lighthouse put style.css alone at
1,768 ms of blocked first paint, and mobile Performance measured 77 against
98 on desktop, entirely on paint metrics (TBT 10ms, CLS 0).

klauspost/compress is already a direct dependency and ships gzhttp, so this
costs no new one. Brotli would save roughly 11 KB more across the three
largest assets in exchange for a runtime dependency, which is not worth it.

The /v2 skip is the part that needs care. The OCI registry API is mounted on
the same chi router as the UI (server.go:561), and container layers are
already gzipped tarballs, so compressing that path burns CPU for no gain.
The content-type allowlist would catch most of it, but /v2/* also serves
application/json for tag listings and errors, so the path check keeps the
registry out of the compression path entirely. The predicate matches the one
the domain-routing middleware already uses.

Measured against a local build, gzip vs identity:

    /                   14,526 ->  4,298   71%
    /css/style.css     183,990 -> 31,596   83%
    /js/bundle.min.js  107,781 -> 32,648   70%
    /icons.svg          26,360 ->  8,403   69%
    total              332,657 -> 76,945   77%

Verified end to end against a running binary: UI and static responses carry
Content-Encoding: gzip with Vary: Accept-Encoding, /v2/ and /v2/*/tags/list
carry neither, and woff2 stays untouched. Tests cover both directions plus
the under-1 KB and no-Accept-Encoding cases.

HTTP/2 is the remaining half and cannot be fixed here: the load balancer
terminates TLS and negotiates no ALPN at all, which pins every request to
HTTP/1.1. That is an LB setting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124r73LT4qoFE82TqwH2Gu9
This commit is contained in:
Evan Jarrett
2026-09-02 20:21:33 -05:00
co-authored by Claude Opus 5
parent 74778bdd05
commit a63b839613
2 changed files with 152 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
package appview
import (
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// handlerWriting returns a handler that writes n bytes of compressible body
// under the given content type.
func handlerWriting(contentType string, n int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentType)
_, _ = w.Write([]byte(strings.Repeat("a", n)))
})
}
func TestCompressUIResponses(t *testing.T) {
tests := []struct {
name string
path string
contentType string
size int
wantGzip bool
}{
{"html is compressed", "/", "text/html; charset=utf-8", 4096, true},
{"css is compressed", "/css/style.css", "text/css; charset=utf-8", 4096, true},
{"js is compressed", "/js/bundle.min.js", "application/javascript", 4096, true},
{"svg is compressed", "/icons.svg", "image/svg+xml", 4096, true},
{"ui json is compressed", "/api/thing", "application/json", 4096, true},
// The registry must stay out of the compression path entirely: OCI
// layers are already gzipped tarballs.
{"registry blob untouched", "/v2/u/i/blobs/sha256:abc", "application/octet-stream", 4096, false},
{"registry json untouched", "/v2/u/i/tags/list", "application/json", 4096, false},
{"registry root untouched", "/v2/", "application/json", 4096, false},
{"registry bare untouched", "/v2", "application/json", 4096, false},
{"binary not compressed", "/static/img.png", "image/png", 4096, false},
{"tiny body not compressed", "/", "text/html; charset=utf-8", 16, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := compressUIResponses(handlerWriting(tt.contentType, tt.size))
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
gotGzip := rec.Header().Get("Content-Encoding") == "gzip"
if gotGzip != tt.wantGzip {
t.Fatalf("Content-Encoding gzip = %v, want %v (header %q)",
gotGzip, tt.wantGzip, rec.Header().Get("Content-Encoding"))
}
body := rec.Body.Bytes()
if gotGzip {
zr, err := gzip.NewReader(rec.Body)
if err != nil {
t.Fatalf("response not valid gzip: %v", err)
}
defer func() { _ = zr.Close() }()
body, err = io.ReadAll(zr)
if err != nil {
t.Fatalf("gzip decode: %v", err)
}
if len(rec.Body.Bytes()) >= tt.size {
t.Errorf("compressed body %d bytes, not smaller than raw %d",
len(rec.Body.Bytes()), tt.size)
}
}
if len(body) != tt.size {
t.Errorf("decoded body = %d bytes, want %d", len(body), tt.size)
}
})
}
}
// A client that does not advertise gzip must still get a usable response.
func TestCompressUIResponsesWithoutAcceptEncoding(t *testing.T) {
h := compressUIResponses(handlerWriting("text/html", 4096))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
t.Fatalf("Content-Encoding = %q, want empty for a client that did not ask", enc)
}
if rec.Body.Len() != 4096 {
t.Errorf("body = %d bytes, want 4096", rec.Body.Len())
}
}
+55
View File
@@ -21,6 +21,7 @@ import (
"github.com/distribution/distribution/v3/registry/handlers"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/klauspost/compress/gzhttp"
"atcr.io/pkg/appview/authgate"
"atcr.io/pkg/appview/db"
@@ -361,6 +362,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
mainRouter.Use(chimiddleware.RealIP)
mainRouter.Use(chimiddleware.Logger)
mainRouter.Use(chimiddleware.Recoverer)
mainRouter.Use(compressUIResponses)
mainRouter.Use(chimiddleware.GetHead)
mainRouter.Use(routes.CORSMiddleware())
// Vanity import prefix must match the `module` line in go.mod exactly,
@@ -1119,3 +1121,56 @@ func (s *AppViewServer) initializeJetstream(ctx context.Context) {
}
}
}
// gzipUI compresses text responses. Built once: the options are static, so a
// failure here is a programming error, not a runtime condition.
//
// klauspost/compress is already a direct dependency, so gzip costs no new one.
// Brotli would save roughly 11 KB more across the three largest assets and
// would add a runtime dependency, which is not a trade worth making.
var gzipUI = func() func(http.Handler) http.HandlerFunc {
wrapper, err := gzhttp.NewWrapper(
// Allowlist rather than denylist. Anything not named here, including
// every OCI media type, is passed through uncompressed.
gzhttp.ContentTypes([]string{
"text/html",
"text/css",
"text/plain",
"application/javascript",
"text/javascript",
"application/json",
"image/svg+xml",
}),
// Below ~1 KB the gzip header costs more than it saves.
gzhttp.MinSize(1024),
)
if err != nil {
panic("appview: bad gzhttp config: " + err.Error())
}
return wrapper
}()
// compressUIResponses gzips UI, static and JSON responses and leaves the OCI
// registry API at /v2/* alone.
//
// Go's net/http compresses nothing on its own, and the appview is served by a
// load balancer that does not compress either, so style.css went out at 180 KB
// uncompressed and blocked first paint. gzip takes the HTML, CSS and JS from
// 388 KB to 72 KB on the wire.
//
// The /v2 skip is not just an optimisation. Container layers are already
// gzipped tarballs, so recompressing them on the registry's hot path burns CPU
// for no gain. The content-type allowlist would catch most of that on its own,
// but /v2/* also serves application/json for tag listings and errors, so the
// path check keeps the registry entirely out of the compression path.
func compressUIResponses(next http.Handler) http.Handler {
compressed := gzipUI(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/v2" || path == "/v2/" || strings.HasPrefix(path, "/v2/") {
next.ServeHTTP(w, r)
return
}
compressed.ServeHTTP(w, r)
})
}