diff --git a/src/routes/api/github/webhook.ts b/src/routes/api/github/webhook.ts index cfe009743..5c69a1c01 100644 --- a/src/routes/api/github/webhook.ts +++ b/src/routes/api/github/webhook.ts @@ -166,6 +166,11 @@ export const Route = createFileRoute("/api/github/webhook")({ .map((library) => `docs:${library.id}:branch:${gitRef}`), ]; + const affectedLibraries = libraries.filter( + (library) => + library.repo === repo && library.latestBranch === gitRef, + ); + const invalidate = async () => { const [staleContentCount, staleArtifactCount] = await Promise.all([ markGitHubContentStale({ repo, gitRef }), @@ -176,6 +181,27 @@ export const Route = createFileRoute("/api/github/webhook")({ return { purge, staleArtifactCount, staleContentCount }; }; + const warmCaches = async () => { + const { warmDocsArtifacts } = await import( + '~/utils/docs-warm.server' + ); + + await Promise.all( + affectedLibraries.map((library) => + warmDocsArtifacts({ + repo: library.repo, + branch: gitRef, + docsRoot: library.docsRoot ?? 'docs', + }).catch((error) => { + console.warn( + `[GitHub webhook] docs cache warm-up failed for ${library.repo}@${gitRef}`, + error, + ); + }), + ), + ); + }; + if ( scheduleHostRuntimeTask(async () => { try { @@ -187,6 +213,10 @@ export const Route = createFileRoute("/api/github/webhook")({ repo, }); } + + // Proactively rebuild manifests so the next user request is + // served from cache rather than triggering an N+1 build. + await warmCaches(); }) ) { return jsonResponse({ @@ -200,6 +230,9 @@ export const Route = createFileRoute("/api/github/webhook")({ const { purge, staleArtifactCount, staleContentCount } = await invalidate(); + // Warm caches inline when background scheduling is unavailable. + await warmCaches(); + return jsonResponse({ ok: true, gitRef, diff --git a/src/utils/docs-warm.server.ts b/src/utils/docs-warm.server.ts new file mode 100644 index 000000000..6beb5a53c --- /dev/null +++ b/src/utils/docs-warm.server.ts @@ -0,0 +1,44 @@ +/** + * Server-only utility for pre-warming the docs artifact cache. + * + * Call warmDocsArtifacts after marking artifacts stale (e.g. from a GitHub + * webhook) so the next user request is served from cache rather than + * triggering an on-request N+1 GitHub API call. + */ +import { getCachedDocsArtifact } from './github-content-cache.server' +import { + buildDocsManifest, + buildDocsPathManifest, + isDocsManifest, +} from './docs.functions' + +export async function warmDocsArtifacts({ + repo, + branch, + docsRoot, +}: { + repo: string + branch: string + docsRoot: string +}) { + await Promise.all([ + getCachedDocsArtifact({ + repo, + gitRef: branch, + docsRoot, + artifactType: 'docs-manifest', + artifactKey: 'default', + isValue: isDocsManifest, + build: () => buildDocsManifest({ repo, branch, docsRoot }), + }), + getCachedDocsArtifact({ + repo, + gitRef: branch, + docsRoot, + artifactType: 'docs-path-manifest', + artifactKey: 'default', + isValue: isDocsManifest, + build: () => buildDocsPathManifest({ repo, branch, docsRoot }), + }), + ]) +} diff --git a/src/utils/docs.functions.ts b/src/utils/docs.functions.ts index 25e88633e..3d7ce11e4 100644 --- a/src/utils/docs.functions.ts +++ b/src/utils/docs.functions.ts @@ -182,24 +182,18 @@ function setDocsCacheHeaders(cdnCacheControl: string) { setResponseHeader('Cloudflare-CDN-Cache-Control', cdnCacheControl) } -function isDocsManifest(value: unknown): value is DocsManifest { - if (typeof value !== 'object' || value === null) { - return false - } - - const candidate = value as { - paths?: unknown - redirects?: unknown - } - +export function isDocsManifest(value: unknown): value is DocsManifest { return ( - Array.isArray(candidate.paths) && - candidate.paths.every((path) => typeof path === 'string') && - typeof candidate.redirects === 'object' && - candidate.redirects !== null && - Object.entries(candidate.redirects).every( - ([key, target]) => typeof key === 'string' && typeof target === 'string', - ) + typeof value === 'object' && + value !== null && + 'paths' in value && + Array.isArray(value.paths) && + value.paths.every((path) => typeof path === 'string') && + 'redirects' in value && + typeof value.redirects === 'object' && + value.redirects !== null && + !Array.isArray(value.redirects) && + Object.values(value.redirects).every((target) => typeof target === 'string') ) } @@ -261,7 +255,7 @@ export async function collectRedirectEntriesForFile( return entries } -async function buildDocsManifest({ +export async function buildDocsManifest({ repo, branch, docsRoot, @@ -303,7 +297,7 @@ async function buildDocsManifest({ } } -async function buildDocsPathManifest({ +export async function buildDocsPathManifest({ repo, branch, docsRoot, diff --git a/tests/docs-artifact-warming.test.ts b/tests/docs-artifact-warming.test.ts new file mode 100644 index 000000000..fdc036dee --- /dev/null +++ b/tests/docs-artifact-warming.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { isDocsManifest } from '../src/utils/docs.functions' +import { + getCachedDocsArtifact, + markDocsArtifactsStale, + resetGitHubContentCacheForTest, +} from '../src/utils/github-content-cache.server' + +test('manifest validation rejects malformed cached payloads', () => { + assert.equal( + isDocsManifest({ paths: ['overview'], redirects: { old: 'overview' } }), + true, + ) + for (const payload of [ + null, + { paths: [4], redirects: {} }, + { paths: [], redirects: null }, + { paths: [], redirects: [] }, + { paths: [], redirects: { old: 4 } }, + ]) { + assert.equal(isDocsManifest(payload), false) + } +}) + +test('artifact cache shares cold and invalidated builds and recovers after failure', async () => { + resetGitHubContentCacheForTest() + let builds = 0 + let fail = false + const opts = { + repo: 'tanstack/router', + gitRef: 'main', + docsRoot: 'docs', + artifactType: 'docs-manifest', + artifactKey: 'default', + isValue: isDocsManifest, + build: async () => { + builds += 1 + await new Promise((resolve) => setTimeout(resolve, 20)) + if (fail) throw new Error('fixture source unavailable') + return { paths: [`version-${builds}`], redirects: {} } + }, + } + const cold = await Promise.all( + Array.from({ length: 8 }, () => getCachedDocsArtifact(opts)), + ) + assert.equal(builds, 1) + assert.ok(cold.every((value) => value.paths[0] === 'version-1')) + await markDocsArtifactsStale({ repo: opts.repo, gitRef: opts.gitRef }) + const warm = await Promise.all( + Array.from({ length: 8 }, () => getCachedDocsArtifact(opts)), + ) + assert.equal(builds, 2) + assert.ok(warm.every((value) => value.paths[0] === 'version-2')) + await getCachedDocsArtifact(opts) + assert.equal(builds, 2) + resetGitHubContentCacheForTest() + fail = true + await assert.rejects( + getCachedDocsArtifact(opts), + /fixture source unavailable/, + ) + fail = false + assert.deepEqual(await getCachedDocsArtifact(opts), { + paths: ['version-4'], + redirects: {}, + }) +})