diff --git a/main.py b/main.py index e5eae00..44b1445 100644 --- a/main.py +++ b/main.py @@ -3,8 +3,11 @@ import asyncio from contextlib import asynccontextmanager from pathlib import Path +import re +from hashlib import sha256 + from fastapi import FastAPI -from fastapi.responses import FileResponse +from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from app.api.meta import router as meta_router @@ -38,6 +41,30 @@ app.include_router(api_router) app.include_router(ws_router) +ASSET_REF = re.compile(r'((?:src|href)="/static/[^"?]+)"') + + +def asset_version() -> str: + """A digest of the served assets, so the URL changes iff the content does. + + StaticFiles sends an ETag but no Cache-Control, so a browser is free to keep + using the copy it already has — and a tab left open simply never fetches + again. That turned a fixed bug into a bug that still reproduced, because the + page was running the JavaScript it had loaded hours earlier. + + Hashing rather than stamping mtimes: a deploy checks every file out fresh, + which would otherwise invalidate assets that never changed. + """ + digest = sha256() + for path in sorted(STATIC_DIR.glob("*.*")): + digest.update(path.read_bytes()) + return digest.hexdigest()[:12] + + @app.get("/") def index(): - return FileResponse(STATIC_DIR / "index.html") + html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") + html = ASSET_REF.sub(rf'\1?v={asset_version()}"', html) + # The document itself must never be cached, or the versioned URLs inside it + # are the stale thing instead. + return HTMLResponse(html, headers={"Cache-Control": "no-store"}) diff --git a/static/app.js b/static/app.js index 8863628..5beeeaf 100644 --- a/static/app.js +++ b/static/app.js @@ -418,6 +418,10 @@ createApp({ onMounted(() => { chartApi = new ConfluenceChart(); + // Deliberate debug handle. Chart geometry bugs are invisible from the + // outside — this is how the trendline slope was measured rather than + // guessed at: __chart.lineData(level) against timeToCoordinate(). + window.__chart = chartApi; chartApi.create(document.getElementById('chart')); chartApi.setClickHandler(handleChartClick); chartApi.setToolCompleteHandler(handleToolComplete); diff --git a/tests/test_asset_versioning.py b/tests/test_asset_versioning.py new file mode 100644 index 0000000..80ee5a4 --- /dev/null +++ b/tests/test_asset_versioning.py @@ -0,0 +1,32 @@ +import re + +from fastapi.testclient import TestClient + +import main + + +def test_assets_are_versioned_and_the_page_is_not_cached(): + # Not a context manager: that would run the lifespan, which seeds months of + # history from Yahoo before serving anything. + response = TestClient(main.app).get("/") + + assert response.headers["cache-control"] == "no-store" + refs = re.findall(r'(?:src|href)="(/static/[^"]+)"', response.text) + assert refs, "no static assets referenced" + # Without this a tab left open keeps running the JavaScript it first loaded, + # which made a fixed bug look like it was still broken. + assert all("?v=" in ref for ref in refs), refs + + +def test_version_tracks_content_not_timestamps(tmp_path, monkeypatch): + asset = tmp_path / "app.js" + asset.write_text("//", encoding="utf-8") + monkeypatch.setattr(main, "STATIC_DIR", tmp_path) + + first = main.asset_version() + asset.touch() + # A deploy checks every file out fresh; unchanged assets must stay cached. + assert main.asset_version() == first + + asset.write_text("// changed", encoding="utf-8") + assert main.asset_version() != first