feat: re-enable immutable assets
3 files changed, 55 insertions(+), 23 deletions(-)
M frontend/assets.go → frontend/assets.go
@@ -1,34 +1,39 @@ package frontend import ( + "encoding/hex" "fmt" "hash/fnv" "io" "io/fs" + "path" "path/filepath" + "strings" "github.com/Southclaws/fault" "github.com/Southclaws/fault/fmsg" ) type Asset struct { - StaticURL string - URL string - ETag string - Filename string + ETag string + Filename string + ImmutablePath string + StaticURL string } type AssetCollection struct { - Scripts []*Asset - Stylesheets []*Asset - ByPath map[string]*Asset + Scripts []*Asset + Stylesheets []*Asset + ByImmutablePath map[string]*Asset + ByPath map[string]*Asset } func New() (*AssetCollection, error) { a := &AssetCollection{ - Scripts: []*Asset{}, - Stylesheets: []*Asset{}, - ByPath: make(map[string]*Asset), + Scripts: []*Asset{}, + Stylesheets: []*Asset{}, + ByImmutablePath: make(map[string]*Asset), + ByPath: make(map[string]*Asset), } err := a.Rehash()@@ -46,8 +51,8 @@ return nil, fault.Wrap(err, fmsg.Withf("could not open file %s", filename)) } defer file.Close() - hash := fnv.New64a() - if _, err := io.Copy(hash, file); err != nil { + hasher := fnv.New64a() + if _, err := io.Copy(hasher, file); err != nil { return nil, fault.Wrap(err, fmsg.Withf("could not hash file %s", filename)) }@@ -56,17 +61,27 @@ if err != nil { return nil, fault.Wrap(err, fmsg.Withf("could not get relative path for %s", filename)) } + hash := hex.EncodeToString(hasher.Sum(nil)) + return &Asset{ - StaticURL: "/" + filename, - URL: "/" + rel, - ETag: fmt.Sprintf(`W/"%x"`, hash.Sum(nil)), - Filename: filename, + ETag: fmt.Sprintf(`W/"%s"`, hash), + Filename: filename, + ImmutablePath: makeImmutablePath(rel, hash), + StaticURL: "/" + rel, }, nil } +func makeImmutablePath(filename string, hash string) string { + ext := filepath.Ext(filename) + + return path.Join("/", "assets", strings.Replace(filename, ext, "."+hash+ext, 1)) +} + func (a *AssetCollection) Rehash() (err error) { - a.Scripts = []*Asset{} - a.Stylesheets = []*Asset{} + a.Scripts = nil + a.Stylesheets = nil + clear(a.ByImmutablePath) + clear(a.ByPath) files, err := fs.Glob(Files, "static/**") if err != nil {@@ -84,7 +99,7 @@ a.Scripts = append(a.Scripts, asset) case ".css": a.Stylesheets = append(a.Stylesheets, asset) } - a.ByPath[asset.URL] = asset + a.ByImmutablePath[asset.ImmutablePath] = asset a.ByPath[asset.StaticURL] = asset }
M internal/components/page.go → internal/components/page.go
@@ -126,11 +126,11 @@ ) } func css(css *frontend.Asset) g.Node { - return Link(Href(css.URL), Rel("stylesheet")) + return Link(Href(css.ImmutablePath), Rel("stylesheet")) } func script(s *frontend.Asset) g.Node { - return Script(Src(s.URL), Defer()) + return Script(Src(s.ImmutablePath), Defer()) } func sourceNameAndType(source *config.Source) string {
M internal/server/mux.go → internal/server/mux.go
@@ -358,14 +358,31 @@ } }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - asset, found := assets.ByPath[r.URL.Path] + path := strings.TrimPrefix(r.URL.Path, "/static") + asset, found := assets.ByPath[path] + if !found { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + + return + } + // optimisation for HTTP/3: first header sent as byte(38), not the string + // see https://datatracker.ietf.org/doc/html/rfc9204#appendix-A for values + w.Header().Add("Cache-Control", "max-age=604800") + w.Header().Add("ETag", asset.ETag) + http.ServeFileFS(w, r, frontend.Files, asset.Filename) + }) + + mux.HandleFunc("/assets/", func(w http.ResponseWriter, r *http.Request) { + asset, found := assets.ByImmutablePath[r.URL.Path] if !found { http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } // optimisation for HTTP/3: first header sent as byte(41), not the string - w.Header().Add("Cache-Control", "public, max-age=86400") + // see https://datatracker.ietf.org/doc/html/rfc9204#appendix-A for values + w.Header().Add("Cache-Control", "public, max-age=31536000") + w.Header().Add("Cache-Control", "immutable") http.ServeFileFS(w, r, frontend.Files, asset.Filename) })