Extending Sitecore Rendering Host to support multisite

One comment

Traditional Sitecore asp.net supports multi domain binding and it’s built to render site based on requested domain. It can easily be configured using a patch configuration as shown below,

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
    <sitecore>
        <sites>
          <site name="mysite" patch:after="site[@name='modules_website']"
            targetHostName="rhino.acme.com”
            enableTracking="true" virtualFolder="/" physicalFolder="/"
            rootPath="/sitecore/content/mysite" 
            startItem="/home" database="web" domain="extranet"
            allowDebug="true" cacheHtml="true" htmlCacheSize="50MB" registryCacheSize="0"
            viewStateCacheSize="0" xslCacheSize="25MB" filteredItemsCacheSize="10MB"
            enablePreview="true" enableWebEdit="true" enableDebugger="true"
            disableClientData="false" cacheRenderingParameters="true"
            renderingParametersCacheSize="10MB" />
        </sites>
    </sitecore>
</configuration>
Unfortunately Rendering Host though built on Asp.net core and can be hosted on IIS - it doesn't support multiple domains. In this blog, we can see how it can be customized to support multi domains or multi sites.
To checkout below steps - please clone this repository https://github.com/nkdram/MVP-Site which is actually a forked version from actual repository.

1. Setup

The primary step is to do a setup with multiple domains. I have used this repository “Sitecore MVP Site” built by Rob Earlam and few other wonderful contributors !! As it’s build using Asp.net core Rendering host and Sitecore Headless service, I’ll be using this solution to demonstrate multi-site support. The repo is well documented and can be easily cloned and run.

Once cloned, let’s make some changes to few files to add another hostname – let’s call it “regional-mvp.sc.localhost”.

  • Init.ps1 : add below line just after $renderingHost

    $regionalRenderingHost = “regional-mvp.sc.localhost”
  • .env : Add env setting as below,

    RENDERING_HOST_RegionalSite=regional-mvp.sc.localhost
  • docker-compose.override.yml : Add below traefik labels to route requests from our new host name.
     – “traefik.http.routers.regional-secure.entrypoints=websecure” 
    – “traefik.http.routers.regional-secure.rule=Host(`${RENDERING_HOST_RegionalSite}`)”     
     – “traefik.http.routers.regional-secure.tls=true”

Sites will automatically load up when the up.ps1 is run and rendering host site should be rendered in browser.

Once backend is loaded, duplicate the MVP site and let’s call it Regional-Mvp site.

Duplicate Sitecore site
MultiSite - Rendering Host Sitecore
Note : Duplicating site will not change datasource for renderings in page - Make sure to update them.
Datasource - Rendering Host

2. Extend Rendering-Host Project layer to pass-in multi-sites

We need to configure project layer to have multi-sites configured, so let’s start from “appsettings.json”. This is where multiple sites are configured – I chose to have it as dictionary entries as shown below, so it is easier to map it based on requested hostname.

