"
 
 
 
ASP.NET (snapshot 2017) Microsoft documentation and samples

Using JavaScriptServices for Creating Single Page Applications with ASP.NET Core

By Scott Addie and Fiyaz Hasan

A Single Page Application (SPA) is a popular type of web application due to its inherent rich user experience. Integrating client-side SPA frameworks or libraries, such as Angular or React, with server-side frameworks like ASP.NET Core can be difficult. JavaScriptServices was developed to reduce friction in the integration process. It enables seamless operation between the different client and server technology stacks.

View or download sample code ((xref:)how to download)

What is JavaScriptServices?

JavaScriptServices is a collection of client-side technologies for ASP.NET Core. Its goal is to position ASP.NET Core as developers’ preferred server-side platform for building SPAs.

JavaScriptServices consists of three distinct NuGet packages: * Microsoft.AspNetCore.NodeServices (NodeServices) * Microsoft.AspNetCore.SpaServices (SpaServices) * Microsoft.AspNetCore.SpaTemplates (SpaTemplates)

These packages are useful if you: * Run JavaScript on the server * Use a SPA framework or library * Build client-side assets with Webpack

Much of the focus in this article is placed on using the SpaServices package.

What is SpaServices?

SpaServices was created to position ASP.NET Core as developers’ preferred server-side platform for building SPAs. SpaServices is not required to develop SPAs with ASP.NET Core, and it doesn’t lock you into a particular client framework.

SpaServices provides useful infrastructure such as: * Server-side prerendering * Webpack Dev Middleware * Hot Module Replacement * Routing helpers

Collectively, these infrastructure components enhance both the development workflow and the runtime experience. The components can be adopted individually.

Prerequisites for using SpaServices

To work with SpaServices, install the following: * Node.js (version 6 or later) with npm * To verify these components are installed and can be found, run the following from the command line:

```console
node -v && npm -v
```

Note: If you’re deploying to an Azure web site, you don’t need to do anything here — Node.js is installed and available in the server environments.

Server-side prerendering

A universal (also known as isomorphic) application is a JavaScript application capable of running both on the server and the client. Angular, React, and other popular frameworks provide a universal platform for this application development style. The idea is to first render the framework components on the server via Node.js, and then delegate further execution to the client.

ASP.NET Core (xref:)Tag Helpers provided by SpaServices simplify the implementation of server-side prerendering by invoking the JavaScript functions on the server.

Prerequisites

Install the following: * aspnet-prerendering npm package:

```console
npm i -S aspnet-prerendering
```

Configuration

The Tag Helpers are made discoverable via namespace registration in the project’s *_ViewImports.cshtml* file:

[!code-cshtmlMain]

   1:  @using SpaServicesSampleApp
   2:  @addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers"
   3:  @addTagHelper "*, Microsoft.AspNetCore.SpaServices"

These Tag Helpers abstract away the intricacies of communicating directly with low-level APIs by leveraging an HTML-like syntax inside the Razor view:

[!code-cshtmlMain]

   1:  @{
   2:      ViewData["Title"] = "Home Page";
   3:  }
   4:   
   5:  <app asp-prerender-module="ClientApp/dist/main-server">Loading...</app>
   6:   
   7:  @* Example of asp-prerender-data Tag Helper passing data to server-side JavaScript *@
   8:  @*
   9:  <app asp-prerender-module="ClientApp/dist/main-server"
  10:          asp-prerender-data='new {
  11:              UserName = "John Doe"
  12:          }'>Loading...</app>
  13:  *@
  14:   
  15:  <script src="~/dist/vendor.js" asp-append-version="true"></script>
  16:  @section scripts {
  17:      <script src="~/dist/main-client.js" asp-append-version="true"></script>
  18:  }

The asp-prerender-module Tag Helper

The asp-prerender-module Tag Helper, used in the preceding code example, executes ClientApp/dist/main-server.js on the server via Node.js. For clarity’s sake, main-server.js file is an artifact of the TypeScript-to-JavaScript transpilation task in the Webpack build process. Webpack defines an entry point alias of main-server; and, traversal of the dependency graph for this alias begins at the ClientApp/boot-server.ts file:

