"""Build the fully-static deployable for Cloudflare (Pages + R2). Emits the Pages output tree and a separate R2 media sync tree: out_dir/ _redirects apex + no-slash → // (308) / the app (served at benstull.art//) index.html app.js scrub.js i18n.js alteration.js style.css config.js GENERATED: { mediaBase, static: true } clips.json ring.json media-versions.json baked API responses media_out/ R2 upload tree — ONLY manifest-referenced files The app lives under a path segment (so apex can redirect to it) and uses RELATIVE asset urls, which resolve correctly under the `//` prefix. The baked JSON is produced through the real app (TestClient) so it is byte-identical to the API. The media tree contains ONLY referenced files — never the master/mezzanine pipeline sources. Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in deploy/cloudflare/ — this script only produces artifacts. Run as a MODULE so the repo root (not tools/) is on sys.path — otherwise the sibling tools/http.py shadows the stdlib `http` that fastapi/starlette import: .venv/bin/python -m tools.build_static """ from __future__ import annotations import hashlib import json import shutil from pathlib import Path from fastapi.testclient import TestClient from simulator.app import MEDIA_DIR, create_app STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static" # Frontend files that ship; everything else in static/ (author*, review*) is dev-only. PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css", "credits.html", "credits.js"] def _bake_api(app_dir: Path) -> dict: app = create_app() client = TestClient(app) clips = client.get("/api/clips").json() ring = client.get("/api/ring").json() versions = client.get("/api/media-versions").json() (app_dir / "clips.json").write_text(json.dumps(clips, ensure_ascii=False)) (app_dir / "ring.json").write_text(json.dumps(ring, ensure_ascii=False)) (app_dir / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False)) return versions["versions"] def _write_config(app_dir: Path, media_base: str) -> None: (app_dir / "config.js").write_text( "// GENERATED by tools/build_static.py — do not edit in dist/.\n" f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n' ) def _write_redirects(out: Path, seg: str) -> None: # Apex → app path, and the no-trailing-slash form → slashed (so RELATIVE asset # urls resolve under the prefix). Cloudflare Pages `_redirects` syntax. (out / "_redirects").write_text( f"/ /{seg}/ 308\n" f"/{seg} /{seg}/ 308\n" ) 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) seg = app_path.strip("/") app_dir = out / seg for d in (out, media): if d.exists(): shutil.rmtree(d) app_dir.mkdir(parents=True) media.mkdir(parents=True) for name in PUBLIC_ASSETS: src = STATIC / name if src.exists(): shutil.copy2(src, app_dir / name) 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. referenced = set(versions.keys()) ring = json.loads((app_dir / "ring.json").read_text()) for s in ring.get("scales", []): if s.get("audio"): referenced.add(f"audio/{s['audio']}") n = 0 for rel in sorted(referenced): src = MEDIA_DIR / rel if not src.exists(): raise FileNotFoundError(f"referenced media missing on disk: {rel}") dst = media / rel dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) n += 1 return {"out": str(out), "app_dir": str(app_dir), "media": str(media), "media_files": n, "app_path": app_path} if __name__ == "__main__": import argparse ap = argparse.ArgumentParser() ap.add_argument("--out", default="dist") ap.add_argument("--media-out", default="dist-media") ap.add_argument("--media-base", default="https://static.benstull.art/") ap.add_argument("--app-path", default="/human-experience-simulator") a = ap.parse_args() r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path) print(json.dumps(r, indent=2))