feat: auto-reload the app when the server ships a new frontend build (#25)
Server hashes embedded web/ at startup → GET /api/version; the page embeds its build (meta app-version). On returning to the app (visibilitychange → visible) the client reloads if the server's build differs — unless there's an unsent draft.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+63
−2
main.go+36 −2web/pages/index.html+1 −0web/static/chat.js+26 −0
diff --git a/main.go b/main.go| index 89c0ef7..827acae 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -1,9 +1,11 @@ |
| package main |
| import ( |
| + "crypto/sha256" |
| "embed" |
| "encoding/json" |
| "flag" |
| + "fmt" |
| "html" |
| "io" |
| "io/fs" |
| @@ -29,6 +31,7 @@ type Server struct { |
| db *DB |
| hub *Hub |
| pages map[string][]byte |
| + version string // frontend build hash; clients reload when it changes (see frontendVersion) |
| vapidPub string |
| vapidPriv string |
| // inflight de-duplicates concurrent unfurls of the same URL. |
| @@ -73,7 +76,7 @@ func main() { |
| } |
| hub := newHub() |
| go hub.run() |
| - s := &Server{db: db, hub: hub, pages: pages} |
| + s := &Server{db: db, hub: hub, pages: pages, version: frontendVersion()} |
| // Web Push: self-generated VAPID keypair (no third-party account). |
| if pub, priv, err := db.ensureVAPID(); err != nil { |
| @@ -148,6 +151,9 @@ func (s *Server) handler() http.Handler { |
| // Group name (public read so the page/manifest can show it; admin write) |
| mux.HandleFunc("GET /api/group", s.getGroup) |
| + // Frontend build version — clients poll this on returning to the app and reload |
| + // if it changed (auto-update). No auth: it's just a build hash. |
| + mux.HandleFunc("GET /api/version", s.getVersion) |
| mux.HandleFunc("POST /api/admin/group", s.setGroup) |
| // Admin API (requires admin token) |
| @@ -190,12 +196,40 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| return |
| } |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| - // Inject the configurable group name (HTML-escaped — it's admin-set). |
| + // Inject the configurable group name (HTML-escaped — it's admin-set) and the |
| + // build version (so the page knows which frontend it loaded — see /api/version). |
| page := strings.ReplaceAll(string(s.pages[name]), "__GROUP_NAME__", html.EscapeString(s.db.getGroupName())) |
| + page = strings.ReplaceAll(page, "__APP_VERSION__", s.version) |
| w.Write([]byte(page)) |
| } |
| } |
| +// frontendVersion is a short hash of the embedded frontend (web/), computed once at |
| +// startup. It changes whenever any UI file changes, so a redeploy that ships new |
| +// frontend code yields a new version — clients notice and reload (see getVersion + |
| +// the visibility check in chat.js). A server-only change leaves it unchanged. |
| +func frontendVersion() string { |
| + h := sha256.New() |
| + _ = fs.WalkDir(webFS, "web", func(p string, d fs.DirEntry, err error) error { |
| + if err != nil || d.IsDir() { |
| + return nil |
| + } |
| + b, e := webFS.ReadFile(p) |
| + if e != nil { |
| + return nil |
| + } |
| + h.Write([]byte(p)) |
| + h.Write(b) |
| + return nil |
| + }) |
| + return fmt.Sprintf("%x", h.Sum(nil))[:12] |
| +} |
| + |
| +// getVersion returns the current frontend build hash. |
| +func (s *Server) getVersion(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{"version": s.version}) |
| +} |
| + |
| // ---- chat API ----------------------------------------------------------- |
| func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
diff --git a/web/pages/index.html b/web/pages/index.html| index af83dc0..d517c51 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -1,6 +1,7 @@ |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
| <meta name="theme-color" content="#5b6cff" /> |
| +<meta name="app-version" content="__APP_VERSION__" /> |
| <title>__GROUP_NAME__</title> |
| <link rel="stylesheet" href="/static/style.css" /> |
| <link rel="manifest" href="/manifest.webmanifest" /> |
diff --git a/web/static/chat.js b/web/static/chat.js| index d3f5599..c565a32 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -329,6 +329,28 @@ function syncThreadKeysToSW() { |
| idbSet("threadKeys", map).catch(() => {}); |
| } |
| +// The frontend build this page loaded with (server-injected into the HTML). |
| +const APP_VERSION = (document.querySelector('meta[name="app-version"]') || {}).content || ""; |
| + |
| +// On returning to the app, ask the server for the current build hash; if it shipped |
| +// a newer frontend, reload to pick it up — unless that would clobber an unsent draft. |
| +let updateChecking = false; |
| +async function checkForUpdate() { |
| + if (!APP_VERSION || updateChecking) return; |
| + updateChecking = true; |
| + try { |
| + const r = await fetch("/api/version", { cache: "no-store" }); |
| + if (r.ok) { |
| + const v = (await r.json()).version; |
| + const text = document.getElementById("text"); |
| + if (v && v !== APP_VERSION && !(text && text.value.trim())) location.reload(); |
| + } |
| + } catch { |
| + } finally { |
| + updateChecking = false; |
| + } |
| +} |
| + |
| // Heartbeat so push is suppressed while the app is open: write a timestamp the SW |
| // checks, and tell the server this device is active (mutes its other devices). |
| function startHeartbeat() { |
| @@ -340,6 +362,10 @@ function startHeartbeat() { |
| beat(); |
| setInterval(beat, 30000); |
| document.addEventListener("visibilitychange", beat); |
| + // Returning to the app (tab/window visible again) → check for a newer build. |
| + document.addEventListener("visibilitychange", () => { |
| + if (document.visibilityState === "visible") checkForUpdate(); |
| + }); |
| } |
| // A one-line snippet for the chat list: "Name: text" in groups, just text in DMs. |