URL based localization scheme for Blazor Server

Pier-Luc Bonneville
Pier-Luc Bonneville
Being technical is important at the leadership level in a world where IT is eating the world.
Sep 30, 2020 6 min read
thumbnail for this post
Photo by Phil Riley

Introduction

In this post I’ll describe how to create a localization scheme based on the URL path that works with Blazor Server (WebSockets).

Demo

The problem

Microsoft recommends the use of a cookie to ensure that the WebSocket connection can correctly propagate the culture.

If localization schemes are based on the URL path or query string, the scheme might not be able to work with WebSockets, thus fail to persist the culture.

Therefore, use of a localization culture cookie is the recommended approach.

I believe that using different URLs for each language version of a page rather than using cookies or browser settings to adjust the content language on the page provides a better user experience.

A URL based localization scheme, enables:

  1. People to navigate to your web application using a link that specifies the language code as a segment (nicer than having a query string parameter), and
  2. People to have multiple browser screens (browser tab or iframe) using different languages in the same browser.

The solution

Here is a summary of the steps we’ll cover to archive URL base localization in Blazor Server.

  1. Set the culture on the first HTTP request
  2. On blazor negotiate:
    1. Check the websocket request referrer; in our case /{TWO_LETTER_ISO_LANGUAGE_NAME}/{SOME_URI}
    2. Change the culture base on the websocket request referrer
    3. Store the connection token and culture key/value pair
  3. On Blazor heartbeat:
    1. With the connection token, retrive the stored culture
    2. Change the culture based on the stored value
  4. When closing the websocket:
    1. Remove the stored connection token and culture key/value pair

Required dependencies

Install the Microsoft.Extensions.Localization NuGet package:

<PackageReference Include="Microsoft.Extensions.Localization" Version="3.1.8" />

The middleware

Let’s create the middleware responsible for the tracking of the Blazor connection and the culture.

Notice that we have three scenarios for the pipeline to take:

  1. On Blazor negotiate
  2. On Blazor heartbeat
  3. Other requests

We also need to do a bit of cleaning when closing the SignalR connections. We’ll to clean up the memory by removing the connection token to culture key/value pair from the dictionary.

public class UrlLocalizationAwareWebSocketsMiddleware
{
    private readonly RequestDelegate _next;
    protected internal static readonly ConcurrentDictionary<string, string> _cultureByConnectionTokens
        = new ConcurrentDictionary<string, string>();

    public UrlLocalizationAwareWebSocketsMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        var segments = httpContext
            .Request
            .Path
            .Value
            .Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);

        var nextAction = segments switch
        {
            string[] { Length: 2 } x
                when x[0] == "_blazor" && x[1] == "negotiate"
                && httpContext.Request.Method == "POST"
                => BlazorNegotiate,

            string[] { Length: 1 } x
                when x[0] == "_blazor"
                && httpContext.Request.QueryString.HasValue
                && httpContext.Request.Method == "GET"
                => BlazorHeartbeat,

            _ => _next
        };

        await nextAction(httpContext);
    }

    // ...
}

On Blazor negotiate

On blazor negotiate, we need to set the culture based on the WebSocket request referrer, the original HTTP request, and save it to a dictionary to be used by the Blazor heartbeat method.

public class UrlLocalizationAwareWebSocketsMiddleware
{
    // ...

    private async Task BlazorNegotiate(HttpContext httpContext)
    {
        var currentCulture = GetCultureFromReferer(httpContext);

        // Set the culture
        var culture = new CultureInfo(currentCulture);
        CultureInfo.CurrentCulture = culture;
        CultureInfo.CurrentUICulture = culture;

        // Enable the rewinding of the response body after the action has been called
        httpContext.Request.EnableBuffering();

        // Save the reference of the response body
        var originalResponseBodyStream = httpContext.Response.Body;
        using var responseBody = new MemoryStream();
        httpContext.Response.Body = responseBody;

        await _next(httpContext);

        // Temporary unwrap the response body to get the connectionToken
        var responseBodyContent = await ReadResponseBodyAsync(httpContext.Response);

        if (httpContext.Response.ContentType == "application/json")
        {
            var root = JsonSerializer
                .Deserialize<BlazorNegociateBody>(responseBodyContent);
            _cultureByConnectionTokens[root.ConnectionToken] = currentCulture;
        }

        // Rewind the response body as if we hadn't unwrap-it
        await responseBody.CopyToAsync(originalResponseBodyStream);
    }

    private static string GetCultureFromReferer(HttpContext httpContext)
    {
        var referer = httpContext.Request.Headers["Referer"].ToString();
        var uri = new Uri(referer);

        var refererSegments = uri.LocalPath
            .Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);

        if (refererSegments.Length >= 1 && refererSegments[0].Length == 2)
        {
            var culture = refererSegments[0];
            return culture;
        }

