diff --git a/packages/sitecore-jss-nextjs/src/edge/personalize-middleware.ts b/packages/sitecore-jss-nextjs/src/edge/personalize-middleware.ts index fa67bd605a..f76b09dcd2 100644 --- a/packages/sitecore-jss-nextjs/src/edge/personalize-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/edge/personalize-middleware.ts @@ -4,7 +4,7 @@ import { GraphQLPersonalizeServiceConfig, CdpService, CdpServiceConfig, - ExperienceContext, + ExperienceParams, getPersonalizedRewrite, } from '@sitecore-jss/sitecore-jss/personalize'; import { debug, NativeDataFetcher } from '@sitecore-jss/sitecore-jss'; @@ -83,7 +83,7 @@ export class PersonalizeMiddleware { } } - protected getExperienceContext(req: NextRequest): ExperienceContext { + protected getExperienceParams(req: NextRequest): ExperienceParams { return { geo: { city: req.geo?.city ?? null, @@ -157,28 +157,35 @@ export class PersonalizeMiddleware { return response; } + if (!browserId) { + browserId = await this.cdpService.generateBrowserId(); + + if (!browserId) { + debug.personalize('skipped (browser id generation failed)'); + return response; + } + } + // Execute targeted experience in CDP - const context = this.getExperienceContext(req); - const experienceResult = await this.cdpService.executeExperience( + const params = this.getExperienceParams(req); + const variantId = await this.cdpService.executeExperience( personalizeInfo.contentId, - context, + params, browserId ); - // If a browserId was not passed in (new session), a new browserId will be returned - browserId = experienceResult.browserId; - if (!experienceResult.variantId) { + if (!variantId) { debug.personalize('skipped (no variant identified)'); return response; } - if (!personalizeInfo.variantIds.includes(experienceResult.variantId)) { + if (!personalizeInfo.variantIds.includes(variantId)) { debug.personalize('skipped (invalid variant)'); return response; } // Rewrite to persononalized path - const rewritePath = getPersonalizedRewrite(pathname, { variantId: experienceResult.variantId }); + const rewritePath = getPersonalizedRewrite(pathname, { variantId }); // Note an absolute URL is required: https://nextjs.org/docs/messages/middleware-relative-urls const rewriteUrl = req.nextUrl.clone(); rewriteUrl.pathname = rewritePath; @@ -189,9 +196,7 @@ export class PersonalizeMiddleware { response.headers.set('x-middleware-cache', 'no-cache'); // Set browserId cookie on the response - if (browserId) { - this.setBrowserId(response, browserId); - } + this.setBrowserId(response, browserId); debug.personalize('personalize middleware end: %o', { rewritePath, diff --git a/packages/sitecore-jss/src/graphql-request-client.ts b/packages/sitecore-jss/src/graphql-request-client.ts index cbf32e9b3e..a76d01ada2 100644 --- a/packages/sitecore-jss/src/graphql-request-client.ts +++ b/packages/sitecore-jss/src/graphql-request-client.ts @@ -109,7 +109,7 @@ export class GraphQLRequestClient implements GraphQLClient { resolve(data); }) .catch((error: ClientError) => { - this.debug('response error: %o', error.response); + this.debug('response error: %o', error.response || error.message || error); reject(error); }); }); diff --git a/packages/sitecore-jss/src/personalize/cdp-service.test.ts b/packages/sitecore-jss/src/personalize/cdp-service.test.ts index e642b93221..7c33a458f5 100644 --- a/packages/sitecore-jss/src/personalize/cdp-service.test.ts +++ b/packages/sitecore-jss/src/personalize/cdp-service.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-unused-expressions */ -import { CdpService, ExperienceContext } from './cdp-service'; +import { CdpService, ExperienceParams } from './cdp-service'; import { expect, spy, use } from 'chai'; import spies from 'chai-spies'; import nock from 'nock'; @@ -13,8 +13,11 @@ describe('CdpService', () => { const contentId = 'content-id'; const variantId = 'variant-1'; const pointOfSale = 'pos-1'; + const friendlyId = contentId; + const channel = 'WEB'; const browserId = 'browser-id'; - const context = { + const ref = browserId; + const params = { geo: { city: 'Testville', country: 'US', @@ -31,7 +34,7 @@ describe('CdpService', () => { utm_medium: null, utm_content: null, }, - } as ExperienceContext; + } as ExperienceParams; const config = { endpoint, @@ -43,127 +46,203 @@ describe('CdpService', () => { nock.cleanAll(); }); - it('should return variant data for a route', async () => { - nock(endpoint) - .post(`/v2/callFlows/getAudience/${contentId}`, { - clientKey, - pointOfSale, - context, - browserId, - }) - .reply(200, { - variantId, - browserId, - }); + describe('executeExperience', () => { + it('should return variant data for a route', async () => { + nock(endpoint) + .post('/v2/callFlows', { + clientKey, + pointOfSale, + params, + browserId, + friendlyId, + channel, + }) + .reply(200, { + variantId, + }); + + const service = new CdpService(config); + const actualVariantId = await service.executeExperience(contentId, params, browserId); + + expect(actualVariantId).to.deep.equal(variantId); + }); - const service = new CdpService(config); - const result = await service.executeExperience(contentId, context, browserId); + it('should return fallback value when no response', async () => { + nock(endpoint) + .post('/v2/callFlows', { + clientKey, + pointOfSale, + params, + browserId, + friendlyId, + channel, + }) + .reply(200, { + variantId: '', + }); + + const service = new CdpService(config); + const variantId = await service.executeExperience(contentId, params, browserId); + + expect(variantId).to.be.undefined; + }); - expect(result).to.deep.equal({ - variantId, - browserId, + it('should fetch using custom fetcher resolver and respond with data', async () => { + const fetcherSpy = spy((url: string, data?: unknown) => { + return new AxiosDataFetcher().fetch(url, data); + }); + + nock(endpoint) + .post('/v2/callFlows', { + clientKey, + pointOfSale, + params, + browserId, + friendlyId, + channel, + }) + .reply(200, { + variantId, + }); + + const service = new CdpService({ ...config, dataFetcherResolver: () => fetcherSpy }); + const actualVariantId = await service.executeExperience(contentId, params, browserId); + + expect(actualVariantId).to.deep.equal(variantId); + expect(fetcherSpy).to.be.called.once; + expect(fetcherSpy).to.be.called.with('http://sctest/v2/callFlows'); }); - }); - it('should return fallback value when no response', async () => { - nock(endpoint) - .post(`/v2/callFlows/getAudience/${contentId}`, { - clientKey, - pointOfSale, - context, - browserId, - }) - .reply(200, { - variantId: '', - browserId: '', + it('should use custom fetcher resolver and return undefined id when timeout is exceeded', async () => { + const fetcherSpy = spy((url: string, data?: any) => { + return new AxiosDataFetcher({ timeout: 30 }).fetch(url, data); }); - const service = new CdpService(config); - const result = await service.executeExperience(contentId, context, browserId); + nock(endpoint) + .post('/v2/callFlows', { + clientKey, + pointOfSale, + params, + browserId, + friendlyId, + channel, + }) + .delay(50) + .reply(408); + + const service = new CdpService({ ...config, dataFetcherResolver: () => fetcherSpy }); + const actualVariantId = await service.executeExperience(contentId, params, browserId); + + expect(actualVariantId).to.deep.equal(undefined); + }); + + it('should throw error', async () => { + nock(endpoint) + .post('/v2/callFlows', { + clientKey, + browserId, + params, + pointOfSale, + friendlyId, + channel, + }) + .replyWithError('error_test'); + const service = new CdpService(config); + await service.executeExperience(contentId, params, browserId).catch((error) => { + expect(error.message).to.contain('error_test'); + }); + }); - expect(result.variantId).to.be.undefined; + it('should return fallback value when api returns timeout error', async () => { + nock(endpoint) + .post('/v2/callFlows', { + clientKey, + pointOfSale, + params, + browserId, + friendlyId, + channel, + }) + .reply(408); + + const service = new CdpService(config); + const actualVariantId = await service.executeExperience(contentId, params, browserId); + + expect(actualVariantId).to.deep.equal(undefined); + }); }); - it('should fetch using custom fetcher resolver and respond with data', async () => { - const fetcherSpy = spy((url: string, data?: unknown) => { - return new AxiosDataFetcher().fetch(url, data); + describe('generateBrowserId', () => { + it('should return browser id', async () => { + nock(endpoint) + .get(`/v1.2/browser/create.json?client_key=${clientKey}&message={}`) + .reply(200, { + ref, + }); + + const service = new CdpService(config); + const actualRef = await service.generateBrowserId(); + + expect(actualRef).to.deep.equal(ref); }); - nock(endpoint) - .post(`/v2/callFlows/getAudience/${contentId}`, { - clientKey, - pointOfSale, - context, - browserId, - }) - .reply(200, { - variantId, - browserId, + it('should fetch using custom fetcher resolver and respond with data', async () => { + const fetcherSpy = spy((url: string, data?: unknown) => { + return new AxiosDataFetcher().fetch(url, data); }); - const service = new CdpService({ ...config, dataFetcherResolver: () => fetcherSpy }); - const result = await service.executeExperience(contentId, context, browserId); + nock(endpoint) + .get(`/v1.2/browser/create.json?client_key=${clientKey}&message={}`) + .reply(200, { + ref, + }); - expect(result).to.deep.equal({ - variantId, - browserId, - }); - expect(fetcherSpy).to.be.called.once; - expect(fetcherSpy).to.be.called.with(`http://sctest/v2/callFlows/getAudience/${contentId}`); - }); - it('should use custom fetcher resolver and return undefined ids', async () => { - const fetcherSpy = spy((url: string, data?: any) => { - return new AxiosDataFetcher({ timeout: 30 }).fetch(url, data); + const service = new CdpService({ ...config, dataFetcherResolver: () => fetcherSpy }); + const actualRef = await service.generateBrowserId(); + + expect(actualRef).to.deep.equal(ref); + expect(fetcherSpy).to.be.called.once; + expect(fetcherSpy).to.be.called.with( + `http://sctest/v1.2/browser/create.json?client_key=${clientKey}&message={}` + ); }); - nock(endpoint) - .post(`/v2/callFlows/getAudience/${contentId}`, { - clientKey, - pointOfSale, - context, - browserId, - }) - .delay(50) - .reply(408); - - const service = new CdpService({ ...config, dataFetcherResolver: () => fetcherSpy }); - const result = await service.executeExperience(contentId, context, browserId); - - expect(result).to.deep.equal({ - variantId: undefined, - browserId: undefined, + it('should use custom fetcher resolver and return undefined ref when timeout is exceeded', async () => { + const fetcherSpy = spy((url: string, data?: any) => { + return new AxiosDataFetcher({ timeout: 30 }).fetch(url, data); + }); + + nock(endpoint) + .get(`/v1.2/browser/create.json?client_key=${clientKey}&message={}`) + .delay(50) + .reply(408); + + const service = new CdpService({ ...config, dataFetcherResolver: () => fetcherSpy }); + const actualRef = await service.generateBrowserId(); + + expect(actualRef).to.deep.equal(undefined); }); - }); - it('should throw error', async () => { - nock(endpoint) - .post(`/v2/callFlows/getAudience/${contentId}`, { - clientKey, - browserId, - context, - pointOfSale, - }) - .replyWithError('error_test'); - const service = new CdpService(config); - await service.executeExperience(contentId, context, browserId).catch((error) => { - expect(error.message).to.contain('error_test'); + + it('should throw error', async () => { + nock(endpoint) + .get(`/v1.2/browser/create.json?client_key=${clientKey}&message={}`) + .replyWithError('error_test'); + + const service = new CdpService(config); + await service.generateBrowserId().catch((error) => { + expect(error.message).to.contain('error_test'); + }); }); - }); - it('should return fallback value when api returns timeout error', async () => { - nock(endpoint) - .post(`/v2/callFlows/getAudience/${contentId}`, { - clientKey, - pointOfSale, - context, - browserId, - }) - .reply(408); - - const service = new CdpService(config); - const result = await service.executeExperience(contentId, context, browserId); - - expect(result).to.deep.equal({ - variantId: undefined, - browserId: undefined, + + it('should return fallback value when api returns timeout error', async () => { + nock(endpoint) + .get(`/v1.2/browser/create.json?client_key=${clientKey}&message={}`) + .reply(408); + + const service = new CdpService(config); + const actualRef = await service.generateBrowserId(); + + expect(actualRef).to.deep.equal(undefined); }); }); }); diff --git a/packages/sitecore-jss/src/personalize/cdp-service.ts b/packages/sitecore-jss/src/personalize/cdp-service.ts index 7385a86a78..1801517783 100644 --- a/packages/sitecore-jss/src/personalize/cdp-service.ts +++ b/packages/sitecore-jss/src/personalize/cdp-service.ts @@ -1,7 +1,7 @@ import debug from '../debug'; -import { HttpDataFetcher, ResponseError } from '../data-fetcher'; +import { HttpDataFetcher } from '../data-fetcher'; import { AxiosDataFetcher } from '../axios-fetcher'; -import { AxiosError } from 'axios'; +import { isTimeoutError } from '../utils'; /** * Object model of CDP execute experience result @@ -11,10 +11,13 @@ export type ExecuteExperienceResult = { * The identified variant */ variantId?: string; +}; + +export type GenerateBrowserIdResult = { /** * The browser id */ - browserId?: string; + ref: string; }; export type CdpServiceConfig = { @@ -48,7 +51,7 @@ export type DataFetcherResolver = ({ timeout }: { timeout: number }) => HttpD /** * Object model of Experience Context data */ -export type ExperienceContext = { +export type ExperienceParams = { geo: { city: string | null; country: string | null; @@ -77,59 +80,91 @@ export class CdpService { } /** - * Executes targeted experience for a page and context to determine the variant to render. + * Executes targeted experience for a page and params to determine the variant to render. * @param {string} contentId the friendly content id of the page - * @param {ExperienceContext} context the experience context for the user - * @param {string} [browserId] the browser id. If omitted, a browserId will be created and returned in the result. + * @param {ExperienceParams} params the experience params for the user + * @param {string} browserId the browser id. * @returns {ExecuteExperienceResult} the execute experience result */ async executeExperience( contentId: string, - context: ExperienceContext, - browserId = '' - ): Promise { - const endpoint = this.getExecuteExperienceUrl(contentId); + params: ExperienceParams, + browserId: string + ): Promise { + const endpoint = this.getExecuteExperienceUrl(); - debug.personalize('executing experience for %s %s %o', contentId, browserId, context); + debug.personalize('executing experience for %s %s %o', contentId, browserId, params); - const fetcher = this.config.dataFetcherResolver - ? this.config.dataFetcherResolver({ timeout: this.timeout }) - : this.getDefaultFetcher(); + const fetcher = this.getFetcher(); try { const response = await fetcher(endpoint, { clientKey: this.config.clientKey, pointOfSale: this.config.pointOfSale, browserId, - context, + friendlyId: contentId, + channel: 'WEB', + params, }); response.data.variantId === '' && (response.data.variantId = undefined); - response.data.browserId === '' && (response.data.browserId = undefined); - return response.data; + return response.data.variantId || undefined; + } catch (error) { + if (isTimeoutError(error)) { + return; + } + + throw error; + } + } + + /** + * Generates a new browser id + * @returns {string} browser id + */ + async generateBrowserId(): Promise { + const endpoint = this.getGenerateBrowserIdUrl(); + + debug.personalize('generating browser id'); + + const fetcher = this.getFetcher(); + + try { + const response = await fetcher(endpoint); + + return response.data.ref; } catch (error) { - if ( - (error as AxiosError).code === '408' || - (error as AxiosError).code === 'ECONNABORTED' || - (error as AxiosError).code === 'ETIMEDOUT' || - (error as ResponseError).response?.status === 408 || - (error as Error).name === 'AbortError' - ) { - return { - variantId: undefined, - browserId: undefined, - }; + if (isTimeoutError(error)) { + return; } + throw error; } } + /** + * Get formatted URL for generateBrowserId call + * @returns {string} formatted URL + */ + protected getGenerateBrowserIdUrl() { + return `${this.config.endpoint}/v1.2/browser/create.json?client_key=${this.config.clientKey}&message={}`; + } + + /** + * Returns provided data fetcher otherwise default one + * @returns {HttpDataFetcher} data fetcher + */ + protected getFetcher() { + return this.config.dataFetcherResolver + ? this.config.dataFetcherResolver({ timeout: this.timeout }) + : this.getDefaultFetcher(); + } + /** * Get formatted URL for executeExperience call - * @param {string} contentId friendly content id * @returns {string} formatted URL */ - protected getExecuteExperienceUrl(contentId: string) { - return `${this.config.endpoint}/v2/callFlows/getAudience/${contentId}`; + protected getExecuteExperienceUrl() { + return `${this.config.endpoint}/v2/callFlows`; } /** diff --git a/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts b/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts index 7dee685437..3ddbd04d0c 100644 --- a/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts +++ b/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts @@ -1,6 +1,6 @@ import { GraphQLClient, GraphQLRequestClient } from '../graphql-request-client'; import debug from '../debug'; -import { ResponseError } from '../data-fetcher'; +import { isTimeoutError } from '../utils'; export type GraphQLPersonalizeServiceConfig = { /** @@ -102,10 +102,7 @@ export class GraphQLPersonalizeService { } : undefined; } catch (error) { - if ( - (error as ResponseError).response?.status === 408 || - (error as Error).name === 'AbortError' - ) { + if (isTimeoutError(error)) { return undefined; } diff --git a/packages/sitecore-jss/src/personalize/index.ts b/packages/sitecore-jss/src/personalize/index.ts index 5cf483d3ba..342658058a 100644 --- a/packages/sitecore-jss/src/personalize/index.ts +++ b/packages/sitecore-jss/src/personalize/index.ts @@ -6,8 +6,9 @@ export { export { CdpService, CdpServiceConfig, - ExperienceContext, + ExperienceParams, ExecuteExperienceResult, + GenerateBrowserIdResult, } from './cdp-service'; export { getPersonalizedRewrite, diff --git a/packages/sitecore-jss/src/utils/index.ts b/packages/sitecore-jss/src/utils/index.ts index 3395bde364..f4e10c261c 100644 --- a/packages/sitecore-jss/src/utils/index.ts +++ b/packages/sitecore-jss/src/utils/index.ts @@ -1,5 +1,5 @@ export { default as isServer } from './is-server'; -export { resolveUrl, isAbsoluteUrl } from './utils'; +export { resolveUrl, isAbsoluteUrl, isTimeoutError } from './utils'; export { ExperienceEditor, HorizonEditor, diff --git a/packages/sitecore-jss/src/utils/utils.test.ts b/packages/sitecore-jss/src/utils/utils.test.ts index 2e42c3aa36..c6a5baed1e 100644 --- a/packages/sitecore-jss/src/utils/utils.test.ts +++ b/packages/sitecore-jss/src/utils/utils.test.ts @@ -2,7 +2,7 @@ import { expect, spy } from 'chai'; import { isEditorActive, resetEditorChromes, isServer, resolveUrl } from '.'; import { ChromeRediscoveryGlobalFunctionName } from './editing'; -import { isAbsoluteUrl } from './utils'; +import { isAbsoluteUrl, isTimeoutError } from './utils'; // must make TypeScript happy with `global` variable modification interface CustomWindow { @@ -171,4 +171,14 @@ describe('utils', () => { expect(isAbsoluteUrl('foo')).to.be.false; }); }); + + describe('isTimeoutError', () => { + it('should return true when error is timeout error', () => { + expect(isTimeoutError({ code: '408' })).to.be.true; + expect(isTimeoutError({ code: 'ECONNABORTED' })).to.be.true; + expect(isTimeoutError({ code: 'ETIMEDOUT' })).to.be.true; + expect(isTimeoutError({ response: { status: 408 } })).to.be.true; + expect(isTimeoutError({ name: 'AbortError' })).to.be.true; + }); + }); }); diff --git a/packages/sitecore-jss/src/utils/utils.ts b/packages/sitecore-jss/src/utils/utils.ts index 5626c44f26..137a0718de 100644 --- a/packages/sitecore-jss/src/utils/utils.ts +++ b/packages/sitecore-jss/src/utils/utils.ts @@ -1,5 +1,7 @@ import isServer from './is-server'; import { ParsedUrlQueryInput } from 'querystring'; +import { AxiosError } from 'axios'; +import { ResponseError } from '../data-fetcher'; /** * note: encodeURIComponent is available via browser (window) or natively in node.js @@ -58,3 +60,18 @@ export const isAbsoluteUrl = (url: string) => { return /^[a-z][a-z0-9+.-]*:/.test(url); }; + +/** + * Indicates whether the error is a timeout error + * @param {unknown} error error + * @returns {boolean} is timeout error + */ +export const isTimeoutError = (error: unknown) => { + return ( + (error as AxiosError).code === '408' || + (error as AxiosError).code === 'ECONNABORTED' || + (error as AxiosError).code === 'ETIMEDOUT' || + (error as ResponseError).response?.status === 408 || + (error as Error).name === 'AbortError' + ); +};