Micro Frontends in Action With ASP.NET Core - Composition via YARP Transforms and Server-Side Includes (SSI)

I'm continuing my series on implementing the samples from Micro Frontends in Action in ASP.NET Core:

In the previous post, I described how I've deployed the two services which provide fragments of the frontend to the single Container Apps environment and then hidden them behind a YARP-based proxy. This technique (called server-side routing) solves some problems related to bad user experience (multiple domains), browser security (CORS), or search engines. That said, there are some other problems on the table.

The first common problem is performance. Server-side routing does improve performance over solution where fragments are loaded directly from different domains by removing multiple DNS lookups, SSL handshakes, etc. Still, separated AJAX calls can have a very negative impact on overall page load time, especially on slower connections.

The second common problem is layout shifts. When fragments are being loaded, it can often cause already visible page content to "jump". This is frustrating for the end-users.

A solution to both of these problems can be providing a complete page to the browser in a response to the first request.

Server-Side Composition

Server-side composition is a technique, where the page is being fully assembled (which means requesting all the required fragments) on the server. The composition can be done either by a central service (a proxy) or can be done in a decentralized manner, where every service requests fragments it requires to build its own UI. As the current solution already has a proxy in place, I've decided to start with a centralized approach. There are two possible mechanisms discussed in the book for this purpose: Server-Side Includes (SSI) and Edge-Side Includes (ESI).

Server-Side Includes (SSI)

Server-Side Includes is a quite old mechanism, it dates back to the NCSA HTTPd web server. It defines a set of directives (called commands or elements in some implementations) that can be placed in HTML and evaluated by the server while the page is being served. Currently, SSI is supported by Apache, Nginx, and IIS. The subset of supported directives varies between implementations, but the most useful and always available one is include, which the server replaces with a file or result of a request. All that a service needs to do, is put that directive as part of the returned markup.

<html>
  ...
  <body class="decide_layout">
    ...
    <aside class="decide_recos">
      <!--#include virtual="/inspire/fragment/recommendations/porsche" -->
    </aside>
  </body>
</html>

All the magic needs to happen at the proxy level, the only question is how?

Supporting SSI in YARP With Response Body Transform

Every well-respected reverse proxy provides more capabilities than just routing and YARP is no different. One of the capabilities provided by YARP, which goes beyond routing, is transforms. Transforms allow for modifying parts of the request or response as part of the flow. Currently, there are three categories of transforms:

  • Request
  • Response
  • Response Trailers

In every one of those categories, YARP provides a number of built-in transforms which allow for modifying path, query string, client certificates, and headers. There are no built-in transforms for request and response body, which probably makes sense as transforms including request and response body are slightly tricky and potentially dangerous. The first tricky part is that direct forwarding ignores any modifications to the response body which transforms could make, so the proxy service needs to be switched to a "full" reverse proxy experience.

app.Run();

var builder = WebApplication.CreateBuilder(args);

...

builder.Services.AddReverseProxy();

var app = builder.Build();

app.MapReverseProxy();

app.Run();

Moving away from direct forwarding means that there is no longer a way to define path prefixes directly. The out-of-the-box approach for configuring the reverse proxy requires is through configuration files. This would mean that I would have to change the way in which the deployment workflow provides the proxy with URLs to other services. That's something I really didn't want to do. Luckily, there is a possibility of implementing configuration providers to load the configuration programmatically from any source. The documentation even contains an example of an in-memory configuration provider which I've basically copy-pasted (looking at the YARP backlog it seems that the team has noticed its usefulness and it will be available out-of-the-box as well). This allowed me to keep the routes and clusters (this is how destinations are represented in YARP) configuration in the code.

...

var routes = new[]
{
    ...
    new RouteConfig
    {
        RouteId = Constants.DECIDE_ROUTE_ID,
        ClusterId = Constants.DECIDE_CLUSTER_ID,
        Match = new RouteMatch { Path = "/decide/{**catch-all}" }
    },
    ...
};

