detached e42fec5

perf(web): gzip pages and assets, add ETag revalidation to public pages

- text assets precompressed once at load and served by Accept-Encoding
- rendered pages over 1 KiB gzipped with pooled writers
- strong ETag from the uncompressed body and sign-in state, -gz for gzip
- If-None-Match answers 304; owner and no-store pages never revalidate
commit
e42fec575cfa6e2d99efeb01531dc667bec41b81
author
brice · · 2026-10-11 18:37 UTC
parent
a2fc532
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +552 -8
M cmd/ermite/web_mr_test.go +2 -2
@@ -67,7 +67,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
6767 anon := b.get("/proj/compare/feat/search", nil)
6868 require.Equal(t, 200, anon.status, anon.body)
6969 assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))
70- assert.Empty(t, anon.hdr.Values("Vary"))
70+ assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary"))
7171 assert.Contains(t, anon.body, "feat/search")
7272 assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit")
7373 assert.NotContains(t, anon.body, "<form class=\"cform create\"")
@@ -129,7 +129,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
129129 mr := b.get("/proj/merge-requests/1", nil)
130130 require.Equal(t, 200, mr.status, mr.body)
131131 assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))
132- assert.Empty(t, mr.hdr.Values("Vary"))
132+ assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary"))
133133 assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
134134 assert.NotContains(t, mr.body, "<b>search</b>")
135135 assert.Contains(t, mr.body, "feat/search")
@@ -67,7 +67,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
67 anon := b.get("/proj/compare/feat/search", nil)67 anon := b.get("/proj/compare/feat/search", nil)
68 require.Equal(t, 200, anon.status, anon.body)68 require.Equal(t, 200, anon.status, anon.body)
69 assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))69 assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))
70- assert.Empty(t, anon.hdr.Values("Vary"))70+ assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary"))
71 assert.Contains(t, anon.body, "feat/search")71 assert.Contains(t, anon.body, "feat/search")
72 assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit")72 assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit")
73 assert.NotContains(t, anon.body, "<form class=\"cform create\"")73 assert.NotContains(t, anon.body, "<form class=\"cform create\"")
@@ -129,7 +129,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
129 mr := b.get("/proj/merge-requests/1", nil)129 mr := b.get("/proj/merge-requests/1", nil)
130 require.Equal(t, 200, mr.status, mr.body)130 require.Equal(t, 200, mr.status, mr.body)
131 assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))131 assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))
132- assert.Empty(t, mr.hdr.Values("Vary"))132+ assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary"))
133 assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")133 assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
134 assert.NotContains(t, mr.body, "<b>search</b>")134 assert.NotContains(t, mr.body, "<b>search</b>")
135 assert.Contains(t, mr.body, "feat/search")135 assert.Contains(t, mr.body, "feat/search")
M cmd/ermite/web_session_test.go +1 -1
@@ -115,7 +115,7 @@ func TestWebLoginEndToEnd(t *testing.T) {
115115 assert.Contains(t, home.body, "pub")
116116 assert.NotContains(t, home.body, "secret")
117117 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
118- assert.Empty(t, home.hdr.Values("Vary"))
118+ assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page")
119119 assert.NotContains(t, home.body, `action="/-/logout"`)
120120 assert.Equal(t, 404, b.get("/secret", nil).status)
121121
@@ -115,7 +115,7 @@ func TestWebLoginEndToEnd(t *testing.T) {
115 assert.Contains(t, home.body, "pub")115 assert.Contains(t, home.body, "pub")
116 assert.NotContains(t, home.body, "secret")116 assert.NotContains(t, home.body, "secret")
117 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))117 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
118- assert.Empty(t, home.hdr.Values("Vary"))118+ assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page")
119 assert.NotContains(t, home.body, `action="/-/logout"`)119 assert.NotContains(t, home.body, `action="/-/logout"`)
120 assert.Equal(t, 404, b.get("/secret", nil).status)120 assert.Equal(t, 404, b.get("/secret", nil).status)
121 121
M internal/adapters/web/assets.go +29 -3
@@ -34,6 +34,9 @@ type Assets struct {
3434 type asset struct {
3535 body []byte
3636 typ string
37+ // gz is the gzip form of body, compressed once at load; nil when the type
38+ // is already compressed or gzip does not make the body smaller.
39+ gz []byte
3740 }
3841
3942 // assetSet is the embedded files by hashed URL path (under StaticPrefix).
@@ -70,7 +73,13 @@ func loadAssets() (*assetSet, error) {
7073 sum := sha256.Sum256(body)
7174 ext := path.Ext(file)
7275 name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
73- s.byName[name] = asset{body: body, typ: typ}
76+ a := asset{body: body, typ: typ}
77+ if compressible(typ) {
78+ if z, err := gzipBest(body); err == nil && len(z) < len(body) {
79+ a.gz = z
80+ }
81+ }
82+ s.byName[name] = a
7483 return StaticPrefix + name
7584 }
7685
@@ -148,8 +157,25 @@ func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
148157 h := w.Header()
149158 h.Set("Content-Type", a.typ)
150159 h.Set("Cache-Control", "public, max-age=31536000, immutable")
151- h.Set("Content-Length", itoa(len(a.body)))
160+ body := a.body
161+ if a.gz != nil {
162+ addVary(h, "Accept-Encoding")
163+ if acceptsGzip(r.Header) {
164+ body = a.gz
165+ h.Set("Content-Encoding", "gzip")
166+ }
167+ }
168+ h.Set("Content-Length", itoa(len(body)))
152169 if r.Method != http.MethodHead {
153- _, _ = w.Write(a.body)
170+ _, _ = w.Write(body)
154171 }
155172 }
173+
174+// compressible tells whether a content type is worth gzip: text and
175+// structured text. Fonts (woff2) and raster images are compressed already.
176+func compressible(typ string) bool {
177+ mt, _, _ := strings.Cut(typ, ";")
178+ mt = strings.TrimSpace(strings.ToLower(mt))
179+ return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" ||
180+ mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json")
181+}
@@ -34,6 +34,9 @@ type Assets struct {
34 type asset struct {34 type asset struct {
35 body []byte35 body []byte
36 typ string36 typ string
37+ // gz is the gzip form of body, compressed once at load; nil when the type
38+ // is already compressed or gzip does not make the body smaller.
39+ gz []byte
37 }40 }
38 41
39 // assetSet is the embedded files by hashed URL path (under StaticPrefix).42 // assetSet is the embedded files by hashed URL path (under StaticPrefix).
@@ -70,7 +73,13 @@ func loadAssets() (*assetSet, error) {
70 sum := sha256.Sum256(body)73 sum := sha256.Sum256(body)
71 ext := path.Ext(file)74 ext := path.Ext(file)
72 name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext75 name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
73- s.byName[name] = asset{body: body, typ: typ}76+ a := asset{body: body, typ: typ}
77+ if compressible(typ) {
78+ if z, err := gzipBest(body); err == nil && len(z) < len(body) {
79+ a.gz = z
80+ }
81+ }
82+ s.byName[name] = a
74 return StaticPrefix + name83 return StaticPrefix + name
75 }84 }
76 85
@@ -148,8 +157,25 @@ func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
148 h := w.Header()157 h := w.Header()
149 h.Set("Content-Type", a.typ)158 h.Set("Content-Type", a.typ)
150 h.Set("Cache-Control", "public, max-age=31536000, immutable")159 h.Set("Cache-Control", "public, max-age=31536000, immutable")
151- h.Set("Content-Length", itoa(len(a.body)))160+ body := a.body
161+ if a.gz != nil {
162+ addVary(h, "Accept-Encoding")
163+ if acceptsGzip(r.Header) {
164+ body = a.gz
165+ h.Set("Content-Encoding", "gzip")
166+ }
167+ }
168+ h.Set("Content-Length", itoa(len(body)))
152 if r.Method != http.MethodHead {169 if r.Method != http.MethodHead {
153- _, _ = w.Write(a.body)170+ _, _ = w.Write(body)
154 }171 }
155 }172 }
173+
174+// compressible tells whether a content type is worth gzip: text and
175+// structured text. Fonts (woff2) and raster images are compressed already.
176+func compressible(typ string) bool {
177+ mt, _, _ := strings.Cut(typ, ";")
178+ mt = strings.TrimSpace(strings.ToLower(mt))
179+ return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" ||
180+ mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json")
181+}
A internal/adapters/web/compress.go +176 -0
@@ -0,0 +1,176 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "crypto/sha256"
9+ "encoding/hex"
10+ "net/http"
11+ "strconv"
12+ "strings"
13+ "sync"
14+)
15+
16+// minCompress is the size under which a page is sent as it is: the gzip
17+// header and the CPU cost are not worth it for a few hundred bytes.
18+const minCompress = 1024
19+
20+// gzipLevel is the level of the pages, compressed on each request: a good
21+// ratio at a cost small enough for a render that already takes longer. The
22+// assets, compressed once at start, use the best level.
23+const gzipLevel = 5
24+
25+// gzipWriters recycles the writers of the pages: a gzip.Writer holds about
26+// 800 KiB of state.
27+var gzipWriters = sync.Pool{New: func() any {
28+ zw, _ := gzip.NewWriterLevel(nil, gzipLevel) // the level is valid
29+ return zw
30+}}
31+
32+// gzipPage compresses b with a pooled writer.
33+func gzipPage(b []byte) ([]byte, error) {
34+ var out bytes.Buffer
35+ zw := gzipWriters.Get().(*gzip.Writer)
36+ defer gzipWriters.Put(zw)
37+ zw.Reset(&out)
38+ if _, err := zw.Write(b); err != nil {
39+ return nil, err
40+ }
41+ if err := zw.Close(); err != nil {
42+ return nil, err
43+ }
44+ return out.Bytes(), nil
45+}
46+
47+// gzipBest compresses b once, at the best level (for the assets).
48+func gzipBest(b []byte) ([]byte, error) {
49+ var out bytes.Buffer
50+ zw, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
51+ if err != nil {
52+ return nil, err
53+ }
54+ if _, err := zw.Write(b); err != nil {
55+ return nil, err
56+ }
57+ if err := zw.Close(); err != nil {
58+ return nil, err
59+ }
60+ return out.Bytes(), nil
61+}
62+
63+// acceptsGzip reports whether the Accept-Encoding of a request allows gzip
64+// (RFC 9110 §12.5.3): gzip, or its alias x-gzip, with a quality above 0, or
65+// "*" when gzip is not named. A quality of 0 refuses the coding; a malformed
66+// quality is a refusal as well.
67+func acceptsGzip(h http.Header) bool {
68+ star, starSet := false, false
69+ for _, line := range h.Values("Accept-Encoding") {
70+ for part := range strings.SplitSeq(line, ",") {
71+ coding, params, _ := strings.Cut(part, ";")
72+ coding = strings.ToLower(strings.TrimSpace(coding))
73+ if coding != "gzip" && coding != "x-gzip" && coding != "*" {
74+ continue
75+ }
76+ ok := qualityPositive(params)
77+ switch coding {
78+ case "*":
79+ star, starSet = ok, true
80+ default:
81+ return ok
82+ }
83+ }
84+ }
85+ return starSet && star
86+}
87+
88+// qualityPositive reads the q parameter of a coding (1 when absent) and
89+// reports whether it is above 0.
90+func qualityPositive(params string) bool {
91+ for p := range strings.SplitSeq(params, ";") {
92+ k, v, found := strings.Cut(p, "=")
93+ if !found || !strings.EqualFold(strings.TrimSpace(k), "q") {
94+ continue
95+ }
96+ q, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
97+ return err == nil && q > 0
98+ }
99+ return true
100+}
101+
102+// addVary adds a field to Vary unless it is already there (or Vary is *).
103+func addVary(h http.Header, field string) {
104+ for _, v := range h.Values("Vary") {
105+ for f := range strings.SplitSeq(v, ",") {
106+ if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, field) {
107+ return
108+ }
109+ }
110+ }
111+ h.Add("Vary", field)
112+}
113+
114+// noTransform tells whether Cache-Control forbids changing the body.
115+func noTransform(h http.Header) bool {
116+ return hasCacheDirective(h, "no-transform")
117+}
118+
119+// hasCacheDirective tells whether Cache-Control holds the directive.
120+func hasCacheDirective(h http.Header, name string) bool {
121+ for _, v := range h.Values("Cache-Control") {
122+ for d := range strings.SplitSeq(v, ",") {
123+ if strings.EqualFold(strings.TrimSpace(d), name) {
124+ return true
125+ }
126+ }
127+ }
128+ return false
129+}
130+
131+// pageETag is the strong validator of a rendered page: a hash of its
132+// identity bytes and of the session state, so that the page of the owner
133+// never shares a tag with the page of a visitor. The gzip representation
134+// has a tag of its own (RFC 9110 §8.8.3).
135+func pageETag(body []byte, signedIn, gzipped bool) string {
136+ h := sha256.New()
137+ if signedIn {
138+ h.Write([]byte{1})
139+ } else {
140+ h.Write([]byte{0})
141+ }
142+ h.Write(body)
143+ tag := hex.EncodeToString(h.Sum(nil)[:16])
144+ if gzipped {
145+ tag += "-gz"
146+ }
147+ return `"` + tag + `"`
148+}
149+
150+// etagMatches implements the weak comparison of If-None-Match (RFC 9110
151+// §13.1.2): a list of entity tags, each optionally weak, or "*".
152+func etagMatches(header, etag string) bool {
153+ want := strings.TrimPrefix(etag, "W/")
154+ s := header
155+ for {
156+ s = strings.TrimLeft(s, " \t,")
157+ if s == "" {
158+ return false
159+ }
160+ if s[0] == '*' {
161+ return true
162+ }
163+ s = strings.TrimPrefix(s, "W/")
164+ if s == "" || s[0] != '"' {
165+ return false // malformed: nothing after this can be trusted
166+ }
167+ end := strings.IndexByte(s[1:], '"')
168+ if end < 0 {
169+ return false
170+ }
171+ if s[:end+2] == want {
172+ return true
173+ }
174+ s = s[end+2:]
175+ }
176+}
@@ -0,0 +1,176 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "crypto/sha256"
9+ "encoding/hex"
10+ "net/http"
11+ "strconv"
12+ "strings"
13+ "sync"
14+)
15+
16+// minCompress is the size under which a page is sent as it is: the gzip
17+// header and the CPU cost are not worth it for a few hundred bytes.
18+const minCompress = 1024
19+
20+// gzipLevel is the level of the pages, compressed on each request: a good
21+// ratio at a cost small enough for a render that already takes longer. The
22+// assets, compressed once at start, use the best level.
23+const gzipLevel = 5
24+
25+// gzipWriters recycles the writers of the pages: a gzip.Writer holds about
26+// 800 KiB of state.
27+var gzipWriters = sync.Pool{New: func() any {
28+ zw, _ := gzip.NewWriterLevel(nil, gzipLevel) // the level is valid
29+ return zw
30+}}
31+
32+// gzipPage compresses b with a pooled writer.
33+func gzipPage(b []byte) ([]byte, error) {
34+ var out bytes.Buffer
35+ zw := gzipWriters.Get().(*gzip.Writer)
36+ defer gzipWriters.Put(zw)
37+ zw.Reset(&out)
38+ if _, err := zw.Write(b); err != nil {
39+ return nil, err
40+ }
41+ if err := zw.Close(); err != nil {
42+ return nil, err
43+ }
44+ return out.Bytes(), nil
45+}
46+
47+// gzipBest compresses b once, at the best level (for the assets).
48+func gzipBest(b []byte) ([]byte, error) {
49+ var out bytes.Buffer
50+ zw, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
51+ if err != nil {
52+ return nil, err
53+ }
54+ if _, err := zw.Write(b); err != nil {
55+ return nil, err
56+ }
57+ if err := zw.Close(); err != nil {
58+ return nil, err
59+ }
60+ return out.Bytes(), nil
61+}
62+
63+// acceptsGzip reports whether the Accept-Encoding of a request allows gzip
64+// (RFC 9110 §12.5.3): gzip, or its alias x-gzip, with a quality above 0, or
65+// "*" when gzip is not named. A quality of 0 refuses the coding; a malformed
66+// quality is a refusal as well.
67+func acceptsGzip(h http.Header) bool {
68+ star, starSet := false, false
69+ for _, line := range h.Values("Accept-Encoding") {
70+ for part := range strings.SplitSeq(line, ",") {
71+ coding, params, _ := strings.Cut(part, ";")
72+ coding = strings.ToLower(strings.TrimSpace(coding))
73+ if coding != "gzip" && coding != "x-gzip" && coding != "*" {
74+ continue
75+ }
76+ ok := qualityPositive(params)
77+ switch coding {
78+ case "*":
79+ star, starSet = ok, true
80+ default:
81+ return ok
82+ }
83+ }
84+ }
85+ return starSet && star
86+}
87+
88+// qualityPositive reads the q parameter of a coding (1 when absent) and
89+// reports whether it is above 0.
90+func qualityPositive(params string) bool {
91+ for p := range strings.SplitSeq(params, ";") {
92+ k, v, found := strings.Cut(p, "=")
93+ if !found || !strings.EqualFold(strings.TrimSpace(k), "q") {
94+ continue
95+ }
96+ q, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
97+ return err == nil && q > 0
98+ }
99+ return true
100+}
101+
102+// addVary adds a field to Vary unless it is already there (or Vary is *).
103+func addVary(h http.Header, field string) {
104+ for _, v := range h.Values("Vary") {
105+ for f := range strings.SplitSeq(v, ",") {
106+ if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, field) {
107+ return
108+ }
109+ }
110+ }
111+ h.Add("Vary", field)
112+}
113+
114+// noTransform tells whether Cache-Control forbids changing the body.
115+func noTransform(h http.Header) bool {
116+ return hasCacheDirective(h, "no-transform")
117+}
118+
119+// hasCacheDirective tells whether Cache-Control holds the directive.
120+func hasCacheDirective(h http.Header, name string) bool {
121+ for _, v := range h.Values("Cache-Control") {
122+ for d := range strings.SplitSeq(v, ",") {
123+ if strings.EqualFold(strings.TrimSpace(d), name) {
124+ return true
125+ }
126+ }
127+ }
128+ return false
129+}
130+
131+// pageETag is the strong validator of a rendered page: a hash of its
132+// identity bytes and of the session state, so that the page of the owner
133+// never shares a tag with the page of a visitor. The gzip representation
134+// has a tag of its own (RFC 9110 §8.8.3).
135+func pageETag(body []byte, signedIn, gzipped bool) string {
136+ h := sha256.New()
137+ if signedIn {
138+ h.Write([]byte{1})
139+ } else {
140+ h.Write([]byte{0})
141+ }
142+ h.Write(body)
143+ tag := hex.EncodeToString(h.Sum(nil)[:16])
144+ if gzipped {
145+ tag += "-gz"
146+ }
147+ return `"` + tag + `"`
148+}
149+
150+// etagMatches implements the weak comparison of If-None-Match (RFC 9110
151+// §13.1.2): a list of entity tags, each optionally weak, or "*".
152+func etagMatches(header, etag string) bool {
153+ want := strings.TrimPrefix(etag, "W/")
154+ s := header
155+ for {
156+ s = strings.TrimLeft(s, " \t,")
157+ if s == "" {
158+ return false
159+ }
160+ if s[0] == '*' {
161+ return true
162+ }
163+ s = strings.TrimPrefix(s, "W/")
164+ if s == "" || s[0] != '"' {
165+ return false // malformed: nothing after this can be trusted
166+ }
167+ end := strings.IndexByte(s[1:], '"')
168+ if end < 0 {
169+ return false
170+ }
171+ if s[:end+2] == want {
172+ return true
173+ }
174+ s = s[end+2:]
175+ }
176+}
A internal/adapters/web/compress_test.go +316 -0
@@ -0,0 +1,316 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "context"
9+ "errors"
10+ "io"
11+ "net/http"
12+ "net/http/httptest"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "github.com/a-h/templ"
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+)
23+
24+func TestAcceptsGzip(t *testing.T) {
25+ for _, tc := range []struct {
26+ name string
27+ header []string
28+ want bool
29+ }{
30+ {"absent", nil, false},
31+ {"gzip", []string{"gzip"}, true},
32+ {"list", []string{"deflate, gzip, br"}, true},
33+ {"case", []string{"GZip"}, true},
34+ {"x-gzip alias", []string{"x-gzip"}, true},
35+ {"q=1", []string{"gzip;q=1.0"}, true},
36+ {"q spaces", []string{"gzip ; q=0.5"}, true},
37+ {"q=0 refuses", []string{"gzip;q=0"}, false},
38+ {"q=0.0 refuses", []string{"br, gzip;q=0.0"}, false},
39+ {"bad q refuses", []string{"gzip;q=abc"}, false},
40+ {"identity only", []string{"identity"}, false},
41+ {"br only", []string{"br"}, false},
42+ {"star", []string{"*"}, true},
43+ {"star q=0", []string{"*;q=0"}, false},
44+ {"gzip refused beats star", []string{"gzip;q=0, *"}, false},
45+ {"gzip accepted beats star q=0", []string{"*;q=0, gzip"}, true},
46+ {"several lines", []string{"br", "gzip"}, true},
47+ {"substring is not a coding", []string{"gzipped"}, false},
48+ } {
49+ t.Run(tc.name, func(t *testing.T) {
50+ h := http.Header{}
51+ for _, v := range tc.header {
52+ h.Add("Accept-Encoding", v)
53+ }
54+ assert.Equal(t, tc.want, acceptsGzip(h))
55+ })
56+ }
57+}
58+
59+func TestEtagMatches(t *testing.T) {
60+ const tag = `"abc"`
61+ for _, tc := range []struct {
62+ name, header, etag string
63+ want bool
64+ }{
65+ {"empty", "", tag, false},
66+ {"same", `"abc"`, tag, true},
67+ {"other", `"abd"`, tag, false},
68+ {"weak client tag", `W/"abc"`, tag, true},
69+ {"list", `"x", "abc", "y"`, tag, true},
70+ {"list no space", `"x","abc"`, tag, true},
71+ {"list without it", `"x", W/"y"`, tag, false},
72+ {"star", `*`, tag, true},
73+ {"unquoted", `abc`, tag, false},
74+ {"unterminated", `"abc`, tag, false},
75+ {"prefix is not equal", `"ab"`, tag, false},
76+ {"weak server tag", `"abc"`, `W/"abc"`, true},
77+ {"comma in a tag", `"a,b", "abc"`, tag, true},
78+ } {
79+ t.Run(tc.name, func(t *testing.T) {
80+ assert.Equal(t, tc.want, etagMatches(tc.header, tc.etag))
81+ })
82+ }
83+}
84+
85+func TestPageETag(t *testing.T) {
86+ body := []byte("<html>x</html>")
87+ a := pageETag(body, false, false)
88+ assert.Equal(t, a, pageETag(body, false, false), "stable")
89+ assert.NotEqual(t, a, pageETag(body, true, false), "owner and visitor do not share a tag")
90+ assert.NotEqual(t, a, pageETag(body, false, true), "gzip is another representation")
91+ assert.NotEqual(t, a, pageETag([]byte("<html>y</html>"), false, false))
92+ assert.True(t, strings.HasPrefix(a, `"`) && strings.HasSuffix(a, `"`))
93+}
94+
95+func newBareServer(t *testing.T) *Server {
96+ t.Helper()
97+ srv, err := New(Config{
98+ Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
99+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
100+ })
101+ require.NoError(t, err)
102+ return srv
103+}
104+
105+func gunzip(t *testing.T, b []byte) string {
106+ t.Helper()
107+ zr, err := gzip.NewReader(bytes.NewReader(b))
108+ require.NoError(t, err)
109+ out, err := io.ReadAll(zr)
110+ require.NoError(t, err)
111+ return string(out)
112+}
113+
114+// write: gzip by size and Accept-Encoding, validators, 304.
115+func TestWritePage(t *testing.T) {
116+ big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>"
117+ small := "<p>tiny</p>"
118+
119+ for _, tc := range []struct {
120+ name string
121+ method string
122+ ae string
123+ body string
124+ status int
125+ cc string // Cache-Control set by the handler beforehand
126+ wantGzip bool
127+ wantTag bool
128+ }{
129+ {"big page, gzip", "GET", "gzip", big, 200, "", true, true},
130+ {"big page, no Accept-Encoding", "GET", "", big, 200, "", false, true},
131+ {"big page, gzip refused", "GET", "gzip;q=0", big, 200, "", false, true},
132+ {"big page, br only", "GET", "br", big, 200, "", false, true},
133+ {"small page not compressed", "GET", "gzip", small, 200, "", false, true},
134+ {"HEAD keeps headers, no body", "HEAD", "gzip", big, 200, "", true, true},
135+ {"404 page compressed, no tag", "GET", "gzip", big, 404, "", true, false},
136+ {"no-store handler: no tag", "GET", "gzip", big, 200, "no-store", true, false},
137+ {"no-transform: untouched", "GET", "gzip", big, 200, "no-transform", false, true},
138+ } {
139+ t.Run(tc.name, func(t *testing.T) {
140+ srv := newBareServer(t)
141+ rec := httptest.NewRecorder()
142+ r := httptest.NewRequestWithContext(context.Background(), tc.method, "/", nil)
143+ if tc.ae != "" {
144+ r.Header.Set("Accept-Encoding", tc.ae)
145+ }
146+ if tc.cc != "" {
147+ rec.Header().Set("Cache-Control", tc.cc)
148+ }
149+ srv.write(r.Context(), rec, r, tc.status, templ.Raw(tc.body))
150+
151+ res := rec.Result()
152+ t.Cleanup(func() { _ = res.Body.Close() })
153+ raw, _ := io.ReadAll(res.Body)
154+ assert.Equal(t, tc.status, res.StatusCode)
155+ assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding")
156+ assert.Equal(t, tc.wantTag, res.Header.Get("ETag") != "")
157+ switch {
158+ case tc.method == "HEAD":
159+ assert.Empty(t, raw)
160+ assert.Equal(t, tc.wantGzip, res.Header.Get("Content-Encoding") == "gzip")
161+ case tc.wantGzip:
162+ assert.Equal(t, "gzip", res.Header.Get("Content-Encoding"))
163+ assert.Equal(t, itoa(len(raw)), res.Header.Get("Content-Length"))
164+ assert.Less(t, len(raw), len(tc.body))
165+ assert.Equal(t, tc.body, gunzip(t, raw))
166+ default:
167+ assert.Empty(t, res.Header.Get("Content-Encoding"))
168+ assert.Equal(t, tc.body, string(raw))
169+ }
170+ })
171+ }
172+}
173+
174+func TestWritePageConditional(t *testing.T) {
175+ big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>"
176+ srv := newBareServer(t)
177+ do := func(ae, inm string) *http.Response {
178+ rec := httptest.NewRecorder()
179+ r := httptest.NewRequestWithContext(context.Background(), "GET", "/", nil)
180+ if ae != "" {
181+ r.Header.Set("Accept-Encoding", ae)
182+ }
183+ if inm != "" {
184+ r.Header.Set("If-None-Match", inm)
185+ }
186+ srv.write(r.Context(), rec, r, 200, templ.Raw(big))
187+ res := rec.Result()
188+ t.Cleanup(func() { _ = res.Body.Close() })
189+ return res
190+ }
191+ plain, gz := do("", "").Header.Get("ETag"), do("gzip", "").Header.Get("ETag")
192+ require.NotEmpty(t, plain)
193+ require.NotEmpty(t, gz)
194+ assert.NotEqual(t, plain, gz, "identity and gzip are different representations")
195+
196+ for _, tc := range []struct {
197+ name, ae, inm string
198+ want int
199+ }{
200+ {"identity match", "", plain, 304},
201+ {"gzip match", "gzip", gz, 304},
202+ {"list", "gzip", `"nope", ` + gz, 304},
203+ {"weak prefix", "", "W/" + plain, 304},
204+ {"star", "", "*", 304},
205+ {"identity tag for gzip request", "gzip", plain, 200},
206+ {"gzip tag for identity request", "", gz, 200},
207+ {"stale", "", `"stale"`, 200},
208+ } {
209+ t.Run(tc.name, func(t *testing.T) {
210+ res := do(tc.ae, tc.inm)
211+ assert.Equal(t, tc.want, res.StatusCode)
212+ b, _ := io.ReadAll(res.Body)
213+ if tc.want == 304 {
214+ assert.Empty(t, b)
215+ assert.NotEmpty(t, res.Header.Get("ETag"))
216+ assert.Equal(t, "no-cache", res.Header.Get("Cache-Control"))
217+ assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding")
218+ assert.Empty(t, res.Header.Get("Content-Encoding"))
219+ } else {
220+ assert.NotEmpty(t, b)
221+ }
222+ })
223+ }
224+}
225+
226+// The owner's page is private and never revalidated, whatever the client sends.
227+func TestWritePageOwnerHasNoValidator(t *testing.T) {
228+ srv := newBareServer(t)
229+ ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
230+ rec := httptest.NewRecorder()
231+ r := httptest.NewRequestWithContext(ctx, "GET", "/", nil)
232+ r.Header.Set("If-None-Match", "*")
233+ srv.write(ctx, rec, r, 200, templ.Raw("<p>mine</p>"))
234+ assert.Equal(t, 200, rec.Code)
235+ assert.Equal(t, sessionCache, rec.Header().Get("Cache-Control"))
236+ assert.Empty(t, rec.Header().Get("ETag"))
237+ assert.Contains(t, rec.Header().Values("Vary"), "Cookie")
238+ assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding")
239+}
240+
241+// The assets: gzip once at load, chosen by Accept-Encoding, not for fonts.
242+func TestAssetsGzip(t *testing.T) {
243+ srv := newBareServer(t)
244+ get := func(url, ae string) *httptest.ResponseRecorder {
245+ rec := httptest.NewRecorder()
246+ r := httptest.NewRequestWithContext(context.Background(), "GET", url, nil)
247+ if ae != "" {
248+ r.Header.Set("Accept-Encoding", ae)
249+ }
250+ srv.ServeHTTP(rec, r)
251+ return rec
252+ }
253+ u := srv.assets.urls
254+ for _, tc := range []struct {
255+ name, url, ae string
256+ wantGzip bool
257+ vary bool
258+ }{
259+ {"css gzip", u.CSS, "gzip, br", true, true},
260+ {"css identity", u.CSS, "", false, true},
261+ {"css refused", u.CSS, "gzip;q=0", false, true},
262+ {"js gzip", u.HTMX, "gzip", true, true},
263+ {"rules gzip", u.Rules, "gzip", true, true},
264+ {"svg gzip", u.Icon, "gzip", true, true},
265+ {"font never", u.Font, "gzip", false, false},
266+ } {
267+ t.Run(tc.name, func(t *testing.T) {
268+ rec := get(tc.url, tc.ae)
269+ require.Equal(t, 200, rec.Code)
270+ assert.Equal(t, "public, max-age=31536000, immutable", rec.Header().Get("Cache-Control"))
271+ assert.Equal(t, tc.vary, strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "Accept-Encoding"))
272+ assert.Equal(t, itoa(rec.Body.Len()), rec.Header().Get("Content-Length"))
273+ plain := srv.assets.byName[strings.TrimPrefix(tc.url, StaticPrefix)].body
274+ if tc.wantGzip {
275+ assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
276+ assert.Less(t, rec.Body.Len(), len(plain))
277+ assert.Equal(t, string(plain), gunzip(t, rec.Body.Bytes()))
278+ } else {
279+ assert.Empty(t, rec.Header().Get("Content-Encoding"))
280+ assert.Equal(t, string(plain), rec.Body.String())
281+ }
282+ })
283+ }
284+
285+ t.Run("HEAD has the length of the encoded body", func(t *testing.T) {
286+ rec := httptest.NewRecorder()
287+ r := httptest.NewRequestWithContext(context.Background(), "HEAD", u.CSS, nil)
288+ r.Header.Set("Accept-Encoding", "gzip")
289+ srv.ServeHTTP(rec, r)
290+ assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
291+ assert.Equal(t, itoa(len(srv.assets.byName[strings.TrimPrefix(u.CSS, StaticPrefix)].gz)), rec.Header().Get("Content-Length"))
292+ assert.Zero(t, rec.Body.Len())
293+ })
294+
295+ t.Run("compressible types", func(t *testing.T) {
296+ for typ, want := range map[string]bool{
297+ "text/css; charset=utf-8": true, "text/javascript; charset=utf-8": true,
298+ "application/speculationrules+json": true, "image/svg+xml": true,
299+ "font/woff2": false, "image/png": false,
300+ } {
301+ assert.Equal(t, want, compressible(typ), typ)
302+ }
303+ })
304+}
305+
306+// The event stream is never compressed, even for a client that accepts gzip.
307+func TestEventsNotCompressed(t *testing.T) {
308+ f := &fakeEvents{release: make(chan struct{})}
309+ ts := eventsServer(t, f, 20*time.Millisecond, 0)
310+ resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"})
311+ require.Equal(t, http.StatusOK, resp.StatusCode)
312+ assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
313+ assert.Empty(t, resp.Header.Get("Content-Encoding"))
314+ assert.Empty(t, resp.Header.Get("ETag"))
315+ assert.Contains(t, resp.Header.Get("Cache-Control"), "no-transform")
316+}
@@ -0,0 +1,316 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "context"
9+ "errors"
10+ "io"
11+ "net/http"
12+ "net/http/httptest"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "github.com/a-h/templ"
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+)
23+
24+func TestAcceptsGzip(t *testing.T) {
25+ for _, tc := range []struct {
26+ name string
27+ header []string
28+ want bool
29+ }{
30+ {"absent", nil, false},
31+ {"gzip", []string{"gzip"}, true},
32+ {"list", []string{"deflate, gzip, br"}, true},
33+ {"case", []string{"GZip"}, true},
34+ {"x-gzip alias", []string{"x-gzip"}, true},
35+ {"q=1", []string{"gzip;q=1.0"}, true},
36+ {"q spaces", []string{"gzip ; q=0.5"}, true},
37+ {"q=0 refuses", []string{"gzip;q=0"}, false},
38+ {"q=0.0 refuses", []string{"br, gzip;q=0.0"}, false},
39+ {"bad q refuses", []string{"gzip;q=abc"}, false},
40+ {"identity only", []string{"identity"}, false},
41+ {"br only", []string{"br"}, false},
42+ {"star", []string{"*"}, true},
43+ {"star q=0", []string{"*;q=0"}, false},
44+ {"gzip refused beats star", []string{"gzip;q=0, *"}, false},
45+ {"gzip accepted beats star q=0", []string{"*;q=0, gzip"}, true},
46+ {"several lines", []string{"br", "gzip"}, true},
47+ {"substring is not a coding", []string{"gzipped"}, false},
48+ } {
49+ t.Run(tc.name, func(t *testing.T) {
50+ h := http.Header{}
51+ for _, v := range tc.header {
52+ h.Add("Accept-Encoding", v)
53+ }
54+ assert.Equal(t, tc.want, acceptsGzip(h))
55+ })
56+ }
57+}
58+
59+func TestEtagMatches(t *testing.T) {
60+ const tag = `"abc"`
61+ for _, tc := range []struct {
62+ name, header, etag string
63+ want bool
64+ }{
65+ {"empty", "", tag, false},
66+ {"same", `"abc"`, tag, true},
67+ {"other", `"abd"`, tag, false},
68+ {"weak client tag", `W/"abc"`, tag, true},
69+ {"list", `"x", "abc", "y"`, tag, true},
70+ {"list no space", `"x","abc"`, tag, true},
71+ {"list without it", `"x", W/"y"`, tag, false},
72+ {"star", `*`, tag, true},
73+ {"unquoted", `abc`, tag, false},
74+ {"unterminated", `"abc`, tag, false},
75+ {"prefix is not equal", `"ab"`, tag, false},
76+ {"weak server tag", `"abc"`, `W/"abc"`, true},
77+ {"comma in a tag", `"a,b", "abc"`, tag, true},
78+ } {
79+ t.Run(tc.name, func(t *testing.T) {
80+ assert.Equal(t, tc.want, etagMatches(tc.header, tc.etag))
81+ })
82+ }
83+}
84+
85+func TestPageETag(t *testing.T) {
86+ body := []byte("<html>x</html>")
87+ a := pageETag(body, false, false)
88+ assert.Equal(t, a, pageETag(body, false, false), "stable")
89+ assert.NotEqual(t, a, pageETag(body, true, false), "owner and visitor do not share a tag")
90+ assert.NotEqual(t, a, pageETag(body, false, true), "gzip is another representation")
91+ assert.NotEqual(t, a, pageETag([]byte("<html>y</html>"), false, false))
92+ assert.True(t, strings.HasPrefix(a, `"`) && strings.HasSuffix(a, `"`))
93+}
94+
95+func newBareServer(t *testing.T) *Server {
96+ t.Helper()
97+ srv, err := New(Config{
98+ Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
99+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
100+ })
101+ require.NoError(t, err)
102+ return srv
103+}
104+
105+func gunzip(t *testing.T, b []byte) string {
106+ t.Helper()
107+ zr, err := gzip.NewReader(bytes.NewReader(b))
108+ require.NoError(t, err)
109+ out, err := io.ReadAll(zr)
110+ require.NoError(t, err)
111+ return string(out)
112+}
113+
114+// write: gzip by size and Accept-Encoding, validators, 304.
115+func TestWritePage(t *testing.T) {
116+ big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>"
117+ small := "<p>tiny</p>"
118+
119+ for _, tc := range []struct {
120+ name string
121+ method string
122+ ae string
123+ body string
124+ status int
125+ cc string // Cache-Control set by the handler beforehand
126+ wantGzip bool
127+ wantTag bool
128+ }{
129+ {"big page, gzip", "GET", "gzip", big, 200, "", true, true},
130+ {"big page, no Accept-Encoding", "GET", "", big, 200, "", false, true},
131+ {"big page, gzip refused", "GET", "gzip;q=0", big, 200, "", false, true},
132+ {"big page, br only", "GET", "br", big, 200, "", false, true},
133+ {"small page not compressed", "GET", "gzip", small, 200, "", false, true},
134+ {"HEAD keeps headers, no body", "HEAD", "gzip", big, 200, "", true, true},
135+ {"404 page compressed, no tag", "GET", "gzip", big, 404, "", true, false},
136+ {"no-store handler: no tag", "GET", "gzip", big, 200, "no-store", true, false},
137+ {"no-transform: untouched", "GET", "gzip", big, 200, "no-transform", false, true},
138+ } {
139+ t.Run(tc.name, func(t *testing.T) {
140+ srv := newBareServer(t)
141+ rec := httptest.NewRecorder()
142+ r := httptest.NewRequestWithContext(context.Background(), tc.method, "/", nil)
143+ if tc.ae != "" {
144+ r.Header.Set("Accept-Encoding", tc.ae)
145+ }
146+ if tc.cc != "" {
147+ rec.Header().Set("Cache-Control", tc.cc)
148+ }
149+ srv.write(r.Context(), rec, r, tc.status, templ.Raw(tc.body))
150+
151+ res := rec.Result()
152+ t.Cleanup(func() { _ = res.Body.Close() })
153+ raw, _ := io.ReadAll(res.Body)
154+ assert.Equal(t, tc.status, res.StatusCode)
155+ assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding")
156+ assert.Equal(t, tc.wantTag, res.Header.Get("ETag") != "")
157+ switch {
158+ case tc.method == "HEAD":
159+ assert.Empty(t, raw)
160+ assert.Equal(t, tc.wantGzip, res.Header.Get("Content-Encoding") == "gzip")
161+ case tc.wantGzip:
162+ assert.Equal(t, "gzip", res.Header.Get("Content-Encoding"))
163+ assert.Equal(t, itoa(len(raw)), res.Header.Get("Content-Length"))
164+ assert.Less(t, len(raw), len(tc.body))
165+ assert.Equal(t, tc.body, gunzip(t, raw))
166+ default:
167+ assert.Empty(t, res.Header.Get("Content-Encoding"))
168+ assert.Equal(t, tc.body, string(raw))
169+ }
170+ })
171+ }
172+}
173+
174+func TestWritePageConditional(t *testing.T) {
175+ big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>"
176+ srv := newBareServer(t)
177+ do := func(ae, inm string) *http.Response {
178+ rec := httptest.NewRecorder()
179+ r := httptest.NewRequestWithContext(context.Background(), "GET", "/", nil)
180+ if ae != "" {
181+ r.Header.Set("Accept-Encoding", ae)
182+ }
183+ if inm != "" {
184+ r.Header.Set("If-None-Match", inm)
185+ }
186+ srv.write(r.Context(), rec, r, 200, templ.Raw(big))
187+ res := rec.Result()
188+ t.Cleanup(func() { _ = res.Body.Close() })
189+ return res
190+ }
191+ plain, gz := do("", "").Header.Get("ETag"), do("gzip", "").Header.Get("ETag")
192+ require.NotEmpty(t, plain)
193+ require.NotEmpty(t, gz)
194+ assert.NotEqual(t, plain, gz, "identity and gzip are different representations")
195+
196+ for _, tc := range []struct {
197+ name, ae, inm string
198+ want int
199+ }{
200+ {"identity match", "", plain, 304},
201+ {"gzip match", "gzip", gz, 304},
202+ {"list", "gzip", `"nope", ` + gz, 304},
203+ {"weak prefix", "", "W/" + plain, 304},
204+ {"star", "", "*", 304},
205+ {"identity tag for gzip request", "gzip", plain, 200},
206+ {"gzip tag for identity request", "", gz, 200},
207+ {"stale", "", `"stale"`, 200},
208+ } {
209+ t.Run(tc.name, func(t *testing.T) {
210+ res := do(tc.ae, tc.inm)
211+ assert.Equal(t, tc.want, res.StatusCode)
212+ b, _ := io.ReadAll(res.Body)
213+ if tc.want == 304 {
214+ assert.Empty(t, b)
215+ assert.NotEmpty(t, res.Header.Get("ETag"))
216+ assert.Equal(t, "no-cache", res.Header.Get("Cache-Control"))
217+ assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding")
218+ assert.Empty(t, res.Header.Get("Content-Encoding"))
219+ } else {
220+ assert.NotEmpty(t, b)
221+ }
222+ })
223+ }
224+}
225+
226+// The owner's page is private and never revalidated, whatever the client sends.
227+func TestWritePageOwnerHasNoValidator(t *testing.T) {
228+ srv := newBareServer(t)
229+ ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
230+ rec := httptest.NewRecorder()
231+ r := httptest.NewRequestWithContext(ctx, "GET", "/", nil)
232+ r.Header.Set("If-None-Match", "*")
233+ srv.write(ctx, rec, r, 200, templ.Raw("<p>mine</p>"))
234+ assert.Equal(t, 200, rec.Code)
235+ assert.Equal(t, sessionCache, rec.Header().Get("Cache-Control"))
236+ assert.Empty(t, rec.Header().Get("ETag"))
237+ assert.Contains(t, rec.Header().Values("Vary"), "Cookie")
238+ assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding")
239+}
240+
241+// The assets: gzip once at load, chosen by Accept-Encoding, not for fonts.
242+func TestAssetsGzip(t *testing.T) {
243+ srv := newBareServer(t)
244+ get := func(url, ae string) *httptest.ResponseRecorder {
245+ rec := httptest.NewRecorder()
246+ r := httptest.NewRequestWithContext(context.Background(), "GET", url, nil)
247+ if ae != "" {
248+ r.Header.Set("Accept-Encoding", ae)
249+ }
250+ srv.ServeHTTP(rec, r)
251+ return rec
252+ }
253+ u := srv.assets.urls
254+ for _, tc := range []struct {
255+ name, url, ae string
256+ wantGzip bool
257+ vary bool
258+ }{
259+ {"css gzip", u.CSS, "gzip, br", true, true},
260+ {"css identity", u.CSS, "", false, true},
261+ {"css refused", u.CSS, "gzip;q=0", false, true},
262+ {"js gzip", u.HTMX, "gzip", true, true},
263+ {"rules gzip", u.Rules, "gzip", true, true},
264+ {"svg gzip", u.Icon, "gzip", true, true},
265+ {"font never", u.Font, "gzip", false, false},
266+ } {
267+ t.Run(tc.name, func(t *testing.T) {
268+ rec := get(tc.url, tc.ae)
269+ require.Equal(t, 200, rec.Code)
270+ assert.Equal(t, "public, max-age=31536000, immutable", rec.Header().Get("Cache-Control"))
271+ assert.Equal(t, tc.vary, strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "Accept-Encoding"))
272+ assert.Equal(t, itoa(rec.Body.Len()), rec.Header().Get("Content-Length"))
273+ plain := srv.assets.byName[strings.TrimPrefix(tc.url, StaticPrefix)].body
274+ if tc.wantGzip {
275+ assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
276+ assert.Less(t, rec.Body.Len(), len(plain))
277+ assert.Equal(t, string(plain), gunzip(t, rec.Body.Bytes()))
278+ } else {
279+ assert.Empty(t, rec.Header().Get("Content-Encoding"))
280+ assert.Equal(t, string(plain), rec.Body.String())
281+ }
282+ })
283+ }
284+
285+ t.Run("HEAD has the length of the encoded body", func(t *testing.T) {
286+ rec := httptest.NewRecorder()
287+ r := httptest.NewRequestWithContext(context.Background(), "HEAD", u.CSS, nil)
288+ r.Header.Set("Accept-Encoding", "gzip")
289+ srv.ServeHTTP(rec, r)
290+ assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
291+ assert.Equal(t, itoa(len(srv.assets.byName[strings.TrimPrefix(u.CSS, StaticPrefix)].gz)), rec.Header().Get("Content-Length"))
292+ assert.Zero(t, rec.Body.Len())
293+ })
294+
295+ t.Run("compressible types", func(t *testing.T) {
296+ for typ, want := range map[string]bool{
297+ "text/css; charset=utf-8": true, "text/javascript; charset=utf-8": true,
298+ "application/speculationrules+json": true, "image/svg+xml": true,
299+ "font/woff2": false, "image/png": false,
300+ } {
301+ assert.Equal(t, want, compressible(typ), typ)
302+ }
303+ })
304+}
305+
306+// The event stream is never compressed, even for a client that accepts gzip.
307+func TestEventsNotCompressed(t *testing.T) {
308+ f := &fakeEvents{release: make(chan struct{})}
309+ ts := eventsServer(t, f, 20*time.Millisecond, 0)
310+ resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"})
311+ require.Equal(t, http.StatusOK, resp.StatusCode)
312+ assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
313+ assert.Empty(t, resp.Header.Get("Content-Encoding"))
314+ assert.Empty(t, resp.Header.Get("ETag"))
315+ assert.Contains(t, resp.Header.Get("Cache-Control"), "no-transform")
316+}
M internal/adapters/web/session_test.go +1 -1
@@ -226,7 +226,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
226226 rec := httptest.NewRecorder()
227227 srv.ServeHTTP(rec, req)
228228 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target)
229- assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"), target)
229+ assert.Equal(t, []string{"Cookie", "Accept-Encoding"}, rec.Header().Values("Vary"), target)
230230 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target)
231231 }
232232
@@ -226,7 +226,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
226 rec := httptest.NewRecorder()226 rec := httptest.NewRecorder()
227 srv.ServeHTTP(rec, req)227 srv.ServeHTTP(rec, req)
228 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target)228 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target)
229- assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"), target)229+ assert.Equal(t, []string{"Cookie", "Accept-Encoding"}, rec.Header().Values("Vary"), target)
230 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target)230 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target)
231 }231 }
232 232
M internal/adapters/web/web.go +27 -1
@@ -205,9 +205,35 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque
205205 if h.Get("Cache-Control") == "" {
206206 cacheHeaders(ctx, h, "no-cache", sessionCache)
207207 }
208+ body := buf.Bytes()
209+ // Whether the body is sent gzipped follows from its size and the request
210+ // alone, so that the tag is known before the compression is spent.
211+ gz := len(body) >= minCompress && acceptsGzip(r.Header) &&
212+ !noTransform(h) && h.Get("Content-Encoding") == ""
213+ addVary(h, "Accept-Encoding")
214+ readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead
215+ // A page that may be stored gets a validator: the browser then
216+ // revalidates with If-None-Match and a page that did not change costs a
217+ // 304. A no-store page (the owner's) is never revalidated.
218+ if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) {
219+ tag := pageETag(body, SignedIn(ctx), gz)
220+ h.Set("ETag", tag)
221+ if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) {
222+ h.Del("Content-Type")
223+ w.WriteHeader(http.StatusNotModified)
224+ return
225+ }
226+ }
227+ if gz {
228+ if z, err := gzipPage(body); err == nil && len(z) < len(body) {
229+ body = z
230+ h.Set("Content-Encoding", "gzip")
231+ }
232+ }
233+ h.Set("Content-Length", itoa(len(body)))
208234 w.WriteHeader(status)
209235 if r.Method != http.MethodHead {
210- _, _ = w.Write(buf.Bytes())
236+ _, _ = w.Write(body)
211237 }
212238 }
213239
@@ -205,9 +205,35 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque
205 if h.Get("Cache-Control") == "" {205 if h.Get("Cache-Control") == "" {
206 cacheHeaders(ctx, h, "no-cache", sessionCache)206 cacheHeaders(ctx, h, "no-cache", sessionCache)
207 }207 }
208+ body := buf.Bytes()
209+ // Whether the body is sent gzipped follows from its size and the request
210+ // alone, so that the tag is known before the compression is spent.
211+ gz := len(body) >= minCompress && acceptsGzip(r.Header) &&
212+ !noTransform(h) && h.Get("Content-Encoding") == ""
213+ addVary(h, "Accept-Encoding")
214+ readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead
215+ // A page that may be stored gets a validator: the browser then
216+ // revalidates with If-None-Match and a page that did not change costs a
217+ // 304. A no-store page (the owner's) is never revalidated.
218+ if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) {
219+ tag := pageETag(body, SignedIn(ctx), gz)
220+ h.Set("ETag", tag)
221+ if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) {
222+ h.Del("Content-Type")
223+ w.WriteHeader(http.StatusNotModified)
224+ return
225+ }
226+ }
227+ if gz {
228+ if z, err := gzipPage(body); err == nil && len(z) < len(body) {
229+ body = z
230+ h.Set("Content-Encoding", "gzip")
231+ }
232+ }
233+ h.Set("Content-Length", itoa(len(body)))
208 w.WriteHeader(status)234 w.WriteHeader(status)
209 if r.Method != http.MethodHead {235 if r.Method != http.MethodHead {
210- _, _ = w.Write(buf.Bytes())236+ _, _ = w.Write(body)
211 }237 }
212 }238 }
213 239

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close