From 2acc01375620fabb06a6e01c1e92aab32c48b013 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 4 Jul 2026 18:17:11 +0200 Subject: [PATCH] perf(landing): WebP welcome screenshots + long-cache static assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3 landing screenshots were 2.3 MB of PNG (feed.png alone 1.6 MB, decoded at 2400x1350 for a ~400px slot) — the biggest LCP/transfer cost on the public page. Re-encoded to WebP capped at 1600px (~284 KB total, feed 91% smaller). Also set Cache-Control: content-hashed /assets/* are immutable for a year, other SPA-root static files (welcome images, favicon, robots) get a 7-day TTL; index.html stays no-cache. Register image/webp+avif mimetypes so FileResponse serves the right Content-Type. Lighthouse (dev): Perf 80->93, LCP 1.8s->1.2s. --- backend/app/main.py | 25 ++++++++++++++++++++++++- frontend/src/components/Welcome.tsx | 6 +++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index d3fb78f..721712c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -112,6 +112,20 @@ async def setup_gate(request, call_next): return await call_next(request) +@app.middleware("http") +async def static_cache_headers(request, call_next): + """Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given + /assets URL never changes content and a browser can hold it forever — this is what makes + repeat loads instant and clears the Lighthouse "efficient cache lifetimes" audit. index.html + stays no-cache (set on its own FileResponse) so a deploy is picked up at once; other + SPA-root static files (welcome images, favicon, robots.txt) get a moderate TTL in the SPA + fallback below.""" + response = await call_next(request) + if request.url.path.startswith("/assets/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + app.include_router(health.router) app.include_router(auth.router) app.include_router(sync.router) @@ -134,6 +148,13 @@ app.include_router(quota.router) app.include_router(version.router) app.include_router(setup_routes.router) +# Ensure modern image types resolve to the right Content-Type when FileResponse guesses from the +# filename (the runtime's mimetypes db doesn't always know .webp → it'd fall back to octet-stream). +import mimetypes + +mimetypes.add_type("image/webp", ".webp") +mimetypes.add_type("image/avif", ".avif") + # The built SPA (populated by the Docker frontend build stage). STATIC_DIR = Path(__file__).parent / "static_spa" # index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be @@ -166,5 +187,7 @@ async def spa_fallback(full_path: str) -> FileResponse: if full_path: candidate = (STATIC_DIR / full_path).resolve() if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: - return FileResponse(candidate) + # Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have + # stable names, so a moderate cache is safe and satisfies the cache-lifetime audit. + return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"}) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index f21de8e..56b3ce1 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -59,7 +59,7 @@ export default function Welcome() { {/* App preview */}