diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3f323c1a..e4c0e5c830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Our versioning strategy is as follows: * `proxyAppDestination` arg can be passed into `create-sitecore-jss` command to define path for proxy to be installed in * `[templates/angular]` `[templates/angular-xmcloud]` `[template/node-xmcloud-proxy]` `[sitecore-jss-proxy]` Introduced /api/editing/config endpoint ([#1903](https://github.com/Sitecore/jss/pull/1903)) * `[templates/angular]` `[templates/angular-xmcloud]` `[template/node-xmcloud-proxy]` `[sitecore-jss-proxy]` Introduced /api/editing/render endpoint ([#1908](https://github.com/Sitecore/jss/pull/1908)) +* `[templates/angular-xmcloud]` `[template/node-xmcloud-proxy]` Personalization support ([#1964](https://github.com/Sitecore/jss/pull/1964)) * `[create-sitecore-jss]``[sitecore-jss-angular]``[template/angular-xmcloud]` Angular SXA components * Angular placeholder now supports SXA components ([#1870](https://github.com/Sitecore/jss/pull/1870)) * Component styles ([#1917](https://github.com/Sitecore/jss/pull/1917)) diff --git a/docs/upgrades/unreleased.md b/docs/upgrades/unreleased.md index abe7d1452d..2ea5ee66f1 100644 --- a/docs/upgrades/unreleased.md +++ b/docs/upgrades/unreleased.md @@ -130,11 +130,17 @@ If you plan to use the Angular SDK with XMCloud, you will need to perform next steps: * On top of existing Angular sample, apply changes from "angular-xmcloud" add-on. -* Update package.json "build:client" script to use explicit "production" configuration: +* Update package.json: + * Update "build:client" script to use explicit "production" configuration: - ```shell - "build:client": "cross-env-shell ng build --configuration=production --base-href $npm_package_config_sitecoreDistPath/browser/ --output-path=$npm_package_config_buildArtifactsPath/browser/" - ``` + ```shell + "build:client": "cross-env-shell ng build --configuration=production --base-href $npm_package_config_sitecoreDistPath/browser/ --output-path=$npm_package_config_buildArtifactsPath/browser/" + ``` + * Add `CloudSDK` dependencies: + ``` + "@sitecore-cloudsdk/core": "^0.4.0", + "@sitecore-cloudsdk/events": "^0.4.0", + ``` * Update /scripts/bootstrap.ts file to generate a metadata for editing integration: Assuming that you have a `generate-metadata.ts` file pulled from the "angular-xmcloud" add-on: @@ -162,7 +168,7 @@ If you plan to use the Angular SDK with XMCloud, you will need to perform next s ``` * Restructure /src/app/lib/client-factory.ts. This is needed in order to separate the GraphQL client factory configuration from the client factory itself, so we have a single source of GraphQL endpoint resolution that can be used in different places. For example node-xmcloud-proxy, scripts/update-graphql-fragment-data.ts, etc. - * Introduce /src/app/lib/graphql-client-factory/config.ts. It should expose the _getGraphQLClientFactoryConfig_ that returns the configuration object for the GraphQL client factory, for example (full code snippet you can find in the "angular-xmcloud" add-on): + * Introduce /src/app/lib/graphql-client-factory/config.ts. It should expose the _getGraphQLClientFactoryConfig_ that returns the configuration object for the GraphQL client factory. You should refer to the full code snippet with Edge endpoint initalization in the the "angular-xmcloud" add-on, but an example initialization for connected and dev setup would look like this: ```ts import { GraphQLRequestClientFactoryConfig } from '@sitecore-jss/sitecore-jss-angular/cjs'; @@ -173,8 +179,8 @@ If you plan to use the Angular SDK with XMCloud, you will need to perform next s if (env.graphQLEndpoint && env.sitecoreApiKey) { clientConfig = { - endpoint: env.graphQLEndpoint, - apiKey: env.sitecoreApiKey, + endpoint: env.graphQLEndpoint, + apiKey: env.sitecoreApiKey, }; } @@ -224,9 +230,10 @@ If you plan to use the Angular SDK with XMCloud, you will need to perform next s import { layoutServiceFactory } from './src/app/lib/layout-service-factory'; import { components } from './src/app/components/app-components.module'; import metadata from './src/environments/metadata.json'; - + ... const defaultLanguage = environment.defaultLanguage; + const sitecoreSiteName = environment.sitecoreSiteName; const getClientFactoryConfig = getGraphQLClientFactoryConfig; export { @@ -236,6 +243,7 @@ If you plan to use the Angular SDK with XMCloud, you will need to perform next s dictionaryServiceFactory, layoutServiceFactory, defaultLanguage, + sitecoreSiteName, components, metadata }; diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/package.json b/packages/create-sitecore-jss/src/templates/angular-xmcloud/package.json index c7ffff1bba..81d9a1d5c4 100644 --- a/packages/create-sitecore-jss/src/templates/angular-xmcloud/package.json +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/package.json @@ -8,8 +8,8 @@ "prepare:proxy-build": "ts-node --project src/tsconfig.webpack-server.json ./scripts/proxy-build.ts" }, "dependencies": { - "@sitecore-cloudsdk/core": "^0.4.0-rc.0", - "@sitecore-cloudsdk/events": "^0.4.0-rc.0", + "@sitecore-cloudsdk/core": "^0.4.0", + "@sitecore-cloudsdk/events": "^0.4.0", "font-awesome": "^4.7.0", "sass": "^1.52.3", "sass-alias": "^1.0.5" diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts b/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts index 5d2c0eb237..155fe8c827 100644 --- a/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts @@ -5,12 +5,12 @@ import { layoutServiceFactory } from './src/app/lib/layout-service-factory'; import { environment } from './src/environments/environment'; import { components } from './src/app/components/app-components.module'; import metadata from './src/environments/metadata.json'; - /** * Define the required configuration values to be exported from the server.bundle.ts. */ const defaultLanguage = environment.defaultLanguage; +const sitecoreSiteName = environment.sitecoreSiteName; const getClientFactoryConfig = getGraphQLClientFactoryConfig; export { @@ -19,6 +19,7 @@ export { dictionaryServiceFactory, layoutServiceFactory, defaultLanguage, + sitecoreSiteName, components, metadata, }; diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/src/app/lib/graphql-client-factory/config.ts b/packages/create-sitecore-jss/src/templates/angular-xmcloud/src/app/lib/graphql-client-factory/config.ts index f7453fbb3a..6cc44823c3 100644 --- a/packages/create-sitecore-jss/src/templates/angular-xmcloud/src/app/lib/graphql-client-factory/config.ts +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/src/app/lib/graphql-client-factory/config.ts @@ -28,10 +28,9 @@ export const getGraphQLClientFactoryConfig = () => { : getEdgeProxyContentUrl(env.sitecoreEdgeContextId, env.proxyHost), }; } else if (env.graphQLEndpoint && env.sitecoreApiKey) { - const graphQLEndpointPath = new URL(env.graphQLEndpoint).pathname; - + // we ignore ssr-proxy and query CM directly in case apiKey is used (i.e. in dev docker deployments) clientConfig = { - endpoint: isServer ? env.graphQLEndpoint : `${env.proxyHost}${graphQLEndpointPath}`, + endpoint: env.graphQLEndpoint, apiKey: env.sitecoreApiKey, }; } diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env index a92a843a95..7be7095bc6 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env @@ -12,3 +12,14 @@ PROXY_BUNDLE_PATH= # Set the DEBUG environment variable to 'sitecore-jss:*,sitecore-jss:proxy,http-proxy-middleware*' to see all logs: #DEBUG=sitecore-jss:*,http-proxy-middleware* + +# An optional Sitecore Personalize scope identifier. +# This can be used to isolate personalization data when multiple XM Cloud Environments share a Personalize tenant. +# This should match the PAGES_PERSONALIZE_SCOPE environment variable for your connected XM Cloud Environment. +PERSONALIZE_SCOPE= + +# Timeout (ms) for Sitecore CDP requests to respond within. Default is 400. +PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT= + +# Timeout (ms) for Sitecore Experience Edge requests to respond within. Default is 400. +PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT= diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/config.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/config.ts index 2c3f8d2c79..4a455d97d0 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/config.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/config.ts @@ -1,5 +1,5 @@ import { Config, ServerBundle } from './types'; - +import { PersonalizeConfig } from '@sitecore-jss/sitecore-jss-proxy'; /** * The server.bundle.js file from your pre-built SPA app. */ @@ -13,6 +13,39 @@ try { throw new Error(`ERROR: The server.bundle.js error. ${error}`); } +const clientFactoryConfig = serverBundle.getClientFactoryConfig(); + +/** + * GraphQL endpoint resolution to meet the requirements of the http-proxy-middleware + */ +export const graphQLEndpoint = (() => { + try { + const graphQLEndpoint = new URL(clientFactoryConfig.endpoint); + // GraphQL endpoint URL (Edge endpoint for production and GraphQL Sitecore CM endpoint for dev) + const graphQLEndpointUrl = `${graphQLEndpoint.protocol}//${graphQLEndpoint.hostname}`; + // Sitecore Edge Context ID - will only be present for production + const sitecoreEdgeContextId = graphQLEndpoint.searchParams.get('sitecoreContextId'); + // Browser request path to the proxy. Includes only the pathname. + const pathname = graphQLEndpoint.pathname; + // Target URL for the proxy. Can't include the query string. + const target = `${graphQLEndpointUrl}${pathname}`; + + return { + target, + path: pathname, + graphQLEndpointUrl, + sitecoreEdgeContextId, + }; + } catch (error) { + throw new Error( + `ERROR: The serverBundle should export a getClientFactoryConfig function with valid GraphQL endpoint URL returned, current value is ${clientFactoryConfig.endpoint}. ` + + 'Please check your server bundle.' + ); + } +})(); + +const { clientFactory } = serverBundle; + export const config: Config = { /** * The require'd server.bundle.js file from your pre-built SPA app. @@ -23,3 +56,36 @@ export const config: Config = { */ port: process.env.PROXY_PORT || 3000, }; + +export const personalizeConfig: PersonalizeConfig = { + // Configuration for your Sitecore Experience Edge endpoint + edgeConfig: { + clientFactory, + timeout: + (process.env.PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT && + parseInt(process.env.PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT)) || + 400, + }, + // Configuration for your Sitecore CDP endpoint + // Edge URL and ID can be taken from proxy env, or the base SPA app + cdpConfig: { + sitecoreEdgeUrl: graphQLEndpoint.graphQLEndpointUrl, + sitecoreEdgeContextId: graphQLEndpoint.sitecoreEdgeContextId || '', + timeout: + (process.env.PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT && + parseInt(process.env.PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT)) || + 400, + }, + // Optional Sitecore Personalize scope identifier. + scope: process.env.PERSONALIZE_SCOPE, + // This function determines if the personalization should be turned off. + // IMPORTANT: You should implement based on your cookie consent management solution of choice. + // You may wish to keep it disabled while in development mode. + // Personalization will also be disabled when edge context id is missing + disabled: () => process.env.NODE_ENV === 'development' || !graphQLEndpoint.sitecoreEdgeContextId, + // This function determines if a route should be excluded from personalization. + excludeRoute: () => false, + sitecoreSiteName: serverBundle.sitecoreSiteName || '', + // defaultLanguage will be used as fallback for personalization, if language cannot be read from layout service data + defaultLanguage: serverBundle.defaultLanguage, +}; diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts index 65d1f3f8cf..85e2390ea5 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts @@ -1,11 +1,11 @@ import 'dotenv/config'; import express, { Response } from 'express'; import compression from 'compression'; -import { createProxyMiddleware } from 'http-proxy-middleware'; +import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware'; import { debug } from '@sitecore-jss/sitecore-jss'; -import { editingRouter } from '@sitecore-jss/sitecore-jss-proxy'; -import { healthCheck } from '@sitecore-jss/sitecore-jss-proxy'; -import { config } from './config'; +import { editingRouter, healthCheck } from '@sitecore-jss/sitecore-jss-proxy'; +import { config, graphQLEndpoint } from './config'; +import { personalizeHelper, personalizePlugin } from './personalize'; const server = express(); @@ -45,31 +45,6 @@ const layoutService = layoutServiceFactory.create(); const dictionaryService = dictionaryServiceFactory.create(); -const clientFactoryConfig = config.serverBundle.getClientFactoryConfig(); - -/** - * GraphQL endpoint resolution to meet the requirements of the http-proxy-middleware - */ -const graphQLEndpoint = (() => { - try { - const graphQLEndpoint = new URL(clientFactoryConfig.endpoint); - // Browser request path to the proxy. Includes only the pathname. - const pathname = graphQLEndpoint.pathname; - // Target URL for the proxy. Can't include the query string. - const target = `${graphQLEndpoint.protocol}//${graphQLEndpoint.hostname}${pathname}`; - - return { - target, - path: pathname, - }; - } catch (error) { - throw new Error( - `ERROR: The serverBundle should export a getClientFactoryConfig function with valid GraphQL endpoint URL returned, current value is ${clientFactoryConfig.endpoint}. ` + - 'Please check your server bundle.' - ); - } -})(); - /** * Parse requested url in order to detect current route and language * @param {string} reqRoute requested route @@ -105,6 +80,8 @@ const handleError = (res: Response, err: unknown) => { // enable gzip compression for appropriate file types server.use(compression()); +// enable access to req.body +server.use(graphQLEndpoint.path, express.json()); // turn off x-powered-by http header server.settings['x-powered-by'] = false; @@ -125,6 +102,12 @@ server.use( createProxyMiddleware({ target: graphQLEndpoint.target, changeOrigin: true, + selfHandleResponse: true, + on: { + proxyReq: fixRequestBody, + }, + // for client-side routing, personalization is performed by modifying layout service response + plugins: [personalizePlugin], }) ); @@ -165,11 +148,17 @@ server.use(async (req, res) => { } // Language is required. In case it's not specified in the requested URL, fallback to the default language from the app configuration. - const layoutData = await layoutService.fetchLayoutData( + let layoutData = await layoutService.fetchLayoutData( route, lang || config.serverBundle.defaultLanguage ); - + // for SSR loading routing, personalization is performed by modifying layoutData directly + const personalizedLayoutData = await personalizeHelper.personalizeLayoutData( + req, + res, + layoutData + ); + layoutData = personalizedLayoutData; const viewBag = { dictionary: {} }; viewBag.dictionary = await dictionaryService.fetchDictionaryData( diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/personalize.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/personalize.ts new file mode 100644 index 0000000000..6aae1db9c6 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/personalize.ts @@ -0,0 +1,34 @@ +import { GRAPHQL_LAYOUT_QUERY_NAME, PersonalizeHelper } from '@sitecore-jss/sitecore-jss-proxy'; +import { personalizeConfig } from './config'; +import { responseInterceptor } from 'http-proxy-middleware'; +import { Plugin } from 'http-proxy-middleware/dist/types'; +import { IncomingMessageWithBody } from './types'; + +export const personalizeHelper = new PersonalizeHelper(personalizeConfig); + +// personalize plugin to modify intercepted Layout Service request data +export const personalizePlugin: Plugin = (proxyServer) => { + proxyServer.on( + 'proxyRes', + responseInterceptor(async (responseBuffer, _, req, res) => { + let responseText = responseBuffer.toString('utf8'); + const payload = JSON.stringify((req as IncomingMessageWithBody).body); + + // only apply personalization onto JSS layout service results + if (payload.includes(GRAPHQL_LAYOUT_QUERY_NAME)) { + let layoutDataRaw = JSON.parse(responseText); + if (!layoutDataRaw?.data?.layout?.item?.rendered?.sitecore) { + return responseText; + } + const personalizedLayout = await personalizeHelper.personalizeLayoutData( + req as IncomingMessageWithBody, + res, + layoutDataRaw?.data?.layout?.item?.rendered + ); + layoutDataRaw.data.layout.item.rendered = personalizedLayout; + responseText = JSON.stringify(layoutDataRaw); + } + return responseText; + }) + ); +}; diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts index 43d34f4ec9..04283bea84 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import { GraphQLRequestClientFactory, GraphQLRequestClientFactoryConfig, @@ -6,6 +7,7 @@ import { DictionaryService } from '@sitecore-jss/sitecore-jss/i18n'; import { Metadata } from '@sitecore-jss/sitecore-jss/utils'; import { LayoutService } from '@sitecore-jss/sitecore-jss/layout'; import { AppRenderer, RouteUrlParser } from '@sitecore-jss/sitecore-jss-proxy'; +import { IncomingMessage } from 'http'; export interface ServerBundle { [key: string]: unknown; @@ -14,6 +16,7 @@ export interface ServerBundle { clientFactory: GraphQLRequestClientFactory; getClientFactoryConfig: () => GraphQLRequestClientFactoryConfig; defaultLanguage: string; + sitecoreSiteName: string; layoutServiceFactory: { create: () => LayoutService }; dictionaryServiceFactory: { create: () => DictionaryService }; components: string[] | Map; @@ -25,3 +28,10 @@ export interface Config { port: string | number; serverBundle: ServerBundle; } + +/** + * IncomingMessage type modified with exporess.json() call to include request body + */ +export type IncomingMessageWithBody = IncomingMessage & { + body: ReadableStream | null; +}; diff --git a/packages/sitecore-jss-angular/src/public_api.ts b/packages/sitecore-jss-angular/src/public_api.ts index c5df6b590d..76cfeeb8b1 100644 --- a/packages/sitecore-jss-angular/src/public_api.ts +++ b/packages/sitecore-jss-angular/src/public_api.ts @@ -64,6 +64,7 @@ export { ComponentParams, getContentStylesheetLink, EditMode, + LayoutServiceContext, } from '@sitecore-jss/sitecore-jss/layout'; export { RetryStrategy, @@ -80,10 +81,20 @@ export { enableDebug, ClientError, HTMLLink, + debug, CacheClient, CacheOptions, MemoryCacheClient, } from '@sitecore-jss/sitecore-jss'; +export { + GraphQLPersonalizeService, + GraphQLPersonalizeServiceConfig, + PersonalizeInfo, + CdpHelper, + DEFAULT_VARIANT, + getGroomedVariantIds, + personalizeLayout, +} from '@sitecore-jss/sitecore-jss/personalize'; export { isServer } from '@sitecore-jss/sitecore-jss/utils'; export { isEditorActive, @@ -104,4 +115,3 @@ export { EventInstance, PageViewInstance, } from '@sitecore-jss/sitecore-jss/tracking'; -export { CdpHelper } from '@sitecore-jss/sitecore-jss/personalize'; diff --git a/packages/sitecore-jss-proxy/package.json b/packages/sitecore-jss-proxy/package.json index 540eef7f74..e2a0079f8e 100644 --- a/packages/sitecore-jss-proxy/package.json +++ b/packages/sitecore-jss-proxy/package.json @@ -12,7 +12,7 @@ "test": "mocha --require ts-node/register \"./src/**/*.test.ts\" --exit", "prepublishOnly": "npm run build", "coverage": "nyc npm test", - "generate-docs": "npx typedoc --plugin typedoc-plugin-markdown --readme none --out ../../ref-docs/sitecore-jss-proxy src/index.ts --githubPages false" + "generate-docs": "npx typedoc --plugin typedoc-plugin-markdown --readme none --entryPoints src/personalize/index.ts --out ../../ref-docs/sitecore-jss-proxy src/index.ts --githubPages false" }, "engines": { "node": ">=20" @@ -27,6 +27,8 @@ "url": "https://github.com/sitecore/jss/issues" }, "dependencies": { + "@sitecore-cloudsdk/core": "^0.4.0", + "@sitecore-cloudsdk/personalize": "^0.4.0", "@sitecore-jss/sitecore-jss": "22.3.0-canary.3", "http-proxy-middleware": "^2.0.6", "http-status-codes": "^2.2.0", @@ -37,6 +39,7 @@ "@types/express": "^4.17.17", "@types/mocha": "^10.0.1", "@types/node": "^20.14.2", + "@types/proxyquire": "^1.3.31", "@types/set-cookie-parser": "^2.4.2", "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", @@ -46,6 +49,7 @@ "express": "^4.19.2", "mocha": "^10.2.0", "nyc": "^15.1.0", + "proxyquire": "^2.1.3", "sinon": "^17.0.1", "supertest": "^7.0.0", "ts-node": "^10.9.1", diff --git a/packages/sitecore-jss-proxy/personalize.d.ts b/packages/sitecore-jss-proxy/personalize.d.ts new file mode 100644 index 0000000000..d66b348935 --- /dev/null +++ b/packages/sitecore-jss-proxy/personalize.d.ts @@ -0,0 +1 @@ +export * from './types/personalize/index'; diff --git a/packages/sitecore-jss-proxy/personalize.js b/packages/sitecore-jss-proxy/personalize.js new file mode 100644 index 0000000000..35e27a3bec --- /dev/null +++ b/packages/sitecore-jss-proxy/personalize.js @@ -0,0 +1 @@ +module.exports = require('./dist/cjs/personalize/index'); diff --git a/packages/sitecore-jss-proxy/src/index.ts b/packages/sitecore-jss-proxy/src/index.ts index 746e12254c..54fc6eea31 100644 --- a/packages/sitecore-jss-proxy/src/index.ts +++ b/packages/sitecore-jss-proxy/src/index.ts @@ -1,2 +1,4 @@ export * from './middleware'; export * from './types'; +export * from './personalize'; +export { GRAPHQL_LAYOUT_QUERY_NAME } from '@sitecore-jss/sitecore-jss/layout'; diff --git a/packages/sitecore-jss-proxy/src/personalize/PersonalizeHelper.test.ts b/packages/sitecore-jss-proxy/src/personalize/PersonalizeHelper.test.ts new file mode 100644 index 0000000000..cd8b43a014 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/personalize/PersonalizeHelper.test.ts @@ -0,0 +1,653 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable dot-notation */ +import { GraphQLRequestClient } from '@sitecore-jss/sitecore-jss'; +import sinon, { spy } from 'sinon'; +import proxyquire from 'proxyquire'; +import { IncomingMessage, OutgoingMessage } from 'http'; +import { debug } from '@sitecore-jss/sitecore-jss'; +import { expect } from 'chai'; +import querystring from 'querystring'; +import { CdpHelper, personalizeLayout } from '@sitecore-jss/sitecore-jss/personalize'; +import { getPersonalizeLayoutData } from './test-data/personalizeData'; + +describe('PersonalizeHelper', () => { + const hostname = 'foo.net'; + const siteName = 'bar'; + + const debugSpy = spy(debug, 'personalize'); + const validateDebugLog = (message: string, ...params: any[]) => { + expect(debugSpy.args.find((log) => log[0] === message)).to.deep.equal( + [message, ...params], + 'Message not found in debug log: ' + [message, ...params].join(' ') + '\n' + ); + }; + const validateEndDebugLog = (message: string, ...params: any[]) => { + const logParams = debugSpy.args.find((log) => log[0] === message) as Array; + expect(logParams.slice(2)).to.deep.equal([...params], 'Matching end message not found'); + }; + + const defaultLayoutData = { + sitecore: { + context: { + itemPath: '/styleguide', + language: 'en', + }, + route: { + name: 'styleguide', + placeholders: {}, + }, + }, + }; + + const pageId = 'item-id'; + const variantIds = ['variant-1', 'variant-2']; + const createRequest = (props: any = {}) => { + const req = { + url: '/styleguide', + headers: { + host: hostname, + }, + ...props, + } as IncomingMessage; + + return req; + }; + + const createResponse = (props: any = {}) => { + const res = { + ...props, + } as OutgoingMessage; + + return res; + }; + + const createHelper = ( + props: { + [key: string]: unknown; + edgeConfig?: any; + cdpConfig?: any; + scope?: string; + variantId?: string; + personalizeInfo?: { + pageId: string; + variantIds: string[]; + } | null; + defaultLanguage?: string; + getPersonalizeInfoStub?: sinon.SinonStub; + personalizeStub?: sinon.SinonStub; + initPersonalizeServerStub?: sinon.SinonStub; + } = {} + ) => { + const cdpConfig = { + sitecoreEdgeContextId: '0000-0000-0000', + sitecoreEdgeUrl: 'https://foo.bar', + ...(props?.cdpConfig || {}), + }; + const clientFactory = GraphQLRequestClient.createClientFactory({ + apiKey: 'edge-api-key', + endpoint: 'http://edge-endpoint/api/graph/edge', + }); + const edgeConfig = { + clientFactory, + ...(props?.edgeConfig || {}), + }; + + const personalizeLayoutStub = sinon.stub(); + const getComponentFriendlyIdStub = sinon.stub(); + const getPageFriendlyIdStub = sinon.stub(); + const PersonalizeHelper = proxyquire('./PersonalizeHelper', { + '@sitecore-jss/sitecore-jss/personalize': { + // we need to track the method calls, without replacing it with a spy + personalizeLayout: personalizeLayoutStub.callsFake( + (layoutData, variantId, componentVariantIds) => { + return personalizeLayout(layoutData, variantId, componentVariantIds); + } + ), + }, + }); + + const helper = new PersonalizeHelper.PersonalizeHelper({ + ...props, + cdpConfig, + edgeConfig, + sitecoreSiteName: siteName, + }); + + const initPersonalizeServer = (helper['initPersonalizeServer'] = + props.initPersonalizeServerStub || sinon.stub()); + + const personalize = (helper['personalize'] = + props.personalizeStub || + sinon.stub().returns( + Promise.resolve({ + variantId: props.variantId, + }) + )); + + const getPersonalizeInfo = (helper['personalizeService']['getPersonalizeInfo'] = + props.getPersonalizeInfoStub || + sinon.stub().returns( + Promise.resolve( + props.personalizeInfo === null + ? undefined + : props.personalizeInfo || { + pageId, + variantIds, + } + ) + )); + return { + helper, + getPersonalizeInfo, + initPersonalizeServer, + personalize, + personalizeLayoutStub, + getComponentFriendlyIdStub, + getPageFriendlyIdStub, + }; + }; + + beforeEach(() => { + debugSpy.resetHistory(); + }); + + describe('personalizeLayoutData', () => { + describe('layout not personalized', () => { + it('disabled', async () => { + const req = createRequest(); + const res = createResponse(); + + const props = { + disabled: (req: IncomingMessage) => req.url === '/styleguide', + }; + + const { helper } = createHelper(props); + + const layoutData = defaultLayoutData; + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + + validateDebugLog('personalize layout start: %o', { + hostname: 'foo.net', + pathname: '/styleguide', + language: 'en', + headers: { + ...req.headers, + }, + }); + + validateDebugLog('skipped (personalize is disabled)'); + + expect(layoutData).to.deep.equal(personalizedLayout); + }); + + it('personalize info not found', async () => { + const req = createRequest(); + const res = createResponse(); + const { helper, getPersonalizeInfo } = createHelper({ + personalizeInfo: null, + }); + + const layoutData = defaultLayoutData; + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + const headers = { ...req.headers }; + + validateDebugLog('personalize layout start: %o', { + hostname: 'foo.net', + pathname: '/styleguide', + language: 'en', + headers, + }); + expect(getPersonalizeInfo.calledWith('/styleguide', 'en')).to.be.true; + validateDebugLog('skipped (personalize info not found)'); + expect(personalizedLayout).to.deep.equal(layoutData); + }); + + it('no personalization configured', async () => { + const req = createRequest(); + const res = createResponse(); + const { helper, getPersonalizeInfo } = createHelper({ + personalizeInfo: { + pageId, + variantIds: [], + }, + }); + const layoutData = defaultLayoutData; + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + const headers = { ...req.headers }; + + validateDebugLog('personalize layout start: %o', { + hostname: 'foo.net', + pathname: '/styleguide', + language: 'en', + headers, + }); + expect(getPersonalizeInfo.calledWith('/styleguide', 'en')).to.be.true; + validateDebugLog('skipped (no personalization configured)'); + expect(personalizedLayout).to.deep.equal(layoutData); + }); + + it('no variant identified', async () => { + const req = createRequest(); + const res = createResponse(); + const { helper, getPersonalizeInfo, initPersonalizeServer, personalize } = createHelper({ + variantId: undefined, + }); + const headers = { ...req.headers }; + + const layoutData = defaultLayoutData; + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + + await validateDebugLog('personalize layout start: %o', { + hostname: 'foo.net', + pathname: '/styleguide', + language: 'en', + headers, + }); + expect(getPersonalizeInfo.calledWith('/styleguide', 'en')).to.be.true; + expect(initPersonalizeServer.called).to.be.true; + expect(personalize.called).to.be.true; + + validateDebugLog('skipped (no variant(s) identified)'); + + expect(personalizedLayout).to.deep.equal(layoutData); + }); + + it('invalid variant', async () => { + const req = createRequest(); + const res = createResponse(); + const invalidVariant = 'invalid-variant'; + const { helper, getPersonalizeInfo, initPersonalizeServer, personalize } = createHelper({ + personalizeInfo: { + pageId, + variantIds, + }, + variantId: invalidVariant, + }); + + const layoutData = defaultLayoutData; + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + const headers = { ...req.headers }; + + validateDebugLog('personalize layout start: %o', { + hostname: 'foo.net', + pathname: '/styleguide', + language: 'en', + headers, + }); + + expect(getPersonalizeInfo.calledWith('/styleguide', 'en')).to.be.true; + expect(initPersonalizeServer.called).to.be.true; + expect(personalize.called).to.be.true; + validateDebugLog('invalid variant %s', invalidVariant); + expect(personalizedLayout).to.deep.equal(layoutData); + }); + + it('layout is empty', async () => { + const req = createRequest(); + const res = createResponse(); + const emptyLayoutData = { + ...defaultLayoutData, + sitecore: { + ...defaultLayoutData.sitecore, + route: null, + }, + }; + const { helper } = createHelper(); + const personalizedLayout = await helper.personalizeLayoutData(req, res, emptyLayoutData); + expect(personalizedLayout).to.deep.equal(emptyLayoutData); + validateDebugLog('skipped (layout is empty)'); + }); + + it('should exclude route', async () => { + const req = createRequest(); + const res = createResponse(); + + const props = { + excludeRoute: (pathname: string) => pathname === '/styleguide', + }; + + const { helper } = createHelper(props); + + const layoutData = defaultLayoutData; + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + + validateDebugLog('personalize layout start: %o', { + hostname: 'foo.net', + pathname: '/styleguide', + language: 'en', + headers: { + ...req.headers, + }, + }); + + validateDebugLog('skipped (route excluded)'); + + expect(personalizedLayout).to.deep.equal(defaultLayoutData); + }); + }); + describe('layout personalization is running', () => { + const variantIds = ['mountain-bike-audience', 'another-variant', 'third-variant']; + it('custom fallback hostname is used when request host header is empty', async () => { + const req = createRequest(); + delete req.headers.host; + const res = createResponse(); + + const props = { + defaultHostname: 'myhost', + }; + + const { helper, initPersonalizeServer } = createHelper(props); + + await helper.personalizeLayoutData(req, res, defaultLayoutData); + + expect(initPersonalizeServer.called).to.be.true; + expect(initPersonalizeServer.getCall(0).args[2]).to.equal(props.defaultHostname); + }); + + it('localhost is used as fallback hostname when request host header is empty and defaultHostname not provided', async () => { + const req = createRequest(); + delete req.headers.host; + const res = createResponse(); + + const { helper, initPersonalizeServer } = createHelper(); + + await helper.personalizeLayoutData(req, res, defaultLayoutData); + expect(initPersonalizeServer.called).to.be.true; + + expect(initPersonalizeServer.getCall(0).args[2]).to.equal('localhost'); + }); + + it('locale from context is used', async () => { + const req = createRequest(); + const res = createResponse(); + const customLang = 'da-DK'; + const { + helper, + initPersonalizeServer, + personalize, + getPersonalizeInfo, + personalizeLayoutStub, + } = createHelper({ + personalizeInfo: { pageId, variantIds }, + variantId: 'mountain-bike-audience', + }); + + const layoutData = getPersonalizeLayoutData('default', customLang); + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + + validateDebugLog('personalize layout start: %o', { + headers: { + ...req.headers, + }, + hostname: hostname, + pathname: '/styleguide', + language: customLang, + }); + expect(initPersonalizeServer.calledOnce).to.be.true; + expect(getPersonalizeInfo.calledWith('/styleguide', customLang)).to.be.true; + expect(personalize.called).to.be.true; + expect(personalizeLayoutStub.called).to.be.true; + const expectedVariantIds = ['mountain-bike-audience']; + validateEndDebugLog('personalize layout end in %dms: %o', { + headers: req.headers, + variantIds: expectedVariantIds, + }); + const expectedLayout = getPersonalizeLayoutData('mountain-bike-audience', customLang); + expect(personalizedLayout).to.deep.equal(expectedLayout); + }); + + it('locale from config is used as first fallback when layoutData language is missing', async () => { + const req = createRequest(); + const res = createResponse(); + const configLang = 'es-ES'; + const { + helper, + initPersonalizeServer, + personalize, + getPersonalizeInfo, + personalizeLayoutStub, + } = createHelper({ + personalizeInfo: { pageId, variantIds }, + variantId: 'mountain-bike-audience', + defaultLanguage: configLang, + }); + + const layoutData = getPersonalizeLayoutData('default'); + layoutData.sitecore.context.language = ''; + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + + validateDebugLog('personalize layout start: %o', { + headers: { + ...req.headers, + }, + hostname: hostname, + pathname: '/styleguide', + language: configLang, + }); + expect(initPersonalizeServer.calledOnce).to.be.true; + expect(getPersonalizeInfo.calledWith('/styleguide', configLang)).to.be.true; + expect(personalize.called).to.be.true; + expect(personalizeLayoutStub.called).to.be.true; + const expectedVariantIds = ['mountain-bike-audience']; + validateEndDebugLog('personalize layout end in %dms: %o', { + headers: req.headers, + variantIds: expectedVariantIds, + }); + const expectedLayout = getPersonalizeLayoutData('mountain-bike-audience'); + // variantID is populated in layout, but language will not + expectedLayout.sitecore.context.language = ''; + expect(personalizedLayout).to.deep.equal(expectedLayout); + }); + + it('en locale is used if default fallback is absent', async () => { + const req = createRequest(); + const res = createResponse(); + const { + helper, + initPersonalizeServer, + personalize, + getPersonalizeInfo, + personalizeLayoutStub, + } = createHelper({ + personalizeInfo: { pageId, variantIds }, + variantId: 'mountain-bike-audience', + }); + + const layoutData = getPersonalizeLayoutData('default'); + layoutData.sitecore.context.language = ''; + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + + validateDebugLog('personalize layout start: %o', { + headers: { + ...req.headers, + }, + hostname: hostname, + pathname: '/styleguide', + language: 'en', + }); + expect(initPersonalizeServer.calledOnce).to.be.true; + expect(getPersonalizeInfo.calledWith('/styleguide', 'en')).to.be.true; + expect(personalize.called).to.be.true; + expect(personalizeLayoutStub.called).to.be.true; + const expectedVariantIds = ['mountain-bike-audience']; + validateEndDebugLog('personalize layout end in %dms: %o', { + headers: req.headers, + variantIds: expectedVariantIds, + }); + const expectedLayout = getPersonalizeLayoutData('mountain-bike-audience'); + expectedLayout.sitecore.context.language = ''; + expect(personalizedLayout).to.deep.equal(expectedLayout); + }); + + it('configured scope is used', async () => { + const req = createRequest(); + const res = createResponse(); + const scope = 'myscope'; + const { helper, personalize } = createHelper({ + personalizeInfo: { pageId, variantIds: ['mountain-bike-audience'] }, + variantId: 'mountain-bike-audience', + scope, + }); + + const layoutData = getPersonalizeLayoutData('default'); + + await helper.personalizeLayoutData(req, res, layoutData); + expect( + personalize.calledWith( + sinon.match({ friendlyId: CdpHelper.getPageFriendlyId(pageId, 'en', scope) }), + sinon.match.any + ) + ).to.be.true; + }); + + it('component testing is executed', async () => { + const req = createRequest(); + const res = createResponse(); + const { helper, personalize } = createHelper({ + personalizeInfo: { pageId, variantIds: ['componentid_variant-id'] }, + variantId: 'componentid_variant-id', + }); + + const layoutData = getPersonalizeLayoutData('default'); + + const personalizedLayout = await helper.personalizeLayoutData(req, res, layoutData); + + expect( + personalize.calledWith( + sinon.match({ + friendlyId: CdpHelper.getComponentFriendlyId(pageId, 'componentid', 'en'), + }), + sinon.match.any + ) + ).to.be.true; + + const expectedLayout = getPersonalizeLayoutData('componentid_variant-id'); + expect(personalizedLayout).to.deep.equal(expectedLayout); + }); + }); + + describe('error handling', () => { + it('CloudSDK initialization throws', async () => { + const error = new Error('init failed'); + const throwInitPersonalizeServer = sinon.stub().throws(error); + const { helper } = createHelper({ + initPersonalizeServerStub: throwInitPersonalizeServer, + }); + const req = createRequest(); + const res = createResponse(); + await helper.personalizeLayoutData(req, res, defaultLayoutData); + validateDebugLog('skipped (CloudSDK initialization failed), error %o', error); + }); + + it('CloudSDK personalize throws', async () => { + const error = new Error('personalize failed'); + const throwPersonalize = sinon.stub().throws(error); + const { helper } = createHelper({ + personalizeStub: throwPersonalize, + }); + const req = createRequest(); + const res = createResponse(); + await helper.personalizeLayoutData(req, res, defaultLayoutData); + validateDebugLog('skipped, error %o', error); + }); + }); + }); + + describe('getLanguage', () => { + it('should read language from layoutData context', () => { + const layoutData = { + sitecore: { + context: { + language: 'da-DK', + }, + route: null, + }, + }; + const { helper } = createHelper(); + expect(helper['getLanguage'](layoutData)).to.equal('da-DK'); + }); + + it('should return config.defaultLanguage as first fallback', () => { + const layoutData = { + sitecore: { + context: {}, + route: null, + }, + }; + const { helper } = createHelper({ + defaultLanguage: 'es-ES', + }); + expect(helper['getLanguage'](layoutData)).to.equal('es-ES'); + }); + + it('should return "en" as fallback when config value is absent', () => { + const layoutData = { + sitecore: { + context: {}, + route: null, + }, + }; + const { helper } = createHelper(); + expect(helper['getLanguage'](layoutData)).to.equal('en'); + }); + }); + + describe('getHostHeaders', () => { + it('should read host header from request', () => { + const req = createRequest(); + const { helper } = createHelper(); + expect(helper['getHostHeader'](req)).to.equal(hostname); + }); + }); + + describe('getExperienceParams', () => { + it('should correctly parse utm input values', () => { + const utmTest = { + utm_campaign: 'campaing', + utm_content: 'content', + utm_medium: 'medium', + utm_source: 'source', + }; + const qs = querystring.stringify(utmTest); + const req = createRequest({ url: `/styleguide?${qs}` }); + const { helper } = createHelper(); + const result = helper['getExperienceParams'](req); + expect(result.utm).to.deep.equal({ + campaign: utmTest.utm_campaign, + content: utmTest.utm_content, + medium: utmTest.utm_medium, + source: utmTest.utm_source, + }); + }); + + it('should correctly parse referer header', () => { + const req = createRequest({ + headers: { + referer: 'withoner', + }, + }); + const { helper } = createHelper(); + const result = helper['getExperienceParams'](req); + expect(result.referrer).to.equal('withoner'); + }); + + it('should correctly parse referrer header', () => { + const req = createRequest({ + headers: { + referrer: ['http://', 'withtwors'], + }, + }); + const { helper } = createHelper(); + const result = helper['getExperienceParams'](req); + expect(result.referrer).to.equal('http://withtwors'); + }); + }); +}); diff --git a/packages/sitecore-jss-proxy/src/personalize/PersonalizeHelper.ts b/packages/sitecore-jss-proxy/src/personalize/PersonalizeHelper.ts new file mode 100644 index 0000000000..02799a2bd2 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/personalize/PersonalizeHelper.ts @@ -0,0 +1,303 @@ +import { CloudSDK } from '@sitecore-cloudsdk/core/server'; +import { personalize } from '@sitecore-cloudsdk/personalize/server'; +import { LayoutServiceData } from '@sitecore-jss/sitecore-jss/layout'; +import { debug } from '@sitecore-jss/sitecore-jss'; +import { + CdpHelper, + DEFAULT_VARIANT, + GraphQLPersonalizeService, + PersonalizeInfo, + getGroomedVariantIds, + personalizeLayout, +} from '@sitecore-jss/sitecore-jss/personalize'; +import { IncomingHttpHeaders, IncomingMessage, OutgoingMessage } from 'http'; +import { ExperienceParams, PersonalizeConfig, PersonalizeExecution } from '../types/personalize'; +import querystring from 'querystring'; + +export class PersonalizeHelper { + private personalizeService: GraphQLPersonalizeService; + private defaultHostname: string; + + constructor(protected config: PersonalizeConfig) { + this.personalizeService = new GraphQLPersonalizeService({ + ...config.edgeConfig, + }); + this.defaultHostname = config.defaultHostname || 'localhost'; + } + + /** + * Performs personalize on layout data before a page is rendered + * @param {IncomingMessage} req Incoming request nodejs object + * @param {OutgoingMessage} res Outgoing response nodejs object + * @param {LayoutServiceData} layoutData layoutData for the page + * @returns layout data with personalization applied + */ + personalizeLayoutData = async ( + req: IncomingMessage, + res: OutgoingMessage, + layoutData: LayoutServiceData + ) => { + if (!layoutData.sitecore?.context) { + debug.personalize('skipped (sitecore context is empty)'); + return layoutData; + } + if (!layoutData.sitecore?.route) { + debug.personalize('skipped (layout is empty)'); + return layoutData; + } + // current method can run for page requests and for layout service requests. + // the latter will not have the correct path - so we use path from layoutData instead + const pathname = layoutData.sitecore.context.itemPath; + const language = this.getLanguage(layoutData); + const hostname = this.getHostHeader(req) || this.defaultHostname; + const startTimestamp = Date.now(); + if (!pathname) { + debug.personalize('skipped (pathname missing from layoutData)'); + return layoutData; + } + + debug.personalize('personalize layout start: %o', { + pathname, + language, + hostname, + headers: this.extractDebugHeaders(req.headers), + }); + + if (this.excludeRoute(pathname)) { + debug.personalize('skipped (route excluded)'); + return layoutData; + } + if (this.config.disabled && this.config.disabled(req, res)) { + debug.personalize('skipped (personalize is disabled)'); + return layoutData; + } + try { + await this.initPersonalizeServer(req, res, hostname); + } catch (e) { + debug.personalize('skipped (CloudSDK initialization failed), error %o', e); + return layoutData; + } + + const variantIds = await this.getVariantIds(req, language, pathname); + if (!variantIds) { + return layoutData; + } + const personalizeData = getGroomedVariantIds(variantIds); + // layout will be personalized here + personalizeLayout(layoutData, personalizeData.variantId, personalizeData.componentVariantIds); + debug.personalize('personalize layout end in %dms: %o', Date.now() - startTimestamp, { + headers: this.extractDebugHeaders(req.headers), + variantIds: variantIds, + }); + return layoutData; + }; + + /** + * Init CloudSDK personalization on server side + * @param {IncomingMessage} request incoming nodejs request object + * @param {OutgoingMessage} response outgoing nodejs response object + * @param {string} hostname host for cookies. Usually a host header, or a fallback config + */ + protected async initPersonalizeServer( + request: IncomingMessage, + response: OutgoingMessage, + hostname: string + ): Promise { + await CloudSDK(request, response, { + sitecoreEdgeUrl: this.config.cdpConfig.sitecoreEdgeUrl, + sitecoreEdgeContextId: this.config.cdpConfig.sitecoreEdgeContextId, + siteName: this.config.sitecoreSiteName, + cookieDomain: hostname, + enableServerCookie: true, + }) + .addPersonalize({ enablePersonalizeCookie: true }) + .initialize(); + } + + protected getVariantIds = async ( + req: IncomingMessage, + language: string, + pathname: string + ): Promise => { + const timeout = this.config.cdpConfig.timeout; + + // Get personalization info from Experience Edge + const personalizeInfo = await this.personalizeService.getPersonalizeInfo( + pathname, + language, + this.config.sitecoreSiteName + ); + if (!personalizeInfo) { + // Likely an invalid route / language + debug.personalize('skipped (personalize info not found)'); + return []; + } + + if (personalizeInfo.variantIds.length === 0) { + debug.personalize('skipped (no personalization configured)'); + return []; + } + + const params = this.getExperienceParams(req); + const executions = this.getPersonalizeExecutions(personalizeInfo, language); + const identifiedVariantIds: string[] = []; + try { + await Promise.all( + executions.map((execution) => + this.personalize( + { + friendlyId: execution.friendlyId, + variantIds: execution.variantIds, + params, + language, + timeout, + }, + req + ).then((personalization) => { + const variantId = personalization.variantId; + if (variantId) { + if (!execution.variantIds.includes(variantId)) { + debug.personalize('invalid variant %s', variantId); + } else { + identifiedVariantIds.push(variantId); + } + } + }) + ) + ); + } catch (e) { + debug.personalize('skipped, error %o', e); + } + + if (identifiedVariantIds.length === 0) { + debug.personalize('skipped (no variant(s) identified)'); + return []; + } + return identifiedVariantIds; + }; + + protected getLanguage(layoutData: LayoutServiceData): string { + return layoutData.sitecore?.context?.language || this.config.defaultLanguage || 'en'; + } + + protected getHostHeader(req: IncomingMessage): string { + return req.headers.host?.split(':')[0] || ''; + } + + protected excludeRoute(pathname: string) { + return this.config?.excludeRoute && this.config?.excludeRoute(pathname); + } + + protected extractDebugHeaders(incomingHeaders: IncomingHttpHeaders) { + const headers = {} as { [key: string]: string | string[] | undefined }; + Object.keys(incomingHeaders).forEach( + (key) => incomingHeaders[key] && (headers[key] = incomingHeaders[key]) + ); + return headers; + } + + protected async personalize( + { + params, + friendlyId, + language, + timeout, + variantIds, + }: { + params: ExperienceParams; + friendlyId: string; + language: string; + timeout?: number; + variantIds?: string[]; + }, + request: IncomingMessage + ) { + debug.personalize('executing experience for %s %o', friendlyId, params); + + return (await personalize( + request, + { + channel: this.config.cdpConfig.channel || 'WEB', + currency: this.config.cdpConfig.currency ?? 'USD', + friendlyId, + params, + language, + pageVariantIds: variantIds, + }, + { timeout } + )) as { + variantId: string; + }; + } + + protected getExperienceParams(req: IncomingMessage): ExperienceParams { + // nodejs req.url does not have a hostname, we parse query string the old fashioned way + const rawQs = req.url?.split('?')[1] || ''; + const queryString = querystring.parse(rawQs); + // also need to account for types (string | string[]) returned by parse() + const utm = { + campaign: [queryString.utm_campaign].join('') || undefined, + content: [queryString.utm_content].join('') || undefined, + medium: [queryString.utm_medium].join('') || undefined, + source: [queryString.utm_source].join('') || undefined, + }; + + return { + // It's expected that the header name "referer" is actually a misspelling of the word "referrer" + // req.referrer is used during fetching to determine the value of the Referer header of the request being made, + // used as a fallback + referrer: req.headers.referer || [''].concat(req.headers.referrer || '').join(''), + utm: utm, + }; + } + + protected getPersonalizeExecutions( + personalizeInfo: PersonalizeInfo, + language: string + ): PersonalizeExecution[] { + if (personalizeInfo.variantIds.length === 0) { + return []; + } + const results: PersonalizeExecution[] = []; + return personalizeInfo.variantIds.reduce((results, variantId) => { + if (variantId.includes('_')) { + // Component-level personalization in format "_" + const componentId = variantId.split('_')[0]; + const friendlyId = CdpHelper.getComponentFriendlyId( + personalizeInfo.pageId, + componentId, + language, + this.config.scope + ); + const execution = results.find((x) => x.friendlyId === friendlyId); + if (execution) { + execution.variantIds.push(variantId); + } else { + // The default/control variant (format "_default") is also a valid value returned by the execution + const defaultVariant = `${componentId}${DEFAULT_VARIANT}`; + results.push({ + friendlyId, + variantIds: [defaultVariant, variantId], + }); + } + } else { + // Embedded (page-level) personalization in format "" + const friendlyId = CdpHelper.getPageFriendlyId( + personalizeInfo.pageId, + language, + this.config.scope + ); + const execution = results.find((x) => x.friendlyId === friendlyId); + if (execution) { + execution.variantIds.push(variantId); + } else { + results.push({ + friendlyId, + variantIds: [variantId], + }); + } + } + return results; + }, results); + } +} diff --git a/packages/sitecore-jss-proxy/src/personalize/index.ts b/packages/sitecore-jss-proxy/src/personalize/index.ts new file mode 100644 index 0000000000..a86ea726f5 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/personalize/index.ts @@ -0,0 +1,2 @@ +export { PersonalizeHelper } from './PersonalizeHelper'; +export { PersonalizeConfig } from '../types/personalize'; diff --git a/packages/sitecore-jss-proxy/src/personalize/test-data/personalizeData.ts b/packages/sitecore-jss-proxy/src/personalize/test-data/personalizeData.ts new file mode 100644 index 0000000000..328b8a12f7 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/personalize/test-data/personalizeData.ts @@ -0,0 +1,89 @@ +export const mountainBikeVariant = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: '20679cd4-356b-4452-b507-453beeb0be39', + fields: { + content: { + value: + '

', + }, + heading: { value: 'Mountain Bike' }, + }, +}; + +export const cityBikeVariant = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: '36e02581-2056-4c55-a4d5-f4b700ba1ae2', + fields: { + content: { + value: + '

', + }, + heading: { value: 'Mountain Bike' }, + }, +}; + +export const component_variant = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: '36e02581-2056-4c55-a4d5-f4b700ba1ae2', + fields: { + content: { + value: + '

', + }, + heading: { value: 'Hybrid Bike' }, + }, +}; + +const defaultRendering = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: 'e020fb58-1be8-4537-aab8-67916452ecf2', + fields: { content: { value: '' }, heading: { value: 'Default Content' } }, + experiences: { + 'mountain-bike-audience': mountainBikeVariant, + 'city-bike-audience': cityBikeVariant, + 'componentid_variant-id': component_variant, + }, +}; + +export const getPersonalizeLayoutData = (variant: string, language?: string, path?: string) => { + const baseLayout = { + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + visitorIdentificationTimestamp: 1038543, + language: language || 'en', + variantId: '', + itemPath: path || '/styleguide', + }, + route: { + name: 'styleguide', + placeholders: { + main: {}, + }, + }, + }, + }; + switch (variant) { + case 'mountain-bike-audience': + baseLayout.sitecore.route.placeholders.main = [mountainBikeVariant]; + baseLayout.sitecore.context.variantId = 'mountain-bike-audience'; + break; + case 'city-bike-audience': + baseLayout.sitecore.route.placeholders.main = [cityBikeVariant]; + baseLayout.sitecore.context.variantId = 'city-bike-audience'; + break; + case 'componentid_variant-id': + baseLayout.sitecore.route.placeholders.main = [component_variant]; + baseLayout.sitecore.context.variantId = '_default'; + break; + default: + baseLayout.sitecore.route.placeholders.main = [defaultRendering]; + break; + } + return baseLayout; +}; diff --git a/packages/sitecore-jss-proxy/src/types/index.ts b/packages/sitecore-jss-proxy/src/types/index.ts index 3732d5f63d..4b7c0427c6 100644 --- a/packages/sitecore-jss-proxy/src/types/index.ts +++ b/packages/sitecore-jss-proxy/src/types/index.ts @@ -1,2 +1,3 @@ export { AppRenderer, RenderResponse } from './AppRenderer'; export { RouteUrlParser } from './RouteUrlParser'; +export { PersonalizeConfig } from './personalize'; diff --git a/packages/sitecore-jss-proxy/src/types/personalize.ts b/packages/sitecore-jss-proxy/src/types/personalize.ts new file mode 100644 index 0000000000..d2229b9e9f --- /dev/null +++ b/packages/sitecore-jss-proxy/src/types/personalize.ts @@ -0,0 +1,89 @@ +import { GraphQLPersonalizeServiceConfig } from '@sitecore-jss/sitecore-jss/personalize'; +import { IncomingMessage, OutgoingMessage } from 'http'; + +export type CdpServiceConfig = { + /** + * Your Sitecore Edge Platform endpoint + * Default is https://edge-platform.sitecorecloud.io + */ + sitecoreEdgeUrl?: string; + /** + * Your unified Sitecore Edge Context Id + */ + sitecoreEdgeContextId: string; + /** + * The Sitecore CDP channel to use for events. Uses 'WEB' by default. + */ + channel?: string; + /** + * Currency for CDP request. Uses 'USA' as default. + */ + currency?: string; + /** + * Timeout (ms) for CDP request. Default is 400. + */ + timeout?: number; +}; + +export type PersonalizeConfig = { + /** + * function, determines if personalization should be turned off, based on cookie, header, or other considerations + * @param {IncomingMessage} [req] request object + * @param {OutgoingMessage} [res] response object + */ + disabled?: (req?: IncomingMessage, res?: OutgoingMessage) => boolean; + /** + * Function used to determine if route should be excluded. + * @param {string} pathname The pathname + * @returns {boolean} Whether to exclude the route + */ + excludeRoute?: (pathname: string) => boolean; + /** + * Fallback hostname in case `host` header is not present + * @default localhost + */ + defaultHostname?: string; + /** + * Fallback language in case language can't be read from layout data + * @default 'en' + */ + defaultLanguage?: string; + /** + * Site name for current site + */ + sitecoreSiteName: string; + /** + * Configuration for your Sitecore Experience Edge endpoint + */ + edgeConfig: Omit; + /** + * Configuration for your Sitecore CDP endpoint + */ + cdpConfig: CdpServiceConfig; + /** + * Optional Sitecore Personalize scope identifier allowing you to isolate your personalization data between XM Cloud environments + */ + scope?: string; +}; + +/** + * Object model of Experience Context data + */ +export type ExperienceParams = { + referrer: string; + utm: { + [key: string]: string | undefined; + campaign: string | undefined; + source: string | undefined; + medium: string | undefined; + content: string | undefined; + }; +}; + +/** + * Object model of personalize execution data + */ +export type PersonalizeExecution = { + friendlyId: string; + variantIds: string[]; +}; diff --git a/packages/sitecore-jss-proxy/tsconfig.json b/packages/sitecore-jss-proxy/tsconfig.json index afdc9e20de..0e0088183b 100644 --- a/packages/sitecore-jss-proxy/tsconfig.json +++ b/packages/sitecore-jss-proxy/tsconfig.json @@ -5,8 +5,9 @@ "module": "commonjs", "outDir": "dist/cjs", "strictBindCallApply": false, + "skipLibCheck": true, "typeRoots": ["node_modules/@types"], "declarationDir": "./types" }, - "exclude": ["node_modules", "types", "dist", "src/**/*.test.ts", "src/test-data/*.ts"] + "exclude": ["node_modules", "types", "dist", "src/**/*.test.ts", "src/test-data/*.ts", "personalize.d.ts"] } diff --git a/packages/sitecore-jss/src/layout/graphql-layout-service.test.ts b/packages/sitecore-jss/src/layout/graphql-layout-service.test.ts index 6526256027..7f44f25d19 100644 --- a/packages/sitecore-jss/src/layout/graphql-layout-service.test.ts +++ b/packages/sitecore-jss/src/layout/graphql-layout-service.test.ts @@ -3,7 +3,7 @@ import { expect, use } from 'chai'; import sinon, { SinonSpy } from 'sinon'; import spies from 'chai-spies'; import nock from 'nock'; -import { GraphQLLayoutService } from './graphql-layout-service'; +import { GRAPHQL_LAYOUT_QUERY_NAME, GraphQLLayoutService } from './graphql-layout-service'; import { GraphQLRequestClient, GraphQLRequestClientFactory } from '../graphql-request-client'; use(spies); @@ -30,7 +30,7 @@ describe('GraphQLLayoutService', () => { .post('/graphql', (body) => { return ( body.query.replace(/\n|\s/g, '') === - 'query{layout(site:"supersite",routePath:"/styleguide",language:"da-DK"){item{rendered}}}' + `query${GRAPHQL_LAYOUT_QUERY_NAME}{layout(site:"supersite",routePath:"/styleguide",language:"da-DK"){item{rendered}}}` ); }) .reply(200, { @@ -89,7 +89,7 @@ describe('GraphQLLayoutService', () => { .post('/graphql', (body) => { return ( body.query.replace(/\n|\s/g, '') === - 'query{layout(site:"supersite",routePath:"/styleguide"){item{rendered}}}' + `query${GRAPHQL_LAYOUT_QUERY_NAME}{layout(site:"supersite",routePath:"/styleguide"){item{rendered}}}` ); }) .reply(200, { @@ -143,7 +143,7 @@ describe('GraphQLLayoutService', () => { .post('/graphql', (body) => { return ( body.query.replace(/\n|\s/g, '') === - 'query{layout111(site:"supersite",route:"/styleguide",language:"en"){item{rendered}}}' + `query${GRAPHQL_LAYOUT_QUERY_NAME}{layout111(site:"supersite",route:"/styleguide",language:"en"){item{rendered}}}` ); }) .reply(200, { @@ -195,7 +195,7 @@ describe('GraphQLLayoutService', () => { .post('/graphql', (body) => { return ( body.query.replace(/\n|\s/g, '') === - 'query{layout(site:"supersite",routePath:"/styleguide",language:"da-DK"){item{rendered}}}' + `query${GRAPHQL_LAYOUT_QUERY_NAME}{layout(site:"supersite",routePath:"/styleguide",language:"da-DK"){item{rendered}}}` ); }) .reply(200, { diff --git a/packages/sitecore-jss/src/layout/graphql-layout-service.ts b/packages/sitecore-jss/src/layout/graphql-layout-service.ts index 255834dfb0..1a3901148f 100644 --- a/packages/sitecore-jss/src/layout/graphql-layout-service.ts +++ b/packages/sitecore-jss/src/layout/graphql-layout-service.ts @@ -7,6 +7,8 @@ import { } from '../graphql-request-client'; import debug from '../debug'; +export const GRAPHQL_LAYOUT_QUERY_NAME = 'JssLayoutQuery'; + export interface GraphQLLayoutServiceConfig extends Pick { /** @@ -107,7 +109,7 @@ export class GraphQLLayoutService extends LayoutServiceBase { ? this.serviceConfig.formatLayoutQuery(this.serviceConfig.siteName, itemPath, language) : `layout(site:"${this.serviceConfig.siteName}", routePath:"${itemPath}"${languageVariable})`; - return `query { + return `query ${GRAPHQL_LAYOUT_QUERY_NAME} { ${layoutQuery}{ item { rendered diff --git a/packages/sitecore-jss/src/layout/index.ts b/packages/sitecore-jss/src/layout/index.ts index 9b4eb5cdad..c37bed05b2 100644 --- a/packages/sitecore-jss/src/layout/index.ts +++ b/packages/sitecore-jss/src/layout/index.ts @@ -37,6 +37,10 @@ export { DataFetcherResolver, } from './rest-layout-service'; -export { GraphQLLayoutService, GraphQLLayoutServiceConfig } from './graphql-layout-service'; +export { + GraphQLLayoutService, + GraphQLLayoutServiceConfig, + GRAPHQL_LAYOUT_QUERY_NAME, +} from './graphql-layout-service'; export { getComponentLibraryStylesheetLinks } from './themes'; diff --git a/packages/sitecore-jss/src/layout/models.ts b/packages/sitecore-jss/src/layout/models.ts index 89c882dd57..0895c90cc5 100644 --- a/packages/sitecore-jss/src/layout/models.ts +++ b/packages/sitecore-jss/src/layout/models.ts @@ -33,6 +33,7 @@ export interface LayoutServiceContext { [key: string]: unknown; pageEditing?: boolean; language?: string; + itemPath?: string; pageState?: LayoutServicePageState; visitorIdentificationTimestamp?: number; site?: { diff --git a/packages/sitecore-jss/src/personalize/layout-personalizer.test.ts b/packages/sitecore-jss/src/personalize/layout-personalizer.test.ts index f6b0e7b070..6ca4878579 100644 --- a/packages/sitecore-jss/src/personalize/layout-personalizer.test.ts +++ b/packages/sitecore-jss/src/personalize/layout-personalizer.test.ts @@ -36,7 +36,7 @@ describe('layout-personalizer', () => { const variant = 'test'; const testLayoutData = structuredClone(layoutDataWithoutPlaceholder); const personalizedLayoutResult = personalizeLayout(testLayoutData, variant); - expect(personalizedLayoutResult).to.equal(undefined); + expect(personalizedLayoutResult).to.deep.equal(undefined); }); it('should set variantId on Sitecore context', () => { diff --git a/packages/sitecore-jss/src/personalize/layout-personalizer.ts b/packages/sitecore-jss/src/personalize/layout-personalizer.ts index 926f61820f..25d04d58ad 100644 --- a/packages/sitecore-jss/src/personalize/layout-personalizer.ts +++ b/packages/sitecore-jss/src/personalize/layout-personalizer.ts @@ -32,9 +32,9 @@ export function personalizeLayout( ): PlaceholdersData | undefined { // Add (page-level) variantId to Sitecore context so that it is accessible here layout.sitecore.context.variantId = variantId; - const placeholders = layout.sitecore.route?.placeholders; - if (Object.keys(placeholders ?? {}).length === 0) { - return; + const placeholders = layout.sitecore.route?.placeholders || {}; + if (Object.keys(placeholders).length === 0) { + return undefined; } const metadataEditing = layout.sitecore.context.pageEditing && layout.sitecore.context.editMode === EditMode.Metadata; diff --git a/yarn.lock b/yarn.lock index 0dd3843f02..d20f0d046e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6279,11 +6279,14 @@ __metadata: version: 0.0.0-use.local resolution: "@sitecore-jss/sitecore-jss-proxy@workspace:packages/sitecore-jss-proxy" dependencies: + "@sitecore-cloudsdk/core": ^0.4.0 + "@sitecore-cloudsdk/personalize": ^0.4.0 "@sitecore-jss/sitecore-jss": 22.3.0-canary.3 "@types/chai": ^4.3.4 "@types/express": ^4.17.17 "@types/mocha": ^10.0.1 "@types/node": ^20.14.2 + "@types/proxyquire": ^1.3.31 "@types/set-cookie-parser": ^2.4.2 "@types/sinon": ^17.0.3 "@types/supertest": ^6.0.2 @@ -6295,6 +6298,7 @@ __metadata: http-status-codes: ^2.2.0 mocha: ^10.2.0 nyc: ^15.1.0 + proxyquire: ^2.1.3 set-cookie-parser: ^2.5.1 sinon: ^17.0.1 supertest: ^7.0.0 @@ -7247,6 +7251,13 @@ __metadata: languageName: node linkType: hard +"@types/proxyquire@npm:^1.3.31": + version: 1.3.31 + resolution: "@types/proxyquire@npm:1.3.31" + checksum: 945024495fc991f6152686795ac6f2f2d0f571834e67fa41c1e84877eeb1a321a24ab8ff67fc5152d9227f5b39f6254213dced15deaf80ed7443059c2257e072 + languageName: node + linkType: hard + "@types/qs@npm:*": version: 6.9.10 resolution: "@types/qs@npm:6.9.10" @@ -14726,6 +14737,16 @@ __metadata: languageName: node linkType: hard +"fill-keys@npm:^1.0.2": + version: 1.0.2 + resolution: "fill-keys@npm:1.0.2" + dependencies: + is-object: ~1.0.1 + merge-descriptors: ~1.0.0 + checksum: 6ac5ff60ff08f2f44d19e919c9ca579f4efaaa8c88232b4aab5a5b5522aeb8ec91501956e780cb2b44574fe4a4a337e9b43187829267d0b66a6bfedbafae893f + languageName: node + linkType: hard + "fill-range@npm:^4.0.0": version: 4.0.0 resolution: "fill-range@npm:4.0.0" @@ -16837,6 +16858,13 @@ __metadata: languageName: node linkType: hard +"is-object@npm:~1.0.1": + version: 1.0.2 + resolution: "is-object@npm:1.0.2" + checksum: 971219c4b1985b9751f65e4c8296d3104f0457b0e8a70849e848a4a2208bc47317d73b3b85d4a369619cb2df8284dc22584cb2695a7d99aca5e8d0aa64fc075a + languageName: node + linkType: hard + "is-path-cwd@npm:^2.2.0": version: 2.2.0 resolution: "is-path-cwd@npm:2.2.0" @@ -19733,6 +19761,13 @@ __metadata: languageName: node linkType: hard +"merge-descriptors@npm:~1.0.0": + version: 1.0.3 + resolution: "merge-descriptors@npm:1.0.3" + checksum: 52117adbe0313d5defa771c9993fe081e2d2df9b840597e966aadafde04ae8d0e3da46bac7ca4efc37d4d2b839436582659cd49c6a43eacb3fe3050896a105d1 + languageName: node + linkType: hard + "merge-stream@npm:^1.0.1": version: 1.0.1 resolution: "merge-stream@npm:1.0.1" @@ -20474,6 +20509,13 @@ __metadata: languageName: node linkType: hard +"module-not-found-error@npm:^1.0.1": + version: 1.0.1 + resolution: "module-not-found-error@npm:1.0.1" + checksum: ebd65339d4d5980dd55cd32dbf112ec02b8e33f30866312b94caeee4783322259f18cf2270e9d2e600df3bd1876c35612b87f5c2525c21885fb1f83e85a9b9b0 + languageName: node + linkType: hard + "moo@npm:^0.5.0": version: 0.5.2 resolution: "moo@npm:0.5.2" @@ -22841,6 +22883,17 @@ __metadata: languageName: node linkType: hard +"proxyquire@npm:^2.1.3": + version: 2.1.3 + resolution: "proxyquire@npm:2.1.3" + dependencies: + fill-keys: ^1.0.2 + module-not-found-error: ^1.0.1 + resolve: ^1.11.1 + checksum: a320f1a04d65aeb41625bfd6bbf848492523b730b07926b6c1ed48f9342f2a30c4a4c0b399e07391e76691b65f604773327767c33a8578e5e4ab19299ba46a02 + languageName: node + linkType: hard + "prr@npm:~1.0.1": version: 1.0.1 resolution: "prr@npm:1.0.1" @@ -23816,7 +23869,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:1.22.8, resolve@npm:^1.10.0, resolve@npm:^1.14.2, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.5.0": +"resolve@npm:1.22.8, resolve@npm:^1.10.0, resolve@npm:^1.11.1, resolve@npm:^1.14.2, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.5.0": version: 1.22.8 resolution: "resolve@npm:1.22.8" dependencies: @@ -23849,7 +23902,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@1.22.8#~builtin, resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin, resolve@patch:resolve@^1.5.0#~builtin": +"resolve@patch:resolve@1.22.8#~builtin, resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.11.1#~builtin, resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin, resolve@patch:resolve@^1.5.0#~builtin": version: 1.22.8 resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=07638b" dependencies: