Skip to content

Commit

Permalink
[Next.js] Personalize Middleware (#1008)
Browse files Browse the repository at this point in the history
* Use config values

* WIP

* Progress on sitecore-jss/personalize service layer and sitecore-jss-nextjs/middleware for personalize

* use native fetch override

* enable fetch config pass-through

* Introduce personalize utils for rewrite url path de/construction

* Replace nextjs-personalize plugin code with SDK middleware use

* "BOXEVER" > "CDP" env var naming and added comments

* fix lint issues

* added unit tests to personalize utils

* added unit tests for graphql personalize

* updated graphql tests

* Add personalize and site submodules to doc generation

* BOXEVER_SCRIPT_URL > CDP_SCRIPT_URL

* add edge submodule to doc generation

* move personalize-middleware to edge

* fixed failing test

* added unit test for cdp-service

* Fix some issues with CdpIntegrationScript.tsx

* Switch from native fetch to use our AxiosDataFetcher with the axios-fetch-adapter module to provide fetch compatibility

* bump next version

* Switching to "variantIds" (Edge schema is already updated)

* expanded debug logging for middleware

* Use absolute URL in middleware, fix tests

* Bake CDP API version into cdp-service and default env values

* Use NEXT_PUBLIC_ prefix for CDP env variables (so they can be used in the browser)

* also skip if segment identified (by CDP) is not in list of configured segments (in layout data)

* Use config.defaultLanguage

* more jsdoc comments

* bump to latest next

* Forgo axios-fetch-adapter in favor of our own NativeDataFetcher

* Edge next.config plugin for webpack config customizations

* update doc comments, pass-through new fetcher types

* Use getPersonalizedRewrite helper in graphql-sitemap-service

* Revert DataFetcher interface, too much confusion for now. Just sticking with a custom data fetcher resolver.

* unit tests for native-fetcher

* Fix omit for dataFetcherResolver

* Remove export of PersonalizedRewriteData type.

* PR review feedback updates

* Handle response.json() errors in native-fetcher

Co-authored-by: Addy Pathania <[email protected]>
  • Loading branch information
ambrauer and addy-pathania authored May 25, 2022
1 parent 9a8c77c commit cd0d3a6
Show file tree
Hide file tree
Showing 27 changed files with 1,165 additions and 262 deletions.
19 changes: 14 additions & 5 deletions packages/create-sitecore-jss/src/templates/nextjs-personalize/.env
Original file line number Diff line number Diff line change
@@ -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=
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 = {
Expand All @@ -44,20 +46,22 @@ 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
if (pageEditing) {
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 (
Expand All @@ -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: ''
};
`,
}}
/>
<Script src={process.env.BOXEVER_SCRIPT_URL} />
<Script src={process.env.NEXT_PUBLIC_CDP_SCRIPT_URL} />
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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();
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ParsedUrlQuery } from 'querystring';
import { normalizePersonalizedRewrite } from '@sitecore-jss/sitecore-jss-nextjs';

/**
* Extract normalized Sitecore item path from query
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions packages/sitecore-jss-nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/sitecore-jss-nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { RedirectsMiddleware } from './redirects-middleware';
export { RedirectsMiddleware, RedirectsMiddlewareConfig } from './redirects-middleware';
export { PersonalizeMiddleware, PersonalizeMiddlewareConfig } from '../edge/personalize-middleware';
Loading

0 comments on commit cd0d3a6

Please sign in to comment.