Files
Frank Schwenk 7cec3030c1 feat: PWA update strategy with hashed assets and cache control
Move frontend sources to frontend/, build content-hashed assets at deploy,
network-first SW for shell/JS, immutable cache for /assets/, version.json
update banner, and Cache-Control middleware. Docs in docs/PWA-STRATEGY.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 08:51:12 +02:00

181 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Build frontend assets with content hashes for cache-immutable PWA deploy."""
from __future__ import annotations
import hashlib
import json
import re
import shutil
import tomllib
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
PUBLIC = ROOT / "public"
ASSETS_DIR = PUBLIC / "assets"
HASH_LEN = 8
IMPORT_RE = re.compile(r'''from\s+["'](\./(?:lib/)?[^"']+\.js)["']''')
STATIC_COPY = ("manifest.json",)
STATIC_ICONS = (
"icon.png",
"icon-72x72.png",
"icon-192x192.png",
"icon-512x512.png",
)
def short_hash(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()[:HASH_LEN]
def js_sources() -> list[tuple[str, str]]:
sources: list[tuple[str, str]] = [("app.js", "app")]
lib_dir = FRONTEND / "lib"
if lib_dir.is_dir():
for path in sorted(lib_dir.glob("*.js")):
sources.append((f"lib/{path.name}", path.stem))
return sources
def read_version() -> str:
pyproject = ROOT / "pyproject.toml"
with pyproject.open("rb") as fh:
data = tomllib.load(fh)
return str(data["project"]["version"])
def rewrite_imports(source: str, stem_to_filename: dict[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
import_path = match.group(1)
stem = Path(import_path).stem
hashed = stem_to_filename.get(stem)
if not hashed:
raise ValueError(f"Unknown import stem {stem!r} in {import_path!r}")
quote = match.group(0).split("from", 1)[1].strip()[0]
return f'from {quote}./{hashed}{quote}'
return IMPORT_RE.sub(replace, source)
def clean_assets_dir() -> None:
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
for path in ASSETS_DIR.iterdir():
if path.name == ".gitkeep":
continue
if path.is_file():
path.unlink()
def copy_static_files() -> list[str]:
copied: list[str] = []
for name in STATIC_COPY:
src = FRONTEND / name
if not src.is_file():
raise FileNotFoundError(f"Missing frontend source: {src}")
shutil.copy2(src, PUBLIC / name)
copied.append(f"/{name}")
for name in STATIC_ICONS:
src = FRONTEND / name
if src.is_file():
shutil.copy2(src, PUBLIC / name)
copied.append(f"/{name}")
return copied
def build() -> dict[str, object]:
if not FRONTEND.is_dir():
raise FileNotFoundError(f"Frontend source directory not found: {FRONTEND}")
PUBLIC.mkdir(parents=True, exist_ok=True)
clean_assets_dir()
hashed_assets: dict[str, str] = {}
stem_to_filename: dict[str, str] = {}
file_hashes: list[str] = []
styles_src = FRONTEND / "styles.css"
if not styles_src.is_file():
raise FileNotFoundError(f"Missing frontend source: {styles_src}")
styles_bytes = styles_src.read_bytes()
styles_hash = short_hash(styles_bytes)
styles_filename = f"styles.{styles_hash}.css"
(ASSETS_DIR / styles_filename).write_bytes(styles_bytes)
hashed_assets["styles"] = f"/assets/{styles_filename}"
file_hashes.append(styles_hash)
for rel_path, stem in js_sources():
src = FRONTEND / rel_path
if not src.is_file():
raise FileNotFoundError(f"Missing frontend source: {src}")
content = src.read_text(encoding="utf-8")
content_hash = short_hash(content.encode("utf-8"))
filename = f"{stem}.{content_hash}.js"
stem_to_filename[stem] = filename
file_hashes.append(content_hash)
for rel_path, stem in js_sources():
src = FRONTEND / rel_path
content = rewrite_imports(src.read_text(encoding="utf-8"), stem_to_filename)
filename = stem_to_filename[stem]
(ASSETS_DIR / filename).write_text(content, encoding="utf-8")
hashed_assets[stem] = f"/assets/{filename}"
build_hash = short_hash("".join(file_hashes).encode("utf-8"))
version = read_version()
build_time = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
cache_version = f"tym-v{version}-{build_hash}"
static_paths = copy_static_files()
asset_paths = sorted(hashed_assets.values())
sw_assets = ["/", "/index.html", *asset_paths, *static_paths, "/version.json"]
index_template = (FRONTEND / "index.template.html").read_text(encoding="utf-8")
index_html = (
index_template.replace("{{STYLES_HREF}}", hashed_assets["styles"])
.replace("{{APP_SCRIPT_SRC}}", hashed_assets["app"])
.replace("{{BUILD_HASH}}", build_hash)
)
(PUBLIC / "index.html").write_text(index_html, encoding="utf-8")
sw_template = (FRONTEND / "sw.template.js").read_text(encoding="utf-8")
sw_js = (
sw_template.replace("{{CACHE_VERSION}}", cache_version)
.replace("{{ASSETS_JSON}}", json.dumps(sw_assets, indent=2))
)
(PUBLIC / "sw.js").write_text(sw_js, encoding="utf-8")
version_json = {
"version": version,
"buildTime": build_time,
"buildHash": build_hash,
}
(PUBLIC / "version.json").write_text(
json.dumps(version_json, indent=2) + "\n",
encoding="utf-8",
)
return {
"version": version,
"buildHash": build_hash,
"cacheVersion": cache_version,
"assets": hashed_assets,
"static": static_paths,
}
def main() -> None:
result = build()
print(f"Built frontend v{result['version']} ({result['buildHash']})")
print(f"Cache: {result['cacheVersion']}")
for stem, url in sorted(result["assets"].items()):
print(f" {stem}: {url}")
if __name__ == "__main__":
main()