diff --git a/src/application.go b/src/application.go
index d14ab08..bd2b964 100644
--- a/src/application.go
+++ b/src/application.go
@@ -2,7 +2,10 @@ package main
import (
"context"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
+ "io"
"log/slog"
"net"
"net/http"
@@ -125,6 +128,69 @@ func buildContentSecurityPolicy() string {
"frame-ancestors 'none'"
}
+// assetVersions maps a served static path to a short hash of its contents,
+// computed once at startup. The stylesheet and the page scripts live at fixed
+// paths, so without a version in the URL a CDN or a browser can pair a cached
+// copy of one deploy with the HTML of the next: every class name the new markup
+// asks for resolves to nothing and the page renders unstyled. Versioning the URL
+// makes a changed asset a different URL, which no cache can confuse for the old
+// one.
+var assetVersions = map[string]string{}
+
+// versionedAssets are the first-party files referenced from the templates whose
+// contents change between deploys. Images are excluded: they are replaced rarely
+// and never in a way that breaks a page that fetched the previous copy.
+var versionedAssets = []string{
+ "/app.css",
+ "/index.js",
+ "/render-body.js",
+ "/har.js",
+ "/endpoint.js",
+}
+
+func hashAsset(path string) string {
+ f, err := os.Open("./public" + path)
+ if err != nil {
+ slog.Warn("asset missing, serving unversioned", "path", path, "err", err)
+ return ""
+ }
+ defer f.Close()
+ h := sha256.New()
+ if _, err := io.Copy(h, f); err != nil {
+ slog.Warn("asset unreadable, serving unversioned", "path", path, "err", err)
+ return ""
+ }
+ return hex.EncodeToString(h.Sum(nil))[:10]
+}
+
+// assetURL is exposed to templates as `asset`. Production reads the map computed
+// at startup. Development re-hashes, but only when the file's modification time
+// has moved, so an edited stylesheet is picked up without a restart and without
+// re-reading every asset on every render.
+var assetMu sync.Mutex
+var assetStamps = map[string]time.Time{}
+
+func assetURL(path string) string {
+ if isProduction {
+ if version := assetVersions[path]; version != "" {
+ return path + "?v=" + version
+ }
+ return path
+ }
+
+ assetMu.Lock()
+ defer assetMu.Unlock()
+ info, err := os.Stat("./public" + path)
+ if err == nil && !info.ModTime().Equal(assetStamps[path]) {
+ assetStamps[path] = info.ModTime()
+ assetVersions[path] = hashAsset(path)
+ }
+ if version := assetVersions[path]; version != "" {
+ return path + "?v=" + version
+ }
+ return path
+}
+
// trustedProxyConfig lists the peers whose X-Forwarded-* headers Fiber may
// honour once TrustProxy is enabled. httphq is only ever fronted by a reverse
// proxy that reaches it from a private, loopback or link-local address; no
@@ -321,6 +387,11 @@ func main() {
engine := html.New("./src/views", ".html")
+ for _, path := range versionedAssets {
+ assetVersions[path] = hashAsset(path)
+ }
+ engine.AddFunc("asset", assetURL)
+
if !isProduction {
engine.Reload(true)
engine.Debug(true)
diff --git a/src/views/layouts/main.html b/src/views/layouts/main.html
index 58a6480..602cc17 100644
--- a/src/views/layouts/main.html
+++ b/src/views/layouts/main.html
@@ -38,7 +38,7 @@
-
+
{{if .AppScripts}}
@@ -61,10 +61,10 @@
-
-
-
-
+
+
+
+