The Frontastic session

Frontastic provides a session mechanism to its extensions. Any action extension can read and write the session, while other extension types can only read the session.

Reading the session works through the property Request.sessionData, a plain JavaScript object of arbitrary (serializable) data. Writing the session can be achieved by setting Response.sessionData.

📘

The Frontastic session is a mechanism meant for server functions. Frontastic components can't access the session directly (see later in this article). If you only need to conserve data for frontend use, it might be easier to directly use cookies, local storage, or a similar mechanism.

Write the session

The mechanics to use the session in an action schematically works as follows:

export default {
  actions: {
    someAction: async (
      request: Request,
      actionContext: ActionContext
    ): Response => {
      const sessionData = {
        desiredSessionKey: {},
        ...(request.sessionData || {}),
      };

      // ...
      sessionData.desiredSessionKey = 42;
      // ...

      return {
        body: actualActionResult,
        statusCode: 200,
        sessionData,
      } as Response;
    },
  },
};

As a first step, the action code ensures to have properly initialized sessionData. During the execution of the action, the session data is updated (in this case, the value 42 is stored). Eventually, the sessionData is returned as part of the Response.

📘

If you return sessionData from an action, you need to maintain all session information that you didn't touch. If you only return the session key written by the action, all other session data will be lost. Frontastic doesn't perform merge on the session but only stores the returned sessionData.

A complete example looks like this:

export const {
  'actions':
    'star-wars': {
      character: async (request: Request, actionContext: ActionContext): Promise<Response> => {
        const sessionData = {
          moviePreferences: {},
          ...(request.sessionData || {}),
        };

        if (!request.query.search) {
          return {
            body: JSON.stringify([]),
            statusCode: 200,
            sessionData,
          };
        }

        return await axios
          .get('https://swapi.dev/api/people/?search=' + request.query.search)
          .then((response) => {
            if (response.data.results[0]) {
              sessionData.moviePreferences = mergePreferences(
                sessionData.moviePreferences || {},
                calculatePreferences(response.data.results[0].films),
              );
            }

            return {
              body: JSON.stringify(response.data),
              statusCode: 200,
              sessionData,
            } as Response;
          })
          .catch((reason) => {
            return {
              body: reason.body,
              statusCode: 500,
            };
          });
      },
    },
  },
};

This example is an extended version of the action extension example we used in the developing an action extension article. In addition to searching for characters, the action now stores statistics about the Star Wars movie preferences of the user, calculated by looking at the first result of the character search.

📘

If you want to reproduce this complete example, find the code for calculating movie preference statistics at the end of this article.

Read the session

Reading the sessionData is possible in any Frontastic extension since it's part of the Request object. As could already be seen, an action extension receives the Request directly as its input. All other extension types receive the Request as part of their corresponding context data. For example, the following data-source extension can be used to read the tracked moviePreferences:

export default {
  'data-sources': {
    'example/star-wars-movie-preferences': (
      configs: DataSourceConfiguration,
      context: DataSourceContext,
    ): DataSourceResult => {
      console.log('Session data', context.request.sessionData?.moviePreferences);
      return {
        dataSourcePayload: context.request.sessionData?.moviePreferences || {},
      };
    },
  },
};

The DataSourceContext carries the Request, which in turn carries sessionData (which might be empty if no session was written before!).

📘

Direct session access in the frontend code is prohibited because the session might contain sensitive data, such as access tokens. For this reason, the session JWT token is encrypted in production. To use parts of the session in a component, you need to expose this part selectively through a data-source or action as shown in this example.

❗️

Session encryption is a pending feature and hasn't been implemented yet. It'll be delivered soon.

Once this data source is registered in Frontastic studio (see the Developing a data source extension article for more information), a Frontastic component can use the exposed part of the sessionData, for example like this:

import React from 'react';

type FavoriteMovieTasticProps = {
  data: {
    moviePreferences: {
      [episodeNumber: string]: number;
    };
  };
};