[!code-javascriptMain]

   1:  const path = require('path');
   2:  const webpack = require('webpack');
   3:  const merge = require('webpack-merge');
   4:  const CheckerPlugin = require('awesome-typescript-loader').CheckerPlugin;
   5:   
   6:  module.exports = (env) => {
   7:      // Configuration in common to both client-side and server-side bundles
   8:      const isDevBuild = !(env && env.prod);
   9:      const sharedConfig = {
  10:          stats: { modules: false },
  11:          context: __dirname,
  12:          resolve: { extensions: [ '.js', '.ts' ] },
  13:          output: {
  14:              filename: '[name].js',
  15:              publicPath: '/dist/' // Webpack dev middleware, if enabled, handles requests for this URL prefix
  16:          },
  17:          module: {
  18:              rules: [
  19:                  { test: /\.ts$/, include: /ClientApp/, use: ['awesome-typescript-loader?silent=true', 'angular2-template-loader', 'angular-router-loader'] },
  20:                  { test: /\.html$/, use: 'html-loader?minimize=false' },
  21:                  { test: /\.css$/, use: [ 'to-string-loader', isDevBuild ? 'css-loader' : 'css-loader?minimize' ] },
  22:                  { test: /\.(png|jpg|jpeg|gif|svg)$/, use: 'url-loader?limit=25000' }
  23:              ]
  24:          },
  25:          plugins: [new CheckerPlugin()]
  26:      };
  27:   
  28:      // Configuration for client-side bundle suitable for running in browsers
  29:      const clientBundleOutputDir = './wwwroot/dist';
  30:      const clientBundleConfig = merge(sharedConfig, {
  31:          entry: { 'main-client': './ClientApp/boot-client.ts' },
  32:          output: { path: path.join(__dirname, clientBundleOutputDir) },
  33:          plugins: [
  34:              new webpack.DllReferencePlugin({
  35:                  context: __dirname,
  36:                  manifest: require('./wwwroot/dist/vendor-manifest.json')
  37:              })
  38:          ].concat(isDevBuild ? [
  39:              // Plugins that apply in development builds only
  40:              new webpack.SourceMapDevToolPlugin({
  41:                  filename: '[file].map', // Remove this line if you prefer inline source maps
  42:                  moduleFilenameTemplate: path.relative(clientBundleOutputDir, '[resourcePath]') // Point sourcemap entries to the original file locations on disk
  43:              })
  44:          ] : [
  45:              // Plugins that apply in production builds only
  46:              new webpack.optimize.UglifyJsPlugin()
  47:          ])
  48:      });
  49:   
  50:      // Configuration for server-side (prerendering) bundle suitable for running in Node
  51:      const serverBundleConfig = merge(sharedConfig, {
  52:          resolve: { mainFields: ['main'] },
  53:          entry: { 'main-server': './ClientApp/boot-server.ts' },
  54:          plugins: [
  55:              new webpack.DllReferencePlugin({
  56:                  context: __dirname,
  57:                  manifest: require('./ClientApp/dist/vendor-manifest.json'),
  58:                  sourceType: 'commonjs2',
  59:                  name: './vendor'
  60:              })
  61:          ],
  62:          output: {
  63:              libraryTarget: 'commonjs',
  64:              path: path.join(__dirname, './ClientApp/dist')
  65:          },
  66:          target: 'node',
  67:          devtool: 'inline-source-map'
  68:      });
  69:   
  70:      return [clientBundleConfig, serverBundleConfig];
  71:  };

In the following Angular example, the ClientApp/boot-server.ts file utilizes the createServerRenderer function and RenderResult type of the aspnet-prerendering npm package to configure server rendering via Node.js. The HTML markup destined for server-side rendering is passed to a resolve function call, which is wrapped in a strongly-typed JavaScript Promise object. The Promise object’s significance is that it asynchronously supplies the HTML markup to the page for injection in the DOM’s placeholder element.

[!code-typescriptMain]

   1:  import 'reflect-metadata';
   2:  import 'zone.js';
   3:  import 'rxjs/add/operator/first';
   4:  import { enableProdMode, ApplicationRef, NgZone, ValueProvider } from '@angular/core';
   5:  import { platformDynamicServer, PlatformState, INITIAL_CONFIG } from '@angular/platform-server';
   6:  import { createServerRenderer, RenderResult } from 'aspnet-prerendering';
   7:  import { AppModule } from './app/app.module.server';
   8:   
   9:  enableProdMode();
  10:   
  11:  export default createServerRenderer(params => {
  12:      const providers = [
  13:          { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
  14:          { provide: 'ORIGIN_URL', useValue: params.origin }
  15:      ];
  16:   
  17:      return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
  18:          const appRef = moduleRef.injector.get(ApplicationRef);
  19:          const state = moduleRef.injector.get(PlatformState);
  20:          const zone = moduleRef.injector.get(NgZone);
  21:          
  22:          return new Promise<RenderResult>((resolve, reject) => {
  23:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  24:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  25:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  26:                  // completing the request in case there's an error to report
  27:                  setImmediate(() => {
  28:                      resolve({
  29:                          html: state.renderToString()
  30:                      });
  31:                      moduleRef.destroy();
  32:                  });
  33:              });
  34:          });
  35:          
  36:          // Example of accessing arguments passed from the Tag Helper
  37:          /*
  38:          return new Promise<RenderResult>((resolve, reject) => {
  39:              const result = `<h1>Hello, ${params.data.userName}</h1>`;
  40:  
  41:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  42:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  43:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  44:                  // completing the request in case there's an error to report
  45:                  setImmediate(() => {
  46:                      resolve({
  47:                          html: result
  48:                      });
  49:                      moduleRef.destroy();
  50:                  });
  51:              });
  52:          });
  53:          */
  54:   
  55:          // Example of attaching property to browser's "window" object
  56:          /*
  57:          return new Promise<RenderResult>((resolve, reject) => {
  58:              const result = `<h1>Hello, ${params.data.userName}</h1>`;
  59:  
  60:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  61:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  62:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  63:                  // completing the request in case there's an error to report
  64:                  setImmediate(() => {
  65:                      resolve({
  66:                          html: result,
  67:                          globals: {
  68:                              postList: [
  69:                                  'Introduction to ASP.NET Core',
  70:                                  'Making apps with Angular and ASP.NET Core'
  71:                              ]
  72:                          }
  73:                      });
  74:                      moduleRef.destroy();
  75:                  });
  76:              });
  77:          });
  78:          */
  79:      });
  80:  });

The asp-prerender-data Tag Helper

When coupled with the asp-prerender-module Tag Helper, the asp-prerender-data Tag Helper can be used to pass contextual information from the Razor view to the server-side JavaScript. For example, the following markup passes user data to the main-server module:

