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. +});