I'm continuing my series on implementing the Micro Frontends in Action samples in ASP.NET Core, and I'm continuing the subject of Blazor WebAssembly based Web Components. In the previous post, the project has been expanded with a new service that provides its fronted fragment as a Custom Element power by Blazor WebAssembly. In this post, I will explore how Custom Elements can communicate with other frontend parts.

There are three communication scenarios I would like to explore: passing information from page to Custom Element (parent to child), passing information from Custom Element to page (child to parent), and passing information between Custom Elements (child to child). Let's go through them one by one.

Page to Custom Element

When it comes to passing information from page to Custom Element, there is a standard approach that every web developer will expect. If I want to disable a button, I set an attribute. If I want to change the text on a button, I set an attribute. In general, if I want to change the state of an element, I set an attribute. The same expectation applies to Custom Elements. How to achieve that?

As mentioned in the previous post, the ES6 class, which represents a Custom Element, can implement a set of lifecycle methods. One of these methods is attributeChangedCallback. It will be invoked each time an attribute from a specified list is added, removed, or its value is changed. The list of the attributes which will result in invoking the attributeChangedCallback is defined by a value returned from observedAttributes static get method.

So, in the case of Custom Elements implemented in JavaScript, one has to implement the observedAttributes to return an array of attributes that can modify the state of the Custom Element and implement the attributeChangedCallback to modify that state. Once again, you will be happy to know that all this work has already been done in the case of Blazor WebAssembly. The Microsoft.AspNetCore.Components.CustomElements package, which wraps Blazor components as Custom Elements handles that. It provides an implementation of observedAttributes which returns all the properties marked as parameters, and an implementation of attributeChangedCallback which will update parameters values and give the component a chance to rerender. That makes the implementation quite simple.

I've added a new property named Edition to the BuyButton component, which I created in the previous post. The new property impacts the price depending if the client has chosen a standard or platinum edition. I've also marked the new property as a parameter.

<button type="button" @onclick="OnButtonClick">
    buy for @(String.IsNullOrWhiteSpace(Sku) || String.IsNullOrWhiteSpace(Edition)  ? "???" : _prices[Sku][Edition])
</button>
...

@code {
    private IDictionary<string, Dictionary<string, int>> _prices = new Dictionary<string, Dictionary<string, int>>
    {
        { "porsche", new Dictionary<string, int> { { "standard", 66 }, { "platinum", 966 } } },
        { "fendt", new Dictionary<string, int> { { "standard", 54 }, { "platinum", 945 } }  },
        { "eicher", new Dictionary<string, int> { { "standard", 58 }, { "platinum", 958 } }  }
    };

    [Parameter]
    public string? Sku { get; set; }

    [Parameter]
    public string? Edition { get; set; }

    ...
}

This should be all from the component perspective. The rest should be only about using the attribute representing the property. First, I've added it to the markup served by the Decide service with the default value. I've also added a checkbox that allows choosing the edition.

<html>
    ...
    <body class="decide_layout">
        ...
        <div class="decide_details">
            <label class="decide_editions">
                <p>Material Upgrade?</p>
                <input type="checkbox" name="edition" value="platinum" />
                <span>Platinum<br />Edition</span>
                <img src="https://mi-fr.org/img/porsche_platinum.svg" />
            </label>
            <checkout-buy sku="porsche" edition="standard"></checkout-buy>
        </div>
        ...
    </body>
</html>

Then I implemented an event handler for the change event of that checkbox, where depending on its state, I would change the value of the edition attribute on the custom element.

(function() {
    ...
    const editionsInput = document.querySelector(".decide_editions input");
    ...
    const buyButton = document.querySelector("checkout-buy");

    ...

    editionsInput.addEventListener("change", e => {
        const edition = e.target.checked ? "platinum" : "standard";
        buyButton.setAttribute("edition", edition);
        ...
    });
})();

It worked without any issues. Checking and unchecking the checkbox would result in nicely displaying different prices on the button.

Custom Element to Page

The situation with passing information from Custom Element to the page is similar to passing information from page to Custom Element - there is an expected standard mechanism: events. If something important has occurred internally in the Custom Element and the external world should know about it, Custom Element should raise an event to which whoever is interested can subscribe.

How to raise a JavaScript event from Blazor? This requires calling a JavaScript function which will wrap a call to dispatchEvent. Why can't dispatchEvent be called directly? That's because Blazor requires function identifier to be relative to the global scope, while dispatchEvent needs to be called on an instance of an element. This raises another challenge. Our wrapper function will require a reference to the Custom Element. Blazor supports capturing references to elements to pass them to JavaScript. The @ref attribute can be included in HTML element markup, resulting in a reference being stored in the variable it is pointing to. This means that the reference to the Custom Element itself can't be passed directly, but a reference to its child element can.

I've written a wrapper function that takes the reference to the button element (but it could be any direct child of the Custom Element) as a parameter and then calls dispatchEvent on its parent.

window.checkout = (function () {
    return {
        dispatchItemAddedEvent: function (checkoutBuyChildElement) {
            checkoutBuyChildElement.parentElement.dispatchEvent(new CustomEvent("checkout:item_added"));
        }
    };
})();

I wanted the event to be raised when the button has been clicked, so I've modified the OnButtonClick to use injected IJSRuntime to call my JavaScript function. In the below code, you can also see the @ref attribute in action and how I'm passing that element reference to the wrapper function.

@using Microsoft.JSInterop

@inject IJSRuntime JS

<button type="button" @ref="_buttonElement" @onclick="OnButtonClick">
    buy for @(String.IsNullOrWhiteSpace(Sku) || String.IsNullOrWhiteSpace(Edition)  ? "???" : _prices[Sku][Edition])
</button>
...

@code {
    private ElementReference _buttonElement;

    ...

    private async Task OnButtonClick(MouseEventArgs e)
    {
        ...

        await JS.InvokeVoidAsync("checkout.dispatchItemAddedEvent", _buttonElement);
    }

    ...
}

For the whole thing to work, I had to reference the JavaScript from the Decide service markup so that the wrapper function could be called.