[!code-cshtmlMain]

   1:  @{
   2:      ViewData["Title"] = "Home Page";
   3:  }
   4:   
   5:  <app asp-prerender-module="ClientApp/dist/main-server">Loading...</app>
   6:   
   7:  @* Example of asp-prerender-data Tag Helper passing data to server-side JavaScript *@
   8:  @*
   9:  <app asp-prerender-module="ClientApp/dist/main-server"
  10:          asp-prerender-data='new {
  11:              UserName = "John Doe"
  12:          }'>Loading...</app>
  13:  *@
  14:   
  15:  <script src="~/dist/vendor.js" asp-append-version="true"></script>
  16:  @section scripts {
  17:      <script src="~/dist/main-client.js" asp-append-version="true"></script>
  18:  }

The received UserName argument is serialized using the built-in JSON serializer and is stored in the params.data object. In the following Angular example, the data is used to construct a personalized greeting within an h1 element:

[!code-typescriptMain]

   1:  import 'reflect-metadata';
   2:  import 'zone.js';
   3:  import 'rxjs/add/operator/first';
   4:  import { enableProdMode, ApplicationRef, NgZone, ValueProvider } from '@angular/core';
   5:  import { platformDynamicServer, PlatformState, INITIAL_CONFIG } from '@angular/platform-server';
   6:  import { createServerRenderer, RenderResult } from 'aspnet-prerendering';
   7:  import { AppModule } from './app/app.module.server';
   8:   
   9:  enableProdMode();
  10:   
  11:  export default createServerRenderer(params => {
  12:      const providers = [
  13:          { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
  14:          { provide: 'ORIGIN_URL', useValue: params.origin }
  15:      ];
  16:   
  17:      return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
  18:          const appRef = moduleRef.injector.get(ApplicationRef);
  19:          const state = moduleRef.injector.get(PlatformState);
  20:          const zone = moduleRef.injector.get(NgZone);
  21:          
  22:          return new Promise<RenderResult>((resolve, reject) => {
  23:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  24:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  25:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  26:                  // completing the request in case there's an error to report
  27:                  setImmediate(() => {
  28:                      resolve({
  29:                          html: state.renderToString()
  30:                      });
  31:                      moduleRef.destroy();
  32:                  });
  33:              });
  34:          });
  35:          
  36:          // Example of accessing arguments passed from the Tag Helper
  37:          /*
  38:          return new Promise<RenderResult>((resolve, reject) => {
  39:              const result = `<h1>Hello, ${params.data.userName}</h1>`;
  40:  
  41:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  42:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  43:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  44:                  // completing the request in case there's an error to report
  45:                  setImmediate(() => {
  46:                      resolve({
  47:                          html: result
  48:                      });
  49:                      moduleRef.destroy();
  50:                  });
  51:              });
  52:          });
  53:          */
  54:   
  55:          // Example of attaching property to browser's "window" object
  56:          /*
  57:          return new Promise<RenderResult>((resolve, reject) => {
  58:              const result = `<h1>Hello, ${params.data.userName}</h1>`;
  59:  
  60:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  61:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  62:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  63:                  // completing the request in case there's an error to report
  64:                  setImmediate(() => {
  65:                      resolve({
  66:                          html: result,
  67:                          globals: {
  68:                              postList: [
  69:                                  'Introduction to ASP.NET Core',
  70:                                  'Making apps with Angular and ASP.NET Core'
  71:                              ]
  72:                          }
  73:                      });
  74:                      moduleRef.destroy();
  75:                  });
  76:              });
  77:          });
  78:          */
  79:      });
  80:  });

Note: Property names passed in Tag Helpers are represented with PascalCase notation. Contrast that to JavaScript, where the same property names are represented with camelCase. The default JSON serialization configuration is responsible for this difference.

To expand upon the preceding code example, data can be passed from the server to the view by hydrating the globals property provided to the resolve function:

[!code-typescriptMain]

   1:  import 'reflect-metadata';
   2:  import 'zone.js';
   3:  import 'rxjs/add/operator/first';
   4:  import { enableProdMode, ApplicationRef, NgZone, ValueProvider } from '@angular/core';
   5:  import { platformDynamicServer, PlatformState, INITIAL_CONFIG } from '@angular/platform-server';
   6:  import { createServerRenderer, RenderResult } from 'aspnet-prerendering';
   7:  import { AppModule } from './app/app.module.server';
   8:   
   9:  enableProdMode();
  10:   
  11:  export default createServerRenderer(params => {
  12:      const providers = [
  13:          { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
  14:          { provide: 'ORIGIN_URL', useValue: params.origin }
  15:      ];
  16:   
  17:      return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
  18:          const appRef = moduleRef.injector.get(ApplicationRef);
  19:          const state = moduleRef.injector.get(PlatformState);
  20:          const zone = moduleRef.injector.get(NgZone);
  21:          
  22:          return new Promise<RenderResult>((resolve, reject) => {
  23:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  24:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  25:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  26:                  // completing the request in case there's an error to report
  27:                  setImmediate(() => {
  28:                      resolve({
  29:                          html: state.renderToString()
  30:                      });
  31:                      moduleRef.destroy();
  32:                  });
  33:              });
  34:          });
  35:          
  36:          // Example of accessing arguments passed from the Tag Helper
  37:          /*
  38:          return new Promise<RenderResult>((resolve, reject) => {
  39:              const result = `<h1>Hello, ${params.data.userName}</h1>`;
  40:  
  41:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  42:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  43:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  44:                  // completing the request in case there's an error to report
  45:                  setImmediate(() => {
  46:                      resolve({
  47:                          html: result
  48:                      });
  49:                      moduleRef.destroy();
  50:                  });
  51:              });
  52:          });
  53:          */
  54:   
  55:          // Example of attaching property to browser's "window" object
  56:          /*
  57:          return new Promise<RenderResult>((resolve, reject) => {
  58:              const result = `<h1>Hello, ${params.data.userName}</h1>`;
  59:  
  60:              zone.onError.subscribe(errorInfo => reject(errorInfo));
  61:              appRef.isStable.first(isStable => isStable).subscribe(() => {
  62:                  // Because 'onStable' fires before 'onError', we have to delay slightly before
  63:                  // completing the request in case there's an error to report
  64:                  setImmediate(() => {
  65:                      resolve({
  66:                          html: result,
  67:                          globals: {
  68:                              postList: [
  69:                                  'Introduction to ASP.NET Core',
  70:                                  'Making apps with Angular and ASP.NET Core'
  71:                              ]
  72:                          }
  73:                      });
  74:                      moduleRef.destroy();
  75:                  });
  76:              });
  77:          });
  78:          */
  79:      });
  80:  });

