Skip to content
quarry Artifact [3b4e8024f7]
Login

Artifact [3b4e8024f7]

Artifact 3b4e8024f7d4013a8c6c4da0f68e90ab10d2ca25d7f535239446613efaf1c5f9:


// 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
}