mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 12:16:06 +00:00
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
98 lines
3.2 KiB
Go
98 lines
3.2 KiB
Go
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())
|
|
}
|
|
}
|