perf(landing): WebP welcome screenshots + long-cache static assets
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.
This commit is contained in:
parent
04c4cb05da
commit
a07fd82ad6
8 changed files with 27 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 251 KiB |
BIN
frontend/public/welcome/channels.webp
Normal file
BIN
frontend/public/welcome/channels.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
BIN
frontend/public/welcome/feed.webp
Normal file
BIN
frontend/public/welcome/feed.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 440 KiB |
BIN
frontend/public/welcome/playlists.webp
Normal file
BIN
frontend/public/welcome/playlists.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
|
|
@ -59,7 +59,7 @@ export default function Welcome() {
|
|||
|
||||
{/* App preview */}
|
||||
<Preview
|
||||
src="/welcome/feed.png"
|
||||
src="/welcome/feed.webp"
|
||||
label={t("welcome.preview.feed")}
|
||||
className="aspect-video"
|
||||
onOpen={open}
|
||||
|
|
@ -101,13 +101,13 @@ export default function Welcome() {
|
|||
{/* Secondary showcase — smaller thumbnails; click to view full size. */}
|
||||
<section className="mt-12 flex flex-wrap justify-center gap-4">
|
||||
<Preview
|
||||
src="/welcome/channels.png"
|
||||
src="/welcome/channels.webp"
|
||||
label={t("welcome.preview.channels")}
|
||||
className="w-full sm:w-72 aspect-[4/3]"
|
||||
onOpen={open}
|
||||
/>
|
||||
<Preview
|
||||
src="/welcome/playlists.png"
|
||||
src="/welcome/playlists.webp"
|
||||
label={t("welcome.preview.playlists")}
|
||||
className="w-full sm:w-72 aspect-[4/3]"
|
||||
onOpen={open}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue