From b9a52b1c413274dcb4ed54c0bf32551163f50abf Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 09:31:10 -0700 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20bust=20the=20app-shell=20cache?= =?UTF-8?q?=20=E2=80=94=20no-cache=20=5Fheaders=20+=20content-versioned=20?= =?UTF-8?q?asset=20urls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages caches .js/.css by type (max-age=14400) and ignores _headers for them, so returning visitors ran stale JS (no-video/no-audio until a private window). index.html is no-cache and now references app.js?v= etc., so a normal reload always gets fresh code; media on R2 stays immutable. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/build_static.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tools/build_static.py b/tools/build_static.py index 5cbfb66..5edbd9e 100644 --- a/tools/build_static.py +++ b/tools/build_static.py @@ -25,6 +25,7 @@ sibling tools/http.py shadows the stdlib `http` that fastapi/starlette import: """ from __future__ import annotations +import hashlib import json import shutil from pathlib import Path @@ -67,6 +68,38 @@ def _write_redirects(out: Path, seg: str) -> None: ) +def _write_headers(out: Path, seg: str) -> None: + # The app shell (html/js/json) must NOT be browser-cached — otherwise a deploy is + # invisible to returning visitors for hours (Pages' default is max-age=14400). + # `no-cache` = store but revalidate every load (cheap 304 via etag). The media is + # on R2 (immutable + content-hash), unaffected. Cloudflare Pages `_headers` syntax. + (out / "_headers").write_text( + f"/{seg}/*\n" + " Cache-Control: no-cache\n" + ) + + +def _version_assets(app_dir: Path) -> None: + # Content-version the app-shell asset URLs in the HTML so a new build = a new URL + # = guaranteed-fresh fetch on a normal reload. The HTML itself is served no-cache + # (_headers), but Cloudflare Pages caches .js/.css by type (max-age=14400) and + # ignores _headers for them — so without this, returning visitors run stale JS. + assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "style.css", "credits.js", "config.js"] + tok = {} + for a in assets: + p = app_dir / a + if p.exists(): + tok[a] = hashlib.sha1(p.read_bytes()).hexdigest()[:12] + for html in ("index.html", "credits.html"): + hp = app_dir / html + if not hp.exists(): + continue + s = hp.read_text() + for a, h in tok.items(): + s = s.replace(f'src="{a}"', f'src="{a}?v={h}"').replace(f'href="{a}"', f'href="{a}?v={h}"') + hp.write_text(s) + + def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict: out = Path(out_dir) media = Path(media_out) @@ -85,7 +118,9 @@ def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict: versions = _bake_api(app_dir) _write_config(app_dir, media_base) + _version_assets(app_dir) # after config.js exists, so it gets hashed too _write_redirects(out, seg) + _write_headers(out, seg) # Sync ONLY referenced media: `versions` covers clip bases + transition morphs; # add each scale's audio explicitly. Never the master/mezzanine pipeline sources.