diff --git a/main.py b/main.py
index e174a34..c255f67 100644
--- a/main.py
+++ b/main.py
@@ -1,10 +1,12 @@
from contextlib import asynccontextmanager
+from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from server import db, discord, drand, fanout, gamestore, reaper, telemetry
+from server.assets import current_build_id
from server.auth import router as auth_router
from server.config import FRONTEND_DIST
from server.discord_interactions import router as discord_router
@@ -44,6 +46,24 @@ async def lifespan(app: FastAPI):
# Stamp CSP + HSTS onto every HTTP response (index page, /static, /metrics, …).
app.add_middleware(SecurityHeadersMiddleware)
+# Service worker, served from root so its scope is the whole origin (a SW under
+# /static/ could only control /static/). Same in dev and prod: in prod /sw.js
+# isn't under /static/, so nginx proxies it here to the app. The build id is
+# substituted at request time so a deploy changes the file's bytes -> the
+# browser installs the new worker. `no-cache` keeps the SW script itself from
+# ever going stale (the deepest staleness trap of all).
+_SW_SOURCE = Path("static/sw.js")
+
+
+@app.get("/sw.js")
+async def service_worker() -> Response:
+ body = _SW_SOURCE.read_text().replace("__BUILD_ID__", current_build_id())
+ return Response(
+ content=body,
+ media_type="text/javascript; charset=utf-8",
+ headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"},
+ )
+
# Static-asset serving is split by deployment mode:
#
# PROD (FRONTEND_DIST set): the frontend is bundled + fingerprinted into a
diff --git a/server/assets.py b/server/assets.py
index f9175aa..60c48bd 100644
--- a/server/assets.py
+++ b/server/assets.py
@@ -1,4 +1,5 @@
import hashlib
+import os
import re
from pathlib import Path
from string import Template
@@ -66,14 +67,18 @@ def build_index_html() -> str:
}
-def build_page_template(html_source: str, app_url: str = "") -> tuple[Template, dict[str, str]]:
+def build_page_template(
+ html_source: str, app_url: str = "", build_id: str = ""
+) -> tuple[Template, dict[str, str]]:
"""Wrap the cache-busted index.html in a Template and resolve defaults.
Called once at startup. The returned (Template, defaults) pair is passed to
render_page() per-request — defaults for most routes, with overrides for
- pages like profiles."""
+ pages like profiles. `build_id` fills the the client
+ compares against the server's build id (see current_build_id)."""
base = app_url.rstrip("/")
defaults = PAGE_DEFAULTS.copy()
+ defaults["build_id"] = build_id
if base:
defaults["share_image"] = f"{base}{_SHARE_IMAGE_PATH}"
defaults["canonical_url"] = base + "/"
@@ -113,6 +118,38 @@ def build_js_cache() -> dict[str, str]:
return cache
+_prod_build_id: str | None = None
+
+
+def current_build_id() -> str:
+ """Build id for the frontend the server is serving right now.
+
+ Used to (a) name the service-worker cache so a deploy busts it, and (b) let
+ a running client notice it's older than the server — sent in the WS `welcome`
+ frame and compared against the baked into the page.
+
+ Prod (FRONTEND_DIST set): the content hash of the prebuilt dist/index.html,
+ which references every fingerprinted asset by hashed name, so it changes iff
+ the build does. Immutable at runtime, so it's computed once and memoised. An
+ explicit BUILD_ID env var overrides it. Dev: the live static-tree hash, which
+ matches the ?v= cache-buster and the page the app just served."""
+ global _prod_build_id
+ if _prod_build_id is not None:
+ return _prod_build_id
+ override = os.environ.get("BUILD_ID")
+ if override:
+ _prod_build_id = override.strip()
+ return _prod_build_id
+ dist = os.environ.get("FRONTEND_DIST", "").strip()
+ if dist:
+ try:
+ _prod_build_id = asset_hash([Path(dist) / "index.html"])
+ except OSError:
+ _prod_build_id = "unknown"
+ return _prod_build_id
+ return dev_assets().build_id()
+
+
class DevAssets:
"""Dev-only cache-busted asset serving that survives file edits without a
server restart.
@@ -133,6 +170,7 @@ def __init__(self, app_url: str = "") -> None:
self._tmpl: Template | None = None
self._defaults: dict[str, str] = {}
self._js: dict[str, str] = {}
+ self._version = "dev"
def _signature(self) -> tuple[int, int]:
css, js, legacy = _collect_assets()
@@ -145,7 +183,11 @@ def _refresh_if_stale(self) -> None:
if sig == self._sig:
return
self._sig = sig
- self._tmpl, self._defaults = build_page_template(build_index_html(), self._app_url)
+ css, js, legacy = _collect_assets()
+ self._version = asset_hash(css + js + legacy)
+ self._tmpl, self._defaults = build_page_template(
+ build_index_html(), self._app_url, self._version
+ )
self._js = build_js_cache()
def template(self) -> tuple[Template, dict[str, str]]:
@@ -157,6 +199,10 @@ def js(self, key: str) -> str | None:
self._refresh_if_stale()
return self._js.get(key)
+ def build_id(self) -> str:
+ self._refresh_if_stale()
+ return self._version
+
_dev_assets: DevAssets | None = None
diff --git a/server/routes.py b/server/routes.py
index 098fc5e..01fef00 100644
--- a/server/routes.py
+++ b/server/routes.py
@@ -6,7 +6,7 @@
from fastapi.responses import HTMLResponse, Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
-from .assets import build_page_template, render_page
+from .assets import build_page_template, current_build_id, render_page
from .config import (
APP_URL,
FOUNDING_CUTOFF,
@@ -26,7 +26,7 @@
if FRONTEND_DIST:
# Prod: bake once from the prebuilt, fingerprinted dist/ index.html.
_html_source = (Path(FRONTEND_DIST) / "index.html").read_text()
- _tmpl, _defaults = build_page_template(_html_source, APP_URL)
+ _tmpl, _defaults = build_page_template(_html_source, APP_URL, current_build_id())
_index_html = render_page(_tmpl, _defaults)
def _page_template():
diff --git a/server/ws.py b/server/ws.py
index 230b709..61ea344 100644
--- a/server/ws.py
+++ b/server/ws.py
@@ -29,6 +29,7 @@
PAUSE_MAX,
log,
)
+from .assets import current_build_id
from .game import apply_roll, make_reconnect_token, sanitize_name, state_msg, verify_token
from .security import client_ip
from .state import connections, sessions
@@ -541,7 +542,7 @@ async def websocket_endpoint(ws: WebSocket) -> None:
session.pinger = Pinger(session.session_id, lambda m: send(ws, m))
session.pinger.start()
- await send(ws, {"type": "welcome", "player_id": session.pid})
+ await send(ws, {"type": "welcome", "player_id": session.pid, "build": current_build_id()})
log.info("connect pid=%s session=%s", session.pid[:8], session.session_id[:8])
disconnect_reason = "client"
diff --git a/static/css/update.css b/static/css/update.css
new file mode 100644
index 0000000..f04d845
--- /dev/null
+++ b/static/css/update.css
@@ -0,0 +1,49 @@
+/* Update-available banner.
+ *
+ * Shown by static/js/update.js only when a newer build is detected and it isn't
+ * safe to auto-reload (i.e. the player is mid-game). Hidden by default so it
+ * never affects first paint or the pixel baselines. */
+@layer components {
+ .update-banner {
+ position: fixed;
+ left: 50%;
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
+ transform: translateX(-50%);
+ z-index: 1000;
+
+ max-inline-size: calc(100vw - 32px);
+ padding: 12px 22px;
+ border: 1px solid var(--color-border-strong);
+ border-radius: 999px;
+
+ font-family: var(--font-family-base);
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-text);
+ text-shadow: var(--shadow-text);
+ white-space: nowrap;
+
+ background: var(--color-panel-screen);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
+ cursor: pointer;
+
+ opacity: 0;
+ translate: 0 12px;
+ transition: opacity 220ms ease, translate 220ms ease;
+ }
+
+ .update-banner.is-visible {
+ opacity: 1;
+ translate: 0 0;
+ }
+
+ .update-banner:active {
+ transform: translateX(-50%) scale(0.97);
+ }
+
+ .update-banner[hidden] {
+ display: none;
+ }
+}
diff --git a/static/index.html b/static/index.html
index fd45dc8..1d41393 100644
--- a/static/index.html
+++ b/static/index.html
@@ -4,6 +4,10 @@
+
+
$page_title
@@ -66,6 +70,7 @@
+
diff --git a/static/js/app.js b/static/js/app.js
index 6918c3d..425fa31 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -17,8 +17,10 @@ import { setupInstall } from './a2hs.js';
import { maybeReconnect } from './net.js';
import { bootstrap } from './router.js';
import { installTouchGuard } from './touch.js';
+import { setupUpdates } from './update.js';
installTouchGuard();
setupInstall();
+setupUpdates();
bootstrap({ resumeSession: maybeReconnect });
diff --git a/static/js/net.js b/static/js/net.js
index 1f2b821..ab5dd48 100644
--- a/static/js/net.js
+++ b/static/js/net.js
@@ -10,6 +10,7 @@ import {
} from './session.js';
import { state, resetRollState } from './state.js';
import { showScreen, showLoading, leaveLoading } from './transitions.js';
+import { checkServerBuild } from './update.js';
/** @typedef {import('./types.js').ServerMessage} ServerMessage */
/** @typedef {import('./types.js').ErrorMessage} ErrorMessage */
@@ -93,7 +94,7 @@ function attemptReconnect(playerId, gameCode, deadline) {
ws.onerror = () => {};
ws.onmessage = (event) => {
const msg = /** @type {ServerMessage} */ (JSON.parse(event.data));
- if (msg.type === 'welcome') return;
+ if (msg.type === 'welcome') { checkServerBuild(msg.build); return; }
if (msg.type === 'error') {
ws.close();
expireSession();
@@ -236,6 +237,7 @@ function handleMessage(msg) {
case 'welcome':
state.myId = msg.player_id;
savePlayerId(msg.player_id);
+ checkServerBuild(msg.build);
return;
case 'auth_ok':
state.authUsername = msg.username;
diff --git a/static/js/types.js b/static/js/types.js
index 42f0661..1964d92 100644
--- a/static/js/types.js
+++ b/static/js/types.js
@@ -38,6 +38,8 @@
* @typedef {object} WelcomeMessage
* @property {'welcome'} type
* @property {string} player_id
+ * @property {string} [build] Server's frontend build id — update.js compares it
+ * against this page's to detect a stale client.
*/
/**
diff --git a/static/js/update.js b/static/js/update.js
new file mode 100644
index 0000000..868cd95
--- /dev/null
+++ b/static/js/update.js
@@ -0,0 +1,183 @@
+// @ts-check
+
+/**
+ * Keeping the installed PWA fresh.
+ *
+ * iOS freezes a standalone PWA's page in memory and resumes it on reopen
+ * without reloading the document — so a warm resume can sit on an old build
+ * indefinitely. Two independent freshness signals guard against that, sharing
+ * one response:
+ *
+ * 1. The service worker's own update lifecycle — a byte-different /sw.js is
+ * found on navigation (and on the resume poll below). The primary path.
+ * 2. The WS `welcome` frame's build id vs the the
+ * page loaded with — a belt-and-suspenders check that fires even when the
+ * SW hasn't polled yet, or when no SW is available at all.
+ *
+ * When either says "the server is newer", we reload immediately if it's safe
+ * (see AUTO_RELOAD_SCREENS) or surface a tap-to-refresh banner otherwise, so we
+ * never yank a player out of a live round.
+ */
+
+// ── Idle policy — the ONE place to change when auto-reload is allowed ────────
+// A detected update reloads on its own ONLY while one of these is the active
+// screen; anywhere else we show the banner and let the player choose. Widen
+// this list (e.g. add 'join', 'lobby') to make auto-reload more eager.
+const AUTO_RELOAD_SCREENS = ['landing'];
+
+/** sessionStorage key: the build we last auto-reloaded toward (loop guard). */
+const RELOADED_KEY = 'tensies_reloaded_build';
+
+let waitingWorker = /** @type {ServiceWorker | null} */ (null);
+let updatePending = false; // an update is ready but deferred (banner shown)
+let expectingActivation = false; // we asked a waiting SW to take over
+let reloading = false; // reload-exactly-once guard
+
+function activeScreenId() {
+ return document.querySelector('.screen.active')?.id ?? null;
+}
+
+function safeToAutoReload() {
+ const id = activeScreenId();
+ return id !== null && AUTO_RELOAD_SCREENS.includes(id);
+}
+
+/** The build the page was served with, from . */
+function pageBuild() {
+ const meta = document.querySelector('meta[name="app-build"]');
+ return meta instanceof HTMLMetaElement ? meta.content : '';
+}
+
+function reloadOnce() {
+ if (reloading) return;
+ reloading = true;
+ location.reload();
+}
+
+/** Apply a ready update: hand off to the waiting SW, or just reload. */
+function applyUpdate() {
+ hideBanner();
+ if (waitingWorker) {
+ expectingActivation = true;
+ waitingWorker.postMessage('skipWaiting');
+ } else {
+ reloadOnce();
+ }
+}
+
+// ── Tap-to-refresh banner ───────────────────────────────────────────────────
+// Built in JS and hidden until needed, so it never touches first paint or the
+// pixel baselines. Styled by css/update.css (no inline styles — the CSP forbids
+// them).
+let banner = /** @type {HTMLButtonElement | null} */ (null);
+
+function ensureBanner() {
+ if (banner) return banner;
+ const el = document.createElement('button');
+ el.id = 'update-banner';
+ el.className = 'update-banner';
+ el.type = 'button';
+ el.hidden = true;
+ el.textContent = 'New version available — tap to refresh';
+ el.addEventListener('click', applyUpdate);
+ document.body.appendChild(el);
+ banner = el;
+ return el;
+}
+
+function showBanner() {
+ const el = ensureBanner();
+ el.hidden = false;
+ el.classList.add('is-visible');
+}
+
+function hideBanner() {
+ if (!banner) return;
+ banner.hidden = true;
+ banner.classList.remove('is-visible');
+}
+
+/**
+ * An update is ready. Apply now if we're on a safe screen; otherwise show the
+ * banner and wait for the player (or their next visit to a safe screen).
+ * @param {ServiceWorker | null} waiting
+ */
+function onUpdateReady(waiting) {
+ updatePending = true;
+ waitingWorker = waiting;
+ if (safeToAutoReload()) applyUpdate();
+ else showBanner();
+}
+
+/**
+ * The server reports its build id in the welcome frame. If it differs from the
+ * build the page loaded with, the server is newer — treat it as an update.
+ * @param {string | undefined} serverBuild
+ */
+export function checkServerBuild(serverBuild) {
+ if (!serverBuild) return;
+ const mine = pageBuild();
+ if (!mine || mine === serverBuild) return;
+ // Loop guard: auto-reload toward a given build at most once (a stale
+ // intermediary serving old HTML could otherwise reload us forever). After
+ // that, only ever offer the banner.
+ const alreadyTried = sessionStorage.getItem(RELOADED_KEY) === serverBuild;
+ if (safeToAutoReload() && !alreadyTried) {
+ sessionStorage.setItem(RELOADED_KEY, serverBuild);
+ onUpdateReady(waitingWorker); // may be null -> plain reload
+ } else {
+ updatePending = true;
+ showBanner();
+ }
+}
+
+/**
+ * Register the service worker, wire its update lifecycle, and re-check for
+ * updates whenever the app returns to the foreground.
+ */
+export function setupUpdates() {
+ // Even without a SW, a banner-deferred update should auto-apply once the
+ // player drifts onto a safe screen and the app is refocused.
+ const onResume = () => {
+ if (document.visibilityState !== 'visible') return;
+ if (updatePending && safeToAutoReload()) applyUpdate();
+ };
+ document.addEventListener('visibilitychange', onResume);
+ window.addEventListener('pageshow', onResume);
+
+ if (!('serviceWorker' in navigator)) return;
+
+ navigator.serviceWorker.register('/sw.js').then((reg) => {
+ // A worker installed by a previous page load may already be waiting.
+ if (reg.waiting && navigator.serviceWorker.controller) onUpdateReady(reg.waiting);
+
+ reg.addEventListener('updatefound', () => {
+ const installing = reg.installing;
+ if (!installing) return;
+ installing.addEventListener('statechange', () => {
+ // installed + an existing controller => an UPDATE, not the first-ever
+ // install (which has no controller and must stay silent).
+ if (installing.state === 'installed' && navigator.serviceWorker.controller) {
+ onUpdateReady(reg.waiting || installing);
+ }
+ });
+ });
+
+ // Poll for a new SW when the app comes back to the foreground — iOS skips
+ // this on a warm resume, which is exactly when a PWA drifts out of date.
+ const poll = () => { reg.update().catch(() => {}); };
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') poll();
+ });
+ window.addEventListener('pageshow', poll);
+ }).catch(() => {
+ // SW unavailable (private mode, unsupported context) — the app still works;
+ // the welcome-frame build check remains as the freshness fallback.
+ });
+
+ // The new worker took control after skipWaiting -> load the fresh assets
+ // once. Guarded so the first-install clients.claim() doesn't reload the page.
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ if (expectingActivation) reloadOnce();
+ });
+}
diff --git a/static/sw.js b/static/sw.js
new file mode 100644
index 0000000..2bc9641
--- /dev/null
+++ b/static/sw.js
@@ -0,0 +1,102 @@
+/* Tensies service worker.
+ *
+ * Two jobs, deliberately designed so the SW can never become the thing that
+ * goes stale — the classic "stuck on an old build until you delete and
+ * reinstall the PWA" trap:
+ *
+ * 1. Cache the fingerprinted /static assets so a warm launch paints fast and
+ * a brief network blip doesn't blank the app.
+ * 2. Keep the app FRESH: the HTML document is always network-first, so any
+ * boot with a connection sees the newest index — and therefore the newest
+ * hashed asset URLs. Only content-hashed /static assets are cache-first,
+ * which is safe precisely because their URL changes whenever their bytes
+ * do (a content hash in prod, a ?v= query in dev).
+ *
+ * BUILD is substituted server-side by the /sw.js route (main.py) with the
+ * current build id, so every deploy changes this file's bytes -> the browser
+ * installs a new SW -> activate() drops the previous build's caches. The PAGE
+ * decides WHEN the new worker takes over: the new SW stays "waiting" until the
+ * client posts 'skipWaiting' (static/js/update.js), so assets are never swapped
+ * out from under a live round.
+ */
+const BUILD = '__BUILD_ID__';
+const CACHE = `tensies-${BUILD}`;
+
+self.addEventListener('install', () => {
+ // Intentionally NOT skipWaiting(): the new worker waits until the page picks
+ // a safe moment to reload (update.js), so a game in progress never has its
+ // assets swapped mid-flight.
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil((async () => {
+ // This build owns exactly CACHE; drop every earlier build's cache.
+ const names = await caches.keys();
+ await Promise.all(
+ names
+ .filter((n) => n.startsWith('tensies-') && n !== CACHE)
+ .map((n) => caches.delete(n)),
+ );
+ // Take control of the open page so the client's controllerchange handler
+ // can drive the one post-update reload.
+ await self.clients.claim();
+ })());
+});
+
+self.addEventListener('message', (event) => {
+ // The page asks us to take over now (player tapped "update", or they're idle
+ // on a safe screen). Activating fires controllerchange -> the page reloads.
+ if (event.data === 'skipWaiting') self.skipWaiting();
+});
+
+/**
+ * Cache-first for a hashed static asset; populate on miss. The URL carries the
+ * version, so a cache hit is always the right bytes — no revalidation needed.
+ */
+async function cacheFirst(request) {
+ const cache = await caches.open(CACHE);
+ const hit = await cache.match(request);
+ if (hit) return hit;
+ const resp = await fetch(request);
+ // Only whole 200 responses are cacheable: Cache.put rejects 206 (Partial
+ // Content), which is how the browser streams the intro/landing videos.
+ if (resp.status === 200) cache.put(request, resp.clone());
+ return resp;
+}
+
+/**
+ * Network-first for navigations (the HTML shell): always try the network so a
+ * connected boot gets the newest document; fall back to the last-seen shell
+ * only when offline.
+ */
+async function networkFirst(request) {
+ const cache = await caches.open(CACHE);
+ try {
+ const resp = await fetch(request);
+ if (resp.status === 200) cache.put('/', resp.clone());
+ return resp;
+ } catch (err) {
+ const cached = (await cache.match(request)) || (await cache.match('/'));
+ if (cached) return cached;
+ throw err;
+ }
+}
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ // Only handle same-origin GETs. Skip Range requests (media streaming) so the
+ // browser owns 206 partial responses end to end.
+ if (request.method !== 'GET' || request.headers.has('range')) return;
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return;
+
+ if (request.mode === 'navigate') {
+ event.respondWith(networkFirst(request));
+ return;
+ }
+ if (url.pathname.startsWith('/static/')) {
+ event.respondWith(cacheFirst(request));
+ }
+ // Everything else (API calls, /ws upgrade, /sw.js itself) falls through to
+ // the network untouched.
+});