"""Build the content-addressed browser shell served by the dashboard.""" from __future__ import annotations import gzip import hashlib import re from dataclasses import dataclass from pathlib import Path import rjsmin SCRIPT_TAG = re.compile(r'^$', re.MULTILINE) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" FEATURE_SOURCES = { "issue-capture": ("static/create-issue-sheet.js",), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), } CACHE_DECLARATION = re.compile( r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" ) @dataclass(frozen=True) class RuntimeBundle: runtime_bytes: bytes runtime_gzip_bytes: bytes runtime_digest: str runtime_name: str @dataclass(frozen=True) class FrontendBuild: dashboard_html: str runtime_bytes: bytes runtime_gzip_bytes: bytes runtime_digest: str runtime_name: str shell_digest: str page_sources: tuple[str, ...] feature_bundles: dict[str, RuntimeBundle] service_worker_source: str def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes: chunks = [] for source in sources: path = frontend_dir / source.removeprefix("static/") chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n") source = b"".join(chunks) revision = hashlib.sha256(source).hexdigest() minified = rjsmin.jsmin(source.decode()).encode() return minified + f';"source-sha256:{revision}";'.encode() def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle: content = _bundle(frontend_dir, sources) digest = hashlib.sha256(content).hexdigest()[:16] return RuntimeBundle( runtime_bytes=content, runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0), runtime_digest=digest, runtime_name=f"{prefix}-{digest}.js", ) def build_frontend(frontend_dir: Path) -> FrontendBuild: """Return one internally consistent HTML, runtime, and offline-worker build.""" source_html = (frontend_dir / "index.html").read_text() sources = tuple(SCRIPT_TAG.findall(source_html)) if not sources: raise ValueError("dashboard entry document has no page scripts") feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group} core_sources = tuple(source for source in sources if source not in feature_source_names) core = _runtime(frontend_dir, core_sources, "runtime") feature_bundles = { name: _runtime(frontend_dir, feature_sources, f"feature-{name}") for name, feature_sources in FEATURE_SOURCES.items() } dashboard_html = SCRIPT_TAG.sub("", source_html) feature_metadata = "\n".join( f'' for name, bundle in feature_bundles.items() ) dashboard_html = dashboard_html.replace("", feature_metadata + "\n") dashboard_html = dashboard_html.replace( "", f'\n' ) worker = (frontend_dir / "service-worker.js").read_text() for source in sources: if source != WORKER_RUNTIME_SOURCE: worker = worker.replace(f" BASE + '{source}',\n", "") worker = worker.replace( " BASE + 'static/dashboard.css',\n", " BASE + 'static/dashboard.css',\n" + f" BASE + '{core.runtime_name}',\n" + "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()), ) worker = CACHE_DECLARATION.sub( "const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1 ) shell_hasher = hashlib.sha256() shell_inputs = ( ("dashboard.html", dashboard_html.encode()), (core.runtime_name, core.runtime_bytes), *((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()), ("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()), ("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()), ( "static/icons/stackchain-192.png", (frontend_dir / "icons/stackchain-192.png").read_bytes(), ), ( "static/icons/stackchain-512.png", (frontend_dir / "icons/stackchain-512.png").read_bytes(), ), ( WORKER_RUNTIME_SOURCE, ( frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/") ).read_bytes(), ), ("service-worker.js", worker.encode()), ) for name, content in shell_inputs: shell_hasher.update(len(name).to_bytes(4, "big")) shell_hasher.update(name.encode()) shell_hasher.update(len(content).to_bytes(8, "big")) shell_hasher.update(content) shell_digest = shell_hasher.hexdigest()[:16] worker = worker.replace( "const CACHE = 'stackchain-dashboard-shell-BUILD';", f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';", 1, ) return FrontendBuild( dashboard_html=dashboard_html, runtime_bytes=core.runtime_bytes, runtime_gzip_bytes=core.runtime_gzip_bytes, runtime_digest=core.runtime_digest, runtime_name=core.runtime_name, shell_digest=shell_digest, page_sources=sources, feature_bundles=feature_bundles, service_worker_source=worker, )