var clusters = new[]
{
    new ClusterConfig()
    {
        ClusterId = Constants.DECIDE_CLUSTER_ID,
        Destinations = new Dictionary<string, DestinationConfig>(StringComparer.OrdinalIgnoreCase)
        {
            { Constants.DECIDE_SERVICE_URL, new DestinationConfig() { Address = builder.Configuration[Constants.DECIDE_SERVICE_URL] } }
        }
    },
    ...
};

builder.Services.AddReverseProxy()
    .LoadFromMemory(routes, clusters);

...

With the configuration in place, it's time for the transform. There are two main ways for adding transforms. One is a callback and the other is ITransformProvider implementation. The ITransformProvider implementation gives more flexibility and isolation, so I've decided to go with it. As the implementation will be a registered dependency, it gives full access to dependency injection. It also gives validation capabilities for routes and clusters.

The simplest ("no-op") implementation of ITransformProvider can look like below.

internal class SsiTransformProvider : ITransformProvider
{
    public void ValidateRoute(TransformRouteValidationContext context)
    { }

    public void ValidateCluster(TransformClusterValidationContext context)
    { }

    public void Apply(TransformBuilderContext transformBuildContext)
    {
        transformBuildContext.AddResponseTransform(TransformResponse);
    }

    private ValueTask TransformResponse(ResponseTransformContext responseContext)
    {
        return default;
    }
}

In order to register that ITransformProvider implementation (and make TranformResponse part of the flow) it is enough to call AddTransforms.

...

builder.Services.AddReverseProxy()
    .LoadFromMemory(routes, clusters)
    .AddTransforms<SsiTransformProvider>();

...

This is where it is important to understand further specifics of transforms that are working with the request or response body. As a result of the Apply method from the above implementation, the TranformResponse will be executed for every flow going through YARP. This is too broad because if that transform deals with request or response body, it will come with a performance penalty. It will have to read (essentially buffer) the response from the destination. The moment the body has been read, it will also have to be written to the HttpContext of the YARP response, otherwise the response will be empty. This is happening because YARP doesn't buffer the response as part of the flow (due to performance reasons), instead it attempts to read the stream which in this case is at the end.

The performance penalty means that there is a need to limit the scope of impact by adding the transform only to certain routes. The routes which should be transformed need to be somehow marked. For that purpose I've decided to include additional information in the routes through metadata. Metadata is a dictionary available on RouteConfig. I've defined a specific key and value for which the Apply method will check before adding the transform. I've also added a statically available dictionary which can be used to set the metadata.

internal class SsiTransformProvider : ITransformProvider
{
    private const string SSI_METADATA_FLAG = "SSI";
    private const string SSI_METADATA_FLAG_ON = "ON";

    public static IReadOnlyDictionary<string, string> SsiEnabledMetadata { get; } = new Dictionary<string, string>()
    {
        { SSI_METADATA_FLAG, SSI_METADATA_FLAG_ON }
    };

    ...

    public void Apply(TransformBuilderContext transformBuildContext)
    {
        if (transformBuildContext.Route.Metadata is not null
            && transformBuildContext.Route.Metadata.ContainsKey(SSI_METADATA_FLAG)
            && transformBuildContext.Route.Metadata[SSI_METADATA_FLAG] == SSI_METADATA_FLAG_ON)
        {
            transformBuildContext.AddResponseTransform(TransformResponse);
        }
    }

    ...
}

Thanks to the in-memory configuration provider, including those metadata mean just setting one more property. While doing this I've also increased the granularity of routes to further limit the affected scope.

...

var routes = new[]
{
    ...
    new RouteConfig
    {
        RouteId = Constants.DECIDE_ROUTE_ID + "-static",
        ClusterId = Constants.DECIDE_CLUSTER_ID,
        Match = new RouteMatch { Path = "/decide/static/{**catch-all}" }
    },
    new RouteConfig
    {
        RouteId = Constants.DECIDE_ROUTE_ID,
        ClusterId = Constants.DECIDE_CLUSTER_ID,
        Match = new RouteMatch { Path = "/decide/{**catch-all}" },
        Metadata = SsiTransformProvider.SsiEnabledMetadata
    },
    ...
};

...

Now it's time for the actual transformation. To focus on the logic needed to support SSI include directive, let's get the response body reading and writing out of the way.

internal class SsiTransformProvider : ITransformProvider
{
    ...

