18 "github.com/mjl-/mox/mlog"
19 "github.com/mjl-/mox/moxvar"
22// WebappFile serves a merged HTML and JS webapp as a single compressed, cacheable
23// file. It merges the JS into the HTML at first load, caches a gzipped version
24// that is generated on first need, and responds with a Last-Modified header.
25type WebappFile struct {
26 HTML, JS []byte // Embedded html/js data.
27 HTMLPath, JSPath string // Paths to load html/js from during development.
32 mtime time.Time // For Last-Modified and conditional request.
35// FallbackMtime returns a time to use for the Last-Modified header in case we
36// cannot find a file, e.g. when used in production.
37func FallbackMtime(log mlog.Log) time.Time {
38 p, err := os.Executable()
39 log.Check(err, "finding executable for mtime")
42 log.Check(err, "stat on executable for mtime")
47 log.Info("cannot find executable for webappfile mtime, using current time")
51func (a *WebappFile) serverError(log mlog.Log, w http.ResponseWriter, err error, action string) {
52 log.Errorx("serve webappfile", err, slog.String("msg", action))
53 http.Error(w, "500 - internal server error", http.StatusInternalServerError)
56// Serve serves a combined file, with headers for caching and possibly gzipped.
57func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *http.Request) {
58 // We typically return the embedded file, but during development it's handy
60 fhtml, _ := os.Open(a.HTMLPath)
64 fjs, _ := os.Open(a.JSPath)
72 var diskmtime time.Time
74 if fhtml != nil && fjs != nil {
75 sth, err := fhtml.Stat()
77 a.serverError(log, w, err, "stat html")
80 stj, err := fjs.Stat()
82 a.serverError(log, w, err, "stat js")
86 maxmtime := sth.ModTime()
87 if stj.ModTime().After(maxmtime) {
88 maxmtime = stj.ModTime()
92 refreshdisk = maxmtime.After(a.mtime) || a.combined == nil
96 html, err = io.ReadAll(fhtml)
98 a.serverError(log, w, err, "reading html")
101 js, err = io.ReadAll(fjs)
103 a.serverError(log, w, err, "reading js")
119 if refreshdisk || a.combined == nil {
120 script := []byte(`<script>/* placeholder */</script>`)
121 index := bytes.Index(html, script)
123 a.serverError(log, w, errors.New("script not found"), "generating combined html")
127 b.Write(html[:index])
128 fmt.Fprintf(&b, "<script>\n// Javascript is generated from typescript, don't modify the javascript because changes will be lost.\nconst moxversion = \"%s\";\nconst moxgoversion = \"%s\";\nconst moxgoos = \"%s\";\nconst moxgoarch = \"%s\";\n", moxvar.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
130 b.WriteString("\t\t</script>")
131 b.Write(html[index+len(script):])
137 a.mtime = FallbackMtime(log)
144 if a.combinedGzip == nil {
146 gzw, err := gzip.NewWriterLevel(&b, gzip.BestCompression)
148 _, err = gzw.Write(out)
154 a.serverError(log, w, err, "gzipping combined html")
157 a.combinedGzip = b.Bytes()
159 origSize = int64(len(out))
165 w.Header().Set("Content-Type", "text/html; charset=utf-8")
166 http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out))
169// gzipInjector is a http.ResponseWriter that optionally injects a
170// Content-Encoding: gzip header, only in case of status 200 OK. Used with
171// http.ServeContent to serve gzipped content if the client supports it. We cannot
172// just unconditionally add the content-encoding header, because we don't know
173// enough if we will be sending data: http.ServeContent may be sending a "not
174// modified" response, and possibly others.
175type gzipInjector struct {
176 http.ResponseWriter // Keep most methods.
181// WriteHeader adds a Content-Encoding: gzip header before actually writing the
182// headers and status.
183func (w gzipInjector) WriteHeader(statusCode int) {
184 if w.gz && statusCode == http.StatusOK {
185 w.ResponseWriter.Header().Set("Content-Encoding", "gzip")
186 if lw, ok := w.ResponseWriter.(interface{ SetUncompressedSize(int64) }); ok {
187 lw.SetUncompressedSize(w.origSize)
190 w.ResponseWriter.WriteHeader(statusCode)
193// AcceptsGzip returns whether the client accepts gzipped responses.
194func AcceptsGzip(r *http.Request) bool {
195 s := r.Header.Get("Accept-Encoding")
196 t := strings.Split(s, ",")
197 for _, e := range t {
198 e = strings.TrimSpace(e)
199 tt := strings.Split(e, ";")
200 if len(tt) > 1 && t[1] == "q=0" {