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>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user