Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 49 additions & 3 deletions server/assets.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import hashlib
import os
import re
from pathlib import Path
from string import Template
Expand Down Expand Up @@ -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 <meta name="app-build"> 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 + "/"
Expand Down Expand Up @@ -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 <meta name="app-build"> 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.
Expand All @@ -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()
Expand All @@ -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]]:
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions server/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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():
Expand Down
3 changes: 2 additions & 1 deletion server/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions static/css/update.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 5 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#1a0e08">
<!-- Build id of the frontend this page was served with. update.js compares it
against the server's build id (sent in the WS welcome frame) to notice
when the running PWA has gone stale and needs a reload. -->
<meta name="app-build" content="$build_id">
<title>$page_title</title>

<link rel="icon" type="image/png" sizes="32x32" href="/static/images/icon-32.png">
Expand Down Expand Up @@ -66,6 +70,7 @@
<link rel="stylesheet" href="/static/css/profile.css">
<link rel="stylesheet" href="/static/css/game-detail.css">
<link rel="stylesheet" href="/static/css/a2hs.css">
<link rel="stylesheet" href="/static/css/update.css">

<!-- Preload the whole module graph so it fetches in parallel instead of
waterfalling app -> router/net -> components. -->
Expand Down
2 changes: 2 additions & 0 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
4 changes: 3 additions & 1 deletion static/js/net.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions static/js/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <meta app-build> to detect a stale client.
*/

/**
Expand Down
Loading
Loading