7d1649d278
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>
84 lines
2.4 KiB
Python
84 lines
2.4 KiB
Python
import hashlib
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, request
|
|
|
|
from app.config import LOG_LEVEL
|
|
|
|
logging.basicConfig(
|
|
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
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
|