diff --git a/.changeset/ppr-cache-tag-prefix.md b/.changeset/ppr-cache-tag-prefix.md new file mode 100644 index 000000000..5a1f4f76f --- /dev/null +++ b/.changeset/ppr-cache-tag-prefix.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Prefix all cache tags with `ppr:` when rendering under the PPR route, so PPR cache entries are partitioned from the static ones. diff --git a/packages/gitbook/src/app/utils.ts b/packages/gitbook/src/app/utils.ts index 7d82dc0ba..c11613faa 100644 --- a/packages/gitbook/src/app/utils.ts +++ b/packages/gitbook/src/app/utils.ts @@ -43,7 +43,8 @@ export type PPRRouteParams = PPRRouteLayoutParams & { */ export async function getStaticSiteContext( params: RouteLayoutParams, - getClaims = getVisitorAuthClaimsFromToken + getClaims = getVisitorAuthClaimsFromToken, + options?: { ppr?: true } ) { const siteURL = getSiteURLFromParams(params); const siteURLData = getSiteURLDataFromParams(params); @@ -60,6 +61,7 @@ export async function getStaticSiteContext( siteURL, siteURLData, urlMode: getModeFromParams(params.mode), + ...(options?.ppr ? { ppr: options.ppr } : {}), }), siteURLData ); @@ -224,7 +226,7 @@ export function getPPRTableOfContentsRouteParams(params: PPRRouteLayoutParams): } export async function getPPRStaticSiteContext(params: RouteLayoutParams) { - return getStaticSiteContext(params, getPPRVisitorAuthClaimsFromToken); + return getStaticSiteContext(params, getPPRVisitorAuthClaimsFromToken, { ppr: true }); } function getPPRRouteParam(encodedParam: string, name: string): string { diff --git a/packages/gitbook/src/components/SitePage/PPRSitePage.tsx b/packages/gitbook/src/components/SitePage/PPRSitePage.tsx index 6c2bdf70a..88931279e 100644 --- a/packages/gitbook/src/components/SitePage/PPRSitePage.tsx +++ b/packages/gitbook/src/components/SitePage/PPRSitePage.tsx @@ -3,9 +3,9 @@ import { type RouteParams, getPPRStaticSiteContext, getPagePathFromParams, - getStaticSiteContext, } from '@/app/utils'; import { SpaceHeader, SpaceTableOfContents } from '@/components/SpaceLayout'; +import { prefixCacheTags } from '@/lib/cache-tags'; import { getCacheTag } from '@gitbook/cache-tags'; @@ -23,14 +23,19 @@ export async function PPRHeader(props: { params: RouteLayoutParams }) { console.log('PPRHeader props.params', props.params); - const { context } = await getStaticSiteContext(props.params); + const { context } = await getPPRStaticSiteContext(props.params); // We only need the site cache tag for the header, as the header is not dependent on the page or space content. cacheTag( - getCacheTag({ - tag: 'site', - site: context.site.id, - }) + ...prefixCacheTags( + [ + getCacheTag({ + tag: 'site', + site: context.site.id, + }), + ], + true + ) ); // Tag the cache entry for the header so it can be invalidated when the site changes return ; @@ -80,18 +85,22 @@ export async function cachedGenerateSitePageViewport(routeParams: RouteParams): const { context } = await getPPRStaticSiteContext(routeParams); cacheTag( - getCacheTag({ - tag: 'site', - site: context.site.id, - }) - ); // Tag the cache entry for the metadata so it can be invalidated when the site changes - - cacheTag( - getCacheTag({ - tag: 'space', - space: context.space.id, - }) - ); // Tag the cache entry for the metadata so it can be invalidated when the space changes + ...prefixCacheTags( + [ + // Tag the cache entry for the metadata so it can be invalidated when the site changes + getCacheTag({ + tag: 'site', + site: context.site.id, + }), + // ...or when the space changes + getCacheTag({ + tag: 'space', + space: context.space.id, + }), + ], + true + ) + ); return generateSitePageViewport(context); } diff --git a/packages/gitbook/src/lib/cache-tags.ts b/packages/gitbook/src/lib/cache-tags.ts new file mode 100644 index 000000000..f8eaa1ecd --- /dev/null +++ b/packages/gitbook/src/lib/cache-tags.ts @@ -0,0 +1,12 @@ +/** + * Cache tags emitted while rendering the PPR route are prefixed, so that PPR cache entries + * live in their own namespace and are invalidated independently from the static ones. + */ +export const PPR_CACHE_TAG_PREFIX = 'ppr:'; + +/** + * Prefix cache tags when rendering under the PPR route, leave them untouched otherwise. + */ +export function prefixCacheTags(tags: string[], ppr: boolean | undefined): string[] { + return ppr ? tags.map((tag) => `${PPR_CACHE_TAG_PREFIX}${tag}`) : tags; +} diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts index 85a2debfc..37bd01a15 100644 --- a/packages/gitbook/src/lib/context.ts +++ b/packages/gitbook/src/lib/context.ts @@ -228,12 +228,15 @@ export function getBaseContext(input: { siteURL: URL | string; siteURLData: SiteURLData; urlMode: 'url' | 'url-host'; + /** Set when rendering under the PPR route, to prefix the cache tags emitted by the fetcher. */ + ppr?: true; }) { const { urlMode, siteURLData } = input; const siteURL = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL; const dataFetcher = createDataFetcher({ apiToken: siteURLData.apiToken ?? null, + ...(input.ppr ? { ppr: input.ppr } : {}), }); const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined; diff --git a/packages/gitbook/src/lib/data/api.ts b/packages/gitbook/src/lib/data/api.ts index 29a77d0f4..11dd296c4 100644 --- a/packages/gitbook/src/lib/data/api.ts +++ b/packages/gitbook/src/lib/data/api.ts @@ -13,6 +13,7 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t import { cache } from '../cache'; import { DataFetcherError, wrapDataFetcherError } from './errors'; import type { GitBookDataFetcher } from './types'; +import { prefixCacheTags } from '@/lib/cache-tags'; import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env'; import { trace } from '@/lib/tracing'; @@ -21,6 +22,12 @@ interface DataFetcherInput { * API token. */ apiToken: string | null; + + /** + * Set when rendering under the PPR route. It is part of the cache key, so PPR cache entries + * are never shared with the static ones and can carry their own `ppr:` prefixed cache tags. + */ + ppr?: true; } /** @@ -37,15 +44,24 @@ export const noCacheFetchOptions: Partial = { * The data are being cached by Next.js built-in cache. */ export function createDataFetcher( - input: DataFetcherInput = { apiToken: null } + rawInput: DataFetcherInput = { apiToken: null } ): GitBookDataFetcher { + // The input is part of the cache key of every cached fetcher below, so we normalize its shape + // here: `ppr` is omitted entirely when unset, keeping non-PPR cache keys unchanged. + const input: DataFetcherInput = rawInput.ppr + ? { apiToken: rawInput.apiToken, ppr: true } + : { apiToken: rawInput.apiToken }; + return { + ppr: input.ppr, + async api() { return apiClient(input); }, withToken({ apiToken }) { return createDataFetcher({ + ...input, apiToken, }); }, @@ -246,7 +262,7 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri const res = await api.users.getUserById(params.userId, { ...noCacheFetchOptions, }); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('days'); return res.data; }); @@ -256,12 +272,12 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri const getSpace = cache( async (input: DataFetcherInput, params: { spaceId: string; shareKey: string | undefined }) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'space', space: params.spaceId, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => { @@ -275,7 +291,7 @@ const getSpace = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('days'); return res.data; }); @@ -286,13 +302,13 @@ const getSpace = cache( const getChangeRequest = cache( async (input: DataFetcherInput, params: { spaceId: string; changeRequestId: string }) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'change-request', space: params.spaceId, changeRequest: params.changeRequestId, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace( @@ -306,7 +322,7 @@ const getChangeRequest = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('minutes'); return res.data; } @@ -332,7 +348,7 @@ const getRevision = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('max'); return res.data; }); @@ -346,13 +362,13 @@ const getChangeRequestChanges = cache( params: { spaceId: string; changeRequestId: string; limit?: number } ) => { 'use cache: remote'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'change-request', space: params.spaceId, changeRequest: params.changeRequestId, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace( @@ -369,7 +385,7 @@ const getChangeRequestChanges = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('minutes'); return res.data; } @@ -401,7 +417,7 @@ const getRevisionSemanticChanges = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('max'); return res.data; } @@ -438,7 +454,7 @@ const getRevisionPageMarkdown = cache( } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('max'); if (!('markdown' in res.data)) { @@ -480,7 +496,7 @@ const getRevisionPageDocument = cache( } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLifeFromResponse(res, 'max'); return res.data; @@ -513,7 +529,7 @@ const getRevisionReusableContentDocument = cache( } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLifeFromResponse(res, 'max'); return res.data; @@ -546,7 +562,7 @@ const getRevisionPageByPath = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('max'); return res.data; } @@ -569,7 +585,7 @@ const getDocument = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLifeFromResponse(res, 'max'); return res.data; }); @@ -588,15 +604,15 @@ const getComputedDocument = cache( } ) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ ...getComputedContentSourceCacheTags( { spaceId: params.spaceId, organizationId: params.organizationId, }, params.source - ) - ); + ), + ]); return wrapDataFetcherError(async () => { return trace( @@ -614,7 +630,7 @@ const getComputedDocument = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLifeFromResponse(res, 'max'); return res.data; } @@ -627,13 +643,13 @@ const getComputedDocument = cache( const getLatestOpenAPISpecVersionContent = cache( async (input: DataFetcherInput, params: { organizationId: string; slug: string }) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'openapi', organization: params.organizationId, openAPISpec: params.slug, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace( @@ -647,7 +663,7 @@ const getLatestOpenAPISpecVersionContent = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('max'); return res.data; } @@ -663,12 +679,12 @@ const getPublishedContentSite = cache( _apiVersion: string ) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'site', site: params.siteId, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace( @@ -685,7 +701,7 @@ const getPublishedContentSite = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('days'); return res.data; } @@ -705,12 +721,12 @@ const getSiteRedirectBySource = cache( } ) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'site', site: params.siteId, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace( @@ -728,7 +744,7 @@ const getSiteRedirectBySource = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('days'); return res.data; } @@ -740,12 +756,12 @@ const getSiteRedirectBySource = cache( const getEmbedByUrl = cache( async (input: DataFetcherInput, params: { spaceId: string; url: string }) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'space', space: params.spaceId, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => { @@ -759,7 +775,7 @@ const getEmbedByUrl = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('weeks'); return res.data; }); @@ -774,12 +790,12 @@ const searchSiteContent = cache( params: Parameters[0] ) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'site', site: params.siteId, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace( @@ -809,7 +825,7 @@ const searchSiteContent = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('hours'); return res.data.items; } @@ -824,12 +840,12 @@ const renderIntegrationUi = cache( params: { integrationName: string; request: RenderIntegrationUI } ) => { 'use cache'; - cacheTag( + cacheTagsFor(input, [ getCacheTag({ tag: 'integration', integration: params.integrationName, - }) - ); + }), + ]); return wrapDataFetcherError(async () => { return trace(`renderIntegrationUi(${params.integrationName})`, async () => { @@ -841,7 +857,7 @@ const renderIntegrationUi = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('days'); return res.data; }); @@ -871,7 +887,7 @@ const listRevisionPageMetaLinks = cache( ...noCacheFetchOptions, } ); - cacheTag(...getCacheTagsFromResponse(res)); + cacheTagsFor(input, getCacheTagsFromResponse(res)); cacheLife('days'); return res.data; } @@ -903,3 +919,11 @@ function getCacheTagsFromResponse(response: HttpResponse) { const tags = !cacheTagHeader ? [] : cacheTagHeader.split(','); return tags; } + +/** + * Tag the current cache entry. Always tag through this helper, never `cacheTag` directly, + * so that a PPR render never emits an un-prefixed tag. + */ +function cacheTagsFor(input: DataFetcherInput, tags: string[]) { + cacheTag(...prefixCacheTags(tags, input.ppr)); +} diff --git a/packages/gitbook/src/lib/data/types.ts b/packages/gitbook/src/lib/data/types.ts index 4dcaf76c0..ddbba3e26 100644 --- a/packages/gitbook/src/lib/data/types.ts +++ b/packages/gitbook/src/lib/data/types.ts @@ -24,6 +24,12 @@ export type DataFetcherResponse = * It is used between v1 and v2. */ export interface GitBookDataFetcher { + /** + * Set when rendering under the PPR route: the cache entries are partitioned from the static + * ones and their cache tags are prefixed. + */ + readonly ppr?: true; + /** * Get an API client for the current context. */ diff --git a/packages/gitbook/src/lib/openapi/fetch.ts b/packages/gitbook/src/lib/openapi/fetch.ts index 7ec7dc53c..273ddab63 100644 --- a/packages/gitbook/src/lib/openapi/fetch.ts +++ b/packages/gitbook/src/lib/openapi/fetch.ts @@ -17,6 +17,7 @@ import type { ResolveOpenAPIBlockArgs, } from './types'; import type { FetchOpenAPIFilesystemResult } from './types'; +import { prefixCacheTags } from '@/lib/cache-tags'; import { DataFetcherError, noCacheFetchOptions } from '@/lib/data'; import { resolveContentRef } from '@/lib/references'; @@ -44,7 +45,7 @@ export async function fetchOpenAPIFilesystem( return resolved.openapi.filesystem; } // For legacy blocks ("swagger"), we need to fetch the file system. - return fetchFilesystem(resolved.href, context.space.id); + return fetchFilesystem(resolved.href, context.space.id, context.dataFetcher.ppr); })(); if ('error' in result) { @@ -74,7 +75,9 @@ export async function fetchOpenAPIFilesystem( */ async function fetchFilesystem( url: string, - spaceId: string + spaceId: string, + // Part of the cache key, so PPR entries are not shared with the static ones. + ppr: true | undefined ): Promise< | Filesystem | { @@ -86,7 +89,7 @@ async function fetchFilesystem( > { 'use cache'; try { - cacheTag(getCacheTag({ tag: 'space', space: spaceId })); + cacheTag(...prefixCacheTags([getCacheTag({ tag: 'space', space: spaceId })], ppr)); return await fetchFilesystemNoCache(url); } catch (error) { // To avoid hammering the file with requests, we cache the error for around a minute.