// 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 ( "bufio" "compress/gzip" "fmt" "io" "net" "net/http" "net/textproto" "os" "strconv" "strings" "sync" ) // 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 } // 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) } } // gzipPool recycles compressors. We compress on every delivery, cache hits // included, so a fresh gzip.Writer per response would be a steady stream 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() { down := g.ResponseWriter.Header() for k := range down { delete(down, k) } for k, v := range g.hdr { down[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 } g.syncTrailers() 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 } // syncTrailers copies LATE additions to the private map down to the real // writer. net/http moves a header set after WriteHeader into the trailer block // when it was announced in Trailer (or carries http.TrailerPrefix) — and every // such Set landed on the private map, where syncHeader has already been and // gone. Without this the wrapper silently eats trailers, which // purge_header.go's contract names as a channel apps may legitimately use. // // The fields the compression edits own are skipped while compressing: the // private map holds the IDENTITY representation's versions of them, and // copying those down would undo the Content-Encoding, restore a Content-Length // that describes the wrong bytes, drop our Vary, and re-strengthen the ETag. func (g *gzipWriter) syncTrailers() { down := g.ResponseWriter.Header() for k, v := range g.hdr { if g.gz != nil && compressionOwned(k) { continue } down[k] = v } } // compressionOwned reports whether a header name is one the compression edits // control on the real writer's map. It is called with RAW map keys, which are // canonical — and canonical for ETag is "Etag". Spelling it "ETag" here (the // form the RFC and Header.Get use, since Get canonicalizes its argument) // matched nothing, and the identity ETag was copied back over the weakened // one. TestGzipWriterForwardsTrailers is what holds this. func compressionOwned(name string) bool { switch textproto.CanonicalMIMEHeaderKey(name) { case "Content-Encoding", "Content-Length", "Vary", "Etag": return true } return false } // 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() } // 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 }