<html>
    ...
    <body class="decide_layout">
        ...
        <script src="/checkout/static/components.js"></script>
        <script src="/checkout/_content/Microsoft.AspNetCore.Components.CustomElements/BlazorCustomElements.js"></script>
        ...
    </body>
</html>

Now I could subscribe to the checkout:item_added event and add some bells and whistles whenever it's raised.

(function() {
    ...
    const productElement = document.querySelector(".decide_product");
    const buyButton = document.querySelector("checkout-buy");

    ...

    buyButton.addEventListener("checkout:item_added", e => {
        productElement.classList.add("decide_product--confirm");
    });

    ...
})();

Custom Element to Custom Element

Passing information between Custom Elements is where things get interesting. That is because there is no direct relation between Custom Elements. Let's assume that the Checkout service exposes a second Custom Element which provides a cart representation. The checkout button and mini-cart don't have to be used together. There might be a scenario where only one of them is present, or there might be scenarios where there are rendered by independent parents.

Of course, everything is happening in the browser's context, so there is always an option to search through the entire DOM tree. This is an approach that should be avoided. First, it's tight coupling, as it requires Custom Element to have detailed knowledge about other Custom Element. Second, it wouldn't scale. What if there are ten different types of Custom Elements to which information should be passed? That would require ten different searches.

Another option is leaving orchestration to the parent. The parent would listen to events from one Custom Element and change properties on the other. This breaks the separation of responsibilities as the parent (in our case, the Decide service) is now responsible for implementing logic that belongs to someone else (in our case, the Checkout Service).

What is needed is a communication channel that will enable a publish-subscribe pattern. This will ensure proper decoupling. The classic implementation of such a channel is events based bus. The publisher raises events with bubbling enabled (by default, it's not), so subscribers can listen for those events on the window object. This is an established approach, but it's not the one I've decided to implement. An events-based bus is a little bit "too public" for me. In the case of multiple Custom Elements communicating, there are a lot of events on the window object, and I would prefer more organization. Luckily, modern browsers provide an alternative way to implement such a channel - the Broadcast Channel API. You can think about Broadcast Channel API as a simple message bus that provides the capability of creating named channels. The hidden power of Broadcast Channel API is that it allows communication between windows/tabs, iframes, web workers, and service workers.

Using Broadcast Channel API in Blazor once again requires JavaScript interop. I've decided to use this opportunity to build a component library that provides easy access to it. I'm not going to describe the process of creating a component library in this post, but if you are interested just let me know and I'm happy to write a separate post about it. If you want to use the Broadcast Channel API, it's available on NuGet.

After building and publishing the component library, I referenced it in the Checkout project and registered the service it provides.

...

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.RegisterAsCustomElement<BuyButton>("checkout-buy");

builder.Services.AddBroadcastChannel();

...

In the checkout button component, I've injected the service. The channel can be created by calling CreateOrJoinAsync, and I'm doing that in OnAfterRenderAsync. I've also made the component implement IAsyncDisposable, where the channel is disposed to avoid JavaScript memory leaks. The last part was calling PostMessageAsync as part of OnButtonClick to send the message to the channel. This completes the publisher.

...

@implements IAsyncDisposable

...
@inject IBroadcastChannelService BroadcastChannelService

...

@code {
    ...
    private IBroadcastChannel? _broadcastChannel;

    ...

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _broadcastChannel = await BroadcastChannelService.CreateOrJoinAsync("checkout:item-added");
        }
    }

    private async Task OnButtonClick(MouseEventArgs e)
    {
        ...

        if (_broadcastChannel is not null)
        {
            await _broadcastChannel.PostMessageAsync(new CheckoutItem { Sku = Sku, Edition = Edition });
        }

        ...
    }

    ...

    public async ValueTask DisposeAsync()
    {
        if (_broadcastChannel is not null)
        {
            await _broadcastChannel.DisposeAsync();
        }
    }
}

The mini-cart component will be the subscriber. I've added there the same code for injecting the service, joining the channel, and disposing of it. The main difference here is that the component will subscribe to the channel Message event instead of sending anything. The BroadcastChannelMessageEventArgs contains the message which has been sent in the Data property as JsonDocument, which can be deserialized to the desired type. In the mini-cart component, I'm using the message to add items.

@using System.Text.Json;

@implements IAsyncDisposable

@inject IBroadcastChannelService BroadcastChannelService

@(_items.Count == 0  ? "Your cart is empty." : $"You've picked {_items.Count} tractors:")
@foreach (var item in _items)
{
    <img src="https://mi-fr.org/img/@(item.Sku)[email protected](item.Edition).svg" />
}

@code {
    private IList<CheckoutItem> _items = new List<CheckoutItem>();
    private IBroadcastChannel? _broadcastChannel;
    private JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _broadcastChannel = await BroadcastChannelService.CreateOrJoinAsync("checkout:item-added");
            _broadcastChannel.Message += OnMessage;
        }
    }

    private void OnMessage(object? sender, BroadcastChannelMessageEventArgs e)
    {
        _items.Add(e.Data.Deserialize<CheckoutItem>(_jsonSerializerOptions));

        StateHasChanged();
    }

    public async ValueTask DisposeAsync()
    {
        if (_broadcastChannel is not null)
        {
            await _broadcastChannel.DisposeAsync();
        }
    }
}

The last thing I did in the Checkout service was exposing the mini-cart component.

...

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.RegisterAsCustomElement<BuyButton>("checkout-buy");
builder.RootComponents.RegisterAsCustomElement<MiniCart>("checkout-minicart");

builder.Services.AddBroadcastChannel();

...

Now the mini-cart could be included in the HTML owned by the Decide service.

<html>
  ...
  <body class="decide_layout">
    ...
    <div class="decide_details">
      <checkout-buy sku="porsche"></checkout-buy>
    </div>
    ...
    <div class="decide_summary">
      <checkout-minicart></checkout-minicart>
    </div>
    ...
  </body>
</html>

Playing With the Complete Sample

The complete sample is available on GitHub. You can run it locally by spinning up all the services, but I've also included a GitHub Actions workflow that can deploy the whole solution to Azure (you just need to fork the repository and provide your own credentials).