The postList array defined inside the globals object is attached to the browser’s global window object. This variable hoisting to global scope eliminates duplication of effort, particularly as it pertains to loading the same data once on the server and again on the client.

global postList variable attached to window object
global postList variable attached to window object

Webpack Dev Middleware

Webpack Dev Middleware introduces a streamlined development workflow whereby Webpack builds resources on demand. The middleware automatically compiles and serves client-side resources when a page is reloaded in the browser. The alternate approach is to manually invoke Webpack via the project’s npm build script when a third-party dependency or the custom code changes. An npm build script in the package.json file is shown in the following example:

[!code-jsonMain]

   1:  {
   2:    "name": "spaservicessampleapp",
   3:    "version": "0.0.0",
   4:    "scripts": {
   5:      "build": "npm run build:vendor && npm run build:custom",
   6:      "build:custom": "webpack",
   7:      "build:vendor": "webpack --config webpack.config.vendor.js",
   8:      "test": "karma start ClientApp/test/karma.conf.js"
   9:    },
  10:    "dependencies": {
  11:      "@angular/animations": "4.1.2",
  12:      "@angular/common": "4.1.2",
  13:      "@angular/compiler": "4.1.2",
  14:      "@angular/core": "4.1.2",
  15:      "@angular/forms": "4.1.2",
  16:      "@angular/http": "4.1.2",
  17:      "@angular/platform-browser": "4.1.2",
  18:      "@angular/platform-browser-dynamic": "4.1.2",
  19:      "@angular/platform-server": "4.1.2",
  20:      "@angular/router": "4.1.2",
  21:      "@types/node": "7.0.18",
  22:      "angular2-template-loader": "0.6.2",
  23:      "aspnet-prerendering": "^2.0.5",
  24:      "aspnet-webpack": "^1.0.29",
  25:      "awesome-typescript-loader": "3.1.3",
  26:      "bootstrap": "3.3.7",
  27:      "css": "2.2.1",
  28:      "css-loader": "0.28.1",
  29:      "es6-shim": "0.35.3",
  30:      "event-source-polyfill": "0.0.9",
  31:      "expose-loader": "0.7.3",
  32:      "extract-text-webpack-plugin": "2.1.0",
  33:      "file-loader": "0.11.1",
  34:      "html-loader": "0.4.5",
  35:      "isomorphic-fetch": "2.2.1",
  36:      "jquery": "3.2.1",
  37:      "json-loader": "0.5.4",
  38:      "preboot": "4.5.2",
  39:      "raw-loader": "0.5.1",
  40:      "reflect-metadata": "0.1.10",
  41:      "rxjs": "5.4.0",
  42:      "style-loader": "0.17.0",
  43:      "to-string-loader": "1.1.5",
  44:      "typescript": "2.3.2",
  45:      "url-loader": "0.5.8",
  46:      "webpack": "2.5.1",
  47:      "webpack-hot-middleware": "2.18.0",
  48:      "webpack-merge": "4.1.0",
  49:      "zone.js": "0.8.10"
  50:    },
  51:    "devDependencies": {
  52:      "@types/chai": "3.5.2",
  53:      "@types/jasmine": "2.5.47",
  54:      "angular-router-loader": "^0.6.0",
  55:      "chai": "3.5.0",
  56:      "jasmine-core": "2.6.1",
  57:      "karma": "1.7.0",
  58:      "karma-chai": "0.1.0",
  59:      "karma-chrome-launcher": "2.1.1",
  60:      "karma-cli": "1.0.1",
  61:      "karma-jasmine": "1.1.0",
  62:      "karma-webpack": "2.0.3"
  63:    }
  64:  }

Prerequisites

Install the following: * aspnet-webpack npm package:

```console
npm i -D aspnet-webpack
```

Configuration

Webpack Dev Middleware is registered into the HTTP request pipeline via the following code in the Startup.cs file’s Configure method:

