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:
@@ -381,9 +381,37 @@ def image_proxy():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
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('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return send_from_directory(app.static_folder, 'index.html')
|
"""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')
|
@app.route('/favicon.ico')
|
||||||
def favicon():
|
def favicon():
|
||||||
@@ -428,8 +456,7 @@ def _compute_version_payload(files):
|
|||||||
return {'version': combined.hexdigest(), 'files': file_hashes}
|
return {'version': combined.hexdigest(), 'files': file_hashes}
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/version', methods=['GET'])
|
def _version_payload():
|
||||||
def frontend_version():
|
|
||||||
files = _scan_frontend_files()
|
files = _scan_frontend_files()
|
||||||
# Use the newest mtime across tracked files as a cheap cache key so frequent
|
# 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.
|
# 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:
|
if _version_cache['mtime'] != latest_mtime or _version_cache['payload'] is None:
|
||||||
_version_cache['payload'] = _compute_version_payload(files)
|
_version_cache['payload'] = _compute_version_payload(files)
|
||||||
_version_cache['mtime'] = latest_mtime
|
_version_cache['mtime'] = latest_mtime
|
||||||
payload = _version_cache['payload']
|
return _version_cache['payload']
|
||||||
resp = jsonify(payload)
|
|
||||||
|
|
||||||
|
@app.route('/api/version', methods=['GET'])
|
||||||
|
def frontend_version():
|
||||||
|
resp = jsonify(_version_payload())
|
||||||
resp.headers['Cache-Control'] = 'no-store'
|
resp.headers['Cache-Control'] = 'no-store'
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user