diff --git a/src/app/img/[...path]/route.ts b/src/app/img/[...path]/route.ts index 0e88a9c..4b348e0 100644 --- a/src/app/img/[...path]/route.ts +++ b/src/app/img/[...path]/route.ts @@ -1,7 +1,8 @@ import { NextRequest } from "next/server"; export const runtime = "nodejs"; -export const dynamic = "force-static"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; /** * Image proxy: serves files from Supabase Storage under a clean broadcastbeat.com @@ -13,24 +14,59 @@ export const dynamic = "force-static"; * /img/campaign-creatives//.jpg * /img/bb-forum-avatars/.png * - * Path segments are forwarded directly to the matching public bucket. + * Resilience rules (prior bug): + * - Supabase storage occasionally returns transient 5xx. We retry up to 2x + * with backoff before surfacing failure. + * - Failures MUST emit no-store cache headers, otherwise Next + Cloudflare + * pin the failure indefinitely (page itself had s-maxage of one year). + * - The route must be dynamic — force-static used to pre-bake a single + * fetch result at build time, which is exactly the bug we hit. */ const SUPABASE_BASE = (process.env.NEXT_PUBLIC_SUPABASE_URL || "https://supabase.onsethost.com") .replace(/\/+$/, "") + "/storage/v1/object/public"; +async function fetchWithRetry(url: string, tries = 3): Promise { + let lastErr: unknown = null; + for (let i = 0; i < tries; i++) { + try { + const r = await fetch(url, { cache: "no-store" }); + if (r.ok) return r; + if (r.status === 404) return r; + // 5xx — back off and retry + lastErr = new Error(`upstream ${r.status}`); + } catch (e) { + lastErr = e; + } + if (i < tries - 1) await new Promise((res) => setTimeout(res, 150 * (i + 1))); + } + throw lastErr instanceof Error ? lastErr : new Error("upstream failed"); +} + +function failResponse(status: number, body: string) { + return new Response(body, { + status, + headers: { + "content-type": "text/plain; charset=utf-8", + // Critical: never cache failures. + "cache-control": "no-store, max-age=0", + "x-bb-img-source": "proxy-error", + }, + }); +} + export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { const { path } = await params; - if (!path || path.length < 2) return new Response("bad path", { status: 400 }); + if (!path || path.length < 2) return failResponse(400, "bad path"); const target = `${SUPABASE_BASE}/${path.map(encodeURIComponent).join("/")}`; let upstream: Response; try { - upstream = await fetch(target, { cache: "force-cache" }); + upstream = await fetchWithRetry(target, 3); } catch (e: any) { - return new Response("upstream error: " + (e?.message || String(e)), { status: 502 }); + return failResponse(502, "upstream error after retries: " + (e?.message || String(e))); } if (!upstream.ok) { - return new Response("not found", { status: upstream.status === 404 ? 404 : 502 }); + return failResponse(upstream.status === 404 ? 404 : 502, "not found"); } return new Response(upstream.body, {