From 7fe5108f66a015c99f4ba8a6063dd486c01bad68 Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Tue, 5 May 2026 12:20:39 -0500 Subject: [PATCH] feat: HTTP shared cache for public marketing pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages got fast again. Public marketing routes are still rendered per-request by Next.js (force-dynamic, until the ISR bug gets isolated), but their HTML is now cached at the Nginx layer for 60s with a 5-minute stale-while-revalidate window. Result: only the first hit on a URL inside a 60s window pays the SSR cost; every other visitor in that window gets a sub-10ms cached response. While a cached entry is revalidating, peers keep getting the stale copy — no cold starts, no thundering herds. NEXT.JS MIDDLEWARE (src/proxy.ts) - isCacheablePublicPath() identifies routes safe to share-cache: /, /, //applications, //news, //heritage. Excludes //parts (auth-gated B2B portal) and /hq-command/*, /api/*, /_next/*. - hasAuthCookie() short-circuits caching when the request carries a flux_session (admin CMS) or flux_b2b_session (client portal) cookie. Authenticated users always get a fresh per-account render. - When both checks pass, the response gets: Cache-Control: public, s-maxage=60, stale-while-revalidate=300 NGINX (nginx/nginx.conf) - New shared zone: proxy_cache_path /var/cache/nginx/flux levels=1:2 keys_zone=flux_html:50m max_size=1g inactive=24h use_temp_path=off; - Access log gets a `cache=$upstream_cache_status` field so we can audit hit/miss ratios in the live logs. NGINX (nginx/conf.d/flux.conf — location /) - proxy_cache flux_html + proxy_cache_revalidate on - proxy_cache_use_stale: serves stale on backend errors / timeout / during update, so 502s during a Next.js restart never reach users. - proxy_cache_background_update + proxy_cache_lock: only one upstream request fires when a cached entry expires; others keep getting stale. - proxy_cache_bypass / proxy_no_cache wired to flux_session + flux_b2b_session cookies — admin and B2B traffic skips the shared cache entirely. - X-Cache-Status response header (HIT/MISS/EXPIRED/STALE/UPDATING/BYPASS) for live debugging — open dev tools, refresh, watch the value flip. WHAT YOU'LL FEEL - First visitor on /en within a 60s window: ~150-300ms (SSR + DB). - Second through Nth visitors in the same window: <10ms. - Editor publishes a change in HQ Command → revalidatePath() inside the existing actions invalidates the Next.js cache; the next marketing-page request rebuilds and primes Nginx fresh. The 60s TTL bounds how long stale content can linger if revalidation is ever skipped. NO BREAKING CHANGES - Auth flows untouched (cookies bypass cache). - HQ Command + API endpoints untouched (separate Nginx locations). - Static assets (cases/, applications/, /branding/, /_next/static) unaffected — they had their own cache headers already. - Server-side cache invalidation via revalidatePath() still works. DEPLOY (David) cd /opt/flux-srl git pull docker compose up -d --build app docker compose exec nginx nginx -t docker compose exec nginx nginx -s reload --- nginx/conf.d/flux.conf | 23 +++++++++++++++++++++++ nginx/nginx.conf | 11 ++++++++++- src/proxy.ts | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/nginx/conf.d/flux.conf b/nginx/conf.d/flux.conf index bfcab13..31c5707 100644 --- a/nginx/conf.d/flux.conf +++ b/nginx/conf.d/flux.conf @@ -186,5 +186,28 @@ server { # Strip any leaked container port from upstream redirects, just in # case Next.js still builds Location headers with :3000. proxy_redirect ~^https?://[^/:]+:3000(/.*)$ https://$host$1; + + # ── Shared HTML cache ─────────────────────────────────────────── + # Caches GET responses that come back with a Cache-Control header + # from Next.js (the middleware sets s-maxage=60 on public marketing + # pages). Authenticated requests skip the cache entirely. While a + # cached entry is being refreshed, other visitors keep getting the + # stale copy — no thundering herd, no cold starts visible to users. + proxy_cache flux_html; + proxy_cache_revalidate on; + proxy_cache_min_uses 1; + proxy_cache_lock on; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_background_update on; + proxy_cache_methods GET HEAD; + + # Bypass cache for authenticated sessions (admin CMS or B2B portal) + # so logged-in users always see fresh, per-account content. + proxy_cache_bypass $cookie_flux_session $cookie_flux_b2b_session $http_pragma; + proxy_no_cache $cookie_flux_session $cookie_flux_b2b_session $http_pragma; + + # Surface cache status in response headers for debugging. + # X-Cache-Status: HIT | MISS | EXPIRED | STALE | UPDATING | BYPASS + add_header X-Cache-Status $upstream_cache_status always; } } diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 1ce1680..6d92f8d 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -35,8 +35,17 @@ http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent"'; + '"$http_user_agent" cache=$upstream_cache_status'; access_log /var/log/nginx/access.log main; + # Shared HTML cache for public marketing pages. + # Honors Cache-Control headers from upstream (Next.js sets s-maxage=60 + # via middleware on cacheable routes). Sized at 1GB on disk, kept warm + # for 24h. proxy_cache_use_stale lets a stale copy serve while a fresh + # render happens in the background — perceived latency stays sub-10ms + # even during regeneration. + proxy_cache_path /var/cache/nginx/flux levels=1:2 keys_zone=flux_html:50m + max_size=1g inactive=24h use_temp_path=off; + include /etc/nginx/conf.d/*.conf; } diff --git a/src/proxy.ts b/src/proxy.ts index 88cdec2..5a4e7e1 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -68,9 +68,46 @@ export async function proxy(request: NextRequest) { } } + // ── Cache-Control on public pages ──────────────────────────────────── + // Marketing pages (home, applications, news, heritage) are dynamic per + // request but the rendered HTML is the same for every visitor — no auth + // gating, no per-user data. So we let Nginx and the browser cache them + // briefly, falling back to a stale copy for up to 5 minutes while a + // refresh happens in the background. Result: first hit on a URL renders + // freshly (~150-300ms), every subsequent hit within 60s comes from cache + // (<10ms). Big perceived-speed boost without breaking ISR semantics. + if (isCacheablePublicPath(path) && !hasAuthCookie(request)) { + response.headers.set( + "Cache-Control", + "public, s-maxage=60, stale-while-revalidate=300" + ); + } + return response; } +// Pages we're happy to cache at the edge: locale-prefixed marketing routes. +// /parts is intentionally excluded — it's the auth-gated B2B portal and its +// HTML changes per logged-in user. +function isCacheablePublicPath(path: string): boolean { + if (path.startsWith("/hq-command")) return false; + if (path.startsWith("/api")) return false; + if (path.startsWith("/_next")) return false; + // Per-locale public pages + if (/^\/(en|it|vec|es|de)\/parts(\/|$)/.test(path)) return false; + if (/^\/(en|it|vec|es|de)(\/applications|\/news|\/heritage|\/?$)/.test(path)) return true; + // Root redirect to /en — also cacheable + if (path === "/") return true; + return false; +} + +function hasAuthCookie(request: NextRequest): boolean { + return Boolean( + request.cookies.get("flux_session")?.value || + request.cookies.get("flux_b2b_session")?.value + ); +} + function sanitizeRedirectLocation(location: string, request: NextRequest): string { try { const forwardedHost =