        return CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
    }

    private static async Task<string> ReadResponseBodyAsync(HttpResponse response)
    {
        response.Body.Seek(0, SeekOrigin.Begin);
        using var reader = new StreamReader(response.Body, leaveOpen: true);
        var bodyAsText = await reader.ReadToEndAsync();
        response.Body.Seek(0, SeekOrigin.Begin);

        return bodyAsText;
    }

    private class BlazorNegociateBody
    {
        [JsonPropertyName("negotiateVersion")]
        public int NegotiateVersion { get; set; }

        [JsonPropertyName("connectionToken")]
        public string ConnectionToken { get; set; }
    }

    // ...
}

On Blazor heartbeat

On blazor heartbeat, we’ll set the culture based on the dictionary entry created in the Blazor negotiate phase.

public class UrlLocalizationAwareWebSocketsMiddleware
{
    // ...

    private async Task BlazorHeartbeat(HttpContext httpContext)
    {
        var components = QueryHelpers.ParseQuery(httpContext.Request.QueryString.Value);
        var connectionToken = components["id"];

        var currentCulture = _cultureByConnectionTokens[connectionToken];
        var culture = new CultureInfo(currentCulture);
        CultureInfo.CurrentCulture = culture;
        CultureInfo.CurrentUICulture = culture;

        await _next(httpContext);

        if (httpContext.Response.StatusCode == StatusCodes.Status101SwitchingProtocols)
        {
            // When "closing" the SignalR connection (websocket) clean-up the
            // memory by removing the token from the dictionary.
            _cultureByConnectionTokens.TryRemove(connectionToken, out var _);
        }
    }

    // ...
}

The request culture middleware extensions

Let’s register our middleware pipeline and clear the default localization providers:

public static class RequestCultureMiddlewareExtensions
{
    public static IApplicationBuilder UseUrlRequestLocalization(this IApplicationBuilder builder, RequestLocalizationOptions options)
    {
        options.RequestCultureProviders.Clear();

        options.AddInitialRequestCultureProvider(new CustomRequestCultureProvider(async context =>
        {
            var currentCulture = GetCultureFromPath(context.Request.Path.Value);

            var requestCulture = new ProviderCultureResult(currentCulture, currentCulture);

            return await Task.FromResult(requestCulture);
        }));

        return builder
            .UseMiddleware<UrlLocalizationAwareWebSocketsMiddleware>()
            .UseRequestLocalization(options);
    }

    private static string GetCultureFromPath(string path)
    {
        var segments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);

        if (segments.Length >= 1 && segments[0].Length == 2)
        {
            var currentCulture = segments[0];
            return currentCulture;
        }

        return CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
    }
}

We are now ready to register our pipeline:

public class Startup
{
    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Error");
        }

        app.UseStaticFiles();

        var supportedCultures = new[]
        {
            new CultureInfo("en"),
            new CultureInfo("fr"),
        };

        var options = new RequestLocalizationOptions
        {
            DefaultRequestCulture = new RequestCulture("en"),
            // Formatting numbers, dates, etc.
            SupportedCultures = supportedCultures,
            // UI strings that we have localized.
            SupportedUICultures = supportedCultures
        };

        app.UseUrlRequestLocalization(options);

        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapBlazorHub();
            endpoints.MapFallbackToPage("/_Host");
        });
    }
}

Enough infrastructure code, let’s use this already!

Add the required namespace to your _Imports.razor file:

@using Microsoft.Extensions.Localization

Create your resources files:

Resources

And then remember to register your resource files:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // ...
        services.AddLocalization(options => options.ResourcesPath = "Resources");
        // ...
    }
}

You are now ready to modify the Index.razor page like so:

@page "/"
@page "/fr"
@page "/en"
@inject IStringLocalizer<App> Localizer


<h1>@Localizer["Hello, world!"]</h1>

@Localizer["Welcome to your new app."]

All done!

Considerations (gotchas)

To change the culture, you’ll have to refresh the page (ie: close the WebSocket and open a new one) NavigationManager.NavigateTo("/fr/some-uri", forceLoad: true); to force a re-rendering of the component tree.

Furthermore, this solution won’t work as is in a load-balanced environment as we are storing the connection tokenculture mapping in a in-memory dictionary.

Conclusion

Although this required a bit of code, we see that it is certainly possible to create a localization scheme based on the URL path that works with WebSockets.

A NuGet packge with the middleware is available at: https://www.nuget.org/packages/BlazorServerUrlRequestCultureProvider/

The code covered in this blog post is available here:

BlazorServerUrlRequestCultureProvider

References

  1. ASP.NET Core Blazor globalization and localization
  2. Globalization and localization in ASP.NET Core
  3. Localization Extensibility
  4. Write custom ASP.NET Core middleware