diff --git a/app/__init__.py b/app/__init__.py index 665cde7..06ccb9f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,10 @@ +import hashlib import logging -from flask import Flask +import time +from pathlib import Path + +from flask import Flask, request + from app.config import LOG_LEVEL logging.basicConfig( @@ -10,4 +15,69 @@ logging.basicConfig( app = Flask(__name__) + +# ── Static asset versioning ──────────────────────────────────────── +# Each /static/ reference gets a ?v= query string so we can serve +# it with `Cache-Control: immutable` and still bust the cache when bytes change. +_FALLBACK_TOKEN = hashlib.sha1(str(time.time()).encode()).hexdigest()[:8] +_static_hashes: dict[str, str] = {} + + +def _hash_file(path: Path) -> str: + h = hashlib.sha1() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + h.update(chunk) + return h.hexdigest()[:8] + + +def static_hash(filename: str) -> str: + if filename in _static_hashes: + return _static_hashes[filename] + path = Path(app.static_folder) / filename + try: + token = _hash_file(path) + except OSError: + logging.getLogger(__name__).warning( + "static_hash: cannot hash %s, using fallback token", filename + ) + return _FALLBACK_TOKEN + _static_hashes[filename] = token + return token + + +def static_v(filename: str) -> str: + return f"/static/{filename}?v={static_hash(filename)}" + + +_static_dir = Path(app.static_folder) +if _static_dir.is_dir(): + for _path in sorted(_static_dir.iterdir()): + if _path.is_file(): + try: + _static_hashes[_path.name] = _hash_file(_path) + except OSError: + continue + +APP_VERSION = ( + hashlib.sha1( + "|".join(f"{n}:{h}" for n, h in sorted(_static_hashes.items())).encode() + ).hexdigest()[:8] + if _static_hashes + else _FALLBACK_TOKEN +) + +app.jinja_env.globals["static_v"] = static_v + + +@app.after_request +def _add_cache_headers(response): + if response.mimetype == "text/html": + response.headers["Cache-Control"] = "no-cache, must-revalidate" + return response + if request.path.startswith("/static/") and request.args.get("v"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + from app import routes # noqa: E402, F401 diff --git a/app/routes.py b/app/routes.py index 7c0faa5..65fc4cf 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,8 +1,8 @@ import json -from flask import abort, render_template, jsonify, send_from_directory +from flask import abort, make_response, render_template, jsonify, send_from_directory -from app import app +from app import APP_VERSION, app, static_v from app.config import SCOREBOARD_DATA_FILE from app.games import parse_games from app.playoff import today_meta @@ -39,7 +39,17 @@ def manifest(): @app.route("/sw.js") def service_worker(): - response = send_from_directory(app.static_folder, "sw.js") + precache = [ + "/", + static_v("styles.css"), + static_v("script.js"), + static_v("icon-192x192.png"), + static_v("icon-512x512.png"), + "/manifest.json", + ] + body = render_template("sw.js.j2", app_version=APP_VERSION, precache=precache) + response = make_response(body) + response.headers["Content-Type"] = "application/javascript; charset=utf-8" response.headers["Service-Worker-Allowed"] = "/" response.headers["Cache-Control"] = "no-cache" return response diff --git a/app/static/script.js b/app/static/script.js index 468719d..70cbff2 100644 --- a/app/static/script.js +++ b/app/static/script.js @@ -417,5 +417,11 @@ window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(err => { console.warn('Service worker registration failed:', err); }); + let reloading = false; + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (reloading) return; + reloading = true; + location.reload(); + }); } }); diff --git a/app/templates/bracket.html b/app/templates/bracket.html index f2eee7d..4cf2386 100644 --- a/app/templates/bracket.html +++ b/app/templates/bracket.html @@ -5,8 +5,8 @@ - - + +
diff --git a/app/templates/index.html b/app/templates/index.html index 8bb54f7..861e2b9 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -8,9 +8,9 @@ - - - + + +
@@ -67,6 +67,6 @@
- + diff --git a/app/templates/series.html b/app/templates/series.html index e37a6c6..a59762e 100644 --- a/app/templates/series.html +++ b/app/templates/series.html @@ -5,8 +5,8 @@ - - + +
diff --git a/app/static/sw.js b/app/templates/sw.js.j2 similarity index 66% rename from app/static/sw.js rename to app/templates/sw.js.j2 index b658b1c..64431f2 100644 --- a/app/static/sw.js +++ b/app/templates/sw.js.j2 @@ -1,12 +1,5 @@ -const CACHE = 'nhl-scoreboard-v3'; -const PRECACHE = [ - '/', - '/static/styles.css', - '/static/script.js', - '/static/icon-192x192.png', - '/static/icon-512x512.png', - '/manifest.json', -]; +const CACHE = 'nhl-scoreboard-{{ app_version }}'; +const PRECACHE = {{ precache | tojson }}; self.addEventListener('install', event => { event.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE))); @@ -23,6 +16,7 @@ self.addEventListener('activate', event => { }); self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') return; const { pathname } = new URL(event.request.url); // Network-first for the live scoreboard API — stale data is useless @@ -33,8 +27,9 @@ self.addEventListener('fetch', event => { return; } - // Network-first for bracket + series detail pages; fall back to cache offline - if (pathname === '/bracket' || pathname.startsWith('/series/')) { + // Network-first for HTML pages (root, bracket, series detail) so the + // very next request after a deploy lands the new asset URLs + if (pathname === '/' || pathname === '/bracket' || pathname.startsWith('/series/')) { event.respondWith( fetch(event.request).then(response => { if (response.ok) { @@ -47,17 +42,19 @@ self.addEventListener('fetch', event => { return; } - // Cache-first for everything else (static assets, shell) + // Stale-while-revalidate for everything else (versioned static assets, + // manifest, icons): return cached bytes immediately, refresh in the + // background so the next load is current event.respondWith( caches.match(event.request).then(cached => { - if (cached) return cached; - return fetch(event.request).then(response => { + const networkFetch = fetch(event.request).then(response => { if (response.ok) { const clone = response.clone(); caches.open(CACHE).then(c => c.put(event.request, clone)); } return response; - }); + }).catch(() => cached); + return cached || networkFetch; }) ); });