[!code-csharpMain]

   1:  using Microsoft.AspNetCore.Builder;
   2:  using Microsoft.AspNetCore.Hosting;
   3:  using Microsoft.Extensions.Configuration;
   4:  using Microsoft.Extensions.DependencyInjection;
   5:  using Microsoft.Extensions.Logging;
   6:   
   7:  namespace SpaServicesSampleApp
   8:  {
   9:      public class Startup
  10:      {
  11:          public Startup(IHostingEnvironment env)
  12:          {
  13:              var builder = new ConfigurationBuilder()
  14:                  .SetBasePath(env.ContentRootPath)
  15:                  .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
  16:                  .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
  17:                  .AddEnvironmentVariables();
  18:              Configuration = builder.Build();
  19:          }
  20:   
  21:          public IConfigurationRoot Configuration { get; }
  22:   
  23:          // This method gets called by the runtime. Use this method to add services to the container.
  24:          public void ConfigureServices(IServiceCollection services)
  25:          {
  26:              // Add framework services.
  27:              services.AddMvc();
  28:          }
  29:   
  30:          // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
  31:          public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
  32:          {
  33:              loggerFactory.AddConsole(Configuration.GetSection("Logging"));
  34:              loggerFactory.AddDebug();
  35:   
  36:              #region webpack-middleware-registration
  37:              if (env.IsDevelopment())
  38:              {
  39:                  app.UseDeveloperExceptionPage();
  40:                  app.UseWebpackDevMiddleware();
  41:              }
  42:              else
  43:              {
  44:                  app.UseExceptionHandler("/Home/Error");
  45:              }
  46:   
  47:              // Call UseWebpackDevMiddleware before UseStaticFiles
  48:              app.UseStaticFiles();
  49:              #endregion
  50:   
  51:              #region mvc-routing-table
  52:              app.UseMvc(routes =>
  53:              {
  54:                  routes.MapRoute(
  55:                      name: "default",
  56:                      template: "{controller=Home}/{action=Index}/{id?}");
  57:   
  58:                  routes.MapSpaFallbackRoute(
  59:                      name: "spa-fallback",
  60:                      defaults: new { controller = "Home", action = "Index" });
  61:              });
  62:              #endregion
  63:          }
  64:      }
  65:  }

The UseWebpackDevMiddleware extension method must be called before (xref:)registering static file hosting via the UseStaticFiles extension method. For security reasons, register the middleware only when the app runs in development mode.

The webpack.config.js file’s output.publicPath property tells the middleware to watch the dist folder for changes:

[!code-javascriptMain]

   1:  const path = require('path');
   2:  const webpack = require('webpack');
   3:  const merge = require('webpack-merge');
   4:  const CheckerPlugin = require('awesome-typescript-loader').CheckerPlugin;
   5:   
   6:  module.exports = (env) => {
   7:      // Configuration in common to both client-side and server-side bundles
   8:      const isDevBuild = !(env && env.prod);
   9:      const sharedConfig = {
  10:          stats: { modules: false },
  11:          context: __dirname,
  12:          resolve: { extensions: [ '.js', '.ts' ] },
  13:          output: {
  14:              filename: '[name].js',
  15:              publicPath: '/dist/' // Webpack dev middleware, if enabled, handles requests for this URL prefix
  16:          },
  17:          module: {
  18:              rules: [
  19:                  { test: /\.ts$/, include: /ClientApp/, use: ['awesome-typescript-loader?silent=true', 'angular2-template-loader', 'angular-router-loader'] },
  20:                  { test: /\.html$/, use: 'html-loader?minimize=false' },
  21:                  { test: /\.css$/, use: [ 'to-string-loader', isDevBuild ? 'css-loader' : 'css-loader?minimize' ] },
  22:                  { test: /\.(png|jpg|jpeg|gif|svg)$/, use: 'url-loader?limit=25000' }
  23:              ]
  24:          },
  25:          plugins: [new CheckerPlugin()]
  26:      };
  27:   
  28:      // Configuration for client-side bundle suitable for running in browsers
  29:      const clientBundleOutputDir = './wwwroot/dist';
  30:      const clientBundleConfig = merge(sharedConfig, {
  31:          entry: { 'main-client': './ClientApp/boot-client.ts' },
  32:          output: { path: path.join(__dirname, clientBundleOutputDir) },
  33:          plugins: [
  34:              new webpack.DllReferencePlugin({
  35:                  context: __dirname,
  36:                  manifest: require('./wwwroot/dist/vendor-manifest.json')
  37:              })
  38:          ].concat(isDevBuild ? [
  39:              // Plugins that apply in development builds only
  40:              new webpack.SourceMapDevToolPlugin({
  41:                  filename: '[file].map', // Remove this line if you prefer inline source maps
  42:                  moduleFilenameTemplate: path.relative(clientBundleOutputDir, '[resourcePath]') // Point sourcemap entries to the original file locations on disk
  43:              })
  44:          ] : [
  45:              // Plugins that apply in production builds only
  46:              new webpack.optimize.UglifyJsPlugin()
  47:          ])
  48:      });
  49:   
  50:      // Configuration for server-side (prerendering) bundle suitable for running in Node
  51:      const serverBundleConfig = merge(sharedConfig, {
  52:          resolve: { mainFields: ['main'] },
  53:          entry: { 'main-server': './ClientApp/boot-server.ts' },
  54:          plugins: [
  55:              new webpack.DllReferencePlugin({
  56:                  context: __dirname,
  57:                  manifest: require('./ClientApp/dist/vendor-manifest.json'),
  58:                  sourceType: 'commonjs2',
  59:                  name: './vendor'
  60:              })
  61:          ],
  62:          output: {
  63:              libraryTarget: 'commonjs',
  64:              path: path.join(__dirname, './ClientApp/dist')
  65:          },
  66:          target: 'node',
  67:          devtool: 'inline-source-map'
  68:      });
  69:   
  70:      return [clientBundleConfig, serverBundleConfig];
  71:  };