This is another post in my series on implementing the samples from Micro Frontends in Action in ASP.NET Core:

This time I'm jumping from server-side composition to client-side composition. I've used the word jumping because I haven't fully covered server-side composition yet. There is one more approach to server-side composition which I intend to cover later, but as lately I was doing some Blazor WebAssembly work I was more eager to write this one.

Expanding The Project

As you may remember from the first post, the project consists of two services that are hosted in Azure Container Apps.

Decide and Inspire Frontend Layout

Both services are using server-side rendering for their frontends, and the Decide service is loading Inspire service frontend fragment via Ajax. It's time to bring a new service to the picture, the Checkout service.

The Checkout service is responsible for checkout flow. As this flow is more sophisticated than what Decide and Inspire services provide, the Checkout service requires client-side rendering to provide the experience of a single page application. This requirement creates a need for isolation and encapsulation of the Checkout service frontend fragment. There is a suite of technologies that can help solve that problem - Web Components.

Web Components

Web Components aim at enabling web developers to create reusable elements with well-encapsulated functionality. To achieve that, they bring together four different specifications:

  • Custom Elements, which allows defining your own tags (custom elements) with their business logic.
  • Shadow DOM, which enables scripting and styling without collisions with other elements.
  • HTML Templates, which allows writing markup templates.
  • ES Module, which defines a consistent way for JavaScript inclusion and reuse.

The Custom Elements is the most interesting one in this context. The way to create a Custom Element is to implement an ES6 class that extends HTMLElement and register it via window.customElements.define. The class can also implement a set of lifecycle methods (constructor, connectedCallback, disconnectedCallback, or attributeChangedCallback). This allows for initializing a SPA framework (Angular, React, Vue, etc.) and instructing it to use this as a root for rendering.

This is exactly what is needed for the Checkout service, where the SPA framework will be Blazor WebAssembly.

Creating a Blazor WebAssembly Based Custom Element

The way Blazor WebAssembly works fits nicely with Custom Elements. If you've ever taken a look at the Program.cs of a Blazor WebAssembly project, you might have noticed some calls to builder.RootComponents.Add. This is because the WebAssembly to which your project gets compiled is designed to perform rendering into elements. Thanks to that a Blazor WebAssembly application can be wrapped into a Custom Element, it just requires proper initialization. You will be happy to learn, that this work has already been done. As part of AspLabs Steve Sanderson has prepared a package and instructions on how to make Blazor components available as Custom Elements. Let's do it.

