perf(ui): cache busting — HTML no-cache + versioned asset URLs (?v=) with immutable 1y asset caching

Phase 33 (story: .agent/user_stories/cache-busting.md).

- app/core/caching.py: asset_version() — git short SHA (a commit is a
  deploy), stable content-hash fallback for non-git checkouts, "dev"
  for a missing static dir; computed once per process. CachingMiddleware
  — the five HTML pages revalidate (no-cache) with ?v=<token> asset refs
  rewritten in flight; /assets/* is public, max-age=31536000, immutable;
  everything else (all /api/*, the SSE chat stream in particular) passes
  through byte-identical.
- tests/e2e/test_cache_busting.py: fresh-Chromium wire assertions —
  document no-cache, versioned CSS/JS request URLs sharing one token,
  immutable asset headers, /api/health baseline headers, SSE chat to
  done (mock LLM).
- README 'Caching / deploys' section + story file.

Also fixed two prod-image defects surfaced by this phase's podman smoke
(the full app would not boot):
- Containerfile: ship the scripts/ package — app/api/sync.py (phase 32)
  imports scripts.git_sync / scripts.import_docs at module level, so the
  container crashed on boot (ModuleNotFoundError: No module named
  'scripts').
- compose.yaml: pass BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET through to
  the app service (:- defaults keep 'podman compose up -d db' working;
  the app's own fail-loud gate still names missing admin auth).

Smoke: podman compose --profile prod up -d on a fresh image + a fresh
Chromium profile — /, /sources.html and /login.html all served
Cache-Control: no-cache; all 8 asset requests versioned with one shared
token (content-hash fallback inside the image — no .git there);
/assets/* immutable for a year.
This commit is contained in:
2026-08-25 22:42:10 -04:00
parent 52136fe307
commit 8fabb7efda
12 changed files with 988 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
"""Static-frontend cache busting (phase 33).
Two layers, one module:
* **Version token** (``asset_version()``) — the value the HTML pages
append to their asset URLs (``?v=<token>``).
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
— applies the caching behavior at the transport layer: the five known
HTML pages are always revalidated (``no-cache``) and their local asset
references are rewritten to carry ``?v=<token>``; ``/assets/*`` is
served ``immutable`` for a year; everything else — all of ``/api/*``,
including the SSE chat stream — passes through byte-identical.
The token is computed **once per process** (``functools.cache``, i.e.
``lru_cache(maxsize=None)``) — zero per-request git/file cost. It changes
when a new commit lands (git path) or the frontend tree's mtimes/sizes
change (fallback path); either way the change takes effect on the next
process start. Tests clear it with ``asset_version.cache_clear()``.
Version-token sources:
* **Git checkouts** (the homelab reality — the project root next to
``frontend/`` has a ``.git``): the token is ``git rev-parse --short HEAD``.
A commit is a deploy, so the token flips on every deploy.
* **Plain directories** (dev checkouts without a ``.git``, or a git failure):
a stable content hash — first 12 hex chars of SHA-256 over the sorted
``relpath:mtime_ns:size`` list of every regular file under the static dir.
Touching or editing a file (plus a process restart) busts the cache.
* **Missing/empty directory**: ``"dev"``.
"""
from __future__ import annotations
import functools
import hashlib
import logging
import re
import subprocess
from pathlib import Path
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from app.config import get_settings
logger = logging.getLogger("app")
#: Token for a missing/empty static dir (e.g. a build that ships the API
#: only — there is nothing on disk to version).
DEV_TOKEN = "dev"
#: Hard cap for the ``git rev-parse`` call — a deploy must not hang a boot.
_GIT_TIMEOUT_S = 5
def _static_files(static_dir: Path) -> list[str]:
"""Sorted ``relpath:mtime_ns:size`` entries for every regular file."""
entries: list[str] = []
for path in static_dir.rglob("*"):
if path.is_file():
st = path.stat()
rel = path.relative_to(static_dir).as_posix()
entries.append(f"{rel}:{st.st_mtime_ns}:{st.st_size}")
return sorted(entries)
def _git_short_sha(repo_root: Path) -> str:
"""``git -C <repo_root> rev-parse --short HEAD`` (raises on any failure)."""
proc = subprocess.run(
["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT_S,
check=True,
)
sha = proc.stdout.strip()
if not sha:
raise ValueError("git returned an empty short SHA")
return sha
@functools.cache # == lru_cache(maxsize=None): one token per process
def asset_version(static_dir: str | None = None) -> str:
"""Version token for the frontend's asset URLs (see module docstring).
``static_dir`` defaults to ``get_settings().static_dir`` (``frontend``);
the git repo root is taken to be its parent. The result is cached for
the process lifetime — clear it with ``asset_version.cache_clear()``.
"""
target = get_settings().static_dir if static_dir is None else static_dir
directory = Path(target).expanduser().resolve()
files = _static_files(directory) if directory.is_dir() else []
if not files:
return DEV_TOKEN
repo_root = directory.parent
if (repo_root / ".git").exists():
try:
return _git_short_sha(repo_root)
except (OSError, subprocess.SubprocessError, ValueError):
# git missing (OSError), timeout or non-zero exit
# (SubprocessError — e.g. a repo with no commits), or empty
# output → the stable content-hash fallback keeps dev checkouts
# (and broken repos) cache-busting instead of breaking a boot.
pass
digest = hashlib.sha256("\n".join(files).encode("utf-8")).hexdigest()
return digest[:12]
# ---------------------------------------------------------------------------
# Response-layer cache busting (task 02)
# ---------------------------------------------------------------------------
#: The five known HTML pages — the ONLY paths whose body is rewritten.
HTML_PAGES: tuple[str, ...] = (
"/",
"/index.html",
"/sources.html",
"/document.html",
"/login.html",
"/tuning.html",
)
#: Prefix of the versioned static assets (header-only caching; the body is
#: never read or modified).
ASSETS_PREFIX = "/assets/"
#: ``/assets/*`` — the URL carries ``?v=<token>``, so long-term caching is
#: safe (a deploy changes the token, hence the URL).
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
#: HTML pages are always revalidated — the page is the cheap redirector to
#: the (long-cached) versioned assets.
HTML_CACHE_CONTROL = "no-cache"
#: Matches ``href="…assets/…"`` / ``src="…assets/…"`` references — leading
#: slash optional (the pages mix ``/assets/…`` and ``assets/…``) — that do
#: NOT already carry a query string or a fragment: ``[^"?#]+`` stops at
#: ``?``/``#``, so an already-``?v=``-tagged reference can never match
#: again (idempotent rewrite, never a double ``?v=``).
_ASSET_REF_RE = re.compile(r'((?:href|src)="(?:/)?assets/[^"?#]+)(")')
def rewrite_asset_refs(html: str, token: str) -> str:
"""Append ``?v=<token>`` before the closing quote of every local
``assets/…`` ``href``/``src`` reference.
Pure and idempotent: references that already carry a query string or a
fragment (``?v=…``, ``#frag``) do not match the pattern and are left
alone, and non-asset references (``href="/sources.html"``,
``href="data:…"``) pass through untouched.
"""
return _ASSET_REF_RE.sub(lambda m: f"{m.group(1)}?v={token}{m.group(2)}", html)
async def _read_body(response: Response) -> bytes:
"""Buffer the whole response body.
starlette 1.x's ``BaseHTTPMiddleware.call_next`` returns a streaming
response (no ``body()`` helper), so drain ``body_iterator``; a plain
buffered ``Response`` simply carries its ``body``.
"""
if hasattr(response, "body"):
raw = response.body # bytes | memoryview (Response.render's output)
if isinstance(raw, memoryview):
return bytes(raw)
return raw
# starlette 1.x's BaseHTTPMiddleware.call_next wraps the app response
# in a streaming shell (``body_iterator``, no ``body``) that is NOT a
# StreamingResponse subclass — drain the iterator instead.
iterator = response.body_iterator # pyright: ignore[reportAttributeAccessIssue]
return b"".join([chunk async for chunk in iterator])
def _no_cache_headers(response: Response) -> dict[str, str]:
"""Copy the original headers, drop the stale ``content-length`` (the
body size changes on rewrite), and force ``Cache-Control: no-cache``."""
headers = {k: v for k, v in response.headers.items()}
headers.pop("content-length", None)
headers["Cache-Control"] = HTML_CACHE_CONTROL
return headers
class CachingMiddleware(BaseHTTPMiddleware):
"""Transport-layer cache busting (phase 33).
Touches exactly two response shapes:
* ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
(header only — the body is never read).
* the five known HTML pages — ``Cache-Control: no-cache``, and (for
``text/html`` bodies) every local asset reference gains
``?v=<token>``.
Everything else — all of ``/api/*`` (including the SSE chat stream) —
passes through byte-identical: no header changes, the body stream is
never drained.
"""
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
response = await call_next(request)
path = request.url.path
if path.startswith(ASSETS_PREFIX):
response.headers["Cache-Control"] = ASSET_CACHE_CONTROL
return response
if path not in HTML_PAGES:
# /api/* (incl. SSE), /favicon.ico, unknown paths: untouched.
return response
content_type = response.headers.get("content-type", "")
if content_type.split(";", 1)[0].strip().lower() != "text/html":
# A page path with a non-HTML body (e.g. the 404 JSON served
# when the static dir is missing): still revalidate, body as-is.
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
return response
# ``asset_version()`` is lru_cached, so only the very first call in
# the process can fail — resolve it BEFORE draining the body stream
# below.
try:
token = asset_version()
body = await _read_body(response)
except Exception:
logger.exception(
"cache busting: could not buffer %s — serving unmodified (no-cache)",
path,
)
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
return response
try:
new_body = rewrite_asset_refs(body.decode("utf-8"), token).encode("utf-8")
except Exception:
# The body IS buffered — re-serve the ORIGINAL bytes so a
# rewrite hiccup never loses the page.
logger.exception("cache busting: rewrite of %s failed — serving raw (no-cache)", path)
return Response(
content=body,
status_code=response.status_code,
headers=_no_cache_headers(response),
)
return Response(
content=new_body,
status_code=response.status_code,
headers=_no_cache_headers(response),
)
def configure_caching(app: FastAPI) -> None:
"""Attach :class:`CachingMiddleware` to ``app`` (phase 33).
Call inside ``create_app()`` after the routers are mounted; the
middleware wraps the whole app, so the ``/`` static catch-all is
covered too. ``/api/*`` responses (including the SSE chat stream) are
never touched by it.
"""
app.add_middleware(CachingMiddleware)
+8
View File
@@ -28,6 +28,7 @@ from app.api.suggestions import router as suggestions_router
from app.api.sync import router as sync_router
from app.config import get_settings
from app.core.auth import ensure_admin_configured
from app.core.caching import configure_caching
from app.core.debugging import configure_debugging
from app.core.logging import configure_logging
@@ -67,6 +68,13 @@ def create_app() -> FastAPI:
app.include_router(steering_router, prefix="/api")
app.include_router(sync_router, prefix="/api")
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
# Added after the session middleware, so it wraps the whole app
# (including the static catch-all below); /api/* — the SSE chat
# stream in particular — passes through untouched.
configure_caching(app)
static_dir = Path(settings.static_dir).resolve()
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")