Skip to content

Instantly share code, notes, and snippets.

@khellang
Last active November 1, 2022 07:30
Show Gist options
  • Save khellang/47e8fac5b1bad2df74cb8c145d32f98e to your computer and use it in GitHub Desktop.
Save khellang/47e8fac5b1bad2df74cb8c145d32f98e to your computer and use it in GitHub Desktop.
Fallback middleware for SPA client-side routing
using System;
using System.Text;
using Microsoft.AspNetCore.Http;
namespace SpaFallback
{
public class SpaFallbackException : Exception
{
private const string Fallback = nameof(SpaFallbackExtensions.UseSpaFallback);
private const string StaticFiles = "UseStaticFiles";
private const string Mvc = "UseMvc";
public SpaFallbackException(PathString path) : base(GetMessage(path))
{
}
private static string GetMessage(PathString path) => new StringBuilder()
.AppendLine($"The {Fallback} middleware failed to provide a fallback response for path '{path}' because no middleware could handle it.")
.AppendLine($"Make sure {Fallback} is placed before any middleware that is supposed to provide the fallback response. This is typically {StaticFiles} or {Mvc}.")
.AppendLine($"If you're using {StaticFiles}, make sure the file exists on disk and that the middleware is configured correctly.")
.AppendLine($"If you're using {Mvc}, make sure you have a controller and action method that can handle '{path}'.")
.ToString();
}
}
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace SpaFallback
{
public static class SpaFallbackExtensions
{
private const string MarkerKey = "middleware.SpaFallback";
public static IServiceCollection AddSpaFallback(this IServiceCollection services)
{
return services.AddSpaFallback(configure: null);
}
public static IServiceCollection AddSpaFallback(this IServiceCollection services, PathString fallbackPath)
{
if (!fallbackPath.HasValue)
{
throw new ArgumentException("Fallback path must have a value.", nameof(fallbackPath));
}
return services.AddSpaFallback(options => options.FallbackPath = fallbackPath);
}
public static IServiceCollection AddSpaFallback(this IServiceCollection services, Action<SpaFallbackOptions> configure)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
if (configure != null)
{
services.Configure(configure);
}
services.TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter, StartupFilter>());
return services;
}
public static IApplicationBuilder UseSpaFallback(this IApplicationBuilder app)
{
if (app == null)
{
throw new ArgumentNullException(nameof(app));
}
app.Properties[MarkerKey] = true;
return app.UseMiddleware<SpaFallbackMiddleware>();
}
private class StartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
next(app);
if (app.Properties.ContainsKey(MarkerKey))
{
app.UseMiddleware<SpaFallbackMiddleware.Marker>();
}
};
}
}
}
}
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace SpaFallback
{
public class SpaFallbackMiddleware
{
private const string MarkerKey = "SpaFallback";
public SpaFallbackMiddleware(RequestDelegate next, IOptions<SpaFallbackOptions> options)
{
Next = next;
Options = options.Value;
}
private RequestDelegate Next { get; }
private SpaFallbackOptions Options { get; }
public async Task Invoke(HttpContext context)
{
await Next(context);
if (ShouldFallback(context))
{
var originalPath = context.Request.Path;
try
{
context.Request.Path = Options.FallbackPath;
await Next(context);
if (ShouldThrow(context))
{
throw new SpaFallbackException(Options.FallbackPath);
}
}
finally
{
context.Request.Path = originalPath;
}
}
}
private bool ShouldFallback(HttpContext context)
{
if (context.Response.HasStarted)
{
return false;
}
if (context.Response.StatusCode != StatusCodes.Status404NotFound)
{
return false;
}
// Fallback only on "hard" 404s, i.e. when the request reached the marker MW.
if (!context.Items.ContainsKey(MarkerKey))
{
return false;
}
if (!HttpMethods.IsGet(context.Request.Method))
{
return false;
}
if (HasFileExtension(context.Request.Path))
{
return Options.UseFileExtensionFallback;
}
return true;
}
private bool ShouldThrow(HttpContext context)
{
return context.Response.StatusCode == StatusCodes.Status404NotFound && Options.ThrowIfFallbackFails;
}
private static bool HasFileExtension(PathString path)
{
return path.HasValue && Path.HasExtension(path.Value);
}
public class Marker
{
public Marker(RequestDelegate next)
{
Next = next;
}
private RequestDelegate Next { get; }
public Task Invoke(HttpContext context)
{
context.Items[MarkerKey] = true; // Where the magic happens...
context.Response.StatusCode = StatusCodes.Status404NotFound;
return Task.CompletedTask;
}
}
}
}
using Microsoft.AspNetCore.Http;
namespace SpaFallback
{
public class SpaFallbackOptions
{
public PathString FallbackPath { get; set; } = "/index.html";
public bool UseFileExtensionFallback { get; set; } = false;
public bool ThrowIfFallbackFails { get; set; } = true;
}
}
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace SpaFallback
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddSpaFallback();
services.AddMvc();
}
public void Configure(IApplicationBuilder app)
{
app.UseDeveloperExceptionPage();
app.UseSpaFallback();
app.UseStaticFiles();
app.UseMvc();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment