Skip to content

Commit d85d125

Browse files
committed
fix(routing): let MVC controllers win over Vue SPA shell on shared prefixes
Vue app prefixes like /CMS, /Effort, etc. were claimed in two places that ran before MVC routing: a dev-only Vite proxy and the URL rewriter ("(?i)^{appName}" → "/2/vue/src/{app}/index.html"). Any controller route under those prefixes - notably /CMS/Files (CMSController.Files, the modernized download surface VPR-138 hardens) - was returned the SPA shell instead of executing. Restructure so SPA shell serving (Vite proxy + rewriter + /2/vue static files) runs inside a UseWhen branch gated on ctx.GetEndpoint() == null. MVC controller routes claim their endpoints during UseRouting() and the branch is skipped; everything else (Vue SPA roots, deep client-side routes, Vite asset paths) still hits the branch and gets the SPA shell. Smoke-tested locally: - /CMS/Files?ids=<bogus> now returns 404 from the controller (was 200 SPA shell). - /CMS still serves the Vue SPA shell. - /Effort and /favicon.ico unaffected. - Vite /2/vue/@vite/client and HMR assets unaffected.
1 parent 48c508f commit d85d125

1 file changed

Lines changed: 54 additions & 55 deletions

File tree

web/Program.cs

Lines changed: 54 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -420,44 +420,6 @@ void RegisterDbContext<TContext>(string connectionStringKey) where TContext : Db
420420

421421
}
422422

423-
// In development, set up Vite proxy BEFORE rewrite rules so it can handle .ts/.js files
424-
if (app.Environment.IsDevelopment())
425-
{
426-
// Development: Proxy Vue.js assets to Vite dev server for hot module replacement (HMR)
427-
// This middleware intercepts requests for Vue assets and forwards them to the Vite dev server
428-
app.Use(async (context, next) =>
429-
{
430-
if (ViteProxyHelpers.ShouldProxyToVite(context, VueAppNames))
431-
{
432-
try
433-
{
434-
// Use the registered HttpClient from dependency injection
435-
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
436-
var httpClient = httpClientFactory.CreateClient("ViteProxy");
437-
438-
// Build the Vite server URL and try to proxy directly
439-
var viteUrl = ViteProxyHelpers.BuildViteUrl(context.Request.Path, context.Request.QueryString, VueAppNames);
440-
var requestMessage = ViteProxyHelpers.CreateProxyRequest(context, viteUrl);
441-
using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);
442-
443-
// Copy the response back to the client
444-
await ViteProxyHelpers.CopyProxyResponse(context, response);
445-
return; // Successfully proxied, don't continue to static files
446-
}
447-
catch (Exception ex)
448-
{
449-
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
450-
logger.LogDebug(ex, "Vite server not available, falling back to static files for {Path}",
451-
Uri.EscapeDataString(context.Request.Path.Value ?? "unknown"));
452-
// Fall through to static file serving
453-
}
454-
}
455-
456-
// Continue to static file serving (either Vite not needed or not available)
457-
await next();
458-
});
459-
}
460-
461423
var rewriteOptions = new RewriteOptions();
462424

463425
// Add redirects and rewrites for each SPA using centralized app names
@@ -474,41 +436,78 @@ void RegisterDbContext<TContext>(string connectionStringKey) where TContext : Db
474436
rewriteOptions.AddRewrite($@"(?i)^{escapedAppName}", $"/2/vue/src/{lowerAppName}/index.html", true);
475437
}
476438

477-
app.UseRewriter(rewriteOptions);
478-
479-
//for the vue src files, use directories in the url but serve index.html
439+
// Default-file convention for /vue (legacy path).
480440
app.UseDefaultFiles(new DefaultFilesOptions
481441
{
482442
DefaultFileNames = new List<string> { "index.html" },
483443
FileProvider = new PhysicalFileProvider(
484-
Path.Combine(builder.Environment.ContentRootPath, "wwwroot/vue")),
444+
Path.Combine(builder.Environment.ContentRootPath, "wwwroot", "vue")),
485445
RequestPath = "/vue",
486446
RedirectToAppendTrailingSlash = true
487447
});
488448

489-
// Static file serving configuration
490-
// Serve built Vue files - in development proxy middleware runs first,
491-
// in production these files are served directly
492-
app.UseStaticFiles(new StaticFileOptions
493-
{
494-
FileProvider = new PhysicalFileProvider(
495-
Path.Combine(builder.Environment.ContentRootPath, "wwwroot/vue")),
496-
RequestPath = "/2/vue"
497-
});
498-
499-
// Serve other static files
449+
// General static files (favicon, /css, /js, /images, etc.).
500450
app.UseStaticFiles();
501451

502-
// Add sitemap middleware after static file handling
503452
app.UseSitemapMiddleware();
504453

505-
// apply settings define earlier
454+
// Routing first so subsequent middleware can defer to a matched MVC endpoint.
506455
app.UseRouting();
507456
app.UseAuthentication();
508457
app.UseAuthorization();
509458
app.UseCookiePolicy();
510459
app.UseSession();
511460

461+
// SPA shell serving — Vue app prefixes like /CMS, /Effort, etc.
462+
// Only runs when no MVC controller endpoint claimed the path, so attribute-routed
463+
// legacy endpoints (e.g. /CMS/Files → CMSController.Files) reach the controller
464+
// instead of being rewritten to the SPA shell.
465+
app.UseWhen(
466+
ctx => ctx.GetEndpoint() is null,
467+
branch =>
468+
{
469+
if (app.Environment.IsDevelopment())
470+
{
471+
// Dev: proxy Vue assets and SPA routes to the Vite dev server (HMR).
472+
branch.Use(async (context, next) =>
473+
{
474+
if (ViteProxyHelpers.ShouldProxyToVite(context, VueAppNames))
475+
{
476+
try
477+
{
478+
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
479+
var httpClient = httpClientFactory.CreateClient("ViteProxy");
480+
481+
var viteUrl = ViteProxyHelpers.BuildViteUrl(context.Request.Path, context.Request.QueryString, VueAppNames);
482+
var requestMessage = ViteProxyHelpers.CreateProxyRequest(context, viteUrl);
483+
using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);
484+
485+
await ViteProxyHelpers.CopyProxyResponse(context, response);
486+
return;
487+
}
488+
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
489+
{
490+
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
491+
logger.LogDebug(ex, "Vite server not available, falling back to static files for {Path}",
492+
Uri.EscapeDataString(context.Request.Path.Value ?? "unknown"));
493+
}
494+
}
495+
496+
await next();
497+
});
498+
}
499+
500+
// Prod (and dev fallback): rewrite SPA routes to the built SPA shell,
501+
// then serve the static file from wwwroot/vue.
502+
branch.UseRewriter(rewriteOptions);
503+
branch.UseStaticFiles(new StaticFileOptions
504+
{
505+
FileProvider = new PhysicalFileProvider(
506+
Path.Combine(builder.Environment.ContentRootPath, "wwwroot", "vue")),
507+
RequestPath = "/2/vue"
508+
});
509+
});
510+
512511
// All health-check pipeline wiring lives in HealthCheckExtensions.
513512
app.UseViperHealthChecks();
514513

0 commit comments

Comments
 (0)