From 001e7bf48060f6bec26facf07be23b66ff71d3ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 5 Jun 2025 18:35:03 +0100 Subject: [PATCH 1/3] Remove need of withoutConcurrentExecution and withCacheKey --- packages/gitbook-v2/src/lib/data/api.ts | 1049 ++++++++--------- .../gitbook-v2/src/lib/data/memoize.test.ts | 52 - packages/gitbook-v2/src/lib/data/memoize.ts | 87 -- packages/gitbook/src/lib/openapi/fetch.ts | 10 +- 4 files changed, 477 insertions(+), 721 deletions(-) delete mode 100644 packages/gitbook-v2/src/lib/data/memoize.test.ts delete mode 100644 packages/gitbook-v2/src/lib/data/memoize.ts diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index f8ae6d2711..44313bfc57 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -8,9 +8,7 @@ import { import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags'; import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env'; import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; -import { getCloudflareRequestGlobal } from './cloudflare'; import { DataFetcherError, wrapDataFetcherError } from './errors'; -import { withCacheKey, withoutConcurrentExecution } from './memoize'; import type { GitBookDataFetcher } from './types'; interface DataFetcherInput { @@ -196,613 +194,518 @@ export function createDataFetcher( }; } -const getUserById = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async (_, input: DataFetcherInput, params: { userId: string }) => { - 'use cache'; - return trace(`getUserById(${params.userId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.users.getUserById(params.userId, { - ...noCacheFetchOptions, - }); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - return res.data; - }); +const getUserById = async (input: DataFetcherInput, params: { userId: string }) => { + 'use cache'; + return trace(`getUserById(${params.userId})`, async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.users.getUserById(params.userId, { + ...noCacheFetchOptions, }); - } - ) -); - -const getSpace = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; shareKey: string | undefined } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'space', - space: params.spaceId, - }) - ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); + return res.data; + }); + }); +}; - return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getSpaceById( - params.spaceId, - { - shareKey: params.shareKey, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - return res.data; - }); - }); - } - ) -); - -const getChangeRequest = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; changeRequestId: string } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'change-request', - space: params.spaceId, - changeRequest: params.changeRequestId, - }) - ); +const getSpace = async ( + input: DataFetcherInput, + params: { spaceId: string; shareKey: string | undefined } +) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'space', + space: params.spaceId, + }) + ); - return trace( - `getChangeRequest(${params.spaceId}, ${params.changeRequestId})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getChangeRequestById( - params.spaceId, - params.changeRequestId, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('minutes'); - return res.data; - }); + return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getSpaceById( + params.spaceId, + { + shareKey: params.shareKey, + }, + { + ...noCacheFetchOptions, } ); - } - ) -); - -const getRevision = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } - ) => { - 'use cache'; - return trace(`getRevision(${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, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); - }); - } - ) -); - -const getRevisionPages = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } - ) => { - 'use cache'; - return trace(`getRevisionPages(${params.spaceId}, ${params.revisionId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.listPagesInRevisionById( - params.spaceId, - params.revisionId, - { - metadata: params.metadata, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data.pages; - }); - }); - } - ) -); - -const getRevisionFile = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; fileId: string } - ) => { - 'use cache'; - return trace( - `getRevisionFile(${params.spaceId}, ${params.revisionId}, ${params.fileId})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getFileInRevisionById( - params.spaceId, - params.revisionId, - params.fileId, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); + return res.data; + }); + }); +}; + +const getChangeRequest = async ( + input: DataFetcherInput, + params: { spaceId: string; changeRequestId: string } +) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'change-request', + space: params.spaceId, + changeRequest: params.changeRequestId, + }) + ); + + return trace(`getChangeRequest(${params.spaceId}, ${params.changeRequestId})`, async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getChangeRequestById( + params.spaceId, + params.changeRequestId, + { + ...noCacheFetchOptions, } ); - } - ) -); - -const getRevisionPageMarkdown = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; pageId: string } - ) => { - 'use cache'; - return trace( - `getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getPageInRevisionById( - params.spaceId, - params.revisionId, - params.pageId, - { - format: 'markdown', - }, - { - ...noCacheFetchOptions, - } - ); - - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - - if (!('markdown' in res.data)) { - throw new DataFetcherError('Page is not a document', 404); - } - return res.data.markdown; - }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('minutes'); + return res.data; + }); + }); +}; + +const getRevision = async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } +) => { + 'use cache'; + return trace(`getRevision(${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, + }, + { + ...noCacheFetchOptions, } ); - } - ) -); - -const getRevisionPageByPath = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; path: string } - ) => { - 'use cache'; - return trace( - `getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`, - async () => { - const encodedPath = encodeURIComponent(params.path); - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getPageInRevisionByPath( - params.spaceId, - params.revisionId, - encodedPath, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + }); + }); +}; + +const getRevisionPages = async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } +) => { + 'use cache'; + return trace(`getRevisionPages(${params.spaceId}, ${params.revisionId})`, async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.listPagesInRevisionById( + params.spaceId, + params.revisionId, + { + metadata: params.metadata, + }, + { + ...noCacheFetchOptions, } ); - } - ) -); - -const getDocument = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async (_, input: DataFetcherInput, params: { spaceId: string; documentId: string }) => { - 'use cache'; - return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getDocumentById( - params.spaceId, - params.documentId, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data.pages; + }); + }); +}; + +const getRevisionFile = async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; fileId: string } +) => { + 'use cache'; + return trace( + `getRevisionFile(${params.spaceId}, ${params.revisionId}, ${params.fileId})`, + async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getFileInRevisionById( + params.spaceId, + params.revisionId, + params.fileId, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; }); } - ) -); - -const getComputedDocument = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { - spaceId: string; - organizationId: string; - source: ComputedContentSource; - seed: string; - } - ) => { - 'use cache'; - cacheTag( - ...getComputedContentSourceCacheTags( + ); +}; + +const getRevisionPageMarkdown = async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; pageId: string } +) => { + 'use cache'; + return trace( + `getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, + async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getPageInRevisionById( + params.spaceId, + params.revisionId, + params.pageId, { - spaceId: params.spaceId, - organizationId: params.organizationId, + format: 'markdown', }, - params.source - ) - ); + { + ...noCacheFetchOptions, + } + ); + + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); - return trace( - `getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getComputedDocument( - params.spaceId, - { - source: params.source, - seed: params.seed, - }, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); + if (!('markdown' in res.data)) { + throw new DataFetcherError('Page is not a document', 404); } - ); + return res.data.markdown; + }); } - ) -); - -const getReusableContent = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; reusableContentId: string } - ) => { - 'use cache'; - return trace( - `getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getReusableContentInRevisionById( - params.spaceId, - params.revisionId, - params.reusableContentId, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); - } - ); + ); +}; + +const getRevisionPageByPath = async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; path: string } +) => { + 'use cache'; + return trace( + `getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`, + async () => { + const encodedPath = encodeURIComponent(params.path); + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getPageInRevisionByPath( + params.spaceId, + params.revisionId, + encodedPath, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + }); } - ) -); - -const getLatestOpenAPISpecVersionContent = withCacheKey( - 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(${params.organizationId}, ${params.slug})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getLatestOpenApiSpecVersionContent( - params.organizationId, - params.slug, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); +const getDocument = async ( + input: DataFetcherInput, + params: { spaceId: string; documentId: string } +) => { + 'use cache'; + return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getDocumentById( + params.spaceId, + params.documentId, + {}, + { + ...noCacheFetchOptions, } ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + }); + }); +}; + +const getComputedDocument = async ( + input: DataFetcherInput, + params: { + spaceId: string; + organizationId: string; + source: ComputedContentSource; + seed: string; + } +) => { + 'use cache'; + cacheTag( + ...getComputedContentSourceCacheTags( + { + spaceId: params.spaceId, + organizationId: params.organizationId, + }, + params.source + ) + ); + + return trace( + `getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`, + async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getComputedDocument( + params.spaceId, + { + source: params.source, + seed: params.seed, + }, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + }); } - ) -); - -const getPublishedContentSite = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { organizationId: string; siteId: string; siteShareKey: string | undefined } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'site', - site: params.siteId, - }) - ); + ); +}; - return trace( - `getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getPublishedContentSite( - params.organizationId, - params.siteId, - { - shareKey: params.siteShareKey, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - return res.data; - }); - } - ); +const getReusableContent = async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; reusableContentId: string } +) => { + 'use cache'; + return trace( + `getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, + async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getReusableContentInRevisionById( + params.spaceId, + params.revisionId, + params.reusableContentId, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + }); } - ) -); - -const getSiteRedirectBySource = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { - organizationId: string; - siteId: string; - siteShareKey: string | undefined; - source: string; - } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'site', - site: params.siteId, - }) - ); + ); +}; - return trace( - `getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getSiteRedirectBySource( - params.organizationId, - params.siteId, - { - shareKey: params.siteShareKey, - source: params.source, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - return res.data; - }); - } - ); +const getLatestOpenAPISpecVersionContent = async ( + input: DataFetcherInput, + params: { organizationId: string; slug: string } +) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'openapi', + organization: params.organizationId, + openAPISpec: params.slug, + }) + ); + + return trace( + `getLatestOpenAPISpecVersionContent(${params.organizationId}, ${params.slug})`, + async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.orgs.getLatestOpenApiSpecVersionContent( + params.organizationId, + params.slug, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + }); } - ) -); - -const getEmbedByUrl = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async (_, input: DataFetcherInput, params: { spaceId: string; url: string }) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'space', - space: params.spaceId, - }) - ); + ); +}; - return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getEmbedByUrlInSpace( - params.spaceId, - { - url: params.url, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('weeks'); - return res.data; - }); +const getPublishedContentSite = async ( + input: DataFetcherInput, + params: { organizationId: string; siteId: string; siteShareKey: string | undefined } +) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); + + return trace( + `getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`, + async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.orgs.getPublishedContentSite( + params.organizationId, + params.siteId, + { + shareKey: params.siteShareKey, + }, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); + return res.data; }); } - ) -); - -const searchSiteContent = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: Parameters[0] - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'site', - site: params.siteId, - }) - ); + ); +}; - return trace( - `searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`, - async () => { - return wrapDataFetcherError(async () => { - const { organizationId, siteId, query, scope } = params; - const api = apiClient(input); - const res = await api.orgs.searchSiteContent( - organizationId, - siteId, - { - query, - ...scope, - }, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('hours'); - return res.data.items; - }); - } - ); +const getSiteRedirectBySource = async ( + input: DataFetcherInput, + params: { + organizationId: string; + siteId: string; + siteShareKey: string | undefined; + source: string; + } +) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); + + return trace( + `getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`, + async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.orgs.getSiteRedirectBySource( + params.organizationId, + params.siteId, + { + shareKey: params.siteShareKey, + source: params.source, + }, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); + return res.data; + }); } - ) -); - -const renderIntegrationUi = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - _, - input: DataFetcherInput, - params: { integrationName: string; request: RenderIntegrationUI } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'integration', - integration: params.integrationName, - }) + ); +}; + +const getEmbedByUrl = async (input: DataFetcherInput, params: { spaceId: string; url: string }) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'space', + space: params.spaceId, + }) + ); + + return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.spaces.getEmbedByUrlInSpace( + params.spaceId, + { + url: params.url, + }, + { + ...noCacheFetchOptions, + } ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('weeks'); + return res.data; + }); + }); +}; + +const searchSiteContent = async ( + input: DataFetcherInput, + params: Parameters[0] +) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); - return trace(`renderIntegrationUi(${params.integrationName})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.integrations.renderIntegrationUiWithPost( - params.integrationName, - params.request, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - return res.data; - }); + return trace( + `searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`, + async () => { + return wrapDataFetcherError(async () => { + const { organizationId, siteId, query, scope } = params; + const api = apiClient(input); + const res = await api.orgs.searchSiteContent( + organizationId, + siteId, + { + query, + ...scope, + }, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('hours'); + return res.data.items; }); } - ) -); + ); +}; + +const renderIntegrationUi = async ( + input: DataFetcherInput, + params: { integrationName: string; request: RenderIntegrationUI } +) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'integration', + integration: params.integrationName, + }) + ); + + return trace(`renderIntegrationUi(${params.integrationName})`, async () => { + return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.integrations.renderIntegrationUiWithPost( + params.integrationName, + params.request, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); + return res.data; + }); + }); +}; async function* streamAIResponse( input: DataFetcherInput, diff --git a/packages/gitbook-v2/src/lib/data/memoize.test.ts b/packages/gitbook-v2/src/lib/data/memoize.test.ts deleted file mode 100644 index 24c1014087..0000000000 --- a/packages/gitbook-v2/src/lib/data/memoize.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test'; -import { AsyncLocalStorage } from 'node:async_hooks'; -import { withCacheKey, withoutConcurrentExecution } from './memoize'; - -describe('withoutConcurrentExecution', () => { - it('should memoize the function based on the cache key', async () => { - const fn = mock(async (_cacheKey: string, a: number, b: number) => a + b); - const memoized = withoutConcurrentExecution(() => null, fn); - - const p1 = memoized('c1', 1, 2); - const p2 = memoized('c1', 1, 2); - const p3 = memoized('c3', 2, 3); - - expect(await p1).toBe(await p2); - expect(await p1).not.toBe(await p3); - expect(fn.mock.calls.length).toBe(2); - }); - - it('should support caching per request', async () => { - const fn = mock(async () => Math.random()); - - const request1 = { id: 'request1' }; - const request2 = { id: 'request2' }; - - const requestContext = new AsyncLocalStorage<{ id: string }>(); - - const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn); - - // Both in the same request - const promise1 = requestContext.run(request1, () => memoized('c1')); - const promise2 = requestContext.run(request1, () => memoized('c1')); - - // In a different request - const promise3 = requestContext.run(request2, () => memoized('c1')); - - expect(await promise1).toBe(await promise2); - expect(await promise1).not.toBe(await promise3); - expect(fn.mock.calls.length).toBe(2); - }); -}); - -describe('withCacheKey', () => { - it('should wrap the function by passing the cache key', async () => { - const fn = mock( - async (cacheKey: string, arg: { a: number; b: number }, c: number) => - `${cacheKey}, result=${arg.a + arg.b + c}` - ); - const memoized = withCacheKey(fn); - expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7'); - expect(fn.mock.calls.length).toBe(1); - }); -}); diff --git a/packages/gitbook-v2/src/lib/data/memoize.ts b/packages/gitbook-v2/src/lib/data/memoize.ts deleted file mode 100644 index ff1ff5e841..0000000000 --- a/packages/gitbook-v2/src/lib/data/memoize.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Wrap a function by preventing concurrent executions of the same function. - * With a logic to work per-request in Cloudflare Workers. - */ -export function withoutConcurrentExecution( - getGlobalContext: () => object | null | undefined, - wrapped: (key: string, ...args: ArgsType) => Promise -): (cacheKey: string, ...args: ArgsType) => Promise { - const globalPromiseCache = new WeakMap>>(); - - return (key: string, ...args: ArgsType) => { - const globalContext = getGlobalContext() ?? globalThis; - - /** - * Cache storage that is scoped to the current request when executed in Cloudflare Workers, - * to avoid "Cannot perform I/O on behalf of a different request" errors. - */ - const promiseCache = - globalPromiseCache.get(globalContext) ?? new Map>(); - globalPromiseCache.set(globalContext, promiseCache); - - const concurrent = promiseCache.get(key); - if (concurrent) { - return concurrent; - } - - const promise = (async () => { - try { - const result = await wrapped(key, ...args); - return result; - } finally { - promiseCache.delete(key); - } - })(); - - promiseCache.set(key, promise); - - return promise; - }; -} - -/** - * Wrap a function by passing it a cache key that is computed from the function arguments. - */ -export function withCacheKey( - wrapped: (cacheKey: string, ...args: ArgsType) => Promise -): (...args: ArgsType) => Promise { - return (...args: ArgsType) => { - const cacheKey = getCacheKey(args); - return wrapped(cacheKey, ...args); - }; -} - -/** - * Compute a cache key from the function arguments. - */ -function getCacheKey(args: any[]) { - return JSON.stringify(deepSortValue(args)); -} - -function deepSortValue(value: unknown): unknown { - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' || - value === null || - value === undefined - ) { - return value; - } - - if (Array.isArray(value)) { - return value.map(deepSortValue); - } - - if (value && typeof value === 'object') { - return Object.entries(value) - .map(([key, subValue]) => { - return [key, deepSortValue(subValue)] as const; - }) - .sort((a, b) => { - return a[0].localeCompare(b[0]); - }); - } - - return value; -} diff --git a/packages/gitbook/src/lib/openapi/fetch.ts b/packages/gitbook/src/lib/openapi/fetch.ts index b496bbe59f..889ea742ae 100644 --- a/packages/gitbook/src/lib/openapi/fetch.ts +++ b/packages/gitbook/src/lib/openapi/fetch.ts @@ -7,8 +7,6 @@ import type { OpenAPIWebhookBlock, ResolveOpenAPIBlockArgs, } from '@/lib/openapi/types'; -import { getCloudflareRequestGlobal } from '@v2/lib/data/cloudflare'; -import { withCacheKey, withoutConcurrentExecution } from '@v2/lib/data/memoize'; import { assert } from 'ts-essentials'; import { resolveContentRef } from '../references'; import { isV2 } from '../v2'; @@ -48,7 +46,7 @@ export async function fetchOpenAPIFilesystem( function fetchFilesystem(url: string) { if (isV2()) { - return fetchFilesystemV2(url); + return fetchFilesystemUseCache(url); } return fetchFilesystemV1(url); @@ -68,12 +66,6 @@ const fetchFilesystemV1 = cache({ }, }); -const fetchFilesystemV2 = withCacheKey( - withoutConcurrentExecution(getCloudflareRequestGlobal, async (_cacheKey, url: string) => { - return fetchFilesystemUseCache(url); - }) -); - const fetchFilesystemUseCache = async (url: string) => { 'use cache'; return fetchFilesystemUncached(url); From cc81edfbdb6d887cb220c9340f827a8fa2bdc75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 5 Jun 2025 18:47:11 +0100 Subject: [PATCH 2/3] Changeset --- .changeset/nervous-students-judge.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/nervous-students-judge.md diff --git a/.changeset/nervous-students-judge.md b/.changeset/nervous-students-judge.md new file mode 100644 index 0000000000..a24d325b74 --- /dev/null +++ b/.changeset/nervous-students-judge.md @@ -0,0 +1,6 @@ +--- +"gitbook-v2": patch +"gitbook": patch +--- + +Fix concurrent execution in Vercel causing pages to not be attached to the proper tags. From 65269d48beaca8f85a653b10c19cdfc5ce54eedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 5 Jun 2025 19:06:28 +0100 Subject: [PATCH 3/3] Cleanup more --- packages/gitbook-v2/src/lib/data/cloudflare.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/gitbook-v2/src/lib/data/cloudflare.ts b/packages/gitbook-v2/src/lib/data/cloudflare.ts index ac3125603d..ca996690b6 100644 --- a/packages/gitbook-v2/src/lib/data/cloudflare.ts +++ b/packages/gitbook-v2/src/lib/data/cloudflare.ts @@ -11,15 +11,3 @@ export function getCloudflareContext() { return getCloudflareContextOpenNext(); } - -/** - * Return an object representing the current request. - */ -export function getCloudflareRequestGlobal() { - const context = getCloudflareContext(); - if (!context) { - return null; - } - - return context.cf; -}