plan: edge response compression (gzip)
Five tasks, TDD throughout: Accept-Encoding negotiation, the eligibility predicate, the gzipWriter with its private header map, the ServeHTTP wrap site plus the cache-identity and metered-bytes invariants, then the regression battery and the gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NoqzkUVahW5ykubsBQitxc
1 file changed,
+1456
−0
docs/superpowers/plans/2026-08-18-edge-response-compression.md+1456 −0
diff --git a/docs/superpowers/plans/2026-08-18-edge-response-compression.md b/docs/superpowers/plans/2026-08-18-edge-response-compression.md| new file mode 100644 |
| index 000000000..06dda8858 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-18-edge-response-compression.md |
| @@ -0,0 +1,1456 @@ |
| +# Edge Response Compression (gzip) Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Compress eligible responses at the CARLOS edge with stdlib gzip, at a single client-write boundary that covers cache hits, proxied misses, static trees and the status host. |
| + |
| +**Architecture:** One `gzipWriter` wrapper inserted in `Edge.ServeHTTP` after the upgrade branch and before the cache check, giving the writer stack `real conn → countingWriter → gzipWriter → recordingResponseWriter → handler`. The recorder above it keeps recording identity bytes (the response cache keeps storing identity bodies, one variant); the usage meter's counter below it counts compressed bytes. The wrapper owns a **private header map** so the compression headers it adds never reach the recorder — that is what stops the cache storing an identity body labelled `Content-Encoding: gzip`. |
| + |
| +**Tech Stack:** Go, stdlib only (`compress/gzip`, `sync.Pool`). No brotli, no zstd, no new module dependency. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-18-edge-response-compression-design.md` |
| + |
| +## Global Constraints |
| + |
| +- **Stdlib only.** `compress/gzip` at `gzip.DefaultCompression`, writers from a `sync.Pool`. No new dependency in `go.mod`. |
| +- **Placement is load-bearing.** The wrap site is in `Edge.ServeHTTP`, strictly **after** the `isUpgradeRequest` branch and strictly **before** the `cacheable` computation. Upgrades must keep today's exact writer stack (see `purge_header.go:222` on the WebSocket-breakage class this package has already paid for). |
| +- **The wrapper owns a private `http.Header`.** Every other writer wrapper in this package embeds the writer below and shares one header map; `maybeStore` caches `rec.Header().Clone()` (edge.go:958). Compression headers go on the **real** writer's map only. |
| +- **Defer order.** `defer gw.finish()` is registered *after* the usage meter's `defer m.Record(...)`, so LIFO closes the gzip stream before the byte count is read. |
| +- **Threshold:** `minCompressSize = 1024`. A response whose `Content-Length` is present and below it is never compressed. |
| +- **Kill switch:** `CARLOS_EDGE_GZIP` — the values `off`, `false`, `0`, `no` (case-insensitive, trimmed) disable compression. Anything else, including unset, leaves it on. Read once in `New`. |
| +- **Missing `Content-Type` means identity, always.** Never sniff. |
| +- **Build gate before any commit that touches Go:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l` clean for touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. |
| +- **Every change lands through a PR.** Never merge to `main` locally. |
| + |
| +--- |
| + |
| +### Task 1: Accept-Encoding negotiation |
| + |
| +The request-side half of eligibility: does this client actually want gzip? Pure functions, no writer involved, so they can be tested exhaustively without a server. |
| + |
| +**Files:** |
| +- Create: `internal/edge/compress.go` |
| +- Test: `internal/edge/compress_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `joinHeader(h http.Header, name string) string` — already in `edge.go`, returns every line of a field comma-joined (a client may legally split `Accept-Encoding` across lines). |
| +- Produces: |
| + - `func acceptsGzip(h http.Header) bool` |
| + - `func qValue(params string) float64` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Create `internal/edge/compress_test.go`: |
| + |
| +```go |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| +) |
| + |
| +// TestAcceptsGzip pins the q-value rules. The two that matter in production |
| +// are `gzip;q=0` (a client explicitly refusing gzip — some corporate proxies |
| +// and old curl invocations send it) and a bare `*`, which admits gzip only |
| +// when its own q is non-zero. A specific token always outranks the wildcard, |
| +// so `gzip;q=0, *` is a refusal however generous the wildcard is. |
| +func TestAcceptsGzip(t *testing.T) { |
| + for _, tc := range []struct { |
| + ae string |
| + want bool |
| + }{ |
| + {"", false}, |
| + {"gzip", true}, |
| + {"GZIP", true}, |
| + {"x-gzip", true}, |
| + {"gzip, deflate, br", true}, |
| + {" gzip ; q=0.5 ", true}, |
| + {"gzip;q=0", false}, |
| + {"gzip;q=0.0", false}, |
| + {"gzip;q=0, *", false}, |
| + {"identity", false}, |
| + {"deflate, br", false}, |
| + {"*", true}, |
| + {"*;q=0", false}, |
| + {"deflate, *;q=0.1", true}, |
| + } { |
| + h := http.Header{} |
| + if tc.ae != "" { |
| + h.Set("Accept-Encoding", tc.ae) |
| + } |
| + if got := acceptsGzip(h); got != tc.want { |
| + t.Errorf("acceptsGzip(%q) = %v, want %v", tc.ae, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestAcceptsGzipReadsEveryLine: Accept-Encoding may arrive on several lines |
| +// and that is identical in meaning to one comma-joined line (RFC 9110 §5.3). |
| +// Reading it with Header.Get would see only the first, which is the exact bug |
| +// joinHeader exists to prevent for Vary and Cache-Control. |
| +func TestAcceptsGzipReadsEveryLine(t *testing.T) { |
| + h := http.Header{} |
| + h.Add("Accept-Encoding", "deflate") |
| + h.Add("Accept-Encoding", "gzip") |
| + if !acceptsGzip(h) { |
| + t.Fatal("gzip on a second Accept-Encoding line was invisible — read every line via joinHeader, not Get") |
| + } |
| +} |
| + |
| +func TestQValue(t *testing.T) { |
| + for _, tc := range []struct { |
| + params string |
| + want float64 |
| + }{ |
| + {"", 1}, |
| + {"q=1", 1}, |
| + {"q=0", 0}, |
| + {"q=0.5", 0.5}, |
| + {" Q = 0.25 ", 0.25}, |
| + {"q=bogus", 1}, // malformed q is ignored, not treated as a refusal |
| + {"charset=utf-8", 1}, |
| + } { |
| + if got := qValue(tc.params); got != tc.want { |
| + t.Errorf("qValue(%q) = %v, want %v", tc.params, got, tc.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestAcceptsGzip|TestQValue' -count=1` |
| +Expected: FAIL — `undefined: acceptsGzip`, `undefined: qValue`. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +Create `internal/edge/compress.go`: |
| + |
| +```go |
| +// Edge response compression: one gzip ResponseWriter wrapper applied at the |
| +// client-write boundary in Edge.ServeHTTP, so every serving class — cache |
| +// hits, proxied misses, static trees, the status host — compresses through a |
| +// single site. Apps ship identity bytes; transport is the platform's problem. |
| +// |
| +// The wrapper sits BELOW recordingResponseWriter and ABOVE countingWriter: |
| +// the cache keeps storing identity bodies (one variant, compressed after |
| +// retrieval on every delivery) and the usage meter counts what actually |
| +// crossed the wire. |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// acceptsGzip reports whether r's Accept-Encoding admits gzip. Parsed as the |
| +// q-valued token list RFC 9110 §12.5.3 defines rather than a substring search: |
| +// "gzip;q=0" is a client REFUSING gzip, and answering it with a compressed |
| +// body is a broken response, not a missed optimization. |
| +// |
| +// A specific token outranks the wildcard, so the first gzip token decides — |
| +// "gzip;q=0, *" is a refusal. The wildcard only speaks when gzip is unnamed. |
| +// |
| +// Read through joinHeader, not Header.Get: a field may legally arrive on |
| +// several lines, and Get returns only the first (the same class of bug |
| +// joinHeader's doc comment describes for Vary and Cache-Control). |
| +func acceptsGzip(h http.Header) bool { |
| + var wildcard, wildcardOK bool |
| + for _, part := range strings.Split(joinHeader(h, "Accept-Encoding"), ",") { |
| + token, params, _ := strings.Cut(strings.TrimSpace(part), ";") |
| + switch strings.ToLower(strings.TrimSpace(token)) { |
| + case "gzip", "x-gzip": |
| + return qValue(params) > 0 |
| + case "*": |
| + wildcard, wildcardOK = true, qValue(params) > 0 |
| + } |
| + } |
| + if wildcard { |
| + return wildcardOK |
| + } |
| + return false |
| +} |
| + |
| +// qValue reads the q parameter out of one Accept-Encoding element's parameter |
| +// string ("q=0.5", "q=0"). Absent or malformed means q=1: a client whose |
| +// header we cannot fully parse is treated as accepting, which is the same |
| +// default the stdlib's own negotiation takes. |
| +func qValue(params string) float64 { |
| + for _, p := range strings.Split(params, ";") { |
| + name, val, ok := strings.Cut(strings.TrimSpace(p), "=") |
| + if !ok || !strings.EqualFold(strings.TrimSpace(name), "q") { |
| + continue |
| + } |
| + f, err := strconv.ParseFloat(strings.TrimSpace(val), 64) |
| + if err != nil { |
| + return 1 |
| + } |
| + return f |
| + } |
| + return 1 |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestAcceptsGzip|TestQValue' -count=1` |
| +Expected: PASS (`ok github.com/carlosframework/platform/internal/edge`) |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/edge/compress.go internal/edge/compress_test.go |
| +git add internal/edge/compress.go internal/edge/compress_test.go |
| +git commit -m "edge: Accept-Encoding gzip negotiation |
| + |
| +q-valued token list, not a substring search: gzip;q=0 is a client |
| +refusing gzip, and a specific token outranks the wildcard." |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: The eligibility predicate |
| + |
| +The response-side half: given the headers a handler produced, is this response worth compressing and safe to compress? Also pure — the request-side questions (Accept-Encoding, HEAD) are settled once at wrapper construction in Task 3, so this function reads headers and status only. |
| + |
| +**Files:** |
| +- Modify: `internal/edge/compress.go` |
| +- Test: `internal/edge/compress_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `hasCCDirective(cc, want string) bool` and `joinHeader` from `edge.go`. |
| +- Produces: |
| + - `const minCompressSize = 1024` |
| + - `func compressible(h http.Header, status int) bool` |
| + - `func compressibleType(ct string) bool` |
| + - `func weakenETag(h http.Header)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/edge/compress_test.go`: |
| + |
| +```go |
| +// TestCompressibleEligibility walks the response-side gate. Each false case |
| +// is a response that would be WRONG or pointless to compress, not merely |
| +// unprofitable — a 206 range body, an already-encoded body, an origin that |
| +// said no-transform. |
| +func TestCompressibleEligibility(t *testing.T) { |
| + base := func() http.Header { |
| + h := http.Header{} |
| + h.Set("Content-Type", "text/css") |
| + return h |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + mutate func(http.Header) |
| + status int |
| + want bool |
| + }{ |
| + {"plain css", func(http.Header) {}, 200, true}, |
| + {"html error page", func(h http.Header) { h.Set("Content-Type", "text/html; charset=utf-8") }, 404, true}, |
| + {"json api", func(h http.Header) { h.Set("Content-Type", "application/json") }, 200, true}, |
| + {"svg", func(h http.Header) { h.Set("Content-Type", "image/svg+xml") }, 200, true}, |
| + {"long body", func(h http.Header) { h.Set("Content-Length", "4096") }, 200, true}, |
| + {"unknown length", func(h http.Header) { h.Del("Content-Length") }, 200, true}, |
| + |
| + {"no content type", func(h http.Header) { h.Del("Content-Type") }, 200, false}, |
| + {"png", func(h http.Header) { h.Set("Content-Type", "image/png") }, 200, false}, |
| + {"event stream", func(h http.Header) { h.Set("Content-Type", "text/event-stream") }, 200, false}, |
| + {"already encoded", func(h http.Header) { h.Set("Content-Encoding", "gzip") }, 200, false}, |
| + {"no-transform", func(h http.Header) { h.Set("Cache-Control", "max-age=60, no-transform") }, 200, false}, |
| + {"short body", func(h http.Header) { h.Set("Content-Length", "512") }, 200, false}, |
| + {"exactly at threshold", func(h http.Header) { h.Set("Content-Length", "1024") }, 200, true}, |
| + {"partial content", func(h http.Header) { h.Set("Content-Range", "bytes 0-99/4096") }, 206, false}, |
| + {"no content", func(http.Header) {}, 204, false}, |
| + {"reset content", func(http.Header) {}, 205, false}, |
| + {"not modified", func(http.Header) {}, 304, false}, |
| + } { |
| + h := base() |
| + tc.mutate(h) |
| + if got := compressible(h, tc.status); got != tc.want { |
| + t.Errorf("%s: compressible(%v, %d) = %v, want %v", tc.name, h, tc.status, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestCompressibleTypeSuffixes: a media type we have never heard of still |
| +// compresses when its structured suffix says it is text underneath. |
| +func TestCompressibleTypeSuffixes(t *testing.T) { |
| + for _, ct := range []string{ |
| + "application/manifest+json", "application/atom+xml", "application/ld+json", |
| + "application/vnd.api+json", "text/plain", "application/javascript", |
| + "application/wasm", "application/xml", "text/css;charset=UTF-8", |
| + } { |
| + if !compressibleType(ct) { |
| + t.Errorf("compressibleType(%q) = false, want true", ct) |
| + } |
| + } |
| + for _, ct := range []string{ |
| + "", "image/png", "video/mp4", "application/octet-stream", |
| + "application/zip", "font/woff2", "text/event-stream", |
| + } { |
| + if compressibleType(ct) { |
| + t.Errorf("compressibleType(%q) = true, want false", ct) |
| + } |
| + } |
| +} |
| + |
| +// TestWeakenETag: a gzipped body is a different representation, so its |
| +// validator must not claim byte-equality with the identity one. Weakening |
| +// keeps conditional requests working — the stdlib's If-None-Match check |
| +// compares weakly for GET — while dropping the strong-equality claim a |
| +// Range request would rely on. |
| +func TestWeakenETag(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {`"abc123"`, `W/"abc123"`}, |
| + {`W/"abc123"`, `W/"abc123"`}, // already weak: untouched |
| + {"", ""}, // absent: nothing to weaken |
| + } { |
| + h := http.Header{} |
| + if tc.in != "" { |
| + h.Set("ETag", tc.in) |
| + } |
| + weakenETag(h) |
| + if got := h.Get("ETag"); got != tc.want { |
| + t.Errorf("weakenETag(%q) -> %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestCompressible|TestWeakenETag' -count=1` |
| +Expected: FAIL — `undefined: compressible`, `undefined: compressibleType`, `undefined: weakenETag`. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +Append to `internal/edge/compress.go`: |
| + |
| +```go |
| +// minCompressSize is the Content-Length below which compressing is not worth |
| +// the CPU: gzip's own header and trailer are 18 bytes, and a sub-kilobyte |
| +// response often lands in one packet either way. A response whose length is |
| +// UNKNOWN (streamed, chunked) is compressed regardless — refusing those would |
| +// exempt most proxied HTML, which is the bulk of what there is to save. |
| +const minCompressSize = 1024 |
| + |
| +// compressible reports whether a response with these headers and this status |
| +// should be gzipped. The request-side questions — does the client accept |
| +// gzip, is this a HEAD — are settled once at wrapper construction |
| +// (newGzipWriter), so this reads the response alone. |
| +// |
| +// Every false below is a response that would be WRONG or pointless to |
| +// compress, not merely unprofitable: |
| +// |
| +// - 204/205 have no body; 304 has none and must keep the validators of the |
| +// representation it refers to; 206 is a byte range of a larger body, and |
| +// compressing a range makes the offsets it names meaningless. |
| +// - An existing Content-Encoding means the origin already encoded it. |
| +// Compressing again produces a body no client can decode without knowing |
| +// we did it. |
| +// - Cache-Control: no-transform is the origin forbidding exactly this |
| +// (RFC 9110 §5.5). |
| +// |
| +// A MISSING Content-Type is refused rather than sniffed. Sniffing is the |
| +// stdlib's job and it runs on the bytes we would have already compressed — |
| +// it would read gzip's magic number and label the response application/x-gzip. |
| +func compressible(h http.Header, status int) bool { |
| + switch status { |
| + case http.StatusNoContent, http.StatusResetContent, |
| + http.StatusPartialContent, http.StatusNotModified: |
| + return false |
| + } |
| + if h.Get("Content-Range") != "" { |
| + return false |
| + } |
| + if h.Get("Content-Encoding") != "" { |
| + return false |
| + } |
| + if hasCCDirective(joinHeader(h, "Cache-Control"), "no-transform") { |
| + return false |
| + } |
| + if n, err := strconv.Atoi(h.Get("Content-Length")); err == nil && n < minCompressSize { |
| + return false |
| + } |
| + return compressibleType(h.Get("Content-Type")) |
| +} |
| + |
| +// compressibleType reports whether a media type is text underneath. text/* is |
| +// the bulk of it; the structured suffixes (+json, +xml) catch types nobody has |
| +// enumerated yet, which is why they are here rather than a longer switch. |
| +// |
| +// text/event-stream is the one text type excluded: it is an open stream a |
| +// client consumes event by event, and buffering it inside a compressor is a |
| +// hang, not a saving. |
| +func compressibleType(ct string) bool { |
| + mt, _, _ := strings.Cut(ct, ";") |
| + mt = strings.ToLower(strings.TrimSpace(mt)) |
| + if mt == "" || mt == "text/event-stream" { |
| + return false |
| + } |
| + if strings.HasPrefix(mt, "text/") || |
| + strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "+xml") { |
| + return true |
| + } |
| + switch mt { |
| + case "application/json", "application/javascript", "application/x-javascript", |
| + "application/xml", "application/wasm": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// weakenETag downgrades a strong validator to a weak one, in place. A gzipped |
| +// body is a different representation of the same resource, so an ETag that |
| +// claims byte-for-byte equality with the identity body is a lie — and the one |
| +// thing that lie breaks is a Range request served against the wrong bytes. |
| +// Conditional requests keep working: If-None-Match compares weakly for GET, |
| +// so a client returning W/"abc" still matches the origin's "abc" and gets its |
| +// 304. |
| +func weakenETag(h http.Header) { |
| + if et := h.Get("ETag"); strings.HasPrefix(et, `"`) { |
| + h.Set("ETag", `W/`+et) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestCompressible|TestWeakenETag|TestAcceptsGzip|TestQValue' -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/edge/compress.go internal/edge/compress_test.go |
| +git add internal/edge/compress.go internal/edge/compress_test.go |
| +git commit -m "edge: gzip eligibility predicate |
| + |
| +Header-only, decided once. A missing Content-Type is refused rather |
| +than sniffed: sniffing runs on bytes we would have already compressed |
| +and would label the response application/x-gzip." |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: The gzipWriter wrapper |
| + |
| +The wrapper itself, tested directly against `httptest.NewRecorder()` — no Edge, no proxy, so each invariant fails for exactly one reason. The private header map is the whole point of this task. |
| + |
| +**Files:** |
| +- Modify: `internal/edge/compress.go` |
| +- Test: `internal/edge/compress_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `acceptsGzip`, `compressible`, `weakenETag` from Tasks 1-2. |
| +- Produces: |
| + - `type gzipWriter struct` — embeds `http.ResponseWriter`, implements `Header`, `WriteHeader`, `Write`, `Flush`, `Hijack`, and `finish()`. |
| + - `func newGzipWriter(w http.ResponseWriter, r *http.Request) *gzipWriter` — returns **nil** when this request can never be compressed. |
| + - `func (g *gzipWriter) finish()` — flushes and returns the pooled writer; the caller defers it. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/edge/compress_test.go` (add `bufio`, `compress/gzip`, `io`, `net`, `net/http/httptest`, `strings` to its imports): |
| + |
| +```go |
| +// hijackableRecorder is a recorder that also implements http.Hijacker, so the |
| +// forwarding test has something to forward TO. httptest.ResponseRecorder |
| +// deliberately does not implement it. |
| +type hijackableRecorder struct { |
| + *httptest.ResponseRecorder |
| + hijacked bool |
| +} |
| + |
| +func (h *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + h.hijacked = true |
| + return nil, nil, nil |
| +} |
| + |
| +// gzipReq builds a request that accepts gzip. |
| +func gzipReq(method, url string) *http.Request { |
| + r := httptest.NewRequest(method, url, nil) |
| + r.Header.Set("Accept-Encoding", "gzip") |
| + return r |
| +} |
| + |
| +// TestGzipWriterKeepsCompressionHeadersOffTheCallerMap is THE test this design |
| +// exists for. Every writer wrapper in this package embeds the writer below, so |
| +// Header() returns one shared map all the way down — and maybeStore caches |
| +// rec.Header().Clone(). If the compression headers landed on that shared map, |
| +// the cache would store an IDENTITY body labelled Content-Encoding: gzip and |
| +// serve it to every later visitor as an undecodable response. |
| +func TestGzipWriterKeepsCompressionHeadersOffTheCallerMap(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + if g == nil { |
| + t.Fatal("newGzipWriter returned nil for a gzip-accepting GET") |
| + } |
| + g.Header().Set("Content-Type", "text/css") |
| + g.Header().Set("ETag", `"abc123"`) |
| + g.Header().Set("Content-Length", "4000") |
| + g.WriteHeader(http.StatusOK) |
| + body := strings.Repeat("a{color:red}\n", 400) |
| + io.WriteString(g, body) |
| + g.finish() |
| + |
| + if got := g.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("caller-visible Content-Encoding = %q, want empty — the recorder above would cache an identity body labelled gzip", got) |
| + } |
| + if got := g.Header().Get("ETag"); got != `"abc123"` { |
| + t.Errorf("caller-visible ETag = %q, want the strong tag untouched (the cached body IS the identity one)", got) |
| + } |
| + if got := g.Header().Get("Content-Length"); got != "4000" { |
| + t.Errorf("caller-visible Content-Length = %q, want it untouched", got) |
| + } |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("client Content-Encoding = %q, want gzip", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "" { |
| + t.Errorf("client Content-Length = %q, want it dropped — it described the identity body", got) |
| + } |
| + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { |
| + t.Errorf("client Vary = %q, want Accept-Encoding", got) |
| + } |
| + if got := rec.Header().Get("ETag"); got != `W/"abc123"` { |
| + t.Errorf("client ETag = %q, want the weakened form", got) |
| + } |
| + |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("client body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if string(got) != body { |
| + t.Fatalf("decompressed body differs from what the handler wrote") |
| + } |
| + if rec.Body.Len() >= len(body) { |
| + t.Fatalf("compressed size %d >= identity size %d", rec.Body.Len(), len(body)) |
| + } |
| +} |
| + |
| +// TestGzipWriterNilForRequestsThatCanNeverCompress: construction is where the |
| +// request-side questions are settled, once. A nil wrapper means ServeHTTP |
| +// leaves the writer stack exactly as it is today. |
| +func TestGzipWriterNilForRequestsThatCanNeverCompress(t *testing.T) { |
| + if g := newGzipWriter(httptest.NewRecorder(), httptest.NewRequest("GET", "http://app.test/", nil)); g != nil { |
| + t.Error("no Accept-Encoding must yield a nil wrapper") |
| + } |
| + if g := newGzipWriter(httptest.NewRecorder(), gzipReq("HEAD", "http://app.test/big.css")); g != nil { |
| + t.Error("HEAD must yield a nil wrapper — there is no body to compress, and its truthful Content-Length is the whole value of the response") |
| + } |
| +} |
| + |
| +// TestGzipWriterPassesThroughIneligibleResponses: an ineligible response must |
| +// be byte-identical to the unwrapped one, headers included. |
| +func TestGzipWriterPassesThroughIneligibleResponses(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/logo.png")) |
| + g.Header().Set("Content-Type", "image/png") |
| + g.Header().Set("Content-Length", "9") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, "PNG-BYTES") |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("Content-Encoding = %q on an image, want empty", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "9" { |
| + t.Errorf("Content-Length = %q, want it preserved on a pass-through", got) |
| + } |
| + if got := rec.Body.String(); got != "PNG-BYTES" { |
| + t.Errorf("body = %q, want the identity bytes", got) |
| + } |
| +} |
| + |
| +// TestGzipWriterSilentHandlerStillDeliversHeaders: the handler set headers and |
| +// returned without ever writing. Those headers live in the PRIVATE map, so |
| +// without a sync in finish() the stdlib would emit a bare 200 with none of |
| +// them — X-Carlos-Version included. |
| +func TestGzipWriterSilentHandlerStillDeliversHeaders(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("X-Carlos-Version", "abc123") |
| + g.finish() |
| + |
| + if got := rec.Header().Get("X-Carlos-Version"); got != "abc123" { |
| + t.Fatalf("X-Carlos-Version = %q, want abc123 — a handler that wrote nothing still lost every header it set", got) |
| + } |
| +} |
| + |
| +// TestGzipWriter1xxDoesNotLatch mirrors recordingResponseWriter.WriteHeader: a |
| +// 103 Early Hints block precedes the final status. Latching it would freeze |
| +// the compression decision on a status that has no body, and the real response |
| +// would then be delivered uncompressed — or worse, with the hints lost, since |
| +// they live in the private map. |
| +func TestGzipWriter1xxDoesNotLatch(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("Link", "</tokens.css>; rel=preload") |
| + g.WriteHeader(http.StatusEarlyHints) |
| + |
| + if got := rec.Header().Get("Link"); got != "</tokens.css>; rel=preload" { |
| + t.Errorf("early-hints Link = %q, want it forwarded — it was set on the private map", got) |
| + } |
| + |
| + g.Header().Del("Link") |
| + g.Header().Set("Content-Type", "text/html") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>hi</p>", 200)) |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("Content-Encoding = %q after a 103, want gzip — the 1xx latched the decision", got) |
| + } |
| + if got := rec.Header().Get("Link"); got != "" { |
| + t.Errorf("Link = %q, want empty — the final header block replaces the informational one", got) |
| + } |
| +} |
| + |
| +// TestGzipWriterFlushReachesTheClient: a flushed response must actually leave |
| +// the compressor, not sit in its buffer until the handler returns. SSE never |
| +// compresses (content type), but a streamed HTML page does. |
| +func TestGzipWriterFlushReachesTheClient(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/stream")) |
| + g.Header().Set("Content-Type", "text/html") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>chunk</p>", 100)) |
| + g.Flush() |
| + |
| + if rec.Body.Len() == 0 { |
| + t.Fatal("Flush left the bytes inside the compressor — a streaming response would hang until the handler returned") |
| + } |
| + if !rec.Flushed { |
| + t.Fatal("Flush did not reach the writer below") |
| + } |
| + g.finish() |
| +} |
| + |
| +// TestGzipWriterForwardsHijack: the upgrade path returns before this wrapper |
| +// is ever built, so this is not a hot path — it exists so the wrapper is never |
| +// itself the reason a hijack fails, matching the convention countingWriter and |
| +// recordingResponseWriter already follow. |
| +func TestGzipWriterForwardsHijack(t *testing.T) { |
| + rec := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()} |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + if _, _, err := g.Hijack(); err != nil { |
| + t.Fatalf("Hijack: %v", err) |
| + } |
| + if !rec.hijacked { |
| + t.Fatal("Hijack did not reach the writer below") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run TestGzipWriter -count=1` |
| +Expected: FAIL — `undefined: newGzipWriter`. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +Append to `internal/edge/compress.go`, and extend its imports to |
| +`bufio`, `compress/gzip`, `fmt`, `io`, `net`, `net/http`, `strconv`, `strings`, `sync`: |
| + |
| +```go |
| +// gzipPool recycles compressors. We compress on every delivery, cache hits |
| +// included, so a fresh gzip.Writer per response would be ~1.2 MB/s of garbage |
| +// on a busy edge for no reason. |
| +var gzipPool = sync.Pool{New: func() any { |
| + w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression) |
| + return w |
| +}} |
| + |
| +// gzipWriter compresses an eligible response on its way to the client. |
| +// |
| +// It owns a PRIVATE header map. Every other ResponseWriter wrapper in this |
| +// package embeds the writer below, so Header() returns one shared map from the |
| +// handler all the way down to the connection — and maybeStore caches |
| +// rec.Header().Clone(). Were the compression headers set on that shared map, |
| +// the cache would store an identity body labelled Content-Encoding: gzip and |
| +// hand it to every later visitor. So callers above read and write hdr, and the |
| +// edits that describe the COMPRESSED representation go on the real writer's |
| +// map alone. |
| +// |
| +// Deliberately does NOT implement io.ReaderFrom: io.Copy (and so |
| +// http.ServeFile) falls back to Write, which is what compresses. |
| +type gzipWriter struct { |
| + http.ResponseWriter |
| + hdr http.Header |
| + gz *gzip.Writer // nil unless this response is being compressed |
| + wroteHdr bool |
| +} |
| + |
| +// newGzipWriter returns a wrapper for r, or nil when this request can never |
| +// produce a compressed response — no wrapper at all, so the writer stack is |
| +// exactly what it is today. The request-side questions are settled here, once: |
| +// everything after depends only on the response. |
| +// |
| +// HEAD is excluded rather than compressed-with-no-body: there is nothing to |
| +// compress, and a HEAD's truthful Content-Length is the entire value of the |
| +// response. The accepted cost is that a HEAD advertises the identity length |
| +// while a GET of the same URL returns gzip with no length at all. |
| +func newGzipWriter(w http.ResponseWriter, r *http.Request) *gzipWriter { |
| + if r.Method == http.MethodHead || !acceptsGzip(r.Header) { |
| + return nil |
| + } |
| + return &gzipWriter{ResponseWriter: w, hdr: http.Header{}} |
| +} |
| + |
| +// Header returns the PRIVATE map. See the type's doc comment: this is what |
| +// keeps the response cache storing identity bodies. |
| +func (g *gzipWriter) Header() http.Header { return g.hdr } |
| + |
| +// syncHeader replaces the real writer's header block with the private one. |
| +// Wholesale, not a merge: a handler that DELETED a header between blocks (the |
| +// shape the purge-on-103 test exercises) must see that deletion reach the |
| +// client, which a merge would silently undo. |
| +func (g *gzipWriter) syncHeader() { |
| + real := g.ResponseWriter.Header() |
| + for k := range real { |
| + delete(real, k) |
| + } |
| + for k, v := range g.hdr { |
| + real[k] = v |
| + } |
| +} |
| + |
| +func (g *gzipWriter) WriteHeader(status int) { |
| + // 1xx blocks (103 Early Hints, forwarded by httputil.ReverseProxy) precede |
| + // the final status. Sync and forward without latching — the same shape |
| + // recordingResponseWriter.WriteHeader uses, for the same reason: latching |
| + // one would decide compression on a status that has no body. |
| + if status < 200 { |
| + g.syncHeader() |
| + g.ResponseWriter.WriteHeader(status) |
| + return |
| + } |
| + if g.wroteHdr { |
| + g.ResponseWriter.WriteHeader(status) // superfluous; let the stdlib log it |
| + return |
| + } |
| + g.wroteHdr = true |
| + g.syncHeader() |
| + if compressible(g.hdr, status) { |
| + h := g.ResponseWriter.Header() |
| + h.Set("Content-Encoding", "gzip") |
| + h.Del("Content-Length") // it described the identity body |
| + h.Add("Vary", "Accept-Encoding") |
| + weakenETag(h) |
| + g.gz = gzipPool.Get().(*gzip.Writer) |
| + g.gz.Reset(g.ResponseWriter) |
| + } |
| + g.ResponseWriter.WriteHeader(status) |
| +} |
| + |
| +func (g *gzipWriter) Write(p []byte) (int, error) { |
| + if !g.wroteHdr { |
| + g.WriteHeader(http.StatusOK) |
| + } |
| + if g.gz != nil { |
| + return g.gz.Write(p) |
| + } |
| + return g.ResponseWriter.Write(p) |
| +} |
| + |
| +// finish closes the compressor and returns it to the pool. ServeHTTP defers |
| +// it, AFTER the usage meter's defer, so LIFO runs this one first and the |
| +// metered byte count includes gzip's trailer. |
| +// |
| +// The no-header case is not a no-op: a handler that returned without ever |
| +// writing set its headers on the private map, and nothing has copied them |
| +// down. Without this sync the stdlib emits a bare 200 with none of them. |
| +func (g *gzipWriter) finish() { |
| + if !g.wroteHdr { |
| + g.syncHeader() |
| + return |
| + } |
| + if g.gz == nil { |
| + return |
| + } |
| + g.gz.Close() |
| + g.gz.Reset(io.Discard) // don't pin this request's writer inside the pool |
| + gzipPool.Put(g.gz) |
| + g.gz = nil |
| +} |
| + |
| +// Flush pushes the compressor's buffered bytes out and then flushes the writer |
| +// below — needed so a streamed response reaches the client incrementally |
| +// instead of buffering until the handler returns. |
| +func (g *gzipWriter) Flush() { |
| + if g.gz != nil { |
| + g.gz.Flush() |
| + } |
| + if f, ok := g.ResponseWriter.(http.Flusher); ok { |
| + f.Flush() |
| + } |
| +} |
| + |
| +// Hijack forwards to the writer below, when it supports it — the same |
| +// optional-interface pattern countingWriter and recordingResponseWriter use. |
| +// ServeHTTP returns on the upgrade path before this wrapper is constructed, so |
| +// this is not expected on the hot path; it exists so the wrapper is never |
| +// itself the reason a hijack fails. |
| +func (g *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + hj, ok := g.ResponseWriter.(http.Hijacker) |
| + if !ok { |
| + return nil, nil, fmt.Errorf("edge: underlying ResponseWriter does not support http.Hijacker") |
| + } |
| + return hj.Hijack() |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestGzip|TestCompressible|TestAcceptsGzip|TestQValue|TestWeakenETag' -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/edge/compress.go internal/edge/compress_test.go |
| +git add internal/edge/compress.go internal/edge/compress_test.go |
| +git commit -m "edge: gzip ResponseWriter with a private header map |
| + |
| +The wrapper owns its own http.Header: every other wrapper here embeds |
| +the writer below, so Header() is one shared map and maybeStore caches |
| +rec.Header().Clone() — setting Content-Encoding on it would store an |
| +identity body labelled gzip and serve it to every later visitor." |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Wire it into Edge.ServeHTTP |
| + |
| +The wrap site, the kill switch, and the two invariants that dictated the ordering: the cache stores identity, the meter counts compressed. |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge.go` — the `Edge` struct (near line 138), `New` (line 181), `ServeHTTP` (after the `isUpgradeRequest` block, ~line 806) |
| +- Modify: `internal/edge/compress.go` — add `gzipEnabled()` |
| +- Test: `internal/edge/compress_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `newGzipWriter`, `(*gzipWriter).finish` from Task 3; `EnableCache(*Cache)`, `NewCache(maxEntries, maxBodyBytes int) *Cache`, `EnableUsage(*UsageMeter)`, `(*Cache).get(CacheKey)`, `openReg(t)` from `edge_test.go`. |
| +- Produces: |
| + - `Edge.gzipOn bool` field |
| + - `func gzipEnabled() bool` |
| + - `func edgeWithBackend(t *testing.T, h http.Handler) *Edge` (test helper, used again in Task 5) |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/edge/compress_test.go` (imports gain `github.com/carlosframework/platform/internal/registry`): |
| + |
| +```go |
| +// edgeWithBackend builds an Edge with one proxied route, "app.test", pointed |
| +// at h. Driven with httptest.NewRecorder + httptest.NewRequest rather than a |
| +// real client: http.Transport adds its own Accept-Encoding and transparently |
| +// decompresses what comes back, which would hide exactly what these tests are |
| +// checking. |
| +func edgeWithBackend(t *testing.T, h http.Handler) *Edge { |
| + t.Helper() |
| + backend := httptest.NewServer(h) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.test", Kind: "service", Status: "ready", |
| + Addr: backend.Listener.Addr().String(), |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + return e |
| +} |
| + |
| +// cssBackend answers every request with a cacheable stylesheet of n bytes. |
| +func cssBackend(n int) http.Handler { |
| + body := strings.Repeat("a{color:red}\n", n/13+1)[:n] |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Header().Set("Cache-Control", "max-age=600") |
| + io.WriteString(w, body) |
| + }) |
| +} |
| + |
| +// TestEdgeCompressesProxiedAssets is the payoff: an app ships identity bytes |
| +// and the edge puts them on the wire compressed. |
| +func TestEdgeCompressesProxiedAssets(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q, want gzip", got) |
| + } |
| + if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { |
| + t.Errorf("Vary = %q, want it to name Accept-Encoding", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if len(got) != 4096 { |
| + t.Fatalf("decompressed %d bytes, want 4096", len(got)) |
| + } |
| +} |
| + |
| +// TestEdgeCacheStoresIdentityBodies is the invariant the whole writer ordering |
| +// exists to hold. The recorder sits ABOVE the compressor, so what lands in the |
| +// cache is the identity body with the app's own headers — one variant, not one |
| +// per encoding — and a client that cannot decode gzip is still served correctly |
| +// from that same entry. |
| +func TestEdgeCacheStoresIdentityBodies(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + e.EnableCache(NewCache(10, 1<<20)) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) // miss |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("miss: Content-Encoding = %q, want gzip", got) |
| + } |
| + |
| + stored, ok := e.cache.get(CacheKey{Host: "app.test", Method: "GET", Path: "/tokens.css"}) |
| + if !ok { |
| + t.Fatal("the response was not cached — this test cannot say anything about what was stored") |
| + } |
| + if ce := stored.Header.Get("Content-Encoding"); ce != "" { |
| + t.Errorf("cached entry carries Content-Encoding %q: an identity body labelled gzip, served to every later visitor as an undecodable response", ce) |
| + } |
| + if v := stored.Header.Get("Vary"); strings.Contains(v, "Accept-Encoding") { |
| + t.Errorf("cached entry carries Vary %q: the edge's own Vary belongs below the recorder, not in the cache key", v) |
| + } |
| + if len(stored.Body) != 4096 { |
| + t.Errorf("cached body is %d bytes, want the 4096-byte identity body", len(stored.Body)) |
| + } |
| + |
| + hit := httptest.NewRecorder() |
| + e.ServeHTTP(hit, gzipReq("GET", "http://app.test/tokens.css")) |
| + if got := hit.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("cache hit: Content-Encoding = %q, want gzip — a hit is a delivery like any other", got) |
| + } |
| + zr, err := gzip.NewReader(hit.Body) |
| + if err != nil { |
| + t.Fatalf("cache hit body is not gzip: %v", err) |
| + } |
| + if b, err := io.ReadAll(zr); err != nil || len(b) != 4096 { |
| + t.Fatalf("cache hit decompressed to %d bytes (err %v), want 4096", len(b), err) |
| + } |
| + |
| + plain := httptest.NewRecorder() |
| + e.ServeHTTP(plain, httptest.NewRequest("GET", "http://app.test/tokens.css", nil)) |
| + if got := plain.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("identity client got Content-Encoding %q from the shared entry", got) |
| + } |
| + if plain.Body.Len() != 4096 { |
| + t.Errorf("identity client got %d bytes, want the 4096-byte identity body", plain.Body.Len()) |
| + } |
| +} |
| + |
| +// TestEdgeMetersCompressedBytes: a tenant is billed for what crossed the wire. |
| +// The counter sits BELOW the compressor and finish() runs before Record — the |
| +// equality below is what pins that defer order, since gzip buffers until Close |
| +// and a reversed order would meter a fraction of the bytes. |
| +func TestEdgeMetersCompressedBytes(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(8192)) |
| + m := NewUsageMeter() |
| + e.EnableUsage(m) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + row, ok := m.Drain()["app.test"] |
| + if !ok { |
| + t.Fatal("no usage row for app.test") |
| + } |
| + if row.BytesOut != int64(rec.Body.Len()) { |
| + t.Fatalf("metered %d bytes, client received %d — finish() must run before Record (defer order) or the trailer is missed", row.BytesOut, rec.Body.Len()) |
| + } |
| + if row.BytesOut >= 8192 { |
| + t.Fatalf("metered %d bytes for an 8192-byte identity body — the meter is counting bytes the edge did not send", row.BytesOut) |
| + } |
| +} |
| + |
| +// TestEdgeGzipKillSwitch: CARLOS_EDGE_GZIP=off is the rollback that needs no |
| +// redeploy, so it must produce today's bytes exactly. |
| +func TestEdgeGzipKillSwitch(t *testing.T) { |
| + t.Setenv("CARLOS_EDGE_GZIP", "off") |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("Content-Encoding = %q with the kill switch off, want empty", got) |
| + } |
| + if got := rec.Header().Get("Vary"); strings.Contains(got, "Accept-Encoding") { |
| + t.Errorf("Vary = %q with the kill switch off — nothing may hint at a variant that is never produced", got) |
| + } |
| + if rec.Body.Len() != 4096 { |
| + t.Errorf("body = %d bytes, want the 4096-byte identity body", rec.Body.Len()) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run TestEdge -count=1` |
| +Expected: FAIL — `TestEdgeCompressesProxiedAssets` reports `Content-Encoding = "" want gzip` (nothing is wired yet). |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +3a. Append to `internal/edge/compress.go` (imports gain `os`): |
| + |
| +```go |
| +// gzipEnabled reads the CARLOS_EDGE_GZIP kill switch. Compression is ON unless |
| +// the variable names a false value — the rollback path is an env change and a |
| +// restart, not a platform binary roll. Matches the CARLOS_* env idiom in |
| +// certcache.go and dns.go. |
| +func gzipEnabled() bool { |
| + switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_EDGE_GZIP"))) { |
| + case "off", "false", "0", "no": |
| + return false |
| + } |
| + return true |
| +} |
| +``` |
| + |
| +3b. In `internal/edge/edge.go`, add to the `Edge` struct (beside the `cache` field, ~line 138): |
| + |
| +```go |
| + // gzipOn is the CARLOS_EDGE_GZIP kill switch, read once in New. When |
| + // false, ServeHTTP never constructs a gzipWriter and the writer stack is |
| + // byte-for-byte what it was before compression existed (compress.go). |
| + gzipOn bool |
| +``` |
| + |
| +3c. In `New` (edge.go:181), set it: |
| + |
| +```go |
| + e := &Edge{reg: reg, act: act, probers: newProberSet(), gzipOn: gzipEnabled()} |
| +``` |
| + |
| +3d. In `ServeHTTP`, immediately after the `if isUpgradeRequest(r) { ... }` block and before the `cacheable := ...` line: |
| + |
| +```go |
| + // Response compression (compress.go). Placed AFTER the upgrade branch, so |
| + // an upgrading request keeps the exact writer stack it has always had — |
| + // purge_header.go's contract records what wrapping the raw ResponseWriter |
| + // on that path has already cost this package — and BEFORE the cache check, |
| + // so a cache hit compresses on replay like any other delivery. |
| + // |
| + // The recorders constructed below wrap THIS writer, so they record |
| + // identity bytes and the cache keeps storing identity bodies; the usage |
| + // meter's countingWriter is below it, so the metered count is the |
| + // compressed one. This defer is registered after the meter's, so LIFO |
| + // closes the gzip stream — trailer included — before Record reads it. |
| + if e.gzipOn { |
| + if gw := newGzipWriter(w, r); gw != nil { |
| + w = gw |
| + defer gw.finish() |
| + } |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run TestEdge -count=1` |
| +Expected: PASS |
| + |
| +Then the whole package: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -count=1` |
| +Expected: PASS. If an existing test fails asserting an exact `Content-Length` or byte count on a compressible body of 1024+ bytes, that is the feature working — update the test to decompress or to assert the identity length via a non-gzip request, and say so in the commit message. Do **not** weaken the feature to keep an assertion. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/edge/compress.go internal/edge/edge.go internal/edge/compress_test.go |
| +git add internal/edge/compress.go internal/edge/edge.go internal/edge/compress_test.go |
| +git commit -m "edge: compress responses at the client-write boundary |
| + |
| +One wrap site in ServeHTTP after the upgrade branch and before the |
| +cache check, so cache hits, proxied misses, static trees and the status |
| +host all pass through it. The cache keeps storing identity bodies and |
| +the usage meter counts the compressed byte count. |
| + |
| +CARLOS_EDGE_GZIP=off disables it without a binary roll." |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Regression battery, full gate, PR |
| + |
| +Everything the wrap site could plausibly have broken, plus the serving classes that reach it through a different door. Then the gate and the PR. |
| + |
| +**Files:** |
| +- Test: `internal/edge/compress_test.go` |
| +- Modify: `CLAUDE.md` (one bullet under "Rules this repo has paid for") |
| + |
| +**Interfaces:** |
| +- Consumes: `edgeWithBackend`, `gzipReq`, `cssBackend` from Task 4; `upgradeEchoBackend(t, tag) string`, `openReg(t)` and `testEdgeWithStore(t, rt)` from `edge_test.go`; `testKeys(t)` and `shipSite(t, store, app, version, files)` from `static_test.go`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/edge/compress_test.go` (imports gain `bufio`, `context`, `fmt`, `net`, `path/filepath`, `time`, and `github.com/carlosframework/platform/internal/release`): |
| + |
| +```go |
| +// TestUpgradeIsUntouchedByCompression: the wrap site is after the upgrade |
| +// branch precisely so a handshake keeps the writer stack it has always had. |
| +// A gzip-accepting client (every browser) must still get its 101, and bytes |
| +// must still flow both ways after the switch. |
| +func TestUpgradeIsUntouchedByCompression(t *testing.T) { |
| + addr := upgradeEchoBackend(t, "ws") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ws.test", Kind: "service", Status: "ready", Addr: addr, |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://")) |
| + if err != nil { |
| + t.Fatalf("dial edge: %v", err) |
| + } |
| + defer c.Close() |
| + if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { |
| + t.Fatalf("set deadline: %v", err) |
| + } |
| + if _, err := fmt.Fprint(c, "GET /socket HTTP/1.1\r\nHost: ws.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nAccept-Encoding: gzip\r\n\r\n"); err != nil { |
| + t.Fatalf("write handshake: %v", err) |
| + } |
| + br := bufio.NewReader(c) |
| + resp, err := http.ReadResponse(br, nil) |
| + if err != nil { |
| + t.Fatalf("read handshake response: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusSwitchingProtocols { |
| + t.Fatalf("handshake status = %d, want 101 — an Accept-Encoding header must not divert an upgrade through the compressor", resp.StatusCode) |
| + } |
| + if _, err := fmt.Fprint(c, "ping\n"); err != nil { |
| + t.Fatalf("write post-upgrade frame: %v", err) |
| + } |
| + line, err := br.ReadString('\n') |
| + if err != nil { |
| + t.Fatalf("read post-upgrade frame: %v", err) |
| + } |
| + if line != "ws:ping\n" { |
| + t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways after the switch", line, "ws:ping\n") |
| + } |
| +} |
| + |
| +// TestAlreadyEncodedUpstreamPassesThrough: an app that compresses for itself |
| +// must not be compressed again into a body nobody can decode. |
| +func TestAlreadyEncodedUpstreamPassesThrough(t *testing.T) { |
| + var raw []byte |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var buf strings.Builder |
| + zw := gzip.NewWriter(&buf) |
| + io.WriteString(zw, strings.Repeat("already compressed\n", 200)) |
| + zw.Close() |
| + raw = []byte(buf.String()) |
| + w.Header().Set("Content-Type", "text/plain") |
| + w.Header().Set("Content-Encoding", "gzip") |
| + w.Write(raw) |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/pre.txt")) |
| + |
| + if got := rec.Header().Values("Content-Encoding"); len(got) != 1 || got[0] != "gzip" { |
| + t.Fatalf("Content-Encoding = %v, want exactly one gzip", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body did not survive as single-encoded gzip: %v", err) |
| + } |
| + body, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read body: %v", err) |
| + } |
| + if !strings.HasPrefix(string(body), "already compressed") { |
| + t.Fatalf("body = %.40q, want the upstream's own gzip decoded in ONE pass", body) |
| + } |
| +} |
| + |
| +// TestEventStreamIsNotCompressed: SSE is an open stream consumed event by |
| +// event. Buffering it inside a compressor is a hang, not a saving. |
| +func TestEventStreamIsNotCompressed(t *testing.T) { |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + w.WriteHeader(http.StatusOK) |
| + for i := 0; i < 3; i++ { |
| + fmt.Fprintf(w, "data: %d\n\n", i) |
| + if f, ok := w.(http.Flusher); ok { |
| + f.Flush() |
| + } |
| + } |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/events")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Fatalf("Content-Encoding = %q on an event stream, want empty", got) |
| + } |
| + if !strings.Contains(rec.Body.String(), "data: 0") { |
| + t.Fatalf("event stream body = %q, want the plain events", rec.Body.String()) |
| + } |
| +} |
| + |
| +// TestRangeAndConditionalRequestsSurvive: a 206 must keep its byte offsets |
| +// meaningful, and a conditional request must still 304 against the weakened |
| +// validator the client was given. |
| +func TestRangeAndConditionalRequestsSurvive(t *testing.T) { |
| + body := strings.Repeat("a{color:red}\n", 400) |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + http.ServeContent(w, r, "tokens.css", time.Unix(1, 0), strings.NewReader(body)) |
| + })) |
| + |
| + rng := gzipReq("GET", "http://app.test/tokens.css") |
| + rng.Header.Set("Range", "bytes=0-9") |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, rng) |
| + if rec.Code != http.StatusPartialContent { |
| + t.Fatalf("range status = %d, want 206", rec.Code) |
| + } |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("206 carries Content-Encoding %q — compressing a byte range makes the offsets it names meaningless", got) |
| + } |
| + if rec.Body.Len() != 10 { |
| + t.Errorf("range body = %d bytes, want 10", rec.Body.Len()) |
| + } |
| + |
| + full := httptest.NewRecorder() |
| + e.ServeHTTP(full, gzipReq("GET", "http://app.test/tokens.css")) |
| + etag := full.Header().Get("ETag") |
| + if etag == "" { |
| + t.Skip("ServeContent emitted no ETag; nothing to condition on") |
| + } |
| + if !strings.HasPrefix(etag, `W/`) { |
| + t.Errorf("ETag = %q on a compressed body, want the weakened form", etag) |
| + } |
| + cond := gzipReq("GET", "http://app.test/tokens.css") |
| + cond.Header.Set("If-None-Match", etag) |
| + nm := httptest.NewRecorder() |
| + e.ServeHTTP(nm, cond) |
| + if nm.Code != http.StatusNotModified { |
| + t.Fatalf("conditional request = %d, want 304 — weakening the validator broke revalidation", nm.Code) |
| + } |
| + if got := nm.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("304 carries Content-Encoding %q; a 304 has no body to encode", got) |
| + } |
| +} |
| + |
| +// TestStatusHostCompresses: the status class reaches the wrap site through a |
| +// different door (its own handler, exempt from the shared cache and the usage |
| +// meter) and must compress like everything else. |
| +func TestStatusHostCompresses(t *testing.T) { |
| + s, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"}) |
| + s.Put(context.Background(), "status/fleet.json", []byte(`{"boxes":[`+strings.Repeat(`{"id":"box"},`, 300)+`{}]}`), "") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, gzipReq("GET", "http://status.test/fleet.json")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q on the status host, want gzip", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil || !strings.Contains(string(got), `"boxes"`) { |
| + t.Fatalf("decompressed status body = %.40q (err %v)", got, err) |
| + } |
| +} |
| + |
| +// TestStaticSiteCompresses: the static class is the payoff case — a shipped |
| +// site tree whose CSS is served straight off disk by http.ServeFile. It |
| +// reaches the wrap site through Edge.ServeHTTP like every other class, so |
| +// this pins that the class nobody proxies is covered too. |
| +func TestStaticSiteCompresses(t *testing.T) { |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + priv, pub := testKeys(t) |
| + css := strings.Repeat("a{color:red}\n", 400) // 5200 bytes, well over the threshold |
| + shipSite(t, store, "site", "v1", map[string]string{ |
| + "index.html": "home", |
| + "tokens.css": css, |
| + }) |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "site.test", Kind: "static", App: "site", Channel: "canary/x", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://site.test/tokens.css")) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", rec.Code) |
| + } |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q on a static asset, want gzip", got) |
| + } |
| + if got := rec.Header().Get("X-Carlos-Version"); got != "v1" { |
| + t.Errorf("X-Carlos-Version = %q, want v1 — compression must not disturb the static class's per-request version stamp", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if string(got) != css { |
| + t.Fatalf("decompressed static asset differs from the shipped file") |
| + } |
| + if rec.Body.Len() >= len(css) { |
| + t.Fatalf("compressed %d bytes >= identity %d", rec.Body.Len(), len(css)) |
| + } |
| +} |
| + |
| +// TestHeadKeepsItsContentLength: a HEAD has no body to compress, and its |
| +// truthful Content-Length is the whole value of the response. The backend |
| +// sets the length explicitly — Go only infers one when the whole body fits |
| +// its 2048-byte buffer, so a 4096-byte handler would otherwise chunk and this |
| +// test would be asserting the stdlib's buffering, not the edge's behaviour. |
| +func TestHeadKeepsItsContentLength(t *testing.T) { |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Header().Set("Content-Length", "4096") |
| + w.WriteHeader(http.StatusOK) |
| + if r.Method != http.MethodHead { |
| + io.WriteString(w, strings.Repeat("x", 4096)) |
| + } |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("HEAD", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("HEAD Content-Encoding = %q, want empty", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "4096" { |
| + t.Errorf("HEAD Content-Length = %q, want 4096", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails (or reveals a real gap)** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestUpgradeIsUntouched|TestAlreadyEncoded|TestEventStream|TestRangeAndConditional|TestStatusHost|TestStaticSiteCompresses|TestHeadKeeps' -count=1 -v` |
| +Expected: these are regression tests over Task 4's implementation, so most should PASS on first run. **Any failure here is a real defect, not a missing feature** — fix `compress.go`, never the assertion. The likely one is the conditional-request case: if `http.ServeContent` compares `If-None-Match` strongly against its own tag, the weakening in `weakenETag` needs the 304 path to strip `W/` on the way back in. Diagnose before changing anything. |
| + |
| +- [ ] **Step 3: Fix any defect the battery found** |
| + |
| +If everything passed, this step is a no-op — record that in the commit message rather than inventing work. |
| + |
| +- [ ] **Step 4: Run the full gate** |
| + |
| +```bash |
| +export GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +GOOS=darwin go build ./... |
| +gofmt -l internal/edge/compress.go internal/edge/compress_test.go internal/edge/edge.go |
| +``` |
| +Expected: every package passes, the darwin cross-compile succeeds, `gofmt -l` prints nothing. |
| + |
| +- [ ] **Step 5: Record the rule and open the PR** |
| + |
| +Add to `CLAUDE.md` under "Rules this repo has paid for": |
| + |
| +```markdown |
| +- **A ResponseWriter wrapper that edits headers needs its own header map.** |
| + Wrappers here embed the writer below, so `Header()` is ONE shared map from |
| + handler to connection — and `maybeStore` caches `rec.Header().Clone()`. The |
| + gzip wrapper (`internal/edge/compress.go`) keeps a private map and writes |
| + `Content-Encoding` only to the real writer's; on the shared map it would |
| + have stored an identity body labelled gzip and served it to every later |
| + visitor. Ordering is load-bearing too: recorder ABOVE the compressor (cache |
| + stores identity), usage counter BELOW it (tenants are billed for wire |
| + bytes), and the wrap site AFTER the upgrade branch. |
| +``` |
| + |
| +```bash |
| +git add CLAUDE.md internal/edge/compress_test.go internal/edge/compress.go |
| +git commit -m "edge: gzip regression battery + record the wrapper rule |
| + |
| +Upgrades still 101 with Accept-Encoding set, an already-encoded |
| +upstream is not double-compressed, SSE and 206 stay identity, the |
| +status host compresses, and a weakened ETag still revalidates." |
| +git push -u origin gzip-assets |
| +gh pr create --title "Edge response compression (gzip)" --body "$(cat <<'BODY' |
| +Compresses eligible responses at the edge with stdlib gzip, at one |
| +client-write boundary covering cache hits, proxied misses, static trees |
| +and the status host. Apps stay identity-only; transport is the |
| +platform's problem. |
| + |
| +Writer stack: `real conn -> countingWriter -> gzipWriter -> recordingResponseWriter -> handler`. |
| +The recorder above keeps the response cache storing identity bodies |
| +(one variant, compressed after retrieval); the counter below means |
| +tenants are metered for what actually crossed the wire. |
| + |
| +The wrapper owns a private header map — wrappers here embed the writer |
| +below, so `Header()` is one shared map and `maybeStore` caches |
| +`rec.Header().Clone()`. Setting `Content-Encoding` there would have |
| +cached an identity body labelled gzip. |
| + |
| +Eligibility is header-only, decided once at WriteHeader: compressible |
| +Content-Type, no existing encoding, no `no-transform`, not 204/205/206/304, |
| +`Content-Length` absent or >= 1024. A missing Content-Type is never |
| +sniffed. Upgrades return before the wrap site. |
| + |
| +Rollback: `CARLOS_EDGE_GZIP=off`, no binary roll. |
| + |
| +Spec: `docs/superpowers/specs/2026-08-18-edge-response-compression-design.md` |
| +Plan: `docs/superpowers/plans/2026-08-18-edge-response-compression.md` |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
| + |
| +https://claude.ai/code/session_01NoqzkUVahW5ykubsBQitxc |
| +BODY |
| +)" |
| +``` |
| + |
| +--- |
| + |
| +## Deployment note (not a task) |
| + |
| +This ships in the platform binary and takes effect at the edge process restart |
| +that a platform roll performs. Verify on a real host with the header, against |
| +the box, not a cached answer: |
| + |
| +```bash |
| +curl -sI -H 'Accept-Encoding: gzip' --resolve <host>:443:<box-ip> https://<host>/<asset.css> \ |
| + | grep -iE 'content-encoding|vary|x-carlos-version' |
| +``` |
| + |
| +Expect `content-encoding: gzip`, a `vary` naming `Accept-Encoding`, and the |
| +`X-Carlos-Version` of the build you just rolled. A request without the |
| +`Accept-Encoding` header must come back identity from the same URL. |