From e1efcd0a11d2676bce3324ff7a481153861d1be5 Mon Sep 17 00:00:00 2001 From: botre Date: Sat, 8 Aug 2026 18:28:47 +0200 Subject: [PATCH] Version static asset URLs by content hash The stylesheet and the page scripts are served from fixed paths. Replacing the Tailwind CDN with a first-party stylesheet made that a deployment hazard: a CDN or a browser holding a cached copy from one deploy pairs it with the HTML of the next, every class name the new markup asks for resolves to nothing, and the page renders unstyled. Border colours fall back to currentColor, so it arrives as black boxes rather than as anything that looks like a caching problem. That is not hypothetical. The colour rework shipped and the edge served the previous stylesheet for its full four-hour lifetime against the new markup. Each versioned asset now carries a short hash of its contents in the query string, so a changed file is a different URL and no cache can confuse it for the old one. Hashes are computed once at startup. Development re-hashes on demand so an edited stylesheet needs no restart, but only when the file's modification time has moved: hashing five files on every render made template rendering the bottleneck under parallel load and turned a four-second test suite into thirty-two. Template strings use backquotes so the attribute's own quotes are not nested and the file stays parseable as HTML. Claude-Session: https://claude.ai/code/session_01X8JeP7AQwpd2coSfwbMopt --- src/application.go | 71 +++++++++++++++++++++++++++++++++++++ src/views/layouts/main.html | 10 +++--- 2 files changed, 76 insertions(+), 5 deletions(-) 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 @@ - - - - + + + +