feat(api): serve SPA from wwwroot + SPA fallback for client-side routes

This commit is contained in:
Claude
2026-08-28 16:32:16 +00:00
parent 2f7f18008e
commit 03de45cf8e
3 changed files with 100 additions and 7 deletions

View File

@@ -157,6 +157,12 @@ using (var scope = app.Services.CreateScope())
app.UseAuthentication();
app.UseAuthorization();
// Serve the compiled SPA from wwwroot. Static files intentionally do NOT
// require auth — the SPA itself sends users to /login when it sees a 401.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapSessionEndpoints();
app.MapNodeTypesEndpoints();
app.MapConnectionProvidersEndpoints();
@@ -166,6 +172,11 @@ app.MapExecutionsEndpoints();
app.MapWebhookEndpoint(TimeSpan.FromSeconds(settings.WebhookDefaultTimeoutSeconds));
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
// SPA fallback — any GET that didn't match /api/* or /wh/* and doesn't map to
// a static file gets index.html so client-side routes resolve.
app.MapFallbackToFile("index.html");
app.Run();
public partial class Program { }

View File

@@ -11,6 +11,10 @@
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\**\*" CopyToOutputDirectory="Never" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>

View File

@@ -0,0 +1,78 @@
using System.Net;
using Microsoft.AspNetCore.Hosting;
namespace VolcanicCore.Tests;
public class StaticFilesFallbackTests
{
// Every test writes its own tiny index.html into a per-test wwwroot dir so
// we can assert the fallback served it verbatim.
private sealed class WwwFactory : WebFactory
{
public string ContentRoot { get; } = Path.Combine(Path.GetTempPath(), $"vc-www-{Guid.NewGuid():N}");
public WwwFactory()
{
Directory.CreateDirectory(Path.Combine(ContentRoot, "wwwroot"));
File.WriteAllText(Path.Combine(ContentRoot, "wwwroot", "index.html"),
"<!doctype html><title>SPA</title>");
File.WriteAllText(Path.Combine(ContentRoot, "wwwroot", "static-thing.txt"),
"hello static");
}
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.UseContentRoot(ContentRoot);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && Directory.Exists(ContentRoot))
Directory.Delete(ContentRoot, recursive: true);
}
}
[Fact]
public async Task Api_route_still_wins()
{
using var f = new WwwFactory();
var c = f.CreateClient();
var res = await c.GetAsync("/api/health");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
var body = await res.Content.ReadAsStringAsync();
Assert.Contains("\"ok\":true", body);
}
[Fact]
public async Task Unknown_route_falls_back_to_index_html()
{
using var f = new WwwFactory();
var c = f.CreateClient();
var res = await c.GetAsync("/workflows/abc123");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.Contains("SPA", await res.Content.ReadAsStringAsync());
}
[Fact]
public async Task Static_asset_is_served_directly()
{
using var f = new WwwFactory();
var c = f.CreateClient();
var res = await c.GetAsync("/static-thing.txt");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.Equal("hello static", await res.Content.ReadAsStringAsync());
}
[Fact]
public async Task Webhook_route_still_wins_over_fallback()
{
using var f = new WwwFactory();
var c = f.CreateClient();
var res = await c.PostAsync("/wh/nonexistent/foo", new StringContent(""));
// Router isn't registered → 404 from WebhookRouter, NOT the SPA index.
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
Assert.DoesNotContain("SPA", await res.Content.ReadAsStringAsync());
}
}