Showing posts with label Vercel. Show all posts
Showing posts with label Vercel. Show all posts

Tuesday, September 20, 2022

Deploying of Sitecore JSS (Next.js) application to Vercel from on-prem Gitlab

 I just went through implementing of on-prem Gitlab CI/CD pipeline for deploying of a Next.js Sitecore JSS application to Vercel. At first everything looked straight forward, but there a few hiccups down the road sharing of which I thought would be beneficial to others.

Building and deploying of Next.js Sitecore JSS application

Vercel team referred us to the following documentation to help use configure on-prem Gitlab CI/CD pipeline to deploy the application to Vercel:

I followed the instructions and configured the CI/CD pipeline script to run the following commands:

- npm install --global vercel 
- vercel pull --yes --environment=preview --token=$VERCEL_TOKEN 
- vercel build --token=$VERCEL_TOKEN 
- vercel deploy --prebuilt --token=$VERCEL_TOKEN

I also added five environment variables to the Vercel project:
Vercel environment variables

When I ran the pipeline and Vercel CLI commands finished executing, I saw the following output in the deployment screen capture. The cause of this broken output was the broken references to JS and CSS files. The host name in these references are pointing to a local instance. If I added PUBLIC_URL to the environment variables for all environments, then the references would be correct and the application would load properly. However, if different environments are expected to have different PUBLIC_URL this solution wouldn't work.



After digging a little deeper into Sitecore JSS code and debugging the deployment script that Vercel CLI produced I realized that VERCEL_URL is always empty when you run the build "locally" (not in Vercel deployment). Going through documentation didn't help much.

By pure accident I ran a command to switch scopes:

vercel --scope $VERCEL_SCOPE ${VERCEL_PARAMS} --token=${VERCEL_TOKEN} 

And all of a sudden I see a traditional build starting in Vercel. It goes through the usual build, checks and deployment and I see JS and CSS references getting proper hostname in references.

I filed a support ticket with Vercel asking to explain the behavior and advise on the way forward. After three weeks of back and forth they finally explained why changing of scope triggered a build. 

Apparently any run of vercel command triggers a build and deployment, and if you run this CLI command from your local "rendering" application, it will trigger a build using your local application. 

The bottom line is that for Sitecore JSS applications and for any applications that expect using VERCEL_URL during build you can't use pull/build/deploy. You have to use vercel command. A

Deployment URL


If you need to get a value of your Vercel deployment each time you run CLI build and deployment command, you need to use the following format:

    - DEPLOYMENT_URL=$(vercel --scope $VERCEL_SCOPE ${VERCEL_PARAMS} --token=${VERCEL_TOKEN} --prod)
    - echo $DEPLOYMENT_URL

However, if you are trying to assign an alias to a preview or development deployment don't expect it to work. Alias assignment only works for production deployments.

Final Script

After all troubles I ended up creating a separate Vercel project for each environment. Each branch from our on-prem Gitlab was configured to deploy to a separate Vercel project into production environment. We can still deploy to preview from branches, but the main environment branch was configured to always deploy to production. That allowed us to have specific domain names for each environment branch. If we were using cloud version of Gitlab, we probably wouldn't have to do that, but since we are working with on-prem version, there was no other choice.

So the final Vercel portion of the script looks like this:

    - npm install --global vercel
    - DEPLOYMENT_URL=$(VERCEL_ORG_ID=$VERCEL_ORG_ID VERCEL_PROJECT_ID=$VERCEL_DEV_PROJECT_ID vercel --scope $VERCEL_SCOPE ${VERCEL_PARAMS} --token=${VERCEL_TOKEN} --prod)
    - echo $DEPLOYMENT_URL
 

Other considerations

