Last active
November 1, 2022 07:30
-
-
Save khellang/47e8fac5b1bad2df74cb8c145d32f98e to your computer and use it in GitHub Desktop.
Fallback middleware for SPA client-side routing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>(); | |
} | |
}; | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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