Source: server/PageRenderer.js

/* global WP_DEFINE_DEVELOPMENT, WP_DEFINE_ASSETS_JSON_LOCATION, WP_DEFINE_VERSIONING_PATH, WP_DEFINE_IS_NODE */
import convertHrtime from 'convert-hrtime';
import { renderToString, renderToStaticMarkup } from 'react-dom/server';
import { match, RouterContext, createMemoryHistory } from 'react-router';
import { prepareComponents, setInitMode, MODE_INIT_SELF } from 'react-redux-component-init';
import { syncHistoryWithStore } from 'react-router-redux';
import { Provider } from 'react-redux';
import debugLib from 'debug';
import React from 'react';
import promisify from 'es6-promisify';
import fs from 'fs';
import path from 'path';
import intercept from 'intercept-stdout';
import stringify from 'qs/lib/stringify';
import pickBy from 'lodash/pickBy';
import mapValues from 'lodash/mapValues';
import flatten from 'lodash/flatten';
import hotHtml from 'hot-callback-loader?export=default!./components/Html';
import Configuration from '../app/config/Configuration';
import createStoreInstance from './util/createStoreInstance';
import { tempAddDebugEnvNamespace, restoreDebugEnv } from './util/tempPatchDebugEnv';
import serverConfig from './server.configdefinitions';
import processQueryRouting from '../app/util/QueryRouting/processQueryRouting';
import serviceConfig from '../app/config/service.configdefinitions';
import { setInitialUserState, setUserPermissionState } from '../app/actions/authenticationActions';
import secureReduxState from './util/secureReduxState';
import {
  WEB_VIEW_REDIRECT_URI,
  WEB_VIEW_FREE_2_GO_REDIRECT_URI,
} from './util/AuthenticationHelper/constants';
import setupInjects from '../app/util/setupInjects';
import parseRouteRequirements from '../app/util/route-requirements/parseRouteRequirements';
import processRequirements from '../app/util/route-requirements/processRequirements';
import { parseResponseQuery } from './actions/responseActions';
import {
  enablePerformanceMeasurements,
  setWebViewCookie,
  setFree2GoApp,
} from '../app/actions/configActions';
import QueryRoutingProvider from '../app/util/QueryRouting/QueryRoutingProvider';
import RedirectError from '../app/util/RedirectError';
import getProtocol from '../app/util/getProtocol';
import { getError, hasErrors } from '../app/reducers/errorReducer';
import { userAccountSelector } from '../app/selectors/userAccountSelectors';
import RenderMode from '../app/data/enum/RenderMode';
import UserRole from '../app/data/enum/UserRole';
import { getPropOnDeepestRoute } from '../app/util/routeUtils';
import { handleRuntimeError } from '../app/actions/errorActions';
import { getClient } from '../app/util/raven/raven-client';
import prependWebHost from '../app/util/prependWebHost';
import { getUserPermissionStoredData } from '../app/util/userPermissionStateUtil';
import { INITIAL_USER_STATE_COOKIE } from '../app/data/AuthConstants';

const appInsights = require('applicationinsights');

const debug = debugLib('SlimmingWorld:PageRenderer');
const matchPromisified = promisify(match, { multiArgs: true });
const HTTP_MOVED_TEMPORARILY = 302;

const prepareLogMetric = label => ({
  time: process.hrtime(),
  label,
});

const logMetric = prepared => {
  const timing = convertHrtime(process.hrtime(prepared.time)).milliseconds;

  debug(`logMetric :: ${prepared.label} : ${timing}`);

  process.env.APPINSIGHTS_METRICS_ENABLED &&
    appInsights &&
    appInsights.defaultClient &&
    appInsights.defaultClient.trackMetric({
      name: prepared.label,
      value: timing,
    });
};

