carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Edge response compression: one gzip ResponseWriter wrapper applied at the
2// client-write boundary in Edge.ServeHTTP, so every serving class — cache
3// hits, proxied misses, static trees, the status host — compresses through a
4// single site. Apps ship identity bytes; transport is the platform's problem.
5//
6// The wrapper sits BELOW recordingResponseWriter and ABOVE countingWriter:
7// the cache keeps storing identity bodies (one variant, compressed after
8// retrieval on every delivery) and the usage meter counts what actually
9// crossed the wire.
10package edge
11
12import (
13 "bufio"
14 "compress/gzip"
15 "fmt"
16 "io"
17 "net"
18 "net/http"
19 "net/textproto"
20 "os"
21 "strconv"
22 "strings"
23 "sync"
24)
25
26// acceptsGzip reports whether r's Accept-Encoding admits gzip. Parsed as the
27// q-valued token list RFC 9110 §12.5.3 defines rather than a substring search:
28// "gzip;q=0" is a client REFUSING gzip, and answering it with a compressed
29// body is a broken response, not a missed optimization.
30//
31// A specific token outranks the wildcard, so the first gzip token decides —
32// "gzip;q=0, *" is a refusal. The wildcard only speaks when gzip is unnamed.
33//
34// Read through joinHeader, not Header.Get: a field may legally arrive on
35// several lines, and Get returns only the first (the same class of bug
36// joinHeader's doc comment describes for Vary and Cache-Control).
37func acceptsGzip(h http.Header) bool {
38 var wildcard, wildcardOK bool
39 for _, part := range strings.Split(joinHeader(h, "Accept-Encoding"), ",") {
40 token, params, _ := strings.Cut(strings.TrimSpace(part), ";")
41 switch strings.ToLower(strings.TrimSpace(token)) {
42 case "gzip", "x-gzip":
43 return qValue(params) > 0
44 case "*":
45 wildcard, wildcardOK = true, qValue(params) > 0
46 }
47 }
48 if wildcard {
49 return wildcardOK
50 }
51 return false
52}
53
54// qValue reads the q parameter out of one Accept-Encoding element's parameter
55// string ("q=0.5", "q=0"). Absent or malformed means q=1: a client whose
56// header we cannot fully parse is treated as accepting, which is the same
57// default the stdlib's own negotiation takes.
58func qValue(params string) float64 {
59 for _, p := range strings.Split(params, ";") {
60 name, val, ok := strings.Cut(strings.TrimSpace(p), "=")
61 if !ok || !strings.EqualFold(strings.TrimSpace(name), "q") {
62 continue
63 }
64 f, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
65 if err != nil {
66 return 1
67 }
68 return f
69 }
70 return 1
71}
72
73// minCompressSize is the Content-Length below which compressing is not worth
74// the CPU: gzip's own header and trailer are 18 bytes, and a sub-kilobyte
75// response often lands in one packet either way. A response whose length is
76// UNKNOWN (streamed, chunked) is compressed regardless — refusing those would
77// exempt most proxied HTML, which is the bulk of what there is to save.
78const minCompressSize = 1024
79
80// compressible reports whether a response with these headers and this status
81// should be gzipped. The request-side questions — does the client accept
82// gzip, is this a HEAD — are settled once at wrapper construction
83// (newGzipWriter), so this reads the response alone.
84//
85// Every false below is a response that would be WRONG or pointless to
86// compress, not merely unprofitable:
87//
88// - 204/205 have no body; 304 has none and must keep the validators of the
89// representation it refers to; 206 is a byte range of a larger body, and
90// compressing a range makes the offsets it names meaningless.
91// - An existing Content-Encoding means the origin already encoded it.
92// Compressing again produces a body no client can decode without knowing
93// we did it.
94// - Cache-Control: no-transform is the origin forbidding exactly this
95// (RFC 9110 §5.5).
96//
97// A MISSING Content-Type is refused rather than sniffed. Sniffing is the
98// stdlib's job and it runs on the bytes we would have already compressed —
99// it would read gzip's magic number and label the response application/x-gzip.
100func compressible(h http.Header, status int) bool {
101 switch status {
102 case http.StatusNoContent, http.StatusResetContent,
103 http.StatusPartialContent, http.StatusNotModified:
104 return false
105 }
106 if h.Get("Content-Range") != "" {
107 return false
108 }
109 if h.Get("Content-Encoding") != "" {
110 return false
111 }
112 if hasCCDirective(joinHeader(h, "Cache-Control"), "no-transform") {
113 return false
114 }
115 if n, err := strconv.Atoi(h.Get("Content-Length")); err == nil && n < minCompressSize {
116 return false
117 }
118 return compressibleType(h.Get("Content-Type"))
119}
120
121// compressibleType reports whether a media type is text underneath. text/* is
122// the bulk of it; the structured suffixes (+json, +xml) catch types nobody has
123// enumerated yet, which is why they are here rather than a longer switch.
124//
125// text/event-stream is the one text type excluded: it is an open stream a
126// client consumes event by event, and buffering it inside a compressor is a
127// hang, not a saving.
128func compressibleType(ct string) bool {
129 mt, _, _ := strings.Cut(ct, ";")
130 mt = strings.ToLower(strings.TrimSpace(mt))
131 if mt == "" || mt == "text/event-stream" {
132 return false
133 }
134 if strings.HasPrefix(mt, "text/") ||
135 strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "+xml") {
136 return true
137 }
138 switch mt {
139 case "application/json", "application/javascript", "application/x-javascript",
140 "application/xml", "application/wasm":
141 return true
142 }
143 return false
144}
145
146// weakenETag downgrades a strong validator to a weak one, in place. A gzipped
147// body is a different representation of the same resource, so an ETag that
148// claims byte-for-byte equality with the identity body is a lie — and the one
149// thing that lie breaks is a Range request served against the wrong bytes.
150// Conditional requests keep working: If-None-Match compares weakly for GET,
151// so a client returning W/"abc" still matches the origin's "abc" and gets its
152// 304.
153func weakenETag(h http.Header) {
154 if et := h.Get("ETag"); strings.HasPrefix(et, `"`) {
155 h.Set("ETag", `W/`+et)
156 }
157}
158
159// gzipPool recycles compressors. We compress on every delivery, cache hits
160// included, so a fresh gzip.Writer per response would be a steady stream of
161// garbage on a busy edge for no reason.
162var gzipPool = sync.Pool{New: func() any {
163 w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
164 return w
165}}
166
167// gzipWriter compresses an eligible response on its way to the client.
168//
169// It owns a PRIVATE header map. Every other ResponseWriter wrapper in this
170// package embeds the writer below, so Header() returns one shared map from the
171// handler all the way down to the connection — and maybeStore caches
172// rec.Header().Clone(). Were the compression headers set on that shared map,
173// the cache would store an identity body labelled Content-Encoding: gzip and
174// hand it to every later visitor. So callers above read and write hdr, and the
175// edits that describe the COMPRESSED representation go on the real writer's
176// map alone.
177//
178// Deliberately does NOT implement io.ReaderFrom: io.Copy (and so
179// http.ServeFile) falls back to Write, which is what compresses.
180type gzipWriter struct {
181 http.ResponseWriter
182 hdr http.Header
183 gz *gzip.Writer // nil unless this response is being compressed
184 wroteHdr bool
185}
186
187// newGzipWriter returns a wrapper for r, or nil when this request can never
188// produce a compressed response — no wrapper at all, so the writer stack is
189// exactly what it is today. The request-side questions are settled here, once:
190// everything after depends only on the response.
191//
192// HEAD is excluded rather than compressed-with-no-body: there is nothing to
193// compress, and a HEAD's truthful Content-Length is the entire value of the
194// response. The accepted cost is that a HEAD advertises the identity length
195// while a GET of the same URL returns gzip with no length at all.
196func newGzipWriter(w http.ResponseWriter, r *http.Request) *gzipWriter {
197 if r.Method == http.MethodHead || !acceptsGzip(r.Header) {
198 return nil
199 }
200 return &gzipWriter{ResponseWriter: w, hdr: http.Header{}}
201}
202
203// Header returns the PRIVATE map. See the type's doc comment: this is what
204// keeps the response cache storing identity bodies.
205func (g *gzipWriter) Header() http.Header { return g.hdr }
206
207// syncHeader replaces the real writer's header block with the private one.
208// Wholesale, not a merge: a handler that DELETED a header between blocks (the
209// shape the purge-on-103 test exercises) must see that deletion reach the
210// client, which a merge would silently undo.
211func (g *gzipWriter) syncHeader() {
212 down := g.ResponseWriter.Header()
213 for k := range down {
214 delete(down, k)
215 }
216 for k, v := range g.hdr {
217 down[k] = v
218 }
219}
220
221func (g *gzipWriter) WriteHeader(status int) {
222 // 1xx blocks (103 Early Hints, forwarded by httputil.ReverseProxy) precede
223 // the final status. Sync and forward without latching — the same shape
224 // recordingResponseWriter.WriteHeader uses, for the same reason: latching
225 // one would decide compression on a status that has no body.
226 if status < 200 {
227 g.syncHeader()
228 g.ResponseWriter.WriteHeader(status)
229 return
230 }
231 if g.wroteHdr {
232 g.ResponseWriter.WriteHeader(status) // superfluous; let the stdlib log it
233 return
234 }
235 g.wroteHdr = true
236 g.syncHeader()
237 if compressible(g.hdr, status) {
238 h := g.ResponseWriter.Header()
239 h.Set("Content-Encoding", "gzip")
240 h.Del("Content-Length") // it described the identity body
241 h.Add("Vary", "Accept-Encoding")
242 weakenETag(h)
243 g.gz = gzipPool.Get().(*gzip.Writer)
244 g.gz.Reset(g.ResponseWriter)
245 }
246 g.ResponseWriter.WriteHeader(status)
247}
248
249func (g *gzipWriter) Write(p []byte) (int, error) {
250 if !g.wroteHdr {
251 g.WriteHeader(http.StatusOK)
252 }
253 if g.gz != nil {
254 return g.gz.Write(p)
255 }
256 return g.ResponseWriter.Write(p)
257}
258
259// finish closes the compressor and returns it to the pool. ServeHTTP defers
260// it, AFTER the usage meter's defer, so LIFO runs this one first and the
261// metered byte count includes gzip's trailer.
262//
263// The no-header case is not a no-op: a handler that returned without ever
264// writing set its headers on the private map, and nothing has copied them
265// down. Without this sync the stdlib emits a bare 200 with none of them.
266func (g *gzipWriter) finish() {
267 if !g.wroteHdr {
268 g.syncHeader()
269 return
270 }
271 g.syncTrailers()
272 if g.gz == nil {
273 return
274 }
275 g.gz.Close()
276 g.gz.Reset(io.Discard) // don't pin this request's writer inside the pool
277 gzipPool.Put(g.gz)
278 g.gz = nil
279}
280
281// syncTrailers copies LATE additions to the private map down to the real
282// writer. net/http moves a header set after WriteHeader into the trailer block
283// when it was announced in Trailer (or carries http.TrailerPrefix) — and every
284// such Set landed on the private map, where syncHeader has already been and
285// gone. Without this the wrapper silently eats trailers, which
286// purge_header.go's contract names as a channel apps may legitimately use.
287//
288// The fields the compression edits own are skipped while compressing: the
289// private map holds the IDENTITY representation's versions of them, and
290// copying those down would undo the Content-Encoding, restore a Content-Length
291// that describes the wrong bytes, drop our Vary, and re-strengthen the ETag.
292func (g *gzipWriter) syncTrailers() {
293 down := g.ResponseWriter.Header()
294 for k, v := range g.hdr {
295 if g.gz != nil && compressionOwned(k) {
296 continue
297 }
298 down[k] = v
299 }
300}
301
302// compressionOwned reports whether a header name is one the compression edits
303// control on the real writer's map. It is called with RAW map keys, which are
304// canonical — and canonical for ETag is "Etag". Spelling it "ETag" here (the
305// form the RFC and Header.Get use, since Get canonicalizes its argument)
306// matched nothing, and the identity ETag was copied back over the weakened
307// one. TestGzipWriterForwardsTrailers is what holds this.
308func compressionOwned(name string) bool {
309 switch textproto.CanonicalMIMEHeaderKey(name) {
310 case "Content-Encoding", "Content-Length", "Vary", "Etag":
311 return true
312 }
313 return false
314}
315
316// Flush pushes the compressor's buffered bytes out and then flushes the writer
317// below — needed so a streamed response reaches the client incrementally
318// instead of buffering until the handler returns.
319func (g *gzipWriter) Flush() {
320 if g.gz != nil {
321 g.gz.Flush()
322 }
323 if f, ok := g.ResponseWriter.(http.Flusher); ok {
324 f.Flush()
325 }
326}
327
328// Hijack forwards to the writer below, when it supports it — the same
329// optional-interface pattern countingWriter and recordingResponseWriter use.
330// ServeHTTP returns on the upgrade path before this wrapper is constructed, so
331// this is not expected on the hot path; it exists so the wrapper is never
332// itself the reason a hijack fails.
333func (g *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
334 hj, ok := g.ResponseWriter.(http.Hijacker)
335 if !ok {
336 return nil, nil, fmt.Errorf("edge: underlying ResponseWriter does not support http.Hijacker")
337 }
338 return hj.Hijack()
339}
340
341// gzipEnabled reads the CARLOS_EDGE_GZIP kill switch. Compression is ON unless
342// the variable names a false value — the rollback path is an env change and a
343// restart, not a platform binary roll. Matches the CARLOS_* env idiom in
344// certcache.go and dns.go.
345func gzipEnabled() bool {
346 switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_EDGE_GZIP"))) {
347 case "off", "false", "0", "no":
348 return false
349 }
350 return true
351}
352