8c63b14967
Cloudflare Pages let the /seg/* no-cache rule win over the per-HTML no-store, so the index was still served no-cache. no-store the whole app path instead — the shell is tiny and media is on R2 (separate, immutable). Guarantees a fresh index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
165 lines
6.8 KiB
Python
165 lines
6.8 KiB
Python
"""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 → /<app>/ (308)
|
|
<app>/ the app (served at benstull.art/<app>/)
|
|
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
|
|
<clip base.mp4 / transition morphs / audio>
|
|
|
|
The app lives under a path segment (so apex can redirect to it) and uses RELATIVE
|
|
asset urls, which resolve correctly under the `/<app>/` 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", "preload.js",
|
|
"style.css", "credits.html", "credits.js", "about.html", "flash.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 must NOT be browser-cached — otherwise a deploy is invisible to
|
|
# returning visitors for hours (Pages' default is max-age=14400). `no-store`
|
|
# (never stored → a guaranteed-fresh fetch every visit) for the whole app path:
|
|
# `no-cache` (revalidate) was NOT enough — Safari served a STALE index from its
|
|
# disk/bfcache, loaded the OLD versioned JS, and broke the page (black, silent).
|
|
# A per-HTML no-store override didn't stick (Pages let the `/seg/*` rule win), so
|
|
# we no-store the whole path. The app shell is tiny (~150 KB); the media is on R2
|
|
# (immutable + content-hash, a separate domain) and is unaffected. The content-
|
|
# hashed asset URLs (see _version_assets) remain as belt-and-suspenders for the
|
|
# case where Pages caches .js/.css at the edge by type and ignores _headers.
|
|
# Cloudflare Pages `_headers` syntax.
|
|
(out / "_headers").write_text(
|
|
f"/{seg}/*\n"
|
|
" Cache-Control: no-store\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", "preload.js", "flash.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))
|