Hot Module Replacement

Think of Webpack’s Hot Module Replacement (HMR) feature as an evolution of Webpack Dev Middleware. HMR introduces all the same benefits, but it further streamlines the development workflow by automatically updating page content after compiling the changes. Don’t confuse this with a refresh of the browser, which would interfere with the current in-memory state and debugging session of the SPA. There is a live link between the Webpack Dev Middleware service and the browser, which means changes are pushed to the browser.

Prerequisites

Install the following: * webpack-hot-middleware npm package:

```console
npm i -D webpack-hot-middleware
```

Configuration

The HMR component must be registered into MVC’s HTTP request pipeline in the Configure method:

As was true with Webpack Dev Middleware, the UseWebpackDevMiddleware extension method must be called before the UseStaticFiles extension method. For security reasons, register the middleware only when the app runs in development mode.

The webpack.config.js file must define a plugins array, even if it’s left empty:

[!code-javascriptMain]

   1:  const path = require('path');
   2:  const webpack = require('webpack');
   3:  const merge = require('webpack-merge');
   4:  const CheckerPlugin = require('awesome-typescript-loader').CheckerPlugin;
   5:   
   6:  module.exports = (env) => {
   7:      // Configuration in common to both client-side and server-side bundles
   8:      const isDevBuild = !(env && env.prod);
   9:      const sharedConfig = {
  10:          stats: { modules: false },
  11:          context: __dirname,
  12:          resolve: { extensions: [ '.js', '.ts' ] },
  13:          output: {
  14:              filename: '[name].js',
  15:              publicPath: '/dist/' // Webpack dev middleware, if enabled, handles requests for this URL prefix
  16:          },
  17:          module: {
  18:              rules: [
  19:                  { test: /\.ts$/, include: /ClientApp/, use: ['awesome-typescript-loader?silent=true', 'angular2-template-loader', 'angular-router-loader'] },
  20:                  { test: /\.html$/, use: 'html-loader?minimize=false' },
  21:                  { test: /\.css$/, use: [ 'to-string-loader', isDevBuild ? 'css-loader' : 'css-loader?minimize' ] },
  22:                  { test: /\.(png|jpg|jpeg|gif|svg)$/, use: 'url-loader?limit=25000' }
  23:              ]
  24:          },
  25:          plugins: [new CheckerPlugin()]
  26:      };
  27:   
  28:      // Configuration for client-side bundle suitable for running in browsers
  29:      const clientBundleOutputDir = './wwwroot/dist';
  30:      const clientBundleConfig = merge(sharedConfig, {
  31:          entry: { 'main-client': './ClientApp/boot-client.ts' },
  32:          output: { path: path.join(__dirname, clientBundleOutputDir) },
  33:          plugins: [
  34:              new webpack.DllReferencePlugin({
  35:                  context: __dirname,
  36:                  manifest: require('./wwwroot/dist/vendor-manifest.json')
  37:              })
  38:          ].concat(isDevBuild ? [
  39:              // Plugins that apply in development builds only
  40:              new webpack.SourceMapDevToolPlugin({
  41:                  filename: '[file].map', // Remove this line if you prefer inline source maps
  42:                  moduleFilenameTemplate: path.relative(clientBundleOutputDir, '[resourcePath]') // Point sourcemap entries to the original file locations on disk
  43:              })
  44:          ] : [
  45:              // Plugins that apply in production builds only
  46:              new webpack.optimize.UglifyJsPlugin()
  47:          ])
  48:      });
  49:   
  50:      // Configuration for server-side (prerendering) bundle suitable for running in Node
  51:      const serverBundleConfig = merge(sharedConfig, {
  52:          resolve: { mainFields: ['main'] },
  53:          entry: { 'main-server': './ClientApp/boot-server.ts' },
  54:          plugins: [
  55:              new webpack.DllReferencePlugin({
  56:                  context: __dirname,
  57:                  manifest: require('./ClientApp/dist/vendor-manifest.json'),
  58:                  sourceType: 'commonjs2',
  59:                  name: './vendor'
  60:              })
  61:          ],
  62:          output: {
  63:              libraryTarget: 'commonjs',
  64:              path: path.join(__dirname, './ClientApp/dist')
  65:          },
  66:          target: 'node',
  67:          devtool: 'inline-source-map'
  68:      });
  69:   
  70:      return [clientBundleConfig, serverBundleConfig];
  71:  };

After loading the app in the browser, the developer tools’ Console tab provides confirmation of HMR activation:

Hot Module Replacement connected message
Hot Module Replacement connected message

Routing helpers

In most ASP.NET Core-based SPAs, you’ll want client-side routing in addition to server-side routing. The SPA and MVC routing systems can work independently without interference. There is, however, one edge case posing challenges: identifying 404 HTTP responses.

Consider the scenario in which an extensionless route of /some/page is used. Assume the request doesn’t pattern-match a server-side route, but its pattern does match a client-side route. Now consider an incoming request for /images/user-512.png, which generally expects to find an image file on the server. If that requested resource path doesn’t match any server-side route or static file, it’s unlikely that the client-side application would handle it — you generally want to return a 404 HTTP status code.

Prerequisites

Install the following: * The client-side routing npm package. Using Angular as an example:

```console
npm i -S @angular/router
```

Configuration

An extension method named MapSpaFallbackRoute is used in the Configure method:

