Skip to content

Commit 500a91b

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 1a0282f commit 500a91b

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
@@ -431,44 +431,6 @@ void RegisterDbContext<TContext>(string connectionStringKey) where TContext : Db
431431

432432
}
433433

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

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

488-
app.UseRewriter(rewriteOptions);
489-
490-
//for the vue src files, use directories in the url but serve index.html
450+
// Default-file convention for /vue (legacy path).
491451
app.UseDefaultFiles(new DefaultFilesOptions
492452
{
493453
DefaultFileNames = new List<string> { "index.html" },
494454
FileProvider = new PhysicalFileProvider(
495-
Path.Join(builder.Environment.ContentRootPath, "wwwroot/vue")),
455+
Path.Join(builder.Environment.ContentRootPath, "wwwroot", "vue")),
496456
RequestPath = "/vue",
497457
RedirectToAppendTrailingSlash = true
498458
});
499459

500-
// Static file serving configuration
501-
// Serve built Vue files - in development proxy middleware runs first,
502-
// in production these files are served directly
503-
app.UseStaticFiles(new StaticFileOptions
504-
{
505-
FileProvider = new PhysicalFileProvider(
506-
Path.Join(builder.Environment.ContentRootPath, "wwwroot/vue")),
507-
RequestPath = "/2/vue"
508-
});
509-
510-
// Serve other static files
460+
// General static files (favicon, /css, /js, /images, etc.).
511461
app.UseStaticFiles();
512462

513-
// Add sitemap middleware after static file handling
514463
app.UseSitemapMiddleware();
515464

516-
// apply settings define earlier
465+
// Routing first so subsequent middleware can defer to a matched MVC endpoint.
517466
app.UseRouting();
518467
app.UseAuthentication();
519468
app.UseAuthorization();
520469
app.UseCookiePolicy();
521470
app.UseSession();
522471

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

0 commit comments

Comments
 (0)