diff --git a/packages/gitbook-v2/next.config.mjs b/packages/gitbook-v2/next.config.mjs index 250b9cf0c8..56ee11fb63 100644 --- a/packages/gitbook-v2/next.config.mjs +++ b/packages/gitbook-v2/next.config.mjs @@ -7,10 +7,7 @@ const nextConfig = { experimental: { // This is needed to throw "forbidden" when the api token expired during revalidation authInterrupts: true, - - // We don't use 'use cache' as it's not supported in OpenNext yet - // and instead we scope `unstable_cache` to the parameters of the function - useCache: false, + useCache: true, // Content is fully static, we can cache it in the session memory cache for a long time staleTimes: { diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index 9fe5eefab7..c46ebbd607 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -3,10 +3,17 @@ import { type ComputedContentSource, GitBookAPI, type GitBookAPIServiceBinding, + type HttpResponse, type RenderIntegrationUI, } from '@gitbook/api'; import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags'; -import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env'; +import { + GITBOOK_API_TOKEN, + GITBOOK_API_URL, + GITBOOK_RUNTIME, + GITBOOK_USER_AGENT, +} from '@v2/lib/env'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { unstable_cache } from 'next/cache'; import { getCloudflareContext, getCloudflareRequestGlobal } from './cloudflare'; import { DataFetcherError, wrapDataFetcherError } from './errors'; @@ -212,6 +219,11 @@ export function createDataFetcher( const getUserById = withCacheKey( async (cacheKey, input: DataFetcherInput, params: { userId: string }) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getUserByIdUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => { return getUserByIdUncached(cacheKey, input, params); @@ -230,10 +242,14 @@ const getUserById = withCacheKey( const getUserByIdUncached = withoutConcurrentExecution( getCloudflareRequestGlobal, async (input: DataFetcherInput, params: { userId: string }) => { + 'use cache'; + return trace(`getUserById.uncached(${params.userId})`, async () => { return wrapDataFetcherError(async () => { const api = apiClient(input); const res = await api.users.getUserById(params.userId); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); return res.data; }); }); @@ -249,6 +265,11 @@ const getSpace = withCacheKey( shareKey: string | undefined; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getSpaceUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => { return getSpaceUncached(cacheKey, input, params); @@ -272,12 +293,24 @@ const getSpace = withCacheKey( const getSpaceUncached = withoutConcurrentExecution( getCloudflareRequestGlobal, async (input: DataFetcherInput, params: { spaceId: string; shareKey: string | undefined }) => { + 'use cache'; + + cacheTag( + getCacheTag({ + tag: 'space', + space: params.spaceId, + }) + ); + return trace(`getSpace.uncached(${params.spaceId}, ${params.shareKey})`, async () => { return wrapDataFetcherError(async () => { const api = apiClient(input); const res = await api.spaces.getSpaceById(params.spaceId, { shareKey: params.shareKey, }); + + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); return res.data; }); }); @@ -293,6 +326,11 @@ const getChangeRequest = withCacheKey( changeRequestId: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getChangeRequestUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getChangeRequestUncached(cacheKey, input, params), [cacheKey], @@ -315,6 +353,16 @@ const getChangeRequest = withCacheKey( const getChangeRequestUncached = withoutConcurrentExecution( getCloudflareRequestGlobal, async (input: DataFetcherInput, params: { spaceId: string; changeRequestId: string }) => { + 'use cache'; + + cacheTag( + getCacheTag({ + tag: 'change-request', + space: params.spaceId, + changeRequest: params.changeRequestId, + }) + ); + return trace( `getChangeRequest.uncached(${params.spaceId}, ${params.changeRequestId})`, async () => { @@ -324,6 +372,8 @@ const getChangeRequestUncached = withoutConcurrentExecution( params.spaceId, params.changeRequestId ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('minutes'); return res.data; }); } @@ -341,6 +391,11 @@ const getRevision = withCacheKey( metadata: boolean; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getRevisionUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getRevisionUncached(cacheKey, input, params), [cacheKey], @@ -360,12 +415,16 @@ const getRevisionUncached = withoutConcurrentExecution( input: DataFetcherInput, params: { spaceId: string; revisionId: string; metadata: boolean } ) => { + 'use cache'; + return trace(`getRevision.uncached(${params.spaceId}, ${params.revisionId})`, async () => { return wrapDataFetcherError(async () => { const api = apiClient(input); const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, { metadata: params.metadata, }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data; }); }); @@ -382,6 +441,11 @@ const getRevisionPages = withCacheKey( metadata: boolean; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getRevisionPagesUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getRevisionPagesUncached(cacheKey, input, params), [cacheKey], @@ -401,6 +465,8 @@ const getRevisionPagesUncached = withoutConcurrentExecution( input: DataFetcherInput, params: { spaceId: string; revisionId: string; metadata: boolean } ) => { + 'use cache'; + return trace( `getRevisionPages.uncached(${params.spaceId}, ${params.revisionId})`, async () => { @@ -413,6 +479,8 @@ const getRevisionPagesUncached = withoutConcurrentExecution( metadata: params.metadata, } ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data.pages; }); } @@ -430,6 +498,11 @@ const getRevisionFile = withCacheKey( fileId: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getRevisionFileUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getRevisionFileUncached(cacheKey, input, params), [cacheKey], @@ -449,6 +522,8 @@ const getRevisionFileUncached = withoutConcurrentExecution( input: DataFetcherInput, params: { spaceId: string; revisionId: string; fileId: string } ) => { + 'use cache'; + return trace( `getRevisionFile.uncached(${params.spaceId}, ${params.revisionId}, ${params.fileId})`, async () => { @@ -460,6 +535,8 @@ const getRevisionFileUncached = withoutConcurrentExecution( params.fileId, {} ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data; }); } @@ -477,6 +554,11 @@ const getRevisionPageMarkdown = withCacheKey( pageId: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getRevisionPageMarkdownUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getRevisionPageMarkdownUncached(cacheKey, input, params), [cacheKey], @@ -496,6 +578,8 @@ const getRevisionPageMarkdownUncached = withoutConcurrentExecution( input: DataFetcherInput, params: { spaceId: string; revisionId: string; pageId: string } ) => { + 'use cache'; + return trace( `getRevisionPageMarkdown.uncached(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, async () => { @@ -509,6 +593,10 @@ const getRevisionPageMarkdownUncached = withoutConcurrentExecution( format: 'markdown', } ); + + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + if (!('markdown' in res.data)) { throw new DataFetcherError('Page is not a document', 404); } @@ -529,6 +617,11 @@ const getRevisionPageByPath = withCacheKey( path: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getRevisionPageByPathUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getRevisionPageByPathUncached(cacheKey, input, params), [cacheKey, 'v2'], @@ -548,6 +641,8 @@ const getRevisionPageByPathUncached = withoutConcurrentExecution( input: DataFetcherInput, params: { spaceId: string; revisionId: string; path: string } ) => { + 'use cache'; + return trace( `getRevisionPageByPath.uncached(${params.spaceId}, ${params.revisionId}, ${params.path})`, async () => { @@ -560,6 +655,8 @@ const getRevisionPageByPathUncached = withoutConcurrentExecution( encodedPath, {} ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data; }); } @@ -576,6 +673,11 @@ const getDocument = withCacheKey( documentId: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getDocumentUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getDocumentUncached(cacheKey, input, params), [cacheKey], @@ -592,10 +694,14 @@ const getDocument = withCacheKey( const getDocumentUncached = withoutConcurrentExecution( getCloudflareRequestGlobal, async (input: DataFetcherInput, params: { spaceId: string; documentId: string }) => { + 'use cache'; + return trace(`getDocument.uncached(${params.spaceId}, ${params.documentId})`, async () => { return wrapDataFetcherError(async () => { const api = apiClient(input); const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {}); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data; }); }); @@ -613,6 +719,11 @@ const getComputedDocument = withCacheKey( seed: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getComputedDocumentUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getComputedDocumentUncached(cacheKey, input, params), [cacheKey], @@ -643,6 +754,18 @@ const getComputedDocumentUncached = withoutConcurrentExecution( seed: string; } ) => { + 'use cache'; + + cacheTag( + ...getComputedContentSourceCacheTags( + { + spaceId: params.spaceId, + organizationId: params.organizationId, + }, + params.source + ) + ); + return trace( `getComputedDocument.uncached(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`, async () => { @@ -652,6 +775,8 @@ const getComputedDocumentUncached = withoutConcurrentExecution( source: params.source, seed: params.seed, }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data; }); } @@ -669,6 +794,11 @@ const getReusableContent = withCacheKey( reusableContentId: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getReusableContentUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getReusableContentUncached(cacheKey, input, params), [cacheKey], @@ -688,6 +818,8 @@ const getReusableContentUncached = withoutConcurrentExecution( input: DataFetcherInput, params: { spaceId: string; revisionId: string; reusableContentId: string } ) => { + 'use cache'; + return trace( `getReusableContent.uncached(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, async () => { @@ -698,6 +830,8 @@ const getReusableContentUncached = withoutConcurrentExecution( params.revisionId, params.reusableContentId ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data; }); } @@ -714,6 +848,11 @@ const getLatestOpenAPISpecVersionContent = withCacheKey( slug: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getLatestOpenAPISpecVersionContentUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getLatestOpenAPISpecVersionContentUncached(cacheKey, input, params), [cacheKey], @@ -736,6 +875,16 @@ const getLatestOpenAPISpecVersionContent = withCacheKey( const getLatestOpenAPISpecVersionContentUncached = withoutConcurrentExecution( getCloudflareRequestGlobal, async (input: DataFetcherInput, params: { organizationId: string; slug: string }) => { + 'use cache'; + + cacheTag( + getCacheTag({ + tag: 'openapi', + organization: params.organizationId, + openAPISpec: params.slug, + }) + ); + return trace( `getLatestOpenAPISpecVersionContent.uncached(${params.organizationId}, ${params.slug})`, async () => { @@ -745,6 +894,8 @@ const getLatestOpenAPISpecVersionContentUncached = withoutConcurrentExecution( params.organizationId, params.slug ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data; }); } @@ -762,6 +913,11 @@ const getPublishedContentSite = withCacheKey( siteShareKey: string | undefined; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getPublishedContentSiteUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getPublishedContentSiteUncached(cacheKey, input, params), [cacheKey], @@ -786,6 +942,15 @@ const getPublishedContentSiteUncached = withoutConcurrentExecution( input: DataFetcherInput, params: { organizationId: string; siteId: string; siteShareKey: string | undefined } ) => { + 'use cache'; + + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); + return trace( `getPublishedContentSite.uncached(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`, async () => { @@ -798,6 +963,8 @@ const getPublishedContentSiteUncached = withoutConcurrentExecution( shareKey: params.siteShareKey, } ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); return res.data; }); } @@ -816,6 +983,11 @@ const getSiteRedirectBySource = withCacheKey( source: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getSiteRedirectBySourceUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getSiteRedirectBySourceUncached(cacheKey, input, params), [cacheKey], @@ -845,6 +1017,15 @@ const getSiteRedirectBySourceUncached = withoutConcurrentExecution( source: string; } ) => { + 'use cache'; + + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); + return trace( `getSiteRedirectBySource.uncached(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`, async () => { @@ -858,6 +1039,8 @@ const getSiteRedirectBySourceUncached = withoutConcurrentExecution( source: params.source, } ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); return res.data; }); } @@ -874,6 +1057,11 @@ const getEmbedByUrl = withCacheKey( spaceId: string; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return getEmbedByUrlUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => getEmbedByUrlUncached(cacheKey, input, params), [cacheKey], @@ -890,12 +1078,23 @@ const getEmbedByUrl = withCacheKey( const getEmbedByUrlUncached = withoutConcurrentExecution( getCloudflareRequestGlobal, async (input: DataFetcherInput, params: { spaceId: string; url: string }) => { + 'use cache'; + + cacheTag( + getCacheTag({ + tag: 'space', + space: params.spaceId, + }) + ); + return trace(`getEmbedByUrl.uncached(${params.spaceId}, ${params.url})`, async () => { return wrapDataFetcherError(async () => { const api = apiClient(input); const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url, }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('weeks'); return res.data; }); }); @@ -908,6 +1107,11 @@ const searchSiteContent = withCacheKey( input: DataFetcherInput, params: Parameters[0] ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return searchSiteContentUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => searchSiteContentUncached(cacheKey, input, params), [cacheKey], @@ -927,6 +1131,15 @@ const searchSiteContentUncached = withoutConcurrentExecution( input: DataFetcherInput, params: Parameters[0] ) => { + 'use cache'; + + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); + return trace( `searchSiteContent.uncached(${params.organizationId}, ${params.siteId}, ${params.query})`, async () => { @@ -937,6 +1150,8 @@ const searchSiteContentUncached = withoutConcurrentExecution( query, ...scope, }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('hours'); return res.data.items; }); } @@ -953,6 +1168,11 @@ const renderIntegrationUi = withCacheKey( request: RenderIntegrationUI; } ) => { + if (GITBOOK_RUNTIME !== 'cloudflare') { + return renderIntegrationUiUncached(cacheKey, input, params); + } + + // FIX_ME: OpenNext doesn't support 'use cache' yet const uncached = unstable_cache( async () => renderIntegrationUiUncached(cacheKey, input, params), [cacheKey], @@ -977,6 +1197,15 @@ const renderIntegrationUiUncached = withoutConcurrentExecution( input: DataFetcherInput, params: { integrationName: string; request: RenderIntegrationUI } ) => { + 'use cache'; + + cacheTag( + getCacheTag({ + tag: 'integration', + integration: params.integrationName, + }) + ); + return trace(`renderIntegrationUi.uncached(${params.integrationName})`, async () => { return wrapDataFetcherError(async () => { const api = apiClient(input); @@ -984,6 +1213,8 @@ const renderIntegrationUiUncached = withoutConcurrentExecution( params.integrationName, params.request ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); return res.data; }); }); @@ -1040,3 +1271,13 @@ export function apiClient(input: DataFetcherInput = { apiToken: null }) { return api; } + +/** + * Get the tags from the API responses. + */ +function getCacheTagsFromResponse(response: HttpResponse) { + const cacheTagHeader = response.headers.get('x-gitbook-cache-tag'); + const tags = !cacheTagHeader ? [] : cacheTagHeader.split(','); + console.log('getCacheTagsFromResponse', tags); + return tags; +} diff --git a/packages/gitbook-v2/src/lib/data/errors.ts b/packages/gitbook-v2/src/lib/data/errors.ts index 0c083fa3ed..784eebdbb5 100644 --- a/packages/gitbook-v2/src/lib/data/errors.ts +++ b/packages/gitbook-v2/src/lib/data/errors.ts @@ -98,6 +98,10 @@ export async function wrapDataFetcherError( */ export function getExposableError(error: Error): DataFetcherErrorData { if (error instanceof GitBookAPIError) { + if (error.code >= 500) { + throw error; + } + return { code: error.code, message: error.errorMessage, @@ -105,6 +109,10 @@ export function getExposableError(error: Error): DataFetcherErrorData { } if (error instanceof DataFetcherError) { + if (error.code >= 500) { + throw error; + } + return { code: error.code, message: error.message,