@faststore/core 3.100.7 → 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 +1 -1
- 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/{Oxei21wY_TrPYtPGUrFiR → 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-bde714c56fd09a0c.js → webpack-62bcb2a452e15476.js} +1 -1
- package/.next/trace +156 -156
- package/.turbo/turbo-build.log +10 -10
- package/.turbo/turbo-test.log +11 -10
- package/CHANGELOG.md +9 -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 +56 -38
- 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 +229 -5
- package/.next/static/chunks/3802.9744a010fe6a4d07.js +0 -1
- /package/.next/static/{Oxei21wY_TrPYtPGUrFiR → 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
|
|
@@ -101,10 +101,10 @@ Import trace for requested module:
|
|
|
101
101
|
Generating static pages (0/8) ...
|
|
102
102
|
|
|
103
103
|
Generating static pages (2/8)
|
|
104
104
|
Search not found. Add a new component for this section or remove it from the CMS
|
|
105
|
-
Search not found. Add a new component for this section or remove it from the CMS
|
|
106
105
|
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.
|
|
107
|
-
|
|
108
106
|
Generating static pages (4/8)
|
|
109
107
|
Search not found. Add a new component for this section or remove it from the CMS
|
|
108
|
+
Search not found. Add a new component for this section or remove it from the CMS
|
|
109
|
+
|
|
110
110
|
Generating static pages (4/8)
|
|
111
111
|
Search not found. Add a new component for this section or remove it from the CMS
|
|
112
112
|
|
|
113
113
|
Generating static pages (6/8)
|
|
114
114
|
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,23 +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/server/content/service.test.ts (
|
|
6
|
-
PASS test/utils/cookieCacheBusting.test.ts (
|
|
7
|
-
PASS test/server/password-protection-service.test.ts (
|
|
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
9
|
PASS test/utils/clearCookies.test.ts
|
|
10
|
+
PASS test/server/cms/index.test.ts
|
|
10
11
|
PASS test/sdk/redirects/index.test.ts
|
|
11
12
|
PASS test/utils/trustedForwardedHost.test.ts
|
|
12
|
-
PASS test/
|
|
13
|
+
PASS test/server/content/promiseCache.test.ts
|
|
13
14
|
PASS test/server/cms/global.test.ts
|
|
15
|
+
PASS test/utils/validateSessionRefreshToken.test.ts
|
|
14
16
|
PASS test/utils/isLocalHost.test.ts
|
|
15
17
|
PASS test/utils/isLocalHost.browser.test.ts
|
|
16
|
-
PASS test/server/
|
|
17
|
-
PASS test/server/index.test.ts (9.929 s)
|
|
18
|
+
PASS test/server/index.test.ts (5.995 s)
|
|
18
19
|
|
|
19
|
-
Test Suites:
|
|
20
|
-
Tests:
|
|
20
|
+
Test Suites: 14 passed, 14 total
|
|
21
|
+
Tests: 155 passed, 155 total
|
|
21
22
|
Snapshots: 0 total
|
|
22
|
-
Time:
|
|
23
|
+
Time: 21.421 s
|
|
23
24
|
Ran all test suites.
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
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
|
+
|
|
6
15
|
## [3.100.7](https://github.com/vtex/faststore/compare/v3.100.6...v3.100.7) (2026-10-26)
|
|
7
16
|
|
|
8
17
|
### 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,23 +15,24 @@ 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
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// Bounds how stale the template list can get in long-lived processes (ISR);
|
|
28
|
-
// a CMS publish already triggers a new build.
|
|
29
|
-
const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
|
|
23
|
+
const OPTIONAL_CONTENT_TYPES = [
|
|
24
|
+
'globalHeaderSections',
|
|
25
|
+
'globalFooterSections',
|
|
26
|
+
] as const
|
|
30
27
|
|
|
31
28
|
export class ContentService {
|
|
32
29
|
private clientCP: ClientCP
|
|
33
30
|
|
|
34
|
-
private readonly contentListCache =
|
|
31
|
+
private readonly contentListCache = createPromiseCache<{
|
|
32
|
+
data: ContentResult[]
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
private readonly firstEntryCache = createPromiseCache<PageContentType>()
|
|
35
36
|
|
|
36
37
|
constructor() {
|
|
37
38
|
this.clientCP = new ClientCP({
|
|
@@ -47,7 +48,7 @@ export class ContentService {
|
|
|
47
48
|
if (isContentPlatformSource()) {
|
|
48
49
|
return this.getFromCP<T>(options)
|
|
49
50
|
}
|
|
50
|
-
return getPage(options.cmsOptions)
|
|
51
|
+
return getPage(options.cmsOptions, { cache: !options.isPreview })
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
async getMultipleContent(
|
|
@@ -118,10 +119,15 @@ export class ContentService {
|
|
|
118
119
|
options: ContentOptions
|
|
119
120
|
): Promise<T> {
|
|
120
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)
|
|
121
126
|
try {
|
|
122
127
|
const entry: PageContentType = await this.getEntry(
|
|
123
128
|
params,
|
|
124
|
-
options.isPreview
|
|
129
|
+
options.isPreview,
|
|
130
|
+
shareable
|
|
125
131
|
)
|
|
126
132
|
return entry as T
|
|
127
133
|
} catch (err: unknown) {
|
|
@@ -133,30 +139,9 @@ export class ContentService {
|
|
|
133
139
|
private getCachedContentList(
|
|
134
140
|
serviceParams: EntryPathParams
|
|
135
141
|
): Promise<{ data: ContentResult[] }> {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const key = JSON.stringify([accountName, storeId, contentType, locale])
|
|
139
|
-
|
|
140
|
-
const cached = this.contentListCache.get(key)
|
|
141
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
142
|
-
return cached.promise
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const promise = this.listAndFillEntries(serviceParams, false).catch(
|
|
146
|
-
(err: unknown) => {
|
|
147
|
-
if (this.contentListCache.get(key)?.promise === promise) {
|
|
148
|
-
this.contentListCache.delete(key)
|
|
149
|
-
}
|
|
150
|
-
throw err
|
|
151
|
-
}
|
|
142
|
+
return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
|
|
143
|
+
this.listAndFillEntries(serviceParams, false)
|
|
152
144
|
)
|
|
153
|
-
|
|
154
|
-
this.contentListCache.set(key, {
|
|
155
|
-
expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
|
|
156
|
-
promise,
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
return promise
|
|
160
145
|
}
|
|
161
146
|
|
|
162
147
|
private async listAndFillEntries(
|
|
@@ -203,11 +188,24 @@ export class ContentService {
|
|
|
203
188
|
|
|
204
189
|
private async getEntry(
|
|
205
190
|
params: EntryPathParams,
|
|
206
|
-
isPreview: boolean
|
|
191
|
+
isPreview: boolean,
|
|
192
|
+
shareable: boolean
|
|
207
193
|
): Promise<PageContentType> {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
)
|
|
211
209
|
}
|
|
212
210
|
|
|
213
211
|
private async getEntryData(
|
|
@@ -338,6 +336,26 @@ export class ContentService {
|
|
|
338
336
|
}
|
|
339
337
|
}
|
|
340
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
|
+
|
|
341
359
|
function isNotFoundError(err: unknown): boolean {
|
|
342
360
|
if (err instanceof MissingContentError) return true
|
|
343
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
|
})
|