269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""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 known
|
|
HTML pages (``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 known HTML pages — the ONLY paths whose body is rewritten.
|
|
#: Phase 35 adds the admin git sources page: without this entry it
|
|
#: would serve unversioned asset refs, which the immutable-for-a-year
|
|
#: asset caching would pin to stale CSS after a deploy.
|
|
HTML_PAGES: tuple[str, ...] = (
|
|
"/",
|
|
"/index.html",
|
|
"/sources.html",
|
|
"/document.html",
|
|
"/login.html",
|
|
"/tuning.html",
|
|
"/git-sources.html", # phase 35: the admin git sources page
|
|
"/history.html", # phase 50: the admin saved-chats page
|
|
)
|
|
|
|
#: 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 known HTML pages (``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)
|