I've started with an empty Blazor WebAssembly application hosted in ASP.NET Core, to which I've added a component that will server as a button initiating the checkout flow (the final version of that component will also contain some confirmation toast, if you're interested you can find it here).

<button type="button" @onclick="OnButtonClick">buy for @(String.IsNullOrWhiteSpace(Sku) ? "???" : _prices[Sku])</button>
...

@code {
    // Dictionary of tractor prices
    private IDictionary<string, int> _prices = new Dictionary<string, int>
    {
        { "porsche", 66 },
        { "fendt", 54 },
        { "eicher", 58 }
    };

    ...
}

Next, I've added the Microsoft.AspNetCore.Components.CustomElements package to the project.

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  ...
  <ItemGroup>
    ...
    <PackageReference Include="Microsoft.AspNetCore.Components.CustomElements" Version="0.1.0-alpha.*" />
  </ItemGroup>
</Project>

I've removed all .razor files besides the above component and _Imports.razor. After that, I modified the Program.cs by removing the builder.RootComponents.Add calls and adding builder.RootComponents.RegisterAsCustomElement to expose my component as a checkout-buy element.

...

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.RegisterAsCustomElement<BuyButton>("checkout-buy");

await builder.Build().RunAsync();

This is it. After the build/publish the service will server the custom element through the _content/Microsoft.AspNetCore.Components.CustomElements/BlazorCustomElements.js script, so it's time to plug it into the page served by Decide service.

Using a Blazor WebAssembly Based Custom Element

Before the Decide service can utilize the custom element, it is necessary to set up the routing. As described in previous posts, all services are hidden behind a YARP-based proxy, which routes the incoming requests based on prefixes (to avoid conflicts). So far, both services were built in a way where the prefixes were an integral part of their implementation (they were included in actions and static content paths). With the Checkout service that would be hard to achieve, due to Blazor WebAssembly static assets.

There is a way to control the base path for Blazor WebAssembly static assets (through StaticWebAssetBasePath project property) but it doesn't affect the BlazorCustomElements.js path. So, instead of complicating the service implementation to handle the prefix, it seems a lot better to make the prefixes a proxy concern and remove them there. YARP has an out-of-the-box capability to do so through PathRemovePrefix transform. There is a ready-to-use extension method (.WithTransformPathRemovePrefix) which allows adding that transform to a specific route.

...

var routes = new[]
{
    ...
    (new RouteConfig
    {
        RouteId = Constants.CHECKOUT_ROUTE_ID,
        ClusterId = Constants.CHECKOUT_CLUSTER_ID,
        Match = new RouteMatch { Path = Constants.CHECKOUT_ROUTE_PREFIX + "/{**catch-all}" }
    }).WithTransformPathRemovePrefix(Constants.CHECKOUT_ROUTE_PREFIX),
    ...
};

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

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

...

Now the Decide service views can be modified to include the custom element. The first step is to add the static assets.

<html>
  <head>
    ...
    <link href="/checkout/static/components.css" rel="stylesheet" />
  </head>
  <body class="decide_layout">
    ...
    <script src="/checkout/_content/Microsoft.AspNetCore.Components.CustomElements/BlazorCustomElements.js"></script>
    <script src="/checkout/_framework/blazor.webassembly.js"></script>
  </body>
</html>

Sadly, this will not be enough for Blazor to work. When Blazor WebAssembly starts, it requests additional boot resources. They must be loaded from the Checkout service as well, which means including the prefix in URIs. This can be achieved thanks to the JS initializers feature which has been added in .NET 6. The automatic start of Blazor WebAssembly can be disabled, and it can be started manually which allows providing a function to customize the URIs.

<html>
  ...
  <body class="decide_layout">
    ...
    <script src="/checkout/_framework/blazor.webassembly.js" autostart="false"></script>
    <script>
      Blazor.start({
        loadBootResource: function (type, name, defaultUri, integrity) {
          return `/checkout/_framework/${name}`;
        }
      });
    </script>
  </body>
</html>

Finally, the custom element can be used. It will be available through a tag matching the name provided as a parameter to builder.RootComponents.RegisterAsCustomElement.

<html>
  ...
  <body class="decide_layout">
    ...
    <div class="decide_details">
      <checkout-buy sku="porsche"></checkout-buy>
    </div>
    ...
  </body>
</html>

The Expanded Project

As with previous samples, I've created a GitHub Actions workflow that deploys the solution to Azure. After the deployment, if you navigate to the URL of the ca-app-proxy Container App, you will see a page with following layout.

Decide, Inspire, and Checkout Frontend Layout

You can click the button rendered by the custom element and see the toast notification.

This approach highlights one of the benefits of micro frontends, the freedom to choose the fronted stack. Thanks to the encapsulation of the Blazor WebAssembly app into a custom element, the Decide service can host it in its static HTML without understanding anything except how to load the scripts. If we would want to hide even that (something I haven't done here) we could create a script that encapsulates Blazor WebAssembly loading and initialization. That script could be a shared asset that loads Blazor WebAssembly from CDN (which besides the performance benefit would be also a way to go for a solution with multiple services using Blazor WebAssembly).

Rate limiting (sometimes also referred to as throttling) is a key mechanism when it comes to ensuring API responsiveness and availability. By enforcing usage quotas, it can protect an API from issues like:

  • Denial Of Service (DOS) attacks
  • Degraded performance due to traffic spikes
  • Monopolization by a single consumer

Despite its importance, the typical approach to rate limiting is far from perfect when it comes to communicating usage quotas by services and (as a result) respecting those quotas by clients. It shouldn't be a surprise that various services were experimenting with different approaches to solve this problem. The common pattern in the web world is that some of such experiments start to gain traction which results in standardization efforts. This is exactly what is currently happening around communicating services usage quotas with RateLimit Fields for HTTP Internet-Draft. As rate limiting will have built-in support with .NET 7, I thought it might be a good time to take a look at what this potential standard is bringing. But before that, let's recall how HTTP currently supports rate limiting (excluding custom extensions).

Current HTTP Support for Rate Limiting

When it comes to current support for rate limiting in HTTP, it's not much. If a service detects that a client has reached the quota, instead of a regular response it may respond with 429 (Too Many Request) or 503 (Service Unavailable). Additionally, the service may include a Retry-After header in the response to indicate how long the client should wait before making another request. That's it. It means that client can be only reactive. There is no way for a client to get the information about the quota to avoid hitting it.

In general, this works. That said, handling requests which are above the quota still consumes some resources on the service side. Clients would also prefer to be able to understand the quota and adjust their usage patterns instead of handling it as an exceptional situation. So as I said, it's not much.

Proposed Rate Limit Headers

The RateLimit Fields for HTTP Internet-Draft proposes four new headers which aim at enabling a service to communicate usage quotas and policies:

  • RateLimit-Limit to communicate the total quota within a time window.
  • RateLimit-Remaining to communicate the remaining quota within the current time window.
  • RateLimit-Reset to communicate the time (in seconds) remaining in the current time window.
  • RateLimit-Policy to communicate the overall quota policy.

The most interesting one is RateLimit-Policy. It is a list of quota policy items. A quota policy item consists of a quota limit and a single required parameter w which provides a time window in seconds. Custom parameters are allowed and should be treated as comments. Below you can see an example of RateLimit-Policy which informs that client is allowed to make 10 requests per second, 50 requests per minute, 1000 requests per hour, and 5000 per 24 hours.

RateLimit-Policy: 10;w=1, 50;w=60, 1000;w=3600, 5000;w=86400

The only headers which intend to be required are RateLimit-Limit and RateLimit-Reset (RateLimit-Remaining is strongly recommended). So, how an ASP.NET Core based service can server those headers.

Communicating Quotas When Using ASP.NET Core Middleware

As I've already mentioned, built-in support for rate limiting comes to .NET with .NET 7. It brings generic purpose primitives for writing rate limiters as well as a few ready-to-use implementations. It also brings a middleware for ASP.NET Core. The below example shows the definition of a fixed time window policy which allows 5 requests per 10 seconds. The OnRejected callback is also provided to return 429 (Too Many Request) status code and set the Retry-After header value based on provided metadata.

using System.Globalization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.UseHttpsRedirection();

app.UseRateLimiter(new RateLimiterOptions
{
    OnRejected = (context, cancellationToken) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }

        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;

        return new ValueTask();
    }
}.AddFixedWindowLimiter("fixed-window", new FixedWindowRateLimiterOptions(
    permitLimit: 5,
    queueProcessingOrder: QueueProcessingOrder.OldestFirst,
    queueLimit: 0,
    window: TimeSpan.FromSeconds(10),
    autoReplenishment: true
)));

app.MapGet("/", context => context.Response.WriteAsync("-- Demo.RateLimitHeaders.AspNetCore.RateLimitingMiddleware --"))
    .RequireRateLimiting("fixed-window");

app.Run();

The question is, if and how this can be extended to return the rate limit headers? The answer is, sadly, that there seems to be no way to provide the required ones right now. All the information about rate limit policies is well hidden from public access. It would be possible to provide RateLimit-Limit and RateLimit-Policy as they are a direct result of provided options. It is also possible to provide RateLimit-Remaining, but it requires rewriting a lot of the middleware ecosystem to get the required value. What seems completely impossible to get right now is RateLimit-Reset as timers are managed centrally deep in System.Threading.RateLimiting core without any access to their state. There is an option to provide your own timers, but it would mean rewriting the entire middleware stack and taking a lot of responsibility from System.Threading.RateLimiting. Let's hope that things will improve.

