Skip to content

Commit ff2b8a4

Browse files
committed
feat: implement stale fallback for GitHub content fetching and enhance error handling
1 parent b79952e commit ff2b8a4

File tree

4 files changed

+318
-24
lines changed

4 files changed

+318
-24
lines changed

src/utils/cache.server.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,70 @@ const docCache =
1212
max: 300,
1313
// ttl: 1,
1414
ttl: process.env.NODE_ENV === 'production' ? 1 : 1000000,
15+
deleteOnStale: false,
1516
}))
1617

1718
const pendingDocCache = new Map<string, Promise<unknown>>()
1819

20+
export function getCached<T>(key: string): T | undefined {
21+
if (!docCache.has(key)) {
22+
return undefined
23+
}
24+
25+
return docCache.get(key) as T
26+
}
27+
28+
export function getStaleCached<T>(key: string): T | undefined {
29+
return docCache.get(key, { allowStale: true }) as T | undefined
30+
}
31+
32+
export async function fetchCachedWithStaleFallback<T>(opts: {
33+
fn: () => Promise<T>
34+
key: string
35+
ttl: number
36+
shouldFallbackToStale: (error: unknown) => boolean
37+
onStaleFallback?: (error: unknown) => void
38+
}): Promise<T> {
39+
const cached = getCached<T>(opts.key)
40+
41+
if (cached !== undefined) {
42+
return cached
43+
}
44+
45+
const pending = pendingDocCache.get(opts.key)
46+
47+
if (pending) {
48+
return pending as Promise<T>
49+
}
50+
51+
const resultPromise = opts
52+
.fn()
53+
.then((result) => {
54+
docCache.set(opts.key, result, {
55+
ttl: opts.ttl,
56+
})
57+
58+
return result
59+
})
60+
.catch((error) => {
61+
const stale = getStaleCached<T>(opts.key)
62+
63+
if (stale !== undefined && opts.shouldFallbackToStale(error)) {
64+
opts.onStaleFallback?.(error)
65+
return stale
66+
}
67+
68+
throw error
69+
})
70+
.finally(() => {
71+
pendingDocCache.delete(opts.key)
72+
})
73+
74+
pendingDocCache.set(opts.key, resultPromise)
75+
76+
return resultPromise
77+
}
78+
1979
export async function fetchCached<T>(opts: {
2080
fn: () => Promise<T>
2181
key: string

src/utils/config.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import * as v from 'valibot'
2-
import { fetchRepoFile } from './documents.server'
2+
import {
3+
fetchRepoFile,
4+
isRecoverableGitHubContentError,
5+
} from './documents.server'
36
import { createServerFn } from '@tanstack/react-start'
47
import { setResponseHeaders } from '@tanstack/react-start/server'
58

@@ -48,6 +51,10 @@ const configSchema = v.object({
4851

4952
export type ConfigSchema = v.InferOutput<typeof configSchema>
5053

54+
const EMPTY_CONFIG: ConfigSchema = {
55+
sections: [],
56+
}
57+
5158
/**
5259
Fetch the config file for the project and validate it.
5360
*/
@@ -56,7 +63,23 @@ export const getTanstackDocsConfig = createServerFn({ method: 'GET' })
5663
v.object({ repo: v.string(), branch: v.string(), docsRoot: v.string() }),
5764
)
5865
.handler(async ({ data: { repo, branch, docsRoot } }) => {
59-
const config = await fetchRepoFile(repo, branch, `${docsRoot}/config.json`)
66+
let config: string | null
67+
68+
try {
69+
config = await fetchRepoFile(repo, branch, `${docsRoot}/config.json`)
70+
} catch (error) {
71+
if (isRecoverableGitHubContentError(error)) {
72+
console.warn('[getTanstackDocsConfig] Falling back to empty config:', {
73+
repo,
74+
branch,
75+
docsRoot,
76+
message: error instanceof Error ? error.message : String(error),
77+
})
78+
return EMPTY_CONFIG
79+
}
80+
81+
throw error
82+
}
6083

6184
if (!config) {
6285
throw new Error(`Repo's ${docsRoot}/config.json was not found!`)

src/utils/docs.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,57 @@ import {
22
extractFrontMatter,
33
fetchApiContents,
44
fetchRepoFile,
5+
isRecoverableGitHubContentError,
56
} from '~/utils/documents.server'
67
import removeMarkdown from 'remove-markdown'
78
import { notFound } from '@tanstack/react-router'
89
import { createServerFn } from '@tanstack/react-start'
910
import * as v from 'valibot'
1011
import { setResponseHeader } from '@tanstack/react-start/server'
1112

13+
const DOCS_UNAVAILABLE_TITLE = 'Content temporarily unavailable'
14+
15+
function buildUnavailableDoc(filePath: string) {
16+
return {
17+
title: DOCS_UNAVAILABLE_TITLE,
18+
description:
19+
'This page could not be refreshed from GitHub right now. Please try again shortly.',
20+
filePath,
21+
content: [
22+
'# Content temporarily unavailable',
23+
'',
24+
'This page could not be refreshed from GitHub right now.',
25+
'Please try again shortly.',
26+
].join('\n'),
27+
frontmatter: {
28+
title: DOCS_UNAVAILABLE_TITLE,
29+
description:
30+
'This page could not be refreshed from GitHub right now. Please try again shortly.',
31+
},
32+
}
33+
}
34+
35+
function buildUnavailableFile(filePath: string) {
36+
const lowerFilePath = filePath.toLowerCase()
37+
const isMarkdown = lowerFilePath.endsWith('.md')
38+
39+
if (isMarkdown) {
40+
return [
41+
'# Content temporarily unavailable',
42+
'',
43+
'This file could not be refreshed from GitHub right now.',
44+
'Please try again shortly.',
45+
].join('\n')
46+
}
47+
48+
return [
49+
'// Content temporarily unavailable',
50+
`// ${filePath}`,
51+
'// This file could not be refreshed from GitHub right now.',
52+
'// Please try again shortly.',
53+
].join('\n')
54+
}
55+
1256
export const loadDocs = async ({
1357
repo,
1458
branch,
@@ -42,7 +86,23 @@ export const fetchDocs = createServerFn({ method: 'GET' })
4286
v.object({ repo: v.string(), branch: v.string(), filePath: v.string() }),
4387
)
4488
.handler(async ({ data: { repo, branch, filePath } }) => {
45-
const file = await fetchRepoFile(repo, branch, filePath)
89+
let file: string | null
90+
91+
try {
92+
file = await fetchRepoFile(repo, branch, filePath)
93+
} catch (error) {
94+
if (isRecoverableGitHubContentError(error)) {
95+
console.warn('[fetchDocs] Falling back to unavailable placeholder:', {
96+
repo,
97+
branch,
98+
filePath,
99+
message: error instanceof Error ? error.message : String(error),
100+
})
101+
return buildUnavailableDoc(filePath)
102+
}
103+
104+
throw error
105+
}
46106

47107
if (!file) {
48108
throw notFound()
@@ -73,7 +133,23 @@ export const fetchFile = createServerFn({ method: 'GET' })
73133
v.object({ repo: v.string(), branch: v.string(), filePath: v.string() }),
74134
)
75135
.handler(async ({ data: { repo, branch, filePath } }) => {
76-
const file = await fetchRepoFile(repo, branch, filePath)
136+
let file: string | null
137+
138+
try {
139+
file = await fetchRepoFile(repo, branch, filePath)
140+
} catch (error) {
141+
if (isRecoverableGitHubContentError(error)) {
142+
console.warn('[fetchFile] Falling back to unavailable placeholder:', {
143+
repo,
144+
branch,
145+
filePath,
146+
message: error instanceof Error ? error.message : String(error),
147+
})
148+
return buildUnavailableFile(filePath)
149+
}
150+
151+
throw error
152+
}
77153

78154
if (!file) {
79155
throw notFound()

0 commit comments

Comments
 (0)