diff --git a/src/routes/$libraryId/$version.docs.$.tsx b/src/routes/$libraryId/$version.docs.$.tsx index b9017bcd..333a24b0 100644 --- a/src/routes/$libraryId/$version.docs.$.tsx +++ b/src/routes/$libraryId/$version.docs.$.tsx @@ -96,6 +96,14 @@ export const Route = createFileRoute('/$libraryId/$version/docs/$')({ const { version, libraryId } = params const library = findLibrary(libraryId) + const cacheTag = library + ? [ + 'docs:all', + `docs:${library.id}`, + `docs:${library.id}:branch:${getBranch(library, version)}`, + ].join(', ') + : 'docs:all' + const isLatestVersion = library && (version === 'latest' || @@ -107,6 +115,7 @@ export const Route = createFileRoute('/$libraryId/$version/docs/$')({ 'cache-control': 'public, max-age=60, must-revalidate', 'cdn-cache-control': 'max-age=600, stale-while-revalidate=3600, durable', + 'netlify-cache-tag': cacheTag, vary: docsContentNegotiationVaryHeader, } } else { @@ -114,6 +123,7 @@ export const Route = createFileRoute('/$libraryId/$version/docs/$')({ 'cache-control': 'public, max-age=3600, must-revalidate', 'cdn-cache-control': 'max-age=86400, stale-while-revalidate=604800, durable', + 'netlify-cache-tag': cacheTag, vary: docsContentNegotiationVaryHeader, } } diff --git a/src/routes/api/github/webhook.ts b/src/routes/api/github/webhook.ts index 1dc3bf02..42306273 100644 --- a/src/routes/api/github/webhook.ts +++ b/src/routes/api/github/webhook.ts @@ -53,11 +53,17 @@ export const Route = createFileRoute("/api/github/webhook")({ server: { handlers: { POST: async ({ request }: { request: Request }) => { - const [{ markDocsArtifactsStale, markGitHubContentStale }, { env }] = - await Promise.all([ - import("~/utils/github-content-cache.server"), - import("~/utils/env"), - ]); + const [ + { markDocsArtifactsStale, markGitHubContentStale }, + { env }, + { libraries }, + { purgeNetlifyTags }, + ] = await Promise.all([ + import("~/utils/github-content-cache.server"), + import("~/utils/env"), + import("~/libraries"), + import("~/utils/netlify-purge.server"), + ]); const rawBody = await request.text(); const event = request.headers.get("x-github-event"); const signature = request.headers.get("x-hub-signature-256"); @@ -132,12 +138,25 @@ export const Route = createFileRoute("/api/github/webhook")({ markDocsArtifactsStale({ repo, gitRef }), ]); + const tags = [ + `docs-config:${repo}:${gitRef}`, + ...libraries + .filter( + (library) => + library.repo === repo && library.latestBranch === gitRef, + ) + .map((library) => `docs:${library.id}:branch:${gitRef}`), + ]; + + const purge = await purgeNetlifyTags(tags); + return Response.json({ ok: true, gitRef, changedPathCount: changedPaths.length, staleArtifactCount, staleContentCount, + purge, }); }, }, diff --git a/src/utils/config.ts b/src/utils/config.ts index c92cd158..ae3c2454 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -106,6 +106,11 @@ export const getTanstackDocsConfig = createServerFn({ method: 'GET' }) 'Cache-Control': 'public, max-age=0, must-revalidate', 'Netlify-CDN-Cache-Control': 'public, max-age=300, durable, stale-while-revalidate=300', + 'Netlify-Cache-Tag': [ + 'docs-config:all', + `docs-config:${repo}`, + `docs-config:${repo}:${branch}`, + ].join(', '), }), ) diff --git a/src/utils/docs-admin.server.ts b/src/utils/docs-admin.server.ts index a36e71b6..9fc89ba9 100644 --- a/src/utils/docs-admin.server.ts +++ b/src/utils/docs-admin.server.ts @@ -1,12 +1,14 @@ import { sql } from 'drizzle-orm' import { db } from '~/db/client' import { docsArtifactCache, githubContentCache } from '~/db/schema' +import { libraries } from '~/libraries' import { docsWebhookSources } from '~/utils/docs-webhook-sources' import { requireCapability } from './auth.server' import { markDocsArtifactsStale, markGitHubContentStale, } from './github-content-cache.server' +import { purgeNetlifyTags } from './netlify-purge.server' function normalizeDateValue(value: Date | number | string | null | undefined) { if (!value) { @@ -170,10 +172,22 @@ export async function invalidateDocsCacheAdmin(opts: { markDocsArtifactsStale({ repo: opts.data.repo }), ]) + const tags = opts.data.repo + ? [ + `docs-config:${opts.data.repo}`, + ...libraries + .filter((library) => library.repo === opts.data.repo) + .map((library) => `docs:${library.id}`), + ] + : ['docs:all', 'docs-config:all'] + + const purge = await purgeNetlifyTags(tags) + return { repo: opts.data.repo ?? null, staleContentCount, staleArtifactCount, totalInvalidated: staleContentCount + staleArtifactCount, + purge, } } diff --git a/src/utils/netlify-purge.server.ts b/src/utils/netlify-purge.server.ts new file mode 100644 index 00000000..22e565e3 --- /dev/null +++ b/src/utils/netlify-purge.server.ts @@ -0,0 +1,42 @@ +import { purgeCache } from '@netlify/functions' +import * as Sentry from '@sentry/node' + +export type PurgeResult = + | { purged: true; tags: Array } + | { + purged: false + reason: 'no-tags' | 'no-credentials' | 'error' + error?: string + } + +export async function purgeNetlifyTags( + tags: Array, +): Promise { + const uniqueTags = Array.from(new Set(tags)).filter((tag) => tag.length > 0) + + if (uniqueTags.length === 0) { + return { purged: false, reason: 'no-tags' } + } + + // SITE_ID + NETLIFY_PURGE_API_TOKEN are auto-injected when running on + // Netlify. Absent locally — no-op so dev workflows still work. + if (!process.env.SITE_ID || !process.env.NETLIFY_PURGE_API_TOKEN) { + return { purged: false, reason: 'no-credentials' } + } + + try { + await purgeCache({ tags: uniqueTags }) + return { purged: true, tags: uniqueTags } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error('[netlify-purge] purgeCache failed', { + tags: uniqueTags, + message, + }) + Sentry.captureException(error, { + tags: { runtime: 'server', context: 'netlify-purge' }, + extra: { tags: uniqueTags }, + }) + return { purged: false, reason: 'error', error: message } + } +}