Aurelia and .NET Core 2.0 (using Razor Views and Dynamic Routes)
Background
I am a freelance web developer and have my own custom framework / CMS which I use for my clients. A few years ago, I decided that I wanted to have the admin backend to be an SPA and so I decided on Rob Eisenberg's Durandal. This worked beautifully - only, I had to figure out how to build the routes dynamically since I had a plugin-style architecture... I could enable/disable plugins per site as needed. I managed to get that working and Durandal has served me well these last few years.
I am now moving my framework / CMS from ASP .NET MVC 5 to ASP .NET Core 2.0. With that, I have also started looking into migrating from Durandal to Aurelia. I was a little disappointed to find that although quite a few people have asked, "How to get Razor (.cshtml) views working with Aurelia?", the solutions appeared to be few and unsatisfactory. Thus, I spent a little time working out how to do this for myself and finally managed to do so. Not only do I have Aurelia working with Razor views, but I can build the routes dynamically as well. Read on to find out how I did it...
Basics
Let's start with the basics that apply to any Aurelia setup:
- Install NodeJS if you don't already have it.
- Install JSPM globally:
npm install -g jspm
- Create a new .NET Core project in the usual way.
- Initialize JSPM:
jspm init
NOTE: Do this from the root directory of the project (not the solution root).
The steps should look something like this:
$ jspm init
warn Running jspm globally, it is advisable to locally install jspm via npm install jspm --save-dev.
Package.json file does not exist, create it? [yes]:
Would you like jspm to prefix the jspm package.json properties under jspm? [yes]:
Enter server baseURL (public folder path) [./]:./wwwroot
Enter jspm packages folder [wwwroot\jspm_packages]:
Enter config file path [wwwroot\config.js]:
Configuration file wwwroot\config.js doesn't exist, create it? [yes]:
Enter client baseURL (public folder URL) [/]:
Do you wish to use a transpiler? [yes]:
Which ES6 transpiler would you like to use, Babel, TypeScript or Traceur? [babel]:
ok Verified package.json at package.json
Verified config file at wwwroot\config.js
Looking up loader files...
system-csp-production.js
system.js
system.src.js
system-csp-production.src.js
system-csp-production.js.map
system.js.map
system-polyfills.js.map
system-polyfills.src.js
system-polyfills.js
Using loader versions:
systemjs@0.19.46
Looking up npm:babel-core
Looking up npm:babel-runtime
Looking up npm:core-js
Updating registry cache...
ok Installed babel as npm:babel-core@^5.8.24 (5.8.38)
Looking up github:systemjs/plugin-json
Looking up github:jspm/nodelibs-fs
Looking up github:jspm/nodelibs-process
Looking up github:jspm/nodelibs-path
ok Installed github:systemjs/plugin-json@^0.1.0 (0.1.2)
ok Installed github:jspm/nodelibs-fs@^0.1.0 (0.1.2)
Looking up npm:process
ok Installed github:jspm/nodelibs-process@^0.1.0 (0.1.2)
Looking up npm:path-browserify
ok Installed github:jspm/nodelibs-path@^0.1.0 (0.1.0)
ok Installed npm:process@^0.11.0 (0.11.10)
ok Installed npm:path-browserify@0.0.0 (0.0.0)
Looking up github:jspm/nodelibs-assert
Looking up github:jspm/nodelibs-vm
Looking up npm:assert
ok Installed github:jspm/nodelibs-assert@^0.1.0 (0.1.0)
Looking up npm:util
ok Installed npm:assert@^1.3.0 (1.4.1)
Looking up npm:vm-browserify
Looking up npm:inherits
ok Installed github:jspm/nodelibs-vm@^0.1.0 (0.1.0)
ok Installed npm:util@0.10.3 (0.10.3)
Looking up npm:indexof
ok Installed npm:inherits@2.0.1 (2.0.1)
ok Installed npm:vm-browserify@0.0.4 (0.0.4)
ok Installed npm:indexof@0.0.1 (0.0.1)
Looking up github:jspm/nodelibs-buffer
Looking up github:jspm/nodelibs-util
Looking up npm:buffer
ok Installed github:jspm/nodelibs-buffer@^0.1.0 (0.1.1)
Looking up npm:base64-js
Looking up npm:ieee754
ok Installed npm:buffer@^5.0.6 (5.1.0)
Downloading npm:base64-js@1.3.0
ok Installed npm:ieee754@^1.1.4 (1.1.11)
ok Installed github:jspm/nodelibs-util@^0.1.0 (0.1.0)
ok Installed npm:base64-js@^1.0.2 (1.3.0)
ok Installed core-js as npm:core-js@^1.1.4 (1.2.7)
ok Installed babel-runtime as npm:babel-runtime@^5.8.24 (5.8.38)
ok Loader files downloaded successfully
Pay special attention to set the baseURL
to ./wwwroot
.
OPTIONAL: When working with JSPM, I recommend using the following Visual Studio extension: Package Installer.
Creating the "skeleton navigation" Project
- Install the following packages with JSPM:
aurelia-binding
aurelia-bootstrapper
aurelia-dependency-injection
aurelia-event-aggregator
aurelia-framework
aurelia-history
aurelia-http-client
aurelia-loader
aurelia-loader-default
aurelia-logging
aurelia-metadata
aurelia-pal
aurelia-pal-browser
aurelia-path
aurelia-route-recognizer
aurelia-router
aurelia-task-queue
aurelia-templating
aurelia-templating-resources
aurelia-templating-router
bootstrap
font-awesome
jquery
- Open your _Layout.cshtml and set the contents to the following:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
@RenderBody()
</body>
</html>
Don't bother adding anything inside the <body>
, as Aurelia will simply remove it to replace with the current "view
".
- Create a folder named aurelia-app under your wwwroot.
- Copy the JavaScript and HTML for the views from the official skeleton-navigation project. In this case, we want the following files copied to your new aurelia-app folder:
welcome.js
NOTE: I renamed this to index.js in my demo project. The rest of this article assumes you do the same.
Additionally, add the following JavaScript into a file called flickr.js also under the aurelia-app folder:
import {inject} from 'aurelia-framework';
import {HttpClient} from 'aurelia-http-client';
@inject(HttpClient)
export class Flickr{
heading = 'Flickr';
images = [];
url =
'http://api.flickr.com/services/feeds/photos_public.gne?tags=mountain&tagmode=any&format=json';
constructor(http){
this.http = http;
}
activate(){
return this.http.jsonp(this.url).then(response => {
this.images = response.content.items;
});
}
}
and then the following files should be copied directly under wwwroot:
app.js
main.js
- Next, change your
HomeController
to look as follows:
public class HomeController : Controller
{
[Route("")]
public IActionResult Host()
{
return View();
}
[Route("index")]
public IActionResult Index()
{
return PartialView();
}
[Route("app")]
public IActionResult App()
{
return PartialView();
}
[Route("nav-bar")]
public IActionResult NavBar()
{
return PartialView();
}
[Route("flickr")]
public IActionResult Flickr()
{
return PartialView();
}
}
NOTE: It is important to return PartialView()
instead of View()
, otherwise ASP.NET will obviously return your view combined with the contents of the _Layout.cshtml.
- Under Views\Home, delete any existing views and add new ones as follows:
App.cshtml
Flickr.cshtml
Host.cshtml
Index.cshtml
NavBar.cshtml
- Copy the following source files in the
skeleton-navigation
project to the following destinations in your own project:
/src/skeleton/src/app.html -> App.cshtml
/src/skeleton/src/nav-bar.html -> NavBar.cshtml
/src/skeleton/src/welcome.html -> Index.cshtml
/src/skeleton/views/home/Index.cshtml -> Host.cshtml
- Add the following to Flickr.cshtml:
<template>
<section class="au-animate">
<h2>${heading}</h2>
<div class="row au-stagger">
<div class="col-sm-6 col-md-3 flickr-img au-animate" repeat.for="image of images">
<a class="thumbnail">
<img src.bind="image.media.m" style="height:200px" />
</a>
</div>
</div>
</section>
</template>
Routing
- Create a folder in your project called Infrastructure (or whatever you prefer). Add the following:
AureliaRoute.cs
public struct AureliaRoute
{
public string Route { get; set; }
public string Name { get; set; }
public string ModuleId { get; set; }
public object Nav { get; set; }
public string Title { get; set; }
}
IAureliaRouteProvider.cs
public interface IAureliaRouteProvider
{
string Area { get; }
IEnumerable<AureliaRoute> Routes { get; }
}
public class AureliaRouteProvider : IAureliaRouteProvider
{
public string Area => "Admin";
public IEnumerable<AureliaRoute> Routes
{
get
{
var routes = new List<AureliaRoute>();
routes.Add(new AureliaRoute { Route = "", Name = "index",
ModuleId = "/aurelia-app/index", Nav = true, Title = "Home" });
routes.Add(new AureliaRoute { Route = "flickr", Name = "flickr",
ModuleId = "/aurelia-app/flickr", Nav = true, Title = "Flickr" });
return routes;
}
}
}
The IAureliaRouteProvider
interface will allow us to define routes we wish to have in our Aurelia app. In my case, each of my plugins could have an instance of IAureliaRouteProvider
. And note that each ModuleId
contains /aurelia-app/ as a prefix. This is important and you will see why at the end of this article where we use Aurelia's convertOriginToViewUrl
function to tell it where to find our views.
- We need to register our instances of
IAureliaRouteProvider
on application startup, so we can resolve all of them when needed. I am using Autofac, so I register our one instance as follows:
builder.RegisterType<AureliaRouteProvider>().As<IAureliaRouteProvider>().InstancePerDependency();
- Now that we have our
IAureliaRouteProvider
registered, we need to resolve it and configure Aurelia to use it. First, we modify HomeController
as follows:
public class HomeController : Controller
{
private readonly IEnumerable<IAureliaRouteProvider> routeProviders;
public HomeController(
IEnumerable<IAureliaRouteProvider> routeProviders)
{
this.routeProviders = routeProviders;
}
[Route("get-spa-routes")]
public JsonResult GetSpaRoutes()
{
var routes = routeProviders
.Where(x => x.Area == "Admin")
.SelectMany(x => x.Routes);
return Json(routes);
}
}
And finally, we modify our app.js as follows:
import { HttpClient } from 'aurelia-http-client';
import { PLATFORM } from 'aurelia-pal';
import $ from 'jquery';
export class App {
async configureRouter(config, router) {
config.title = 'Aurelia';
this.router = router;
let http = new HttpClient();
let response = await http.get("/get-spa-routes");
$(response.content).each(function (index, item) {
this.router.addRoute({
route: item.route,
name: item.name,
moduleId: PLATFORM.moduleName(item.moduleId),
title: item.title,
nav: item.nav
});
});
this.router.refreshNavigation();
}
}
We are almost done!
Configuring Aurelia to Use MVC routes Instead of .html Files
The last thing we need to do is to get Aurelia to look at our .NET Core routes (defined in HomeController
) instead of looking for .html files, which it does by default. The way to do this is to open our main.js and override the ViewLocator.prototype.convertOriginToViewUrl
function with our own implementation. The following was my first attempt and although it works, it does not allow for some view URLs that should be left as the default for serving actual .html files. For example, the aurelia-kendoui-bridge.
export function configure(aurelia) {
aurelia.use
.standardConfiguration()
.developmentLogging();
ViewLocator.prototype.convertOriginToViewUrl = function (origin) {
var viewUrl = null;
var idx = origin.moduleId.indexOf('aurelia-app');
if (idx != -1) {
viewUrl = origin.moduleId.substring(idx + 11).replace(".js", '');
}
else {
var split = origin.moduleId.split("/");
viewUrl = split[split.length - 1].replace(".js", '');
}
return viewUrl;
}
aurelia.start().then(a => a.setRoot("./app", document.body));
}
NOTE: Originally, I just split the moduleId
by "/" and took the last part, but that caused problems for me when having routes that NEED to have a slash in them. So instead, I decided to add an /aurelia-app/
prefix to my module IDs (see this in the routes defined in the AureliaRouteProvider
instance).
In my second attempt, I allowed for .html files under jspm_packages:
ViewLocator.prototype.convertOriginToViewUrl = function (origin) {
var viewUrl = null;
var idx = origin.moduleId.indexOf('aurelia-app');
if (idx != -1) {
viewUrl = origin.moduleId.substring(idx + 11).replace(".js", '');
}
else if (origin.moduleId.indexOf('jspm_packages') !== -1) {
viewUrl = origin.moduleId.replace(".js", '.html');
}
else {
var split = origin.moduleId.split("/");
viewUrl = split[split.length - 1].replace(".js", '');
}
return viewUrl;
}
As you can see, this was an improvement, but still not quite perfect. At this point, I have requested the Aurelia team to create a new viewUrl
property on the routes. We can have Aurelia still look for .html files by default, but if there’s a viewUrl
property in the route definition, then use that instead and we won't have to implement ViewLocator.prototype.convertOriginToViewUrl
. This way, modules that are not being served by ASP.NET controllers and have actual HTML files (for example: aurelia-kendo-bridge
and others) won’t be affected... and it will allow me to have the .js file location decoupled from the .html file location, so I can put them wherever I want and additionally, I can then use embedded JavaScript as well, which is a requirement in some parts of my CMS.
At the time of writing, it is uncertain when this viewUrl
will be implemented on the Aurelia routes. So I came up with a temporary solution. I decided I would modify the IAureliaRouteProvider
interface to allow for a dictionary of mappings from module IDs to view URLs.
- The first step is to do just that:
public interface IAureliaRouteProvider
{
string Area { get; }
IEnumerable<AureliaRoute> Routes { get; }
IDictionary<string, string> ModuleIdToViewUrlMappings { get; }
}
public class AureliaRouteProvider : IAureliaRouteProvider
{
public string Area => "Admin";
public IEnumerable<AureliaRoute> Routes
{
get
{
var routes = new List<AureliaRoute>();
routes.Add(new AureliaRoute { Route = "", Name = "index",
ModuleId = "/aurelia-app/index", Nav = true, Title = "Home" });
routes.Add(new AureliaRoute { Route = "flickr", Name = "flickr",
ModuleId = "/aurelia-app/flickr", Nav = true, Title = "Flickr" });
return routes;
}
}
public IDictionary<string, string> ModuleIdToViewUrlMappings => new Dictionary<string, string>
{
{ "aurelia-app/index", "index" },
{ "aurelia-app/app", "app" },
{ "aurelia-app/nav-bar", "nav-bar" },
{ "aurelia-app/flickr", "flickr" },
};
}
- Secondly, we need to add one more action in our
HomeController
:
[Route("get-moduleId-to-viewUrl-mappings")]
public JsonResult GetModuleIdToViewUrlMappings()
{
var mappings = routeProviders
.Where(x => x.Area == "Admin")
.SelectMany(x => x.ModuleIdToViewUrlMappings);
return Json(mappings);
}
- And finally, we go back to main.js to make the following change:
ViewLocator.prototype.convertOriginToViewUrl = function (origin) {
let viewUrl = null;
let storageKey = "moduleIdToViewUrlMappings";
let mappingsJson = window.localStorage.getItem(storageKey);
let mappings = null;
if (mappingsJson) {
mappings = JSON.parse(mappingsJson);
}
if (!mappings) {
$.ajax({
url: "/get-moduleId-to-viewUrl-mappings",
type: "GET",
dataType: "json",
async: false
}).done(function (content) {
window.localStorage.setItem(storageKey, JSON.stringify(content));
mappings = content;
}).fail(function (jqXHR, textStatus, errorThrown) {
console.log(textStatus + ': ' + errorThrown);
});
}
if (mappings) {
let idx = origin.moduleId.indexOf('aurelia-app');
if (idx != -1) {
let moduleId = origin.moduleId.substring(idx).replace(".js", '');
let item = mappings.find(x => x.key === moduleId);
if (item) {
viewUrl = item.value;
}
else {
viewUrl = moduleId;
}
}
}
if (!viewUrl) {
viewUrl = origin.moduleId.replace(".js", '.html');
}
return viewUrl;
}
And that's it! You can download a fully working demo, which also includes the Todo List and Contact Manager tutorials (as well as a bonus demo page for the Aurelia-Kendo-Bridge
which uses an OData backend) from my GitHub project: aurelia-razor-netcore2-skeleton.
Enjoy!