diff --git a/next.config.ts b/next.config.ts index ba078a1..02e0c3b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -92,6 +92,54 @@ const nextConfig: NextConfig = { }, ], }, + { + // Static assets served from /public (images, icons, fonts). Filenames + // are not content-hashed, so use a week-long TTL with revalidation + // rather than `immutable`. + source: '/:path*.(svg|png|jpg|jpeg|gif|webp|avif|ico|woff|woff2)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=604800, stale-while-revalidate=86400', + }, + ], + }, + { + source: '/manifest.json', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=86400', + }, + ], + }, + { + // Public, non-personalised marketing pages. Edge-cache them but allow + // an instant revalidate after each deploy. + source: '/(landing|about)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400', + }, + ], + }, + { + // Signed-in app *shells*. These pages render an identical HTML shell for + // every user — all wallet data loads client-side via Server Actions, and + // auth gating happens client-side from localStorage. The shells are + // therefore safe to edge-cache (short TTL + SWR). NOTE: only the static + // shell is cached; the per-user data requests are separate POSTs that + // always reach the origin. Parameterised routes (address/[address] etc.) + // are intentionally excluded. + source: '/(dashboard|analysis|security|chat|report|coin-control)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=0, s-maxage=300, stale-while-revalidate=3600', + }, + ], + }, ]; }, // Updated experimental flags for Next.js 16 diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index fd2bb57..6f10d35 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -383,14 +383,37 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) { if (process.env.NODE_ENV === 'production' && window.__NEXT_DATA__) { const currentBuildId = window.__NEXT_DATA__.buildId; - const intervalId = setInterval(async () => { + // This fetch hits the App Hosting origin (Cloud Run) with `no-store`, + // so it is both a CDN miss and an origin request. Keep it infrequent, + // skip it while the tab is hidden, and add jitter so clients don't all + // poll in lockstep. + const BASE_INTERVAL = 15 * 60 * 1000; // 15 minutes + const JITTER = 60 * 1000; // up to 60s of spread + let timeoutId: ReturnType; + let stopped = false; + + const scheduleNext = () => { + const delay = BASE_INTERVAL + Math.floor(Math.random() * JITTER); + timeoutId = setTimeout(check, delay); + }; + + const check = async () => { + if (stopped) return; + + // Don't poll the origin while the tab is in the background. + if (document.visibilityState !== 'visible') { + scheduleNext(); + return; + } + try { const res = await fetch(window.location.href, { cache: 'no-store', }); - + if (!res.ok) { console.warn(`Update check failed with status: ${res.status}`); + scheduleNext(); return; } @@ -402,16 +425,28 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) { const serverBuildId = serverData.buildId; if (serverBuildId && currentBuildId && serverBuildId !== currentBuildId) { + // If the effect was cleaned up (unmounted) while this fetch was + // in flight, `stopped` is already true — skip the state update + // to avoid setting state on an unmounted component. + if (stopped) return; setIsUpdateAvailable(true); - clearInterval(intervalId); // Stop checking once an update is found. + stopped = true; // Stop checking once an update is found. + return; } } } catch (err) { console.warn('Failed to check for app update:', err); } - }, 60000); // Check every 60 seconds. - return () => clearInterval(intervalId); + scheduleNext(); + }; + + scheduleNext(); + + return () => { + stopped = true; + clearTimeout(timeoutId); + }; } }, []); diff --git a/src/app/(app)/market/page.tsx b/src/app/(app)/market/page.tsx index 75a00dd..ef9d7ae 100644 --- a/src/app/(app)/market/page.tsx +++ b/src/app/(app)/market/page.tsx @@ -144,11 +144,15 @@ export default function MarketPage() { }, [loadData]); useEffect(() => { + // Each refresh is a Server Action call to the App Hosting origin + // (Cloud Run). The underlying CoinGecko data revalidates on a 60s+ + // window, so a 5-minute client poll keeps the page fresh without + // hammering the origin. const interval = setInterval(() => { if (document.visibilityState === 'visible') { loadData(false); } - }, 60000); + }, 5 * 60 * 1000); return () => clearInterval(interval); }, [loadData]); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 92c7f85..d696756 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -20,6 +20,10 @@ import { Lightbulb } from 'lucide-react'; +// Public marketing page — statically generated and revalidated hourly so it +// can be served from the Firebase App Hosting CDN edge. +export const revalidate = 3600; + export const metadata: Metadata = { title: 'About BitSleuth - AI Bitcoin Wallet Analyzer | Security & Privacy Tool', description: 'Learn about BitSleuth, the leading AI-powered Bitcoin wallet analyzer. Discover how our advanced security analysis, privacy tools, and transaction insights help Bitcoin users worldwide.', diff --git a/src/app/landing/page.tsx b/src/app/landing/page.tsx index 7b6278d..d6ba39d 100644 --- a/src/app/landing/page.tsx +++ b/src/app/landing/page.tsx @@ -20,6 +20,10 @@ import { Globe } from 'lucide-react'; +// Public marketing page — statically generated and revalidated hourly so it +// can be served from the Firebase App Hosting CDN edge. +export const revalidate = 3600; + export const metadata: Metadata = { title: 'BitSleuth - Free AI Bitcoin Wallet Analyzer & Security Tool', description: 'The most advanced AI-powered Bitcoin wallet analyzer. Get comprehensive security insights, privacy analysis, and transaction patterns for any Bitcoin wallet. Free Bitcoin wallet scanner with AI technology.', diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 50b3735..de645ee 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,49 +1,53 @@ import { type MetadataRoute } from 'next' +// Stable timestamp so the sitemap doesn't appear "always new" to crawlers on +// every build/request. Bump when public page content changes meaningfully. +const LAST_MODIFIED = new Date('2026-06-01T00:00:00Z'); + export default function sitemap(): MetadataRoute.Sitemap { const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://app.bitsleuth.ai'; - + const staticRoutes = [ { url: `${siteUrl}/`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'daily' as const, priority: 1.0, }, { url: `${siteUrl}/market`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'hourly' as const, priority: 0.9, }, { url: `${siteUrl}/mempool`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'hourly' as const, priority: 0.9, }, { url: `${siteUrl}/discover`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'daily' as const, priority: 0.8, }, // Add more public pages that can be indexed { url: `${siteUrl}/landing`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'weekly' as const, priority: 0.9, }, { url: `${siteUrl}/about`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'monthly' as const, priority: 0.8, }, { url: `${siteUrl}/feedback`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'weekly' as const, priority: 0.6, }, diff --git a/src/contexts/wallet-context.tsx b/src/contexts/wallet-context.tsx index 38df73e..d671752 100644 --- a/src/contexts/wallet-context.tsx +++ b/src/contexts/wallet-context.tsx @@ -908,14 +908,27 @@ export const WalletProvider = ({ children, testXpub }: { children: ReactNode; te errorToastId.current = toastResult.id; - if (errorRetryTimeout.current) { - clearTimeout(errorRetryTimeout.current); - } - errorRetryTimeout.current = setTimeout(triggerRetry, 3000); + // Exponential backoff (3s, 6s, 12s ... capped at 60s) so a failing origin + // — wallet data is fetched via Server Actions on Cloud Run — is not hammered + // by retries. `immediate` is used when the user returns to the tab. + const scheduleRetry = (immediate = false) => { + if (errorRetryCount.current >= 3) return; + if (errorRetryTimeout.current) { + clearTimeout(errorRetryTimeout.current); + } + const delay = immediate + ? 1000 + : Math.min(3000 * 2 ** errorRetryCount.current, 60000); + errorRetryTimeout.current = setTimeout(triggerRetry, delay); + }; + + scheduleRetry(); + // Retry when the user returns to the tab, but route it through the same + // debounced timer so a focus storm can't fire back-to-back origin calls. const handleVisibility = () => { if (!document.hidden) { - triggerRetry(); + scheduleRetry(true); } }; @@ -958,7 +971,7 @@ export const WalletProvider = ({ children, testXpub }: { children: ReactNode; te logger.warn("Could not refresh BTC price:", e); } scheduleNext(); // Schedule the next execution - }, 60000); // every 60 seconds + }, 5 * 60 * 1000); // every 5 minutes }; scheduleNext();