[!code-csharpMain]

   1:  using Microsoft.AspNetCore.Builder;
   2:  using Microsoft.AspNetCore.Hosting;
   3:  using Microsoft.Extensions.Configuration;
   4:  using Microsoft.Extensions.DependencyInjection;
   5:  using Microsoft.Extensions.Logging;
   6:   
   7:  namespace SpaServicesSampleApp
   8:  {
   9:      public class Startup
  10:      {
  11:          public Startup(IHostingEnvironment env)
  12:          {
  13:              var builder = new ConfigurationBuilder()
  14:                  .SetBasePath(env.ContentRootPath)
  15:                  .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
  16:                  .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
  17:                  .AddEnvironmentVariables();
  18:              Configuration = builder.Build();
  19:          }
  20:   
  21:          public IConfigurationRoot Configuration { get; }
  22:   
  23:          // This method gets called by the runtime. Use this method to add services to the container.
  24:          public void ConfigureServices(IServiceCollection services)
  25:          {
  26:              // Add framework services.
  27:              services.AddMvc();
  28:          }
  29:   
  30:          // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
  31:          public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
  32:          {
  33:              loggerFactory.AddConsole(Configuration.GetSection("Logging"));
  34:              loggerFactory.AddDebug();
  35:   
  36:              #region webpack-middleware-registration
  37:              if (env.IsDevelopment())
  38:              {
  39:                  app.UseDeveloperExceptionPage();
  40:                  app.UseWebpackDevMiddleware();
  41:              }
  42:              else
  43:              {
  44:                  app.UseExceptionHandler("/Home/Error");
  45:              }
  46:   
  47:              // Call UseWebpackDevMiddleware before UseStaticFiles
  48:              app.UseStaticFiles();
  49:              #endregion
  50:   
  51:              #region mvc-routing-table
  52:              app.UseMvc(routes =>
  53:              {
  54:                  routes.MapRoute(
  55:                      name: "default",
  56:                      template: "{controller=Home}/{action=Index}/{id?}");
  57:   
  58:                  routes.MapSpaFallbackRoute(
  59:                      name: "spa-fallback",
  60:                      defaults: new { controller = "Home", action = "Index" });
  61:              });
  62:              #endregion
  63:          }
  64:      }
  65:  }

Tip: Routes are evaluated in the order in which they’re configured. Consequently, the default route in the preceding code example is used first for pattern matching.

Creating a new project

JavaScriptServices provides pre-configured application templates. SpaServices is used in these templates, in conjunction with different frameworks and libraries such as Angular, Aurelia, Knockout, React, and Vue.

These templates can be installed via the .NET Core CLI by running the following command:

dotnet new --install Microsoft.AspNetCore.SpaTemplates::*

A list of available SPA templates is displayed:

