Stamp asset URLs with their content hash

index.html is revalidated on every load, but the assets it names are not
under our control once they leave the origin: Cloudflare rewrites our
`Cache-Control: no-cache` on /static/* to `max-age=14400`, so a phone --
an iOS home-screen app above all, which keeps running whatever it has --
can execute four-hour-old JavaScript after a deploy.

The URLs now carry the file's content hash (static/js/main.js?v=<hash>),
reusing the manifest /api/version already computes, so every deploy asks
for URLs no cache can answer from an old copy. index.html itself gets an
explicit no-cache, must-revalidate.

Verified: served HTML carries per-file hashes, a changed file yields a
new URL, the app boots clean, and the refresh button's update path still
hot-swaps CSS and reloads for JS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-05 16:02:11 +00:00
parent 6631447acc
commit 0f7e27fd77

View File

@@ -381,10 +381,38 @@ def image_proxy():
except Exception as e:
return jsonify({"error": str(e)}), 500
# Captures the path *relative to the frontend dir* (the manifest's key), since
# the served URL carries an extra `static/` prefix.
_ASSET_REF_RE = re.compile(r'(src|href)="static/((?:js|css)/[^"?#]+)"')
@app.route('/')
def index():
"""Serve index.html with each local asset URL stamped with its content hash.
index.html itself is always revalidated, but the assets it names are not
under our control once a CDN or a phone has them: Cloudflare rewrites our
`no-cache` to `max-age=14400`, and an iOS home-screen app will happily run
four-hour-old JavaScript. A content hash in the query gives every deploy new
URLs, which no cache can satisfy from an old copy -- so a reload always
lands on the build that's actually deployed."""
hashes = _version_payload().get('files', {})
def stamp(match):
attr, rel = match.group(1), match.group(2)
digest = hashes.get(rel)
return f'{attr}="static/{rel}?v={digest}"' if digest else match.group(0)
try:
with open(os.path.join(_FRONTEND_DIR, 'index.html'), encoding='utf-8') as fh:
html = _ASSET_REF_RE.sub(stamp, fh.read())
except OSError:
return send_from_directory(app.static_folder, 'index.html')
resp = Response(html, mimetype='text/html')
resp.headers['Cache-Control'] = 'no-cache, must-revalidate'
return resp
@app.route('/favicon.ico')
def favicon():
return send_from_directory(app.static_folder, 'favicon.ico')
@@ -428,8 +456,7 @@ def _compute_version_payload(files):
return {'version': combined.hexdigest(), 'files': file_hashes}
@app.route('/api/version', methods=['GET'])
def frontend_version():
def _version_payload():
files = _scan_frontend_files()
# Use the newest mtime across tracked files as a cheap cache key so frequent
# polls only re-hash contents when something on disk actually changed.
@@ -441,8 +468,12 @@ def frontend_version():
if _version_cache['mtime'] != latest_mtime or _version_cache['payload'] is None:
_version_cache['payload'] = _compute_version_payload(files)
_version_cache['mtime'] = latest_mtime
payload = _version_cache['payload']
resp = jsonify(payload)
return _version_cache['payload']
@app.route('/api/version', methods=['GET'])
def frontend_version():
resp = jsonify(_version_payload())
resp.headers['Cache-Control'] = 'no-store'
return resp