Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ packages/*/*.tgz
samples/

.idea
.vscode/settings.json
.next

.npmrc
3 changes: 0 additions & 3 deletions .vscode/settings.json

This file was deleted.

2,215 changes: 1,130 additions & 1,085 deletions CHANGELOG.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { GetServerSidePropsContext, GetStaticPropsContext } from 'next';
import { Plugin } from '..';
import { getPersonalizedRewriteData, personalizeLayout } from '@sitecore-jss/sitecore-jss-nextjs';
import {
getPersonalizedRewriteData,
personalizeLayout,
PERSONALIZE_TOKENS_HEADER,
replaceTokensInObject,
TokenMap,
} from '@sitecore-jss/sitecore-jss-nextjs';
import { SitecorePageProps } from 'lib/page-props';

class PersonalizePlugin implements Plugin {
Expand All @@ -27,6 +33,53 @@ class PersonalizePlugin implements Plugin {
personalizeData.componentVariantIds
);

// Apply personalization tokens returned by the decision table (if any).
// The middleware encodes them as base64 JSON in the x-sc-personalize-tokens header.
// This block must run after personalizeLayout() so tokens are applied to the selected variant.
try {
const tokensHeader = (context as GetServerSidePropsContext).req?.headers[
PERSONALIZE_TOKENS_HEADER
] as string | undefined;
if (tokensHeader) {
const decoded: Record<string, Record<string, unknown>> = JSON.parse(
Buffer.from(tokensHeader, 'base64').toString('utf-8')
);
// Merge all per-variant token maps, validating that every value is a
// string. This avoids the Object.assign spread path that can trigger
// the __proto__ setter when JSON-parsed data contains that key.
const mergedTokens: TokenMap = {};
for (const variantTokens of Object.values(decoded)) {
for (const [k, v] of Object.entries(variantTokens)) {
if (
k !== '__proto__' &&
k !== 'constructor' &&
k !== 'prototype' &&
typeof v === 'string'
) {
if (mergedTokens[k] !== undefined) {
console.warn(
`[Personalize] Token key collision: "${k}" overwritten by value from another decision table.`
);
}
mergedTokens[k] = v;
}
}
}
if (Object.keys(mergedTokens).length > 0) {
const replaced = replaceTokensInObject(props.layoutData, mergedTokens, {
removeUnmatched: true,
onUnmatched: (key) =>
console.warn(`[Personalize] Unmatched token "${key}" has no value or fallback.`),
});
// Direct property assignment rather than Object.assign to make clear that
// layoutData.sitecore is the only subtree that contains token placeholders.
props.layoutData.sitecore = replaced.sitecore;
}
}
} catch (e) {
console.warn('Failed to apply personalize tokens:', e);
}

return props;
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/sitecore-jss-nextjs/src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ export { MiddlewareBase, MiddlewareBaseConfig } from './middleware';
export { RedirectsMiddleware, RedirectsMiddlewareConfig } from './redirects-middleware';
export { PersonalizeMiddleware, PersonalizeMiddlewareConfig } from './personalize-middleware';
export { MultisiteMiddleware, MultisiteMiddlewareConfig } from './multisite-middleware';
export {
PERSONALIZE_TOKENS_HEADER,
replaceTokensInObject,
TokenMap,
ReplaceTokensOptions,
} from '@sitecore-jss/sitecore-jss/personalize';
Original file line number Diff line number Diff line change
Expand Up @@ -1061,4 +1061,166 @@ describe('PersonalizeMiddleware', () => {
expect(finalRes).to.deep.equal(res);
});
});

describe('token handling', () => {
it('should set x-sc-personalize-tokens header when personalize returns tokens', async () => {
const req = createRequest();
const res = createResponse();
const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res);
const personalizeStub = sinon.stub().returns(
Promise.resolve({
variantId: 'variant-1',
tokens: { firstName: 'Bob', city: 'Portland' },
})
);

const { middleware } = createMiddleware({
personalizeStub,
personalizeInfo: { pageId, variantIds: ['variant-1'] },
});

const finalRes = await middleware.getHandler()(req, res);

const headerValue = finalRes.headers['x-sc-personalize-tokens'];
expect(headerValue).to.be.a('string');

const decoded = JSON.parse(Buffer.from(headerValue, 'base64').toString('utf-8'));
expect(decoded).to.deep.equal({ 'variant-1': { firstName: 'Bob', city: 'Portland' } });
nextRewriteStub.restore();
});

it('should merge tokens from multiple component executions into header', async () => {
const req = createRequest();
const res = createResponse();
const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res);
const personalizeStub = sinon.stub();
personalizeStub
.onFirstCall()
.returns(Promise.resolve({ variantId: 'comp1_var1', tokens: { firstName: 'Bob' } }));
personalizeStub
.onSecondCall()
.returns(Promise.resolve({ variantId: 'comp2_var1', tokens: { region: 'US' } }));

const { middleware } = createMiddleware({
personalizeStub,
personalizeInfo: { pageId, variantIds: ['comp1_var1', 'comp2_var1'] },
});

const finalRes = await middleware.getHandler()(req, res);

const headerValue = finalRes.headers['x-sc-personalize-tokens'];
expect(headerValue).to.be.a('string');

const decoded = JSON.parse(Buffer.from(headerValue, 'base64').toString('utf-8'));
expect(decoded).to.deep.equal({
comp1_var1: { firstName: 'Bob' },
comp2_var1: { region: 'US' },
});
nextRewriteStub.restore();
});

it('should not set x-sc-personalize-tokens header when no tokens returned', async () => {
const req = createRequest();
const res = createResponse();
const { middleware } = createMiddleware({
variantId: 'variant-1',
personalizeInfo: { pageId, variantIds: ['variant-1'] },
});

const finalRes = await middleware.getHandler()(req, res);

expect(finalRes.headers['x-sc-personalize-tokens']).to.be.undefined;
});

it('should not set x-sc-personalize-tokens header when tokens is empty object', async () => {
const req = createRequest();
const res = createResponse();
const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res);
const personalizeStub = sinon
.stub()
.returns(Promise.resolve({ variantId: 'variant-1', tokens: {} }));

const { middleware } = createMiddleware({
personalizeStub,
personalizeInfo: { pageId, variantIds: ['variant-1'] },
});

const finalRes = await middleware.getHandler()(req, res);

expect(finalRes.headers['x-sc-personalize-tokens']).to.be.undefined;
nextRewriteStub.restore();
});

it('should not collect tokens from invalid variants', async () => {
const req = createRequest();
const res = createResponse();
const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res);
const personalizeStub = sinon
.stub()
.returns(Promise.resolve({ variantId: 'invalid-variant', tokens: { firstName: 'Bob' } }));

const { middleware } = createMiddleware({
personalizeStub,
personalizeInfo: { pageId, variantIds: ['variant-1'] },
});

const finalRes = await middleware.getHandler()(req, res);

expect(finalRes.headers['x-sc-personalize-tokens']).to.be.undefined;
nextRewriteStub.restore();
});

it('should handle mixed executions where only some return tokens', async () => {
const req = createRequest();
const res = createResponse();
const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res);
const personalizeStub = sinon.stub();
personalizeStub
.onFirstCall()
.returns(Promise.resolve({ variantId: 'comp1_var1', tokens: { name: 'Bob' } }));
personalizeStub.onSecondCall().returns(Promise.resolve({ variantId: 'comp2_var1' })); // no tokens property

const { middleware } = createMiddleware({
personalizeStub,
personalizeInfo: { pageId, variantIds: ['comp1_var1', 'comp2_var1'] },
});

const finalRes = await middleware.getHandler()(req, res);

const headerValue = finalRes.headers['x-sc-personalize-tokens'];
expect(headerValue).to.be.a('string');

const decoded = JSON.parse(Buffer.from(headerValue, 'base64').toString('utf-8'));
expect(decoded).to.deep.equal({ comp1_var1: { name: 'Bob' } });
nextRewriteStub.restore();
});

it('should correctly encode non-ASCII token values in header', async () => {
const req = createRequest();
const res = createResponse();
const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res);
const personalizeStub = sinon.stub().returns(
Promise.resolve({
variantId: 'variant-1',
tokens: { city: 'München', greeting: '你好', emoji: '🎉' },
})
);

const { middleware } = createMiddleware({
personalizeStub,
personalizeInfo: { pageId, variantIds: ['variant-1'] },
});

const finalRes = await middleware.getHandler()(req, res);

const headerValue = finalRes.headers['x-sc-personalize-tokens'];
expect(headerValue).to.be.a('string');

const decoded = JSON.parse(Buffer.from(headerValue, 'base64').toString('utf-8'));
expect(decoded).to.deep.equal({
'variant-1': { city: 'München', greeting: '你好', emoji: '🎉' },
});
nextRewriteStub.restore();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PersonalizeInfo,
CdpHelper,
DEFAULT_VARIANT,
PERSONALIZE_TOKENS_HEADER,
} from '@sitecore-jss/sitecore-jss/personalize';
import { debug } from '@sitecore-jss/sitecore-jss';
import { MiddlewareBase, MiddlewareBaseConfig, REWRITE_HEADER_NAME } from './middleware';
Expand Down Expand Up @@ -188,6 +189,7 @@ export class PersonalizeMiddleware extends MiddlewareBase {
{ timeout }
)) as {
variantId: string;
tokens?: Record<string, string>;
};
}

Expand Down Expand Up @@ -343,6 +345,7 @@ export class PersonalizeMiddleware extends MiddlewareBase {
const params = this.getExperienceParams(req);
const executions = this.getPersonalizeExecutions(personalizeInfo, language);
const identifiedVariantIds: string[] = [];
const collectedTokens: Record<string, Record<string, string>> = {};

await Promise.all(
executions.map((execution) =>
Expand All @@ -363,6 +366,9 @@ export class PersonalizeMiddleware extends MiddlewareBase {
debug.personalize('invalid variant %s', variantId);
} else {
identifiedVariantIds.push(variantId);
if (personalization.tokens && Object.keys(personalization.tokens).length > 0) {
collectedTokens[variantId] = personalization.tokens;
}
}
}
})
Expand All @@ -381,6 +387,12 @@ export class PersonalizeMiddleware extends MiddlewareBase {
const rewritePath = getPersonalizedRewrite(basePath, identifiedVariantIds);
response = this.rewrite(rewritePath, req, response);

if (Object.keys(collectedTokens).length > 0) {
const tokensJson = JSON.stringify(collectedTokens);
const tokensBase64 = btoa(unescape(encodeURIComponent(tokensJson)));
response.headers.set(PERSONALIZE_TOKENS_HEADER, tokensBase64);
}

// Disable preflight caching to force revalidation on client-side navigation (personalization MAY be influenced).
// See https://github.com/vercel/next.js/pull/32767
response.headers.set('x-middleware-cache', 'no-cache');
Expand Down
Loading
Loading