diff --git a/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx b/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx index 1e749592c..e53e77668 100644 --- a/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx +++ b/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx @@ -1,10 +1,5 @@ import { isNotFound, notFound, createFileRoute } from '@tanstack/react-router' -import { - queryOptions, - useQuery, - useQueryClient, - useSuspenseQuery, -} from '@tanstack/react-query' +import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query' import React from 'react' import { DocTitle } from '~/components/DocTitle' import { Framework, getBranch, getLibrary } from '~/libraries' @@ -52,7 +47,6 @@ const repoDirApiContentsQueryOptions = ( export const Route = createFileRoute( '/$libraryId/$version/docs/framework/$framework/examples/$', )({ - component: RouteComponent, validateSearch: v.object({ path: v.optional(v.string()), panel: v.optional(v.string()), @@ -62,6 +56,9 @@ export const Route = createFileRoute( const library = getLibrary(params.libraryId) const branch = getBranch(library, params.version) const examplePath = [params.framework, params._splat].join('/') + const fallbackPath = + path || + getExampleStartingPath(params.framework as Framework, params.libraryId) // Used to tell the github contents api where to start looking for files in the target repository const repoStartingDirPath = `examples/${examplePath}` @@ -80,9 +77,7 @@ export const Route = createFileRoute( // It's either the selected path in the search params or a default we can derive // i.e. app.tsx, main.tsx, src/routes/__root.tsx, etc. // This value is not absolutely guaranteed to be available, so further resolution may be necessary - const explorerCandidateStartingFileName = - path || - getExampleStartingPath(params.framework as Framework, params.libraryId) + const explorerCandidateStartingFileName = fallbackPath // Using the fetched contents, get the actual starting file-path for the explorer // The `explorerCandidateStartingFileName` is used for matching, but the actual file-path may differ @@ -99,7 +94,11 @@ export const Route = createFileRoute( fileQueryOptions(library.repo, branch, currentPath), ) - return { repoStartingDirPath, currentPath } + return { + repoStartingDirPath, + currentPath, + githubContentsAvailable: true, + } } catch (error) { const isNotFoundError = isNotFound(error) || @@ -107,7 +106,17 @@ export const Route = createFileRoute( if (isNotFoundError) { throw notFound() } - throw error + + console.warn( + `Failed to fetch example contents for ${library.repo}@${branch}:${repoStartingDirPath}`, + error, + ) + + return { + repoStartingDirPath, + currentPath: fallbackPath, + githubContentsAvailable: false, + } } }, head: ({ params }) => { @@ -125,6 +134,32 @@ export const Route = createFileRoute( }), } }, + component: RouteComponent, + headers: ({ params }) => { + const { version, libraryId } = params + const library = getLibrary(libraryId) + + const isLatestVersion = + version === 'latest' || + version === library.latestVersion || + version === library.latestBranch + + if (isLatestVersion) { + return { + 'cache-control': 'public, max-age=60, must-revalidate', + 'cdn-cache-control': + 'max-age=600, stale-while-revalidate=3600, durable', + vary: 'Accept-Encoding', + } + } + + return { + 'cache-control': 'public, max-age=3600, must-revalidate', + 'cdn-cache-control': + 'max-age=86400, stale-while-revalidate=604800, durable', + vary: 'Accept-Encoding', + } + }, staleTime: 1000 * 60 * 5, // 5 minutes }) @@ -134,7 +169,8 @@ function RouteComponent() { } function PageComponent() { - const { repoStartingDirPath, currentPath } = Route.useLoaderData() + const { repoStartingDirPath, currentPath, githubContentsAvailable } = + Route.useLoaderData() const navigate = Route.useNavigate() const queryClient = useQueryClient() @@ -149,9 +185,14 @@ function PageComponent() { libraryId, ) - const { data: githubContents } = useSuspenseQuery( - repoDirApiContentsQueryOptions(library.repo, branch, repoStartingDirPath), - ) + const { data: githubContents } = useQuery({ + ...repoDirApiContentsQueryOptions( + library.repo, + branch, + repoStartingDirPath, + ), + enabled: githubContentsAvailable, + }) const [isDark, setIsDark] = React.useState(true) const [deployDialogOpen, setDeployDialogOpen] = React.useState(false) @@ -197,9 +238,10 @@ function PageComponent() { }) } - const { data: currentCode } = useQuery( - fileQueryOptions(library.repo, branch, currentPath), - ) + const { data: currentCode } = useQuery({ + ...fileQueryOptions(library.repo, branch, currentPath), + enabled: githubContentsAvailable, + }) const prefetchFileContent = React.useCallback( (path: string) => { @@ -312,19 +354,27 @@ function PageComponent() {
- + {githubContentsAvailable && githubContents ? ( + + ) : ( +
+ The example source browser is temporarily unavailable. You can still + open this example on GitHub, StackBlitz, or CodeSandbox using the + links above. +
+ )}
{deployProvider && ( + var docStaleCache: LRUCache } const docCache = @@ -12,20 +13,42 @@ const docCache = ttl: process.env.NODE_ENV === 'production' ? 1 : 1000000, })) +const docStaleCache = + globalThis.docStaleCache || + (globalThis.docStaleCache = new LRUCache({ + max: 300, + })) + export async function fetchCached(opts: { fn: () => Promise key: string ttl: number + staleOnError?: boolean }): Promise { if (docCache.has(opts.key)) { return docCache.get(opts.key) as T } - const result = await opts.fn() + try { + const result = await opts.fn() - docCache.set(opts.key, result, { - ttl: opts.ttl, - }) + docCache.set(opts.key, result, { + ttl: opts.ttl, + }) - return result + if (opts.staleOnError) { + docStaleCache.set(opts.key, result) + } + + return result + } catch (error) { + if (opts.staleOnError && docStaleCache.has(opts.key)) { + console.warn( + `[fetchCached] Serving stale value for key '${opts.key}' after fetch error`, + ) + return docStaleCache.get(opts.key) as T + } + + throw error + } } diff --git a/src/utils/documents.server.ts b/src/utils/documents.server.ts index 6e6c730f1..e54573b72 100644 --- a/src/utils/documents.server.ts +++ b/src/utils/documents.server.ts @@ -287,6 +287,7 @@ export async function fetchRepoFile( const file = await fetchCached({ key, ttl, + staleOnError: true, fn: async () => { const maxDepth = 4 let currentDepth = 1 @@ -423,6 +424,7 @@ export function fetchApiContents( return fetchCached({ key: `${repoPair}:${branch}:${startingPath}`, ttl: isDev ? 1 : 10 * 60 * 1000, // 10 minute + staleOnError: true, fn: () => { return isDev ? fetchApiContentsFs(repoPair, startingPath) @@ -541,10 +543,16 @@ async function fetchApiContentsRemote( branch: string, startingPath: string, ): Promise | null> { + const githubToken = env.GITHUB_AUTH_TOKEN + const hasConfiguredGitHubToken = + Boolean(githubToken) && githubToken !== 'USE_A_REAL_KEY_IN_PRODUCTION' + const fetchOptions: RequestInit = { headers: { 'X-GitHub-Api-Version': '2022-11-28', - Authorization: `Bearer ${env.GITHUB_AUTH_TOKEN}`, + ...(hasConfiguredGitHubToken + ? { Authorization: `Bearer ${githubToken}` } + : {}), }, } const res = await fetch( @@ -556,8 +564,42 @@ async function fetchApiContentsRemote( if (res.status === 404) { return null } + + const githubRequestId = res.headers.get('x-github-request-id') + const rateLimitLimit = res.headers.get('x-ratelimit-limit') + const rateLimitRemaining = res.headers.get('x-ratelimit-remaining') + const rateLimitReset = res.headers.get('x-ratelimit-reset') + + let errorBody = '' + try { + errorBody = (await res.text()).replace(/\s+/g, ' ').trim() + } catch { + // Ignore parse failures for error response body + } + + if (res.status === 403) { + console.error('[GitHub API] 403 while fetching repository contents', { + repo, + branch, + startingPath, + hasConfiguredGitHubToken, + githubRequestId, + rateLimitLimit, + rateLimitRemaining, + rateLimitReset, + errorBody: errorBody.slice(0, 500), + }) + } + + const hint = + res.status === 403 + ? rateLimitRemaining === '0' + ? 'GitHub rate limit exceeded.' + : 'GitHub forbidden. Check token permissions/access.' + : 'GitHub request failed.' + throw new Error( - `Failed to fetch repo contents for ${repo}/${branch}/${startingPath}: Status is ${res.statusText} - ${res.status}`, + `${hint} Failed to fetch repo contents for ${repo}/${branch}/${startingPath}: Status is ${res.statusText} - ${res.status}. requestId=${githubRequestId ?? 'unknown'} rateLimitRemaining=${rateLimitRemaining ?? 'unknown'} rateLimitLimit=${rateLimitLimit ?? 'unknown'} rateLimitReset=${rateLimitReset ?? 'unknown'}${errorBody ? ` body=${errorBody.slice(0, 500)}` : ''}`, ) }