From c47d5bfe3bdbd71ed4c867a89fb3adaa9abf16fa Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 29 Sep 2017 13:41:58 +0100 Subject: [PATCH] Add SPA middleware APIs to support new templates --- .../AngularCli/AngularCliBuilder.cs | 43 ++++++ .../AngularCli/AngularCliMiddleware.cs | 123 +++++++++++++++++ .../AngularCliMiddlewareExtensions.cs | 31 +++++ .../Content/Node/angular-cli-middleware.js | 77 +++++++++++ .../DefaultSpaBuilder.cs | 37 +++++ .../ISpaBuilder.cs | 57 ++++++++ .../Microsoft.AspNetCore.SpaServices.csproj | 1 + .../Prerendering/DefaultSpaPrerenderer.cs | 12 ++ .../Prerendering/ISpaPrerendererBuilder.cs | 24 ++++ .../Prerendering/SpaPrerenderingExtensions.cs | 126 ++++++++++++++++++ .../ConditionalProxyMiddleware.cs | 31 +++-- .../ConditionalProxyMiddlewareTarget.cs | 19 +++ .../SpaExtensions.cs | 120 +++++++++++++++++ .../ConditionalProxyMiddlewareOptions.cs | 20 --- .../Webpack/WebpackDevMiddleware.cs | 11 +- 15 files changed, 698 insertions(+), 34 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/Content/Node/angular-cli-middleware.js create mode 100644 src/Microsoft.AspNetCore.SpaServices/DefaultSpaBuilder.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/ISpaBuilder.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs rename src/Microsoft.AspNetCore.SpaServices/{Webpack => Proxying}/ConditionalProxyMiddleware.cs (77%) create mode 100644 src/Microsoft.AspNetCore.SpaServices/Proxying/ConditionalProxyMiddlewareTarget.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/SpaExtensions.cs delete mode 100644 src/Microsoft.AspNetCore.SpaServices/Webpack/ConditionalProxyMiddlewareOptions.cs 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(); + PublicPath = publicPath; + } + + public IApplicationBuilder AppBuilder { get; } + public PathString DefaultFilePath { get; } + public IDictionary Properties { get; } + public string PublicPath { get; } + public Task StartupTasks { get; private set; } = Task.CompletedTask; + + public void AddStartupTask(Task task) + { + lock (_startupTasksLock) + { + StartupTasks = Task.WhenAll(StartupTasks, task); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices/ISpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/ISpaBuilder.cs new file mode 100644 index 00000000..04703224 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/ISpaBuilder.cs @@ -0,0 +1,57 @@ +// 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 +{ + /// + /// Defines a class that provides mechanisms to configure a Single Page Application + /// being hosted by an ASP.NET server. + /// + public interface ISpaBuilder + { + /// + /// Gets the for the host application. + /// + IApplicationBuilder AppBuilder { get; } + + /// + /// Gets the path to the SPA's default file. By default, this is the file + /// index.html within the . + /// + PathString DefaultFilePath { get; } + + /// + /// Gets the URL path, relative to the application's PathBase, from which + /// the SPA files are served. + /// + /// + /// If the SPA files are located in wwwroot/dist, then the value would + /// usually be "dist", because that is the URL prefix from which clients + /// can request those files. + /// + string PublicPath { get; } + + /// + /// Gets a key/value collection that can be used to share data between SPA middleware. + /// + IDictionary Properties { get; } + + /// + /// Gets a that represents the completion of all registered + /// SPA startup tasks. + /// + Task StartupTasks { get; } + + /// + /// Registers a task that represents part of SPA startup process. Middleware + /// may choose to wait for these tasks to complete before taking some action. + /// + /// The . + void AddStartupTask(Task task); + } +} 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..7fb346ca 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 { @@ -18,6 +19,17 @@ internal class DefaultSpaPrerenderer : ISpaPrerenderer private readonly IHttpContextAccessor _httpContextAccessor; private readonly INodeServices _nodeServices; + public DefaultSpaPrerenderer( + INodeServices nodeServices, + IServiceProvider serviceProvider) + : this( + nodeServices, + (IApplicationLifetime)serviceProvider.GetService(typeof(IApplicationLifetime)), + (IHostingEnvironment)serviceProvider.GetService(typeof(IHostingEnvironment)), + (IHttpContextAccessor)serviceProvider.GetService(typeof(IHttpContextAccessor))) + { + } + public DefaultSpaPrerenderer( INodeServices nodeServices, IApplicationLifetime applicationLifetime, diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs new file mode 100644 index 00000000..860a0398 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs @@ -0,0 +1,24 @@ +// 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.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(ISpaBuilder spaBuilder); + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs new file mode 100644 index 00000000..45a16dbf --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs @@ -0,0 +1,126 @@ +// 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.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.NodeServices; +using Microsoft.AspNetCore.SpaServices; +using Microsoft.AspNetCore.SpaServices.Prerendering; +using System; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for configuring prerendering of a Single Page Application. + /// + public static class SpaPrerenderingExtensions + { + /// + /// Adds middleware for server-side prerendering of 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 UsePrerendering( + this ISpaBuilder spaBuilder, + string entryPoint, + ISpaPrerendererBuilder buildOnDemand = null) + { + if (string.IsNullOrEmpty(entryPoint)) + { + throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); + } + + // 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(spaBuilder)); + + var appBuilder = spaBuilder.AppBuilder; + var prerenderer = GetPrerenderer(appBuilder.ApplicationServices); + + // 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 aren't meant to render the SPA default file + if (!context.Items.ContainsKey(SpaExtensions.IsSpaFallbackRequestTag)) + { + await next(); + return; + } + + // If we're building on demand, do that first + var buildOnDemandTask = lazyBuildOnDemandTask.Value; + if (buildOnDemandTask != null && !buildOnDemandTask.IsCompleted) + { + await buildOnDemandTask; + } + + // If we're waiting for other SPA initialization tasks, do that first. + await spaBuilder.StartupTasks; + + // 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(spaBuilder, context) + }; + + // 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( + entryPoint, + customDataParameter: customData); + 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(UsePrerendering)}(). 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(ISpaBuilder spaBuilder, HttpContext context) + { + var req = context.Request; + var defaultFileAbsoluteUrl = UriHelper.BuildAbsolute( + req.Scheme, req.Host, req.PathBase, spaBuilder.DefaultFilePath); + return defaultFileAbsoluteUrl; + } + + private static ISpaPrerenderer GetPrerenderer(IServiceProvider serviceProvider) + { + // Use the registered instance, or create a new private instance if none is registered + var instance = (ISpaPrerenderer)serviceProvider.GetService(typeof(ISpaPrerenderer)); + return instance ?? new DefaultSpaPrerenderer( + GetNodeServices(serviceProvider), + serviceProvider); + } + + 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/SpaExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/SpaExtensions.cs new file mode 100644 index 00000000..5b597747 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/SpaExtensions.cs @@ -0,0 +1,120 @@ +// 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.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 SpaExtensions + { + internal readonly static object IsSpaFallbackRequestTag = new object(); + + /// + /// 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, configures hosting options and further middleware for your SPA. + /// + public static void UseSpa( + this IApplicationBuilder app, + string publicPath, + string defaultPage = null, + Action configure = null) + { + if (string.IsNullOrEmpty(publicPath)) + { + throw new ArgumentException("Cannot be null or empty", nameof(publicPath)); + } + + if (string.IsNullOrEmpty(defaultPage)) + { + defaultPage = "index.html"; + } + + var publicPathString = new PathString(publicPath); + var defaultFilePath = publicPathString.Add(new PathString("/" + defaultPage)); + + // Support client-side routing by mapping all requests to the SPA default file + RewriteAllRequestsToServeDefaultFile(app, publicPathString, defaultFilePath); + + // Allow other SPA configuration. This could add other middleware for + // serving the default file, such as prerendering or Webpack/AngularCLI middleware. + configure?.Invoke(new DefaultSpaBuilder(app, publicPath, defaultFilePath)); + + // If the default file wasn't served by any other middleware, + // serve it as a static file from disk + AddTerminalMiddlewareForDefaultFile(app, defaultFilePath); + } + + private static void RewriteAllRequestsToServeDefaultFile(IApplicationBuilder app, PathString publicPathString, PathString defaultFilePath) + { + app.Use(async (context, next) => + { + // The only requests we don't map to the default file are those + // for other files within the SPA (e.g., its .js or .css files). + // Normally this makes no difference in production because those + // files exist on disk, but it does matter in development if they + // are being served by some subsequent middleware. + if (!context.Request.Path.StartsWithSegments(publicPathString)) + { + context.Request.Path = defaultFilePath; + context.Items[IsSpaFallbackRequestTag] = true; + } + + await next.Invoke(); + }); + } + + private static void AddTerminalMiddlewareForDefaultFile( + IApplicationBuilder app, PathString defaultFilePath) + { + app.Map(defaultFilePath, _ => + { + 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 {nameof(UseSpa)}() middleware could not return the default page '{defaultFilePath}' 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); + }); + }); + } + } +} 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