Skip to content

Commit d156470

Browse files
authored
feat(ui): Serve pre-compressed assets (#5133)
Content-Encoding now varies per request based on Accept-Encoding. Assets are stored only in compressed form (.gz level 9, .br quality 11), which decreases the binary size from ~42.8 MB (v0.31.1) to ~39.7 MB. Clients that accept neither gzip nor brotli receive a response decompressed in-memory from the .gz variant on every request. Adds vite-plugin-compression2 as a build dependency. Signed-off-by: Solomon Jacobs <solomonjacobs@protonmail.com>
1 parent 5637381 commit d156470

5 files changed

Lines changed: 319 additions & 49 deletions

File tree

ui/app/package-lock.json

Lines changed: 69 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"elm-review": "2.5.0",
1212
"elm-test": "0.19.1-revision6",
1313
"vite": "^8.0.0",
14+
"vite-plugin-compression2": "^2.5.1",
1415
"vite-plugin-elm": "^3.0.1"
1516
},
1617
"dependencies": {

ui/app/vite.config.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import { defineConfig } from "vite";
22
import elm from "vite-plugin-elm";
3+
import { compression, defineAlgorithm } from "vite-plugin-compression2";
34

45
export default defineConfig({
56
base: "./", // ensure that `--web.route.prefix` works correctly.
67
plugins: [
78
elm(),
9+
compression({
10+
include: [/\.(eot|ttf|ico|js|mjs|json|css|html|svg)$/],
11+
threshold: 0,
12+
deleteOriginalAssets: true,
13+
algorithms: [
14+
defineAlgorithm("gzip", { level: 9 }),
15+
defineAlgorithm("brotliCompress", {
16+
params: { 1: 11 }, // zlib.constants.BROTLI_PARAM_QUALITY = 1
17+
}),
18+
],
19+
}),
820
],
921
});

ui/web.go

Lines changed: 131 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
package ui
1515

1616
import (
17+
"bytes"
18+
"compress/gzip"
1719
"embed"
1820
"io"
1921
"io/fs"
2022
"net/http"
2123
"path"
24+
"strconv"
2225
"strings"
2326
"time"
2427

@@ -28,17 +31,87 @@ import (
2831
//go:embed app/dist
2932
var asset embed.FS
3033

31-
// https://www.iana.org/assignments/media-types/
32-
var fileTypes = map[string]string{
33-
".css": "text/css; charset=utf-8",
34-
".eot": "application/vnd.ms-fontobject",
35-
".html": "text/html; charset=utf-8",
36-
".ico": "image/vnd.microsoft.icon",
37-
".js": "text/javascript; charset=utf-8",
38-
".svg": "image/svg+xml",
39-
".ttf": "font/ttf",
40-
".woff": "font/woff",
41-
".woff2": "font/woff2",
34+
var fileTypes = map[string]struct {
35+
contentType string // https://www.iana.org/assignments/media-types/
36+
varyEncoding bool // Must match build configuration in vite.config.mjs.
37+
}{
38+
".css": {"text/css; charset=utf-8", true},
39+
".eot": {"application/vnd.ms-fontobject", true},
40+
".html": {"text/html; charset=utf-8", true},
41+
".ico": {"image/vnd.microsoft.icon", true},
42+
".js": {"text/javascript; charset=utf-8", true},
43+
".svg": {"image/svg+xml", true},
44+
".ttf": {"font/ttf", true},
45+
".woff": {"font/woff", false},
46+
".woff2": {"font/woff2", false},
47+
}
48+
49+
type encoding int
50+
51+
const (
52+
encNone encoding = iota
53+
encGzip
54+
encBrotli
55+
)
56+
57+
type tokenEffect int
58+
59+
const (
60+
effectUnseen tokenEffect = iota
61+
effectReject
62+
effectAccept
63+
)
64+
65+
// selectEncoding parses the Accept-Encoding header and returns the preferred
66+
// encoding. For simplicity, non-zero q-values are not ranked.
67+
func selectEncoding(header string) encoding {
68+
brotli, gzip, wildcard := effectUnseen, effectUnseen, effectUnseen
69+
for part := range strings.SplitSeq(header, ",") {
70+
encAndQ := strings.SplitN(strings.TrimSpace(part), ";", 2)
71+
72+
effect := effectAccept
73+
if len(encAndQ) > 1 {
74+
// https://www.rfc-editor.org/rfc/rfc9110.html#section-12.4.2
75+
if q, ok := strings.CutPrefix(strings.TrimSpace(encAndQ[1]), "q="); ok {
76+
if weight, err := strconv.ParseFloat(q, 64); err == nil && weight <= 0 {
77+
effect = effectReject
78+
}
79+
}
80+
}
81+
82+
switch strings.TrimSpace(encAndQ[0]) {
83+
case "br":
84+
brotli = effect
85+
case "gzip":
86+
gzip = effect
87+
case "*":
88+
wildcard = effect
89+
}
90+
}
91+
92+
if brotli == effectAccept || (wildcard == effectAccept && brotli == effectUnseen) {
93+
return encBrotli
94+
} else if gzip == effectAccept || (wildcard == effectAccept && gzip == effectUnseen) {
95+
return encGzip
96+
}
97+
return encNone
98+
}
99+
100+
// decompressToReader decompresses f into memory. For simplicity the entire
101+
// file is buffered; this is acceptable given the small size of the assets.
102+
func decompressToReader(f fs.File) (*bytes.Reader, error) {
103+
gzReader, err := gzip.NewReader(f)
104+
if err != nil {
105+
return nil, err
106+
}
107+
defer gzReader.Close()
108+
109+
data, err := io.ReadAll(gzReader)
110+
if err != nil {
111+
return nil, err
112+
}
113+
114+
return bytes.NewReader(data), nil
42115
}
43116

44117
// Register registers handlers to serve files for the web interface.
@@ -49,22 +122,59 @@ func Register(r *route.Router) {
49122
}
50123
serve := func(w http.ResponseWriter, req *http.Request, filePath string, immutable bool) {
51124
ext := strings.ToLower(path.Ext(filePath))
52-
contentType, ok := fileTypes[ext]
125+
fileType, ok := fileTypes[ext]
53126
if !ok {
54127
http.NotFound(w, req)
55128
return
56129
}
57130

58-
f, err := appFS.Open(filePath)
59-
if err != nil {
60-
http.NotFound(w, req)
61-
return
131+
if fileType.varyEncoding {
132+
switch selectEncoding(req.Header.Get("Accept-Encoding")) {
133+
case encBrotli:
134+
if f, err := appFS.Open(filePath + ".br"); err == nil {
135+
defer f.Close()
136+
setCachePolicy(w, immutable)
137+
w.Header().Set("Content-Type", fileType.contentType)
138+
w.Header().Set("Content-Encoding", "br")
139+
w.Header().Add("Vary", "Accept-Encoding")
140+
http.ServeContent(w, req, filePath, time.Time{}, f.(io.ReadSeeker))
141+
return
142+
}
143+
case encGzip:
144+
if f, err := appFS.Open(filePath + ".gz"); err == nil {
145+
defer f.Close()
146+
setCachePolicy(w, immutable)
147+
w.Header().Set("Content-Type", fileType.contentType)
148+
w.Header().Set("Content-Encoding", "gzip")
149+
w.Header().Add("Vary", "Accept-Encoding")
150+
http.ServeContent(w, req, filePath, time.Time{}, f.(io.ReadSeeker))
151+
return
152+
}
153+
case encNone:
154+
if f, err := appFS.Open(filePath + ".gz"); err == nil {
155+
defer f.Close()
156+
uncompressedBytes, err := decompressToReader(f)
157+
if err != nil {
158+
http.Error(w, "failed to decompress file", http.StatusInternalServerError)
159+
return
160+
}
161+
setCachePolicy(w, immutable)
162+
w.Header().Set("Content-Type", fileType.contentType)
163+
w.Header().Add("Vary", "Accept-Encoding")
164+
http.ServeContent(w, req, filePath, time.Time{}, uncompressedBytes)
165+
return
166+
}
167+
}
168+
} else {
169+
if f, err := appFS.Open(filePath); err == nil {
170+
defer f.Close()
171+
setCachePolicy(w, immutable)
172+
w.Header().Set("Content-Type", fileType.contentType)
173+
http.ServeContent(w, req, filePath, time.Time{}, f.(io.ReadSeeker))
174+
return
175+
}
62176
}
63-
defer f.Close()
64-
65-
setCachePolicy(w, immutable)
66-
w.Header().Set("Content-Type", contentType)
67-
http.ServeContent(w, req, filePath, time.Time{}, f.(io.ReadSeeker))
177+
http.NotFound(w, req)
68178
}
69179

70180
r.Get("/", func(w http.ResponseWriter, req *http.Request) {

0 commit comments

Comments
 (0)