Communicating Quotas When Using AspNetCoreRateLimit Package

That built-in support for rate limiting is something that is just coming to .NET. So far the ASP.NET Core developers were using their own implementations or non-Microsoft packages for this purpose. Arguably, the most popular rate limiting solution for ASP.NET Core is AspNetCoreRateLimit. The example below provides similar functionality to the one from the built-in rate limiting example.

using AspNetCoreRateLimit;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMemoryCache();

builder.Services.Configure<IpRateLimitOptions>(options =>
{
    options.EnableEndpointRateLimiting = true;
    options.StackBlockedRequests = false;
    options.HttpStatusCode = 429;
    options.GeneralRules = new List<RateLimitRule>
    {
        new RateLimitRule { Endpoint = "*", Period = "10s", Limit = 5 }
    };
});

builder.Services.AddInMemoryRateLimiting();

builder.Services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();

var app = builder.Build();

app.UseHttpsRedirection();

app.UseIpRateLimiting();

app.MapGet("/", context => context.Response.WriteAsync("-- Demo.RateLimitHeaders.AspNetCore.RateLimitPackage --"));

app.Run();

The AspNetCoreRateLimit has its own custom way of communicating quotas with HTTP headers. In the case of the above example, you might receive the following values in response.

X-Rate-Limit-Limit: 10s
X-Rate-Limit-Remaining: 4
X-Rate-Limit-Reset: 2022-07-24T11:30:47.2291052Z

As you can see, they provide potentially useful information but not in a way that RateLimit Fields for HTTP is going for. Luckily, AspNetCoreRateLimit is not as protective about its internal state and algorithms, the information needed can be accessed and served in a different way.

The information about the current state is kept in IRateLimitCounterStore. This is a dependency that could be accessed directly, but the method for generating needed identifiers lives in ProcessingStrategy so it will be better to create an implementation of it dedicated for purpose of just getting the counters state.

internal interface IRateLimitHeadersOnlyProcessingStrategy : IProcessingStrategy
{ }

internal class RateLimitHeadersOnlyProcessingStrategy : ProcessingStrategy, IRateLimitHeadersOnlyProcessingStrategy
{
    private readonly IRateLimitCounterStore _counterStore;
    private readonly IRateLimitConfiguration _config;

    public RateLimitHeadersOnlyProcessingStrategy(IRateLimitCounterStore counterStore, IRateLimitConfiguration config) : base(config)
    {
        _counterStore = counterStore;
        _config = config;
    }

    public override async Task ProcessRequestAsync(ClientRequestIdentity requestIdentity, RateLimitRule rule,
        ICounterKeyBuilder counterKeyBuilder, RateLimitOptions rateLimitOptions, CancellationToken cancellationToken = default)
    {
        string rateLimitCounterId = BuildCounterKey(requestIdentity, rule, counterKeyBuilder, rateLimitOptions);

        RateLimitCounter? rateLimitCounter = await _counterStore.GetAsync(rateLimitCounterId, cancellationToken);
        if (rateLimitCounter.HasValue)
        {
            return new RateLimitCounter
            {
                Timestamp = rateLimitCounter.Value.Timestamp,
                Count = rateLimitCounter.Value.Count
            };
        }
        else
        {
            return new RateLimitCounter
            {
                Timestamp = DateTime.UtcNow,
                Count = _config.RateIncrementer?.Invoke() ?? 1
            };
        }
    }
}

The second thing that is needed are rules which apply to specific endpoint and identity. Those can be retrieved from specific (either based on IP or client identifier) IRateLimitProcessor. The IRateLimitProcessor is also a tunnel to IProcessingStrategy, so it's nice we have a dedicated one. But what about the identity I've just mentioned? The algorithm to retrieve it lies in RateLimitMiddleware, so access to it will be needed. There are two options here. One is to inherit from RateLimitMiddleware and the other is to create an instance of one of its implementation and use it as a dependency. The first case would require hiding the base implementation of Invoke as it can't be overridden. I didn't like that, so I went with keeping an instance as a dependency approach. This led to the following code.

internal class IpRateLimitHeadersMiddleware
{
    private readonly RequestDelegate _next;
    private readonly RateLimitOptions _rateLimitOptions;
    private readonly IpRateLimitProcessor _ipRateLimitProcessor;
    private readonly IpRateLimitMiddleware _ipRateLimitMiddleware;

    public IpRateLimitHeadersMiddleware(RequestDelegate next,
        IRateLimitHeadersOnlyProcessingStrategy processingStrategy, IOptions<IpRateLimitOptions> options, IIpPolicyStore policyStore,
        IRateLimitConfiguration config, ILogger<IpRateLimitMiddleware> logger)
    {
        _next = next;
        _rateLimitOptions = options?.Value;
        _ipRateLimitProcessor = new IpRateLimitProcessor(options?.Value, policyStore, processingStrategy);
        _ipRateLimitMiddleware = new IpRateLimitMiddleware(next, processingStrategy, options, policyStore, config, logger);
    }

    public async Task Invoke(HttpContext context)
    {
        ClientRequestIdentity identity = await _ipRateLimitMiddleware.ResolveIdentityAsync(context);

        if (!_ipRateLimitProcessor.IsWhitelisted(identity))
        {
            var rateLimitRulesWithCounters = new Dictionary<RateLimitRule, RateLimitCounter>();

            foreach (var rateLimitRule in await _ipRateLimitProcessor.GetMatchingRulesAsync(identity, context.RequestAborted))
            {
                rateLimitRulesWithCounters.Add(
                    rateLimitRule,
                    await _ipRateLimitProcessor.ProcessRequestAsync(identity, rateLimitRule, context.RequestAborted)
                 );
            }
        }

        await _next.Invoke(context);

        return;
    }
}

The rateLimitRulesWithCounters contains all the rules applying to the endpoint in the context of the current request. This can be used to calculate the rate limit headers values.

internal class IpRateLimitHeadersMiddleware
{
    private class RateLimitHeadersState
    {
        public HttpContext Context { get; set; }

        public int Limit { get; set; }

        public int Remaining { get; set; }