const checkWebView = req => {
  const returnUrl = req.query.ReturnUrl;

  if (!returnUrl) {
    return false;
  }

  const params = returnUrl.split('?');
  const param = new URLSearchParams(params[1]).get('redirect_uri');
  return (
    (req.cookies && !!req.cookies.isWebView) ||
    (req.headers && !!req.headers['x-webview']) ||
    param === WEB_VIEW_REDIRECT_URI ||
    param === WEB_VIEW_FREE_2_GO_REDIRECT_URI
  );
};

const checkFree2GoRedirectUri = req => {
  const returnUrl = req.query.ReturnUrl;

  if (!returnUrl) {
    return false;
  }

  const params = returnUrl.split('?');
  const param = new URLSearchParams(params[1]).get('redirect_uri');

  return param === WEB_VIEW_FREE_2_GO_REDIRECT_URI;
};

const checkInitialUserState = req => req.cookies && req.cookies[INITIAL_USER_STATE_COOKIE];

const getComponentForRoute = (nextState, route) => {
  if (route.component || route.components) {
    return Promise.resolve(route.component);
  }
  const getComponent = route.getComponent;
  if (getComponent) {
    return new Promise(resolve => {
      const componentReturn = getComponent.call(route, nextState, (err, component) => {
        resolve(component);
      });
      if (componentReturn && componentReturn.then && typeof componentReturn.then === 'function') {
        return componentReturn.then((err, component) => resolve(component)).catch(() => resolve());
      }
      return undefined;
    });
  }

  return Promise.resolve();
};

const getPageComponents = (renderProps, routes) =>
  Promise.all(
    routes.map(route => route.component || getComponentForRoute(renderProps, route)),
  ).then(components => components.filter(_ => _));

class AccessDeniedError extends Error {}

/**
 * Helper class that pre-renders markup for a given route.
 */
class PageRenderer {
  Routes = null;
  reduxReducer = null;
  environmentConfig = null;
  assetsManifest = null;
  oidcDiscovery = null;
  buildInfo = null;

  constructor() {
    hotHtml(Html => (this.Html = Html));
  }

  /**
   * Returns the parsed assets manifest that outputs during the web build. On development,
   * we will read the assets json file on every call. Otherwise, this object will be cached.
   *
   * @returns {object}
   */
  getAssetsManifest = () => {
    if (!this.assetsManifest || WP_DEFINE_DEVELOPMENT) {
      try {
        const assetsFile = fs.readFileSync(path.join(__dirname, WP_DEFINE_ASSETS_JSON_LOCATION));
        this.assetsManifest = JSON.parse(assetsFile);
      } catch (error) {
        throw error;
      }
    }

    return this.assetsManifest;
  };

  /**
   * A JSON object containing details of the current build.
   * @param buildInfo An object providing details of the current build.
   */
  setBuildInfo = buildInfo => (this.buildInfo = buildInfo);

  /**
   * Set the react-router configuration component that should be used to match incoming
   * requests.
   * @param Routes The react-router configuration component
   */
  setReactRoutes = Routes => (this.Routes = processQueryRouting(Routes));

  /**
   * Sets the main reducer to be used when creating new store instances.
   * @param reducer A redux reducer function
   */
  setReduxReducer = reducer => (this.reduxReducer = reducer);

  /**
   * Sets the OidcDiscovery instance used to get the public key for validating tokens
   * @param oidcDiscovery An instance of OidcDiscovery util
   */
  setOidcDiscovery = oidcDiscovery => (this.oidcDiscovery = oidcDiscovery);

  /**
   * Sets the gateway
   *
   * @param environmentConfig
   */
  setSetupInjects = environmentConfig =>
    setupInjects({
      config: JSON.parse(JSON.stringify(environmentConfig)),
    });

