eleven / messenger Public

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>
Paul Campbell 40d24fe4ff722220d83cc76e5c3ee350553d3685 parent bfb04e5
3 files changed, +63 −2
  • main.go +36 −2
  • web/pages/index.html +1 −0
  • web/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.