1414package ui
1515
1616import (
17+ "bytes"
18+ "compress/gzip"
1719 "embed"
1820 "io"
1921 "io/fs"
@@ -28,17 +30,87 @@ import (
2830//go:embed app/dist
2931var 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