  /**
   * Sets the environmentConfig instance to set the configurations settings
   * @param environmentConfig
   * @returns {*}
   */
  setEnvironmentConfig = environmentConfig => (this.environmentConfig = environmentConfig);
  /**
   * Handles incoming request from the express app. Will use renderPage() to do the actual
   * page rendering. Catches any errors that occur and calls express' res.redirect()
   * or next() accordingly.
   * @param {Object} req The express request object
   * @param {Object} res The express response object
   * @param {function} next The express next callback
   * @param {Object} options Options object
   * @param {AuthenticationHelper} [options.authHelper=null] If authentication is enabled for this
   * server, an instance of AuthenticationHelper will be passed so authentication can be performed
   * on routes that require login
   * @param {function} [options.afterStoreHook=null] Optional hook function that is called after
   * the store is created, so it can dispatch actions or subscribe to changes from outside this
   * function. The store will be passed as the first and only parameter to the hook function.
   * @param {boolean} [debugMode=false] If debug mode is enabled, will run respond with a json
   * with additional metadata instead of the response content as HTML.
   */
  async handleRequest(
    req,
    res,
    next,
    { authHelper = null, afterStoreHook = null } = {},
    debugMode = false,
  ) {
    const debugProps = {};
    let unhookIntercept = null;

    if (debugMode) {
      tempAddDebugEnvNamespace();
      debugProps.errorBuffer = [];
      debugProps.logBuffer = [];
      const startTime = process.hrtime();
      debugProps.times = [{ time: startTime }];
      debugProps.pushTime = label =>
        debugProps.times.push({
          time: process.hrtime(debugProps.times[0].time),
          label,
        });

      unhookIntercept = intercept(
        mssg => {
          debugProps.logBuffer.push(mssg);
        },
        mssg => {
          debugProps.errorBuffer.push(mssg);
        },
      );

      // eslint-disable-next-line no-param-reassign
      res.redirect = function redirect(a1, a2) {
        const redirectPath = typeof a1 === 'number' ? a2 : a1;
        res.send({ redirect: redirectPath, success: false });
      };
    }

    let markup = null;
    try {
      markup = await this.renderPage(
        req,
        res,
        { authHelper, afterStoreHook },
        debugMode && debugProps,
      );
    } catch (e) {
      if (e instanceof RedirectError) {
        if (debugMode) {
          restoreDebugEnv();
        }
        debug(`RedirectError thrown. Redirecting request to "${e.path}"`);
        res.redirect(e.path);
      } else if (e instanceof AccessDeniedError) {
        if (debugMode) {
          restoreDebugEnv();
        }
        res.status(403).send({
          success: false,
          error: {
            message: e.message,
          },
        });
        return;
      } else {
        if (debugMode) {
          restoreDebugEnv();
          res.send({
            success: false,
            error: {
              message: e.message || (e.error && (e.error.message || e.error.error)) || e,
              stack: e.stack || (e.error && e.error.stack) || null,
            },
          });
          return;
        }

        next(e);
      }
    }

    if (debugMode) {
      restoreDebugEnv();
    }
    if (unhookIntercept) {
      unhookIntercept();
    }

    if (markup) {
      if (debugMode) {
        debugProps.duration = process.hrtime(debugProps.times[0].time);
        res.send({
          success: true,
          markup,
          ...debugProps,
        });
      } else {
        res.set({
          'Content-Type': 'text/html; charset=utf-8',
          'Content-Security-Policy': "frame-ancestors 'self'",
          'X-Frame-Options': 'SAMEORIGIN',
        });

        debug('sending markup');
        res.send(markup);
      }
    }
  }