        public int Reset { get; set; }

        public string Policy { get; set; } = String.Empty;

        public RateLimitHeadersState(HttpContext context)
        {
            Context = context;
        }
    }

    ...

    public async Task Invoke(HttpContext context)
    {
        ...
    }

    private RateLimitHeadersState PrepareRateLimitHeaders(HttpContext context, Dictionary<RateLimitRule, RateLimitCounter> rateLimitRulesWithCounters)
    {
        RateLimitHeadersState rateLimitHeadersState = new RateLimitHeadersState(context);

        var rateLimitHeadersRuleWithCounter = rateLimitRulesWithCounters.OrderByDescending(x => x.Key.PeriodTimespan).FirstOrDefault();
        var rateLimitHeadersRule = rateLimitHeadersRuleWithCounter.Key;
        var rateLimitHeadersCounter = rateLimitHeadersRuleWithCounter.Value;

        rateLimitHeadersState.Limit = (int)rateLimitHeadersRule.Limit;

        rateLimitHeadersState.Remaining = rateLimitHeadersState.Limit - (int)rateLimitHeadersCounter.Count;

        rateLimitHeadersState.Reset = (int)(
            (rateLimitHeadersCounter.Timestamp+ (rateLimitHeadersRule.PeriodTimespan ?? rateLimitHeadersRule.Period.ToTimeSpan())) - DateTime.UtcNow
            ).TotalSeconds;

        rateLimitHeadersState.Policy = String.Join(
            ", ",
            rateLimitRulesWithCounters.Keys.Select(rateLimitRule =>
                $"{(int)rateLimitRule.Limit};w={(int)(rateLimitRule.PeriodTimespan ?? rateLimitRule.Period.ToTimeSpan()
            ).TotalSeconds}")
        );

        return rateLimitHeadersState;
    }
}

The only thing that remains is setting the headers on the response.

internal class IpRateLimitHeadersMiddleware
{
    ...

    public async Task Invoke(HttpContext context)
    {
        ...

        if (!_ipRateLimitProcessor.IsWhitelisted(identity))
        {
            ...

            if (rateLimitRulesWithCounters.Any() && !_rateLimitOptions.DisableRateLimitHeaders)
            {
                context.Response.OnStarting(
                    SetRateLimitHeaders,
                    state: PrepareRateLimitHeaders(context, rateLimitRulesWithCounters)
                );
            }
        }

        ...
    }

    ...

    private Task SetRateLimitHeaders(object state)
    {
        var rateLimitHeadersState = (RateLimitHeadersState)state;

        rateLimitHeadersState.Context.Response.Headers["RateLimit-Limit"] = rateLimitHeadersState.Limit.ToString(CultureInfo.InvariantCulture);
        rateLimitHeadersState.Context.Response.Headers["RateLimit-Remaining"] = rateLimitHeadersState.Remaining.ToString(CultureInfo.InvariantCulture);
        rateLimitHeadersState.Context.Response.Headers["RateLimit-Reset"] = rateLimitHeadersState.Reset.ToString(CultureInfo.InvariantCulture);
        rateLimitHeadersState.Context.Response.Headers["RateLimit-Policy"] = rateLimitHeadersState.Policy;

        return Task.CompletedTask;
    }
}

