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:
@@ -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)
|
||||
Reference in New Issue
Block a user