@faststore/core 3.100.6 → 3.100.8
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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +23 -23
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/config.json +3 -3
- package/.next/cache/webpack/client-production/0.pack +2 -2
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/edge-server-production/0.pack +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/0.pack +2 -2
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/prerender-manifest.js +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/react-loadable-manifest.json +2 -2
- package/.next/routes-manifest.json +1 -1
- package/.next/server/chunks/948.js +2 -2
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/api/preview.js +1 -1
- package/.next/server/pages/en-US/404.html +2 -2
- package/.next/server/pages/en-US/500.html +2 -2
- package/.next/server/pages/en-US/checkout.html +2 -2
- package/.next/server/pages/en-US/login.html +2 -2
- package/.next/server/pages/en-US/password-protection.html +2 -2
- package/.next/server/pages/en-US/s.html +2 -2
- package/.next/server/pages/en-US.html +2 -2
- package/.next/server/pages-manifest.json +1 -1
- package/.next/static/{TfWW2xib3Snr8xF4Ldqws → 3we-qu8I2EmorLqlDX8oP}/_buildManifest.js +1 -1
- package/.next/static/chunks/7783.017603f0dd9e1dd7.js +1 -0
- package/.next/static/chunks/pages/{[...slug]-cf9e3bf4febd21c3.js → [...slug]-d2b8abf5b0c0bb19.js} +1 -1
- package/.next/static/chunks/{webpack-6a844a3f6a390f62.js → webpack-62bcb2a452e15476.js} +1 -1
- package/.next/trace +156 -156
- package/.turbo/turbo-build.log +9 -9
- package/.turbo/turbo-test.log +12 -10
- package/CHANGELOG.md +15 -0
- package/package.json +2 -2
- package/src/server/cms/index.ts +59 -7
- package/src/server/cms/pdp.ts +10 -5
- package/src/server/cms/plp.ts +10 -5
- package/src/server/content/promiseCache.ts +68 -0
- package/src/server/content/service.ts +93 -27
- package/test/server/cms/index.test.ts +112 -8
- package/test/server/content/promiseCache.test.ts +150 -0
- package/test/server/content/service.test.ts +446 -0
- package/.next/static/chunks/3802.904f302bf009f795.js +0 -1
- /package/.next/static/{TfWW2xib3Snr8xF4Ldqws → 3we-qu8I2EmorLqlDX8oP}/_ssgManifest.js +0 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
|
|
2
|
-
> @faststore/core@3.100.
|
|
2
|
+
> @faststore/core@3.100.7 prebuild /home/runner/work/faststore/faststore/packages/core
|
|
3
3
|
> na run partytown && na run generate
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
> @faststore/core@3.100.
|
|
6
|
+
> @faststore/core@3.100.7 partytown /home/runner/work/faststore/faststore/packages/core
|
|
7
7
|
> partytown copylib ./public/~partytown
|
|
8
8
|
|
|
9
9
|
Partytown lib copied to: /home/runner/work/faststore/faststore/packages/core/public/~partytown
|
|
10
10
|
|
|
11
|
-
> @faststore/core@3.100.
|
|
11
|
+
> @faststore/core@3.100.7 generate /home/runner/work/faststore/faststore/packages/core
|
|
12
12
|
> na run generate:schema && na run generate:codegen && na run format:generated
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
> @faststore/core@3.100.
|
|
15
|
+
> @faststore/core@3.100.7 generate:schema /home/runner/work/faststore/faststore/packages/core
|
|
16
16
|
> tsx src/server/generator/generateGraphQLSchemaFile.ts
|
|
17
17
|
|
|
18
18
|
Schema GraphQL file generated successfully
|
|
19
19
|
|
|
20
|
-
> @faststore/core@3.100.
|
|
20
|
+
> @faststore/core@3.100.7 generate:codegen /home/runner/work/faststore/faststore/packages/core
|
|
21
21
|
> graphql-codegen
|
|
22
22
|
|
|
23
23
|
[STARTED] Parse Configuration
|
|
@@ -37,11 +37,11 @@ Running lifecycle hook "afterStart" scripts...
|
|
|
37
37
|
[CLI] Loading Documents
|
|
38
38
|
[CLI] Generating output
|
|
39
39
|
|
|
40
|
-
> @faststore/core@3.100.
|
|
40
|
+
> @faststore/core@3.100.7 format:generated /home/runner/work/faststore/faststore/packages/core
|
|
41
41
|
> prettier --write "@generated/**/*.{ts,js,tsx,jsx,json}" --loglevel error
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
> @faststore/core@3.100.
|
|
44
|
+
> @faststore/core@3.100.7 build /home/runner/work/faststore/faststore/packages/core
|
|
45
45
|
> next build
|
|
46
46
|
|
|
47
47
|
⚠ No build cache found. Please configure build caching for faster rebuilds. Read more: https://nextjs.org/docs/messages/no-cache
|
|
@@ -103,8 +103,8 @@ Import trace for requested module:
|
|
|
103
103
|
Search not found. Add a new component for this section or remove it from the CMS
|
|
104
104
|
Warning: Dynamic Content not found for the page: home. Refer to the Dynamic Content documentation at https://developers.vtex.com/docs/guides/faststore/dynamic-content-overview for mapping the page and the corresponding data-fetching function.
|
|
105
105
|
Search not found. Add a new component for this section or remove it from the CMS
|
|
106
|
-
|
|
107
106
|
Generating static pages (4/8)
|
|
108
107
|
Search not found. Add a new component for this section or remove it from the CMS
|
|
108
|
+
|
|
109
109
|
Generating static pages (4/8)
|
|
110
110
|
Search not found. Add a new component for this section or remove it from the CMS
|
|
111
111
|
|
|
112
112
|
Generating static pages (6/8)
|
|
113
113
|
Search not found. Add a new component for this section or remove it from the CMS
|
|
@@ -154,7 +154,7 @@ Route (pages) Size First Load JS
|
|
|
154
154
|
├ chunks/framework-d514426edf885c68.js 45.4 kB
|
|
155
155
|
├ chunks/main-a2d1cfe626f2fef1.js 33.4 kB
|
|
156
156
|
├ chunks/pages/_app-35e8dda638ee2672.js 26.6 kB
|
|
157
|
-
├ chunks/webpack-
|
|
157
|
+
├ chunks/webpack-62bcb2a452e15476.js 3.9 kB
|
|
158
158
|
└ css/146a8678babc61bf.css 9.2 kB
|
|
159
159
|
|
|
160
160
|
ƒ Middleware 44.8 kB
|
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
|
|
2
|
-
> @faststore/core@3.100.
|
|
2
|
+
> @faststore/core@3.100.7 test /home/runner/work/faststore/faststore/packages/core
|
|
3
3
|
> jest
|
|
4
4
|
|
|
5
|
-
PASS test/
|
|
6
|
-
PASS test/
|
|
7
|
-
PASS test/
|
|
5
|
+
PASS test/server/content/service.test.ts (13.432 s)
|
|
6
|
+
PASS test/utils/cookieCacheBusting.test.ts (11.728 s)
|
|
7
|
+
PASS test/server/password-protection-service.test.ts (13.8 s)
|
|
8
8
|
PASS test/utils/multipleTemplates.test.ts
|
|
9
|
+
PASS test/utils/clearCookies.test.ts
|
|
10
|
+
PASS test/server/cms/index.test.ts
|
|
9
11
|
PASS test/sdk/redirects/index.test.ts
|
|
10
12
|
PASS test/utils/trustedForwardedHost.test.ts
|
|
11
|
-
PASS test/
|
|
13
|
+
PASS test/server/content/promiseCache.test.ts
|
|
12
14
|
PASS test/server/cms/global.test.ts
|
|
15
|
+
PASS test/utils/validateSessionRefreshToken.test.ts
|
|
13
16
|
PASS test/utils/isLocalHost.test.ts
|
|
14
17
|
PASS test/utils/isLocalHost.browser.test.ts
|
|
15
|
-
PASS test/server/
|
|
16
|
-
PASS test/server/index.test.ts (10.891 s)
|
|
18
|
+
PASS test/server/index.test.ts (5.995 s)
|
|
17
19
|
|
|
18
|
-
Test Suites:
|
|
19
|
-
Tests:
|
|
20
|
+
Test Suites: 14 passed, 14 total
|
|
21
|
+
Tests: 155 passed, 155 total
|
|
20
22
|
Snapshots: 0 total
|
|
21
|
-
Time:
|
|
23
|
+
Time: 21.421 s
|
|
22
24
|
Ran all test suites.
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [3.100.8](https://github.com/vtex/faststore/compare/v3.100.7...v3.100.8) (2026-10-27)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **core:** address review feedback on CMS cache (3.x) ([ef77b3f](https://github.com/vtex/faststore/commit/ef77b3fa73d2cd475fef6129a3d0a948b9b59cbc))
|
|
11
|
+
- **core:** cache Content Platform template list across page generations (3.x) ([6480dd5](https://github.com/vtex/faststore/commit/6480dd5850baaacd3e9cc698be4690795b63ab7a)), closes [#3526](https://github.com/vtex/faststore/issues/3526)
|
|
12
|
+
- **core:** cache global and single CMS content across page generations (3.x) ([01d1b3a](https://github.com/vtex/faststore/commit/01d1b3a6ab6df30676f38275399ba6e5d3e0213b))
|
|
13
|
+
- **core:** do not cache empty required CMS content (3.x) ([df849cb](https://github.com/vtex/faststore/commit/df849cb3b967b305fa67b2cc7b3f7fdafabcba99))
|
|
14
|
+
|
|
15
|
+
## [3.100.7](https://github.com/vtex/faststore/compare/v3.100.6...v3.100.7) (2026-10-26)
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
- **core:** cache Content Platform template list across page generations (3.x) ([#3534](https://github.com/vtex/faststore/issues/3534)) ([825c176](https://github.com/vtex/faststore/commit/825c176357b0f40ac9d112ee9789dce0c8198cf0)), closes [#3526](https://github.com/vtex/faststore/issues/3526) [#3384](https://github.com/vtex/faststore/issues/3384) [#3526](https://github.com/vtex/faststore/issues/3526) [#3526](https://github.com/vtex/faststore/issues/3526) [#3384](https://github.com/vtex/faststore/issues/3384)
|
|
20
|
+
|
|
6
21
|
## [3.100.6](https://github.com/vtex/faststore/compare/v3.100.5...v3.100.6) (2026-10-07)
|
|
7
22
|
|
|
8
23
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faststore/core",
|
|
3
|
-
"version": "3.100.
|
|
3
|
+
"version": "3.100.8",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -127,5 +127,5 @@
|
|
|
127
127
|
"ts-jest": "29.1.1",
|
|
128
128
|
"typescript": "5.3.2"
|
|
129
129
|
},
|
|
130
|
-
"gitHead": "
|
|
130
|
+
"gitHead": "e742ac062ea5c0dd4df931f2431123bbbf973f34"
|
|
131
131
|
}
|
package/src/server/cms/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ContentData, ContentTypeOptions, Locator } from '@vtex/client-cms'
|
|
|
2
2
|
import ClientCMS from '@vtex/client-cms'
|
|
3
3
|
|
|
4
4
|
import MultipleContentError from 'src/sdk/error/MultipleContentError'
|
|
5
|
+
import { createPromiseCache } from 'src/server/content/promiseCache'
|
|
5
6
|
import { sanitizeHost } from 'src/utils/utilities'
|
|
6
7
|
import config from '../../../discovery.config'
|
|
7
8
|
|
|
@@ -71,16 +72,64 @@ export const clientCMS = new ClientCMS({
|
|
|
71
72
|
host: sanitizeHost(config.storeUrl),
|
|
72
73
|
})
|
|
73
74
|
|
|
75
|
+
type CMSPageResult = { data: ContentData[] }
|
|
76
|
+
|
|
77
|
+
export const cmsPageCache = createPromiseCache<CMSPageResult>()
|
|
78
|
+
|
|
79
|
+
type GetCMSPageOptions = {
|
|
80
|
+
/**
|
|
81
|
+
* Reuse a document fetched by locator. Only safe when the locator does not
|
|
82
|
+
* come from a preview, since preview versions change between requests.
|
|
83
|
+
*/
|
|
84
|
+
cache?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Listings by content type never carry preview data (previews always resolve
|
|
89
|
+
* to a locator), so they are shared across pages. Filtered listings depend on
|
|
90
|
+
* the page (e.g. landing page slug) and stay uncached.
|
|
91
|
+
*/
|
|
74
92
|
export const getCMSPage = async (
|
|
75
93
|
options: Options,
|
|
76
|
-
cmsClient: ClientCMS = clientCMS
|
|
77
|
-
|
|
94
|
+
cmsClient: ClientCMS = clientCMS,
|
|
95
|
+
{ cache = false }: GetCMSPageOptions = {}
|
|
96
|
+
): Promise<CMSPageResult> => {
|
|
97
|
+
const shareable = cmsClient === clientCMS
|
|
98
|
+
|
|
78
99
|
if (isLocator(options)) {
|
|
79
|
-
|
|
80
|
-
.getCMSPage(options)
|
|
81
|
-
|
|
100
|
+
const fetchByLocator = () =>
|
|
101
|
+
cmsClient.getCMSPage(options).then((page) => ({ data: [page] }))
|
|
102
|
+
|
|
103
|
+
if (!cache || !shareable) {
|
|
104
|
+
return fetchByLocator()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { contentType, documentId, versionId, releaseId } = options
|
|
108
|
+
return cmsPageCache.get(
|
|
109
|
+
JSON.stringify([
|
|
110
|
+
'locator',
|
|
111
|
+
contentType,
|
|
112
|
+
documentId,
|
|
113
|
+
versionId,
|
|
114
|
+
releaseId,
|
|
115
|
+
]),
|
|
116
|
+
fetchByLocator
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (options.filters || !shareable) {
|
|
121
|
+
return listCMSPages(options, cmsClient)
|
|
82
122
|
}
|
|
83
123
|
|
|
124
|
+
return cmsPageCache.get(JSON.stringify(['list', options.contentType]), () =>
|
|
125
|
+
listCMSPages(options, cmsClient)
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const listCMSPages = async (
|
|
130
|
+
options: Exclude<Options, Locator>,
|
|
131
|
+
cmsClient: ClientCMS
|
|
132
|
+
): Promise<CMSPageResult> => {
|
|
84
133
|
const pages = []
|
|
85
134
|
const page = 1
|
|
86
135
|
const perPage = 10
|
|
@@ -116,8 +165,11 @@ export const getCMSPage = async (
|
|
|
116
165
|
return { data: pages }
|
|
117
166
|
}
|
|
118
167
|
|
|
119
|
-
export const getPage = async <T extends ContentData>(
|
|
120
|
-
|
|
168
|
+
export const getPage = async <T extends ContentData>(
|
|
169
|
+
options: Options,
|
|
170
|
+
getOptions?: GetCMSPageOptions
|
|
171
|
+
) => {
|
|
172
|
+
const result = await getCMSPage(options, clientCMS, getOptions)
|
|
121
173
|
|
|
122
174
|
const pages = result.data
|
|
123
175
|
|
package/src/server/cms/pdp.ts
CHANGED
|
@@ -57,11 +57,16 @@ const getPDPFromCmsEnvData = async (
|
|
|
57
57
|
|
|
58
58
|
const template = findBestPDPTemplate(pages, product)
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
// The template locator comes from the build-time CMS data, never from a
|
|
61
|
+
// preview, so the fetched document can be shared across page generations.
|
|
62
|
+
return getPage<PDPContentType>(
|
|
63
|
+
{
|
|
64
|
+
contentType: 'pdp',
|
|
65
|
+
documentId: template.documentId as string,
|
|
66
|
+
versionId: template.versionId,
|
|
67
|
+
},
|
|
68
|
+
{ cache: true }
|
|
69
|
+
)
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
const getPDPFromCms = async (
|
package/src/server/cms/plp.ts
CHANGED
|
@@ -71,11 +71,16 @@ const getPLPFromCmsEnvData = async (
|
|
|
71
71
|
|
|
72
72
|
const template = findBestPLPTemplate(pages, slug, rewrites)
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
// The template locator comes from the build-time CMS data, never from a
|
|
75
|
+
// preview, so the fetched document can be shared across page generations.
|
|
76
|
+
return getPage<PLPContentType>(
|
|
77
|
+
{
|
|
78
|
+
contentType: 'plp',
|
|
79
|
+
documentId: template.documentId as string,
|
|
80
|
+
versionId: template.versionId,
|
|
81
|
+
},
|
|
82
|
+
{ cache: true }
|
|
83
|
+
)
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
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
|
+
}
|
|
@@ -15,13 +15,25 @@ import MissingContentError from 'src/sdk/error/MissingContentError'
|
|
|
15
15
|
import { getPDP, type PDPContentType } from '../cms/pdp'
|
|
16
16
|
import MultipleContentError from 'src/sdk/error/MultipleContentError'
|
|
17
17
|
import type { ServerProductQueryQuery } from '@generated/graphql'
|
|
18
|
+
import { createPromiseCache } from './promiseCache'
|
|
18
19
|
import { isBranchPreview, isContentPlatformSource } from './utils'
|
|
19
20
|
|
|
20
21
|
type ContentResult = ContentData | (ContentEntry & PageContentType)
|
|
21
22
|
|
|
23
|
+
const OPTIONAL_CONTENT_TYPES = [
|
|
24
|
+
'globalHeaderSections',
|
|
25
|
+
'globalFooterSections',
|
|
26
|
+
] as const
|
|
27
|
+
|
|
22
28
|
export class ContentService {
|
|
23
29
|
private clientCP: ClientCP
|
|
24
30
|
|
|
31
|
+
private readonly contentListCache = createPromiseCache<{
|
|
32
|
+
data: ContentResult[]
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
private readonly firstEntryCache = createPromiseCache<PageContentType>()
|
|
36
|
+
|
|
25
37
|
constructor() {
|
|
26
38
|
this.clientCP = new ClientCP({
|
|
27
39
|
tenant: config.api.storeId,
|
|
@@ -36,7 +48,7 @@ export class ContentService {
|
|
|
36
48
|
if (isContentPlatformSource()) {
|
|
37
49
|
return this.getFromCP<T>(options)
|
|
38
50
|
}
|
|
39
|
-
return getPage(options.cmsOptions)
|
|
51
|
+
return getPage(options.cmsOptions, { cache: !options.isPreview })
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
async getMultipleContent(
|
|
@@ -46,29 +58,12 @@ export class ContentService {
|
|
|
46
58
|
|
|
47
59
|
if (isContentPlatformSource()) {
|
|
48
60
|
const serviceParams = this.convertOptionsToParams(options)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let scroll: string | undefined
|
|
53
|
-
let pages = 0
|
|
54
|
-
|
|
55
|
-
do {
|
|
56
|
-
const result = await this.clientCP.listEntries(serviceParams, scroll)
|
|
57
|
-
allEntries.push(...result.entries)
|
|
58
|
-
const next = result.scroll ?? undefined
|
|
59
|
-
if (next && next === scroll) break
|
|
60
|
-
scroll = next
|
|
61
|
-
} while (scroll && ++pages < MAX_PAGES)
|
|
62
|
-
|
|
63
|
-
if (pages >= MAX_PAGES) {
|
|
64
|
-
console.warn('listEntries pagination hit MAX_PAGES cap', serviceParams)
|
|
61
|
+
|
|
62
|
+
if (options.isPreview) {
|
|
63
|
+
return this.listAndFillEntries(serviceParams, true)
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
return this.
|
|
68
|
-
allEntries,
|
|
69
|
-
serviceParams,
|
|
70
|
-
options.isPreview
|
|
71
|
-
)
|
|
66
|
+
return this.getCachedContentList(serviceParams)
|
|
72
67
|
}
|
|
73
68
|
return getCMSPage(options.cmsOptions)
|
|
74
69
|
}
|
|
@@ -124,10 +119,15 @@ export class ContentService {
|
|
|
124
119
|
options: ContentOptions
|
|
125
120
|
): Promise<T> {
|
|
126
121
|
const params = this.convertOptionsToParams(options)
|
|
122
|
+
// Filters are forwarded to listEntries, so a filtered lookup is not shared.
|
|
123
|
+
const shareable =
|
|
124
|
+
!options.isPreview &&
|
|
125
|
+
!('filters' in options.cmsOptions && options.cmsOptions.filters)
|
|
127
126
|
try {
|
|
128
127
|
const entry: PageContentType = await this.getEntry(
|
|
129
128
|
params,
|
|
130
|
-
options.isPreview
|
|
129
|
+
options.isPreview,
|
|
130
|
+
shareable
|
|
131
131
|
)
|
|
132
132
|
return entry as T
|
|
133
133
|
} catch (err: unknown) {
|
|
@@ -136,6 +136,39 @@ export class ContentService {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
private getCachedContentList(
|
|
140
|
+
serviceParams: EntryPathParams
|
|
141
|
+
): Promise<{ data: ContentResult[] }> {
|
|
142
|
+
return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
|
|
143
|
+
this.listAndFillEntries(serviceParams, false)
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async listAndFillEntries(
|
|
148
|
+
serviceParams: EntryPathParams,
|
|
149
|
+
isPreview: boolean
|
|
150
|
+
): Promise<{ data: (ContentEntry & PageContentType)[] }> {
|
|
151
|
+
// TODO: This should be temporary until the cp data-plane have a search engine.
|
|
152
|
+
const MAX_PAGES = 25
|
|
153
|
+
const allEntries: ContentEntry[] = []
|
|
154
|
+
let scroll: string | undefined
|
|
155
|
+
let pages = 0
|
|
156
|
+
|
|
157
|
+
do {
|
|
158
|
+
const result = await this.clientCP.listEntries(serviceParams, scroll)
|
|
159
|
+
allEntries.push(...result.entries)
|
|
160
|
+
const next = result.scroll ?? undefined
|
|
161
|
+
if (next && next === scroll) break
|
|
162
|
+
scroll = next
|
|
163
|
+
} while (scroll && ++pages < MAX_PAGES)
|
|
164
|
+
|
|
165
|
+
if (pages >= MAX_PAGES) {
|
|
166
|
+
console.warn('listEntries pagination hit MAX_PAGES cap', serviceParams)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return this.fillEntriesWithData(allEntries, serviceParams, isPreview)
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
private async fillEntriesWithData(
|
|
140
173
|
entries: ContentEntry[],
|
|
141
174
|
serviceParams: EntryPathParams,
|
|
@@ -155,11 +188,24 @@ export class ContentService {
|
|
|
155
188
|
|
|
156
189
|
private async getEntry(
|
|
157
190
|
params: EntryPathParams,
|
|
158
|
-
isPreview: boolean
|
|
191
|
+
isPreview: boolean,
|
|
192
|
+
shareable: boolean
|
|
159
193
|
): Promise<PageContentType> {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
194
|
+
if (params.entryId || params.slug) {
|
|
195
|
+
return this.getEntryData(params, isPreview)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!shareable) {
|
|
199
|
+
return this.fetchFirstEntryFromList(params, isPreview)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// An empty required type is likely content that is about to be published,
|
|
203
|
+
// so it is not kept for the whole TTL. Optional types are empty by design.
|
|
204
|
+
return this.firstEntryCache.get(
|
|
205
|
+
getContentTypeKey(params),
|
|
206
|
+
() => this.fetchFirstEntryFromList(params, false),
|
|
207
|
+
(entry) => !isEmptyEntry(entry) || isOptionalContentType(params)
|
|
208
|
+
)
|
|
163
209
|
}
|
|
164
210
|
|
|
165
211
|
private async getEntryData(
|
|
@@ -290,6 +336,26 @@ export class ContentService {
|
|
|
290
336
|
}
|
|
291
337
|
}
|
|
292
338
|
|
|
339
|
+
function isOptionalContentType({ contentType }: EntryPathParams): boolean {
|
|
340
|
+
return OPTIONAL_CONTENT_TYPES.includes(
|
|
341
|
+
contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isEmptyEntry(entry: PageContentType): boolean {
|
|
346
|
+
return Object.keys(entry).length === 0
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// listEntries only depends on these fields; the page slug must stay out of the key.
|
|
350
|
+
function getContentTypeKey({
|
|
351
|
+
accountName,
|
|
352
|
+
storeId,
|
|
353
|
+
contentType,
|
|
354
|
+
locale,
|
|
355
|
+
}: EntryPathParams): string {
|
|
356
|
+
return JSON.stringify([accountName, storeId, contentType, locale])
|
|
357
|
+
}
|
|
358
|
+
|
|
293
359
|
function isNotFoundError(err: unknown): boolean {
|
|
294
360
|
if (err instanceof MissingContentError) return true
|
|
295
361
|
if (err instanceof Error && /\b404\b/.test(err.message)) return true
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
clientCMS,
|
|
3
|
+
cmsPageCache,
|
|
4
|
+
getCMSPage,
|
|
5
|
+
getPage,
|
|
6
|
+
} from '../../../src/server/cms'
|
|
2
7
|
import { jest } from '@jest/globals'
|
|
3
8
|
import type { ContentData } from '@vtex/client-cms'
|
|
4
9
|
|
|
@@ -18,15 +23,32 @@ describe('CMS Integration', () => {
|
|
|
18
23
|
return data
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
const mockListing = (count = 1) => {
|
|
27
|
+
const mockFunction: jest.Mock<typeof clientCMS.getCMSPagesByContentType> =
|
|
28
|
+
jest.fn()
|
|
29
|
+
mockFunction.mockImplementation(() =>
|
|
30
|
+
Promise.resolve({
|
|
31
|
+
data: mockData(count),
|
|
32
|
+
hasNextPage: false,
|
|
33
|
+
totalItems: count,
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
return mockFunction
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const mockLocator = () => {
|
|
40
|
+
const mockFunction: jest.Mock<typeof clientCMS.getCMSPage> = jest.fn()
|
|
41
|
+
mockFunction.mockImplementation(() => Promise.resolve(mockData(1)[0]))
|
|
42
|
+
return mockFunction
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
cmsPageCache.clear()
|
|
47
|
+
})
|
|
48
|
+
|
|
21
49
|
describe('getCMSPage', () => {
|
|
22
50
|
it('returns the first page if there is only one page', async () => {
|
|
23
|
-
const mockFunction =
|
|
24
|
-
return Promise.resolve({
|
|
25
|
-
data: mockData(3),
|
|
26
|
-
hasNextPage: false,
|
|
27
|
-
totalItems: 3,
|
|
28
|
-
})
|
|
29
|
-
})
|
|
51
|
+
const mockFunction = mockListing(3)
|
|
30
52
|
clientCMS.getCMSPagesByContentType = mockFunction
|
|
31
53
|
|
|
32
54
|
const result = await getCMSPage({ contentType: 'plp' }, clientCMS)
|
|
@@ -62,4 +84,86 @@ describe('CMS Integration', () => {
|
|
|
62
84
|
expect(result.data.length).toBe(15)
|
|
63
85
|
})
|
|
64
86
|
})
|
|
87
|
+
|
|
88
|
+
describe('cache', () => {
|
|
89
|
+
it('shares a content type listing across concurrent callers', async () => {
|
|
90
|
+
const mockFunction = mockListing(3)
|
|
91
|
+
clientCMS.getCMSPagesByContentType = mockFunction
|
|
92
|
+
|
|
93
|
+
await Promise.all([
|
|
94
|
+
getCMSPage({ contentType: 'plp' }),
|
|
95
|
+
getCMSPage({ contentType: 'plp' }),
|
|
96
|
+
getCMSPage({ contentType: 'plp' }),
|
|
97
|
+
])
|
|
98
|
+
await getCMSPage({ contentType: 'pdp' })
|
|
99
|
+
|
|
100
|
+
expect(
|
|
101
|
+
mockFunction.mock.calls.map(([contentType]) => contentType)
|
|
102
|
+
).toEqual(['plp', 'pdp'])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not cache filtered listings', async () => {
|
|
106
|
+
const mockFunction = mockListing(1)
|
|
107
|
+
clientCMS.getCMSPagesByContentType = mockFunction
|
|
108
|
+
const options = {
|
|
109
|
+
contentType: 'landingPage',
|
|
110
|
+
filters: { filters: { 'settings.seo.slug': '/summer-sale' } },
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await getCMSPage(options)
|
|
114
|
+
await getCMSPage(options)
|
|
115
|
+
|
|
116
|
+
expect(mockFunction).toHaveBeenCalledTimes(2)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('fetches a locator on every call by default', async () => {
|
|
120
|
+
const mockFunction = mockLocator()
|
|
121
|
+
clientCMS.getCMSPage = mockFunction
|
|
122
|
+
const locator = {
|
|
123
|
+
contentType: 'globalSections',
|
|
124
|
+
documentId: 'doc-1',
|
|
125
|
+
versionId: 'version-1',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await getPage(locator)
|
|
129
|
+
await getPage(locator)
|
|
130
|
+
|
|
131
|
+
expect(mockFunction).toHaveBeenCalledTimes(2)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('reuses a locator fetch when caching is enabled', async () => {
|
|
135
|
+
const mockFunction = mockLocator()
|
|
136
|
+
clientCMS.getCMSPage = mockFunction
|
|
137
|
+
const locator = {
|
|
138
|
+
contentType: 'globalSections',
|
|
139
|
+
documentId: 'doc-1',
|
|
140
|
+
versionId: 'version-1',
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const [first, second] = await Promise.all([
|
|
144
|
+
getPage(locator, { cache: true }),
|
|
145
|
+
getPage(locator, { cache: true }),
|
|
146
|
+
])
|
|
147
|
+
await getPage({ ...locator, versionId: 'version-2' }, { cache: true })
|
|
148
|
+
|
|
149
|
+
expect(first).toEqual(second)
|
|
150
|
+
expect(mockFunction).toHaveBeenCalledTimes(2)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('does not cache failures', async () => {
|
|
154
|
+
const error = new Error('Request failed with status code 500')
|
|
155
|
+
const mockFunction: jest.Mock<typeof clientCMS.getCMSPagesByContentType> =
|
|
156
|
+
jest.fn()
|
|
157
|
+
mockFunction
|
|
158
|
+
.mockRejectedValueOnce(error)
|
|
159
|
+
.mockResolvedValueOnce({ data: mockData(1), totalItems: 1 } as never)
|
|
160
|
+
clientCMS.getCMSPagesByContentType = mockFunction
|
|
161
|
+
|
|
162
|
+
await expect(getCMSPage({ contentType: 'plp' })).rejects.toBe(error)
|
|
163
|
+
const result = await getCMSPage({ contentType: 'plp' })
|
|
164
|
+
|
|
165
|
+
expect(result.data).toHaveLength(1)
|
|
166
|
+
expect(mockFunction).toHaveBeenCalledTimes(2)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
65
169
|
})
|