First, a small note: I faced the exact same problem, but using ASP.NET MVC 5.
So, I cannot guarantee 100% that the same exact solution is going to work.
But, I'm fairly sure that the technique I'm going to describe is sound, and
at the very least can be adapted with a minimal effort.
Coming to the root of the problem: in order to have ASP.NET MVC (MVC for short)
and Angular 2+ happily coexist, they have to know where the routing handling
responsibility is going to end for the server and where it does start for the client.
We have also to understand how MVC simplifies addresses when fed with a routing
request.
The solution I advocate is in creating a single Controller, called NgController
,
which is going to serve all the Single Page Applications (from now on, SPAs) in
your installation.
A sketch of NgController
is this:
public class NgController : Controller
{
private boolean Authenticated()
{
// returns true if the user is authenticated. This system can
// (and probably should) be integrated or replaced with
// one of the ASP.NET Core authentication modules.
return true;
}
public ActionResult Index()
{
if (!Authenticated())
return RedirectToAction("Login", "Authentication", new { ReturnUrl = HttpContext?.Request?.Path });
// Index does not serve directly the app: redirect to default one
return RedirectToAction("TheApp");
}
// One action for each SPA in your installment
public ActionResult TheApp() => UiSinglePageApp("TheApp");
public ActionResult AnotherSPA() => UiSinglePageApp("AnotherSPA");
// The implementation of every SPA action is reusable
private ActionResult UiSinglePageApp(string appName)
{
if (!Authenticated())
return RedirectToAction("Login", "Authentication", new { ReturnUrl = HttpContext?.Request?.Path });
return View("Ui", new SinglePageAppModel { AppName = appName });
}
}
Really basic stuff: we just need to make sure that the default Index
action does NOT serve directly any app.
Then, we need to make sure that the MVC routing works properly:
- is going to map
/TheApp/...
and /AnotherSPA/
as root routes (because we like short mnemonic URLs)
- is not going to mess with the part of the routing that will be handled by Angular.
Some routes and how they should behave:
- an URL of
https://server:port
should redirect to https://server:port/TheApp
https://server:port/TheApp
should be served by the TheApp
action in the NgController
https://server:port/AnotherSPA
should be served by the AnotherSPA
action in the NgController
https://server:port/TheApp/some/token/and/subroute/for/angular/deep/linking
should be served by the
TheApp
action in the NgController
AND everything after TheApp
should be kept as is and go straight
to Angular Router
My choice of configuration is this (MVC 5, needs some adaptation for MVC Core 2):
public static void RegisterRoutes(RouteCollection routes)
{
// the Ng controller is published directly onto the root,
// the * before id makes sure that everything after the action name
// is kept as-is
routes.MapRoute("Ng",
"{action}/{*id}",
new {controller = "Ng", id = UrlParameter.Optional},
new {action = GetUiConstraint()}
);
// this route allows the mapping of the default site route
routes.MapRoute(
"Default",
"{controller}/{action}/{*id}",
new { controller = "Ng", action = "Index", id = UrlParameter.Optional }
);
}
/// <summary> The Ui constraint is to be used as a constraint for the Ng route.
/// It is an expression of the form "^(app1)|(app2)$", with one branch for
/// each possible SPA. </summary>
/// <returns></returns>
public static string GetUiConstraint()
{
// If you don't need this level of indirection, just
// swap this function with the result.
// return "^(TheApp)|(AnotherSPA)$";
var uiActions = new ReflectedControllerDescriptor(typeof(NgController))
.GetCanonicalActions()
.Select(x => x.ActionName.ToLowerInvariant())
.Where(x => x != "index")
.Select(x => "(" + x + ")");
return string.Concat("^", string.Join("|", uiActions), "$");
}
Now the MVC routing and Controller are fine and testable for the Redirection part
and for the "do-not-mess-with-everything-after-the-app-name" part. ;)
We must now setup the only bit of MVC View we need to host the Angular app.
We need a single "Ui" view for the "Ng" controller. The only truly important bit
of the view is the <base>
tag in the head of the html document.
I personally copy the dist
folder into a static
folder inside the MVC site,
just to clarify the naming. The "static" folder is meant to contain all the
(you guessed it) static content. I think that MVC creates a Content
folder, by
default, but I never liked that name.
MVCroot
|-static
|-ng
|-TheApp (this is the dist folder for TheApp)
|-AnotherSPA (this is the dist folder for AnotherSPA)
I also pack every js file into one single file named like the SPA, but it's not necessary.
And I don't like partials, just because I don't need layouts, so I keep everything together.
YMMV.
Ui.cshtml
@{
Layout = null;
var baseUrl = Url.Content("/") + Model.AppName;
var appName = Model.AppName.ToLowerInvariant();
}
<!DOCTYPE html>
<html style="min-height: 100%;">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="~/static/favicon.ico">
<title>My marvelous application</title>
@Url.CssImportContent("static/ng/{appName}/css/main.css")
<!-- This is the CRUCIAL bit. -->
<base href="@baseUrl">
</head>
<body>
<app-root>Loading...</app-root>
@Url.ScriptImportContent($"~static/ng/{appName}/main.js")
</body>
</html>
AAAAAnd... we are done with the MVC side.
On the client side, we just need to ensure Angular manages the routing
using the appropriate defaults (do NOT enable the hash option).
And everything should just work.
Hope it helps.