If you are working with on-prem source control and have to use Vercel CLI, make sure that you are running the build on linux/amd64 machine. The system must match Vercel platform Build Image (https://vercel.com/docs/build-output-api/v3#introduction/known-limitations)

Friday, May 27, 2022

Vercel, Next.js middleware and personalization using Sitecore Personalize

When I started working with Vercel middleware I was convinced that I would be able to parse html before returning it to the user, however, to my big surprise you can't do html parsing in middleware which makes complete sense from the caching perspective.

I used Boxever library implementation from Sitecore.Demo.Edge Git repo and added a few things that I needed.

So if html can't be parse, how would one implement personalization? While searching for solution I ran into a YouTube video by Steve Sewel and a solution from https://www.builder.io. The solution that Steve described made perfect sense and seemed to be the only way to implement personalization in middleware. I used the same approach in my POC and here what I was able to do.

Use case

A user from Germany comes to the site and a component on a product listing page shows a greeting stating the user is visiting from Germany.

While the use case is very simple and you don't necessarily need Sitecore Personalize to implement that, it was the easiest scenario to implement to test the waters with middleware.

Solution

Under /pages folder I added _middleware.ts file which mean that the middleware function will be called for all pages. Here is the content of the file:

import { getPersonalizedPagePath } from 'lib/middleware-utils';
import { NextResponse, NextRequest } from 'next/server'

const excludededPrefixes = ['/favicon', '/api']

export async function middleware(req: NextRequest) {
  const url = req.nextUrl.clone();
  const country = req.geo?.country || 'US';

  if(!excludededPrefixes.find((path) => url.pathname?.startsWith(path))) {  
    const browserId = req.cookies[`bid_${process.env.CDP_CLIENT_KEY}`] || process.env.CDP_BROWSER_ID;
    const friendlyId = 'user_geolocation';  

    if (!url?.pathname!.includes(';')) {        
      const rewriteUrl = await getPersonalizedPagePath(friendlyId, url?.pathname!, req.cookies, country, browserId);
      if (rewriteUrl && rewriteUrl.path) {
        url.pathname = rewriteUrl.path;          
        return NextResponse.rewrite(url).cookie(`bx_userAttributes_${friendlyId}`, rewriteUrl[friendlyId]);        
      }
    }

    return NextResponse.rewrite(url).cookie(`bx_userAttributes_${friendlyId}`, friendlyId);    
  }
  return NextResponse.rewrite(url);  
}

As you can see I am rewriting the url of the current page to include country and friendlyId parameters.

If a request is made to /products page, for the user from Germany the rewrite url will look like this:

/products;country=DE;user_geolocation=Germany

Country code is easily retrieved from NextRequest. The second parameter is a little bit more involved. I defined a Full Stack experience in Sitecore Personalize that is called user_geolocation. The experience returns Germany if the latest guest session come from Germany. I added the country code (retrieved from NextRequest) to the VIEW event, so now all guest VIEW events have a country code. I also have a Decision Model attached to my Full Stack experience which gives me flexibility with API response. I can return "Germany" or I can return something else that I can use to display personalized content. The only thing to be aware of is the length of the outcome. Since it is part of the url, it should be short. I can use this response either to hardcode personalization or retrieve personalized content from Sitecore. For the simplicity of this POC, I hardcoded the personalization message.

When the content for rewritten url is being request and for SSG Next.js application it is possible because of the Next.js ISR functionality. The fallback configuration is set to "blocking" as for a usual Sitecore JSS application with SSG and the revalidate for this POC is set to 15 seconds. In real live you would want it to be as high as possible.

When the new version of the pages is being generated getStaticProps function is being called in [[...path]].ts. I added a few lines to read the parameters that I set in middleware and remove them from the actual page path, so it can retrieve the proper page from Sitecore.

export const getStaticProps: GetStaticProps = async (context) => {
  if (context) {    
    let userAttributes:any = {};
    let path:string[] = [];
    if(context.params?.path) {
      (context.params?.path as string[]).forEach((part) => {
        if (part && part.includes(';')) {
          userAttributes = { ...getTargetingValues(part?.split(';').slice(1)) };
          path.push(part?.split(';')[0]);
        } else {
          path.push(part);
        }
      });
    }
    context.params!.userAttributes = userAttributes;
    context.params!.path = path;
  }

  const props = await sitecorePagePropsFactory.create(context);  

  return {
    props,
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every 15 seconds
    revalidate: 15, // In seconds
    notFound: props.notFound, // Returns custom 404 page with a status code of 404 when true
  };
};

Now in the Next.js component that will render the personalized content I added the following:

import { Field, withDatasourceCheck, useComponentProps, GetStaticComponentProps } from '@sitecore-jss/sitecore-jss-nextjs';
import { StyleguideComponentProps } from 'lib/component-props';

type PersonalizedNavigationProps = StyleguideComponentProps & {
  fields: {
    friendlyId: Field<string>;
  };
};

type PersonalizedNavigationData = { content: '', country: '' };

const PersonalizedNavigation = ({ rendering }: PersonalizedNavigationProps): JSX.Element => {
  const personalizedContent = rendering.uid ? useComponentProps<PersonalizedNavigationData>(rendering.uid) : undefined;
  let personalizedOutput = '';
  //console.log(personalizedContent);
  if (personalizedContent?.country) {    
    switch(personalizedContent?.country) {
      case 'US': {
        personalizedOutput = 'USA';
        break;
      }
      case 'DE': {
        personalizedOutput = 'Germany';
        break;
      }
      default: {
        //statements;
        break;
      }
    }
  }

  if (personalizedContent?.content) {    
    switch(personalizedContent?.content) {
      case 'US': {
        personalizedOutput = personalizedOutput + ' ::: USA';
        break;
      }
      case 'DE': {
        personalizedOutput = personalizedOutput + ' ::: Germany';
        break;
      }
      default: {
        //statements;
        break;
      }
    }
  }

  return (
    <div>
      <h2>
        Country:
        {personalizedOutput && (
          <> {personalizedOutput}</>
        )}      
      </h2>
    </div>
  );
};

export const getStaticProps: GetStaticComponentProps = async (rendering:any, context:any, params:any) => {
  if (params && rendering && context) {
    const friendlyId = rendering?.fields?.data?.contextItem?.friendlyId.value || 'user_geolocation' as string;
    const country = params?.params?.userAttributes ? params?.params?.userAttributes?.country : '';
    const output = params?.params?.userAttributes ? params?.params?.userAttributes[friendlyId] : '';
    //console.log(params);
    return { country: country || '', content: output || ''};
  }
  return {};
};

export default withDatasourceCheck()<PersonalizedNavigationProps>(PersonalizedNavigation);

And on the page after the code has been deployed to Vercel the following snippet will render:




Below you'll find the files references from the code above. I decided to separate them into a separate section to avoid the flow disruption in my explanation.

Referenced .ts files:

middleware-utils.ts

import { DynamicData, getDynamicData, PersonalizedPath } from "src/services/BoxeverApiService";

const getUrlSegments = (values:any) => {
    const attrs = Object.keys(values);
    return attrs
        .map((attr) => `${attr}=${encodeURIComponent(values[attr])}`)
        .sort();
};

const getTargetingCookies = (cookiesMap:any) => Object.keys(cookiesMap).filter((cookie) => cookie.startsWith("bx_userAttributes"));

export function getTargetingValues(path:any) {
    return path.sort().reduce((acc:any, segment:any) => {
        const [key, value] = segment.split("=");
        if (key) {
            return Object.assign(Object.assign({}, acc), { [key]: decodeURIComponent(value) });
        }
        return acc;
    }, {});
};

export function getPersonalizedRewrite(pathname: string, cookies: Record<string, string>, country: string, experiencePair: string) {
    const attributes = getTargetingCookies(cookies);
    const values = attributes.reduce((acc, cookie) => {
        const value:string = cookies[cookie];
        const key = cookie.split("bx_userAttributes_")[1];
        return Object.assign(Object.assign({}, acc), (typeof value === 'string' && { [key]: value }));
    }, {});
    if (Object.keys(values).length > 0 || country) {
        return `${pathname};${experiencePair};${getUrlSegments(Object.assign(values, { country })).join(";")}`;
    }
    return false;
}

export function getUserAttribute(name:string, path:string) {
    if (name && path) {
        const attr = path.split(';');
        let attrValue = '';
        attr.forEach((part) => {
            const pathAttr = part.split('=');
            if (pathAttr[0] === name) {
                attrValue = pathAttr[1];
            }
        });

        return attrValue;
    }

    return '';
}

export async function getPersonalizedPagePath(friendlyId:string, path:string, cookies:any, country:string, browserId?:string):Promise<PersonalizedPath> {
    let response:PersonalizedPath = {};
    const outputValue = cookies[`bx_userAttributes_${friendlyId}`];
    if (browserId) {
        await getDynamicData('en', friendlyId, browserId).then((content: DynamicData) => {
            const result = content && content.decisionValues ? content.decisionValues.filter((i) => { return i.decisionType === 'programmable'})[0].outputValue : ''
            if (result) {    
                response[friendlyId] = result;
                response.path = getPersonalizedRewrite(
                    path,
                    cookies,
                    country,
                    `${friendlyId}=${response[friendlyId]}`
                ) as string;

                return response;
            }      
            return response;
        });
    } else if (outputValue) {
        response[friendlyId] = outputValue;
        response.path = getPersonalizedRewrite(
            path,
            cookies,
            country,
            `${friendlyId}=${response[friendlyId]}`
        ) as string;

        return response;
    }
   
    return response;
}

BoxeverApiService.ts

import BoxeverServiceConfig from './BoxeverApiServiceConfig';

// ***** TYPES *****

type GuestRef = string;

interface GuestRefResponse {
  guestRef: GuestRef;
}


// ***** API *****

const CDP_CLIENT_KEY = process.env.CDP_CLIENT_KEY || '';
const CDP_API_TARGET_ENDPOINT = process.env.CDP_API_TARGET_ENDPOINT || '';
const CDP_API_ENDPOINT = process.env.CDP_API_ENDPOINT || '';
export const isCdpConfigured = !!CDP_CLIENT_KEY && !!CDP_API_TARGET_ENDPOINT;

// ****************************************************************************
// Delaying calls is required due to some race conditions where a guest
// identification call was executed before the Boxever library had finished
// initializing.
// ****************************************************************************

function callFlows(flowConfig: Record<string, unknown>) {
  return new Promise(function (resolve, reject) {
    try {
      boxeverGet('callFlows', flowConfig)
      .then((result) => { resolve(result) })
      .catch((error) => console.log(error));      
    } catch (err) {
      reject(err);
    }
  });
}

// ****************************************************************************
// Gets the current guest ref by calling a CDP flow.
// This is a workaround needed when an anonymous guest identify itself as a
// previously known guest, CDP is merging the 2 guests. The browser keeps the
// anonymous guest ref. From that moment, calls to the authenticated CDP APIs
// like get/set for data extensions are failing when using this old anonymous
// guest ref. These calls require the previously known guest ref which is
// returned by our flow.
// ****************************************************************************
export function getGuestRef(): Promise<GuestRefResponse> {
  return callFlows({
    friendlyId: 'get_guestref',
  }) as Promise<GuestRefResponse>;
}

export async function getBrowserId() {
  let browserId = '';
  const url = `${CDP_API_TARGET_ENDPOINT}/browser/create.json?client_key=${BoxeverServiceConfig.clientKey}?message={}`;
  const response = await fetch(url, { headers: {
    'Content-Type': 'application/json',
  } });
  response.json().then((response) => { browserId = response.ref || '' }).catch((error) => console.log(error));
  return browserId;
}

async function boxeverGet(action: string, payload?: Record<string, unknown>): Promise<unknown> {  
  let message = {...BoxeverServiceConfig, ...payload};
  const url = `${CDP_API_ENDPOINT}/${action}?message=${JSON.stringify(message)}`;
  const response = await fetch(url, { headers: {
    'Content-Type': 'application/json',
  } });
  return response.json().catch((error) => console.log(error));      ;
}

export interface DynamicData {
  decisionValues: [
    {
      decisionType: string,
      trackingUrl: string,
      decisionName: string,
      outputName: string,
      outputValue: string,
    }
  ],
  decisionOffers: [
    {
      ref: string,
      name: string,
      description: string,
      status: string,
      attributes: {
        link: string,
        name: string,
        imageUrl: string
      }
    }
  ]
}

export interface UserAttributes {
  urlPath:string,
  countr:string,
  browserId:string
}

export interface CreateResponse {
  status: string,
  version: string,
  client_key: string,
  ref: string,
  customer_ref: string
}

export interface PersonalizedPath {
  [key: string]: any
}

export function getDynamicData(
  language: string,
  friendlyId:string,
  browserId: string
): Promise<DynamicData> {
 
  return callFlows({
    friendlyId: friendlyId,
    language: language,
    browserId: browserId
  }) as Promise<DynamicData>;
}

BoxeverApiServiceConfig.ts

const POINT_OF_SALE = process.env.CDP_POINT_OF_SALE;
const CURRENCY = 'USD';
const CDP_CLIENT_KEY = process.env.CDP_CLIENT_KEY || '';

const BoxeverApiServiceConfig = {
  channel: 'WEB',
  clientKey: CDP_CLIENT_KEY,
  pointOfSale: POINT_OF_SALE,
  currencyCode: CURRENCY
};

export default BoxeverApiServiceConfig;



Friday, March 25, 2022

Deploying Sitecore Next.js application to Sitecore Managed Cloud with containers and Vercel

Introduction

Many of you have probably attended User Groups where Nick Wesselman showed how to go about working with Sitecore JSS Next.js SDK and deploying it to Vercel where Vercel was pointing to a local environment. If you didn’t, you might have seen the videos of his presentations in Sitecore YouTube channel.

If you haven’t seen either of them, here are the videos with Nick’s presentations:

·         Sitecore Headless Development with Next.js with Nick Wesselman and Thomas Desmond (https://youtu.be/3AQYkgm_10s)

·         Walkthrough: Deploying JSS Next.js apps to Vercel (https://doc.sitecore.com/xp/en/developers/hd/190/sitecore-headless-development/walkthrough--deploying-jss-next-js-apps-to-vercel.html)

You can also find more information about Next.js SDK at https://doc.sitecore.com/xp/en/developers/hd/190/sitecore-headless-development/sitecore-jss-for-next-js-differences-from-jss-for-react,-angular,-and-vue-js.html

 On one of my recent projects, I had to almost the same that Nick did in his demos, but with Sitecore instance being in Sitecore Managed Cloud with containers instead of local developer machine.

I am not going to repeat Nick’s presentations. I’ll focus on area where there are differences.

Sitecore Managed Cloud with containers

If you haven’t worked with Sitecore Managed Cloud with containers (MCC) yet, you can learn more about what it is and how to work with it on Sitecore documentation site at https://doc.sitecore.com/xp/en/developers/102/managed-cloud/introduction-to-managed-cloud.html

There are a couple of things to keep in mind when you are deciding whether MCC is right for your project or not. Sitecore MCC doesn’t support multiple regions as Sitecore Support informed us. Also, you won’t be able to add your own repository to DevOps along side the application and infrastructure Sitecore MCC repositories. This is also according to Sitecore support.

If you are working with a solution that doesn’t require multiple regions on won’t need a CD role and will only be served from Vercel, MCC might be suitable for that kind of solution. Vercel is multiregional and if you are using APIs like Edge, you most likely won’t need CD.

Application repository changes

As indicated in Sitecore MCC documentation, you’ll have to build your own Docker images. For this project I added JSS and SPE roles to my CM, CD and MsSql-Init images. The new images were uploaded to Azure Sitecore MCC container registry. The references to the new CM and CD images were updated in application repository /config/docker-images/docker-images.json. I added a new line for my custom mssql-init job. The name had to be added without dashes or spaces for configurations to work properly.



In addition to that my custom mssql-init job had to be added to the templates folder where I referenced the image from the docker-images.json above.



The steps for the new job had to be added to the init.yaml file as well.



The details you can find in Sitecore documentation at https://doc.sitecore.com/xp/en/developers/102/managed-cloud/walkthrough--adding-the-jss-module.html

There was one issue with Sitecore Layout Service access that we ran into. When Vercel tried to connect to Azure MCC instance passing the sc_apikey header, the header was being stripped out. It turned out that it is related to ingress controller. You can find more information on this default functionality at https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/?highlight=disappearing%20http%20headers#missing-disappearing-http-headers

Sitecore Support team provided a fix for this issues that we were able to implement. In this fix there was a new custom-nginx folder with the folder structure you see below. 



In addition to that a reference to custom-nginx needed to be added to the root main.yaml file.

 


After all the configurations are done, checked in into a new branch, and the branch is merged into the master branch, the application pipeline starts building and deploying to Azure. After deployment is successfully completed, you should see SPE and JSS modules in your CM.

Vercel configurations

Nick is going into a lot of details on how to configure a deployment in Vercel, so I am not going to cover that. I would like to mention just the areas that I had to change.

For more information regarding configurations that you need to have in Vercel deployments, see https://doc.sitecore.com/xp/en/developers/hd/190/sitecore-headless-development/walkthrough--deploying-jss-next-js-apps-to-vercel.html

Sitecore Experience Editor configurations

I used the same Sitecore Containers for Next.js template for this project as Nick did, so the file structure I am referring to is the same as in Nick’s demos.

In a Sitecore patch file that contains the JavaScript Services definition I updated the configuration for serverSideRenderingEngineEndpointUrl and serverSideRenderingEngineApplicationUrl attributes. Both of these variables were added to Azure Key Vault and are being used in ASK pod creation.

In Azure MCC the values for both RENDERING_HOST_URI and RENDERING_HOST_PUBLIC_URI are the same while in local environment the value for the first attribute points to your local rendering host container - https://rendering:3000.


 Preview mode

When you configure a deployment in Vercel, you supply the reference to your solution repository. It can be GitHub, GitLab or other source control systems that Vercel supports. When you check in a change into any branch in your repository, a new Vercel deployment starts. The master (or a different branch that you configure as the main branch) gets deployed to Production environment. All other branch deployments produce a Preview Vercel url where you can validate your work before it gets deployed to production.

Conclusion

Next.js with Vercel has been a very promising and popular architectural approach. The solution becomes even easier to configure and maintain with introduction of Sitecore Managed Cloud with containers.