Skip to content

Commit 7fac84f

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 7ad1880 commit 7fac84f

1 file changed

Lines changed: 53 additions & 54 deletions

File tree

web/Program.cs

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

417417
}
418418

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

459421
// Add redirects and rewrites for each SPA using centralized app names
@@ -470,9 +432,7 @@ void RegisterDbContext<TContext>(string connectionStringKey) where TContext : Db
470432
rewriteOptions.AddRewrite($@"(?i)^{escapedAppName}", $"/2/vue/src/{lowerAppName}/index.html", true);
471433
}
472434

473-
app.UseRewriter(rewriteOptions);
474-
475-
//for the vue src files, use directories in the url but serve index.html
435+
// Default-file convention for /vue (legacy path).
476436
app.UseDefaultFiles(new DefaultFilesOptions
477437
{
478438
DefaultFileNames = new List<string> { "index.html" },
@@ -482,29 +442,68 @@ void RegisterDbContext<TContext>(string connectionStringKey) where TContext : Db
482442
RedirectToAppendTrailingSlash = true
483443
});
484444

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

498-
// Add sitemap middleware after static file handling
499448
app.UseSitemapMiddleware();
500449

501-
// apply settings define earlier
450+
// Routing first so subsequent middleware can defer to a matched MVC endpoint.
502451
app.UseRouting();
503452
app.UseAuthentication();
504453
app.UseAuthorization();
505454
app.UseCookiePolicy();
506455
app.UseSession();
507456

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

0 commit comments

Comments
 (0)