7cec3030c1
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>
181 lines
5.6 KiB
Python
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()
|