  /**
   * Handles incoming request from the express app. Will match the requested route using
   * react-router and if a match has been found, render the markup for that route and
   * respond it back to the client.
   * @param {Object} req The express request object
   * @param {Object} res The express response object
   * @param {Object} options Options object
   * @param {AuthenticationHelper} [options.authHelper=null] If authentication is enabled for this
   * server, an instance of AuthenticationHelper will be passed so authentication can be performed
   * on routes that require login
   * @param {function} [options.afterStoreHook=null] Optional hook function that is called after
   * the store is created, so it can dispatch actions or subscribe to changes from outside this
   * function. The store will be passed as the first and only parameter to the hook function.
   * @param {Object} [debugProps=null] If debug mode is enabled, this will be an object where
   * additional diagnostics will be pushed onto
   */
  async renderPage(req, res, { authHelper = null, afterStoreHook = null }, debugProps = null) {
    // wait for OIDC discovery
    if (!this.oidcDiscovery) {
      throw new Error(
        'OidcDiscovery instance needs to be initialized before requests can be handled',
      );
    }
    await this.oidcDiscovery.discoverComplete;

    debugProps && debugProps.pushTime('route matching');
    const routeMatchingMetric = prepareLogMetric('route matching');
    // perform main route matching
    const renderProps = await this.matchRoutes(req, res);
    if (!renderProps) return null;

    // get authentication object with subscriptionType and accountState
    let authentication;
    if (serviceConfig.useServerAuthentication && authHelper) {
      authentication = await authHelper.getAuthentication(req, res);
    }
    logMetric(routeMatchingMetric);

    debugProps && debugProps.pushTime('initialize store');
    const initializeStoreMetric = prepareLogMetric('initialize store');
    // initialize history and store instance
    const memoryHistory = createMemoryHistory(req.url);
    const store = await createStoreInstance(
      this.reduxReducer,
      {
        jar: req.cookies,
        set: (name, value, options) => {
          if (res.headersSent) {
            debug('warning: trying to persist state but headers already sent');
          } else {
            res.cookie(name, value, options);
          }
        },
        clear: (name, options) => {
          if (res.headersSent) {
            debug('warning: trying to clear state but headers already sent');
          } else {
            res.clearCookie(name, options);
          }
        },
      },
      memoryHistory,
      authentication,
    );
    syncHistoryWithStore(memoryHistory, store);
    if (afterStoreHook) {
      await afterStoreHook(store, req, this.environmentConfig);
    }

    if (debugProps) {
      store.dispatch(enablePerformanceMeasurements());
    }

    // check the if the 'isWebView' cookie exists, then update its value in the app state
    if (checkWebView(req)) {
      store.dispatch(setWebViewCookie(true));
    }

    // Check the Free2Go redirect uri
    // If the uri is from the Free2Go app - set the state to be true
    if (checkFree2GoRedirectUri(req)) {
      store.dispatch(setFree2GoApp(true));
    }

    if (checkInitialUserState(req)) {
      store.dispatch(setInitialUserState(req.cookies[INITIAL_USER_STATE_COOKIE]));
    }

    // check the location.query for a payment or logout response
    await store.dispatch(
      parseResponseQuery(
        renderProps.location.query,
        res,
        store.getState()?.config?.environmentConfig,
      ),
    );
    logMetric(initializeStoreMetric);

    debugProps && debugProps.pushTime('authentication');
    const authenticationMetric = prepareLogMetric('authentication');
    // handle authentication
    await PageRenderer.handleAuthenticationForRequest(req, res, authentication, store);

    if (debugProps) {
      const account = userAccountSelector(store.getState());
      if (!account || !account.roles.includes(UserRole.DEVELOPER)) {
        throw new AccessDeniedError('access denied');
      }
    }
    logMetric(authenticationMetric);

    debugProps && debugProps.pushTime('query routing');
    const queryRoutingMetric = prepareLogMetric('query routing');
    // handle query routing (e.g. ?modal=weighin)
    const queryRoutingConfigs = PageRenderer.getQueryRoutingConfigs(renderProps);
    const routeQueryParams = pickBy(
      renderProps ? renderProps.location.query : {},
      (value, param) => queryRoutingConfigs[param],
    );
    // initialize an object with empty query routing results
    // we will pass this object to <QueryRoutingProvider /> later on
    const queryRoutingResults = mapValues(queryRoutingConfigs, () => null);
    // initialize an array with all routing results. we will add the query routing result later
    const allRoutingResults = [{ renderProps, queryParam: null }];
    for (const queryParam of Object.keys(routeQueryParams)) {
      const queryParamValue = routeQueryParams[queryParam];
      const queryRoutingConfig = queryRoutingConfigs[queryParam];

      if (queryRoutingConfig.routes && queryParamValue) {
        const [queryRedirect, queryRenderProps] = await matchPromisified({
          routes: queryRoutingConfig.routes,
          location: queryParamValue,
        });

        // If the query result is a redirect, perform a redirect in express
        if (queryRedirect) {
          debug(
            `react-router matched a redirect on query ${queryParam}=${queryParamValue}. Redirecting query to ${queryRedirect.pathname}`,
          );
          const newQuery = stringify(
            { ...req.query, [queryParam]: queryRedirect.pathname },
            { addQueryPrefix: true },
          );
          res.redirect(HTTP_MOVED_TEMPORARILY, `${req.path}${newQuery}`);
          return null;
        }

        // store the query routing result in the queryRoutingResults map
        queryRoutingResults[queryParam] = queryRenderProps;
        allRoutingResults.push({ renderProps: queryRenderProps, queryParam });
      }
    }
    logMetric(queryRoutingMetric);

    debugProps && debugProps.pushTime('route requirements');
    const routeRequirementsMetric = prepareLogMetric('route requirements');
    const meetsRequirements = await PageRenderer.processRouteRequirements(
      res,
      req,
      allRoutingResults,
      store,
      authHelper,
      this.environmentConfig,
    );
    if (!meetsRequirements) {
      return null;
    }
    logMetric(routeRequirementsMetric);

    // TODO: This only handles 404 status code, do we also want to handle other types?
    // TODO: 'public' will use a catch-all route for content, should check for API errors here
    if (renderProps.routes && renderProps.routes.some(({ status }) => status === 404)) {
      res.status(404);
    }

    const renderMode = store.getState().config.renderMode;

    let markup;

    debug(`Rendering on ${renderMode}`);
    if (renderMode === RenderMode.CLIENT) {
      // set the initMode. We normally do this on the client but we do this early because we're
      // skipping the server render
      store.dispatch(setInitMode(MODE_INIT_SELF));
      // Render markup without content
      debugProps && debugProps.pushTime('render shell markup');
      markup = await this.renderMarkup(
        req,
        renderProps,
        null,
        secureReduxState(store.getState()),
        undefined,
      );
    } else {
      const allRoutes = flatten(
        allRoutingResults.map(result => (result.renderProps && result.renderProps.routes) || []),
      );

      debugProps && debugProps.pushTime('prepare components');
      const prepareComponentsMetric = prepareLogMetric('prepare components');

      const components = await getPageComponents(renderProps, allRoutes);
      try {
        await store.dispatch(prepareComponents(components, renderProps));
      } catch (e) {
        if (e instanceof RedirectError) {
          throw e;
        }

        debug('Error during prepareComponents. Triggering error page');
        debug(e);
        store.dispatch(handleRuntimeError(e));
        // set initMode to force client to get its own data
        store.dispatch(setInitMode(MODE_INIT_SELF));
      }

      logMetric(prepareComponentsMetric);

      const app = (
        <QueryRoutingProvider queryRoutingResults={queryRoutingResults}>
          <Provider store={store}>
            <RouterContext {...renderProps} />
          </Provider>
        </QueryRoutingProvider>
      );

      // get the state that is used to render React markup
      const stateBeforeRender = store.getState();
      if (debugProps) {
        // eslint-disable-next-line no-param-reassign
        debugProps.performance = stateBeforeRender.performance;
      }

      // render everything
      debugProps && debugProps.pushTime('render markup');

      try {
        const renderMarkupMetric = prepareLogMetric('render markup');
        const contentMarkup = renderToString(app);
        logMetric(renderMarkupMetric);

        // output actual HTML with rendered content and current server state
        debugProps && debugProps.pushTime('render shell markup');
        const renderShellMarkupMetric = prepareLogMetric('render shell markup');
        markup = await this.renderMarkup(
          req,
          renderProps,
          contentMarkup,
          secureReduxState(stateBeforeRender),
        );
        logMetric(renderShellMarkupMetric);
      } catch (e) {
        debug('Error during react render. Triggering error page');
        debug(e);
        const ravenClient = getClient();

        if (ravenClient) {
          ravenClient.captureException(e);
        }

        store.dispatch(handleRuntimeError(e));
        // set initMode to force client to get its own data
        store.dispatch(setInitMode(MODE_INIT_SELF));

        const renderShellMarkupMetric = prepareLogMetric('render shell markup');
        markup = await this.renderMarkup(
          req,
          renderProps,
          null,
          secureReduxState(store.getState()),
        );
        logMetric(renderShellMarkupMetric);
      }
    }

    debug('Markup rendered. Sending response...');

    // When global errors are in the state, output status code
    if (hasErrors(store.getState().error)) {
      const error = getError(store.getState().error);
      res.status(error.statusCode || 500);
    }

    // Pick deepest route with a maxAge prop
    const lastRouteWithMaxAge =
      renderProps && renderProps.routes.filter(route => typeof route.maxAge !== 'undefined').pop();

    const maxAge = lastRouteWithMaxAge?.maxAge ?? serverConfig.maxAge;

    if (typeof maxAge !== 'undefined') {
      if (maxAge === 0) {
        res.set({
          'Cache-Control': `private, max-age=${maxAge}`,
        });
      } else {
        res.set({
          'Cache-Control': `public, max-age=${maxAge}`,
        });
      }
    }

    return `<!DOCTYPE html>${markup}`;
  }

