Files
Nexus/deploy/prune_frontend_assets.py
T

150 lines
4.6 KiB
Python
Raw Normal View History

#!/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())