@faststore/core 4.10.1 → 4.10.2
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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +278 -235
- package/@generated/graphql.ts +1 -0
- package/@generated/schema.graphql +1 -1
- package/CHANGELOG.md +86 -0
- package/package.json +6 -6
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
- package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
- package/src/components/account/Layout/Layout.tsx +5 -7
- package/src/components/account/Menu/Menu.tsx +1 -1
- package/src/components/navigation/Navbar/Navbar.tsx +3 -3
- package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
- package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
- package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
- package/src/experimental/myAccountServerSideProps.ts +4 -4
- package/src/pages/[slug]/p.tsx +18 -4
- package/src/pages/api/fs/logout.ts +18 -10
- package/src/pages/api/fs/switch-contract.ts +170 -21
- package/src/pages/api/graphql.ts +101 -60
- package/src/pages/pvt/account/403.tsx +5 -4
- package/src/pages/pvt/account/cards/index.tsx +5 -5
- package/src/pages/pvt/account/orders/[id].tsx +5 -5
- package/src/pages/pvt/account/orders/index.tsx +5 -5
- package/src/pages/pvt/account/profile.tsx +5 -5
- package/src/pages/pvt/account/quotes/index.tsx +5 -5
- package/src/pages/pvt/account/security.tsx +5 -5
- package/src/pages/pvt/account/user-details.tsx +6 -6
- package/src/sdk/account/getB2BSessionClaims.ts +1 -1
- package/src/sdk/account/getIsB2BBuyer.ts +28 -0
- package/src/sdk/account/refreshToken.ts +89 -11
- package/src/sdk/account/refreshTokenPaths.ts +25 -0
- package/src/sdk/account/useRefreshToken.ts +15 -11
- package/src/sdk/product/isProductNotFound.ts +13 -0
- package/src/sdk/product/usePageProductsQuery.ts +80 -21
- package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
- package/src/sdk/product/useProductQuery.ts +29 -0
- package/src/sdk/session/index.ts +46 -34
- 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 +71 -51
- package/src/utils/getCheckoutParentDomain.ts +53 -0
- package/src/utils/isLocalHost.ts +2 -16
- package/src/utils/shouldBypassRefreshToken.ts +63 -0
- package/src/utils/trustedForwardedHost.ts +18 -0
- package/test/components/account/Layout.test.tsx +1 -1
- package/test/components/account/Menu.browser.test.tsx +7 -5
- package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
- package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
- package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
- package/test/components/navigation/Navbar.browser.test.tsx +3 -3
- package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
- package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
- package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
- package/test/pages/api/graphql.test.ts +175 -0
- package/test/pages/api/logout.test.ts +106 -0
- package/test/pages/api/switch-contract.test.ts +443 -15
- package/test/pages/pvt/account/403.test.ts +67 -0
- package/test/pages/pvt/account/cards/index.test.ts +3 -3
- package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
- package/test/sdk/account/refreshToken.browser.test.ts +10 -1
- package/test/sdk/account/refreshToken.test.ts +150 -18
- package/test/sdk/account/useRefreshToken.browser.test.ts +72 -0
- package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
- package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
- package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
- package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
- package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
- package/test/sdk/session/validateSession.browser.test.ts +111 -5
- package/test/server/cms/index.test.ts +102 -9
- package/test/server/content/promiseCache.test.ts +150 -0
- package/test/server/content/service.test.ts +277 -11
- package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
- package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
- package/test/utils/trustedForwardedHost.test.ts +21 -0
- package/src/sdk/account/getIsRepresentative.ts +0 -20
- package/test/utils/isLocalHost.browser.test.ts +0 -37
package/src/sdk/session/index.ts
CHANGED
|
@@ -8,10 +8,12 @@ import type {
|
|
|
8
8
|
ValidateSessionMutationVariables,
|
|
9
9
|
} from '@generated/graphql'
|
|
10
10
|
import deepEqual from 'fast-deep-equal'
|
|
11
|
-
import {
|
|
11
|
+
import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
|
|
12
12
|
import { filterChannel } from 'src/utils/utilities'
|
|
13
13
|
import storeConfig from '../../../discovery.config'
|
|
14
14
|
import {
|
|
15
|
+
getNextRefreshAfter,
|
|
16
|
+
isB2BSession,
|
|
15
17
|
isRefreshTokenSuccessful,
|
|
16
18
|
refreshTokenRequest,
|
|
17
19
|
} from '../account/refreshToken'
|
|
@@ -94,14 +96,12 @@ export const mutation = gql(`
|
|
|
94
96
|
`)
|
|
95
97
|
|
|
96
98
|
async function handleRefreshToken(session: Session): Promise<Session | null> {
|
|
97
|
-
const result = await refreshTokenRequest()
|
|
99
|
+
const result = await refreshTokenRequest({ isB2B: isB2BSession(session) })
|
|
98
100
|
|
|
99
101
|
if (isRefreshTokenSuccessful(result)) {
|
|
100
102
|
return {
|
|
101
103
|
...session,
|
|
102
|
-
refreshAfter:
|
|
103
|
-
Math.floor(new Date(result?.refreshAfter).getTime() / 1000)
|
|
104
|
-
),
|
|
104
|
+
refreshAfter: getNextRefreshAfter(result),
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -109,6 +109,43 @@ async function handleRefreshToken(session: Session): Promise<Session | null> {
|
|
|
109
109
|
return null
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The refresh-token flow runs only when `experimental.refreshToken` is on and
|
|
114
|
+
* the host can reach VTEX ID: localhost and WebOps previews do not proxy it.
|
|
115
|
+
*/
|
|
116
|
+
function canRefreshToken(): boolean {
|
|
117
|
+
return (
|
|
118
|
+
!!storeConfig.experimental?.refreshToken &&
|
|
119
|
+
!shouldBypassRefreshTokenBrowser()
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* With deliveryPromise on and no postalCode in the session, fills it from the
|
|
125
|
+
* B2B saved address or from the shopper location.
|
|
126
|
+
*/
|
|
127
|
+
async function syncDeliveryPromisePostalCode(session: Session) {
|
|
128
|
+
if (
|
|
129
|
+
!storeConfig.deliveryPromise?.enabled ||
|
|
130
|
+
(session.postalCode &&
|
|
131
|
+
session.postalCode !== storeConfig.session.postalCode)
|
|
132
|
+
) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Case B2B: If a B2B shopper is logged in and a saved address is available, the postalCode field is automatically updated with the postal code from that address by the B2B session apps (shopper-session and profile-session).
|
|
137
|
+
if (session.b2b?.savedPostalCode) {
|
|
138
|
+
sessionStore.set({
|
|
139
|
+
...session,
|
|
140
|
+
postalCode: session.b2b.savedPostalCode,
|
|
141
|
+
})
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sessionWithLocation = await getPostalCode(session)
|
|
146
|
+
!!sessionWithLocation && sessionStore.set(sessionWithLocation)
|
|
147
|
+
}
|
|
148
|
+
|
|
112
149
|
function isRefreshAfterExpired(session: Session): boolean {
|
|
113
150
|
return (
|
|
114
151
|
!!session.refreshAfter &&
|
|
@@ -126,12 +163,8 @@ export const validateSession = async (session: Session) => {
|
|
|
126
163
|
// If the refreshToken is enabled and the refreshAfter is expired, refresh the token.
|
|
127
164
|
// On success, continue to the validation flow with the refreshed session.
|
|
128
165
|
// On failure (logoutAndClearSession already triggered), bail out.
|
|
129
|
-
// Skipped
|
|
130
|
-
if (
|
|
131
|
-
!isLocalHostBrowser() &&
|
|
132
|
-
storeConfig.experimental?.refreshToken &&
|
|
133
|
-
isRefreshAfterExpired(session)
|
|
134
|
-
) {
|
|
166
|
+
// Skipped on localhost and WebOps previews, where the refresh token infrastructure is unavailable.
|
|
167
|
+
if (canRefreshToken() && isRefreshAfterExpired(session)) {
|
|
135
168
|
const refreshed = await handleRefreshToken(session)
|
|
136
169
|
if (!refreshed) {
|
|
137
170
|
return null
|
|
@@ -139,23 +172,7 @@ export const validateSession = async (session: Session) => {
|
|
|
139
172
|
session = refreshed
|
|
140
173
|
}
|
|
141
174
|
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
storeConfig.deliveryPromise?.enabled &&
|
|
145
|
-
(!session.postalCode ||
|
|
146
|
-
session.postalCode === storeConfig.session.postalCode)
|
|
147
|
-
) {
|
|
148
|
-
// Case B2B: If a B2B shopper is logged in and a saved address is available, the postalCode field is automatically updated with the postal code from that address by the B2B session apps (shopper-session and profile-session).
|
|
149
|
-
if (session.b2b && session.b2b?.savedPostalCode) {
|
|
150
|
-
sessionStore.set({
|
|
151
|
-
...session,
|
|
152
|
-
postalCode: session.b2b.savedPostalCode,
|
|
153
|
-
})
|
|
154
|
-
} else {
|
|
155
|
-
const sessionWithLocation = await getPostalCode(session)
|
|
156
|
-
!!sessionWithLocation && sessionStore.set(sessionWithLocation)
|
|
157
|
-
}
|
|
158
|
-
}
|
|
175
|
+
await syncDeliveryPromisePostalCode(session)
|
|
159
176
|
|
|
160
177
|
try {
|
|
161
178
|
// Prevents to call ValidateSession without session (required) and get Error
|
|
@@ -173,12 +190,7 @@ export const validateSession = async (session: Session) => {
|
|
|
173
190
|
|
|
174
191
|
return data.validateSession
|
|
175
192
|
} catch (error) {
|
|
176
|
-
|
|
177
|
-
!isLocalHostBrowser() &&
|
|
178
|
-
error?.status === 401 &&
|
|
179
|
-
storeConfig.experimental?.refreshToken
|
|
180
|
-
|
|
181
|
-
if (shouldRefreshToken) {
|
|
193
|
+
if (error?.status === 401 && canRefreshToken()) {
|
|
182
194
|
const refreshed = await handleRefreshToken(session)
|
|
183
195
|
if (refreshed) {
|
|
184
196
|
sessionStore.set(refreshed)
|
package/src/server/cms/index.ts
CHANGED
|
@@ -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
|
-
|
|
88
|
-
.getCMSPage(options)
|
|
89
|
-
|
|
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>(
|
|
128
|
-
|
|
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
|
|
package/src/server/cms/pdp.ts
CHANGED
|
@@ -58,11 +58,16 @@ const getPDPFromCmsEnvData = async (
|
|
|
58
58
|
|
|
59
59
|
const template = findBestPDPTemplate(pages, product)
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 (
|
package/src/server/cms/plp.ts
CHANGED
|
@@ -74,11 +74,16 @@ const getPLPFromCmsEnvData = async (
|
|
|
74
74
|
|
|
75
75
|
const template = findBestPLPTemplate(pages, slug, rewrites)
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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,31 +14,29 @@ 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
|
|
|
20
|
-
type ContentResult = ContentData |
|
|
21
|
+
type ContentResult = ContentData | PageContentType
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
expiresAt: number
|
|
24
|
-
promise: Promise<{ data: ContentResult[] }>
|
|
25
|
-
}
|
|
23
|
+
const CONTENT_AUTHOR_FIELDS = ['createdBy', 'updatedBy'] as const
|
|
26
24
|
|
|
27
25
|
const OPTIONAL_CONTENT_TYPES = [
|
|
28
26
|
'globalHeaderSections',
|
|
29
27
|
'globalFooterSections',
|
|
30
28
|
] as const
|
|
31
29
|
|
|
32
|
-
// Bounds how stale the template list can get in long-lived processes (ISR);
|
|
33
|
-
// a CMS publish already triggers a new build.
|
|
34
|
-
const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
|
|
35
|
-
|
|
36
30
|
export class ContentService {
|
|
37
31
|
private readonly clientCP = new ClientCP({
|
|
38
32
|
tenant: config.api.storeId,
|
|
39
33
|
})
|
|
40
34
|
|
|
41
|
-
private readonly contentListCache =
|
|
35
|
+
private readonly contentListCache = createPromiseCache<{
|
|
36
|
+
data: ContentResult[]
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
private readonly firstEntryCache = createPromiseCache<PageContentType>()
|
|
42
40
|
|
|
43
41
|
async getSingleContent<T extends ContentData>(
|
|
44
42
|
params: ContentParams
|
|
@@ -48,7 +46,7 @@ export class ContentService {
|
|
|
48
46
|
if (isContentPlatformSource()) {
|
|
49
47
|
return this.getFromCP<T>(options)
|
|
50
48
|
}
|
|
51
|
-
return getPage(options.cmsOptions)
|
|
49
|
+
return getPage(options.cmsOptions, { cache: !options.isPreview })
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
async getMultipleContent(
|
|
@@ -134,36 +132,15 @@ export class ContentService {
|
|
|
134
132
|
private getCachedContentList(
|
|
135
133
|
serviceParams: EntryPathParams
|
|
136
134
|
): Promise<{ data: ContentResult[] }> {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const key = JSON.stringify([accountName, storeId, contentType, locale])
|
|
140
|
-
|
|
141
|
-
const cached = this.contentListCache.get(key)
|
|
142
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
143
|
-
return cached.promise
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const promise = this.listAndFillEntries(serviceParams, false).catch(
|
|
147
|
-
(err: unknown) => {
|
|
148
|
-
if (this.contentListCache.get(key)?.promise === promise) {
|
|
149
|
-
this.contentListCache.delete(key)
|
|
150
|
-
}
|
|
151
|
-
throw err
|
|
152
|
-
}
|
|
135
|
+
return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
|
|
136
|
+
this.listAndFillEntries(serviceParams, false)
|
|
153
137
|
)
|
|
154
|
-
|
|
155
|
-
this.contentListCache.set(key, {
|
|
156
|
-
expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
|
|
157
|
-
promise,
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
return promise
|
|
161
138
|
}
|
|
162
139
|
|
|
163
140
|
private async listAndFillEntries(
|
|
164
141
|
serviceParams: EntryPathParams,
|
|
165
142
|
isPreview: boolean
|
|
166
|
-
): Promise<{ data:
|
|
143
|
+
): Promise<{ data: PageContentType[] }> {
|
|
167
144
|
// TODO: This should be temporary until the cp data-plane have a search engine.
|
|
168
145
|
const MAX_PAGES = 25
|
|
169
146
|
const allEntries: ContentEntry[] = []
|
|
@@ -189,7 +166,7 @@ export class ContentService {
|
|
|
189
166
|
entries: ContentEntry[],
|
|
190
167
|
serviceParams: EntryPathParams,
|
|
191
168
|
isPreview: boolean
|
|
192
|
-
): Promise<{ data:
|
|
169
|
+
): Promise<{ data: PageContentType[] }> {
|
|
193
170
|
const data = await Promise.all(
|
|
194
171
|
entries.map(async (entry) => {
|
|
195
172
|
const entryData = await this.getEntryData(
|
|
@@ -206,9 +183,21 @@ export class ContentService {
|
|
|
206
183
|
params: EntryPathParams,
|
|
207
184
|
isPreview: boolean
|
|
208
185
|
): Promise<PageContentType> {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
+
)
|
|
212
201
|
}
|
|
213
202
|
|
|
214
203
|
private async getEntryData(
|
|
@@ -220,15 +209,21 @@ export class ContentService {
|
|
|
220
209
|
throw new Error(`${operation} requires entryId or slug`)
|
|
221
210
|
}
|
|
222
211
|
|
|
212
|
+
const entry = await this.loadEntry(params, isPreview)
|
|
213
|
+
|
|
214
|
+
return withoutContentAuthorMetadata(entry as PageContentType)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private loadEntry(params: EntryPathParams, isPreview: boolean) {
|
|
223
218
|
if (isPreview) {
|
|
224
219
|
return params.entryId
|
|
225
|
-
?
|
|
226
|
-
:
|
|
220
|
+
? this.clientCP.previewEntryById(params)
|
|
221
|
+
: this.clientCP.previewEntryBySlug(params)
|
|
227
222
|
}
|
|
228
223
|
|
|
229
224
|
return params.entryId
|
|
230
|
-
?
|
|
231
|
-
:
|
|
225
|
+
? this.clientCP.getEntry(params)
|
|
226
|
+
: this.clientCP.getEntryBySlug(params)
|
|
232
227
|
}
|
|
233
228
|
|
|
234
229
|
private async fetchFirstEntryFromList(
|
|
@@ -237,11 +232,7 @@ export class ContentService {
|
|
|
237
232
|
): Promise<PageContentType> {
|
|
238
233
|
const { entries } = await this.clientCP.listEntries(params)
|
|
239
234
|
if (!entries || entries.length === 0) {
|
|
240
|
-
|
|
241
|
-
params.contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
if (!isOptional) {
|
|
235
|
+
if (!isOptionalContentType(params)) {
|
|
245
236
|
console.warn('No entries found for params', params)
|
|
246
237
|
}
|
|
247
238
|
return {} as PageContentType
|
|
@@ -341,16 +332,45 @@ export class ContentService {
|
|
|
341
332
|
private mergeEntryWithData(
|
|
342
333
|
entry: ContentEntry,
|
|
343
334
|
data: PageContentType
|
|
344
|
-
):
|
|
335
|
+
): PageContentType {
|
|
345
336
|
return {
|
|
346
|
-
...
|
|
347
|
-
...data,
|
|
337
|
+
...withoutContentAuthorMetadata(data),
|
|
348
338
|
id: entry.id,
|
|
349
339
|
name: entry.name ?? '',
|
|
350
340
|
}
|
|
351
341
|
}
|
|
352
342
|
}
|
|
353
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
|
+
|
|
364
|
+
function withoutContentAuthorMetadata<T extends object>(value: T): T {
|
|
365
|
+
const content = { ...value } as Record<string, unknown>
|
|
366
|
+
|
|
367
|
+
for (const field of CONTENT_AUTHOR_FIELDS) {
|
|
368
|
+
delete content[field]
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return content as T
|
|
372
|
+
}
|
|
373
|
+
|
|
354
374
|
function isNotFoundError(err: unknown): boolean {
|
|
355
375
|
if (err instanceof MissingContentError) return true
|
|
356
376
|
if (err instanceof Error && /\b404\b/.test(err.message)) return true
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import storeConfig from 'discovery.config'
|
|
2
|
+
|
|
3
|
+
const parseHostname = (url: unknown): string | undefined => {
|
|
4
|
+
if (typeof url !== 'string' || !url) {
|
|
5
|
+
return undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
return new URL(url).hostname.toLowerCase()
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Common parent of the store host and the checkout host (`secureSubdomain`)
|
|
17
|
+
* when checkout runs on a sibling host: `www.store.com` and `secure.store.com`
|
|
18
|
+
* share `store.com`.
|
|
19
|
+
*
|
|
20
|
+
* Returns `undefined` when a cookie scoped to the store host already reaches
|
|
21
|
+
* checkout (same host, or checkout on a subdomain of it), and when the hosts
|
|
22
|
+
* share fewer than two labels.
|
|
23
|
+
*/
|
|
24
|
+
export const getCheckoutParentDomain = (
|
|
25
|
+
hostname: string
|
|
26
|
+
): string | undefined => {
|
|
27
|
+
const checkoutHostname = parseHostname(storeConfig.secureSubdomain)
|
|
28
|
+
|
|
29
|
+
if (!checkoutHostname) {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const storeLabels = hostname.toLowerCase().split('.').reverse()
|
|
34
|
+
const checkoutLabels = checkoutHostname.split('.').reverse()
|
|
35
|
+
const common: string[] = []
|
|
36
|
+
|
|
37
|
+
for (
|
|
38
|
+
let index = 0;
|
|
39
|
+
index < Math.min(storeLabels.length, checkoutLabels.length) &&
|
|
40
|
+
storeLabels[index] === checkoutLabels[index];
|
|
41
|
+
index++
|
|
42
|
+
) {
|
|
43
|
+
common.unshift(storeLabels[index])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (common.length < 2) {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parent = common.join('.')
|
|
51
|
+
|
|
52
|
+
return parent === hostname.toLowerCase() ? undefined : parent
|
|
53
|
+
}
|
package/src/utils/isLocalHost.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hostnames that represent a local development environment.
|
|
3
|
-
*
|
|
4
|
-
* These are the only hosts where the FastStore app skips the refresh-token
|
|
5
|
-
* flow so developers can drive the app with a manually injected
|
|
6
|
-
* `VtexIdclientAutCookie_<account>` cookie regardless of feature flags.
|
|
7
3
|
*/
|
|
8
4
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
|
|
9
5
|
|
|
@@ -11,9 +7,8 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
|
|
|
11
7
|
* Returns `true` when the given hostname corresponds to a local development
|
|
12
8
|
* environment (`localhost` / `127.0.0.1`).
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* injected cookies, independent of any experimental flag.
|
|
10
|
+
* To decide whether to skip the refresh-token flow, use
|
|
11
|
+
* `shouldBypassRefreshToken`, which also covers WebOps preview hosts.
|
|
17
12
|
*
|
|
18
13
|
* The input MUST be a bare hostname (no port). Use {@link getRequestHostname}
|
|
19
14
|
* on server-side request headers before calling this helper.
|
|
@@ -22,12 +17,3 @@ export function isLocalHost(hostname: string | null | undefined): boolean {
|
|
|
22
17
|
if (!hostname) return false
|
|
23
18
|
return LOCAL_HOSTNAMES.has(hostname.toLowerCase())
|
|
24
19
|
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Browser-side wrapper that reads `window.location.hostname`. Returns `false`
|
|
28
|
-
* when `window` is not available (SSR/Node).
|
|
29
|
-
*/
|
|
30
|
-
export function isLocalHostBrowser(): boolean {
|
|
31
|
-
if (typeof window === 'undefined') return false
|
|
32
|
-
return isLocalHost(window.location.hostname)
|
|
33
|
-
}
|