  static async processRouteRequirements(
    res,
    req,
    allRoutingResults,
    store,
    authHelper,
    environmentConfig,
  ) {
    for (const { renderProps, queryParam } of allRoutingResults) {
      if (renderProps) {
        const routeRequirements = parseRouteRequirements(renderProps.routes);
        debug(
          `Processing routeRequirements for ${
            queryParam ? `queryParam "${queryParam}"` : 'main routing'
          }`,
        );

        const { accountState } = getUserPermissionStoredData({
          getState: store.getState,
          req,
        });
        const meetsRequirements = await processRequirements(
          routeRequirements,
          {
            dispatch: store.dispatch,
            getState: store.getState,
            accountState,
            renderProps,
          },
          {
            redirect: (location_, webHost = null) => {
              const location = prependWebHost(environmentConfig, location_, webHost);

              if (queryParam) {
                const newQuery = stringify(
                  { ...req.query, [queryParam]: location },
                  { addQueryPrefix: true },
                );
                res.redirect(`${req.path}${newQuery}`);
              } else {
                res.redirect(location);
              }
            },
            redirectToLogin: () => {
              authHelper.redirectToLogin(req, res);
            },
          },
        );

        if (!meetsRequirements) {
          return false;
        }
      }
    }

    return true;
  }

  /**
   * Checks if authentication is enabled. If so, will get the current authentication state and
   * store any auth tokens found in redux state by dispatching `setAuthTokens`.
   * @private
   * @param {Object} req The express request object
   * @param {Object} res The express response object
   * @param {AuthenticationHelper} [authHelper] An instance of AuthenticationHelper
   * @param store The redux store to store authentication tokens in
   * @returns {Promise} A Promise that resolves when getting authentication has completed
   */
  static async handleAuthenticationForRequest(req, res, authentication, store) {
    if (
      !(serviceConfig.useClientAuthentication || serviceConfig.useServerAuthentication) ||
      !authentication
    ) {
      debug('Authentication not enabled. Skipping authentication logic.');
      return;
    }

    if (authentication) {
      debug('User permission state found.');
      store.dispatch(
        setUserPermissionState({
          ...authentication,
        }),
      );
    } else {
      debug('User not authenticated.');
    }
  }