Templates Short Name Language Tags
MVC ASP.NET Core with Angular angular [C#] Web/MVC/SPA
MVC ASP.NET Core with Aurelia aurelia [C#] Web/MVC/SPA
MVC ASP.NET Core with Knockout.js knockout [C#] Web/MVC/SPA
MVC ASP.NET Core with React.js react [C#] Web/MVC/SPA
MVC ASP.NET Core with React.js and Redux reactredux [C#] Web/MVC/SPA
MVC ASP.NET Core with Vue.js vue [C#] Web/MVC/SPA

To create a new project using one of the SPA templates, include the Short Name of the template in the dotnet new command. The following command creates an Angular application with ASP.NET Core MVC configured for the server side:

dotnet new angular

Set the runtime configuration mode

Two primary runtime configuration modes exist: * Development: * Includes source maps to ease debugging. * Doesn’t optimize the client-side code for performance. * Production: * Excludes source maps. * Optimizes the client-side code via bundling & minification.

ASP.NET Core uses an environment variable named ASPNETCORE_ENVIRONMENT to store the configuration mode. See (xref:)Setting the environment for more information.

Running with .NET Core CLI

Restore the required NuGet and npm packages by running the following command at the project root:

dotnet restore && npm i

Build and run the application:

dotnet run

The application starts on localhost according to the runtime configuration mode. Navigating to http://localhost:5000 in the browser displays the landing page.

Running with Visual Studio 2017

Open the .csproj file generated by the dotnet new command. The required NuGet and npm packages are restored automatically upon project open. This restoration process may take up to a few minutes, and the application is ready to run when it completes. Click the green run button or press Ctrl + F5, and the browser opens to the application’s landing page. The application runs on localhost according to the runtime configuration mode.

Testing the app

SpaServices templates are pre-configured to run client-side tests using Karma and Jasmine. Jasmine is a popular unit testing framework for JavaScript, whereas Karma is a test runner for those tests. Karma is configured to work with the Webpack Dev Middleware such that you don’t have to stop and run the test every time changes are made. Whether it’s the code running against the test case or the test case itself, the test runs automatically.

Using the Angular application as an example, two Jasmine test cases are already provided for the CounterComponent in the counter.component.spec.ts file:

[!code-typescriptMain]

   1:  /// <reference path="../../../../node_modules/@types/jasmine/index.d.ts" />
   2:  import { assert } from 'chai';
   3:  import { CounterComponent } from './counter.component';
   4:  import { TestBed, async, ComponentFixture } from '@angular/core/testing';
   5:   
   6:  let fixture: ComponentFixture<CounterComponent>;
   7:   
   8:  describe('Counter component', () => {
   9:      beforeEach(() => {
  10:          TestBed.configureTestingModule({ declarations: [CounterComponent] });
  11:          fixture = TestBed.createComponent(CounterComponent);
  12:          fixture.detectChanges();
  13:      });
  14:   
  15:      it('should display a title', async(() => {
  16:          const titleText = fixture.nativeElement.querySelector('h1').textContent;
  17:          expect(titleText).toEqual('Counter');
  18:      }));
  19:   
  20:      it('should start with count 0, then increments by 1 when clicked', async(() => {
  21:          const countElement = fixture.nativeElement.querySelector('strong');
  22:          expect(countElement.textContent).toEqual('0');
  23:   
  24:          const incrementButton = fixture.nativeElement.querySelector('button');
  25:          incrementButton.click();
  26:          fixture.detectChanges();
  27:          expect(countElement.textContent).toEqual('1');
  28:      }));
  29:  });

Open the command prompt at the project root, and run the following command:

npm test

The script launches the Karma test runner, which reads the settings defined in the karma.conf.js file. Among other settings, the karma.conf.js identifies the test files to be executed via its files array:

[!code-javascriptMain]

   1:  // Karma configuration file, see link for more information
   2:  // https://karma-runner.github.io/0.13/config/configuration-file.html
   3:   
   4:  module.exports = function (config) {
   5:      config.set({
   6:          basePath: '.',
   7:          frameworks: ['jasmine'],
   8:          files: [
   9:              '../../wwwroot/dist/vendor.js',
  10:              './boot-tests.ts'
  11:          ],
  12:          preprocessors: {
  13:              './boot-tests.ts': ['webpack']
  14:          },
  15:          reporters: ['progress'],
  16:          port: 9876,
  17:          colors: true,
  18:          logLevel: config.LOG_INFO,
  19:          autoWatch: true,
  20:          browsers: ['Chrome'],
  21:          mime: { 'application/javascript': ['ts','tsx'] },
  22:          singleRun: false,
  23:          webpack: require('../../webpack.config.js')().filter(config => config.target !== 'node'), // Test against client bundle, because tests run in a browser
  24:          webpackMiddleware: { stats: 'errors-only' }
  25:      });
  26:  };

Publishing the application

Combining the generated client-side assets and the published ASP.NET Core artifacts into a ready-to-deploy package can be cumbersome. Thankfully, SpaServices orchestrates that entire publication process with a custom MSBuild target named RunWebpack:

[!code-xmlMain]

   1:  <Project ToolsVersion="15.0" Sdk="Microsoft.NET.Sdk.Web">
   2:    <PropertyGroup>
   3:      <TargetFramework>netcoreapp1.1</TargetFramework>
   4:      <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
   5:      <IsPackable>false</IsPackable>
   6:      <PackageTargetFallback>portable-net45+win8</PackageTargetFallback>
   7:    </PropertyGroup>
   8:    <ItemGroup>
   9:      <PackageReference Include="Microsoft.AspNetCore" Version="1.1.2" />
  10:      <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="1.1.3" />
  11:      <PackageReference Include="Microsoft.AspNetCore.SpaServices" Version="1.1.1" />
  12:      <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="1.1.2" />
  13:      <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="1.1.2" />
  14:      <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="1.1.1" />
  15:    </ItemGroup>
  16:    <ItemGroup>
  17:      <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" />
  18:    </ItemGroup>
  19:    <ItemGroup>
  20:      <!-- Files not to show in IDE -->
  21:      <None Remove="yarn.lock" />
  22:   
  23:      <!-- Nest the npm lock file beneath package.json -->
  24:      <Content Update="package-lock.json">
  25:        <DependentUpon>package.json</DependentUpon>
  26:      </Content>
  27:      
  28:      <!-- Files not to publish (note that the 'dist' subfolders are re-added below) -->
  29:      <Content Remove="ClientApp\**" />
  30:    </ItemGroup>
  31:    <Target Name="RunWebpack" AfterTargets="ComputeFilesToPublish">
  32:      <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
  33:      <Exec Command="npm install" />
  34:      <Exec Command="node node_modules/webpack/bin/webpack.js --config webpack.config.vendor.js --env.prod" />
  35:      <Exec Command="node node_modules/webpack/bin/webpack.js --env.prod" />
  36:   
  37:      <!-- Include the newly-built files in the publish output -->
  38:      <ItemGroup>
  39:        <DistFiles Include="wwwroot\dist\**; ClientApp\dist\**" />
  40:        <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
  41:          <RelativePath>%(DistFiles.Identity)</RelativePath>
  42:          <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
  43:        </ResolvedFileToPublish>
  44:      </ItemGroup>
  45:    </Target>
  46:  </Project>

The MSBuild target has the following responsibilities: 1. Restore the npm packages 1. Create a production-grade build of the third-party, client-side assets 1. Create a production-grade build of the custom client-side assets 1. Copy the Webpack-generated assets to the publish folder

The MSBuild target is invoked when running:

dotnet publish -c Release

Additional resources





Comments ( )
<00>  <01>  <02>  <03>  <04>  <05>  <06>  <07>  <08>  <09>  <10>  <11>  <12>  <13>  <14>  <15>  <16>  <17>  <18>  <19>  <20>  <21>  <22>  <23
Link to this page: //www.vb-net.com/AspNet-DocAndSamples-2017/aspnetcore/client-side/spa-services.htm
<SITEMAP>  <MVC>  <ASP>  <NET>  <DATA>  <KIOSK>  <FLEX>  <SQL>  <NOTES>  <LINUX>  <MONO>  <FREEWARE>  <DOCS>  <ENG>  <CHAT ME>  <ABOUT ME>  < THANKS ME>