After registering the RateLimitHeadersOnlyProcessingStrategy and IpRateLimitHeadersMiddleware (I've registered it after the IpRateLimitMiddleware) response will contain values similar to the following ones.

RateLimit-Limit: 5
RateLimit-Remaining: 4
RateLimit-Reset: 9
RateLimit-Policy: 5;w=10
X-Rate-Limit-Limit: 10s
X-Rate-Limit-Remaining: 4
X-Rate-Limit-Reset: 2022-07-25T20:57:32.0746592Z

The code works but certainly isn't perfect, so I've created an issue in hope that AspNetCoreRateLimit will get those headers built in.

Limiting the Number of Outbound Requests in HttpClient

The general rule around rate limit headers is that they should be treated as informative, so the client doesn't have to do anything specific with them. They are also described as generated at response time without any guarantee of consistency between requests. This makes perfect sense. In the simple examples above, multiple clients would be competing for the same quota so received headers values don't exactly tell how many requests a specific client can make within a given window. But, real-life scenarios are usually more specific and complex. It's very common for quotas to be per client or per IP address (this is why AspNetCoreRateLimit has concepts like request identity as a first-class citizen, the ASP.NET Core built-in middleware also enables sophisticated scenarios by using PartitionedRateLimiter at its core). In a such scenario, the client might want to use rate limit headers to avoid making requests which have a high likelihood of being throttled. Let's explore that, below is a simple code that can handle 429 (Too Many Request) responses and utilize the Retry-After header.

HttpClient client = new();
client.BaseAddress = new("http://localhost:5262");

while (true)
{
    Console.Write("{0:hh:mm:ss}: ", DateTime.UtcNow);

    int nextRequestDelay = 1;

    try
    {
        HttpResponseMessage response = await client.GetAsync("/");
        if (response.IsSuccessStatusCode)
        {
            Console.WriteLine(await response.Content.ReadAsStringAsync());
        }
        else
        {
            Console.Write($"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");

            string? retryAfter = response.Headers.GetValues("Retry-After").FirstOrDefault();
            if (Int32.TryParse(retryAfter, out nextRequestDelay))
            {
                Console.Write($" | Retry-After: {nextRequestDelay}");
            }

            Console.WriteLine();
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }

    await Task.Delay(TimeSpan.FromSeconds(nextRequestDelay));
}

Let's assume that the service is sending all rate limit headers and that they are dedicated to the client. We can rate limit the HttpClient by creating a DelegatingHandler which will read the RateLimit-Policy header value and instantiate a FixedWindowRateLimiter based on it. The FixedWindowRateLimiter will be used to rate limit the outbound requests - if a lease can't be acquired a locally created HttpResponseMessage will be returned.

internal class RateLimitPolicyHandler : DelegatingHandler
{
    private string? _rateLimitPolicy;
    private RateLimiter? _rateLimiter;

    private static readonly Regex RATE_LIMIT_POLICY_REGEX = new Regex(@"(\d+);w=(\d+)", RegexOptions.Compiled);

    public RateLimitPolicyHandler() : base(new HttpClientHandler())
    { }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (_rateLimiter is not null)
        {
            using var rateLimitLease = await _rateLimiter.WaitAsync(1, cancellationToken);
            if (rateLimitLease.IsAcquired)
            {
                return await base.SendAsync(request, cancellationToken);
            }

            var rateLimitResponse = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
            rateLimitResponse.Content = new StringContent($"Service rate limit policy ({_rateLimitPolicy}) exceeded!");

            if (rateLimitLease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
            {
                rateLimitResponse.Headers.Add("Retry-After", ((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo));
            }

            return rateLimitResponse;
        }

        var response = await base.SendAsync(request, cancellationToken);

        if (response.Headers.Contains("RateLimit-Policy"))
        {
            _rateLimitPolicy = response.Headers.GetValues("RateLimit-Policy").FirstOrDefault();

            if (_rateLimitPolicy is not null)
            {
                Match rateLimitPolicyMatch = RATE_LIMIT_POLICY_REGEX.Match(_rateLimitPolicy);

                if (rateLimitPolicyMatch.Success)
                {
                    int limit = Int32.Parse(rateLimitPolicyMatch.Groups[1].Value);
                    int window = Int32.Parse(rateLimitPolicyMatch.Groups[2].Value);

                    _rateLimiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(
                        limit,
                        QueueProcessingOrder.NewestFirst,
                        0,
                        TimeSpan.FromSeconds(window),
                        true
                    ));

                    string? rateLimitRemaining = response.Headers.GetValues("RateLimit-Remaining").FirstOrDefault();
                    if (Int32.TryParse(rateLimitRemaining, out int remaining))
                    {
                        using var rateLimitLease = await _rateLimiter.WaitAsync(limit - remaining, cancellationToken);
                    }
                }
            }
        }

        return response;
    }
}

The above code also uses the RateLimit-Remaining header value to acquire leases for requests which are no longer available in the initial window.

Now depending if the sample code is run with the RateLimitPolicyHandler in the HttpClient pipeline or not, the console output will be different as those 429 will be coming from a different place.

Opinions

The rate limit headers seem like an interesting addition for communicating services usage quotas. Properly used in the right situations might be a useful tool, it is just important not to treat them as guarantees.

Serving rate limit headers from ASP.NET Core right now has its challenges. If they will become a standard and gain popularity, I think this will change.

If you want to play with the samples, they are available on GitHub.

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.

Recently, I've been reading Micro Frontends in Action and while doing it I've decided that I want to implement the samples in ASP.NET Core and host them on Azure. Then I had a thought, that maybe I can use this as an opportunity to play with some technologies I didn't have a chance to play with yet, like YARP or Azure Container Apps. Once I did that, the next thought was that maybe I should write down some of this and maybe someone will find it useful. So here we are, possibly starting a new series around micro frontends techniques:

Micro Frontends in Action

One of the praises for Micro Frontends in Action says that it's an excellent starting point to understanding how to introduce micro frontends in your projects. I'm about one-third through it and I'm willing to agree with that statement. The book starts with very simple techniques like page transition via links, composition via iframe, and composition via Ajax. Then it moves to server-side composition, advanced techniques for client-side composition, communication patterns, and more (I don't know, I haven't gotten there yet). Up to this point, it has been interesting and engaging (this is my opinion and just to be clear - nobody is paying me to read the book and provide an opinion about it).

The book contains samples for every discussed technique and it was just too tempting not to implement them with technologies I like. That doesn't mean I will implement every single one of those samples. I will also certainly not describe every single one of those implementations. I'm doing it for fun.

I also recommend you read the book if you are interested in the subject - a blog post describing some specific implementation will not provide you with information about benefits, drawbacks, and when to use a certain technique.

The Landscape So Far

The technique I'm going to describe in this post is server-side routing. Prior to introducing that technique, the project consists of two services: Decide and Inspire. The Decide service is loading frontend fragments provided by Inspire service via Ajax request.

Composition via Ajax Frontend Layout

Under the hood, both services are simple ASP.NET Core MVC applications that serve views based on static HTML from original samples, where the URLs are generated based on configuration.

@using Microsoft.Extensions.Configuration
@inject IConfiguration Configuration
@{string decideServiceUrl = Configuration["DECIDE_SERVICE_URL"];}
<link href="@(Context.Request.Scheme + "://" + Context.Request.Host + "/static/fragment.css")" rel="stylesheet" />
<div class="inspire_fragment">
  <h2 class="inspire_headline">Recommendations</h2>
  <div class="inspire_recommendations">
    <a href="@(decideServiceUrl + "/product/fendt")"><img src="https://mi-fr.org/img/fendt.svg" /></a>
    <a href="@(decideServiceUrl + "/product/eicher")"><img src="https://mi-fr.org/img/eicher.svg" /></a>
  </div>
</div>

The Inspire service additionally defines a CORS policy to enable requesting the fragments from different domain via Ajax.

var builder = WebApplication.CreateBuilder(args);

string decideServiceCorsPolicyName = "decide-service-cors-policy";

builder.Services.AddCors(options =>
{
    options.AddPolicy(name: decideServiceCorsPolicyName, policy =>
    {
        policy.WithOrigins(builder.Configuration["DECIDE_SERVICE_URL"]);
        policy.AllowAnyHeader();
        policy.WithMethods("GET");
    });
});

...

app.UseRouting();

app.UseCors(decideServiceCorsPolicyName);

...

Both services are containerized and deployed as two Container Apps within a single Container Apps environment.

az containerapp create \
  -n ${DECIDE_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/decide:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress external \
  --target-port 3001 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io

az containerapp create \
  -n ${INSPIRE_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/inspire:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress external \
  --target-port 3002 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io

This results in the following Container Apps solution.

Composition via Ajax Container Apps Solution

I've created a GitHub Actions workflow that performs all the necessary steps (creating Azure resources, building and pushing containers images, and deploying Container Apps) to set up the entire solution from scratch. There is one tricky step in that workflow. Both services must know each other URLs but those are available only after Container Apps are created. To solve this I'm first getting the ingress information for every app (with az containerapp ingress show) and then write them to environment variables (using the multiline strings approach).

jobs:
  ..
  deploy-to-container-apps:
  ..
  steps:
    ..
    - name: Get Services Ingress
      run: |
        echo 'DECIDE_CONTAINERAPP_INGRESS_JSON<<EOF' >> $GITHUB_ENV
        az containerapp ingress show -n ${DECIDE_CONTAINERAPP} -g ${RESOURCE_GROUP} >> $GITHUB_ENV
        echo 'EOF' >> $GITHUB_ENV
        echo 'INSPIRE_CONTAINERAPP_INGRESS_JSON<<EOF' >> $GITHUB_ENV
        az containerapp ingress show -n ${INSPIRE_CONTAINERAPP} -g ${RESOURCE_GROUP} >> $GITHUB_ENV
        echo 'EOF' >> $GITHUB_ENV
    ..

Next, I'm using the fromJson expression to get FQDN from ingress information and update the Container Apps.

jobs:
  ..
  deploy-to-container-apps:
  ..
  steps:
    ..
    - name: Configure Services URLs
      run: |
        az containerapp update -n ${DECIDE_CONTAINERAPP} -g ${RESOURCE_GROUP} --set-env-vars INSPIRE_SERVICE_URL=https://${{ fromJSON(env.INSPIRE_CONTAINERAPP_INGRESS_JSON).fqdn }}
        az containerapp update -n ${INSPIRE_CONTAINERAPP} -g ${RESOURCE_GROUP} --set-env-vars DECIDE_SERVICE_URL=https://${{ fromJSON(env.DECIDE_CONTAINERAPP_INGRESS_JSON).fqdn }}
    ..

The Challenge

There is a problem with this solution. There are two services and each of them is available to public requests under a different domain. This has several drawbacks:

  • Bad user experience (requests flying to different domains).
  • Internal infrastructure of the solution is exposed publicly.
  • Performance (two DNS lookups, two SSL handshakes, etc.).
  • Indexing by search engines.

To solve this a central service (a proxy), where all requests will arrive, is needed.

Introducing YARP as a Solution

For about a year, the ASP.NET Core stack have its own reverse proxy - YARP. It provides a lot of routing features like headers-based routing, session affinity, load balancing, or destination health checks. In this scenario I'm going to use direct forwarding to simply forward requests to specific services based on a path. I've also decided to set up the rules from code instead of using configuration (it seemed simpler as I still could provide service URLs through environment variables). For this purpose, I've created a MapForwarder extension method.

public static void MapForwarder(this IEndpointRouteBuilder endpoints, string pattern, string serviceUrl)
{
    var forwarder = endpoints.ServiceProvider.GetRequiredService<IHttpForwarder>();
    var requestConfig = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromMilliseconds(500) };

    endpoints.Map(pattern, async httpContext =>
    {
        var error = await forwarder.SendAsync(httpContext, serviceUrl, HttpClient, requestConfig, HttpTransformer.Default);

        if (error != ForwarderError.None)
        {
            var errorFeature = httpContext.GetForwarderErrorFeature();
            var exception = errorFeature?.Exception;
        }
    });
}

I've followed the approach of defining service-specific path prefixes as well as specific prefixes for important pages.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpForwarder();

var app = builder.Build();

string decideServiceUrl = app.Configuration["DECIDE_SERVICE_URL"];
string inspireServiceUrl = app.Configuration["INSPIRE_SERVICE_URL"];

app.UseRouting();
app.UseEndpoints(endpoints =>
{
    // Per service prefixes
    endpoints.MapForwarder("/", decideServiceUrl);

    // Per service prefixes
    endpoints.MapForwarder("/decide/{**catch-all}", decideServiceUrl);
    endpoints.MapForwarder("/inspire/{**catch-all}", inspireServiceUrl);

    // Per page prefixes
    endpoints.MapForwarder("/product/{**catch-all}", decideServiceUrl);
    endpoints.MapForwarder("/recommendations/{**catch-all}", inspireServiceUrl);
});

app.Run();

As it will be now the proxy responsibility to know the addresses of both services, they no longer need to be able to point to each other. All the paths can now be relative, as long as they follow the established rules.

<link href="/inspire/static/fragment.css" rel="stylesheet" />
<div class="inspire_fragment">
  <h2 class="inspire_headline">Recommendations</h2>
  <div class="inspire_recommendations">
    <a href="/product/fendt"><img src="https://mi-fr.org/img/fendt.svg" /></a>
    <a href="/product/eicher"><img src="https://mi-fr.org/img/eicher.svg" /></a>
  </div>
</div>

There is also no need for CORS anymore, as from a browser perspective there are no cross-origin requests. So I've removed that code from the Inspire service.

Now it's time to hide the services from the public. For starters let's change their ingress to internal.

az containerapp create \
  -n ${DECIDE_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/decide:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress internal \
  --target-port 3001 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io

az containerapp create \
  -n ${INSPIRE_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/inspire:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress internal \
  --target-port 3002 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io

This way the services are now accessible only from within the Container Apps environment. The proxy can be deployed to the same Container Apps environment and expose the desired traffic outside.

az containerapp create \
  -n ${PROXY_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/proxy:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress external \
  --target-port 3000 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io \
  --env-vars INSPIRE_SERVICE_URL=https://${INSPIRE_CONTAINERAPP_FQDN} DECIDE_SERVICE_URL=https://${DECIDE_CONTAINERAPP_FQDN}

After this change, the Container Apps solution looks like in the below diagram.

Server-Side Routing via YARP Container Apps Solution

That's All Folks

At least for this post. You can find the final solution here and its GitHub Actions workflow here. The services have become a little bit strange in the process (they server static HTML in an unnecessarily complicated way) but they are not the focus here, the server-side routing technique is.

I don't know if or when I'm going to play with another technique, but if I will it's probably going to be something around server-side composition.

Older Posts