  /**
   * Looks up all <QueryRouting> configs on the currently matched routes.
   * @param renderProps {Object} The main route matching result
   * @returns {Object} An object of query routing configurations mapped by query parameter name
   */
  static getQueryRoutingConfigs(renderProps) {
    const queryRoutingConfigs = {};
    if (renderProps) {
      renderProps.routes.forEach(route => {
        if (route.queryRoutes) {
          Object.keys(route.queryRoutes).forEach(queryRoutDef => {
            queryRoutingConfigs[queryRoutDef] = route.queryRoutes[queryRoutDef];
          });
        }
      });
    }
    return queryRoutingConfigs;
  }

  /**
   * Performs a react-router match() call against the incoming express request. Will execute
   * a redirect if a redirect is returned from the match.
   * @param {Object} req The express request object
   * @param {Object} res The express response object
   * @returns {Promise} A Promise that resolves with the renderProps properties if the route was
   * matched. Returns null otherwise.
   */
  async matchRoutes(req, res) {
    const [redirectLocation, renderProps] = await matchPromisified({
      routes: this.Routes,
      location: req.url,
    });
    debug(`Route matching complete for ${req.url}`);

    if (redirectLocation) {
      debug(
        `react-router matched a redirect. Redirecting to ${redirectLocation.pathname}${redirectLocation.search}`,
      );
      res.redirect(
        HTTP_MOVED_TEMPORARILY,
        `${redirectLocation.pathname}${redirectLocation.search}`,
      );
      return null;
    }

    if (!renderProps) {
      debug("react-router didn't match any route. Showing a server 404");
      res.sendStatus(404);
      return null;
    }

    return renderProps;
  }

