#!/usr/bin/env python3 """Remove orphaned files under web/app/assets/ not referenced by current Vite build. Vite/Rolldown minified bundles reference chunks by bare filename (e.g. ServersPage-xxx.js) without import() paths — scan file contents for known asset basenames. Orphans newer than --retention-days are kept so long-lived browser tabs can still load their lazy chunks after a deploy (see docs/design/specs/2026-06-02-keep-session-no-refresh-design.md). """ from __future__ import annotations import re import sys import time from pathlib import Path # Leading `_` required for Vite chunks like `_plugin-vue_export-helper-*.js` ASSET_NAME_RE = re.compile( r"[_A-Za-z0-9][A-Za-z0-9_.-]*\.(?:js|css|woff2?|ttf|eot)" ) URL_RE = re.compile(r"""url\(["']?([^"')]+)["']?\)""") DEFAULT_RETENTION_DAYS = 14 def _parse_args(argv: list[str]) -> tuple[bool, int, Path]: dry_run = "--dry-run" in argv retention_days = DEFAULT_RETENTION_DAYS positional: list[str] = [] i = 0 while i < len(argv): arg = argv[i] if arg == "--dry-run": i += 1 continue if arg.startswith("--retention-days="): retention_days = int(arg.split("=", 1)[1]) i += 1 continue if arg == "--retention-days": if i + 1 >= len(argv): raise SystemExit("--retention-days requires a value") retention_days = int(argv[i + 1]) i += 2 continue positional.append(arg) i += 1 if retention_days < 0: raise SystemExit("--retention-days must be >= 0") app_dir = Path(positional[0] if positional else "/www/wwwroot/api.synaglobal.vip/web/app") return dry_run, retention_days, app_dir def _referenced_basenames(text: str, known: set[str]) -> set[str]: found: set[str] = set() for m in ASSET_NAME_RE.finditer(text): name = m.group(0) if name in known: found.add(name) for raw in URL_RE.findall(text): name = Path(raw).name if name in known: found.add(name) return found def collect(app_dir: Path) -> set[Path]: assets = app_dir / "assets" index = app_dir / "index.html" if not index.is_file(): raise SystemExit(f"missing {index}") all_names = {p.name for p in assets.iterdir() if p.is_file()} keep_names: set[str] = set() for m in re.finditer(r"/app/assets/([^\s\"']+)", index.read_text(encoding="utf-8")): name = m.group(1).split("/")[-1] if name in all_names: keep_names.add(name) if not keep_names: raise SystemExit("no entry assets found in index.html") changed = True while changed: changed = False for name in list(keep_names): path = assets / name if not path.is_file(): continue text = path.read_text(encoding="utf-8", errors="ignore") for ref in _referenced_basenames(text, all_names): if ref not in keep_names: keep_names.add(ref) changed = True return {assets / n for n in keep_names} def _partition_orphans( orphan: list[Path], retention_days: int, ) -> tuple[list[Path], list[Path]]: """Split orphans into delete-now vs keep-for-open-tabs (mtime within window).""" if retention_days <= 0: return orphan, [] cutoff = time.time() - retention_days * 86400 to_remove: list[Path] = [] retained: list[Path] = [] for p in orphan: if p.stat().st_mtime >= cutoff: retained.append(p) else: to_remove.append(p) return to_remove, retained def main() -> int: dry_run, retention_days, app_dir = _parse_args(sys.argv[1:]) assets = app_dir / "assets" if not assets.is_dir(): raise SystemExit(f"not a directory: {assets}") keep = collect(app_dir) all_files = {p for p in assets.iterdir() if p.is_file()} orphan = sorted(all_files - keep, key=lambda p: p.name) to_remove, retained = _partition_orphans(orphan, retention_days) if dry_run: print( f"dry-run retention_days={retention_days} " f"referenced_kept={len(keep)} " f"would_remove={len(to_remove)} " f"retained_orphans={len(retained)} " f"total={len(all_files)}" ) return 0 for p in to_remove: p.unlink() print( f"kept={len(keep)} removed={len(to_remove)} " f"retained_orphans={len(retained)} retention_days={retention_days} " f"total_before={len(all_files)}" ) return 0 if __name__ == "__main__": raise SystemExit(main())