From d19ea6a81c72943b1f2c4727df8efd3f51f4256e Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 19 May 2026 19:34:38 -0600 Subject: [PATCH] feat(docs-cache): purge Netlify CDN tags on invalidation The DB-level SWR fix only refreshes the cached GitHub content rows; the Netlify CDN sits in front of the SSR with its own 10min/1d SWR windows, so clicking "invalidate" or receiving a push webhook still left stale rendered pages in front of users for up to ~10 minutes. Attach Netlify-Cache-Tag headers to the docs route and the getTanstackDocsConfig server function with three tiers (docs:all, docs:{libId}, docs:{libId}:branch:{resolvedBranch}) so we can purge at any granularity. Then issue purgeCache calls from both invalidateDocsCacheAdmin and the push webhook after the existing DB mark calls. Purge keys off the resolved branch (via getBranch), so a single push invalidates the latest/v5/main URL variants together. Failures are logged + sent to Sentry but never thrown - the DB rows are already stale and SWR will eventually catch up. Falls back to a silent no-op locally when SITE_ID / NETLIFY_PURGE_API_TOKEN are absent. --- src/routes/$libraryId/$version.docs.$.tsx | 10 ++++++ src/routes/api/github/webhook.ts | 29 +++++++++++++--- src/utils/config.ts | 5 +++ src/utils/docs-admin.server.ts | 14 ++++++++ src/utils/netlify-purge.server.ts | 42 +++++++++++++++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 src/utils/netlify-purge.server.ts diff --git a/src/routes/$libraryId/$version.docs.$.tsx b/src/routes/$libraryId/$version.docs.$.tsx index b9017bcdc..333a24b03 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 1dc3bf028..423062731 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 c92cd1587..ae3c24542 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 a36e71b68..9fc89ba9f 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 000000000..22e565e35 --- /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 } + } +}