Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · internal/compress/compress.go

Line attribution on main · 206 line(s)

a76b2f38
Keenan Nemetz
12d ago
1 package compress
306f36fc
Keenan Nemetz
12d ago
2
a76b2f38
Keenan Nemetz
12d ago
3 // Package compress gzips text responses.
4 //
5 // It is shared by the two HTTP surfaces that serve bytes: the forge UI (wrapped
6 // into its middleware, because it needs the writer inside its own panic and
7 // page-view bookkeeping) and hosted sites (wrapped once around the site
8 // dispatcher). One implementation so both answer the same negotiation and the
9 // same header rules.
306f36fc
Keenan Nemetz
12d ago
10 //
11 // The web surface sends HTML, CSS, JavaScript and source blobs, all of which
12 // compress to a fraction of their size: the vendored Prism bundle alone is
13 // 545 KB and lands near 200 KB gzipped, and a rendered page about a fifth of its
14 // size. There is no brotli here on purpose — it would add a dependency for a few
15 // points of ratio, and Go's gzip needs none.
16 //
17 // The rules are narrow, because the same handler chain also streams git
18 // responses, release archives and LFS objects:
19 //
20 // - only when the client sent `Accept-Encoding: gzip` with q > 0;
21 // - only text-like content types, never a body that is already encoded;
22 // - never a Range request (the compressed body has no meaningful ranges);
23 // - never a body the handler has already sized below compressMinBytes;
24 // - never a 1xx, 204, 206, 304 or redirect (nothing to gain, and 304 must
25 // stay bodiless).
26 //
27 // The writer is transparent otherwise: it passes Header through, keeps Flush
28 // working for the streaming handlers, and exposes Unwrap so a handler can reach
29 // the underlying ResponseWriter.
30
31 import (
32 "compress/gzip"
33 "net/http"
34 "strconv"
35 "strings"
36 )
37
38 // compressMinBytes is the smallest declared body worth a gzip header and
39 // trailer. Below it the compressed form is usually larger than the source.
40 const compressMinBytes = 256
41
a76b2f38
Keenan Nemetz
12d ago
42 // Writer decides per response whether to gzip, and lazily starts the stream on
43 // the first write so a header-only response never opens one.
44 type Writer struct {
306f36fc
Keenan Nemetz
12d ago
45 rw http.ResponseWriter
46 r *http.Request
47 gz *gzip.Writer
48 status int
49 wroteHeader bool
50 gzipping bool
51 }
52
a76b2f38
Keenan Nemetz
12d ago
53 // New wraps rw for one request. The caller must Close it when the handler has
54 // returned; Handler does that for a whole handler tree.
55 func New(rw http.ResponseWriter, r *http.Request) *Writer {
56 return &Writer{rw: rw, r: r}
57 }
58
59 // Handler wraps next so every response it writes is compressed by the rules in
60 // New. It is the whole of the site surface's compression support.
61 func Handler(next http.Handler) http.Handler {
62 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
63 w := New(rw, r)
64 next.ServeHTTP(w, r)
65 w.Close()
66 })
306f36fc
Keenan Nemetz
12d ago
67 }
68
a76b2f38
Keenan Nemetz
12d ago
69 func (c *Writer) Header() http.Header { return c.rw.Header() }
306f36fc
Keenan Nemetz
12d ago
70
a76b2f38
Keenan Nemetz
12d ago
71 func (c *Writer) WriteHeader(status int) {
306f36fc
Keenan Nemetz
12d ago
72 if c.wroteHeader {
73 return
74 }
75 c.wroteHeader = true
76 c.status = status
77 h := c.rw.Header()
78 if c.acceptsGzip() && compressibleStatus(status) && compressibleHeader(h, c.r) {
79 c.gzipping = true
80 h.Set("Content-Encoding", "gzip")
81 h.Del("Content-Length")
82 h.Add("Vary", "Accept-Encoding")
83 }
84 c.rw.WriteHeader(status)
85 }
86
a76b2f38
Keenan Nemetz
12d ago
87 func (c *Writer) Write(b []byte) (int, error) {
306f36fc
Keenan Nemetz
12d ago
88 if !c.wroteHeader {
89 c.WriteHeader(http.StatusOK)
90 }
91 if !c.gzipping {
92 return c.rw.Write(b)
93 }
94 if c.gz == nil {
95 c.gz = gzip.NewWriter(c.rw)
96 }
97 return c.gz.Write(b)
98 }
99
100 // Flush keeps streaming handlers working: the header is settled, the gzip
101 // buffer is emptied, and the underlying writer is flushed if it can be.
a76b2f38
Keenan Nemetz
12d ago
102 func (c *Writer) Flush() {
306f36fc
Keenan Nemetz
12d ago
103 if !c.wroteHeader {
104 c.WriteHeader(http.StatusOK)
105 }
106 if c.gz != nil {
107 _ = c.gz.Flush()
108 }
109 if f, ok := c.rw.(http.Flusher); ok {
110 f.Flush()
111 }
112 }
113
114 // Close finishes the gzip stream. It is called once by the middleware after the
115 // handler returns, never by a handler, but it is idempotent enough for the
116 // panic path that may have written nothing at all.
a76b2f38
Keenan Nemetz
12d ago
117 func (c *Writer) Close() {
306f36fc
Keenan Nemetz
12d ago
118 if c.gz != nil {
119 _ = c.gz.Close()
120 c.gz = nil
121 }
122 }
123
a76b2f38
Keenan Nemetz
12d ago
124 func (c *Writer) Unwrap() http.ResponseWriter { return c.rw }
306f36fc
Keenan Nemetz
12d ago
125
126 // acceptsGzip reports whether the client asked for gzip and would accept it:
127 // `gzip` with a q-value of zero is a refusal, not a request.
a76b2f38
Keenan Nemetz
12d ago
128 func (c *Writer) acceptsGzip() bool {
306f36fc
Keenan Nemetz
12d ago
129 if c.r.Method == http.MethodHead {
130 return false
131 }
132 for _, part := range strings.Split(c.r.Header.Get("Accept-Encoding"), ",") {
133 name, params, _ := strings.Cut(strings.TrimSpace(part), ";")
134 if !strings.EqualFold(strings.TrimSpace(name), "gzip") {
135 continue
136 }
137 q := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(params), "q="))
138 if q == "" {
139 return true
140 }
141 if v, err := strconv.ParseFloat(q, 64); err == nil {
142 return v > 0
143 }
144 return true
145 }
146 return false
147 }
148
149 // compressibleStatus covers the bodies worth compressing: a rendered page, a
150 // fragment, and the styled error page. Redirects are a sentence, a 304 must stay
151 // bodiless, and a 206 is already a byte range.
152 func compressibleStatus(status int) bool {
153 switch status {
154 case http.StatusNoContent, http.StatusNotModified, http.StatusPartialContent:
155 return false
156 }
157 return (status >= 200 && status < 300) || status >= 400
158 }
159
160 // compressibleHeader applies the remaining rules: a body that is already
161 // encoded stays as it is, a Range request is answered with bytes, and a body
162 // the handler already sized as tiny is not worth compressing.
163 func compressibleHeader(h http.Header, r *http.Request) bool {
164 if h.Get("Content-Encoding") != "" || h.Get("Content-Range") != "" {
165 return false
166 }
167 // A handler that promised exact bytes (a raw download with its Content-Length)
168 // opts out with the standard directive rather than a private header.
169 if strings.Contains(strings.ToLower(h.Get("Cache-Control")), "no-transform") {
170 return false
171 }
172 if r.Header.Get("Range") != "" {
173 return false
174 }
175 if n, err := strconv.Atoi(h.Get("Content-Length")); err == nil && n < compressMinBytes {
176 return false
177 }
178 return compressibleType(h.Get("Content-Type"))
179 }
180
181 // compressibleType is the allow-list of types worth gzipping. Anything else —
182 // images, archives, packs, LFS objects, git protocol output — is already
183 // compressed or is a byte range where compression cannot help.
184 func compressibleType(contentType string) bool {
185 ct := strings.ToLower(strings.TrimSpace(contentType))
186 if i := strings.IndexByte(ct, ';'); i >= 0 {
187 ct = strings.TrimSpace(ct[:i])
188 }
189 switch ct {
190 case "text/event-stream":
191 return false
192 case "image/svg+xml",
193 "application/json",
194 "application/javascript",
195 "application/x-javascript",
196 "application/xml",
197 "application/xhtml+xml",
198 "application/manifest+json",
199 "application/rss+xml",
200 "application/atom+xml":
201 return true
202 }
203 return strings.HasPrefix(ct, "text/") ||
204 strings.HasSuffix(ct, "+json") ||
205 strings.HasSuffix(ct, "+xml")
206 }