@faststore/core 4.10.1-dev.2 → 4.10.1-dev.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import ClientCMS from '@vtex/client-cms'
3
3
 
4
4
  import MultipleContentError from 'src/sdk/error/MultipleContentError'
5
5
  import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
6
+ import { createPromiseCache } from 'src/server/content/promiseCache'
6
7
  import { sanitizeHost } from 'src/utils/utilities'
7
8
  import config from '../../../discovery.config'
8
9
 
@@ -79,16 +80,64 @@ export const clientCMS = new ClientCMS({
79
80
  host: sanitizeHost(new URL(getStoreURL()).origin),
80
81
  })
81
82
 
83
+ type CMSPageResult = { data: ContentData[] }
84
+
85
+ export const cmsPageCache = createPromiseCache<CMSPageResult>()
86
+
87
+ type GetCMSPageOptions = {
88
+ /**
89
+ * Reuse a document fetched by locator. Only safe when the locator does not
90
+ * come from a preview, since preview versions change between requests.
91
+ */
92
+ cache?: boolean
93
+ }
94
+
95
+ /**
96
+ * Listings by content type never carry preview data (previews always resolve
97
+ * to a locator), so they are shared across pages. Filtered listings depend on
98
+ * the page (e.g. landing page slug) and stay uncached.
99
+ */
82
100
  export const getCMSPage = async (
83
101
  options: Options,
84
- cmsClient: ClientCMS = clientCMS
85
- ) => {
102
+ cmsClient: ClientCMS = clientCMS,
103
+ { cache = false }: GetCMSPageOptions = {}
104
+ ): Promise<CMSPageResult> => {
105
+ const shareable = cmsClient === clientCMS
106
+
86
107
  if (isLocator(options)) {
87
- return await cmsClient
88
- .getCMSPage(options)
89
- .then((page) => ({ data: [page] }))
108
+ const fetchByLocator = () =>
109
+ cmsClient.getCMSPage(options).then((page) => ({ data: [page] }))
110
+
111
+ if (!cache || !shareable) {
112
+ return fetchByLocator()
113
+ }
114
+
115
+ const { contentType, documentId, versionId, releaseId } = options
116
+ return cmsPageCache.get(
117
+ JSON.stringify([
118
+ 'locator',
119
+ contentType,
120
+ documentId,
121
+ versionId,
122
+ releaseId,
123
+ ]),
124
+ fetchByLocator
125
+ )
126
+ }
127
+
128
+ if (options.filters || !shareable) {
129
+ return listCMSPages(options, cmsClient)
90
130
  }
91
131
 
132
+ return cmsPageCache.get(JSON.stringify(['list', options.contentType]), () =>
133
+ listCMSPages(options, cmsClient)
134
+ )
135
+ }
136
+
137
+ const listCMSPages = async (
138
+ options: Exclude<Options, Locator>,
139
+ cmsClient: ClientCMS
140
+ ): Promise<CMSPageResult> => {
92
141
  const pages = []
93
142
  const page = 1
94
143
  const perPage = 10
@@ -124,8 +173,11 @@ export const getCMSPage = async (
124
173
  return { data: pages }
125
174
  }
126
175
 
127
- export const getPage = async <T extends ContentData>(options: Options) => {
128
- const result = await getCMSPage(options)
176
+ export const getPage = async <T extends ContentData>(
177
+ options: Options,
178
+ getOptions?: GetCMSPageOptions
179
+ ) => {
180
+ const result = await getCMSPage(options, clientCMS, getOptions)
129
181
 
130
182
  const pages = result.data
131
183
 
@@ -58,11 +58,16 @@ const getPDPFromCmsEnvData = async (
58
58
 
59
59
  const template = findBestPDPTemplate(pages, product)
60
60
 
61
- return getPage<PDPContentType>({
62
- contentType: 'pdp',
63
- documentId: template.documentId as string,
64
- versionId: template.versionId,
65
- })
61
+ // The template locator comes from the build-time CMS data, never from a
62
+ // preview, so the fetched document can be shared across page generations.
63
+ return getPage<PDPContentType>(
64
+ {
65
+ contentType: 'pdp',
66
+ documentId: template.documentId as string,
67
+ versionId: template.versionId,
68
+ },
69
+ { cache: true }
70
+ )
66
71
  }
67
72
 
68
73
  const getPDPFromCms = async (
@@ -74,11 +74,16 @@ const getPLPFromCmsEnvData = async (
74
74
 
75
75
  const template = findBestPLPTemplate(pages, slug, rewrites)
76
76
 
77
- return getPage<PLPContentType>({
78
- contentType: 'plp',
79
- documentId: template.documentId as string,
80
- versionId: template.versionId,
81
- })
77
+ // The template locator comes from the build-time CMS data, never from a
78
+ // preview, so the fetched document can be shared across page generations.
79
+ return getPage<PLPContentType>(
80
+ {
81
+ contentType: 'plp',
82
+ documentId: template.documentId as string,
83
+ versionId: template.versionId,
84
+ },
85
+ { cache: true }
86
+ )
82
87
  }
83
88
 
84
89
  export const getPLPFromCms = async (
@@ -0,0 +1,68 @@
1
+ // Bounds how stale shared CMS content can get in long-lived processes (ISR);
2
+ // a CMS publish already triggers a new build.
3
+ export const CONTENT_CACHE_TTL_MS = 5 * 60 * 1000
4
+
5
+ type PromiseCacheEntry<T> = {
6
+ expiresAt: number
7
+ promise: Promise<T>
8
+ }
9
+
10
+ /**
11
+ * Per-process memoization of async loaders. Concurrent callers share the
12
+ * in-flight promise, entries expire after `ttlMs`, and rejections are evicted
13
+ * so the next caller retries. When `shouldCache` rejects a resolved value, the
14
+ * entry is evicted too, so only the callers already waiting share that value.
15
+ * Expired entries are also dropped whenever a new one is stored, so keys that
16
+ * are never requested again (e.g. superseded document versions) do not pile up
17
+ * for the lifetime of the process.
18
+ */
19
+ export function createPromiseCache<T>(ttlMs = CONTENT_CACHE_TTL_MS) {
20
+ const entries = new Map<string, PromiseCacheEntry<T>>()
21
+
22
+ return {
23
+ get(
24
+ key: string,
25
+ load: () => Promise<T>,
26
+ shouldCache: (value: T) => boolean = () => true
27
+ ): Promise<T> {
28
+ const cached = entries.get(key)
29
+ if (cached && cached.expiresAt > Date.now()) {
30
+ return cached.promise
31
+ }
32
+
33
+ const evict = () => {
34
+ if (entries.get(key)?.promise === promise) {
35
+ entries.delete(key)
36
+ }
37
+ }
38
+
39
+ const now = Date.now()
40
+ entries.forEach((entry, entryKey) => {
41
+ if (entry.expiresAt <= now) entries.delete(entryKey)
42
+ })
43
+
44
+ const promise = load().then(
45
+ (value) => {
46
+ if (!shouldCache(value)) evict()
47
+ return value
48
+ },
49
+ (err: unknown) => {
50
+ evict()
51
+ throw err
52
+ }
53
+ )
54
+
55
+ entries.set(key, { expiresAt: now + ttlMs, promise })
56
+
57
+ return promise
58
+ },
59
+
60
+ clear() {
61
+ entries.clear()
62
+ },
63
+
64
+ get size() {
65
+ return entries.size
66
+ },
67
+ }
68
+ }
@@ -14,6 +14,7 @@ import {
14
14
  import config from '../../../discovery.config'
15
15
  import { getPDP, type PDPContentType } from '../cms/pdp'
16
16
  import { getPLP, type PLPContentType } from '../cms/plp'
17
+ import { createPromiseCache } from './promiseCache'
17
18
  import type { ContentOptions, ContentParams } from './types'
18
19
  import { isBranchPreview, isContentPlatformSource } from './utils'
19
20
 
@@ -21,26 +22,21 @@ type ContentResult = ContentData | PageContentType
21
22
 
22
23
  const CONTENT_AUTHOR_FIELDS = ['createdBy', 'updatedBy'] as const
23
24
 
24
- type ContentListCacheEntry = {
25
- expiresAt: number
26
- promise: Promise<{ data: ContentResult[] }>
27
- }
28
-
29
25
  const OPTIONAL_CONTENT_TYPES = [
30
26
  'globalHeaderSections',
31
27
  'globalFooterSections',
32
28
  ] as const
33
29
 
34
- // Bounds how stale the template list can get in long-lived processes (ISR);
35
- // a CMS publish already triggers a new build.
36
- const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
37
-
38
30
  export class ContentService {
39
31
  private readonly clientCP = new ClientCP({
40
32
  tenant: config.api.storeId,
41
33
  })
42
34
 
43
- private readonly contentListCache = new Map<string, ContentListCacheEntry>()
35
+ private readonly contentListCache = createPromiseCache<{
36
+ data: ContentResult[]
37
+ }>()
38
+
39
+ private readonly firstEntryCache = createPromiseCache<PageContentType>()
44
40
 
45
41
  async getSingleContent<T extends ContentData>(
46
42
  params: ContentParams
@@ -50,7 +46,7 @@ export class ContentService {
50
46
  if (isContentPlatformSource()) {
51
47
  return this.getFromCP<T>(options)
52
48
  }
53
- return getPage(options.cmsOptions)
49
+ return getPage(options.cmsOptions, { cache: !options.isPreview })
54
50
  }
55
51
 
56
52
  async getMultipleContent(
@@ -136,30 +132,9 @@ export class ContentService {
136
132
  private getCachedContentList(
137
133
  serviceParams: EntryPathParams
138
134
  ): Promise<{ data: ContentResult[] }> {
139
- // listEntries only depends on these fields; the page slug must stay out of the key.
140
- const { accountName, storeId, contentType, locale } = serviceParams
141
- const key = JSON.stringify([accountName, storeId, contentType, locale])
142
-
143
- const cached = this.contentListCache.get(key)
144
- if (cached && cached.expiresAt > Date.now()) {
145
- return cached.promise
146
- }
147
-
148
- const promise = this.listAndFillEntries(serviceParams, false).catch(
149
- (err: unknown) => {
150
- if (this.contentListCache.get(key)?.promise === promise) {
151
- this.contentListCache.delete(key)
152
- }
153
- throw err
154
- }
135
+ return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
136
+ this.listAndFillEntries(serviceParams, false)
155
137
  )
156
-
157
- this.contentListCache.set(key, {
158
- expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
159
- promise,
160
- })
161
-
162
- return promise
163
138
  }
164
139
 
165
140
  private async listAndFillEntries(
@@ -208,9 +183,21 @@ export class ContentService {
208
183
  params: EntryPathParams,
209
184
  isPreview: boolean
210
185
  ): Promise<PageContentType> {
211
- return params.entryId || params.slug
212
- ? await this.getEntryData(params, isPreview)
213
- : await this.fetchFirstEntryFromList(params, isPreview)
186
+ if (params.entryId || params.slug) {
187
+ return this.getEntryData(params, isPreview)
188
+ }
189
+
190
+ if (isPreview) {
191
+ return this.fetchFirstEntryFromList(params, true)
192
+ }
193
+
194
+ // An empty required type is likely content that is about to be published,
195
+ // so it is not kept for the whole TTL. Optional types are empty by design.
196
+ return this.firstEntryCache.get(
197
+ getContentTypeKey(params),
198
+ () => this.fetchFirstEntryFromList(params, false),
199
+ (entry) => !isEmptyEntry(entry) || isOptionalContentType(params)
200
+ )
214
201
  }
215
202
 
216
203
  private async getEntryData(
@@ -245,11 +232,7 @@ export class ContentService {
245
232
  ): Promise<PageContentType> {
246
233
  const { entries } = await this.clientCP.listEntries(params)
247
234
  if (!entries || entries.length === 0) {
248
- const isOptional = OPTIONAL_CONTENT_TYPES.includes(
249
- params.contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
250
- )
251
-
252
- if (!isOptional) {
235
+ if (!isOptionalContentType(params)) {
253
236
  console.warn('No entries found for params', params)
254
237
  }
255
238
  return {} as PageContentType
@@ -358,6 +341,26 @@ export class ContentService {
358
341
  }
359
342
  }
360
343
 
344
+ function isOptionalContentType({ contentType }: EntryPathParams): boolean {
345
+ return OPTIONAL_CONTENT_TYPES.includes(
346
+ contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
347
+ )
348
+ }
349
+
350
+ function isEmptyEntry(entry: PageContentType): boolean {
351
+ return Object.keys(entry).length === 0
352
+ }
353
+
354
+ // listEntries only depends on these fields; the page slug must stay out of the key.
355
+ function getContentTypeKey({
356
+ accountName,
357
+ storeId,
358
+ contentType,
359
+ locale,
360
+ }: EntryPathParams): string {
361
+ return JSON.stringify([accountName, storeId, contentType, locale])
362
+ }
363
+
361
364
  function withoutContentAuthorMetadata<T extends object>(value: T): T {
362
365
  const content = { ...value } as Record<string, unknown>
363
366
 
@@ -1,5 +1,10 @@
1
- import { vi } from 'vitest'
2
- import { clientCMS, getCMSPage } from '../../../src/server/cms'
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ clientCMS,
4
+ cmsPageCache,
5
+ getCMSPage,
6
+ getPage,
7
+ } from '../../../src/server/cms'
3
8
  import type { ContentData } from '@vtex/client-cms'
4
9
 
5
10
  describe('CMS Integration', () => {
@@ -18,15 +23,22 @@ describe('CMS Integration', () => {
18
23
  return data
19
24
  }
20
25
 
26
+ const mockListing = (count = 1) =>
27
+ vi.fn((_contentType: string) =>
28
+ Promise.resolve({
29
+ data: mockData(count),
30
+ hasNextPage: false,
31
+ totalItems: count,
32
+ })
33
+ )
34
+
35
+ beforeEach(() => {
36
+ cmsPageCache.clear()
37
+ })
38
+
21
39
  describe('getCMSPage', () => {
22
40
  it('returns the first page if there is only one page', async () => {
23
- const mockFunction = vi.fn(() => {
24
- return Promise.resolve({
25
- data: mockData(3),
26
- hasNextPage: false,
27
- totalItems: 3,
28
- })
29
- })
41
+ const mockFunction = mockListing(3)
30
42
  clientCMS.getCMSPagesByContentType = mockFunction
31
43
 
32
44
  const result = await getCMSPage({ contentType: 'plp' }, clientCMS)
@@ -61,4 +73,85 @@ describe('CMS Integration', () => {
61
73
  expect(result.data.length).toBe(15)
62
74
  })
63
75
  })
76
+
77
+ describe('cache', () => {
78
+ it('shares a content type listing across concurrent callers', async () => {
79
+ const mockFunction = mockListing(3)
80
+ clientCMS.getCMSPagesByContentType = mockFunction
81
+
82
+ await Promise.all([
83
+ getCMSPage({ contentType: 'plp' }),
84
+ getCMSPage({ contentType: 'plp' }),
85
+ getCMSPage({ contentType: 'plp' }),
86
+ ])
87
+ await getCMSPage({ contentType: 'pdp' })
88
+
89
+ expect(
90
+ mockFunction.mock.calls.map(([contentType]) => contentType)
91
+ ).toEqual(['plp', 'pdp'])
92
+ })
93
+
94
+ it('does not cache filtered listings', async () => {
95
+ const mockFunction = mockListing(1)
96
+ clientCMS.getCMSPagesByContentType = mockFunction
97
+ const options = {
98
+ contentType: 'landingPage',
99
+ filters: { 'settings.seo.slug': '/summer-sale' },
100
+ }
101
+
102
+ await getCMSPage(options)
103
+ await getCMSPage(options)
104
+
105
+ expect(mockFunction).toHaveBeenCalledTimes(2)
106
+ })
107
+
108
+ it('fetches a locator on every call by default', async () => {
109
+ const mockFunction = vi.fn(() => Promise.resolve(mockData(1)[0]))
110
+ clientCMS.getCMSPage = mockFunction
111
+ const locator = {
112
+ contentType: 'globalSections',
113
+ documentId: 'doc-1',
114
+ versionId: 'version-1',
115
+ }
116
+
117
+ await getPage(locator)
118
+ await getPage(locator)
119
+
120
+ expect(mockFunction).toHaveBeenCalledTimes(2)
121
+ })
122
+
123
+ it('reuses a locator fetch when caching is enabled', async () => {
124
+ const mockFunction = vi.fn(() => Promise.resolve(mockData(1)[0]))
125
+ clientCMS.getCMSPage = mockFunction
126
+ const locator = {
127
+ contentType: 'globalSections',
128
+ documentId: 'doc-1',
129
+ versionId: 'version-1',
130
+ }
131
+
132
+ const [first, second] = await Promise.all([
133
+ getPage(locator, { cache: true }),
134
+ getPage(locator, { cache: true }),
135
+ ])
136
+ await getPage({ ...locator, versionId: 'version-2' }, { cache: true })
137
+
138
+ expect(first).toEqual(second)
139
+ expect(mockFunction).toHaveBeenCalledTimes(2)
140
+ })
141
+
142
+ it('does not cache failures', async () => {
143
+ const error = new Error('Request failed with status code 500')
144
+ const mockFunction = vi
145
+ .fn()
146
+ .mockRejectedValueOnce(error)
147
+ .mockResolvedValueOnce({ data: mockData(1), totalItems: 1 })
148
+ clientCMS.getCMSPagesByContentType = mockFunction
149
+
150
+ await expect(getCMSPage({ contentType: 'plp' })).rejects.toBe(error)
151
+ const result = await getCMSPage({ contentType: 'plp' })
152
+
153
+ expect(result.data).toHaveLength(1)
154
+ expect(mockFunction).toHaveBeenCalledTimes(2)
155
+ })
156
+ })
64
157
  })
@@ -0,0 +1,150 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { createPromiseCache } from '../../../src/server/content/promiseCache'
4
+
5
+ describe('createPromiseCache', () => {
6
+ afterEach(() => {
7
+ vi.useRealTimers()
8
+ })
9
+
10
+ it('shares one in-flight load across concurrent callers', async () => {
11
+ const cache = createPromiseCache<string>(1000)
12
+ const load = vi.fn(async () => 'value')
13
+
14
+ const results = await Promise.all([
15
+ cache.get('key', load),
16
+ cache.get('key', load),
17
+ cache.get('key', load),
18
+ ])
19
+
20
+ expect(load).toHaveBeenCalledTimes(1)
21
+ expect(results).toEqual(['value', 'value', 'value'])
22
+ })
23
+
24
+ it('keeps separate entries per key', async () => {
25
+ const cache = createPromiseCache<string>(1000)
26
+ const load = vi.fn(async () => 'value')
27
+
28
+ await cache.get('a', load)
29
+ await cache.get('b', load)
30
+
31
+ expect(load).toHaveBeenCalledTimes(2)
32
+ })
33
+
34
+ it('loads again after the TTL', async () => {
35
+ vi.useFakeTimers()
36
+ const cache = createPromiseCache<string>(1000)
37
+ const load = vi.fn(async () => 'value')
38
+
39
+ await cache.get('key', load)
40
+ vi.advanceTimersByTime(999)
41
+ await cache.get('key', load)
42
+ expect(load).toHaveBeenCalledTimes(1)
43
+
44
+ vi.advanceTimersByTime(1)
45
+ await cache.get('key', load)
46
+ expect(load).toHaveBeenCalledTimes(2)
47
+ })
48
+
49
+ it('does not cache failures', async () => {
50
+ const cache = createPromiseCache<string>(1000)
51
+ const error = new Error('boom')
52
+ const load = vi
53
+ .fn<() => Promise<string>>()
54
+ .mockRejectedValueOnce(error)
55
+ .mockResolvedValueOnce('value')
56
+
57
+ await expect(cache.get('key', load)).rejects.toBe(error)
58
+ await expect(cache.get('key', load)).resolves.toBe('value')
59
+ expect(load).toHaveBeenCalledTimes(2)
60
+ })
61
+
62
+ it('does not let a late failure evict a newer load', async () => {
63
+ vi.useFakeTimers()
64
+ const cache = createPromiseCache<string>(1000)
65
+ let rejectFirst: (err: Error) => void = () => {}
66
+ const load = vi
67
+ .fn<() => Promise<string>>()
68
+ .mockImplementationOnce(
69
+ () =>
70
+ new Promise((_, reject) => {
71
+ rejectFirst = reject
72
+ })
73
+ )
74
+ .mockResolvedValue('value')
75
+
76
+ const first = cache.get('key', load)
77
+ const firstResult = expect(first).rejects.toThrow('late failure')
78
+ vi.advanceTimersByTime(1000)
79
+ await cache.get('key', load)
80
+
81
+ rejectFirst(new Error('late failure'))
82
+ await firstResult
83
+ await cache.get('key', load)
84
+
85
+ expect(load).toHaveBeenCalledTimes(2)
86
+ })
87
+
88
+ it('does not keep values rejected by shouldCache', async () => {
89
+ const cache = createPromiseCache<string>(1000)
90
+ const load = vi.fn(async () => '')
91
+ const shouldCache = (value: string) => value !== ''
92
+
93
+ const concurrent = await Promise.all([
94
+ cache.get('key', load, shouldCache),
95
+ cache.get('key', load, shouldCache),
96
+ ])
97
+ await cache.get('key', load, shouldCache)
98
+
99
+ expect(concurrent).toEqual(['', ''])
100
+ expect(load).toHaveBeenCalledTimes(2)
101
+ })
102
+
103
+ it('keeps values accepted by shouldCache', async () => {
104
+ const cache = createPromiseCache<string>(1000)
105
+ const load = vi.fn(async () => 'value')
106
+
107
+ await cache.get('key', load, (value) => value !== '')
108
+ await cache.get('key', load, (value) => value !== '')
109
+
110
+ expect(load).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ it('drops expired entries when a new key is stored', async () => {
114
+ vi.useFakeTimers()
115
+ const cache = createPromiseCache<string>(1000)
116
+ const load = vi.fn(async () => 'value')
117
+
118
+ await cache.get('old-version', load)
119
+ await cache.get('other', load)
120
+ expect(cache.size).toBe(2)
121
+
122
+ vi.advanceTimersByTime(1000)
123
+ await cache.get('new-version', load)
124
+
125
+ expect(cache.size).toBe(1)
126
+ })
127
+
128
+ it('keeps entries that have not expired when a new key is stored', async () => {
129
+ vi.useFakeTimers()
130
+ const cache = createPromiseCache<string>(1000)
131
+ const load = vi.fn(async () => 'value')
132
+
133
+ await cache.get('a', load)
134
+ vi.advanceTimersByTime(999)
135
+ await cache.get('b', load)
136
+
137
+ expect(cache.size).toBe(2)
138
+ })
139
+
140
+ it('drops every entry on clear', async () => {
141
+ const cache = createPromiseCache<string>(1000)
142
+ const load = vi.fn(async () => 'value')
143
+
144
+ await cache.get('key', load)
145
+ cache.clear()
146
+ await cache.get('key', load)
147
+
148
+ expect(load).toHaveBeenCalledTimes(2)
149
+ })
150
+ })