feat: cache-control overhaul so visual changes propagate immediately
CI / Lint (push) Successful in 14s
CI / Test (push) Successful in 12s
CI / Build & Push (push) Successful in 32s

Per-file content-hash versioning on every /static reference, immutable cache
headers on versioned URLs, no-cache on HTML, auto-bumped service worker cache
name with stale-while-revalidate for assets, and a controllerchange listener
that silently reloads the page when a new SW takes control.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 20:11:36 -04:00
parent aaa0899506
commit 7d1649d278
7 changed files with 110 additions and 27 deletions
+71 -1
View File
@@ -1,5 +1,10 @@
import hashlib
import logging import logging
from flask import Flask import time
from pathlib import Path
from flask import Flask, request
from app.config import LOG_LEVEL from app.config import LOG_LEVEL
logging.basicConfig( logging.basicConfig(
@@ -10,4 +15,69 @@ logging.basicConfig(
app = Flask(__name__) app = Flask(__name__)
# ── Static asset versioning ────────────────────────────────────────
# Each /static/<file> reference gets a ?v=<hash> 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 from app import routes # noqa: E402, F401
+13 -3
View File
@@ -1,8 +1,8 @@
import json 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.config import SCOREBOARD_DATA_FILE
from app.games import parse_games from app.games import parse_games
from app.playoff import today_meta from app.playoff import today_meta
@@ -39,7 +39,17 @@ def manifest():
@app.route("/sw.js") @app.route("/sw.js")
def service_worker(): 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["Service-Worker-Allowed"] = "/"
response.headers["Cache-Control"] = "no-cache" response.headers["Cache-Control"] = "no-cache"
return response return response
+6
View File
@@ -417,5 +417,11 @@ window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(err => { navigator.serviceWorker.register('/sw.js').catch(err => {
console.warn('Service worker registration failed:', err); console.warn('Service worker registration failed:', err);
}); });
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return;
reloading = true;
location.reload();
});
} }
}); });
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a"> <meta name="theme-color" content="#0f172a">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/static/icon-32x32.png"> <link rel="icon" type="image/png" href="{{ static_v('icon-32x32.png') }}">
<link rel="stylesheet" type="text/css" href="/static/styles.css"> <link rel="stylesheet" type="text/css" href="{{ static_v('styles.css') }}">
</head> </head>
<body class="playoff-mode bracket-mode"> <body class="playoff-mode bracket-mode">
<header> <header>
+4 -4
View File
@@ -8,9 +8,9 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NHL Scores"> <meta name="apple-mobile-web-app-title" content="NHL Scores">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/static/icon-32x32.png"> <link rel="icon" type="image/png" href="{{ static_v('icon-32x32.png') }}">
<link rel="apple-touch-icon" href="/static/icon-180x180.png"> <link rel="apple-touch-icon" href="{{ static_v('icon-180x180.png') }}">
<link rel="stylesheet" type="text/css" href="/static/styles.css"> <link rel="stylesheet" type="text/css" href="{{ static_v('styles.css') }}">
</head> </head>
<body> <body>
<header> <header>
@@ -67,6 +67,6 @@
<div id="final-games-section" class="games-grid"></div> <div id="final-games-section" class="games-grid"></div>
</section> </section>
</main> </main>
<script src="/static/script.js"></script> <script src="{{ static_v('script.js') }}"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a"> <meta name="theme-color" content="#0f172a">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/static/icon-32x32.png"> <link rel="icon" type="image/png" href="{{ static_v('icon-32x32.png') }}">
<link rel="stylesheet" type="text/css" href="/static/styles.css"> <link rel="stylesheet" type="text/css" href="{{ static_v('styles.css') }}">
</head> </head>
<body class="playoff-mode series-mode"> <body class="playoff-mode series-mode">
<header class="series-header"> <header class="series-header">
+12 -15
View File
@@ -1,12 +1,5 @@
const CACHE = 'nhl-scoreboard-v3'; const CACHE = 'nhl-scoreboard-{{ app_version }}';
const PRECACHE = [ const PRECACHE = {{ precache | tojson }};
'/',
'/static/styles.css',
'/static/script.js',
'/static/icon-192x192.png',
'/static/icon-512x512.png',
'/manifest.json',
];
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE))); event.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
@@ -23,6 +16,7 @@ self.addEventListener('activate', event => {
}); });
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const { pathname } = new URL(event.request.url); const { pathname } = new URL(event.request.url);
// Network-first for the live scoreboard API — stale data is useless // Network-first for the live scoreboard API — stale data is useless
@@ -33,8 +27,9 @@ self.addEventListener('fetch', event => {
return; return;
} }
// Network-first for bracket + series detail pages; fall back to cache offline // Network-first for HTML pages (root, bracket, series detail) so the
if (pathname === '/bracket' || pathname.startsWith('/series/')) { // very next request after a deploy lands the new asset URLs
if (pathname === '/' || pathname === '/bracket' || pathname.startsWith('/series/')) {
event.respondWith( event.respondWith(
fetch(event.request).then(response => { fetch(event.request).then(response => {
if (response.ok) { if (response.ok) {
@@ -47,17 +42,19 @@ self.addEventListener('fetch', event => {
return; 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( event.respondWith(
caches.match(event.request).then(cached => { caches.match(event.request).then(cached => {
if (cached) return cached; const networkFetch = fetch(event.request).then(response => {
return fetch(event.request).then(response => {
if (response.ok) { if (response.ok) {
const clone = response.clone(); const clone = response.clone();
caches.open(CACHE).then(c => c.put(event.request, clone)); caches.open(CACHE).then(c => c.put(event.request, clone));
} }
return response; return response;
}); }).catch(() => cached);
return cached || networkFetch;
}) })
); );
}); });