package web import ( "crypto/sha256" "encoding/hex" "io/fs" "net/http" "strings" "sync" ) // Static assets are served out of the embedded FS, and embedded files have no // modification time, so http.FileServer sends no Last-Modified and no ETag. With // no validator and no Cache-Control, every page view re-downloaded the whole // stylesheet and every script in full. // // So each file is hashed once, at first use, and the hash does two jobs: // // - Templates link assets through {{asset "/static/..."}}, which appends // ?v=. A request carrying the current hash is content-addressed, so it // is served immutable for a year: the next deploy that changes the file // changes the URL, and the browser never has to ask. // - Every static response carries the hash as its ETag. Anything fetched by a // bare URL (audio samples, scripts loading scripts, a stale page asking for // an old ?v=) is served no-cache, and the revalidation is a cheap 304. var ( assetOnce sync.Once assetHashes map[string]string // path under static/ -> short content hash ) func loadAssetHashes() map[string]string { assetOnce.Do(func() { assetHashes = map[string]string{} sub, err := fs.Sub(staticFS, "static") if err != nil { return } _ = fs.WalkDir(sub, ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } b, err := fs.ReadFile(sub, path) if err != nil { return nil } sum := sha256.Sum256(b) assetHashes[path] = hex.EncodeToString(sum[:])[:12] return nil }) }) return assetHashes } // assetURL is the {{asset}} template func: "/static/css/output.css" becomes // "/static/css/output.css?v=". A path that isn't an embedded file comes // back unchanged rather than breaking the page. func assetURL(path string) string { h, ok := loadAssetHashes()[strings.TrimPrefix(path, "/static/")] if !ok { return path } return path + "?v=" + h } // staticHandler serves the embedded static tree (mounted under /static/ with the // prefix already stripped) with a content-hash ETag and a cache policy that // depends on whether the URL named the current hash. func staticHandler(sub fs.FS) http.Handler { files := http.FileServer(http.FS(sub)) hashes := loadAssetHashes() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if h, ok := hashes[strings.TrimPrefix(r.URL.Path, "/")]; ok { // FileServer honours If-None-Match against an ETag already set on // the response, so this alone turns revalidation into a 304. w.Header().Set("ETag", `"`+h+`"`) if r.URL.Query().Get("v") == h { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } } files.ServeHTTP(w, r) }) }