From 756b293b8d17b1c87a192f4bca89279b5b5b0b5a Mon Sep 17 00:00:00 2001 From: Yuriy Plotnikov Date: Fri, 8 May 2026 08:13:21 +0300 Subject: [PATCH] Add revalidation Next.js cache --- .env.template | 3 + .github/workflows/deploy.yml | 4 +- src/app/api/revalidate/route.ts | 78 +++++++++++++++++++ .../categories/[categories-detail]/page.tsx | 2 - src/app/gallery/[...slug]/page.tsx | 2 - src/app/in-stock/[in-stock-detail]/page.tsx | 2 - src/app/layout.tsx | 2 + src/app/news/[news-detail]/page.tsx | 2 - src/app/works/[works-detail]/page.tsx | 2 - src/lib/api-client.ts | 8 +- 10 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 src/app/api/revalidate/route.ts diff --git a/.env.template b/.env.template index d57f0c23..74ab2f1d 100644 --- a/.env.template +++ b/.env.template @@ -7,6 +7,9 @@ INTERNAL_URL=http://localhost:3000 COCKPIT_API_URL=https://your-cockpit-url.com/ COCKPIT_API_KEY=your-cockpit-api-key +# On-demand revalidation +REVALIDATE_SECRET=your-random-secret-min-32-chars + # Yandex Smart Captcha NEXT_PUBLIC_CAPTCHA_SITE_KEY=your-yandex-captcha-site-key CAPTCHA_SECRET=your-yandex-captcha-secret-key diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 58e36438..235f4a2b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -82,12 +82,13 @@ jobs: COCKPIT_API_URL: ${{ secrets.COCKPIT_API_URL }} COCKPIT_API_KEY: ${{ secrets.COCKPIT_API_KEY }} YANDEX_MAPS_API_KEY: ${{ secrets.YANDEX_MAPS_API_KEY }} + REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }} with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ secrets.VPS_PORT || 22 }} - envs: IMAGE_NAME,NEXT_PUBLIC_SITE_URL,NEXT_PUBLIC_CAPTCHA_SITE_KEY,CAPTCHA_SECRET,COCKPIT_API_URL,COCKPIT_API_KEY,YANDEX_MAPS_API_KEY + envs: IMAGE_NAME,NEXT_PUBLIC_SITE_URL,NEXT_PUBLIC_CAPTCHA_SITE_KEY,CAPTCHA_SECRET,COCKPIT_API_URL,COCKPIT_API_KEY,YANDEX_MAPS_API_KEY,REVALIDATE_SECRET script: | set -euo pipefail echo "🚀 Starting deployment process..." @@ -104,6 +105,7 @@ jobs: "CAPTCHA_SECRET=${CAPTCHA_SECRET}" \ "COCKPIT_API_URL=${COCKPIT_API_URL}" \ "COCKPIT_API_KEY=${COCKPIT_API_KEY}" \ + "REVALIDATE_SECRET=${REVALIDATE_SECRET}" \ > .env echo "✅ .env file written" echo "🔍 .env keys (values masked):" diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts new file mode 100644 index 00000000..b814118c --- /dev/null +++ b/src/app/api/revalidate/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server' +import { revalidateTag } from 'next/cache' + +/** + * POST /api/revalidate + * Эндпоинт для ревалидации кеша Next.js по тегам, связанным с моделями данных. + * + * Пример запроса: + * curl -X POST http://localhost:3000/api/revalidate \ + * -H "Content-Type: application/json" \ + * -H "x-webhook-secret: your_secret_here" \ + * -d '{"model": "news"}' + * + * В ответ возвращает JSON с результатом ревалидации. + */ +export async function POST(request: NextRequest) { + try { + const secret = request.headers.get('x-webhook-secret') + const expectedSecret = process.env.REVALIDATE_SECRET + + if (!expectedSecret) { + console.error('REVALIDATE_SECRET is not configured') + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }) + } + + if (secret !== expectedSecret) { + console.warn('Invalid webhook secret received') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { model } = body + + if (!model) { + return NextResponse.json({ error: 'Model parameter is required' }, { status: 400 }) + } + + const modelTagMap: Record = { + // Collections + news: ['collection-news'], + works: ['collection-works'], + reviews: ['collection-reviews'], + category: ['collection-category'], + faq: ['collection-faq'], + advantages: ['collection-advantages'], + createprocess: ['collection-createprocess'], + restoration: ['singleton-restoration'], + masters: ['collection-masters'], + mainslider: ['collection-mainslider'], + // Singletons + maininfo: ['singleton-maininfo'], + order: ['singleton-order'], + // Trees + gallery: ['tree-gallery'], + } + + const tagsToRevalidate = modelTagMap[model] || [] + + if (tagsToRevalidate.length === 0) { + return NextResponse.json({ error: `Unknown model: ${model}` }, { status: 400 }) + } + + for (const tag of tagsToRevalidate) { + revalidateTag(tag) + console.log(`Revalidated tag: ${tag}`) + } + + return NextResponse.json({ + success: true, + revalidated: true, + tags: tagsToRevalidate, + timestamp: new Date().toISOString(), + }) + } catch (err) { + console.error('Revalidation error:', err) + return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 }) + } +} diff --git a/src/app/categories/[categories-detail]/page.tsx b/src/app/categories/[categories-detail]/page.tsx index 62256a58..5ba5e5df 100644 --- a/src/app/categories/[categories-detail]/page.tsx +++ b/src/app/categories/[categories-detail]/page.tsx @@ -6,8 +6,6 @@ import Heading from '@/components/Heading/Heading' import Detail from '@/components/Detail/Detail' import { fetchCollectionItem, getImageUrl } from '@/lib/api-client' -export const dynamic = 'force-dynamic' - type PageProps = { params: Promise<{ 'categories-detail': string diff --git a/src/app/gallery/[...slug]/page.tsx b/src/app/gallery/[...slug]/page.tsx index f286960a..6028c685 100644 --- a/src/app/gallery/[...slug]/page.tsx +++ b/src/app/gallery/[...slug]/page.tsx @@ -11,8 +11,6 @@ import { buildGalleryBreadcrumbs, } from '@/functions/gallery' -export const dynamic = 'force-dynamic' - type PageParams = { params: Promise<{ slug: string[] diff --git a/src/app/in-stock/[in-stock-detail]/page.tsx b/src/app/in-stock/[in-stock-detail]/page.tsx index a713df6c..386e4544 100644 --- a/src/app/in-stock/[in-stock-detail]/page.tsx +++ b/src/app/in-stock/[in-stock-detail]/page.tsx @@ -7,8 +7,6 @@ import Detail from '@/components/Detail/Detail' import Master from '@/components/Master/Master' import { fetchCollectionItem, getImageUrl } from '@/lib/api-client' -export const dynamic = 'force-dynamic' - type PageProps = { params: Promise<{ 'in-stock-detail': string diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1124d624..3c8ae105 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,8 @@ import Header from '@/components/Header/Header' import Footer from '@/components/Footer/Footer' import ScrollButton from '@/components/ScrollButton/ScrollButton' +export const dynamic = 'force-dynamic' + type LayoutProps = { children?: React.ReactNode } diff --git a/src/app/news/[news-detail]/page.tsx b/src/app/news/[news-detail]/page.tsx index 17d2f029..6f9774f8 100644 --- a/src/app/news/[news-detail]/page.tsx +++ b/src/app/news/[news-detail]/page.tsx @@ -6,8 +6,6 @@ import Heading from '@/components/Heading/Heading' import Detail from '@/components/Detail/Detail' import { fetchCollectionItem, getImageUrl } from '@/lib/api-client' -export const dynamic = 'force-dynamic' - type PageParams = { params: Promise<{ 'news-detail': string diff --git a/src/app/works/[works-detail]/page.tsx b/src/app/works/[works-detail]/page.tsx index e99d9806..9828aa27 100644 --- a/src/app/works/[works-detail]/page.tsx +++ b/src/app/works/[works-detail]/page.tsx @@ -7,8 +7,6 @@ import Detail from '@/components/Detail/Detail' import Master from '@/components/Master/Master' import { fetchCollectionItem, getImageUrl } from '@/lib/api-client' -export const dynamic = 'force-dynamic' - type PageProps = { params: Promise<{ 'works-detail': string diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index d61470d6..e2e59538 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -68,7 +68,7 @@ export async function fetchSingleton( try { const response = await fetch(url, { - cache: options.cache ?? 'no-store', + cache: options.cache ?? 'force-cache', next: { ...(options.revalidate !== undefined && { revalidate: options.revalidate }), tags: options.tags || [`singleton-${model}`], @@ -106,7 +106,7 @@ export async function fetchCollection( try { const response = await fetch(url, { - cache: options.cache ?? 'no-store', + cache: options.cache ?? 'force-cache', next: { ...(options.revalidate !== undefined && { revalidate: options.revalidate }), tags: options.tags || [`collection-${model}`], @@ -144,7 +144,7 @@ export async function fetchCollectionItem( try { const response = await fetch(url, { - cache: options.cache ?? 'no-store', + cache: options.cache ?? 'force-cache', next: { ...(options.revalidate !== undefined && { revalidate: options.revalidate }), tags: options.tags || [`collection-${model}-${id}`], @@ -182,7 +182,7 @@ export async function fetchTree( try { const response = await fetch(url, { - cache: options.cache ?? 'no-store', + cache: options.cache ?? 'force-cache', next: { ...(options.revalidate !== undefined && { revalidate: options.revalidate }), tags: options.tags || [`tree-${model}`],