Render sw.js with a per-build asset hash so updates are detected

The update banner only fires when the browser sees sw.js change, but the
cache name was a hand-bumped constant — so a deploy that touched only
app.js/index.html/style.css left sw.js byte-identical, no new worker
installed, and the banner never showed (and cached assets never refreshed).

Have the server render sw.js at serve time, substituting a __BUILD_HASH__
placeholder with a SHA-256 over the assets the worker caches (index.html,
style.css, app.js, manifest.json, icon.svg). Any asset change now yields a
new cache name and a byte-different sw.js, which is exactly the signal that
makes the browser install a new worker. The hash is memoised and only
recomputed when a file's size/modtime changes, so it needs no server
restart. Served unsubstituted (dev/static host), sw.js stays a valid constant.
This commit is contained in:
Alexander Heldt
2026-07-10 12:55:01 +00:00
parent d267ebef86
commit 16799ad7c7
2 changed files with 91 additions and 3 deletions
+84 -2
View File
@@ -1,10 +1,14 @@
package main
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
@@ -13,6 +17,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
@@ -384,6 +389,75 @@ type cacheControlFS struct {
func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Open(name) }
// swVersion computes a short content hash over the static assets the service
// worker caches. The hash is substituted into sw.js at serve time (see
// serveSW), so the served worker changes whenever any asset changes — that byte
// difference is what makes the browser install a new worker and prompt to
// reload. Results are memoised and only recomputed when a file's size or modtime
// changes, so the steady state is a handful of cheap stats.
type swVersion struct {
dir string
files []string
mu sync.Mutex
sig string // signature of (name,size,modtime) across files
hash string
}
func newSWVersion(dir string) *swVersion {
// The assets the SW caches and that actually change between builds. sw.js
// itself is excluded: it carries the placeholder, so hashing it would be
// circular and it never changes except when we edit it here.
return &swVersion{
dir: dir,
files: []string{"index.html", "style.css", "app.js", "manifest.json", "icon.svg"},
}
}
func (v *swVersion) hashHex() string {
v.mu.Lock()
defer v.mu.Unlock()
var sb strings.Builder
for _, name := range v.files {
if info, err := os.Stat(filepath.Join(v.dir, name)); err == nil {
fmt.Fprintf(&sb, "%s:%d:%d;", name, info.Size(), info.ModTime().UnixNano())
}
}
sig := sb.String()
if sig == v.sig && v.hash != "" {
return v.hash
}
h := sha256.New()
for _, name := range v.files {
f, err := os.Open(filepath.Join(v.dir, name))
if err != nil {
continue
}
_, _ = io.Copy(h, f)
f.Close()
}
v.sig = sig
v.hash = hex.EncodeToString(h.Sum(nil))[:12]
return v.hash
}
// serveSW renders sw.js with the current build hash substituted for its
// placeholder. no-cache lets the browser refetch and byte-compare on each
// update check; the substituted hash is what actually differs between builds.
func serveSW(w http.ResponseWriter, staticDir string, ver *swVersion) {
data, err := os.ReadFile(filepath.Join(staticDir, "sw.js"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
out := bytes.ReplaceAll(data, []byte("__BUILD_HASH__"), []byte(ver.hashHex()))
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(out)
}
func main() {
addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)")
dataPath := flag.String("data", "puppy.db", "path to SQLite database file")
@@ -592,9 +666,17 @@ func main() {
if *staticDir != "" {
fileServer := http.FileServer(http.Dir(*staticDir))
swVer := newSWVersion(*staticDir)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// PWA: sw.js and manifest.json must revalidate so updates propagate.
if r.URL.Path == "/sw.js" || r.URL.Path == "/manifest.json" {
// The service worker is rendered with a per-build hash of the static
// assets, so any asset change yields a byte-different sw.js — that's
// what makes the browser detect an update and show the reload prompt.
if r.URL.Path == "/sw.js" {
serveSW(w, *staticDir, swVer)
return
}
// PWA: manifest.json must revalidate so updates propagate.
if r.URL.Path == "/manifest.json" {
w.Header().Set("Cache-Control", "no-cache")
}
// SPA fallback: unknown paths -> index.html (so deep links work).
+7 -1
View File
@@ -1,4 +1,10 @@
const CACHE = "puppy-tracker-v9";
// BUILD is substituted per-deploy by the server with a hash of the static
// assets (see serveSW in server/main.go), so the cache name — and therefore the
// bytes of this file — change whenever any asset changes. That byte difference
// is what makes the browser install a new worker and surface the update prompt.
// Served unsubstituted (dev / a plain static host) it stays a valid constant.
const BUILD = "__BUILD_HASH__";
const CACHE = `puppy-tracker-${BUILD}`;
const PHOTO_CACHE = "puppy-tracker-photos-v1";
const ASSETS = [
"./",