Serving an Aurelia app from a subpath off the root of an MVC app is an interesting idea, this way we can have the index page and other pages as standard server generated pages that are easily discoverable and parsable by search engines but still provide an SPA component for other purposes such as a set of admin or data management pages.

Getting it to work

The following description is based on the assumption that you already have Aurelia running in a .NET Core MVC app, created by swapping out the Angular app from a dotnet new angular generated project. If not I have detailed this in a previous post: ASP.NET Core Aurelia SPA

The current dotnet new generated SPA templates plant the SPA at the root of the .NET Core MVC site and provide a "SampleDataController" under the "/api" subpath. In order to move the SPA we need to branch the middleware pipeline based on the request path.

To achieve that we can use either the "Map" or "MapWhen" middleware which creates a non-rejoining branch as opposed to "UseWhen" which creates a rejoining branch. Once the request is being handled by the SPA middleware there is no need to rejoin the main branch.

Updating the Aurelia project

Before we go any further we first need to make the Aurelia app run from a subpath off the root. Luckily this is easy enough to achieve, go into ClientApp/webpack.config.js and change the line const baseUrl = '/'; to const baseUrl = '/app/'; note that the trailing slash is important here.

The baseUrl constant sets the value of output.publicPath, the recommendation in the webpack docs is to use a trailing slash for public path:

The value of the option is prefixed to every URL created by the runtime or loaders. Because of this the value of this option ends with / in most cases.

https://webpack.js.org/configuration/output/#output-publicpath

Make sure devServer.publicPath always starts and ends with a forward slash.

https://webpack.js.org/configuration/dev-server/#devserver-publicpath-

I am not sure of the reasoning behind this but when not using a trailing slash I found that the Aurelia app does not work properly without making other modifications to the webpack config. Refer to this issue for more detail: https://github.com/webpack-contrib/file-loader/issues/286

As a result of the trailing slash requirement, when accessing the Aurelia app in dev mode after running au run you have to visit: http://localhost:8080/app/ if you miss off the trailing slash it doesn't work. At first I didn't like this, but when you consider the hash based url-fragment routing it makes sense, the following url would look odd without the trailing slash after /app: http://localhost:8080/app/#/users

Updating the .NET Core project

It turned out that getting this work seamlessly in both production and development modes was a bit tricky.

For example we could just take the dotnet new generated SPA template code in Startup.cs and move it into a branch using Map:

app.Map("/app", frontendApp => {
    if (!env.IsDevelopment()) {
        // In Production env, ClientApp is served using minified and bundled code from 'ClientApp/dist'
        frontendApp.UseSpaStaticFiles();
    }
    frontendApp.UseSpa(spa => {
        spa.Options.SourcePath = "ClientApp";

        if (env.IsDevelopment()) {
            // Aurelia Webpack Dev Server runs on port 8080
            spa.UseProxyToSpaDevelopmentServer(baseUri: "http://localhost:8080");
        }
    });
});

but this only works in Production mode.

Note that I have wrapped UseSpaStaticFiles in a check for !env.IsDevelopment(), to see my reasoning refer to this issue that I logged: https://github.com/aspnet/JavaScriptServices/issues/1781

The above code works in production mode because of the way Map works, according to the docs:

When Map is used, the matched path segment(s) are removed from HttpRequest.Path and appended to HttpRequest.PathBase for each request.

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-2.1#use-run-and-map

In Production mode, when a request for a file such as "https://localhost:5001/app/app.{hash}.bundle.js" goes through the middleware, it is changed to "https://localhost:5001/app.{hash}.bundle.js" inside the app.Map block before being handled by the UseSpaStaticFiles middleware. The static bundle files generated by webpack in the 'ClientApp/dist' dir are not contained in an '/app' subfolder so a matching file is found. The output.publicPath property only sets what the public path is going to be, ie. what the url should be, not where it is on the filesystem.

As you will probably have guessed by now, this same code does not work in Development mode because passing the modified request "https://localhost:5001/app.{hash}.bundle.js" to the SpaProxy is going to result in the Aurelia dev server receiving a request like this "http://localhost:8080/app.{hash}.bundle.js" when it is expecting "http://localhost:8080/app/app.{hash}.bundle.js"

The first and most obvious solution is to modify the SpaProxy baseUri like this spa.UseProxyToSpaDevelopmentServer(baseUri: "http://localhost:8080/app/"); however due to the way in which the SpaProxy has been coded that solution doesn't work. I also discovered that SpaProxy has some rather undesirable behaviour, refer to the following issue that I logged: https://github.com/aspnet/JavaScriptServices/issues/1779

Getting it working in both Production and Development modes

To prevent the path from being modified, we could use "MapWhen" which does not modify the request path, but I would rather not duplicate all of the Spa middleware code in two different blocks of branching code depending on the value of env.IsDevelopment().

The solution that I came up with involved creating a custom version of the Map middleware that I called "MapPath" which accepts a parameter which controls the request path modification behaviour.

