diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs
new file mode 100644
index 00000000..483d5850
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs
@@ -0,0 +1,43 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.SpaServices.Prerendering;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.SpaServices.AngularCli
+{
+ ///
+ /// Provides an implementation of that can build
+ /// an Angular application by invoking the Angular CLI.
+ ///
+ public class AngularCliBuilder : ISpaPrerendererBuilder
+ {
+ private readonly string _cliAppName;
+
+ ///
+ /// Constructs an instance of .
+ ///
+ /// The name of the application to be built. This must match an entry in your .angular-cli.json file.
+ public AngularCliBuilder(string cliAppName)
+ {
+ _cliAppName = cliAppName;
+ }
+
+ ///
+ public Task Build(ISpaBuilder spaBuilder)
+ {
+ // Locate the AngularCliMiddleware within the provided ISpaBuilder
+ var angularCliMiddleware = spaBuilder
+ .Properties.Keys.OfType().FirstOrDefault();
+ if (angularCliMiddleware == null)
+ {
+ throw new Exception(
+ $"Cannot use {nameof (AngularCliBuilder)} unless you are also using {nameof(AngularCliMiddleware)}.");
+ }
+
+ return angularCliMiddleware.StartAngularCliBuilderAsync(_cliAppName);
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs
new file mode 100644
index 00000000..e8636457
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs
@@ -0,0 +1,123 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using Microsoft.AspNetCore.NodeServices;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using System.Threading;
+using Microsoft.AspNetCore.SpaServices.Proxy;
+
+namespace Microsoft.AspNetCore.SpaServices.AngularCli
+{
+ internal class AngularCliMiddleware
+ {
+ private const string _middlewareResourceName = "/Content/Node/angular-cli-middleware.js";
+
+ private readonly INodeServices _nodeServices;
+ private readonly string _middlewareScriptPath;
+
+ public AngularCliMiddleware(ISpaBuilder spaBuilder, string sourcePath)
+ {
+ if (string.IsNullOrEmpty(sourcePath))
+ {
+ throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
+ }
+
+ // Prepare to make calls into Node
+ var appBuilder = spaBuilder.AppBuilder;
+ _nodeServices = CreateNodeServicesInstance(appBuilder, sourcePath);
+ _middlewareScriptPath = GetAngularCliMiddlewareScriptPath(appBuilder);
+
+ // Start Angular CLI and attach to middleware pipeline
+ var angularCliServerInfoTask = StartAngularCliServerAsync();
+ spaBuilder.AddStartupTask(angularCliServerInfoTask);
+
+ // Proxy the corresponding requests through ASP.NET and into the Node listener
+ // Anything under / (e.g., /dist) is proxied as a normal HTTP request
+ // with a typical timeout (100s is the default from HttpClient).
+ UseProxyToLocalAngularCliMiddleware(appBuilder, spaBuilder.PublicPath,
+ angularCliServerInfoTask, TimeSpan.FromSeconds(100));
+
+ // Advertise the availability of this feature to other SPA middleware
+ spaBuilder.Properties.Add(this, null);
+ }
+
+ public Task StartAngularCliBuilderAsync(string cliAppName)
+ {
+ return _nodeServices.InvokeExportAsync(
+ _middlewareScriptPath,
+ "startAngularCliBuilder",
+ cliAppName);
+ }
+
+ private static INodeServices CreateNodeServicesInstance(
+ IApplicationBuilder appBuilder, string sourcePath)
+ {
+ // Unlike other consumers of NodeServices, AngularCliMiddleware dosen't share Node instances, nor does it
+ // use your DI configuration. It's important for AngularCliMiddleware to have its own private Node instance
+ // because it must *not* restart when files change (it's designed to watch for changes and rebuild).
+ var nodeServicesOptions = new NodeServicesOptions(appBuilder.ApplicationServices)
+ {
+ WatchFileExtensions = new string[] { }, // Don't watch anything
+ ProjectPath = Path.Combine(Directory.GetCurrentDirectory(), sourcePath),
+ };
+
+ return NodeServicesFactory.CreateNodeServices(nodeServicesOptions);
+ }
+
+ private static string GetAngularCliMiddlewareScriptPath(IApplicationBuilder appBuilder)
+ {
+ var script = EmbeddedResourceReader.Read(typeof(AngularCliMiddleware), _middlewareResourceName);
+ var nodeScript = new StringAsTempFile(script, GetStoppingToken(appBuilder));
+ return nodeScript.FileName;
+ }
+
+ private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder)
+ {
+ var applicationLifetime = appBuilder
+ .ApplicationServices
+ .GetService(typeof(IApplicationLifetime));
+ return ((IApplicationLifetime)applicationLifetime).ApplicationStopping;
+ }
+
+ private async Task StartAngularCliServerAsync()
+ {
+ // Tell Node to start the server hosting the Angular CLI
+ var angularCliServerInfo = await _nodeServices.InvokeExportAsync(
+ _middlewareScriptPath,
+ "startAngularCliServer");
+
+ // Even after the Angular CLI claims to be listening for requests, there's a short
+ // period where it will give an error if you make a request too quickly. Give it
+ // a moment to finish starting up.
+ await Task.Delay(500);
+
+ return angularCliServerInfo;
+ }
+
+ private static void UseProxyToLocalAngularCliMiddleware(
+ IApplicationBuilder appBuilder, string publicPath,
+ Task serverInfoTask, TimeSpan requestTimeout)
+ {
+ // This is hardcoded to use http://localhost because:
+ // - the requests are always from the local machine (we're not accepting remote
+ // requests that go directly to the Angular CLI middleware server)
+ // - given that, there's no reason to use https, and we couldn't even if we
+ // wanted to, because in general the Angular CLI server has no certificate
+ var proxyOptionsTask = serverInfoTask.ContinueWith(
+ task => new ConditionalProxyMiddlewareTarget(
+ "http", "localhost", task.Result.Port.ToString()));
+ appBuilder.UseMiddleware(publicPath, requestTimeout, proxyOptionsTask);
+ }
+
+#pragma warning disable CS0649
+ class AngularCliServerInfo
+ {
+ public int Port { get; set; }
+ }
+ }
+#pragma warning restore CS0649
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs
new file mode 100644
index 00000000..54f2d142
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs
@@ -0,0 +1,31 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.SpaServices.AngularCli
+{
+ ///
+ /// Extension methods for enabling Angular CLI middleware support.
+ ///
+ public static class AngularCliMiddlewareExtensions
+ {
+ ///
+ /// Enables Angular CLI middleware support. This hosts an instance of the Angular CLI in memory in
+ /// your application so that you can always serve up-to-date CLI-built resources without having
+ /// to run CLI server manually.
+ ///
+ /// Incoming requests that match Angular CLI-built files will be handled by returning the CLI server
+ /// output directly.
+ ///
+ /// This feature should only be used in development. For production deployments, be sure not to
+ /// enable Angular CLI middleware.
+ ///
+ /// The .
+ /// The path, relative to the application root, of the directory containing the SPA source files.
+ public static void UseAngularCliMiddleware(
+ this ISpaBuilder spaBuilder,
+ string sourcePath)
+ {
+ new AngularCliMiddleware(spaBuilder, sourcePath);
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/Content/Node/angular-cli-middleware.js b/src/Microsoft.AspNetCore.SpaServices/Content/Node/angular-cli-middleware.js
new file mode 100644
index 00000000..721c01ea
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/Content/Node/angular-cli-middleware.js
@@ -0,0 +1,77 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+var childProcess = require('child_process');
+var net = require('net');
+var readline = require('readline');
+var url = require('url');
+
+module.exports = {
+ startAngularCliBuilder: function startAngularCliBuilder(callback, appName) {
+ var proc = executeAngularCli([
+ 'build',
+ '-app', appName,
+ '--watch'
+ ]);
+ proc.stdout.pipe(process.stdout);
+ waitForLine(proc.stdout, /chunk/).then(function () {
+ callback();
+ });
+ },
+
+ startAngularCliServer: function startAngularCliServer(callback, options) {
+ getOSAssignedPortNumber().then(function (portNumber) {
+ // Start @angular/cli dev server on private port, and pipe its output
+ // back to the ASP.NET host process.
+ // TODO: Support streaming arbitrary chunks to host process's stdout
+ // rather than just full lines, so we can see progress being logged
+ var devServerProc = executeAngularCli([
+ 'serve',
+ '--port', portNumber.toString(),
+ '--deploy-url', '/dist/', // Value should come from .angular-cli.json, but https://github.com/angular/angular-cli/issues/7347
+ '--extract-css'
+ ]);
+ devServerProc.stdout.pipe(process.stdout);
+
+ // Wait until the CLI dev server is listening before letting ASP.NET start the app
+ console.log('Waiting for @angular/cli service to start...');
+ waitForLine(devServerProc.stdout, /open your browser on (http\S+)/).then(function (matches) {
+ var devServerUrl = url.parse(matches[1]);
+ console.log('@angular/cli service has started on internal port ' + devServerUrl.port);
+ callback(null, {
+ Port: parseInt(devServerUrl.port)
+ });
+ });
+ });
+ }
+};
+
+function waitForLine(stream, regex) {
+ return new Promise(function (resolve, reject) {
+ var lineReader = readline.createInterface({ input: stream });
+ lineReader.on('line', function (line) {
+ var matches = regex.exec(line);
+ if (matches) {
+ lineReader.close();
+ resolve(matches);
+ }
+ });
+ });
+}
+
+function executeAngularCli(args) {
+ var angularCliBin = require.resolve('@angular/cli/bin/ng');
+ return childProcess.fork(angularCliBin, args, {
+ stdio: [/* stdin */ 'ignore', /* stdout */ 'pipe', /* stderr */ 'inherit', 'ipc']
+ });
+}
+
+function getOSAssignedPortNumber() {
+ return new Promise(function (resolve, reject) {
+ var server = net.createServer();
+ server.listen(0, 'localhost', function () {
+ var portNumber = server.address().port;
+ server.close(function () { resolve(portNumber); });
+ });
+ });
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/DefaultSpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/DefaultSpaBuilder.cs
new file mode 100644
index 00000000..08d9c0ee
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/DefaultSpaBuilder.cs
@@ -0,0 +1,37 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.SpaServices
+{
+ internal class DefaultSpaBuilder : ISpaBuilder
+ {
+ private readonly object _startupTasksLock = new object();
+
+ public DefaultSpaBuilder(IApplicationBuilder appBuilder, string publicPath, PathString defaultFilePath)
+ {
+ AppBuilder = appBuilder;
+ DefaultFilePath = defaultFilePath;
+ Properties = new Dictionary