about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorAlan Pearce2024-05-31 06:03:52 +0200
committerAlan Pearce2024-05-31 06:03:52 +0200
commit896926a63a8f2d145467b325f9b0198315e0af6d (patch)
treedd298424bc99f87bb4c1c96d8be753908801eeb9
parent66b2556a6a7c911a69b231fddeefe0a939d8898d (diff)
downloadsearchix-896926a63a8f2d145467b325f9b0198315e0af6d.tar.lz
searchix-896926a63a8f2d145467b325f9b0198315e0af6d.tar.zst
searchix-896926a63a8f2d145467b325f9b0198315e0af6d.zip
feat: serve assets via immutable paths
-rw-r--r--frontend/assets.go52
-rw-r--r--internal/server/mux.go13
2 files changed, 40 insertions, 25 deletions
diff --git a/frontend/assets.go b/frontend/assets.go
index 286d505..c04e706 100644
--- a/frontend/assets.go
+++ b/frontend/assets.go
@@ -3,52 +3,58 @@ package frontend
 import (
 	"crypto/sha256"
 	"encoding/base64"
+	"encoding/hex"
+	"hash/fnv"
 	"io"
 	"io/fs"
-	"net/url"
+	"path/filepath"
+	"strings"
 
 	"github.com/pkg/errors"
 )
 
 var Assets = &AssetCollection{
-	Scripts:     make(map[string]Asset),
-	Stylesheets: make(map[string]Asset),
+	Scripts:         make(map[string]*Asset),
+	Stylesheets:     make(map[string]*Asset),
+	ByImmutablePath: make(map[string]*Asset),
 }
 
 type Asset struct {
 	URL          string
+	Filename     string
 	Base64SHA256 string
 }
 
 type AssetCollection struct {
-	Scripts     map[string]Asset
-	Stylesheets map[string]Asset
+	Scripts         map[string]*Asset
+	Stylesheets     map[string]*Asset
+	ByImmutablePath map[string]*Asset
 }
 
-func hashFile(filename string) ([]byte, error) {
+func newAsset(filename string) (*Asset, error) {
 	file, err := Files.Open(filename)
 	if err != nil {
 		return nil, errors.WithMessagef(err, "could not open file %s", filename)
 	}
-	sum := sha256.New()
 	defer file.Close()
-	if _, err := io.Copy(sum, file); err != nil {
+
+	shasum := sha256.New()
+	hash := fnv.New64a()
+	if _, err := io.Copy(io.MultiWriter(shasum, hash), file); err != nil {
 		return nil, errors.WithMessagef(err, "could not hash file %s", filename)
 	}
 
-	return sum.Sum(nil), nil
+	return &Asset{
+		URL:          makeImmutablePath(filename, hex.EncodeToString(hash.Sum(nil))),
+		Filename:     filename,
+		Base64SHA256: base64.StdEncoding.EncodeToString(shasum.Sum(nil)),
+	}, nil
 }
 
-func newAsset(filename string, hash []byte) Asset {
-	u, err := url.JoinPath("/", filename)
-	if err != nil {
-		panic(err)
-	}
+func makeImmutablePath(filename string, hash string) string {
+	ext := filepath.Ext(filename)
 
-	return Asset{
-		URL:          u,
-		Base64SHA256: base64.StdEncoding.EncodeToString(hash),
-	}
+	return "/" + strings.Replace(filename, ext, "."+hash+ext, 1)
 }
 
 func hashScripts() error {
@@ -57,11 +63,12 @@ func hashScripts() error {
 		return errors.WithMessage(err, "could not glob files")
 	}
 	for _, filename := range scripts {
-		hash, err := hashFile(filename)
+		asset, err := newAsset(filename)
 		if err != nil {
 			return err
 		}
-		Assets.Scripts[filename] = newAsset(filename, hash)
+		Assets.Scripts[filename] = asset
+		Assets.ByImmutablePath[asset.URL] = asset
 	}
 
 	return nil
@@ -73,11 +80,12 @@ func hashStyles() error {
 		return errors.WithMessage(err, "could not glob files")
 	}
 	for _, filename := range styles {
-		hash, err := hashFile(filename)
+		asset, err := newAsset(filename)
 		if err != nil {
 			return err
 		}
-		Assets.Stylesheets[filename] = newAsset(filename, hash)
+		Assets.Stylesheets[filename] = asset
+		Assets.ByImmutablePath[asset.URL] = asset
 	}
 
 	return nil
diff --git a/internal/server/mux.go b/internal/server/mux.go
index 2837dc0..1140484 100644
--- a/internal/server/mux.go
+++ b/internal/server/mux.go
@@ -260,10 +260,17 @@ func NewMux(
 	mux.HandleFunc("/options/{source}/opensearch.xml", createOpenSearchXMLHandler(config.Options))
 	mux.HandleFunc("/packages/{source}/opensearch.xml", createOpenSearchXMLHandler(config.Packages))
 
-	fs := http.FileServer(http.FS(frontend.Files))
 	mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Add("Cache-Control", "max-age=86400")
-		fs.ServeHTTP(w, r)
+		// optimisation for HTTP/3: first header sent as byte(41), not the string
+		asset, found := frontend.Assets.ByImmutablePath[r.URL.Path]
+		if !found {
+			http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+
+			return
+		}
+		w.Header().Add("Cache-Control", "public, max-age=31536000")
+		w.Header().Add("Cache-Control", "immutable")
+		http.ServeFileFS(w, r, frontend.Files, asset.Filename)
 	})
 
 	if liveReload {