  /**
   * Renders the Html component and returns it as a string of HTML markup.
   * @param contentMarkup If provided, will render the given content inside the main container
   * of the Html component.
   */
  renderMarkup = (req, renderProps, contentMarkup = null, reduxState = null) => {
    const assetsManifest = this.getAssetsManifest();
    const mainBundleAssets = [
      assetsManifest.manifest,
      assetsManifest.vendor,
      assetsManifest.client,
    ];

    // detect if browser-sync should be enabled
    const injectWeinreBrwosersyncHost = req.cookies.swdevicedebugging || false;

    // get the host from the config corresponding to the microservice we are running
    const host = this.environmentConfig?.web?.[serviceConfig.webHost]?.host?.replace(
      /https?:\/\//gi,
      '',
    );
    const protocol = getProtocol(req);

    // Please note: trailing slash is stripped from canonical url
    const canonical = `${protocol}://${host}${(req.originalUrl || req.url).replace(/\/$/, '')}`;

    // Temp fix why we figure out the node env is set to none
    const indexSEO =
      req.headers.host === 'www.slimmingworld.co.uk' || req.headers.host === 'www.slimmingworld.ie';

    // find page title on main routes
    const pageTitle = getPropOnDeepestRoute(renderProps.routes, 'title');
    if (typeof pageTitle === 'undefined') throw new Error('Page title is missing and required');

    let seo = {};

    if (pageTitle) {
      seo = {
        metaTitle: pageTitle.replace('{pageTitle}', Configuration.pageTitle),
      };
    }

    const stateSeo = reduxState && reduxState.seo;
    if (stateSeo && stateSeo.metaTitle) {
      // append the default title behind the custom page title
      stateSeo.metaTitle += ` | ${Configuration.pageTitle}`;
      seo = {
        ...seo,
        ...stateSeo,
      };
    }

    const serviceScripts = serviceConfig.scripts || [];

    return renderToStaticMarkup(
      <this.Html
        environmentConfig={this.environmentConfig}
        markup={contentMarkup}
        buildInfo={this.buildInfo}
        reduxState={reduxState}
        injectWeinreBrwosersyncHost={injectWeinreBrwosersyncHost}
        mainBundleAssets={mainBundleAssets}
        scripts={[].concat(serviceScripts)}
        canonical={canonical}
        indexSEO={indexSEO}
        seo={seo}
      />,
    );
  };
}

export default PageRenderer;