    private ValueTask TransformResponse(ResponseTransformContext responseContext)
    {

        string proxyResponseContent = await responseContext.ProxyResponse.Content.ReadAsStringAsync();

        responseContext.SuppressResponseBody = true;

        ...

        byte[] proxyResponseContentBytes = Encoding.UTF8.GetBytes(proxyResponseContent);
        responseContext.HttpContext.Response.ContentLength = proxyResponseContentBytes.Length;
        await responseContext.HttpContext.Response.Body.WriteAsync(proxyResponseContentBytes);
    }
}

In order to get the include directive from the response body, I've decided to use (I don't believe I'm saying this) regex. The snippet below will grab all the directives. The group with index one of the resulting match will contain the directive name (all others than include are to be ignored) while the group with index two will contain parameters for further parsing (I'm doing this with another regex, but ultimately I care only for virtual parameter).

internal class SsiTransformProvider : ITransformProvider
{
    private static readonly Regex SSI_DIRECTIVE_REGEX = new Regex(
        @"<!--\#([a-z]+)\b([^>]+[^\/>])?-->",
        RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace
    );

    ...

    private ValueTask TransformResponse(ResponseTransformContext responseContext)
    {

        ...

        var directives = SSI_DIRECTIVE_REGEX.Matches(proxyResponseContent)

        ...
    }
}

To get the content to which the include directive is pointing, I will need to make an HTTP request to a specific service. The configuration provided for YARP already has an URL for that service. YARP also maintains a HttpClient instance dedicated to the cluster to which the service belongs. It would be nice to reuse it. In order to do that, first I needed to identify the endpoint for the path to which the virtual parameter is pointing.

Endpoint? virtualEndpoint = null;

var endpointDataSource = context.RequestServices.GetService<EndpointDataSource>();
if (endpointDataSource is not null)
{
    var virtualPath = new PathString(directive.Parameters[VIRTUAL_PARAMETER]);
    foreach (Endpoint possibleVirtualEndpoint in endpointDataSource.Endpoints)
    {
        var routeEndpoint = possibleVirtualEndpoint as RouteEndpoint;
        if (routeEndpoint is not null)
        {
            var routeTemplateMatcher = new TemplateMatcher(new RouteTemplate(routeEndpoint.RoutePattern), _emptyRouteValueDictionary);
            if (routeTemplateMatcher.TryMatch(virtualPath, _emptyRouteValueDictionary))
            {
                virtualEndpoint = possibleVirtualEndpoint;
                break;
            }
        }
    }
}

The endpoint also has a metadata collection. In this collection, YARP keeps the route model, which includes the cluster model.

ClusterModel? cluster = null;

foreach (var endpointMetadata in virtualEndpoint.Metadata)
{
    var proxyRoute = endpointMetadata as RouteModel;
    if (proxyRoute is not null)
    {
        cluster = proxyRoute.Cluster?.Model;
        break;
    }
}

The cluster model contains the configured destinations (with URLs) and that mentioned HttpClient instance. All that remains is to build the request URI, make the request, and read the content.

string virtualUri = cluster.Config.Destinations.FirstOrDefault().Value.Address + parameters["virtual"];

HttpResponseMessage response = await cluster.HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, virtualUri), CancellationToken.None);

string directiveOutputContent = await response.Content.ReadAsStringAsync();

Once the content has been read, the directive can be replaced in the body.

proxyResponseContent = proxyResponseContent.Substring(0, directives[directiveIndex].Index)
    + directiveOutputContent
    + proxyResponseContent.Substring(directives[directiveIndex].Index + directives[directiveIndex].Length);

This Is Just a Proof of Concept

Yes, this code (even the little more polished version available in the repository) is just a POC. It's missing constraints, error checking, support for multiple destinations in a cluster, support for other parameters of the include directive, and much more.

There should be also further performance considerations. The approach that the sample code takes (buffer the body, request content for include directives in parallel, and then build the final response body) is typical for SSI, but an approach that streams the body whenever possible could be considered. This is for example how ESI (which is a more modern mechanism) is sometimes implemented.

The only goal of this post (and related sample) is to show some YARP capabilities which can be used for achieving server-side composition at its level.