{
  "Sitecore": {
    "InstanceUri": "http://mvp-cd",
    "LayoutServicePath": "/sitecore/api/layout/render/jss",
    "DefaultSiteName": "mvp-site",
    "ApiKey": "{E2F3D43E-B1FD-495E-B4B1-84579892422A}",
    "Sites": {
      "mvp.sc.localhost": "mvp-site",
      "regional-mvp.sc.localhost": "regional-mvp-site"
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft": "Information",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Now we need to pass this value to SitecoreOptions which is nothing but a serialized object based on appsetting configuration. Creating a dictionary and calling it as ‘Sites’. This will hold sites that are configured in appsettings.

 public class SitecoreOptions
    {
        public static readonly string Key = "Sitecore";

        public Uri InstanceUri { get; set; }
        public string LayoutServicePath { get; set; } = "/sitecore/api/layout/render/jss";
        public string DefaultSiteName { get; set; }
        public string ApiKey { get; set; }
        public Uri RenderingHostUri { get; set; }
        public bool EnableExperienceEditor { get; set; }
        public Dictionary<string, string> Sites { get; set; }
        public Uri LayoutServiceUri
        {
            get
            {
                if (InstanceUri == null) return null;

                return new Uri(InstanceUri, LayoutServicePath);
            }
        }
    }

3. Adding a MultiSite Foundation Layer to extend Rendering Host

The next step would be to extend SitecoreLayoutRequest and to create a custom Rendering Engine Pipeline to pass on Site information based on requested url.

Let’s look on how to pass sites by extending SitecoreLayoutRequest. As it is nothing but an inherited Dictionary<string,object> object, we could easily store the sites.

public static class LayoutRequestExtensions
	{
		public static Dictionary<string, string>? Sites(this SitecoreLayoutRequest request)
		{
			return ReadValue<Dictionary<string, string>?>(request, "sc_sites");
		}

		public static SitecoreLayoutRequest Sites(this SitecoreLayoutRequest request, Dictionary<string, string>? value)
		{
			return WriteValue(request, "sc_sites", value);
		}
		private static T ReadValue<T>(SitecoreLayoutRequest request, string key)
		{
			if (request.TryReadValue<T>(key, out var value))
			{
				return value;
			}
			return default(T);
		}

		private static SitecoreLayoutRequest WriteValue<T>(SitecoreLayoutRequest request, string key, [AllowNull] T value)
		{
			request[key] = value;
			return request;
		}
	}

The sites can now be passed from SitecoreOptions to SitecoreLayoutRequest in startup.cs as shown below,

 // Register the Sitecore Layout Service Client, which will be invoked by the Sitecore Rendering Engine.
            services.AddSitecoreLayoutService()
                // Set default parameters for the Layout Service Client from our bound configuration object.
                .WithDefaultRequestOptions(request =>
                {
                    request
                        .SiteName(Configuration.DefaultSiteName) //register default site
                        .ApiKey(Configuration.ApiKey)
                        .Sites(Configuration.Sites); // register other sites
                })
                .AddHttpHandler("default", Configuration.LayoutServiceUri)
                .AsDefaultHandler();
As we now have Sites that are passed during App startup, the next step would be to create a custom Rendering engine that would use a custom Middleware and utilize the Sites information and resolve site based on Requested Url.

The CustomRenderingEnginePipeline is inherited from RenderingEnginePipeline as Configure can be overridden as shown below.

public class CustomRenderingEnginePipeline : RenderingEnginePipeline
    {
        public override void Configure(IApplicationBuilder app)
        {
            //Register Custom Rendering Engine Middleware
            app.UseMiddleware<CustomRenderingEngineMiddelware>(Array.Empty<object>());
        }
    }

As middleware methods cannot be overridden, I had to completely create a custom one based on RenderingEngineMiddleware and do some tweaks to pass Site information based on requested url as highlighted in the below code.

 public class CustomRenderingEngineMiddelware
    {
       ...
        private async Task<SitecoreLayoutResponse> GetSitecoreLayoutResponse(HttpContext httpContext)
        {
            SitecoreLayoutRequest sitecoreLayoutRequest = _requestMapper.Map(httpContext.Request);
            //Update LayoutRequest Sitename based on hostname from httpcontext
            sitecoreLayoutRequest = ResolveSiteNameBasedOnHost(httpContext, sitecoreLayoutRequest);

            return await _layoutService.Request(sitecoreLayoutRequest).ConfigureAwait(continueOnCapturedContext: false);
        }
        private SitecoreLayoutRequest ResolveSiteNameBasedOnHost(HttpContext httpContext, SitecoreLayoutRequest sitecoreLayoutRequest)
        {
            Dictionary<string, string>? sites;
            string siteName = "", currentSitename = "";
            //Getting private _layoutRequestOptions and then accessing SitecoreLayoutRequestOptions.RequestDefaults
            FieldInfo field = typeof(DefaultLayoutClient).GetField
            ("_layoutRequestOptions", BindingFlags.Instance | BindingFlags.NonPublic);
            IOptionsSnapshot<SitecoreLayoutRequestOptions> sitecoreLayoutRequestOptions = (IOptionsSnapshot<SitecoreLayoutRequestOptions>)field.GetValue(_layoutService);

            if (sitecoreLayoutRequestOptions.Value.RequestDefaults.TryReadValue<Dictionary<string, string>?>("sc_sites", out sites) && sitecoreLayoutRequestOptions.Value.RequestDefaults.TryReadValue<string>("sc_site", out currentSitename))
            {
                string hostName = httpContext.Request.Host.Value;
                siteName = sites.ContainsKey(hostName) ? sites[hostName] : "";

                if (currentSitename.ToLower() != siteName.ToLower())
                {
                    sitecoreLayoutRequestOptions.Value.RequestDefaults["sc_site"] = siteName;
                    //finaly update sitecorelayout request defaults
                    field.SetValue(_layoutService, sitecoreLayoutRequestOptions);
                    //also update in layoutrequest
                    sitecoreLayoutRequest.SiteName(siteName);
                }
            }
            return sitecoreLayoutRequest;
        }
    }

Now that the RenderingEngine and Middleware is created, next step would be to update DefaultController’s Index action to use it. This can be done using UseSitecoreRenderingAttribute.

// Injecting Custom Middleware that overrides Site based on Requested URL
        [UseSitecoreRenderingAttribute(typeof(CustomRenderingEnginePipeline))]
        public IActionResult Index(Route route)
        {
            var request = HttpContext.GetSitecoreRenderingContext();

            if (request.Response.HasErrors)
            {
                foreach (var error in request.Response.Errors)
                {
                    switch (error)
                    {
                        case ItemNotFoundSitecoreLayoutServiceClientException notFound:
                            Response.StatusCode = (int)HttpStatusCode.NotFound;
                            return View("NotFound", request.Response.Content.Sitecore.Context);
                        case InvalidRequestSitecoreLayoutServiceClientException badRequest:
                        case CouldNotContactSitecoreLayoutServiceClientException transportError:
                        case InvalidResponseSitecoreLayoutServiceClientException serverError:
                        default:
                            throw error;
                    }
                }
            }

            return View(route);
        }

4. Add Site and app entry

Now to the final step ! We just need to site and app entry to map with the newly duplicated site.

 <sitecore>
    <sites>
      <site name="mvp-site"
            inherits="website"
            hostName="mvp-cm.sc.localhost"
            rootPath="/sitecore/content/MvpSite"
            dictionaryDomain="{DB704D9E-113D-44A1-AA85-2A5D127CD2A3}"
            patch:before="site[@name='website']" />
	  <site name="regional-mvp-site"
            inherits="website"
            hostName="mvp-cm.sc.localhost"
            rootPath="/sitecore/content/Regional-MvpSite"
            dictionaryDomain="{DB704D9E-113D-44A1-AA85-2A5D127CD2A3}"
            patch:before="site[@name='website']" />
    </sites>
    <javaScriptServices>
      <apps>
        <!--
          We need to configure an 'app' for the site as well in order to
          enable support for Experience Editor. The URL below will be used
          by the Experience Editor to render pages for editing.
        -->
        <app name="mvp-site"
             sitecorePath="/sitecore/content/MvpSite"
             serverSideRenderingEngine="http"
             serverSideRenderingEngineEndpointUrl="http://mvp-rendering/jss-render"
             inherits="defaults" />
		  <app name="regional-mvp-site"
			sitecorePath="/sitecore/content/Regional-MvpSite"
			serverSideRenderingEngine="http"
			serverSideRenderingEngineEndpointUrl="http://mvp-rendering/jss-render"
			inherits="defaults" />
      </apps>
    </javaScriptServices>
  </sitecore>
To view the sites, let's run up.ps1 script and test them !! It should now resolve to sites based on requested domain.
MultiSite - Rendering Host Sitecore
Finally it should also work in Experience Editor and making multisite editing feasible using a single Rendering Host !!!!
Experience Editing - Rendering Host Sitecore

P.S : Thanks @NickWesselman for showcasing how custom rendering engine can be avoided as there is options.MapToRequest to replace sitename !!!

https://gist.github.com/nickwesselman/f670449840787f471ad6e4da2cc43399.js

1 comments on “Extending Sitecore Rendering Host to support multisite”

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.