diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/.env b/packages/create-sitecore-jss/src/templates/nextjs-personalize/.env
index ce5a3956cb..6ebaff0a5a 100644
--- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/.env
+++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/.env
@@ -1,5 +1,14 @@
-BOXEVER_CLIENT_KEY=
-BOXEVER_API=
-BOXEVER_TARGET_URL=
-BOXEVER_SCRIPT_URL=
-CDP_POINTOFSALE=
+# Your Sitecore CDP REST API base URL
+NEXT_PUBLIC_CDP_API_URL=https://api.boxever.com
+
+# Your Sitecore CDP client key
+NEXT_PUBLIC_CDP_CLIENT_KEY=
+
+# Your Sitecore CDP target URL
+NEXT_PUBLIC_CDP_TARGET_URL=https://api.boxever.com/v1.2
+
+# Your Sitecore CDP JavaScript library URL
+NEXT_PUBLIC_CDP_SCRIPT_URL=https://d1mj578wat5n4o.cloudfront.net/boxever-1.4.8.min.js
+
+# Sitecore CDP point of sale
+NEXT_PUBLIC_CDP_POINTOFSALE=
diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/components/CdpIntegrationScript.tsx b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/components/CdpIntegrationScript.tsx
index 6ecc6b5e18..c533b0f072 100644
--- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/components/CdpIntegrationScript.tsx
+++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/components/CdpIntegrationScript.tsx
@@ -1,6 +1,7 @@
import { useSitecoreContext } from '@sitecore-jss/sitecore-jss-nextjs';
import Script from 'next/script';
import { useEffect } from 'react';
+import config from 'temp/config';
declare const _boxeverq: { (): void }[];
declare const Boxever: Boxever;
@@ -21,7 +22,8 @@ interface BoxeverViewEventArgs {
function createPageView(locale: string, routeName: string) {
// POS must be valid in order to save events (domain name might be taken but it must be defined in CDP settings)
- const pointOfSale = process.env.CDP_POINTOFSALE || window.location.host.replace(/^www\./, '');
+ const pointOfSale =
+ process.env.NEXT_PUBLIC_CDP_POINTOFSALE || window.location.host.replace(/^www\./, '');
_boxeverq.push(function () {
const pageViewEvent: BoxeverViewEventArgs = {
@@ -44,7 +46,9 @@ function createPageView(locale: string, routeName: string) {
}
const CdpIntegrationScript = (): JSX.Element => {
- const { pageEditing, route } = useSitecoreContext();
+ const {
+ sitecoreContext: { pageEditing, route },
+ } = useSitecoreContext();
useEffect(() => {
// Do not create events in editing mode
@@ -52,12 +56,12 @@ const CdpIntegrationScript = (): JSX.Element => {
return;
}
- createPageView(route.itemLanguage, route.name);
+ route && createPageView(route.itemLanguage || config.defaultLanguage, route.name);
});
// Boxever is not needed during page editing
if (pageEditing) {
- return null;
+ <>>;
}
return (
@@ -70,14 +74,14 @@ const CdpIntegrationScript = (): JSX.Element => {
var _boxeverq = _boxeverq || [];
var _boxever_settings = {
- client_key: '${process.env.BOXEVER_CLIENT_KEY}',
- target: '${process.env.BOXEVER_TARGET_URL}',
+ client_key: '${process.env.NEXT_PUBLIC_CDP_CLIENT_KEY}',
+ target: '${process.env.NEXT_PUBLIC_CDP_TARGET_URL}',
cookie_domain: ''
};
`,
}}
/>
-
+
>
);
};
diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/middleware/plugins/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/middleware/plugins/personalize.ts
index 090b90e513..c41413282a 100644
--- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/middleware/plugins/personalize.ts
+++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/middleware/plugins/personalize.ts
@@ -1,150 +1,31 @@
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-import { NextResponse } from 'next/server';
-import type { NextRequest } from 'next/server';
+import { NextRequest, NextResponse } from 'next/server';
+import { PersonalizeMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/edge';
import { MiddlewarePlugin } from '..';
-
-export const personalizePlugin: MiddlewarePlugin = async function (
- req: NextRequest,
- res: NextResponse
-) {
- // no need to personalize for preview, layout data already prepared on XM Cloud for preview,
- // personalizeLayout function will not perform any transformation if pass not existing segment code: e.g. _default
- const isPreview = req.cookies['__prerender_bypass'] || req.cookies['__next_preview_data'];
- let segment = '';
- let cdpBrowserId = '';
-
- const pathname = req.nextUrl?.pathname;
- // exclude /api route as not page one
- const isApiRoute = pathname?.indexOf('/api/') !== -1;
-
- // middleware in the root intercepts requests for static assets (/public folder on app src)
- // no need to personalize them, no way to distinguish asset based on request, see https://github.com/vercel/next.js/issues/31721
- const isAsset = /\.(gif|jpg|jpeg|tiff|png|svg|ashx|ico)$/i.test(pathname || '');
-
- if (!isAsset && !isApiRoute) {
- if (isPreview) {
- segment = '_default';
- } else {
- // logic inside call Exp Edge to get itemid, call Boxever API to get segment
- const cdpResponse = await getSegmentForCurrentUser(req);
- segment = cdpResponse.segmentCode;
- cdpBrowserId = cdpResponse.browserId;
- if (!segment) {
- segment = '_default';
- }
- }
-
- if (pathname) {
- // _segmentId_ is just special word to distinguish path with segment code
- // without local rewrite will not work, see bug: https://github.com/vercel-customer-feedback/edge-functions/issues/85
- const rewriteTo = `/${req.nextUrl.locale || 'en'}/_segmentId_${segment}` + pathname;
-
- const nextResponse = NextResponse.rewrite(rewriteTo);
- // set Boxever identification cookie
- // had better set boxeverid cookie on server, read https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/
- if (cdpBrowserId) {
- const boxeverClientKey = process.env.BOXEVER_CLIENT_KEY;
- const browserIdCookieName = `bid_${boxeverClientKey}`;
- SetCookie(nextResponse, cdpBrowserId, browserIdCookieName);
- }
-
- return nextResponse;
- }
+import config from 'temp/config';
+
+class PersonalizePlugin implements MiddlewarePlugin {
+ private personalizeMiddleware: PersonalizeMiddleware;
+
+ // Using 1 to leave room for things like redirects to occur first
+ order = 1;
+
+ constructor() {
+ this.personalizeMiddleware = new PersonalizeMiddleware({
+ edgeConfig: {
+ endpoint: config.graphQLEndpoint,
+ apiKey: config.sitecoreApiKey,
+ siteName: config.jssAppName,
+ },
+ cdpConfig: {
+ endpoint: process.env.NEXT_PUBLIC_CDP_API_URL || '',
+ clientKey: process.env.NEXT_PUBLIC_CDP_CLIENT_KEY || '',
+ },
+ });
}
- return res;
-};
-
-personalizePlugin.order = 0;
-
-async function getSegmentForCurrentUser(req: NextRequest) {
- // ALL THOSE KEYS ALL PUBLIC, move to env variables in production implementation
- const boxeverApi = process.env.BOXEVER_API;
- const boxeverClientKey = process.env.BOXEVER_CLIENT_KEY;
- const expEdgeGraphql =
- process.env.GRAPH_QL_ENDPOINT ||
- (process.env.SITECORE_API_HOST || 'http://nextjsedge102') + '/sitecore/api/graph/edge';
- const sc_apikey = process.env.SITECORE_API_KEY || '24B40E6D-B002-465B-91CF-A3EE37E584E2';
- const site = process.env.JSS_APP_NAME || 'JssNextWeb';
- const routePath = req.nextUrl?.pathname;
- const language = req.nextUrl.locale || 'en';
- let friendlyId = '';
-
- // HERE WILL BE personalization field on item with segmentFriendlyIds,
- // if segmentFriendlyIds is empty no need to call Boxever, page has not personalization
- const init = {
- body: JSON.stringify({
- operationName: 'layout',
- query: `query layout {
- layout(site: "${site}", routePath: "${routePath}", language: "${language}") {
- item {
- id
- version
- }
- }
- }`,
- variables: {},
- }),
- method: 'POST',
- headers: {
- 'content-type': 'application/json',
- sc_apikey: sc_apikey,
- },
- };
- const edgeResponse = await fetch(`${expEdgeGraphql}`, init);
- const edgeResult = await edgeResponse.json();
-
- friendlyId =
- `${edgeResult?.data?.layout?.item.id}_${language}_${edgeResult.data?.layout?.item?.version}`.toLowerCase();
-
- return await GetSegmentFromCdp(req, boxeverApi, boxeverClientKey, friendlyId);
-}
-
-async function GetSegmentFromCdp(
- req: NextRequest,
- boxeverApi: string,
- boxeverClientKey: string,
- contentFriendlyId: string
-) {
- // Each user should have saved identifier to connect between session, Boxever use bid cookies + local storage
- const browserIdCookieName = `bid_${boxeverClientKey}`;
-
- const payload = { clientKey: boxeverClientKey, browserId: '', params: {} };
- if (req.cookies[browserIdCookieName] !== null) {
- payload.browserId = req.cookies[browserIdCookieName];
- }
- console.log(`Payload -> ${JSON.stringify(payload)}`);
-
- const rawResponse = await fetch(boxeverApi + `/${contentFriendlyId}`, {
- method: 'POST',
- headers: {
- 'content-type': 'application/json',
- },
- body: JSON.stringify(payload),
- });
-
- if (!rawResponse.ok) {
- return { segmentCode: '' };
+ async exec(req: NextRequest, res?: NextResponse): Promise {
+ return this.personalizeMiddleware.getHandler()(req, res);
}
-
- const cdpSegmentsResponseJson = await rawResponse.json();
- console.log(`CDP response -> ${JSON.stringify(cdpSegmentsResponseJson)}`);
-
- const segmentCode =
- cdpSegmentsResponseJson?.segments && cdpSegmentsResponseJson?.segments.length
- ? cdpSegmentsResponseJson?.segments[0]
- : '';
- return {
- segmentCode: segmentCode,
- browserId: cdpSegmentsResponseJson.browserId,
- };
}
-function SetCookie(res: NextResponse, browserId: string, browserIdCookieName: string) {
- if (typeof browserId !== 'undefined') {
- const expiryDate = new Date(new Date().setFullYear(new Date().getFullYear() + 2));
- const options = { expires: expiryDate, secure: true };
-
- res.cookie(browserIdCookieName, browserId, options);
- }
-}
+export const personalizePlugin = new PersonalizePlugin();
diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/extract-path.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/extract-path.ts
index 903bd35aae..c4baea2862 100644
--- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/extract-path.ts
+++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/extract-path.ts
@@ -1,4 +1,5 @@
import { ParsedUrlQuery } from 'querystring';
+import { normalizePersonalizedRewrite } from '@sitecore-jss/sitecore-jss-nextjs';
/**
* Extract normalized Sitecore item path from query
@@ -15,11 +16,8 @@ export function extractPath(params: ParsedUrlQuery | undefined): string {
path = '/' + path;
}
- // Remove SegmentId part from path, otherwise layout service will not find layout data
- if (path.includes('_segmentId_')) {
- const result = path.match('_segmentId_.*?\\/');
- path = result === null ? '/' : path.replace(result[0], '');
- }
+ // Ensure personalized rewrite data is removed
+ path = normalizePersonalizedRewrite(path);
return path;
}
diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts
index 670a4ce2ea..8aae8c0547 100644
--- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts
+++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts
@@ -1,29 +1,24 @@
import { GetServerSidePropsContext, GetStaticPropsContext } from 'next';
import { Plugin } from '..';
-import { personalizeLayout } from '@sitecore-jss/sitecore-jss-nextjs';
+import { getPersonalizedRewriteData, personalizeLayout } from '@sitecore-jss/sitecore-jss-nextjs';
import { SitecorePageProps } from 'lib/page-props';
class PersonalizePlugin implements Plugin {
order = 2;
async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) {
-
- // Get segment for personalization (from path)
- let filtered = null;
- if (context !== null) {
- // temporary disable null assertion
- if (Array.isArray(context!.params!.path)) {
- filtered = context!.params!.path.filter((e) => e.includes('_segmentId_'));
- }
+ if (!context?.params?.path) {
+ return props;
}
+ // Get segment for personalization (from path)
+ const path = Array.isArray(context.params.path)
+ ? context.params.path.join('/')
+ : context.params.path ?? '/';
- const segment =
- filtered === null || filtered.length == 0
- ? '_default'
- : filtered[0].replace('_segmentId_', '');
+ const personalizeData = getPersonalizedRewriteData(path);
- // modify layoutData to use specific segment instead of default
- personalizeLayout(props.layoutData, segment);
+ // Modify layoutData to use specific segment instead of default
+ personalizeLayout(props.layoutData, personalizeData.segmentId);
return props;
}
diff --git a/packages/create-sitecore-jss/src/templates/nextjs/package.json b/packages/create-sitecore-jss/src/templates/nextjs/package.json
index c1adc0a84c..838463d2b5 100644
--- a/packages/create-sitecore-jss/src/templates/nextjs/package.json
+++ b/packages/create-sitecore-jss/src/templates/nextjs/package.json
@@ -32,7 +32,7 @@
"@sitecore-jss/sitecore-jss-nextjs": "^21.0.0-canary",
"graphql": "~15.4.0",
"graphql-tag": "^2.11.0",
- "next": "12.1.5",
+ "next": "^12.1.6",
"next-localization": "^0.10.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/next-config/plugins/edge.js b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/next-config/plugins/edge.js
new file mode 100644
index 0000000000..931742e93a
--- /dev/null
+++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/next-config/plugins/edge.js
@@ -0,0 +1,26 @@
+const edgePlugin = (nextConfig = {}) => {
+ return Object.assign({}, nextConfig, {
+ webpack: (config, options) => {
+ if (options.isServer && options.nextRuntime === 'edge') {
+ // Next.js enforces a strict (browser-based) runtime on Edge.
+ // Point the Edge compiler in the right direction for 3rd-party module browser bundles.
+
+ // debug
+ config.resolve.alias.debug = require.resolve('debug/src/browser');
+
+ // graphql-request
+ config.resolve.alias['cross-fetch'] = require.resolve('cross-fetch/dist/browser-ponyfill');
+ config.resolve.alias['form-data'] = require.resolve('form-data/lib/browser');
+ }
+
+ // Overload the Webpack config if it was already overloaded
+ if (typeof nextConfig.webpack === 'function') {
+ return nextConfig.webpack(config, options);
+ }
+
+ return config;
+ }
+ });
+};
+
+module.exports = edgePlugin;
diff --git a/packages/sitecore-jss-nextjs/package.json b/packages/sitecore-jss-nextjs/package.json
index 94c0c4d977..b63c472838 100644
--- a/packages/sitecore-jss-nextjs/package.json
+++ b/packages/sitecore-jss-nextjs/package.json
@@ -11,7 +11,7 @@
"test": "mocha --require ./test/setup.js \"./src/**/*.test.ts\" \"./src/**/*.test.tsx\" --exit",
"prepublishOnly": "npm run build",
"coverage": "nyc npm test",
- "generate-docs": "npx typedoc --plugin typedoc-plugin-markdown --readme none --out ../../ref-docs/sitecore-jss-nextjs --entryPoints src/index.ts --entryPoints src/middleware/index.ts --githubPages false"
+ "generate-docs": "npx typedoc --plugin typedoc-plugin-markdown --readme none --out ../../ref-docs/sitecore-jss-nextjs --entryPoints src/index.ts --entryPoints src/edge/index.ts --entryPoints src/middleware/index.ts --githubPages false"
},
"engines": {
"node": ">=12",
@@ -52,7 +52,7 @@
"eslint-plugin-react": "^7.21.5",
"jsdom": "^15.1.1",
"mocha": "^9.1.3",
- "next": "12.1.5",
+ "next": "^12.1.6",
"nock": "^13.0.5",
"nyc": "^15.1.0",
"react": "^17.0.2",
@@ -64,7 +64,7 @@
"typescript": "~4.3.5"
},
"peerDependencies": {
- "next": "12.1.5",
+ "next": "^12.1.6",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
diff --git a/packages/sitecore-jss-nextjs/src/edge/index.ts b/packages/sitecore-jss-nextjs/src/edge/index.ts
index f942b0ad9b..a84a21b614 100644
--- a/packages/sitecore-jss-nextjs/src/edge/index.ts
+++ b/packages/sitecore-jss-nextjs/src/edge/index.ts
@@ -1 +1,2 @@
-export { RedirectsMiddleware } from './redirects-middleware';
+export { RedirectsMiddleware, RedirectsMiddlewareConfig } from './redirects-middleware';
+export { PersonalizeMiddleware, PersonalizeMiddlewareConfig } from '../edge/personalize-middleware';
diff --git a/packages/sitecore-jss-nextjs/src/edge/personalize-middleware.ts b/packages/sitecore-jss-nextjs/src/edge/personalize-middleware.ts
new file mode 100644
index 0000000000..c9bf3b19be
--- /dev/null
+++ b/packages/sitecore-jss-nextjs/src/edge/personalize-middleware.ts
@@ -0,0 +1,184 @@
+import { NextResponse, NextRequest } from 'next/server';
+import {
+ GraphQLPersonalizeService,
+ GraphQLPersonalizeServiceConfig,
+ CdpService,
+ CdpServiceConfig,
+ getPersonalizedRewrite,
+} from '@sitecore-jss/sitecore-jss/personalize';
+import { debug, NativeDataFetcher } from '@sitecore-jss/sitecore-jss';
+
+export type PersonalizeMiddlewareConfig = {
+ /**
+ * Function used to determine if route should be excluded from personalization.
+ * By default, files (pathname.includes('.')) and API routes (pathname.startsWith('/api/')) are ignored.
+ * This is an important performance consideration since Next.js Edge middleware runs on every request.
+ * @param {string} pathname The pathname
+ * @returns {boolean} Whether to exclude the route from personalization
+ */
+ excludeRoute?: (pathname: string) => boolean;
+ /**
+ * Configuration for your Sitecore Experience Edge endpoint
+ */
+ edgeConfig: Omit;
+ /**
+ * Configuration for your Sitecore CDP endpoint
+ */
+ cdpConfig: Omit;
+};
+
+/**
+ * Middleware / handler to support Sitecore Personalize
+ */
+export class PersonalizeMiddleware {
+ private personalizeService: GraphQLPersonalizeService;
+ private cdpService: CdpService;
+
+ /**
+ * @param {PersonalizeMiddlewareConfig} [config] Personalize middleware config
+ */
+ constructor(protected config: PersonalizeMiddlewareConfig) {
+ // NOTE: we provide native fetch for compatibility on Next.js Edge Runtime
+ // (underlying default 'cross-fetch' is not currently compatible: https://github.com/lquixada/cross-fetch/issues/78)
+ this.personalizeService = new GraphQLPersonalizeService({
+ ...config.edgeConfig,
+ fetch: fetch,
+ });
+ // NOTE: same here, we provide NativeDataFetcher for compatibility on Next.js Edge Runtime
+ this.cdpService = new CdpService({
+ ...config.cdpConfig,
+ dataFetcherResolver: () => {
+ const fetcher = new NativeDataFetcher({
+ debugger: debug.personalize,
+ });
+ return (url: string, data?: unknown) => fetcher.fetch(url, data);
+ },
+ });
+ }
+
+ /**
+ * Gets the Next.js middleware handler
+ * @returns middleware handler
+ */
+ public getHandler(): (req: NextRequest, res?: NextResponse) => Promise {
+ return this.handler;
+ }
+
+ protected get browserIdCookieName(): string {
+ // Each user should have saved identifier to connect between session, CDP uses bid cookies + local storage
+ return `bid_${this.config.cdpConfig.clientKey}`;
+ }
+
+ protected getBrowserId(req: NextRequest): string | undefined {
+ return req.cookies[this.browserIdCookieName] || undefined;
+ }
+
+ protected setBrowserId(res: NextResponse, browserId: string) {
+ if (browserId.length > 0) {
+ const expiryDate = new Date(new Date().setFullYear(new Date().getFullYear() + 2));
+ const options = { expires: expiryDate, secure: true };
+ res.cookie(this.browserIdCookieName, browserId, options);
+ }
+ }
+
+ private excludeRoute(pathname: string) {
+ if (
+ pathname.includes('.') || // Ignore files
+ pathname.startsWith('/api/') // Ignore API calls
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ private isPreview(req: NextRequest) {
+ return req.cookies.__prerender_bypass || req.cookies.__next_preview_data;
+ }
+
+ private handler = async (req: NextRequest, res?: NextResponse): Promise => {
+ const pathname = req.nextUrl.pathname;
+ const language = req.nextUrl.locale || req.nextUrl.defaultLocale || 'en';
+ let browserId = this.getBrowserId(req);
+ let segment: string | undefined = undefined;
+
+ debug.personalize('personalize middleware start: %o', {
+ pathname,
+ language,
+ browserId,
+ ip: req.ip,
+ ua: req.ua,
+ geo: req.geo,
+ headers: req.headers,
+ });
+
+ // Response will be provided if other middleware is run before us (e.g. redirects)
+ let response = res || NextResponse.next();
+
+ if (
+ response.redirected || // Don't attempt to personalize a redirect
+ this.isPreview(req) || // No need to personalize for preview (layout data is already prepared for preview)
+ (this.config.excludeRoute || this.excludeRoute)(pathname)
+ ) {
+ debug.personalize(
+ 'skipped (%s)',
+ response.redirected ? 'redirected' : this.isPreview(req) ? 'preview' : 'route excluded'
+ );
+ return response;
+ }
+
+ // Get personalization info from Experience Edge
+ const personalizeInfo = await this.personalizeService.getPersonalizeInfo(pathname, language);
+
+ if (!personalizeInfo) {
+ // Likely an invalid route / language
+ debug.personalize('skipped (personalize info not found)');
+ return response;
+ }
+
+ if (personalizeInfo.segments.length === 0) {
+ debug.personalize('skipped (no personalization configured)');
+ return response;
+ }
+
+ // Get segment data from CDP
+ const segmentData = await this.cdpService.getSegments(personalizeInfo.contentId, browserId);
+ // If a browserId was not passed in (new session), a new browserId will be returned
+ browserId = segmentData.browserId;
+ // This may change, but for now we are only expected to use the first segment if there are multiple
+ segment = segmentData.segments.length ? segmentData.segments[0] : undefined;
+
+ if (!segment) {
+ debug.personalize('skipped (no segment identified)');
+ return response;
+ }
+
+ if (!personalizeInfo.segments.includes(segment)) {
+ debug.personalize('skipped (invalid segment)');
+ return response;
+ }
+
+ // Rewrite to persononalized path
+ const rewritePath = getPersonalizedRewrite(pathname, { segmentId: segment });
+ // Note an absolute URL is required: https://nextjs.org/docs/messages/middleware-relative-urls
+ const rewriteUrl = req.nextUrl.clone();
+ rewriteUrl.pathname = rewritePath;
+ response = NextResponse.rewrite(rewriteUrl);
+
+ // Disable preflight caching to force revalidation on client-side navigation (personalization may be influenced)
+ // See https://github.com/vercel/next.js/issues/32727
+ response.headers.set('x-middleware-cache', 'no-cache');
+
+ // Set browserId cookie on the response
+ if (browserId) {
+ this.setBrowserId(response, browserId);
+ }
+
+ debug.personalize('personalize middleware end: %o', {
+ rewriteUrl,
+ browserId,
+ headers: response.headers,
+ });
+
+ return response;
+ };
+}
diff --git a/packages/sitecore-jss-nextjs/src/edge/redirects-middleware.ts b/packages/sitecore-jss-nextjs/src/edge/redirects-middleware.ts
index 66c7e7b018..799280670b 100644
--- a/packages/sitecore-jss-nextjs/src/edge/redirects-middleware.ts
+++ b/packages/sitecore-jss-nextjs/src/edge/redirects-middleware.ts
@@ -24,11 +24,11 @@ export class RedirectsMiddleware {
private locales: string[];
/**
- * NOTE: we provide native fetch for compatibility on Next.js Edge Runtime
- * (underlying default 'cross-fetch' is not currently compatible: https://github.com/lquixada/cross-fetch/issues/78)
* @param {RedirectsMiddlewareConfig} [config] redirects middleware config
*/
constructor(config: RedirectsMiddlewareConfig) {
+ // NOTE: we provide native fetch for compatibility on Next.js Edge Runtime
+ // (underlying default 'cross-fetch' is not currently compatible: https://github.com/lquixada/cross-fetch/issues/78)
this.redirectsService = new GraphQLRedirectsService({ ...config, fetch: fetch });
this.locales = config.locales;
}
@@ -75,8 +75,8 @@ export class RedirectsMiddleware {
/**
* Method returns RedirectInfo when matches
- * @param url
- * @returns Promise
+ * @param {NextRequest} req
+ * @returns Promise
* @private
*/
private async getExistsRedirect(req: NextRequest): Promise {
diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts
index d5af5ad25e..1c2a21343a 100644
--- a/packages/sitecore-jss-nextjs/src/index.ts
+++ b/packages/sitecore-jss-nextjs/src/index.ts
@@ -5,6 +5,8 @@ export {
HttpResponse,
AxiosDataFetcher,
AxiosDataFetcherConfig,
+ NativeDataFetcher,
+ NativeDataFetcherConfig,
enableDebug,
} from '@sitecore-jss/sitecore-jss';
export {
@@ -52,7 +54,12 @@ export {
RestDictionaryService,
RestDictionaryServiceConfig,
} from '@sitecore-jss/sitecore-jss/i18n';
-export { personalizeLayout } from '@sitecore-jss/sitecore-jss/personalize';
+export {
+ personalizeLayout,
+ getPersonalizedRewrite,
+ getPersonalizedRewriteData,
+ normalizePersonalizedRewrite,
+} from '@sitecore-jss/sitecore-jss/personalize';
export {
RobotsQueryResult,
GraphQLRobotsService,
diff --git a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts
index 121a3ca931..25a392390d 100644
--- a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts
+++ b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts
@@ -1,5 +1,6 @@
import { GraphQLClient, GraphQLRequestClient, PageInfo } from '@sitecore-jss/sitecore-jss/graphql';
import { debug } from '@sitecore-jss/sitecore-jss';
+import { getPersonalizedRewrite } from '@sitecore-jss/sitecore-jss/personalize';
/** @private */
export const languageError = 'The list of languages cannot be empty';
@@ -206,8 +207,6 @@ export class GraphQLSitemapService {
languages: string[],
formatStaticPath: (path: string[], language: string) => StaticPath
): Promise {
- const segmentPrefix = '_segmentId_';
-
if (!languages.length) {
throw new RangeError(languageError);
}
@@ -240,7 +239,7 @@ export class GraphQLSitemapService {
if (item.personalize?.variantIds.length) {
aggregatedPaths.push(
...item.personalize?.variantIds.map((varId) =>
- formatPath(`/${segmentPrefix}${varId}${item.path}`)
+ formatPath(getPersonalizedRewrite(item.path, { segmentId: varId }))
)
);
}
diff --git a/packages/sitecore-jss/package.json b/packages/sitecore-jss/package.json
index e066926b7f..113acdcaed 100644
--- a/packages/sitecore-jss/package.json
+++ b/packages/sitecore-jss/package.json
@@ -11,7 +11,7 @@
"test": "mocha --require ts-node/register \"./src/**/*.test.ts\"",
"prepublishOnly": "npm run build",
"coverage": "nyc npm test",
- "generate-docs": "npx typedoc --plugin typedoc-plugin-markdown --readme none --out ../../ref-docs/sitecore-jss --entryPoints src/index.ts --entryPoints src/graphql/index.ts --entryPoints src/i18n/index.ts --entryPoints src/layout/index.ts --entryPoints src/media/index.ts --entryPoints src/tracking/index.ts --entryPoints src/utils/index.ts --githubPages false"
+ "generate-docs": "npx typedoc --plugin typedoc-plugin-markdown --readme none --out ../../ref-docs/sitecore-jss --entryPoints src/index.ts --entryPoints src/graphql/index.ts --entryPoints src/i18n/index.ts --entryPoints src/layout/index.ts --entryPoints src/media/index.ts --entryPoints src/personalize/index.ts --entryPoints src/site/index.ts --entryPoints src/tracking/index.ts --entryPoints src/utils/index.ts --githubPages false"
},
"engines": {
"node": ">=12",
diff --git a/packages/sitecore-jss/src/axios-fetcher.ts b/packages/sitecore-jss/src/axios-fetcher.ts
index ed226a7c14..d5802de0fc 100644
--- a/packages/sitecore-jss/src/axios-fetcher.ts
+++ b/packages/sitecore-jss/src/axios-fetcher.ts
@@ -51,13 +51,20 @@ export class AxiosDataFetcher {
* be included in CORS requests (which is necessary for analytics and such).
*/
constructor(dataFetcherConfig: AxiosDataFetcherConfig = {}) {
- const { onReq, onRes, onReqError, onResError, ...axiosConfig } = dataFetcherConfig;
+ const {
+ onReq,
+ onRes,
+ onReqError,
+ onResError,
+ debugger: debuggerOverride,
+ ...axiosConfig
+ } = dataFetcherConfig;
if (axiosConfig.withCredentials === undefined) {
axiosConfig.withCredentials = true;
}
this.instance = axios.create(axiosConfig);
- const debug = dataFetcherConfig.debugger || debuggers.http;
+ const debug = debuggerOverride || debuggers.http;
// Note Axios response interceptors are applied in registered order;
// however, request interceptors are REVERSED (https://github.com/axios/axios/issues/1663).
diff --git a/packages/sitecore-jss/src/debug.ts b/packages/sitecore-jss/src/debug.ts
index 96e47cbafc..22e56401d5 100644
--- a/packages/sitecore-jss/src/debug.ts
+++ b/packages/sitecore-jss/src/debug.ts
@@ -35,4 +35,5 @@ export default Object.freeze({
sitemap: debug(`${rootNamespace}:sitemap`),
robots: debug(`${rootNamespace}:robots`),
redirects: debug(`${rootNamespace}:redirects`),
+ personalize: debug(`${rootNamespace}:personalize`),
});
diff --git a/packages/sitecore-jss/src/index.ts b/packages/sitecore-jss/src/index.ts
index 7c52cbe4be..fa40ca03e6 100644
--- a/packages/sitecore-jss/src/index.ts
+++ b/packages/sitecore-jss/src/index.ts
@@ -10,4 +10,5 @@ export {
GraphQLRequestClientConfig,
} from './graphql-request-client';
export { AxiosDataFetcher, AxiosDataFetcherConfig } from './axios-fetcher';
+export { NativeDataFetcher, NativeDataFetcherConfig } from './native-fetcher';
export { constants };
diff --git a/packages/sitecore-jss/src/native-fetcher.test.ts b/packages/sitecore-jss/src/native-fetcher.test.ts
new file mode 100644
index 0000000000..cf1519d60b
--- /dev/null
+++ b/packages/sitecore-jss/src/native-fetcher.test.ts
@@ -0,0 +1,179 @@
+/* eslint-disable no-unused-expressions */
+import { expect, use, spy } from 'chai';
+import spies from 'chai-spies';
+import { NativeDataFetcher } from './native-fetcher';
+import debugApi from 'debug';
+import debug from './debug';
+
+use(spies);
+
+let fetchInput: RequestInfo | undefined;
+let fetchInit: RequestInit | undefined;
+
+const mockFetch = (status: number, response: unknown = {}, jsonError?: string) => {
+ return (input: RequestInfo, init?: RequestInit) => {
+ fetchInput = input;
+ fetchInit = init;
+ return Promise.resolve({
+ ok: status === 200,
+ status,
+ statusText: status === 200 ? 'OK' : 'ERROR',
+ url: input,
+ redirected: false,
+ headers: {
+ get: (name: string) => {
+ return name === 'Content-Type' ? 'application/json' : '';
+ },
+ } as Headers,
+ json: () => {
+ return jsonError ? Promise.reject(jsonError) : Promise.resolve(response);
+ },
+ } as Response);
+ };
+};
+
+const mockHeaders = () => {
+ return () => ({
+ set: spy(),
+ });
+};
+
+describe('NativeDataFetcher', () => {
+ let debugNamespaces: string;
+
+ before(() => {
+ debugNamespaces = debugApi.disable();
+ debugApi.enable(`${debug.http.namespace},${debug.personalize.namespace}`);
+ });
+
+ beforeEach(() => {
+ spy.on(global, 'Headers', mockHeaders());
+ spy.on(debug.http, 'log', () => true);
+ spy.on(debug.personalize, 'log', () => true);
+ });
+
+ afterEach(() => {
+ fetchInput = undefined;
+ fetchInit = undefined;
+ spy.restore(global);
+ spy.restore(debug.http);
+ spy.restore(debug.personalize);
+ });
+
+ after(() => {
+ debugApi.enable(debugNamespaces);
+ });
+
+ describe('fetch', () => {
+ it('should execute POST request with data', async () => {
+ const fetcher = new NativeDataFetcher();
+ const postData = { x: 'val1', y: 'val2' };
+ const respData = { z: 'val3' };
+
+ spy.on(global, 'fetch', mockFetch(200, respData));
+
+ const response = await fetcher.fetch('http://test.com/api', postData);
+ expect(response.status).to.equal(200);
+ expect(response.data).to.equal(respData);
+ expect(fetchInput).to.equal('http://test.com/api');
+ expect(fetchInit?.method).to.equal('POST');
+ expect(fetchInit?.body).to.equal(JSON.stringify(postData));
+ });
+
+ it('should execute GET request without data', async () => {
+ const fetcher = new NativeDataFetcher();
+ const respData = { z: 'val3' };
+
+ spy.on(global, 'fetch', mockFetch(200, respData));
+
+ const response = await fetcher.fetch('http://test.com/api');
+ expect(response.status).to.equal(200);
+ expect(response.data).to.equal(respData);
+ expect(fetchInput).to.equal('http://test.com/api');
+ expect(fetchInit?.method).to.equal('GET');
+ expect(fetchInit?.body).to.be.undefined;
+ });
+
+ it('should throw error for failed request', async () => {
+ const fetcher = new NativeDataFetcher();
+
+ spy.on(global, 'fetch', mockFetch(400));
+
+ await fetcher.fetch('http://test.com/api').catch((error) => {
+ expect(error).to.be.instanceOf(Error);
+ expect(error.message).to.equal('HTTP 400 ERROR');
+ });
+ });
+
+ it('should execute request with custom init', async () => {
+ const headers = {
+ x: 'x',
+ y: 'y',
+ };
+ const fetcher = new NativeDataFetcher({
+ credentials: 'same-origin',
+ referrer: 'foo',
+ headers,
+ });
+
+ spy.on(global, 'fetch', mockFetch(200));
+
+ const response = await fetcher.fetch('http://test.com/api');
+ expect(response.status).to.equal(200);
+ expect(fetchInit?.credentials).to.equal('same-origin');
+ expect(fetchInit?.referrer).to.equal('foo');
+ expect(global.Headers).to.be.called.once;
+ expect(global.Headers).to.be.called.with(headers);
+ });
+
+ it('should debug log request and response', async () => {
+ const fetcher = new NativeDataFetcher();
+
+ spy.on(global, 'fetch', mockFetch(200));
+
+ await fetcher.fetch('http://test.com/api');
+ expect(debug.http.log, 'request and response log').to.be.called.twice;
+ });
+
+ it('should debug log request and response error', async () => {
+ const fetcher = new NativeDataFetcher();
+
+ spy.on(global, 'fetch', mockFetch(400));
+
+ await fetcher.fetch('http://test.com/api').catch(() => {
+ expect(debug.http.log, 'request and response error log').to.be.called.twice;
+ });
+ });
+
+ it('should use debugger override', async () => {
+ const fetcher = new NativeDataFetcher({ debugger: debug.personalize });
+
+ spy.on(global, 'fetch', mockFetch(200));
+
+ await fetcher.fetch('http://test.com/api');
+ expect(debug.personalize.log, 'request and response log').to.be.called.twice;
+ });
+
+ it('should use fetch override', async () => {
+ const fetchOverride = spy(mockFetch(200));
+ const fetcher = new NativeDataFetcher({ fetch: fetchOverride });
+
+ await fetcher.fetch('http://test.com/api');
+ expect(fetchOverride).to.be.called;
+ });
+
+ it('should handle response.json() error', async () => {
+ const fetcher = new NativeDataFetcher();
+
+ spy.on(global, 'fetch', mockFetch(200, {}, 'ERROR'));
+
+ const response = await fetcher.fetch('http://test.com/api');
+ expect(response.status).to.equal(200);
+ expect(response.data).to.be.undefined;
+ expect(
+ debug.http.log,
+ 'request and response.json() error and response log'
+ ).to.be.called.exactly(3);
+ });
+ });
+});
diff --git a/packages/sitecore-jss/src/native-fetcher.ts b/packages/sitecore-jss/src/native-fetcher.ts
new file mode 100644
index 0000000000..f8e1a53c80
--- /dev/null
+++ b/packages/sitecore-jss/src/native-fetcher.ts
@@ -0,0 +1,87 @@
+import { HttpResponse } from './data-fetcher';
+import debuggers, { Debugger } from './debug';
+
+type NativeDataFetcherOptions = {
+ /**
+ * Override debugger for logging. Uses 'sitecore-jss:http' by default.
+ */
+ debugger?: Debugger;
+ /**
+ * Override fetch method. Uses native (or polyfilled) fetch by default.
+ */
+ fetch?: typeof fetch;
+};
+
+export type NativeDataFetcherConfig = NativeDataFetcherOptions & RequestInit;
+
+export class NativeDataFetcher {
+ constructor(protected config: NativeDataFetcherConfig = {}) {}
+
+ /**
+ * Implements a data fetcher. @see HttpDataFetcher type for implementation details/notes.
+ * @param {string} url The URL to request; may include query string
+ * @param {unknown} [data] Optional data to POST with the request.
+ * @returns {Promise>} response
+ */
+ async fetch(url: string, data?: unknown): Promise> {
+ const { debugger: debugOverride, fetch: fetchOverride, ...init } = this.config;
+
+ const fetchImpl = fetchOverride || fetch;
+ const debug = debugOverride || debuggers.http;
+ const requestInit = this.getRequestInit(init, data);
+
+ // Note a goal here is to provide consistent debug logging and error handling
+ // as we do in AxiosDataFetcher and GraphQLRequestClient
+ debug('request: %o', { url, ...requestInit });
+ const response = await fetchImpl(url, requestInit).catch((error) => {
+ debug('request error: %o', error);
+ throw error;
+ });
+
+ // Note even an error status may send useful json data in response (which we want for logging)
+ let respData: unknown = undefined;
+ const isJson = response.headers.get('Content-Type')?.includes('application/json');
+ if (isJson) {
+ respData = await response.json().catch((error) => {
+ debug('response.json() error: %o', error);
+ });
+ }
+ const debugResponse = {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ url: response.url,
+ redirected: response.redirected,
+ data: respData,
+ };
+
+ if (!response.ok) {
+ debug('response error: %o', debugResponse);
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
+ }
+ debug('response: %o', debugResponse);
+ return {
+ ...response,
+ data: respData as T,
+ };
+ }
+
+ /**
+ * Determines settings for the request
+ * @param {RequestInit} init Custom settings for request
+ * @param {unknown} [data] Optional data to POST with the request
+ * @returns {RequestInit} The final request settings
+ */
+ protected getRequestInit(init: RequestInit = {}, data?: unknown): RequestInit {
+ // This is a focused implementation (GET or POST only using JSON input/output)
+ // so we are opinionated about method, body, and Content-Type
+ init.method = data ? 'POST' : 'GET';
+ init.body = data ? JSON.stringify(data) : undefined;
+
+ const headers = new Headers(init.headers);
+ headers.set('Content-Type', 'application/json');
+ init.headers = headers;
+
+ return init;
+ }
+}
diff --git a/packages/sitecore-jss/src/personalize/cdp-service.test.ts b/packages/sitecore-jss/src/personalize/cdp-service.test.ts
new file mode 100644
index 0000000000..70dbe9af61
--- /dev/null
+++ b/packages/sitecore-jss/src/personalize/cdp-service.test.ts
@@ -0,0 +1,94 @@
+/* eslint-disable no-unused-expressions */
+import { CdpService } from './cdp-service';
+import { expect, spy, use } from 'chai';
+import spies from 'chai-spies';
+import nock from 'nock';
+import { AxiosDataFetcher } from '../axios-fetcher';
+
+use(spies);
+
+describe('CdpService', () => {
+ const endpoint = 'http://sctest';
+ const clientKey = 'client-key';
+ const contentId = 'content-id';
+ const segments = ['segment-1', 'segment-2'];
+ const browserId = 'browser-id';
+
+ const config = {
+ endpoint,
+ clientKey,
+ };
+
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ it('should return segment data for a route', async () => {
+ nock(endpoint)
+ .post(`/v2/callFlows/getSegments/${contentId}`, {
+ clientKey,
+ browserId,
+ params: {},
+ })
+ .reply(200, {
+ segments,
+ browserId,
+ });
+
+ const service = new CdpService(config);
+ const getSegmentDataResult = await service.getSegments(contentId, browserId);
+
+ expect(getSegmentDataResult).to.deep.equal({
+ segments,
+ browserId,
+ });
+ });
+
+ it('should return empty segments array when no response', async () => {
+ nock(endpoint)
+ .post(`/v2/callFlows/getSegments/${contentId}`, {
+ clientKey,
+ browserId,
+ params: {},
+ })
+ .reply(200, {
+ segments: [],
+ browserId: '',
+ });
+
+ const service = new CdpService(config);
+ const getSegmentDataResult = await service.getSegments(contentId, browserId);
+
+ expect(getSegmentDataResult).to.deep.equal({
+ segments: [],
+ browserId: '',
+ });
+ });
+
+ it('should fetch using custom fetcher resolver', async () => {
+ const fetcherSpy = spy((url: string, data?: unknown) => {
+ return new AxiosDataFetcher().fetch(url, data);
+ });
+
+ nock(endpoint)
+ .post(`/v2/callFlows/getSegments/${contentId}`, {
+ clientKey,
+ browserId,
+ params: {},
+ })
+ .reply(200, {
+ segments,
+ browserId,
+ });
+
+ const service = new CdpService({ ...config, dataFetcherResolver: () => fetcherSpy });
+ const getSegmentDataResult = await service.getSegments(contentId, browserId);
+
+ expect(getSegmentDataResult).to.deep.equal({
+ segments,
+ browserId,
+ });
+ expect(fetcherSpy).to.be.called.once;
+ expect(fetcherSpy).to.be.called.with(`http://sctest/v2/callFlows/getSegments/${contentId}`);
+ });
+});
diff --git a/packages/sitecore-jss/src/personalize/cdp-service.ts b/packages/sitecore-jss/src/personalize/cdp-service.ts
new file mode 100644
index 0000000000..b58fc0b1a4
--- /dev/null
+++ b/packages/sitecore-jss/src/personalize/cdp-service.ts
@@ -0,0 +1,88 @@
+import debug from '../debug';
+import { HttpDataFetcher } from '../data-fetcher';
+import { AxiosDataFetcher } from '../axios-fetcher';
+
+/**
+ * Object model of CDP segment data
+ */
+export type SegmentData = {
+ /**
+ * The identified segments
+ */
+ segments: string[];
+ /**
+ * The browser id
+ */
+ browserId?: string;
+};
+
+export type CdpServiceConfig = {
+ /**
+ * Your CDP API endpoint
+ */
+ endpoint: string;
+ /**
+ * The client key to use for authentication
+ */
+ clientKey: string;
+ /**
+ * Custom data fetcher resolver. Uses @see AxiosDataFetcher by default.
+ */
+ dataFetcherResolver?: DataFetcherResolver;
+};
+
+/**
+ * Data fetcher resolver in order to provide custom data fetcher
+ */
+export type DataFetcherResolver = () => HttpDataFetcher;
+
+export class CdpService {
+ /**
+ * @param {CdpServiceConfig} [config] CDP service config
+ */
+ constructor(protected config: CdpServiceConfig) {}
+
+ /**
+ * Returns a list of segments to determine which variant of a page to render.
+ * @param {string} contentId the friendly content id
+ * @param {string} [browserId] the browser id. If omitted, a browserId will be created and returned in the response.
+ * @returns {SegmentData} the segment data
+ */
+ async getSegments(contentId: string, browserId = ''): Promise {
+ const endpoint = this.getSegmentsUrl(contentId);
+ // TODO: params (TBD)
+ const params = {};
+
+ debug.personalize('fetching segment data for %s %s %o', contentId, browserId, params);
+
+ const fetcher = this.config.dataFetcherResolver
+ ? this.config.dataFetcherResolver()
+ : this.getDefaultFetcher();
+ const response = await fetcher(endpoint, {
+ clientKey: this.config.clientKey,
+ browserId,
+ params,
+ });
+ return response.data;
+ }
+
+ /**
+ * Get formatted URL for getSegments call
+ * @param {string} contentId friendly content id
+ * @returns {string} formatted URL
+ */
+ protected getSegmentsUrl(contentId: string) {
+ return `${this.config.endpoint}/v2/callFlows/getSegments/${contentId}`;
+ }
+
+ /**
+ * Provides default @see AxiosDataFetcher data fetcher
+ * @returns default fetcher
+ */
+ protected getDefaultFetcher = () => {
+ const fetcher = new AxiosDataFetcher({
+ debugger: debug.personalize,
+ });
+ return (url: string, data?: unknown) => fetcher.fetch(url, data);
+ };
+}
diff --git a/packages/sitecore-jss/src/personalize/graphql-personalize-service.test.ts b/packages/sitecore-jss/src/personalize/graphql-personalize-service.test.ts
new file mode 100644
index 0000000000..abe51d4605
--- /dev/null
+++ b/packages/sitecore-jss/src/personalize/graphql-personalize-service.test.ts
@@ -0,0 +1,94 @@
+import { expect, use } from 'chai';
+import spies from 'chai-spies';
+import nock from 'nock';
+import { GraphQLPersonalizeService } from './graphql-personalize-service';
+
+use(spies);
+
+describe('GraphQLPersonalizeService', () => {
+ const endpoint = 'http://sctest/graphql';
+ const siteName = 'sitecore';
+ const apiKey = 'api-key';
+ const id = 'item-id';
+ const version = '1';
+ const segments = ['segment-1', 'segment-2'];
+
+ const config = {
+ endpoint,
+ siteName,
+ apiKey,
+ };
+ const personalizeQueryResult = {
+ layout: {
+ item: {
+ id,
+ version,
+ personalization: {
+ variantIds: segments,
+ },
+ },
+ },
+ };
+
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ it('should return personalize info for a route', async () => {
+ nock('http://sctest', {
+ reqheaders: {
+ sc_apikey: apiKey,
+ },
+ })
+ .post('/graphql')
+ .reply(200, {
+ data: personalizeQueryResult,
+ });
+
+ const service = new GraphQLPersonalizeService(config);
+ const personalizeData = await service.getPersonalizeInfo('/sitecore/content/home', 'en');
+
+ expect(personalizeData).to.deep.equal({
+ contentId: `${id}_en_${version}`.toLowerCase(),
+ segments,
+ });
+ });
+
+ it('should return undefined if itemPath / language not found', async () => {
+ nock('http://sctest', {
+ reqheaders: {
+ sc_apikey: apiKey,
+ },
+ })
+ .post('/graphql')
+ .reply(200, {
+ data: {
+ layout: {},
+ },
+ });
+
+ const service = new GraphQLPersonalizeService(config);
+ const personalizeData = await service.getPersonalizeInfo('/sitecore/content/home', '');
+
+ expect(personalizeData).to.eql(undefined);
+ });
+
+ it('shold return an error', async () => {
+ nock('http://sctest', {
+ reqheaders: {
+ sc_apikey: apiKey,
+ },
+ })
+ .post('/graphql')
+ .reply(401, {
+ error: 'error',
+ });
+
+ const service = new GraphQLPersonalizeService(config);
+
+ await service.getPersonalizeInfo('/sitecore/content/home', 'en').catch((error) => {
+ expect(error.response.status).to.equal(401);
+ expect(error.response.error).to.equal('error');
+ });
+ });
+});
diff --git a/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts b/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts
new file mode 100644
index 0000000000..7152777648
--- /dev/null
+++ b/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts
@@ -0,0 +1,116 @@
+import { GraphQLClient, GraphQLRequestClient } from '../graphql-request-client';
+import debug from '../debug';
+
+export type GraphQLPersonalizeServiceConfig = {
+ /**
+ * Your Graphql endpoint
+ */
+ endpoint: string;
+ /**
+ * The JSS application name
+ */
+ siteName: string;
+ /**
+ * The API key to use for authentication
+ */
+ apiKey: string;
+ /**
+ * Override fetch method. Uses 'GraphQLRequestClient' default otherwise.
+ */
+ fetch?: typeof fetch;
+};
+
+/**
+ * Object model of personlize info
+ */
+export type PersonalizeInfo = {
+ /**
+ * The (CDP-friendly) content id
+ */
+ contentId: string;
+ /**
+ * The configured segments
+ */
+ segments: string[];
+};
+
+type PersonalizeQueryResult = {
+ layout: { item: { id: string; version: string; personalization: { variantIds: string[] } } };
+};
+
+export class GraphQLPersonalizeService {
+ private graphQLClient: GraphQLClient;
+
+ protected get query(): string {
+ return /* GraphQL */ `
+ query($siteName: String!, $language: String!, $itemPath: String!) {
+ layout(site: $siteName, routePath: $itemPath, language: $language) {
+ item {
+ id
+ version
+ personalization {
+ variantIds
+ }
+ }
+ }
+ }
+ `;
+ }
+
+ /**
+ * Fetch personalize data using the Sitecore GraphQL endpoint.
+ * @param {GraphQLPersonalizeServiceConfig} config
+ */
+ constructor(protected config: GraphQLPersonalizeServiceConfig) {
+ this.graphQLClient = this.getGraphQLClient();
+ }
+
+ /**
+ * Get personalize information for a route
+ * @param {string} itemPath page route
+ * @param {string} language language
+ * @returns {Promise} the personalize information or undefined (if itemPath / language not found)
+ */
+ async getPersonalizeInfo(
+ itemPath: string,
+ language: string
+ ): Promise {
+ debug.personalize(
+ 'fetching personalize info for %s %s %s',
+ this.config.siteName,
+ itemPath,
+ language
+ );
+
+ const data = await this.graphQLClient.request(this.query, {
+ siteName: this.config.siteName,
+ itemPath,
+ language,
+ });
+
+ if (!data?.layout?.item) {
+ // Possible if itemPath / language doesn't exist
+ return undefined;
+ }
+
+ return {
+ // CDP expects content id format `__` (lowercase)
+ contentId: `${data.layout.item.id}_${language}_${data.layout.item.version}`.toLowerCase(),
+ segments: data.layout.item.personalization.variantIds,
+ };
+ }
+
+ /**
+ * Gets a GraphQL client that can make requests to the API. Uses graphql-request as the default
+ * library for fetching graphql data (@see GraphQLRequestClient). Override this method if you
+ * want to use something else.
+ * @returns {GraphQLClient} implementation
+ */
+ protected getGraphQLClient(): GraphQLClient {
+ return new GraphQLRequestClient(this.config.endpoint, {
+ apiKey: this.config.apiKey,
+ debugger: debug.personalize,
+ fetch: this.config.fetch,
+ });
+ }
+}
diff --git a/packages/sitecore-jss/src/personalize/index.ts b/packages/sitecore-jss/src/personalize/index.ts
index 1480629f09..3f16979f26 100644
--- a/packages/sitecore-jss/src/personalize/index.ts
+++ b/packages/sitecore-jss/src/personalize/index.ts
@@ -1 +1,11 @@
export { personalizeLayout } from './layout-personalizer';
+export {
+ GraphQLPersonalizeService,
+ GraphQLPersonalizeServiceConfig,
+} from './graphql-personalize-service';
+export { CdpService, CdpServiceConfig } from './cdp-service';
+export {
+ getPersonalizedRewrite,
+ getPersonalizedRewriteData,
+ normalizePersonalizedRewrite,
+} from './utils';
diff --git a/packages/sitecore-jss/src/personalize/utils.test.ts b/packages/sitecore-jss/src/personalize/utils.test.ts
new file mode 100644
index 0000000000..08fd9b600d
--- /dev/null
+++ b/packages/sitecore-jss/src/personalize/utils.test.ts
@@ -0,0 +1,75 @@
+import { expect } from 'chai';
+import * as utils from './utils';
+
+const { getPersonalizedRewrite, getPersonalizedRewriteData, normalizePersonalizedRewrite } = utils;
+
+export const DEFAULT_SEGMENT = '_default';
+export const SEGMENT_PREFIX = '_segmentId_';
+
+describe('utils', () => {
+ describe('getPersonalizedRewrite', () => {
+ const data = {
+ segmentId: 'segment-id',
+ };
+ it('should return a string', () => {
+ expect(getPersonalizedRewrite('/pathname', data)).to.be.a('string');
+ });
+ it('should return the path with the segment id when pathname starts with "/"', () => {
+ const pathname = '/some/path';
+ const result = getPersonalizedRewrite(pathname, data);
+ expect(result).to.equal(`/${SEGMENT_PREFIX}${data.segmentId}/some/path`);
+ });
+ it('should return the path with the segment id when pathname not starts with "/"', () => {
+ const pathname = 'some/path';
+ const result = getPersonalizedRewrite(pathname, data);
+ expect(result).to.equal(`/${SEGMENT_PREFIX}${data.segmentId}/some/path`);
+ });
+ it('should return the root path with the segment id', () => {
+ const pathname = '/';
+ const result = getPersonalizedRewrite(pathname, data);
+ expect(result).to.equal(`/${SEGMENT_PREFIX}${data.segmentId}/`);
+ });
+ });
+
+ describe('getPersonalizedRewriteData', () => {
+ it('should return a PersonalizedRewriteData object', () => {
+ expect(getPersonalizedRewriteData('/some/path')).to.be.an('object');
+ });
+ it('should return the default segment id when pathname does not contain segment id', () => {
+ const pathname = '/some/path';
+ const result = getPersonalizedRewriteData(pathname);
+ expect(result.segmentId).to.equal(DEFAULT_SEGMENT);
+ });
+ it('should return the personalized data from the rewrite path', () => {
+ const pathname = '/some/path/_segmentId_/';
+ const result = getPersonalizedRewriteData(pathname);
+ expect(result.segmentId).to.equal('');
+ });
+ });
+
+ describe('normalizePersonalizedRewrite', () => {
+ it('should return a string', () => {
+ expect(normalizePersonalizedRewrite('/some/path')).to.be.a('string');
+ });
+ it('should return the pathname when it does not contain segment id', () => {
+ const pathname = '/some/path';
+ const result = normalizePersonalizedRewrite(pathname);
+ expect(result).to.equal(pathname);
+ });
+ it('should return the pathname without the segment id', () => {
+ const pathname = '/_segmentId_foo/some/path';
+ const result = normalizePersonalizedRewrite(pathname);
+ expect(result).to.equal('/some/path');
+ });
+ it('should return the root pathname without the segment id', () => {
+ const pathname = '/_segmentId_foo/';
+ const result = normalizePersonalizedRewrite(pathname);
+ expect(result).to.equal('/');
+ });
+ it('should return the root pathname without the segment id when pathname not ends with "/"', () => {
+ const pathname = '/_segmentId_foo';
+ const result = normalizePersonalizedRewrite(pathname);
+ expect(result).to.equal('/');
+ });
+ });
+});
diff --git a/packages/sitecore-jss/src/personalize/utils.ts b/packages/sitecore-jss/src/personalize/utils.ts
new file mode 100644
index 0000000000..5ef5ff28d7
--- /dev/null
+++ b/packages/sitecore-jss/src/personalize/utils.ts
@@ -0,0 +1,47 @@
+export const DEFAULT_SEGMENT = '_default';
+export const SEGMENT_PREFIX = '_segmentId_';
+
+export type PersonalizedRewriteData = {
+ segmentId: string;
+};
+
+/**
+ * Get a personalized rewrite path for given pathname
+ * @param {string} pathname the pathname
+ * @param {PersonalizedRewriteData} data the personalize data to include in the rewrite
+ * @returns {string} the rewrite path
+ */
+export function getPersonalizedRewrite(pathname: string, data: PersonalizedRewriteData): string {
+ const path = pathname.startsWith('/') ? pathname : '/' + pathname;
+ return `/${SEGMENT_PREFIX}${data.segmentId}${path}`;
+}
+
+/**
+ * Get personalize data from the rewrite path
+ * @param {string} pathname the pathname
+ * @returns {PersonalizedRewriteData} the personalize data from the rewrite
+ */
+export function getPersonalizedRewriteData(pathname: string): PersonalizedRewriteData {
+ const data: PersonalizedRewriteData = {
+ segmentId: DEFAULT_SEGMENT,
+ };
+ const path = pathname.endsWith('/') ? pathname : pathname + '/';
+ const result = path.match(`${SEGMENT_PREFIX}(.*?)\\/`);
+ if (result) {
+ data.segmentId = result[1];
+ }
+ return data;
+}
+
+/**
+ * Normalize a personalized rewrite path (remove personalize data)
+ * @param {string} pathname the pathname
+ * @returns {string} the pathname with personalize data removed
+ */
+export function normalizePersonalizedRewrite(pathname: string): string {
+ if (!pathname.includes(SEGMENT_PREFIX)) {
+ return pathname;
+ }
+ const result = pathname.match(`${SEGMENT_PREFIX}.*?(?:\\/|$)`);
+ return result === null ? pathname : pathname.replace(result[0], '');
+}
diff --git a/yarn.lock b/yarn.lock
index 62f4e11e1b..618ab9c71a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3099,93 +3099,93 @@ __metadata:
languageName: node
linkType: hard
-"@next/env@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/env@npm:12.1.5"
- checksum: 21a6345ad6ff39edd76630484af3f2a87a0984d06be19ca88d9758583a1c724905f1ea6c0d110a4c825430e5bcda835f7197ee820c7173c428416fb615ff97e6
+"@next/env@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/env@npm:12.1.6"
+ checksum: e6a4f189f0d653d13dc7ad510f6c9d2cf690bfd9e07c554bd501b840f8dabc3da5adcab874b0bc01aab86c3647cff4fb84692e3c3b28125af26f0b05cd4c7fcf
languageName: node
linkType: hard
-"@next/swc-android-arm-eabi@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-android-arm-eabi@npm:12.1.5"
+"@next/swc-android-arm-eabi@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-android-arm-eabi@npm:12.1.6"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
-"@next/swc-android-arm64@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-android-arm64@npm:12.1.5"
+"@next/swc-android-arm64@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-android-arm64@npm:12.1.6"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
-"@next/swc-darwin-arm64@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-darwin-arm64@npm:12.1.5"
+"@next/swc-darwin-arm64@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-darwin-arm64@npm:12.1.6"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
-"@next/swc-darwin-x64@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-darwin-x64@npm:12.1.5"
+"@next/swc-darwin-x64@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-darwin-x64@npm:12.1.6"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
-"@next/swc-linux-arm-gnueabihf@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-linux-arm-gnueabihf@npm:12.1.5"
+"@next/swc-linux-arm-gnueabihf@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-linux-arm-gnueabihf@npm:12.1.6"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
-"@next/swc-linux-arm64-gnu@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-linux-arm64-gnu@npm:12.1.5"
+"@next/swc-linux-arm64-gnu@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-linux-arm64-gnu@npm:12.1.6"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
-"@next/swc-linux-arm64-musl@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-linux-arm64-musl@npm:12.1.5"
+"@next/swc-linux-arm64-musl@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-linux-arm64-musl@npm:12.1.6"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
-"@next/swc-linux-x64-gnu@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-linux-x64-gnu@npm:12.1.5"
+"@next/swc-linux-x64-gnu@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-linux-x64-gnu@npm:12.1.6"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
-"@next/swc-linux-x64-musl@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-linux-x64-musl@npm:12.1.5"
+"@next/swc-linux-x64-musl@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-linux-x64-musl@npm:12.1.6"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
-"@next/swc-win32-arm64-msvc@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-win32-arm64-msvc@npm:12.1.5"
+"@next/swc-win32-arm64-msvc@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-win32-arm64-msvc@npm:12.1.6"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
-"@next/swc-win32-ia32-msvc@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-win32-ia32-msvc@npm:12.1.5"
+"@next/swc-win32-ia32-msvc@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-win32-ia32-msvc@npm:12.1.6"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
-"@next/swc-win32-x64-msvc@npm:12.1.5":
- version: 12.1.5
- resolution: "@next/swc-win32-x64-msvc@npm:12.1.5"
+"@next/swc-win32-x64-msvc@npm:12.1.6":
+ version: 12.1.6
+ resolution: "@next/swc-win32-x64-msvc@npm:12.1.6"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -3916,7 +3916,7 @@ __metadata:
eslint-plugin-react: ^7.21.5
jsdom: ^15.1.1
mocha: ^9.1.3
- next: 12.1.5
+ next: ^12.1.6
nock: ^13.0.5
nyc: ^15.1.0
prop-types: ^15.7.2
@@ -3930,7 +3930,7 @@ __metadata:
ts-node: ^9.0.0
typescript: ~4.3.5
peerDependencies:
- next: 12.1.5
+ next: ^12.1.6
react: ^17.0.2
react-dom: ^17.0.2
languageName: unknown
@@ -8488,10 +8488,10 @@ __metadata:
languageName: node
linkType: hard
-"caniuse-lite@npm:^1.0.30001283":
- version: 1.0.30001341
- resolution: "caniuse-lite@npm:1.0.30001341"
- checksum: 7262b093fb0bf49dbc5328418f5ce4e3dbb0b13e39c015f986ba1807634c123ac214efc94df7d095a336f57f86852b4b63ee61838f18dcc3a4a35f87b390c8f5
+"caniuse-lite@npm:^1.0.30001332":
+ version: 1.0.30001339
+ resolution: "caniuse-lite@npm:1.0.30001339"
+ checksum: c974676c6e38692ab5a274c460557476f1d167166493249059b5595dc3166942a30bb030dd4a6f6dcbc28fb53c741b5c95fc0f82b043def296e6a49b32b68478
languageName: node
linkType: hard
@@ -18217,26 +18217,26 @@ __metadata:
languageName: node
linkType: hard
-"next@npm:12.1.5":
- version: 12.1.5
- resolution: "next@npm:12.1.5"
- dependencies:
- "@next/env": 12.1.5
- "@next/swc-android-arm-eabi": 12.1.5
- "@next/swc-android-arm64": 12.1.5
- "@next/swc-darwin-arm64": 12.1.5
- "@next/swc-darwin-x64": 12.1.5
- "@next/swc-linux-arm-gnueabihf": 12.1.5
- "@next/swc-linux-arm64-gnu": 12.1.5
- "@next/swc-linux-arm64-musl": 12.1.5
- "@next/swc-linux-x64-gnu": 12.1.5
- "@next/swc-linux-x64-musl": 12.1.5
- "@next/swc-win32-arm64-msvc": 12.1.5
- "@next/swc-win32-ia32-msvc": 12.1.5
- "@next/swc-win32-x64-msvc": 12.1.5
- caniuse-lite: ^1.0.30001283
+"next@npm:^12.1.6":
+ version: 12.1.6
+ resolution: "next@npm:12.1.6"
+ dependencies:
+ "@next/env": 12.1.6
+ "@next/swc-android-arm-eabi": 12.1.6
+ "@next/swc-android-arm64": 12.1.6
+ "@next/swc-darwin-arm64": 12.1.6
+ "@next/swc-darwin-x64": 12.1.6
+ "@next/swc-linux-arm-gnueabihf": 12.1.6
+ "@next/swc-linux-arm64-gnu": 12.1.6
+ "@next/swc-linux-arm64-musl": 12.1.6
+ "@next/swc-linux-x64-gnu": 12.1.6
+ "@next/swc-linux-x64-musl": 12.1.6
+ "@next/swc-win32-arm64-msvc": 12.1.6
+ "@next/swc-win32-ia32-msvc": 12.1.6
+ "@next/swc-win32-x64-msvc": 12.1.6
+ caniuse-lite: ^1.0.30001332
postcss: 8.4.5
- styled-jsx: 5.0.1
+ styled-jsx: 5.0.2
peerDependencies:
fibers: ">= 3.1.0"
node-sass: ^6.0.0 || ^7.0.0
@@ -18277,7 +18277,7 @@ __metadata:
optional: true
bin:
next: dist/bin/next
- checksum: a70e70f7863035d9b2d5bb70ede6b165d4022c75d8e3b44411097eb20e4a4d9b34205873cb2d0088a76986fe3e0f160edad0ff7602fc269994e02dc4d21d035c
+ checksum: 670d544fd47670c29681d10824e6da625e9d4a048e564c8d9cb80d37f33c9ff9b5ca0a53e6d84d8d618b1fe7c9bb4e6b45040cb7e57a5c46b232a8f914425dc1
languageName: node
linkType: hard
@@ -23299,9 +23299,9 @@ __metadata:
languageName: node
linkType: hard
-"styled-jsx@npm:5.0.1":
- version: 5.0.1
- resolution: "styled-jsx@npm:5.0.1"
+"styled-jsx@npm:5.0.2":
+ version: 5.0.2
+ resolution: "styled-jsx@npm:5.0.2"
peerDependencies:
react: ">= 16.8.0 || 17.x.x || ^18.0.0-0"
peerDependenciesMeta:
@@ -23309,7 +23309,7 @@ __metadata:
optional: true
babel-plugin-macros:
optional: true
- checksum: b85eb03da76b566065b10911a24396659b45a34efa16eef87ed8ff2ce3ea7b9b6a9c0c92c3b2113f92cbca9ab12496690d72329cb9932759ea98d0386acb38a9
+ checksum: 86d55819ebeabd283a574d2f44f7d3f8fa6b8c28fa41687ece161bf1e910e04965611618921d8f5cd33dc6dae1033b926a70421ae5ea045440a9861edc3e0d87
languageName: node
linkType: hard