const FavoriteMovieTastic: React.FC<FavoriteMovieProps> = ({ data }) => {
  return !data.moviePreferences || data.moviePreferences.dataSource === [] ? (
    <div>Our AI did not receive enough data, yet. Please continue to use our page!</div>
  ) : (
    <div>
      Our AI detected your favorite Star Wars episode is&nbsp;
      <b>
        {
          Object.entries(data.moviePreferences.dataSource).reduce((previousValue, currentValue) => {
            return currentValue[1] > previousValue[1] ? currentValue : previousValue;
          })[0]
        }
      </b>
    </div>
  );
};

export default FavoriteMovieTastic;
{
  "tasticType": "example/star-wars-favorite-movie",
  "name": "Star Wars favorite movie",
  "icon": "star",
  "description": "A Frontastic component showing AI detected favorite movie",
  "schema": [
    {
      "name": "Data source",
      "fields": [
        {
          "label": "Movie preferences",
          "field": "moviePreferences",
          "type": "stream",
          "streamType": "example/star-wars-movie-preferences"
        }
      ]
    }
  ]
}

Caveats with session

When there are multiple actions writing to the session, the last one to finish executing wins. This might lead to unexpected behavior in non-deterministic implementations.

Consider you give your customers 50 reward points each time they add an item to the cart and 20 when they add it to the wishlist. The application stores the customer’s reward points in the sessionData and batch the updates for reward points to increase efficiency.

The updateRewardPoints function below initiates all the updates in the order the customer interacted with the application.

import React from "react";
import { fetchApiHub } from "../../../lib/fetch-api-hub";

const SimpleButtonTastic = ({ data }) => {
  function updateRewardPoints() {
    fetchApiHub("/action/examples/addFiftyRewardsPoints"); // item added to the cart
    fetchApiHub("/action/examples/addTwentyRewardsPoints"); // item added to wishlist
    fetchApiHub("/action/examples/deductTwentyRewardPoints"); // item removed from wishlist
    fetchApiHub("/action/examples/deductFiftyRewardsPoints"); // item removed from the cart
  }

  return <button onClick={updateRewardPoints}>{data.label}</button>;
};

export default SimpleButtonTastic;

In the above example, the customer should end up with the same reward points as they started with. But, because the updates are network calls and each request can take a different time to start, execute and return the final state of the session becomes un-deterministic. The diagram below shows a scenario where deductFiftyRewardsPoints takes the longest to execute.

To avoid this pitfall, you need to write deterministic code. The above example can be re-implemented using async/await to happen in a sequential and deterministic manner.

async function updateRewardPoints() {
    await fetchApiHub('/action/examples/addFiftyRewardsPoints'); // #1
    await fetchApiHub('/action/examples/deductTwentyRewardPoints'); // #2
    await fetchApiHub('/action/examples/addTwentyRewardsPoints'); // #3
    await fetchApiHub('/action/examples/deductFiftyRewardsPoints'); // #4
}

Movie preference calculation

The following code is used to calculate movie preferences in the action example we used earlier in this article:

export type MoviePreferences = {
  [key: string]: number;
};

export const calculatePreferences = (movieUrls: string[]): MoviePreferences => {
  const preferences: MoviePreferences = {};
  for (const movieUrl of movieUrls) {
    const movieNo = movieUrl.slice(-2, -1);
    if (!preferences[movieNo]) {
      preferences[movieNo] = 0;
    }
    preferences[movieNo]++;
  }
  return preferences;
};

export const mergePreferences = (first: MoviePreferences, second: MoviePreferences = {}): MoviePreferences => {
  const result = { ...first };
  for (const movieKey in second) {
    if (!result[movieKey]) {
      result[movieKey] = 0;
    }
    result[movieKey] += second[movieKey];
  }
  return result;
};

export const getPreferredMovie = (preferences: MoviePreferences): string => {
  return Object.entries(preferences).reduce(
    (previousEntry, currentEntry) => {
      return currentEntry[1] > previousEntry[1] ? currentEntry : previousEntry;
    },
    // Return episode 4 when preferences are empty
    ['4', 0],
  )[0];
};

Did this page help you?