app.MapPath("/app", !env.IsDevelopment(), frontendApp => {
    if (!env.IsDevelopment()) {
        // In Production env, ClientApp is served using minified and bundled code from 'ClientApp/dist'
        frontendApp.UseSpaStaticFiles();
    }
    frontendApp.UseSpa(spa => {
        spa.Options.SourcePath = "ClientApp";

        if (env.IsDevelopment()) {
            // Aurelia Webpack Dev Server runs on port 8080
            spa.UseProxyToSpaDevelopmentServer(baseUri: "http://localhost:8080");
        }
    });
});
Issues with Aurelia HMR (Hot Module Replacement)

The first thing I noticed when testing this was that HMR was no longer working. At the bottom of this post ASP.NET Core Aurelia SPA I detailed a change that had to be made to get HMR working, even with that in place it was now broken when running from a subpath.

Looking at the chrome console logs it was clear that the webpack dev server client was still trying to connect to "https://localhost:5001/sockjs-node/" rather than "https://localhost:5001/app/sockjs-node/"

It was also clear that something had to be done with the following line in the index.ejs file: <script src="/webpack-dev-server.js"></script> which loads the webpack dev server client script from the root path of the dev server where it lives.

The first issue is caused by the fact that although the webpack-dev-server client can be instructed to connect to a different path, there is currently no way to make the websocket server listen at a different path. The client can be made to connect to a different path by modifying "ClientApp/aurelia_project/tasks/run.ts" there is a line which updates a copy of the webpack config used when running the Aurelia in dev mode:

config.entry.app.unshift(`webpack-dev-server/client?http://${opts.host}:${opts.port}/`, 'webpack/hot/dev-server');

My previous fix involved changing this to the root path '/' relative to the current host and port, rather than a specific host and port:

config.entry.app.unshift(`webpack-dev-server/client?/`, 'webpack/hot/dev-server');

If the path is changed to '/app/' or removed altogether which has the same effect:

config.entry.app.unshift(`webpack-dev-server/client?/app/`, 'webpack/hot/dev-server');
// or
config.entry.app.unshift(`webpack-dev-server/client`, 'webpack/hot/dev-server');

The websocket requests now go to "https://localhost:5001/app/sockjs-node/" but there is nobody listening at the other end.

Until this pull request is merged and released https://github.com/webpack/webpack-dev-server/pull/1553 we need a workaround.

The second issue appeared to be more difficult at first until I realised that the webpack dev server client <script src="/webpack-dev-server.js"></script> did not need to be included in the index.ejs file, that is as long as we make a modification to "ClientApp/aurelia_project/tasks/run.ts"

Looking at the Aurelia CLI generated code, it seems there is an inconsistency because the client is always included in the index.ejs in dev mode, but when using HMR, run.ts also injects the client in addition to the hot reload setting into the entry.app section of the webpack config. This can be cleaned up by removing <script src="/webpack-dev-server.js"></script> from index.ejs and changing the code in run.ts to this:

  if (project.platform.hmr || CLIOptions.hasFlag('hmr')) {
    config.plugins.push(new webpack.HotModuleReplacementPlugin());
    config.entry.app.unshift(`webpack-dev-server/client?/`, 'webpack/hot/dev-server');
  } else {
    config.entry.app.unshift(`webpack-dev-server/client?/`);
  }

Refer to this issue which I logged: https://github.com/aurelia/cli/issues/966

With those changes to the Aurelia config in place we can update .NET Core to also proxy these websocket requests that fall outside of the '/app/' subpath by adding the following code above the app.MapPath block:

app.MapWhen(context => WebPackDevServerMatcher(context), webpackDevServer => {
    webpackDevServer.UseSpa(spa => {
        spa.UseProxyToSpaDevelopmentServer(baseUri: "http://localhost:8080");
    });
});

Define the WebPackDevServerMatcher method in Startup.cs or anywhere you like:

// Captures the websocket requests generated when using webpack dev server in the following ways:
// via: https://localhost:5001/app/ (inline mode)
// via: https://localhost:5001/webpack-dev-server/app/  (iframe mode)
// captures requests like these:
// https://localhost:5001/webpack-dev-server/app/
// https://localhost:5001/__webpack_dev_server__/live.bundle.js
// wss://localhost:5001/sockjs-node/978/qhjp11ck/websocket
private bool WebPackDevServerMatcher(HttpContext context) {
    return context.Request.Path.StartsWithSegments("/webpack-dev-server") ||
        context.Request.Path.StartsWithSegments("/__webpack_dev_server__") ||
        context.Request.Path.StartsWithSegments("/sockjs-node");
}

The above solution is not great, it works for the purposes in this post but would quickly fall over if we tried to run multiple SPA apps at different subpaths, which is something I might cover in a future post.

Summary

Running an Aurelia or other SPA app inside an MVC app is an interesting idea which removes the need to either: a) use CORS or b) use an NGINX reverse proxy to achieve the same by putting both apps under the same origin at different subpaths

If future requirements mean that that the SPA app needs to be separated it should not really be too much of be problem.

A working example based on what I have laid out here is available in the following repo: https://github.com/chrisckc/DotNetCoreAureliaSpaSubpath

Next Post Previous Post