URL based localization scheme for Blazor Server
Introduction
In this post I’ll describe how to create a localization scheme based on the URL path that works with Blazor Server (WebSockets).
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:
- 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
- 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.
- Set the culture on the first HTTP request
- On blazor negotiate:
- Check the websocket request referrer; in our case
/{TWO_LETTER_ISO_LANGUAGE_NAME}/{SOME_URI}
- Change the culture base on the websocket request referrer
- Store the connection token and culture key/value pair
- Check the websocket request referrer; in our case
- On Blazor heartbeat:
- With the connection token, retrive the stored culture
- Change the culture based on the stored value
- When closing the websocket:
- 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:
- On Blazor negotiate
- On Blazor heartbeat
- 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:
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 token
↔ culture
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