// compress.go adds Content-Encoding to responses that benefit from it.
//
// Measured on this forge before it existed: the timeline page was
// 309,584 bytes and gzipped to 36,599 (88% off), a ticket list 209,058
// to 33,194 (84%), a highlighted source page 2,335,223 to 196,463
// (92%). Nothing was compressed — a request carrying `Accept-Encoding:
// gzip` got byte-identical bytes back.
//
// gzip rather than brotli, deliberately. Brotli at quality 5 beat gzip
// at 6 on BOTH size (34,551 vs 37,166) and time (3.0ms vs 3.3ms) in the
// same benchmark, so it is the better algorithm — but it is a third
// party dependency for a ~7% gain on a page already 88% smaller, and
// the standard library covers the rest. If a measurement ever justifies
// the dependency the negotiation below is where it goes.
package web
import (
"compress/gzip"
"net/http"
"strings"
"sync"
)
// gzipMinBytes is the size below which compressing costs more than it
// saves — a header, a trailer and a syscall against a few hundred
// bytes. Fragments and small JSON answers fall under it.
const gzipMinBytes = 1024
var gzipPool = sync.Pool{New: func() any { return gzip.NewWriter(nil) }}
// Compress wraps h so responses are gzipped when the client asked and
// the body is worth compressing.
func Compress(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !acceptsGzip(r.Header.Get("Accept-Encoding")) {
h.ServeHTTP(w, r)
return
}
// Announced whether or not this particular response ends up
// compressed: the same URL answers differently by header, and
// a cache that does not know that will serve gzip to a client
// that cannot read it.
w.Header().Add("Vary", "Accept-Encoding")
cw := &gzipResponseWriter{ResponseWriter: w}
defer cw.Close()
h.ServeHTTP(cw, r)
})
}
// acceptsGzip reads the header rather than searching it for a
// substring: "gzip;q=0" is a client saying it does NOT want gzip, and a
// contains-check reads that as a yes.
func acceptsGzip(header string) bool {
for _, part := range strings.Split(header, ",") {
name, params, _ := strings.Cut(strings.TrimSpace(part), ";")
if !strings.EqualFold(strings.TrimSpace(name), "gzip") {
continue
}
for _, p := range strings.Split(params, ";") {
k, v, ok := strings.Cut(strings.TrimSpace(p), "=")
if ok && strings.EqualFold(strings.TrimSpace(k), "q") && strings.TrimSpace(v) == "0" {
return false
}
}
return true
}
return false
}
// gzipResponseWriter decides at WriteHeader time, because that is the
// first moment the Content-Type is known.
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
decided bool
// pending holds bytes written before the decision, which happens
// when a handler writes without calling WriteHeader.
pending []byte
}
func (w *gzipResponseWriter) WriteHeader(status int) {
w.decide(status)
w.ResponseWriter.WriteHeader(status)
}
// decide installs the encoder, or does not, and must be called before
// the header is sent.
func (w *gzipResponseWriter) decide(status int) {
if w.decided {
return
}
w.decided = true
h := w.Header()
// Never twice: a handler that already encoded its body owns it.
if h.Get("Content-Encoding") != "" {
return
}
// 204 and 304 carry no body; 1xx is not a response yet.
if status == http.StatusNoContent || status == http.StatusNotModified || status < 200 {
return
}
if !compressible(h.Get("Content-Type")) {
return
}
h.Set("Content-Encoding", "gzip")
// The length of the ENCODED body is not known yet, and a stale one
// is worse than none: the client would truncate or hang.
h.Del("Content-Length")
gz := gzipPool.Get().(*gzip.Writer)
gz.Reset(w.ResponseWriter)
w.gz = gz
}
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
if !w.decided {
// A handler that never called WriteHeader: hold the first bytes
// until there are enough to be worth compressing, so a small
// fragment is not wrapped in a gzip envelope larger than itself.
w.pending = append(w.pending, b...)
if len(w.pending) < gzipMinBytes {
return len(b), nil
}
pending := w.pending
w.pending = nil
w.WriteHeader(http.StatusOK)
if w.gz != nil {
return writeAll(w.gz, pending, len(b))
}
return writeAll(w.ResponseWriter, pending, len(b))
}
if w.gz != nil {
return w.gz.Write(b)
}
return w.ResponseWriter.Write(b)
}
func writeAll(dst interface{ Write([]byte) (int, error) }, buf []byte, report int) (int, error) {
if _, err := dst.Write(buf); err != nil {
return 0, err
}
return report, nil
}
// Unwrap lets http.ResponseController reach the connection beneath the
// encoder. Without it a stream's per-frame write deadline is silently
// unsupported, and a reader that stops reading holds its handler open.
func (w *gzipResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// Flush is what keeps server-sent events working. quarry has 22 stream
// writers, and a middleware that buffers turns live updates into a page
// that never arrives — the failure mode is silence, which is why the
// SSE content type is excluded above rather than relied upon to flush.
func (w *gzipResponseWriter) Flush() {
if !w.decided && len(w.pending) > 0 {
pending := w.pending
w.pending = nil
w.WriteHeader(http.StatusOK)
if w.gz != nil {
w.gz.Write(pending)
} else {
w.ResponseWriter.Write(pending)
}
}
if w.gz != nil {
w.gz.Flush()
}
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *gzipResponseWriter) Close() {
if !w.decided && len(w.pending) > 0 {
pending := w.pending
w.pending = nil
w.WriteHeader(http.StatusOK)
if w.gz != nil {
w.gz.Write(pending)
} else {
w.ResponseWriter.Write(pending)
}
}
if w.gz != nil {
w.gz.Close()
gzipPool.Put(w.gz)
w.gz = nil
}
}
// compressible reports whether a content type is worth encoding.
//
// text/event-stream is the important exclusion: SSE is a long-lived
// stream, and gzip buffers until it has something worth emitting, so a
// compressed stream delivers its first event late or never. Images,
// fonts and archives are already compressed and only grow.
func compressible(ct string) bool {
mt, _, _ := strings.Cut(ct, ";")
mt = strings.ToLower(strings.TrimSpace(mt))
if mt == "" || mt == "text/event-stream" {
return false
}
switch mt {
case "image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon",
"font/woff", "font/woff2", "application/zip", "application/gzip",
"application/x-fossil", "application/octet-stream":
return false
}
if strings.HasPrefix(mt, "text/") {
return true
}
switch mt {
case "application/json", "application/javascript", "application/xml",
"application/rss+xml", "application/atom+xml", "image/svg+xml":
return true
}
return false
}