Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/routes/api/github/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand All @@ -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 {
Expand All @@ -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({
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions src/utils/docs-warm.server.ts
Original file line number Diff line number Diff line change
@@ -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 }),
}),
])
}
32 changes: 13 additions & 19 deletions src/utils/docs.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)
}

Expand Down Expand Up @@ -261,7 +255,7 @@ export async function collectRedirectEntriesForFile(
return entries
}

async function buildDocsManifest({
export async function buildDocsManifest({
repo,
branch,
docsRoot,
Expand Down Expand Up @@ -303,7 +297,7 @@ async function buildDocsManifest({
}
}

async function buildDocsPathManifest({
export async function buildDocsPathManifest({
repo,
branch,
docsRoot,
Expand Down
68 changes: 68 additions & 0 deletions tests/docs-artifact-warming.test.ts
Original file line number Diff line number Diff line change
@@ -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: {},
})
})
Loading