Skip to content

Commit 6cb401d

Browse files
committed
feat(ui): Serve pre-compressed assets (gzip + brotli)
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 1d6097c commit 6cb401d

5 files changed

Lines changed: 318 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: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
package ui
1515

1616
import (
17+
"bytes"
18+
"compress/gzip"
1719
"embed"
1820
"io"
1921
"io/fs"
@@ -28,17 +30,87 @@ import (
2830
//go:embed app/dist
2931
var asset embed.FS
3032

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

44116
// Register registers handlers to serve files for the web interface.
@@ -49,22 +121,59 @@ func Register(r *route.Router) {
49121
}
50122
serve := func(w http.ResponseWriter, req *http.Request, filePath string, immutable bool) {
51123
ext := strings.ToLower(path.Ext(filePath))
52-
contentType, ok := fileTypes[ext]
124+
fileType, ok := fileTypes[ext]
53125
if !ok {
54126
http.NotFound(w, req)
55127
return
56128
}
57129

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

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

0 commit comments

Comments
 (0)