From c47d5bfe3bdbd71ed4c867a89fb3adaa9abf16fa Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 29 Sep 2017 13:41:58 +0100 Subject: [PATCH 1/7] 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 From 5bdb771ae23e9877ec27b76a982da570a2261807 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 2 Oct 2017 11:48:41 +0100 Subject: [PATCH 2/7] In SpaPrerenderingExtensions, don't assume that an IHttpContextAccessor will be available. Get HttpContext from middleware params instead. --- .../Prerendering/DefaultSpaPrerenderer.cs | 11 ------ .../Prerendering/SpaPrerenderingExtensions.cs | 37 ++++++++++++------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs index 7fb346ca..07945306 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs @@ -19,17 +19,6 @@ 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/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs index 45a16dbf..6e6d52d7 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs @@ -1,6 +1,7 @@ // 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; @@ -36,8 +37,15 @@ public static void UsePrerendering( // a request comes in (because we need to wait for all middleware to be configured) var lazyBuildOnDemandTask = new Lazy(() => buildOnDemand?.Build(spaBuilder)); + // Get all the necessary context info that will be used for each prerendering call var appBuilder = spaBuilder.AppBuilder; - var prerenderer = GetPrerenderer(appBuilder.ApplicationServices); + var serviceProvider = appBuilder.ApplicationServices; + var nodeServices = GetNodeServices(serviceProvider); + var applicationStoppingToken = GetRequiredService(serviceProvider) + .ApplicationStopping; + var applicationBasePath = GetRequiredService(serviceProvider) + .ContentRootPath; + var moduleExport = new JavaScriptModuleExport(entryPoint); // Add the actual middleware that intercepts requests for the SPA default file // and invokes the prerendering code @@ -71,13 +79,25 @@ public static void UsePrerendering( // 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); + var renderResult = await Prerenderer.RenderToString( + applicationBasePath, + nodeServices, + applicationStoppingToken, + moduleExport, + context, + customDataParameter: customData, + timeoutMilliseconds: 0); + await ApplyRenderResult(context, renderResult); }); } + private static T GetRequiredService(IServiceProvider serviceProvider) where T: class + { + return (T)serviceProvider.GetService(typeof(T)) + ?? throw new Exception($"Could not resolve service of type {typeof(T).FullName} in service provider."); + } + private static async Task ApplyRenderResult(HttpContext context, RenderToStringResult renderResult) { if (!string.IsNullOrEmpty(renderResult.RedirectUrl)) @@ -106,15 +126,6 @@ private static string GetDefaultFileAbsoluteUrl(ISpaBuilder spaBuilder, HttpCont 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 From d466cdb1d7ae395f8548f5644769895587f6b68d Mon Sep 17 00:00:00 2001 From: Kristian Hellang Date: Mon, 2 Oct 2017 21:20:25 +0200 Subject: [PATCH 3/7] Use built-in GetRequiredService --- .../Prerendering/SpaPrerenderingExtensions.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs index 6e6d52d7..ac456464 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.NodeServices; using Microsoft.AspNetCore.SpaServices; using Microsoft.AspNetCore.SpaServices.Prerendering; +using Microsoft.Extensions.DependencyInjection; using System; using System.Threading.Tasks; @@ -41,9 +42,9 @@ public static void UsePrerendering( var appBuilder = spaBuilder.AppBuilder; var serviceProvider = appBuilder.ApplicationServices; var nodeServices = GetNodeServices(serviceProvider); - var applicationStoppingToken = GetRequiredService(serviceProvider) + var applicationStoppingToken = serviceProvider.GetRequiredService() .ApplicationStopping; - var applicationBasePath = GetRequiredService(serviceProvider) + var applicationBasePath = serviceProvider.GetRequiredService() .ContentRootPath; var moduleExport = new JavaScriptModuleExport(entryPoint); @@ -92,12 +93,6 @@ public static void UsePrerendering( }); } - private static T GetRequiredService(IServiceProvider serviceProvider) where T: class - { - return (T)serviceProvider.GetService(typeof(T)) - ?? throw new Exception($"Could not resolve service of type {typeof(T).FullName} in service provider."); - } - private static async Task ApplyRenderResult(HttpContext context, RenderToStringResult renderResult) { if (!string.IsNullOrEmpty(renderResult.RedirectUrl)) From fe900f74edde18eed2c9cf5bcfcb8a7045f05e99 Mon Sep 17 00:00:00 2001 From: Kristian Hellang Date: Mon, 2 Oct 2017 21:10:52 +0200 Subject: [PATCH 4/7] Use AddHttpContextAccessor method added in aspnet/HttpAbstractions#947 --- .../PrerenderingServiceCollectionExtensions.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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(); } } From 52c69d83ba0c39643b0017290193f7624af44d9e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 5 Oct 2017 21:28:51 +0100 Subject: [PATCH 5/7] Update to newer API designs --- .../AngularCli/AngularCliBuilder.cs | 22 ++-- .../AngularCli/AngularCliMiddleware.cs | 50 +++++++- .../AngularCliMiddlewareExtensions.cs | 31 +++-- .../DefaultSpaBuilder.cs | 37 ------ .../ISpaBuilder.cs | 57 --------- .../Prerendering/ISpaPrerendererBuilder.cs | 5 +- .../Prerendering/SpaPrerenderingExtensions.cs | 47 ++++--- .../SpaDefaultPageExtensions.cs | 92 ++++++++++++++ .../SpaExtensions.cs | 120 ------------------ 9 files changed, 198 insertions(+), 263 deletions(-) delete mode 100644 src/Microsoft.AspNetCore.SpaServices/DefaultSpaBuilder.cs delete mode 100644 src/Microsoft.AspNetCore.SpaServices/ISpaBuilder.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageExtensions.cs delete mode 100644 src/Microsoft.AspNetCore.SpaServices/SpaExtensions.cs diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs index 483d5850..ad8479b8 100644 --- a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliBuilder.cs @@ -1,9 +1,9 @@ // 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.Linq; using System.Threading.Tasks; namespace Microsoft.AspNetCore.SpaServices.AngularCli @@ -26,18 +26,22 @@ public AngularCliBuilder(string cliAppName) } /// - public Task Build(ISpaBuilder spaBuilder) + public Task Build(IApplicationBuilder app) { - // Locate the AngularCliMiddleware within the provided ISpaBuilder - var angularCliMiddleware = spaBuilder - .Properties.Keys.OfType().FirstOrDefault(); - if (angularCliMiddleware == null) + // 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(AngularCliMiddleware)}."); + $"Cannot use {nameof(AngularCliBuilder)} unless you are also using" + + $" {nameof(AngularCliMiddlewareExtensions.UseAngularCliServer)}."); } - - return angularCliMiddleware.StartAngularCliBuilderAsync(_cliAppName); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs index e8636457..4d422c5a 100644 --- a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Hosting; using System.Threading; using Microsoft.AspNetCore.SpaServices.Proxy; +using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.SpaServices.AngularCli { @@ -16,10 +17,12 @@ 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(ISpaBuilder spaBuilder, string sourcePath) + public AngularCliMiddleware(IApplicationBuilder appBuilder, string sourcePath, string urlPrefix, string defaultPage) { if (string.IsNullOrEmpty(sourcePath)) { @@ -27,22 +30,20 @@ public AngularCliMiddleware(ISpaBuilder spaBuilder, string 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, + UseProxyToLocalAngularCliMiddleware(appBuilder, urlPrefix, defaultPage, angularCliServerInfoTask, TimeSpan.FromSeconds(100)); // Advertise the availability of this feature to other SPA middleware - spaBuilder.Properties.Add(this, null); + appBuilder.Properties.Add(AngularCliMiddlewareKey, this); } public Task StartAngularCliBuilderAsync(string cliAppName) @@ -65,6 +66,11 @@ private static INodeServices CreateNodeServicesInstance( ProjectPath = Path.Combine(Directory.GetCurrentDirectory(), sourcePath), }; + if (!Directory.Exists(nodeServicesOptions.ProjectPath)) + { + throw new DirectoryNotFoundException($"Directory not found: {nodeServicesOptions.ProjectPath}"); + } + return NodeServicesFactory.CreateNodeServices(nodeServicesOptions); } @@ -99,7 +105,7 @@ private async Task StartAngularCliServerAsync() } private static void UseProxyToLocalAngularCliMiddleware( - IApplicationBuilder appBuilder, string publicPath, + IApplicationBuilder appBuilder, string urlPrefix, string defaultPage, Task serverInfoTask, TimeSpan requestTimeout) { // This is hardcoded to use http://localhost because: @@ -110,7 +116,37 @@ private static void UseProxyToLocalAngularCliMiddleware( var proxyOptionsTask = serverInfoTask.ContinueWith( task => new ConditionalProxyMiddlewareTarget( "http", "localhost", task.Result.Port.ToString())); - appBuilder.UseMiddleware(publicPath, requestTimeout, proxyOptionsTask); + + // Requests outside / are proxied to the default page + var hasRewrittenUrlMarker = new object(); + var defaultPageUrl = SpaDefaultPageExtensions.GetDefaultPageUrl( + urlPrefix, defaultPage); + 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 diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs index 54f2d142..bb82d110 100644 --- a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs @@ -1,6 +1,8 @@ // 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; + namespace Microsoft.AspNetCore.SpaServices.AngularCli { /// @@ -9,23 +11,24 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli 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. + /// 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 Angular CLI middleware. + /// This feature should only be used in development. For production deployments, be + /// sure not to enable the Angular CLI server. /// - /// 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) + /// 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. + /// The URL prefix from which your SPA's files are served. This needs to match the --deploy-url option passed to Angular CLI. + /// Optional. Specifies the URL, relative to , of the default HTML page that starts up your SPA. Defaults to index.html. + public static void UseAngularCliServer( + this IApplicationBuilder app, + string sourcePath, + string urlPrefix, + string defaultPage = null) { - new AngularCliMiddleware(spaBuilder, sourcePath); + new AngularCliMiddleware(app, sourcePath, urlPrefix, defaultPage); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices/DefaultSpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/DefaultSpaBuilder.cs deleted file mode 100644 index 08d9c0ee..00000000 --- a/src/Microsoft.AspNetCore.SpaServices/DefaultSpaBuilder.cs +++ /dev/null @@ -1,37 +0,0 @@ -// 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 deleted file mode 100644 index 04703224..00000000 --- a/src/Microsoft.AspNetCore.SpaServices/ISpaBuilder.cs +++ /dev/null @@ -1,57 +0,0 @@ -// 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/Prerendering/ISpaPrerendererBuilder.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs index 860a0398..ccff5069 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerendererBuilder.cs @@ -1,6 +1,7 @@ // 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 @@ -17,8 +18,8 @@ public interface ISpaPrerendererBuilder /// exists on disk. Prerendering middleware can then execute that file in /// a Node environment. /// - /// The . + /// The . /// A representing completion of the build process. - Task Build(ISpaBuilder spaBuilder); + Task Build(IApplicationBuilder appBuilder); } } diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs index ac456464..8139ad10 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs @@ -5,7 +5,6 @@ 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; @@ -19,27 +18,40 @@ namespace Microsoft.AspNetCore.Builder public static class SpaPrerenderingExtensions { /// - /// Adds middleware for server-side prerendering of a Single Page Application. + /// Enables server-side prerendering middleware for a Single Page Application. /// - /// The . + /// The . /// The path, relative to your application root, of the JavaScript file containing prerendering logic. + /// The URL prefix from which your SPA's files are served. /// 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, + /// Optional. Specifies the URL, relative to , of the default HTML page that starts up your SPA. Defaults to index.html. + public static void UseSpaPrerendering( + this IApplicationBuilder appBuilder, string entryPoint, - ISpaPrerendererBuilder buildOnDemand = null) + string urlPrefix, + ISpaPrerendererBuilder buildOnDemand = null, + string defaultPage = null) { if (string.IsNullOrEmpty(entryPoint)) { throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); } + 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(spaBuilder)); + var lazyBuildOnDemandTask = new Lazy(() => buildOnDemand?.Build(appBuilder)); // Get all the necessary context info that will be used for each prerendering call - var appBuilder = spaBuilder.AppBuilder; var serviceProvider = appBuilder.ApplicationServices; var nodeServices = GetNodeServices(serviceProvider); var applicationStoppingToken = serviceProvider.GetRequiredService() @@ -47,13 +59,17 @@ public static void UsePrerendering( var applicationBasePath = serviceProvider.GetRequiredService() .ContentRootPath; var moduleExport = new JavaScriptModuleExport(entryPoint); + var defaultPageUrl = SpaDefaultPageExtensions.GetDefaultPageUrl( + urlPrefix, defaultPage); + 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 aren't meant to render the SPA default file - if (!context.Items.ContainsKey(SpaExtensions.IsSpaFallbackRequestTag)) + // 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; @@ -66,15 +82,12 @@ public static void UsePrerendering( 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) + templateUrl = GetDefaultFileAbsoluteUrl(context, defaultPageUrl) }; // TODO: Add an optional "supplyCustomData" callback param so people using @@ -105,7 +118,7 @@ private static async Task ApplyRenderResult(HttpContext context, RenderToStringR // 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."); + 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"; @@ -113,11 +126,11 @@ private static async Task ApplyRenderResult(HttpContext context, RenderToStringR } } - private static string GetDefaultFileAbsoluteUrl(ISpaBuilder spaBuilder, HttpContext context) + private static string GetDefaultFileAbsoluteUrl(HttpContext context, string defaultPageUrl) { var req = context.Request; var defaultFileAbsoluteUrl = UriHelper.BuildAbsolute( - req.Scheme, req.Host, req.PathBase, spaBuilder.DefaultFilePath); + req.Scheme, req.Host, req.PathBase, defaultPageUrl); return defaultFileAbsoluteUrl; } diff --git a/src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageExtensions.cs new file mode 100644 index 00000000..9545ea9e --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageExtensions.cs @@ -0,0 +1,92 @@ +// 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 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 SpaDefaultPageExtensions + { + /// + /// 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". + /// + public static void UseSpaDefaultPage( + this IApplicationBuilder app, + string urlPrefix, + string defaultPage = null) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (urlPrefix == null) + { + throw new ArgumentNullException(nameof(urlPrefix)); + } + + // Rewrite all requests to the default page + var defaultPageUrl = GetDefaultPageUrl(urlPrefix, defaultPage); + 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 {nameof(UseSpaDefaultPage)}() 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); + }); + } + + internal static string GetDefaultPageUrl(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/SpaExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/SpaExtensions.cs deleted file mode 100644 index 5b597747..00000000 --- a/src/Microsoft.AspNetCore.SpaServices/SpaExtensions.cs +++ /dev/null @@ -1,120 +0,0 @@ -// 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); - }); - }); - } - } -} From e12c0eee61d2aec97d2a536c17c6628bcffd5e55 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 5 Oct 2017 23:00:41 +0100 Subject: [PATCH 6/7] Swap order of params for UseAngularCliServer. Makes usage look neater. --- .../AngularCli/AngularCliMiddlewareExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs index bb82d110..170ea698 100644 --- a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs @@ -19,13 +19,13 @@ public static class AngularCliMiddlewareExtensions /// 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. /// The URL prefix from which your SPA's files are served. This needs to match the --deploy-url option passed to Angular CLI. + /// 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. /// Optional. Specifies the URL, relative to , of the default HTML page that starts up your SPA. Defaults to index.html. public static void UseAngularCliServer( this IApplicationBuilder app, - string sourcePath, string urlPrefix, + string sourcePath, string defaultPage = null) { new AngularCliMiddleware(app, sourcePath, urlPrefix, defaultPage); From 8d33fa545ac9ed88e058019fbf8579819d024bd5 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 5 Oct 2017 23:53:16 +0100 Subject: [PATCH 7/7] Avoid the need to pass urlPrefix/defaultPage to any middleware but one, enforcing a particular structure of middleware --- .../AngularCli/AngularCliMiddleware.cs | 10 +- .../AngularCliMiddlewareExtensions.cs | 15 +-- .../Prerendering/SpaPrerenderingExtensions.cs | 18 ++-- .../SpaApplicationBuilderExtensions.cs | 49 ++++++++++ .../SpaDefaultPageExtensions.cs | 92 ------------------- .../SpaDefaultPageMiddleware.cs | 91 ++++++++++++++++++ 6 files changed, 164 insertions(+), 111 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SpaServices/SpaApplicationBuilderExtensions.cs delete mode 100644 src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageExtensions.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageMiddleware.cs diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs index 4d422c5a..8e25f85f 100644 --- a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs @@ -22,7 +22,7 @@ internal class AngularCliMiddleware private readonly INodeServices _nodeServices; private readonly string _middlewareScriptPath; - public AngularCliMiddleware(IApplicationBuilder appBuilder, string sourcePath, string urlPrefix, string defaultPage) + public AngularCliMiddleware(IApplicationBuilder appBuilder, string sourcePath, SpaDefaultPageMiddleware defaultPageMiddleware) { if (string.IsNullOrEmpty(sourcePath)) { @@ -39,7 +39,7 @@ public AngularCliMiddleware(IApplicationBuilder appBuilder, string sourcePath, s // 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, urlPrefix, defaultPage, + UseProxyToLocalAngularCliMiddleware(appBuilder, defaultPageMiddleware, angularCliServerInfoTask, TimeSpan.FromSeconds(100)); // Advertise the availability of this feature to other SPA middleware @@ -105,7 +105,7 @@ private async Task StartAngularCliServerAsync() } private static void UseProxyToLocalAngularCliMiddleware( - IApplicationBuilder appBuilder, string urlPrefix, string defaultPage, + IApplicationBuilder appBuilder, SpaDefaultPageMiddleware defaultPageMiddleware, Task serverInfoTask, TimeSpan requestTimeout) { // This is hardcoded to use http://localhost because: @@ -119,8 +119,8 @@ private static void UseProxyToLocalAngularCliMiddleware( // Requests outside / are proxied to the default page var hasRewrittenUrlMarker = new object(); - var defaultPageUrl = SpaDefaultPageExtensions.GetDefaultPageUrl( - urlPrefix, defaultPage); + var defaultPageUrl = defaultPageMiddleware.DefaultPageUrl; + var urlPrefix = defaultPageMiddleware.UrlPrefix; var urlPrefixIsRoot = string.IsNullOrEmpty(urlPrefix) || urlPrefix == "/"; appBuilder.Use((context, next) => { diff --git a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs index 170ea698..43c1e678 100644 --- a/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddlewareExtensions.cs @@ -2,6 +2,7 @@ // 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 { @@ -19,16 +20,18 @@ public static class AngularCliMiddlewareExtensions /// sure not to enable the Angular CLI server. /// /// The . - /// The URL prefix from which your SPA's files are served. This needs to match the --deploy-url option passed to Angular CLI. /// 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. - /// Optional. Specifies the URL, relative to , of the default HTML page that starts up your SPA. Defaults to index.html. public static void UseAngularCliServer( this IApplicationBuilder app, - string urlPrefix, - string sourcePath, - string defaultPage = null) + string sourcePath) { - new AngularCliMiddleware(app, sourcePath, urlPrefix, defaultPage); + 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/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs index 8139ad10..ab31f8b8 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/SpaPrerenderingExtensions.cs @@ -5,6 +5,7 @@ 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; @@ -22,21 +23,24 @@ public static class SpaPrerenderingExtensions /// /// The . /// The path, relative to your application root, of the JavaScript file containing prerendering logic. - /// The URL prefix from which your SPA's files are served. /// Optional. If specified, executes the supplied before looking for the file. This is only intended to be used during development. - /// Optional. Specifies the URL, relative to , of the default HTML page that starts up your SPA. Defaults to index.html. public static void UseSpaPrerendering( this IApplicationBuilder appBuilder, string entryPoint, - string urlPrefix, - ISpaPrerendererBuilder buildOnDemand = null, - string defaultPage = null) + 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( @@ -59,8 +63,6 @@ public static void UseSpaPrerendering( var applicationBasePath = serviceProvider.GetRequiredService() .ContentRootPath; var moduleExport = new JavaScriptModuleExport(entryPoint); - var defaultPageUrl = SpaDefaultPageExtensions.GetDefaultPageUrl( - urlPrefix, defaultPage); var urlPrefixAsPathString = new PathString(urlPrefix); // Add the actual middleware that intercepts requests for the SPA default file @@ -87,7 +89,7 @@ public static void UseSpaPrerendering( // remove this. var customData = new { - templateUrl = GetDefaultFileAbsoluteUrl(context, defaultPageUrl) + templateUrl = GetDefaultFileAbsoluteUrl(context, defaultPageMiddleware.DefaultPageUrl) }; // TODO: Add an optional "supplyCustomData" callback param so people using 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/SpaDefaultPageExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageExtensions.cs deleted file mode 100644 index 9545ea9e..00000000 --- a/src/Microsoft.AspNetCore.SpaServices/SpaDefaultPageExtensions.cs +++ /dev/null @@ -1,92 +0,0 @@ -// 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 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 SpaDefaultPageExtensions - { - /// - /// 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". - /// - public static void UseSpaDefaultPage( - this IApplicationBuilder app, - string urlPrefix, - string defaultPage = null) - { - if (app == null) - { - throw new ArgumentNullException(nameof(app)); - } - - if (urlPrefix == null) - { - throw new ArgumentNullException(nameof(urlPrefix)); - } - - // Rewrite all requests to the default page - var defaultPageUrl = GetDefaultPageUrl(urlPrefix, defaultPage); - 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 {nameof(UseSpaDefaultPage)}() 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); - }); - } - - internal static string GetDefaultPageUrl(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/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)); + } + } +}