From 0f7e27fd77d6563bf4c971b8163f4944540789ea Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 5 Sep 2026 16:02:11 +0000 Subject: [PATCH] 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=), 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 Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd --- backend/main.py | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/backend/main.py b/backend/main.py index b5efc36..bebbe42 100644 --- a/backend/main.py +++ b/backend/main.py @@ -381,9 +381,37 @@ 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(): - 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') def favicon(): @@ -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