diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs
new file mode 100644
index 00000000..ad8479b8
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs
@@ -0,0 +1,47 @@
+// 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.SpaServices.Prerendering;
+using System;
+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(IApplicationBuilder app)
+ {
+ // Locate the AngularCliMiddleware within the provided IApplicationBuilder
+ if (app.Properties.TryGetValue(
+ AngularCliMiddleware.AngularCliMiddlewareKey,
+ out var angularCliMiddleware))
+ {
+ return ((AngularCliMiddleware)angularCliMiddleware)
+ .StartAngularCliBuilderAsync(_cliAppName);
+ }
+ else
+ {
+ throw new Exception(
+ $"Cannot use {nameof(AngularCliBuilder)} unless you are also using" +
+ $" {nameof(AngularCliMiddlewareExtensions.UseAngularCliServer)}.");
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs
new file mode 100644
index 00000000..8e25f85f
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs
@@ -0,0 +1,159 @@
+// 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;
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.SpaServices.AngularCli
+{
+ internal class AngularCliMiddleware
+ {
+ private const string _middlewareResourceName = "/Content/Node/angular-cli-middleware.js";
+
+ internal readonly static string AngularCliMiddlewareKey = Guid.NewGuid().ToString();
+
+ private readonly INodeServices _nodeServices;
+ private readonly string _middlewareScriptPath;
+
+ public AngularCliMiddleware(IApplicationBuilder appBuilder, string sourcePath, SpaDefaultPageMiddleware defaultPageMiddleware)
+ {
+ if (string.IsNullOrEmpty(sourcePath))
+ {
+ throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
+ }
+
+ // Prepare to make calls into Node
+ _nodeServices = CreateNodeServicesInstance(appBuilder, sourcePath);
+ _middlewareScriptPath = GetAngularCliMiddlewareScriptPath(appBuilder);
+
+ // Start Angular CLI and attach to middleware pipeline
+ var angularCliServerInfoTask = StartAngularCliServerAsync();
+
+ // 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, defaultPageMiddleware,
+ angularCliServerInfoTask, TimeSpan.FromSeconds(100));
+
+ // Advertise the availability of this feature to other SPA middleware
+ appBuilder.Properties.Add(AngularCliMiddlewareKey, this);
+ }
+
+ 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),
+ };
+
+ if (!Directory.Exists(nodeServicesOptions.ProjectPath))
+ {
+ throw new DirectoryNotFoundException($"Directory not found: {nodeServicesOptions.ProjectPath}");
+ }
+
+ 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, SpaDefaultPageMiddleware defaultPageMiddleware,
+ 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()));
+
+ // Requests outside / are proxied to the default page
+ var hasRewrittenUrlMarker = new object();
+ var defaultPageUrl = defaultPageMiddleware.DefaultPageUrl;
+ var urlPrefix = defaultPageMiddleware.UrlPrefix;
+ var urlPrefixIsRoot = string.IsNullOrEmpty(urlPrefix) || urlPrefix == "/";
+ appBuilder.Use((context, next) =>
+ {
+ if (!urlPrefixIsRoot && !context.Request.Path.StartsWithSegments(urlPrefix))
+ {
+ context.Items[hasRewrittenUrlMarker] = context.Request.Path;
+ context.Request.Path = defaultPageUrl;
+ }
+
+ return next();
+ });
+
+ appBuilder.UseMiddleware(urlPrefix, requestTimeout, proxyOptionsTask);
+
+ // If we rewrote the path, rewrite it back. Don't want to interfere with
+ // any other middleware.
+ appBuilder.Use((context, next) =>
+ {
+ if (context.Items.ContainsKey(hasRewrittenUrlMarker))
+ {
+ context.Request.Path = (PathString)context.Items[hasRewrittenUrlMarker];
+ context.Items.Remove(hasRewrittenUrlMarker);
+ }
+
+ return next();
+ });
+ }
+
+#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..43c1e678
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.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 System;
+
+namespace Microsoft.AspNetCore.SpaServices.AngularCli
+{
+ ///
+ /// Extension methods for enabling Angular CLI middleware support.
+ ///
+ public static class AngularCliMiddlewareExtensions
+ {
+ ///
+ /// Handles requests by passing them through to an instance of the Angular CLI server.
+ /// This means you can always serve up-to-date CLI-built resources without having
+ /// to run the Angular CLI server manually.
+ ///
+ /// This feature should only be used in development. For production deployments, be
+ /// sure not to enable the Angular CLI server.
+ ///
+ /// The .
+ /// The disk path, relative to the current directory, of the directory containing the SPA source files. When Angular CLI executes, this will be its working directory.
+ public static void UseAngularCliServer(
+ this IApplicationBuilder app,
+ string sourcePath)
+ {
+ var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(app);
+ if (defaultPageMiddleware == null)
+ {
+ throw new Exception($"{nameof(UseAngularCliServer)} should be called inside the 'configue' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
+ }
+
+ new AngularCliMiddleware(app, sourcePath, defaultPageMiddleware);
+ }
+ }
+}
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/Microsoft.AspNetCore.SpaServices.csproj b/src/Microsoft.AspNetCore.SpaServices/Microsoft.AspNetCore.SpaServices.csproj
index 5c856e99..88c9f858 100644
--- a/src/Microsoft.AspNetCore.SpaServices/Microsoft.AspNetCore.SpaServices.csproj
+++ b/src/Microsoft.AspNetCore.SpaServices/Microsoft.AspNetCore.SpaServices.csproj
@@ -17,6 +17,7 @@
+
diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs
index 946f96d3..07945306 100644
--- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs
+++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs
@@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.NodeServices;
using System.Threading.Tasks;
+using System;
namespace Microsoft.AspNetCore.SpaServices.Prerendering
{
diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs
new file mode 100644
index 00000000..ccff5069
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs
@@ -0,0 +1,25 @@
+// 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 System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.SpaServices.Prerendering
+{
+ ///
+ /// Represents the ability to build a Single Page Application application on demand
+ /// so that it can be prerendered. This is only intended to be used at development
+ /// time. In production, a SPA should already be built during publishing.
+ ///
+ public interface ISpaPrerendererBuilder
+ {
+ ///
+ /// Builds the Single Page Application so that a JavaScript entrypoint file
+ /// exists on disk. Prerendering middleware can then execute that file in
+ /// a Node environment.
+ ///
+ /// The .
+ /// A representing completion of the build process.
+ Task Build(IApplicationBuilder appBuilder);
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderingServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderingServiceCollectionExtensions.cs
index 2a6eb0b9..d6a67439 100644
--- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderingServiceCollectionExtensions.cs
+++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderingServiceCollectionExtensions.cs
@@ -1,10 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.NodeServices;
-using Microsoft.AspNetCore.SpaServices.Prerendering;
-using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.AspNetCore.SpaServices.Prerendering;
namespace Microsoft.Extensions.DependencyInjection
{
@@ -20,7 +14,7 @@ public static class PrerenderingServiceCollectionExtensions
/// The .
public static void AddSpaPrerenderer(this IServiceCollection serviceCollection)
{
- serviceCollection.TryAddSingleton();
+ serviceCollection.AddHttpContextAccessor();
serviceCollection.AddSingleton();
}
}
diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs
new file mode 100644
index 00000000..ab31f8b8
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs
@@ -0,0 +1,147 @@
+// 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.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.AspNetCore.NodeServices;
+using Microsoft.AspNetCore.SpaServices;
+using Microsoft.AspNetCore.SpaServices.Prerendering;
+using Microsoft.Extensions.DependencyInjection;
+using System;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.Builder
+{
+ ///
+ /// Extension methods for configuring prerendering of a Single Page Application.
+ ///
+ public static class SpaPrerenderingExtensions
+ {
+ ///
+ /// Enables server-side prerendering middleware for a Single Page Application.
+ ///
+ /// The .
+ /// The path, relative to your application root, of the JavaScript file containing prerendering logic.
+ /// Optional. If specified, executes the supplied before looking for the file. This is only intended to be used during development.
+ public static void UseSpaPrerendering(
+ this IApplicationBuilder appBuilder,
+ string entryPoint,
+ ISpaPrerendererBuilder buildOnDemand = null)
+ {
+ if (string.IsNullOrEmpty(entryPoint))
+ {
+ throw new ArgumentException("Cannot be null or empty", nameof(entryPoint));
+ }
+
+ var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(appBuilder);
+ if (defaultPageMiddleware == null)
+ {
+ throw new Exception($"{nameof(UseSpaPrerendering)} should be called inside the 'configure' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
+ }
+
+ var urlPrefix = defaultPageMiddleware.UrlPrefix;
+ if (urlPrefix == null || urlPrefix.Length < 2)
+ {
+ throw new ArgumentException(
+ "If you are using server-side prerendering, the SPA's public path must be " +
+ "set to a non-empty and non-root value. This makes it possible to identify " +
+ "requests for the SPA's internal static resources, so the prerenderer knows " +
+ "not to return prerendered HTML for those requests.",
+ nameof(urlPrefix));
+ }
+
+ // We only want to start one build-on-demand task, but it can't commence until
+ // a request comes in (because we need to wait for all middleware to be configured)
+ var lazyBuildOnDemandTask = new Lazy(() => buildOnDemand?.Build(appBuilder));
+
+ // Get all the necessary context info that will be used for each prerendering call
+ var serviceProvider = appBuilder.ApplicationServices;
+ var nodeServices = GetNodeServices(serviceProvider);
+ var applicationStoppingToken = serviceProvider.GetRequiredService()
+ .ApplicationStopping;
+ var applicationBasePath = serviceProvider.GetRequiredService()
+ .ContentRootPath;
+ var moduleExport = new JavaScriptModuleExport(entryPoint);
+ var urlPrefixAsPathString = new PathString(urlPrefix);
+
+ // Add the actual middleware that intercepts requests for the SPA default file
+ // and invokes the prerendering code
+ appBuilder.Use(async (context, next) =>
+ {
+ // Don't interfere with requests that are within the SPA's urlPrefix, because
+ // these requests are meant to serve its internal resources (.js, .css, etc.)
+ if (context.Request.Path.StartsWithSegments(urlPrefixAsPathString))
+ {
+ await next();
+ return;
+ }
+
+ // If we're building on demand, do that first
+ var buildOnDemandTask = lazyBuildOnDemandTask.Value;
+ if (buildOnDemandTask != null && !buildOnDemandTask.IsCompleted)
+ {
+ await buildOnDemandTask;
+ }
+
+ // As a workaround for @angular/cli not emitting the index.html in 'server'
+ // builds, pass through a URL that can be used for obtaining it. Longer term,
+ // remove this.
+ var customData = new
+ {
+ templateUrl = GetDefaultFileAbsoluteUrl(context, defaultPageMiddleware.DefaultPageUrl)
+ };
+
+ // TODO: Add an optional "supplyCustomData" callback param so people using
+ // UsePrerendering() can, for example, pass through cookies into the .ts code
+
+ var renderResult = await Prerenderer.RenderToString(
+ applicationBasePath,
+ nodeServices,
+ applicationStoppingToken,
+ moduleExport,
+ context,
+ customDataParameter: customData,
+ timeoutMilliseconds: 0);
+
+ await ApplyRenderResult(context, renderResult);
+ });
+ }
+
+ private static async Task ApplyRenderResult(HttpContext context, RenderToStringResult renderResult)
+ {
+ if (!string.IsNullOrEmpty(renderResult.RedirectUrl))
+ {
+ context.Response.Redirect(renderResult.RedirectUrl);
+ }
+ else
+ {
+ // The Globals property exists for back-compatibility but is meaningless
+ // for prerendering that returns complete HTML pages
+ if (renderResult.Globals != null)
+ {
+ throw new Exception($"{nameof(renderResult.Globals)} is not supported when prerendering via {nameof(UseSpaPrerendering)}(). Instead, your prerendering logic should return a complete HTML page, in which you embed any information you wish to return to the client.");
+ }
+
+ context.Response.ContentType = "text/html";
+ await context.Response.WriteAsync(renderResult.Html);
+ }
+ }
+
+ private static string GetDefaultFileAbsoluteUrl(HttpContext context, string defaultPageUrl)
+ {
+ var req = context.Request;
+ var defaultFileAbsoluteUrl = UriHelper.BuildAbsolute(
+ req.Scheme, req.Host, req.PathBase, defaultPageUrl);
+ return defaultFileAbsoluteUrl;
+ }
+
+ private static INodeServices GetNodeServices(IServiceProvider serviceProvider)
+ {
+ // Use the registered instance, or create a new private instance if none is registered
+ var instance = (INodeServices)serviceProvider.GetService(typeof(INodeServices));
+ return instance ?? NodeServicesFactory.CreateNodeServices(
+ new NodeServicesOptions(serviceProvider));
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/Webpack/ConditionalProxyMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices/Proxying/ConditionalProxyMiddleware.cs
similarity index 77%
rename from src/Microsoft.AspNetCore.SpaServices/Webpack/ConditionalProxyMiddleware.cs
rename to src/Microsoft.AspNetCore.SpaServices/Proxying/ConditionalProxyMiddleware.cs
index 292f62d0..e219b536 100644
--- a/src/Microsoft.AspNetCore.SpaServices/Webpack/ConditionalProxyMiddleware.cs
+++ b/src/Microsoft.AspNetCore.SpaServices/Proxying/ConditionalProxyMiddleware.cs
@@ -1,18 +1,20 @@
+// 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.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
-using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
-namespace Microsoft.AspNetCore.SpaServices.Webpack
+namespace Microsoft.AspNetCore.SpaServices.Proxy
{
///
/// Based on https://github.com/aspnet/Proxy/blob/dev/src/Microsoft.AspNetCore.Proxy/ProxyMiddleware.cs
/// Differs in that, if the proxied request returns a 404, we pass through to the next middleware in the chain
- /// This is useful for Webpack middleware, because it lets you fall back on prebuilt files on disk for
- /// chunks not exposed by the current Webpack config (e.g., DLL/vendor chunks).
+ /// This is useful for Webpack/Angular CLI middleware, because it lets you fall back on prebuilt files on disk
+ /// for files not served by that middleware.
///
internal class ConditionalProxyMiddleware
{
@@ -20,14 +22,15 @@ internal class ConditionalProxyMiddleware
private readonly HttpClient _httpClient;
private readonly RequestDelegate _next;
- private readonly ConditionalProxyMiddlewareOptions _options;
+ private readonly Task _targetTask;
private readonly string _pathPrefix;
private readonly bool _pathPrefixIsRoot;
public ConditionalProxyMiddleware(
RequestDelegate next,
string pathPrefix,
- ConditionalProxyMiddlewareOptions options)
+ TimeSpan requestTimeout,
+ Task targetTask)
{
if (!pathPrefix.StartsWith("/"))
{
@@ -37,9 +40,9 @@ public ConditionalProxyMiddleware(
_next = next;
_pathPrefix = pathPrefix;
_pathPrefixIsRoot = string.Equals(_pathPrefix, "/", StringComparison.Ordinal);
- _options = options;
+ _targetTask = targetTask;
_httpClient = new HttpClient(new HttpClientHandler());
- _httpClient.Timeout = _options.RequestTimeout;
+ _httpClient.Timeout = requestTimeout;
}
public async Task Invoke(HttpContext context)
@@ -59,6 +62,12 @@ public async Task Invoke(HttpContext context)
private async Task PerformProxyRequest(HttpContext context)
{
+ // We allow for the case where the target isn't known ahead of time, and want to
+ // delay proxied requests until the target becomes known. This is useful, for example,
+ // when proxying to Angular CLI middleware: we won't know what port it's listening
+ // on until it finishes starting up.
+ var target = await _targetTask;
+
var requestMessage = new HttpRequestMessage();
// Copy the request headers
@@ -70,9 +79,9 @@ private async Task PerformProxyRequest(HttpContext context)
}
}
- requestMessage.Headers.Host = _options.Host + ":" + _options.Port;
+ requestMessage.Headers.Host = target.Host + ":" + target.Port;
var uriString =
- $"{_options.Scheme}://{_options.Host}:{_options.Port}{context.Request.Path}{context.Request.QueryString}";
+ $"{target.Scheme}://{target.Host}:{target.Port}{context.Request.Path}{context.Request.QueryString}";
requestMessage.RequestUri = new Uri(uriString);
requestMessage.Method = new HttpMethod(context.Request.Method);
@@ -120,4 +129,4 @@ private async Task PerformProxyRequest(HttpContext context)
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/Proxying/ConditionalProxyMiddlewareTarget.cs b/src/Microsoft.AspNetCore.SpaServices/Proxying/ConditionalProxyMiddlewareTarget.cs
new file mode 100644
index 00000000..a5d53dfe
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/Proxying/ConditionalProxyMiddlewareTarget.cs
@@ -0,0 +1,19 @@
+// 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.Proxy
+{
+ internal class ConditionalProxyMiddlewareTarget
+ {
+ public ConditionalProxyMiddlewareTarget(string scheme, string host, string port)
+ {
+ Scheme = scheme;
+ Host = host;
+ Port = port;
+ }
+
+ public string Scheme { get; }
+ public string Host { get; }
+ public string Port { get; }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.SpaServices/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/SpaApplicationBuilderExtensions.cs
new file mode 100644
index 00000000..b7efcb6c
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/SpaApplicationBuilderExtensions.cs
@@ -0,0 +1,49 @@
+// 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;
+using System;
+
+namespace Microsoft.AspNetCore.Builder
+{
+ ///
+ /// Provides extension methods used for configuring an application to
+ /// host a client-side Single Page Application (SPA).
+ ///
+ public static class SpaApplicationBuilderExtensions
+ {
+ ///
+ /// Handles all requests from this point in the middleware chain by returning
+ /// the default page for the Single Page Application (SPA).
+ ///
+ /// This middleware should be placed late in the chain, so that other middleware
+ /// for serving static files, MVC actions, etc., takes precedence.
+ ///
+ /// The .
+ ///
+ /// The URL path, relative to your application's PathBase, from which the
+ /// SPA files are served.
+ ///
+ /// For example, if your SPA files are located in wwwroot/dist, then
+ /// the value should usually be "dist", because that is the URL prefix
+ /// from which browsers can request those files.
+ ///
+ ///
+ /// Optional. If specified, configures the path (relative to )
+ /// of the default page that hosts your SPA user interface.
+ /// If not specified, the default value is "index.html".
+ ///
+ ///
+ /// Optional. If specified, this callback will be invoked so that additional middleware
+ /// can be registered within the context of this SPA.
+ ///
+ public static void UseSpa(
+ this IApplicationBuilder app,
+ string urlPrefix,
+ string defaultPage = null,
+ Action configure = null)
+ {
+ new SpaDefaultPageMiddleware(app, urlPrefix, defaultPage, configure);
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageMiddleware.cs
new file mode 100644
index 00000000..7d1f9fec
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageMiddleware.cs
@@ -0,0 +1,91 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using System;
+
+namespace Microsoft.AspNetCore.SpaServices
+{
+ internal class SpaDefaultPageMiddleware
+ {
+ private static readonly string _propertiesKey = Guid.NewGuid().ToString();
+
+ public static SpaDefaultPageMiddleware FindInPipeline(IApplicationBuilder app)
+ {
+ return app.Properties.TryGetValue(_propertiesKey, out var instance)
+ ? (SpaDefaultPageMiddleware)instance
+ : null;
+ }
+
+ public string UrlPrefix { get; }
+ public string DefaultPageUrl { get; }
+
+ public SpaDefaultPageMiddleware(IApplicationBuilder app, string urlPrefix,
+ string defaultPage, Action configure)
+ {
+ if (app == null)
+ {
+ throw new ArgumentNullException(nameof(app));
+ }
+
+ UrlPrefix = urlPrefix ?? throw new ArgumentNullException(nameof(urlPrefix));
+ DefaultPageUrl = ConstructDefaultPageUrl(urlPrefix, defaultPage);
+
+ // Attach to pipeline, but invoke 'configure' to give the developer a chance
+ // to insert extra middleware before the 'default page' pipeline entries
+ RegisterSoleInstanceInPipeline(app);
+ configure?.Invoke();
+ AttachMiddlewareToPipeline(app);
+ }
+
+ private void RegisterSoleInstanceInPipeline(IApplicationBuilder app)
+ {
+ if (app.Properties.ContainsKey(_propertiesKey))
+ {
+ throw new Exception($"Only one usage of {nameof(SpaApplicationBuilderExtensions.UseSpa)} is allowed in any single branch of the middleware pipeline. This is because one instance would handle all requests.");
+ }
+
+ app.Properties[_propertiesKey] = this;
+ }
+
+ private void AttachMiddlewareToPipeline(IApplicationBuilder app)
+ {
+ // Rewrite all requests to the default page
+ app.Use((context, next) =>
+ {
+ context.Request.Path = DefaultPageUrl;
+ return next();
+ });
+
+ // Serve it as file from disk
+ app.UseStaticFiles();
+
+ // If the default file didn't get served as a static file (because it
+ // was not present on disk), the SPA is definitely not going to work.
+ app.Use((context, next) =>
+ {
+ var message = $"The SPA default page middleware could not return the default page '{DefaultPageUrl}' because it was not found on disk, and no other middleware handled the request.\n";
+
+ // Try to clarify the common scenario where someone runs an application in
+ // Production environment without first publishing the whole application
+ // or at least building the SPA.
+ var hostEnvironment = (IHostingEnvironment)context.RequestServices.GetService(typeof(IHostingEnvironment));
+ if (hostEnvironment != null && hostEnvironment.IsProduction())
+ {
+ message += "Your application is running in Production mode, so make sure it has been published, or that you have built your SPA manually. Alternatively you may wish to switch to the Development environment.\n";
+ }
+
+ throw new Exception(message);
+ });
+ }
+
+ private static string ConstructDefaultPageUrl(string urlPrefix, string defaultPage)
+ {
+ if (string.IsNullOrEmpty(defaultPage))
+ {
+ defaultPage = "index.html";
+ }
+
+ return new PathString(urlPrefix).Add(new PathString("/" + defaultPage));
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices/Webpack/ConditionalProxyMiddlewareOptions.cs b/src/Microsoft.AspNetCore.SpaServices/Webpack/ConditionalProxyMiddlewareOptions.cs
deleted file mode 100644
index 2c3311aa..00000000
--- a/src/Microsoft.AspNetCore.SpaServices/Webpack/ConditionalProxyMiddlewareOptions.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System;
-
-namespace Microsoft.AspNetCore.SpaServices.Webpack
-{
- internal class ConditionalProxyMiddlewareOptions
- {
- public ConditionalProxyMiddlewareOptions(string scheme, string host, string port, TimeSpan requestTimeout)
- {
- Scheme = scheme;
- Host = host;
- Port = port;
- RequestTimeout = requestTimeout;
- }
-
- public string Scheme { get; }
- public string Host { get; }
- public string Port { get; }
- public TimeSpan RequestTimeout { get; }
- }
-}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.SpaServices/Webpack/WebpackDevMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices/Webpack/WebpackDevMiddleware.cs
index 2e8f92ea..1323d183 100644
--- a/src/Microsoft.AspNetCore.SpaServices/Webpack/WebpackDevMiddleware.cs
+++ b/src/Microsoft.AspNetCore.SpaServices/Webpack/WebpackDevMiddleware.cs
@@ -5,6 +5,8 @@
using Microsoft.AspNetCore.SpaServices.Webpack;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
+using Microsoft.AspNetCore.SpaServices.Proxy;
+using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Builder
{
@@ -128,9 +130,12 @@ private static void UseProxyToLocalWebpackDevMiddleware(this IApplicationBuilder
// because the HMR service has no need for HTTPS (the client doesn't see it directly - all traffic
// to it is proxied), and the HMR service couldn't use HTTPS anyway (in general it wouldn't have
// the necessary certificate).
- var proxyOptions = new ConditionalProxyMiddlewareOptions(
- "http", "localhost", proxyToPort.ToString(), requestTimeout);
- appBuilder.UseMiddleware(publicPath, proxyOptions);
+ var target = new ConditionalProxyMiddlewareTarget(
+ "http", "localhost", proxyToPort.ToString());
+ appBuilder.UseMiddleware(
+ publicPath,
+ requestTimeout,
+ Task.FromResult(target));
}
#pragma warning disable CS0649