From 7682c749fc94cdf71394deb689e3a9d34170331b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Oct 2017 15:38:32 +0100 Subject: [PATCH 01/36] Add new Microsoft.AspNetCore.SpaServices.Extensions package to host new runtime functionality needed for updated templates until 2.1 ships --- JavaScriptServices.sln | 9 +- .../AngularCli/AngularCliBuilder.cs | 47 +++ .../AngularCli/AngularCliMiddleware.cs | 138 +++++++++ .../AngularCliMiddlewareExtensions.cs | 37 +++ .../Content/Node/angular-cli-middleware.js | 78 +++++ ...t.AspNetCore.SpaServices.Extensions.csproj | 20 ++ .../Prerendering/ISpaPrerendererBuilder.cs | 25 ++ .../Prerendering/SpaPrerenderingExtensions.cs | 165 +++++++++++ .../Proxying/ConditionalProxy.cs | 268 ++++++++++++++++++ .../Proxying/ConditionalProxyMiddleware.cs | 62 ++++ .../ConditionalProxyMiddlewareTarget.cs | 19 ++ .../SpaApplicationBuilderExtensions.cs | 49 ++++ .../SpaDefaultPageMiddleware.cs | 91 ++++++ 13 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddlewareTarget.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs diff --git a/JavaScriptServices.sln b/JavaScriptServices.sln index 1c05a050..f01aa1a9 100644 --- a/JavaScriptServices.sln +++ b/JavaScriptServices.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26730.0 +VisualStudioVersion = 15.0.26730.16 MinimumVisualStudioVersion = 15.0.26730.03 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{27304DDE-AFB2-4F8B-B765-E3E2F11E886C}" ProjectSection(SolutionItems) = preProject @@ -37,6 +37,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Directory.Build.targets = Directory.Build.targets EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SpaServices.Extensions", "src\Microsoft.AspNetCore.SpaServices.Extensions\Microsoft.AspNetCore.SpaServices.Extensions.csproj", "{D40BD1C4-6A6F-4213-8535-1057F3EB3400}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,10 @@ Global {93EFCC5F-C6EE-4623-894F-A42B22C0B6FE}.Debug|Any CPU.Build.0 = Debug|Any CPU {93EFCC5F-C6EE-4623-894F-A42B22C0B6FE}.Release|Any CPU.ActiveCfg = Release|Any CPU {93EFCC5F-C6EE-4623-894F-A42B22C0B6FE}.Release|Any CPU.Build.0 = Release|Any CPU + {D40BD1C4-6A6F-4213-8535-1057F3EB3400}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D40BD1C4-6A6F-4213-8535-1057F3EB3400}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D40BD1C4-6A6F-4213-8535-1057F3EB3400}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D40BD1C4-6A6F-4213-8535-1057F3EB3400}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -79,6 +85,7 @@ Global {1931B19A-EC42-4D56-B2D0-FB06D17244DA} = {E6A161EA-646C-4033-9090-95BE809AB8D9} {DE479DC3-1461-4EAD-A188-4AF7AA4AE344} = {E6A161EA-646C-4033-9090-95BE809AB8D9} {93EFCC5F-C6EE-4623-894F-A42B22C0B6FE} = {E6A161EA-646C-4033-9090-95BE809AB8D9} + {D40BD1C4-6A6F-4213-8535-1057F3EB3400} = {27304DDE-AFB2-4F8B-B765-E3E2F11E886C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DDF59B0D-2DEC-45D6-8667-DCB767487101} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs new file mode 100644 index 00000000..ad8479b8 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.SpaServices.Prerendering; +using System; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SpaServices.AngularCli +{ + /// + /// Provides an implementation of that can build + /// an Angular application by invoking the Angular CLI. + /// + public class AngularCliBuilder : ISpaPrerendererBuilder + { + private readonly string _cliAppName; + + /// + /// Constructs an instance of . + /// + /// The name of the application to be built. This must match an entry in your .angular-cli.json file. + public AngularCliBuilder(string cliAppName) + { + _cliAppName = cliAppName; + } + + /// + public Task Build(IApplicationBuilder app) + { + // Locate the AngularCliMiddleware within the provided IApplicationBuilder + if (app.Properties.TryGetValue( + AngularCliMiddleware.AngularCliMiddlewareKey, + out var angularCliMiddleware)) + { + return ((AngularCliMiddleware)angularCliMiddleware) + .StartAngularCliBuilderAsync(_cliAppName); + } + else + { + throw new Exception( + $"Cannot use {nameof(AngularCliBuilder)} unless you are also using" + + $" {nameof(AngularCliMiddlewareExtensions.UseAngularCliServer)}."); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs new file mode 100644 index 00000000..5518a053 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -0,0 +1,138 @@ +// 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.Hosting; +using Microsoft.AspNetCore.NodeServices; +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.AspNetCore.SpaServices.Extensions.Proxy; + +namespace Microsoft.AspNetCore.SpaServices.AngularCli +{ + internal class AngularCliMiddleware + { + private const string _middlewareResourceName = "/Content/Node/angular-cli-middleware.js"; + + internal readonly static string AngularCliMiddlewareKey = Guid.NewGuid().ToString(); + + private readonly INodeServices _nodeServices; + private readonly string _middlewareScriptPath; + private readonly HttpClient _neverTimeOutHttpClient = + ConditionalProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); + + public AngularCliMiddleware( + IApplicationBuilder appBuilder, + string sourcePath, + SpaDefaultPageMiddleware defaultPageMiddleware) + { + if (string.IsNullOrEmpty(sourcePath)) + { + throw new ArgumentException("Cannot be null or empty", nameof(sourcePath)); + } + + // Prepare to make calls into Node + _nodeServices = CreateNodeServicesInstance(appBuilder, sourcePath); + _middlewareScriptPath = GetAngularCliMiddlewareScriptPath(appBuilder); + + // Start Angular CLI and attach to middleware pipeline + var angularCliServerInfoTask = StartAngularCliServerAsync(); + + // Everything we proxy is hardcoded to target 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 = angularCliServerInfoTask.ContinueWith( + task => new ConditionalProxyMiddlewareTarget( + "http", "localhost", task.Result.Port.ToString())); + + var applicationStoppingToken = GetStoppingToken(appBuilder); + + // Proxy all requests into the Angular CLI server + appBuilder.Use(async (context, next) => + { + var didProxyRequest = await ConditionalProxy.PerformProxyRequest( + context, _neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken); + + // Since we are proxying everything, this is the end of the middleware pipeline. + // We won't call next(). + if (!didProxyRequest) + { + context.Response.StatusCode = 404; + } + }); + + // Advertise the availability of this feature to other SPA middleware + appBuilder.Properties.Add(AngularCliMiddlewareKey, this); + } + + internal Task StartAngularCliBuilderAsync(string cliAppName) + { + return _nodeServices.InvokeExportAsync( + _middlewareScriptPath, + "startAngularCliBuilder", + cliAppName); + } + + private static INodeServices CreateNodeServicesInstance( + IApplicationBuilder appBuilder, string sourcePath) + { + // Unlike other consumers of NodeServices, AngularCliMiddleware dosen't share Node instances, nor does it + // use your DI configuration. It's important for AngularCliMiddleware to have its own private Node instance + // because it must *not* restart when files change (it's designed to watch for changes and rebuild). + var nodeServicesOptions = new NodeServicesOptions(appBuilder.ApplicationServices) + { + WatchFileExtensions = new string[] { }, // Don't watch anything + ProjectPath = Path.Combine(Directory.GetCurrentDirectory(), sourcePath), + }; + + if (!Directory.Exists(nodeServicesOptions.ProjectPath)) + { + throw new DirectoryNotFoundException($"Directory not found: {nodeServicesOptions.ProjectPath}"); + } + + return NodeServicesFactory.CreateNodeServices(nodeServicesOptions); + } + + private static string GetAngularCliMiddlewareScriptPath(IApplicationBuilder appBuilder) + { + var script = EmbeddedResourceReader.Read(typeof(AngularCliMiddleware), _middlewareResourceName); + var nodeScript = new StringAsTempFile(script, GetStoppingToken(appBuilder)); + return nodeScript.FileName; + } + + private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder) + { + var applicationLifetime = appBuilder + .ApplicationServices + .GetService(typeof(IApplicationLifetime)); + return ((IApplicationLifetime)applicationLifetime).ApplicationStopping; + } + + private async Task StartAngularCliServerAsync() + { + // Tell Node to start the server hosting the Angular CLI + var angularCliServerInfo = await _nodeServices.InvokeExportAsync( + _middlewareScriptPath, + "startAngularCliServer"); + + // Even after the Angular CLI claims to be listening for requests, there's a short + // period where it will give an error if you make a request too quickly. Give it + // a moment to finish starting up. + await Task.Delay(500); + + return angularCliServerInfo; + } + +#pragma warning disable CS0649 + class AngularCliServerInfo + { + public int Port { get; set; } + } + } +#pragma warning restore CS0649 +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs new file mode 100644 index 00000000..43c1e678 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using System; + +namespace Microsoft.AspNetCore.SpaServices.AngularCli +{ + /// + /// Extension methods for enabling Angular CLI middleware support. + /// + public static class AngularCliMiddlewareExtensions + { + /// + /// Handles requests by passing them through to an instance of the Angular CLI server. + /// This means you can always serve up-to-date CLI-built resources without having + /// to run the Angular CLI server manually. + /// + /// This feature should only be used in development. For production deployments, be + /// sure not to enable the Angular CLI server. + /// + /// The . + /// The disk path, relative to the current directory, of the directory containing the SPA source files. When Angular CLI executes, this will be its working directory. + public static void UseAngularCliServer( + this IApplicationBuilder app, + string sourcePath) + { + var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(app); + if (defaultPageMiddleware == null) + { + throw new Exception($"{nameof(UseAngularCliServer)} should be called inside the 'configue' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + new AngularCliMiddleware(app, sourcePath, defaultPageMiddleware); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js b/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js new file mode 100644 index 00000000..16b91244 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js @@ -0,0 +1,78 @@ +// 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 }); + var listener = function (line) { + var matches = regex.exec(line); + if (matches) { + lineReader.removeListener('line', listener); + resolve(matches); + } + }; + lineReader.addListener('line', listener); + }); +} + +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.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj new file mode 100644 index 00000000..83f06e05 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj @@ -0,0 +1,20 @@ + + + + Helpers for building single-page applications on ASP.NET MVC Core. + netstandard2.0 + + + + + + + + + + + + + + + diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs new file mode 100644 index 00000000..ccff5069 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SpaServices.Prerendering +{ + /// + /// Represents the ability to build a Single Page Application application on demand + /// so that it can be prerendered. This is only intended to be used at development + /// time. In production, a SPA should already be built during publishing. + /// + public interface ISpaPrerendererBuilder + { + /// + /// Builds the Single Page Application so that a JavaScript entrypoint file + /// exists on disk. Prerendering middleware can then execute that file in + /// a Node environment. + /// + /// The . + /// A representing completion of the build process. + Task Build(IApplicationBuilder appBuilder); + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs new file mode 100644 index 00000000..2a0984b7 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -0,0 +1,165 @@ +// 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.Http.Features; +using Microsoft.AspNetCore.NodeServices; +using Microsoft.AspNetCore.SpaServices; +using Microsoft.AspNetCore.SpaServices.Prerendering; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for configuring prerendering of a Single Page Application. + /// + public static class SpaPrerenderingExtensions + { + /// + /// Enables server-side prerendering middleware for a Single Page Application. + /// + /// The . + /// The path, relative to your application root, of the JavaScript file containing prerendering logic. + /// Optional. If specified, executes the supplied before looking for the file. This is only intended to be used during development. + public static void UseSpaPrerendering( + this IApplicationBuilder appBuilder, + string entryPoint, + ISpaPrerendererBuilder buildOnDemand = null) + { + if (string.IsNullOrEmpty(entryPoint)) + { + throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); + } + + var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(appBuilder); + if (defaultPageMiddleware == null) + { + throw new Exception($"{nameof(UseSpaPrerendering)} should be called inside the 'configure' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + var urlPrefix = defaultPageMiddleware.UrlPrefix; + if (urlPrefix == null || urlPrefix.Length < 2) + { + throw new ArgumentException( + "If you are using server-side prerendering, the SPA's public path must be " + + "set to a non-empty and non-root value. This makes it possible to identify " + + "requests for the SPA's internal static resources, so the prerenderer knows " + + "not to return prerendered HTML for those requests.", + nameof(urlPrefix)); + } + + // We only want to start one build-on-demand task, but it can't commence until + // a request comes in (because we need to wait for all middleware to be configured) + var lazyBuildOnDemandTask = new Lazy(() => buildOnDemand?.Build(appBuilder)); + + // Get all the necessary context info that will be used for each prerendering call + var serviceProvider = appBuilder.ApplicationServices; + var nodeServices = GetNodeServices(serviceProvider); + var applicationStoppingToken = serviceProvider.GetRequiredService() + .ApplicationStopping; + var applicationBasePath = serviceProvider.GetRequiredService() + .ContentRootPath; + var moduleExport = new JavaScriptModuleExport(entryPoint); + var urlPrefixAsPathString = new PathString(urlPrefix); + + // Add the actual middleware that intercepts requests for the SPA default file + // and invokes the prerendering code + appBuilder.Use(async (context, next) => + { + // Don't interfere with requests that are within the SPA's urlPrefix, because + // these requests are meant to serve its internal resources (.js, .css, etc.) + if (context.Request.Path.StartsWithSegments(urlPrefixAsPathString)) + { + await next(); + return; + } + + // If we're building on demand, do that first + var buildOnDemandTask = lazyBuildOnDemandTask.Value; + if (buildOnDemandTask != null && !buildOnDemandTask.IsCompleted) + { + await buildOnDemandTask; + } + + // As a workaround for @angular/cli not emitting the index.html in 'server' + // builds, pass through a URL that can be used for obtaining it. Longer term, + // remove this. + var customData = new + { + templateUrl = GetDefaultFileAbsoluteUrl(context, defaultPageMiddleware.DefaultPageUrl) + }; + + // TODO: Add an optional "supplyCustomData" callback param so people using + // UsePrerendering() can, for example, pass through cookies into the .ts code + + var (unencodedAbsoluteUrl, unencodedPathAndQuery) = GetUnencodedUrlAndPathQuery(context); + var renderResult = await Prerenderer.RenderToString( + applicationBasePath, + nodeServices, + applicationStoppingToken, + moduleExport, + unencodedAbsoluteUrl, + unencodedPathAndQuery, + customDataParameter: customData, + timeoutMilliseconds: 0, + requestPathBase: context.Request.PathBase.ToString()); + + await ApplyRenderResult(context, renderResult); + }); + } + + private static (string, string) GetUnencodedUrlAndPathQuery(HttpContext httpContext) + { + // This is a duplicate of code from Prerenderer.cs in the SpaServices package. + // Once the SpaServices.Extension package implementation gets merged back into + // SpaServices, this duplicate can be removed. To remove this, change the code + // above that calls Prerenderer.RenderToString to use the internal overload + // that takes an HttpContext instead of a url/path+query pair. + var requestFeature = httpContext.Features.Get(); + var unencodedPathAndQuery = requestFeature.RawTarget; + var request = httpContext.Request; + var unencodedAbsoluteUrl = $"{request.Scheme}://{request.Host}{unencodedPathAndQuery}"; + return (unencodedAbsoluteUrl, unencodedPathAndQuery); + } + + private static async Task ApplyRenderResult(HttpContext context, RenderToStringResult renderResult) + { + if (!string.IsNullOrEmpty(renderResult.RedirectUrl)) + { + context.Response.Redirect(renderResult.RedirectUrl); + } + else + { + // The Globals property exists for back-compatibility but is meaningless + // for prerendering that returns complete HTML pages + if (renderResult.Globals != null) + { + throw new Exception($"{nameof(renderResult.Globals)} is not supported when prerendering via {nameof(UseSpaPrerendering)}(). Instead, your prerendering logic should return a complete HTML page, in which you embed any information you wish to return to the client."); + } + + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync(renderResult.Html); + } + } + + private static string GetDefaultFileAbsoluteUrl(HttpContext context, string defaultPageUrl) + { + var req = context.Request; + var defaultFileAbsoluteUrl = UriHelper.BuildAbsolute( + req.Scheme, req.Host, req.PathBase, defaultPageUrl); + return defaultFileAbsoluteUrl; + } + + private static INodeServices GetNodeServices(IServiceProvider serviceProvider) + { + // Use the registered instance, or create a new private instance if none is registered + var instance = (INodeServices)serviceProvider.GetService(typeof(INodeServices)); + return instance ?? NodeServicesFactory.CreateNodeServices( + new NodeServicesOptions(serviceProvider)); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs new file mode 100644 index 00000000..9b234828 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs @@ -0,0 +1,268 @@ +// 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 System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SpaServices.Extensions.Proxy +{ + // This duplicates and updates the proxying logic in SpaServices so that we can update + // the project templates without waiting for 2.1 to ship. When 2.1 is ready to ship, + // merge the additional proxying features (e.g., proxying websocket connections) back + // into the SpaServices proxying code. It's all internal. + internal static class ConditionalProxy + { + private const int DefaultWebSocketBufferSize = 4096; + private const int StreamCopyBufferSize = 81920; + + private static readonly string[] NotForwardedWebSocketHeaders = new[] { "Connection", "Host", "Upgrade", "Sec-WebSocket-Key", "Sec-WebSocket-Version" }; + + public static HttpClient CreateHttpClientForProxy(TimeSpan requestTimeout) + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + + }; + + return new HttpClient(handler) + { + Timeout = requestTimeout + }; + } + + public static async Task PerformProxyRequest( + HttpContext context, + HttpClient httpClient, + Task targetTask, + CancellationToken applicationStoppingToken) + { + // Stop proxying if either the server or client wants to disconnect + var proxyCancellationToken = CancellationTokenSource.CreateLinkedTokenSource( + context.RequestAborted, + applicationStoppingToken).Token; + + // 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 targetUri = new UriBuilder( + target.Scheme, + target.Host, + int.Parse(target.Port), + context.Request.Path, + context.Request.QueryString.Value).Uri; + + try + { + if (context.WebSockets.IsWebSocketRequest) + { + await AcceptProxyWebSocketRequest(context, ToWebSocketScheme(targetUri), proxyCancellationToken); + return true; + } + else + { + using (var requestMessage = CreateProxyHttpRequest(context, targetUri)) + using (var responseMessage = await SendProxyHttpRequest(context, httpClient, requestMessage, proxyCancellationToken)) + { + return await CopyProxyHttpResponse(context, responseMessage, proxyCancellationToken); + } + } + } + catch (OperationCanceledException) + { + // If we're aborting because either the client disconnected, or the server + // is shutting down, don't treat this as an error. + return true; + } + catch (IOException) + { + // This kind of exception can also occur if a proxy read/write gets interrupted + // due to the process shutting down. + return true; + } + } + + private static HttpRequestMessage CreateProxyHttpRequest(HttpContext context, Uri uri) + { + var request = context.Request; + + var requestMessage = new HttpRequestMessage(); + var requestMethod = request.Method; + if (!HttpMethods.IsGet(requestMethod) && + !HttpMethods.IsHead(requestMethod) && + !HttpMethods.IsDelete(requestMethod) && + !HttpMethods.IsTrace(requestMethod)) + { + var streamContent = new StreamContent(request.Body); + requestMessage.Content = streamContent; + } + + // Copy the request headers + foreach (var header in request.Headers) + { + if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()) && requestMessage.Content != null) + { + requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + } + + requestMessage.Headers.Host = uri.Authority; + requestMessage.RequestUri = uri; + requestMessage.Method = new HttpMethod(request.Method); + + return requestMessage; + } + + private static Task SendProxyHttpRequest(HttpContext context, HttpClient httpClient, HttpRequestMessage requestMessage, CancellationToken cancellationToken) + { + if (requestMessage == null) + { + throw new ArgumentNullException(nameof(requestMessage)); + } + + return httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } + + private static async Task CopyProxyHttpResponse(HttpContext context, HttpResponseMessage responseMessage, CancellationToken cancellationToken) + { + if (responseMessage.StatusCode == HttpStatusCode.NotFound) + { + // Let some other middleware handle this + return false; + } + + // We can handle this + context.Response.StatusCode = (int)responseMessage.StatusCode; + foreach (var header in responseMessage.Headers) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + + foreach (var header in responseMessage.Content.Headers) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + + // SendAsync removes chunking from the response. This removes the header so it doesn't expect a chunked response. + context.Response.Headers.Remove("transfer-encoding"); + + using (var responseStream = await responseMessage.Content.ReadAsStreamAsync()) + { + await responseStream.CopyToAsync(context.Response.Body, StreamCopyBufferSize, cancellationToken); + } + + return true; + } + + private static Uri ToWebSocketScheme(Uri uri) + { + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + var uriBuilder = new UriBuilder(uri); + if (string.Equals(uriBuilder.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + { + uriBuilder.Scheme = "wss"; + } + else if (string.Equals(uriBuilder.Scheme, "http", StringComparison.OrdinalIgnoreCase)) + { + uriBuilder.Scheme = "ws"; + } + + return uriBuilder.Uri; + } + + private static async Task AcceptProxyWebSocketRequest(HttpContext context, Uri destinationUri, CancellationToken cancellationToken) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (destinationUri == null) + { + throw new ArgumentNullException(nameof(destinationUri)); + } + if (!context.WebSockets.IsWebSocketRequest) + { + throw new InvalidOperationException(); + } + + using (var client = new ClientWebSocket()) + { + foreach (var headerEntry in context.Request.Headers) + { + if (!NotForwardedWebSocketHeaders.Contains(headerEntry.Key, StringComparer.OrdinalIgnoreCase)) + { + client.Options.SetRequestHeader(headerEntry.Key, headerEntry.Value); + } + } + + try + { + // Note that this is not really good enough to make Websockets work with + // Angular CLI middleware. For some reason, ConnectAsync takes over 1 second, + // by which time the logic in SockJS has already timed out and made it fall + // back on some other transport (xhr_streaming, usually). This is not a problem, + // because the transport fallback logic works correctly and doesn't surface any + // errors, but it would be better if ConnectAsync was fast enough and the + // initial Websocket transport could actually be used. + await client.ConnectAsync(destinationUri, cancellationToken); + } + catch (WebSocketException) + { + context.Response.StatusCode = 400; + return false; + } + + using (var server = await context.WebSockets.AcceptWebSocketAsync(client.SubProtocol)) + { + var bufferSize = DefaultWebSocketBufferSize; + await Task.WhenAll( + PumpWebSocket(client, server, bufferSize, cancellationToken), + PumpWebSocket(server, client, bufferSize, cancellationToken)); + } + + return true; + } + } + + private static async Task PumpWebSocket(WebSocket source, WebSocket destination, int bufferSize, CancellationToken cancellationToken) + { + if (bufferSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(bufferSize)); + } + + var buffer = new byte[bufferSize]; + + while (true) + { + var result = await source.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + { + if (destination.State == WebSocketState.Open || destination.State == WebSocketState.CloseReceived) + { + await destination.CloseOutputAsync(source.CloseStatus.Value, source.CloseStatusDescription, cancellationToken); + } + + return; + } + + await destination.SendAsync(new ArraySegment(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, cancellationToken); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs new file mode 100644 index 00000000..e4aa3fa4 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs @@ -0,0 +1,62 @@ +// 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.Hosting; +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SpaServices.Extensions.Proxy +{ + // This duplicates and updates the proxying logic in SpaServices so that we can update + // the project templates without waiting for 2.1 to ship. When 2.1 is ready to ship, + // merge the additional proxying features (e.g., proxying websocket connections) back + // into the SpaServices proxying code. It's all internal. + internal class ConditionalProxyMiddleware + { + private readonly RequestDelegate _next; + private readonly Task _targetTask; + private readonly string _pathPrefix; + private readonly bool _pathPrefixIsRoot; + private readonly HttpClient _httpClient; + private readonly CancellationToken _applicationStoppingToken; + + public ConditionalProxyMiddleware( + RequestDelegate next, + string pathPrefix, + TimeSpan requestTimeout, + Task targetTask, + IApplicationLifetime applicationLifetime) + { + if (!pathPrefix.StartsWith("/")) + { + pathPrefix = "/" + pathPrefix; + } + + _next = next; + _pathPrefix = pathPrefix; + _pathPrefixIsRoot = string.Equals(_pathPrefix, "/", StringComparison.Ordinal); + _targetTask = targetTask; + _httpClient = ConditionalProxy.CreateHttpClientForProxy(requestTimeout); + _applicationStoppingToken = applicationLifetime.ApplicationStopping; + } + + public async Task Invoke(HttpContext context) + { + if (context.Request.Path.StartsWithSegments(_pathPrefix) || _pathPrefixIsRoot) + { + var didProxyRequest = await ConditionalProxy.PerformProxyRequest( + context, _httpClient, _targetTask, _applicationStoppingToken); + if (didProxyRequest) + { + return; + } + } + + // Not a request we can proxy + await _next.Invoke(context); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddlewareTarget.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddlewareTarget.cs new file mode 100644 index 00000000..28c54f68 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/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.Extensions.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.Extensions/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs new file mode 100644 index 00000000..b7efcb6c --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/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.Extensions/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs new file mode 100644 index 00000000..7d1f9fec --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/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)); + } + } +} From 415aa549168aec540488f7135205d2c5bc0d419e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Oct 2017 15:49:17 +0100 Subject: [PATCH 02/36] SpaServices.Extensions will first ship to work with 2.0.0 dependencies --- ...icrosoft.AspNetCore.SpaServices.Extensions.csproj | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj index 83f06e05..9a890843 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj @@ -10,11 +10,13 @@ - - - - - + + + From 77fcfc256bd1424d554457211db69be0130aeee4 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Oct 2017 20:12:23 +0100 Subject: [PATCH 03/36] Revert "SpaServices.Extensions will first ship to work with 2.0.0 dependencies" because it breaks the build. Will need to find a different way to enforce this. This reverts commit 105422ba6e32aa711584a2b8b1f91a8e5f29e29e. --- ...icrosoft.AspNetCore.SpaServices.Extensions.csproj | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj index 9a890843..83f06e05 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj @@ -10,13 +10,11 @@ - - - + + + + + From 257e09566820833d0f1208c7d72a7e2b3a6ca2dc Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Oct 2017 15:56:08 +0100 Subject: [PATCH 04/36] Make ConditionalProxy shut down the WebSocket proxy much faster when the app is shutting down --- .../Proxying/ConditionalProxy.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs index 9b234828..9bcb1fe3 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs @@ -249,8 +249,26 @@ private static async Task PumpWebSocket(WebSocket source, WebSocket destination, while (true) { - var result = await source.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + // Because WebSocket.ReceiveAsync doesn't work well with CancellationToken (it doesn't + // actually exit when the token notifies, at least not in the 'server' case), use + // polling. The perf might not be ideal, but this is a dev-time feature only. + var resultTask = source.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (resultTask.IsCompleted) + { + break; + } + + await Task.Delay(250); + } + var result = resultTask.Result; // We know it's completed already if (result.MessageType == WebSocketMessageType.Close) { if (destination.State == WebSocketState.Open || destination.State == WebSocketState.CloseReceived) From 248591123bd5894fa432b3f00dfef16a961f97dc Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Oct 2017 15:57:25 +0100 Subject: [PATCH 05/36] Make UseSpaPrerendering capture the non-prerendered response and supply it to the boot function --- .../Prerendering/SpaPrerenderingExtensions.cs | 140 +++++++++++------- 1 file changed, 83 insertions(+), 57 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index 2a0984b7..f58d5dce 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -6,10 +6,14 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.NodeServices; -using Microsoft.AspNetCore.SpaServices; using Microsoft.AspNetCore.SpaServices.Prerendering; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; using System.Threading.Tasks; namespace Microsoft.AspNetCore.Builder @@ -25,33 +29,18 @@ public static class SpaPrerenderingExtensions /// 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. + /// Optional. If specified, requests within these URL paths will bypass the prerenderer. public static void UseSpaPrerendering( this IApplicationBuilder appBuilder, string entryPoint, - ISpaPrerendererBuilder buildOnDemand = null) + ISpaPrerendererBuilder buildOnDemand = null, + string[] excludeUrls = null) { if (string.IsNullOrEmpty(entryPoint)) { throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); } - var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(appBuilder); - if (defaultPageMiddleware == null) - { - throw new Exception($"{nameof(UseSpaPrerendering)} should be called inside the 'configure' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); - } - - var urlPrefix = defaultPageMiddleware.UrlPrefix; - if (urlPrefix == null || urlPrefix.Length < 2) - { - throw new ArgumentException( - "If you are using server-side prerendering, the SPA's public path must be " + - "set to a non-empty and non-root value. This makes it possible to identify " + - "requests for the SPA's internal static resources, so the prerenderer knows " + - "not to return prerendered HTML for those requests.", - nameof(urlPrefix)); - } - // We only want to start one build-on-demand task, but it can't commence until // a request comes in (because we need to wait for all middleware to be configured) var lazyBuildOnDemandTask = new Lazy(() => buildOnDemand?.Build(appBuilder)); @@ -64,54 +53,89 @@ public static void UseSpaPrerendering( var applicationBasePath = serviceProvider.GetRequiredService() .ContentRootPath; var moduleExport = new JavaScriptModuleExport(entryPoint); - var urlPrefixAsPathString = new PathString(urlPrefix); - - // Add the actual middleware that intercepts requests for the SPA default file - // and invokes the prerendering code + var excludePathStrings = (excludeUrls ?? Array.Empty()) + .Select(url => new PathString(url)) + .ToArray(); + + // Capture the non-prerendered responses, which in production will typically only + // be returning the default SPA index.html page (because other resources will be + // served statically from disk). We will use this as a template in which to inject + // the prerendered output. appBuilder.Use(async (context, next) => { - // Don't interfere with requests that are within the SPA's urlPrefix, because - // these requests are meant to serve its internal resources (.js, .css, etc.) - if (context.Request.Path.StartsWithSegments(urlPrefixAsPathString)) + // If this URL is excluded, skip prerendering + foreach (var excludePathString in excludePathStrings) { - await next(); - return; + if (context.Request.Path.StartsWithSegments(excludePathString)) + { + await next(); + return; + } } - // If we're building on demand, do that first - var buildOnDemandTask = lazyBuildOnDemandTask.Value; - if (buildOnDemandTask != null && !buildOnDemandTask.IsCompleted) - { - await buildOnDemandTask; - } + // It's no good if we try to return a 304. We need to capture the actual + // HTML content so it can be passed as a template to the prerenderer. + RemoveConditionalRequestHeaders(context.Request); - // 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 + using (var outputBuffer = new MemoryStream()) { - templateUrl = GetDefaultFileAbsoluteUrl(context, defaultPageMiddleware.DefaultPageUrl) - }; - - // TODO: Add an optional "supplyCustomData" callback param so people using - // UsePrerendering() can, for example, pass through cookies into the .ts code - - var (unencodedAbsoluteUrl, unencodedPathAndQuery) = GetUnencodedUrlAndPathQuery(context); - var renderResult = await Prerenderer.RenderToString( - applicationBasePath, - nodeServices, - applicationStoppingToken, - moduleExport, - unencodedAbsoluteUrl, - unencodedPathAndQuery, - customDataParameter: customData, - timeoutMilliseconds: 0, - requestPathBase: context.Request.PathBase.ToString()); - - await ApplyRenderResult(context, renderResult); + var originalResponseStream = context.Response.Body; + context.Response.Body = outputBuffer; + + try + { + await next(); + outputBuffer.Seek(0, SeekOrigin.Begin); + } + finally + { + context.Response.Body = originalResponseStream; + } + + // If we're building on demand, do that first + var buildOnDemandTask = lazyBuildOnDemandTask.Value; + if (buildOnDemandTask != null && !buildOnDemandTask.IsCompleted) + { + await buildOnDemandTask; + } + + // Most prerendering logic will want to know about the original, unprerendered + // HTML that the client would be getting otherwise. Typically this is used as + // a template from which the fully prerendered page can be generated. + var customData = new Dictionary + { + { "originalHtml", Encoding.UTF8.GetString(outputBuffer.GetBuffer()) } + }; + + // TODO: Add an optional "supplyCustomData" callback param so people using + // UsePrerendering() can, for example, pass through cookies into the .ts code + + var (unencodedAbsoluteUrl, unencodedPathAndQuery) = GetUnencodedUrlAndPathQuery(context); + var renderResult = await Prerenderer.RenderToString( + applicationBasePath, + nodeServices, + applicationStoppingToken, + moduleExport, + unencodedAbsoluteUrl, + unencodedPathAndQuery, + customDataParameter: customData, + timeoutMilliseconds: 0, + requestPathBase: context.Request.PathBase.ToString()); + + await ApplyRenderResult(context, renderResult); + } }); } + private static void RemoveConditionalRequestHeaders(HttpRequest request) + { + request.Headers.Remove(HeaderNames.IfMatch); + request.Headers.Remove(HeaderNames.IfModifiedSince); + request.Headers.Remove(HeaderNames.IfNoneMatch); + request.Headers.Remove(HeaderNames.IfUnmodifiedSince); + request.Headers.Remove(HeaderNames.IfRange); + } + private static (string, string) GetUnencodedUrlAndPathQuery(HttpContext httpContext) { // This is a duplicate of code from Prerenderer.cs in the SpaServices package. @@ -128,6 +152,8 @@ private static (string, string) GetUnencodedUrlAndPathQuery(HttpContext httpCont private static async Task ApplyRenderResult(HttpContext context, RenderToStringResult renderResult) { + context.Response.Clear(); + if (!string.IsNullOrEmpty(renderResult.RedirectUrl)) { context.Response.Redirect(renderResult.RedirectUrl); From eff9bb9bc81fc728f1aafcce9ac50e059d655d80 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Oct 2017 16:06:14 +0100 Subject: [PATCH 06/36] Add API for supplying custom data to prerenderer --- .../Prerendering/SpaPrerenderingExtensions.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index f58d5dce..c6e81ab3 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -30,11 +30,13 @@ public static class SpaPrerenderingExtensions /// 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. /// Optional. If specified, requests within these URL paths will bypass the prerenderer. + /// Optional. If specified, this callback will be invoked during prerendering, allowing you to pass additional data to the prerendering entrypoint code. public static void UseSpaPrerendering( this IApplicationBuilder appBuilder, string entryPoint, ISpaPrerendererBuilder buildOnDemand = null, - string[] excludeUrls = null) + string[] excludeUrls = null, + Action> supplyData = null) { if (string.IsNullOrEmpty(entryPoint)) { @@ -107,8 +109,7 @@ public static void UseSpaPrerendering( { "originalHtml", Encoding.UTF8.GetString(outputBuffer.GetBuffer()) } }; - // TODO: Add an optional "supplyCustomData" callback param so people using - // UsePrerendering() can, for example, pass through cookies into the .ts code + supplyData?.Invoke(context, customData); var (unencodedAbsoluteUrl, unencodedPathAndQuery) = GetUnencodedUrlAndPathQuery(context); var renderResult = await Prerenderer.RenderToString( From 2404cc2333e458db4a6332e4e9ebf7e6fec07790 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 26 Oct 2017 12:06:39 +0100 Subject: [PATCH 07/36] Remove angular-cli-middleware's dependency on Promise --- .../Content/Node/angular-cli-middleware.js | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js b/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js index 16b91244..9e40c649 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js @@ -14,17 +14,18 @@ module.exports = { '--watch' ]); proc.stdout.pipe(process.stdout); - waitForLine(proc.stdout, /chunk/).then(function () { - callback(); - }); + waitForLine(proc.stdout, /chunk/, function () { callback() }); }, startAngularCliServer: function startAngularCliServer(callback, options) { - getOSAssignedPortNumber().then(function (portNumber) { + getOSAssignedPortNumber(function (err, portNumber) { + if (err) { + callback(err); + return; + } + // 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 + // back to the ASP.NET host process var devServerProc = executeAngularCli([ 'serve', '--port', portNumber.toString(), @@ -35,7 +36,7 @@ module.exports = { // 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) { + waitForLine(devServerProc.stdout, /open your browser on (http\S+)/, function (matches) { var devServerUrl = url.parse(matches[1]); console.log('@angular/cli service has started on internal port ' + devServerUrl.port); callback(null, { @@ -46,18 +47,16 @@ module.exports = { } }; -function waitForLine(stream, regex) { - return new Promise(function (resolve, reject) { - var lineReader = readline.createInterface({ input: stream }); - var listener = function (line) { - var matches = regex.exec(line); - if (matches) { - lineReader.removeListener('line', listener); - resolve(matches); - } - }; - lineReader.addListener('line', listener); - }); +function waitForLine(stream, regex, callback) { + var lineReader = readline.createInterface({ input: stream }); + var listener = function (line) { + var matches = regex.exec(line); + if (matches) { + lineReader.removeListener('line', listener); + callback(matches); + } + }; + lineReader.addListener('line', listener); } function executeAngularCli(args) { @@ -67,12 +66,14 @@ function executeAngularCli(args) { }); } -function getOSAssignedPortNumber() { - return new Promise(function (resolve, reject) { - var server = net.createServer(); - server.listen(0, 'localhost', function () { +function getOSAssignedPortNumber(callback) { + var server = net.createServer(); + server.listen(0, 'localhost', function (err) { + if (err) { + callback(err); + } else { var portNumber = server.address().port; - server.close(function () { resolve(portNumber); }); - }); + server.close(function () { callback(null, portNumber); }); + } }); } From e4e396ed6f02ba22e91dac4bbd14058c3082c990 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 26 Oct 2017 12:47:54 +0100 Subject: [PATCH 08/36] Better handle errors during prerendering --- .../Prerendering/SpaPrerenderingExtensions.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index c6e81ab3..c4bd7413 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -75,6 +75,13 @@ public static void UseSpaPrerendering( } } + // If we're building on demand, do that first + var buildOnDemandTask = lazyBuildOnDemandTask.Value; + if (buildOnDemandTask != null) + { + await buildOnDemandTask; + } + // It's no good if we try to return a 304. We need to capture the actual // HTML content so it can be passed as a template to the prerenderer. RemoveConditionalRequestHeaders(context.Request); @@ -94,11 +101,17 @@ public static void UseSpaPrerendering( context.Response.Body = originalResponseStream; } - // If we're building on demand, do that first - var buildOnDemandTask = lazyBuildOnDemandTask.Value; - if (buildOnDemandTask != null && !buildOnDemandTask.IsCompleted) + // If it's not a success response, we're not going to have any template HTML + // to pass to the prerenderer. + if (context.Response.StatusCode < 200 || context.Response.StatusCode >= 300) { - await buildOnDemandTask; + var message = $"Prerendering failed because no HTML template could be obtained. Check that your SPA is compiling without errors. The {nameof(SpaApplicationBuilderExtensions.UseSpa)}() middleware returned a response with status code {context.Response.StatusCode}"; + if (outputBuffer.Length > 0) + { + message += " and the following content: " + Encoding.UTF8.GetString(outputBuffer.GetBuffer()); + } + + throw new InvalidOperationException(message); } // Most prerendering logic will want to know about the original, unprerendered From 0e1bde4929ad03b0bcddd41d206cacbe104513e5 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 26 Oct 2017 13:00:53 +0100 Subject: [PATCH 09/36] Capture errors and timeouts that occur ing angular-cli-middleware.js --- .../Content/Node/angular-cli-middleware.js | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js b/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js index 9e40c649..a560200b 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js @@ -5,6 +5,7 @@ var childProcess = require('child_process'); var net = require('net'); var readline = require('readline'); var url = require('url'); +var timeoutSeconds = 50; module.exports = { startAngularCliBuilder: function startAngularCliBuilder(callback, appName) { @@ -14,7 +15,9 @@ module.exports = { '--watch' ]); proc.stdout.pipe(process.stdout); - waitForLine(proc.stdout, /chunk/, function () { callback() }); + waitForLine(proc.stdout, /chunk/, function () { callback() }, timeoutSeconds * 1000, function () { + callback('The ng build process timed out after ' + timeoutSeconds + ' seconds. Check the output log for error information.'); + }); }, startAngularCliServer: function startAngularCliServer(callback, options) { @@ -42,20 +45,36 @@ module.exports = { callback(null, { Port: parseInt(devServerUrl.port) }); + }, timeoutSeconds * 1000, function () { + callback('The @angular/cli service did not start within the timeout period of ' + timeoutSeconds + ' seconds. Check the output log for error information.'); }); }); } }; -function waitForLine(stream, regex, callback) { +function waitForLine(stream, regex, successCallback, timeoutMilliseconds, timeoutCallback) { var lineReader = readline.createInterface({ input: stream }); var listener = function (line) { var matches = regex.exec(line); if (matches) { lineReader.removeListener('line', listener); - callback(matches); + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + successCallback(matches); } }; + + var timeoutId = null; + if (timeoutMilliseconds > 0) { + timeoutId = setTimeout(function () { + lineReader.removeListener('line', listener); + if (timeoutCallback) { + timeoutCallback(); + } + }, timeoutMilliseconds); + } + lineReader.addListener('line', listener); } From 9ee8cbc53ec26dcef93a9ec6f16834ad2637e6a1 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 26 Oct 2017 16:20:18 +0100 Subject: [PATCH 10/36] Have AngularCliMiddleware run npm scripts directly (no longer needs to go via NodeServices) --- .../AngularCli/AngularCliBuilder.cs | 10 +- .../AngularCli/AngularCliMiddleware.cs | 154 ++++++++++++------ .../AngularCliMiddlewareExtensions.cs | 6 +- .../Content/Node/angular-cli-middleware.js | 98 ----------- ...t.AspNetCore.SpaServices.Extensions.csproj | 4 - .../Npm/EventedStreamReader.cs | 111 +++++++++++++ .../Npm/NpmScriptRunner.cs | 90 ++++++++++ 7 files changed, 315 insertions(+), 158 deletions(-) delete mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs index ad8479b8..aca369eb 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -14,15 +14,15 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli /// public class AngularCliBuilder : ISpaPrerendererBuilder { - private readonly string _cliAppName; + private readonly string _npmScriptName; /// /// 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) + /// The name of the script in your package.json file that builds the server-side bundle for your Angular application. + public AngularCliBuilder(string npmScriptName) { - _cliAppName = cliAppName; + _npmScriptName = npmScriptName; } /// @@ -34,7 +34,7 @@ public Task Build(IApplicationBuilder app) out var angularCliMiddleware)) { return ((AngularCliMiddleware)angularCliMiddleware) - .StartAngularCliBuilderAsync(_cliAppName); + .StartAngularCliBuilderAsync(_npmScriptName); } else { diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index 5518a053..53ca7bbf 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -3,30 +3,38 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.NodeServices; using System; -using System.IO; using System.Net.Http; using System.Threading.Tasks; using System.Threading; using Microsoft.AspNetCore.SpaServices.Extensions.Proxy; +using Microsoft.AspNetCore.NodeServices.Npm; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Console; +using System.Net.Sockets; +using System.Net; +using System.Linq; namespace Microsoft.AspNetCore.SpaServices.AngularCli { internal class AngularCliMiddleware { - private const string _middlewareResourceName = "/Content/Node/angular-cli-middleware.js"; + private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; + private const int TimeoutMilliseconds = 50 * 1000; internal readonly static string AngularCliMiddlewareKey = Guid.NewGuid().ToString(); - private readonly INodeServices _nodeServices; - private readonly string _middlewareScriptPath; + private readonly string _sourcePath; + private readonly ILogger _logger; private readonly HttpClient _neverTimeOutHttpClient = ConditionalProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); public AngularCliMiddleware( IApplicationBuilder appBuilder, string sourcePath, + string npmScriptName, SpaDefaultPageMiddleware defaultPageMiddleware) { if (string.IsNullOrEmpty(sourcePath)) @@ -34,12 +42,21 @@ public AngularCliMiddleware( throw new ArgumentException("Cannot be null or empty", nameof(sourcePath)); } - // Prepare to make calls into Node - _nodeServices = CreateNodeServicesInstance(appBuilder, sourcePath); - _middlewareScriptPath = GetAngularCliMiddlewareScriptPath(appBuilder); + if (string.IsNullOrEmpty(npmScriptName)) + { + throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName)); + } + + _sourcePath = sourcePath; + + // If the DI system gives us a logger, use it. Otherwise, set up a default one. + var loggerFactory = appBuilder.ApplicationServices.GetService(); + _logger = loggerFactory != null + ? loggerFactory.CreateLogger(LogCategoryName) + : new ConsoleLogger(LogCategoryName, null, false); // Start Angular CLI and attach to middleware pipeline - var angularCliServerInfoTask = StartAngularCliServerAsync(); + var angularCliServerInfoTask = StartAngularCliServerAsync(npmScriptName); // Everything we proxy is hardcoded to target http://localhost because: // - the requests are always from the local machine (we're not accepting remote @@ -55,14 +72,27 @@ public AngularCliMiddleware( // Proxy all requests into the Angular CLI server appBuilder.Use(async (context, next) => { - var didProxyRequest = await ConditionalProxy.PerformProxyRequest( - context, _neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken); - - // Since we are proxying everything, this is the end of the middleware pipeline. - // We won't call next(). - if (!didProxyRequest) + try + { + var didProxyRequest = await ConditionalProxy.PerformProxyRequest( + context, _neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken); + + // Since we are proxying everything, this is the end of the middleware pipeline. + // We won't call next(). + if (!didProxyRequest) + { + context.Response.StatusCode = 404; + } + } + catch (AggregateException) + { + ThrowIfTaskCancelled(angularCliServerInfoTask); + throw; + } + catch (TaskCanceledException) { - context.Response.StatusCode = 404; + ThrowIfTaskCancelled(angularCliServerInfoTask); + throw; } }); @@ -70,39 +100,25 @@ public AngularCliMiddleware( appBuilder.Properties.Add(AngularCliMiddlewareKey, this); } - internal Task StartAngularCliBuilderAsync(string cliAppName) + private void ThrowIfTaskCancelled(Task task) { - return _nodeServices.InvokeExportAsync( - _middlewareScriptPath, - "startAngularCliBuilder", - cliAppName); - } - - private static INodeServices CreateNodeServicesInstance( - IApplicationBuilder appBuilder, string sourcePath) - { - // Unlike other consumers of NodeServices, AngularCliMiddleware dosen't share Node instances, nor does it - // use your DI configuration. It's important for AngularCliMiddleware to have its own private Node instance - // because it must *not* restart when files change (it's designed to watch for changes and rebuild). - var nodeServicesOptions = new NodeServicesOptions(appBuilder.ApplicationServices) - { - WatchFileExtensions = new string[] { }, // Don't watch anything - ProjectPath = Path.Combine(Directory.GetCurrentDirectory(), sourcePath), - }; - - if (!Directory.Exists(nodeServicesOptions.ProjectPath)) + if (task.IsCanceled) { - throw new DirectoryNotFoundException($"Directory not found: {nodeServicesOptions.ProjectPath}"); + throw new InvalidOperationException( + $"The Angular CLI process did not start listening for requests " + + $"within the timeout period of {TimeoutMilliseconds / 1000} seconds. " + + $"Check the log output for error information."); } - - return NodeServicesFactory.CreateNodeServices(nodeServicesOptions); } - private static string GetAngularCliMiddlewareScriptPath(IApplicationBuilder appBuilder) + internal Task StartAngularCliBuilderAsync(string npmScriptName) { - var script = EmbeddedResourceReader.Read(typeof(AngularCliMiddleware), _middlewareResourceName); - var nodeScript = new StringAsTempFile(script, GetStoppingToken(appBuilder)); - return nodeScript.FileName; + var npmScriptRunner = new NpmScriptRunner( + _sourcePath, npmScriptName, "--watch"); + AttachToLogger(_logger, npmScriptRunner); + + return npmScriptRunner.StdOut.WaitForMatch( + new Regex("chunk"), TimeoutMilliseconds); } private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder) @@ -113,19 +129,59 @@ private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder return ((IApplicationLifetime)applicationLifetime).ApplicationStopping; } - private async Task StartAngularCliServerAsync() + private async Task StartAngularCliServerAsync(string npmScriptName) { - // Tell Node to start the server hosting the Angular CLI - var angularCliServerInfo = await _nodeServices.InvokeExportAsync( - _middlewareScriptPath, - "startAngularCliServer"); + var portNumber = FindAvailablePort(); + _logger.LogInformation($"Starting @angular/cli on port {portNumber}..."); + + var npmScriptRunner = new NpmScriptRunner( + _sourcePath, npmScriptName, $"--port {portNumber}"); + AttachToLogger(_logger, npmScriptRunner); + + var openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( + new Regex("open your browser on (http\\S+)"), + TimeoutMilliseconds); + var uri = new Uri(openBrowserLine.Groups[1].Value); + var serverInfo = new AngularCliServerInfo { Port = uri.Port }; // 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; + return serverInfo; + } + + private static void AttachToLogger(ILogger logger, NpmScriptRunner npmScriptRunner) + { + // When the NPM task emits complete lines, pass them through to the real logger + // But when it emits incomplete lines, assume this is progress information and + // hence just pass it through to StdOut regardless of logger config. + npmScriptRunner.CopyOutputToLogger(logger); + + npmScriptRunner.StdErr.OnReceivedChunk += chunk => + { + var containsNewline = Array.IndexOf( + chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; + if (!containsNewline) + { + Console.Write(chunk.Array, chunk.Offset, chunk.Count); + } + }; + } + + private static int FindAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } } #pragma warning disable CS0649 diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs index 43c1e678..ea53668e 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs @@ -21,9 +21,11 @@ public static class AngularCliMiddlewareExtensions /// /// 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 name of the script in your package.json file that launches the Angular CLI process. public static void UseAngularCliServer( this IApplicationBuilder app, - string sourcePath) + string sourcePath, + string npmScriptName) { var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(app); if (defaultPageMiddleware == null) @@ -31,7 +33,7 @@ public static void UseAngularCliServer( throw new Exception($"{nameof(UseAngularCliServer)} should be called inside the 'configue' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } - new AngularCliMiddleware(app, sourcePath, defaultPageMiddleware); + new AngularCliMiddleware(app, sourcePath, npmScriptName, defaultPageMiddleware); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js b/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js deleted file mode 100644 index a560200b..00000000 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js +++ /dev/null @@ -1,98 +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. - -var childProcess = require('child_process'); -var net = require('net'); -var readline = require('readline'); -var url = require('url'); -var timeoutSeconds = 50; - -module.exports = { - startAngularCliBuilder: function startAngularCliBuilder(callback, appName) { - var proc = executeAngularCli([ - 'build', - '-app', appName, - '--watch' - ]); - proc.stdout.pipe(process.stdout); - waitForLine(proc.stdout, /chunk/, function () { callback() }, timeoutSeconds * 1000, function () { - callback('The ng build process timed out after ' + timeoutSeconds + ' seconds. Check the output log for error information.'); - }); - }, - - startAngularCliServer: function startAngularCliServer(callback, options) { - getOSAssignedPortNumber(function (err, portNumber) { - if (err) { - callback(err); - return; - } - - // Start @angular/cli dev server on private port, and pipe its output - // back to the ASP.NET host process - 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+)/, 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) - }); - }, timeoutSeconds * 1000, function () { - callback('The @angular/cli service did not start within the timeout period of ' + timeoutSeconds + ' seconds. Check the output log for error information.'); - }); - }); - } -}; - -function waitForLine(stream, regex, successCallback, timeoutMilliseconds, timeoutCallback) { - var lineReader = readline.createInterface({ input: stream }); - var listener = function (line) { - var matches = regex.exec(line); - if (matches) { - lineReader.removeListener('line', listener); - if (timeoutId !== null) { - clearTimeout(timeoutId); - } - successCallback(matches); - } - }; - - var timeoutId = null; - if (timeoutMilliseconds > 0) { - timeoutId = setTimeout(function () { - lineReader.removeListener('line', listener); - if (timeoutCallback) { - timeoutCallback(); - } - }, timeoutMilliseconds); - } - - lineReader.addListener('line', listener); -} - -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(callback) { - var server = net.createServer(); - server.listen(0, 'localhost', function (err) { - if (err) { - callback(err); - } else { - var portNumber = server.address().port; - server.close(function () { callback(null, portNumber); }); - } - }); -} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj index 83f06e05..2a58bfcc 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj @@ -5,10 +5,6 @@ netstandard2.0 - - - - diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs new file mode 100644 index 00000000..2f5bb5f4 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.NodeServices.Util +{ + class EventedStreamReader + { + public delegate void OnReceivedChunkHandler(ArraySegment chunk); + public delegate void OnReceivedLineHandler(string line); + + public event OnReceivedChunkHandler OnReceivedChunk; + public event OnReceivedLineHandler OnReceivedLine; + + private readonly StreamReader _streamReader; + private readonly StringBuilder _linesBuffer; + + public EventedStreamReader(StreamReader streamReader) + { + _streamReader = streamReader ?? throw new ArgumentNullException(nameof(streamReader)); + _linesBuffer = new StringBuilder(); + Task.Factory.StartNew(Run); + } + + public Task WaitForMatch(Regex regex, int timeoutMilliseconds = 0) + { + var tcs = new TaskCompletionSource(); + var completionLock = new object(); + + OnReceivedLineHandler onReceivedLineHandler = null; + onReceivedLineHandler = line => + { + var match = regex.Match(line); + if (match.Success) + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnReceivedLine -= onReceivedLineHandler; + tcs.SetResult(match); + } + } + } + }; + + OnReceivedLine += onReceivedLineHandler; + + if (timeoutMilliseconds > 0) + { + var timeoutToken = new CancellationTokenSource(timeoutMilliseconds); + timeoutToken.Token.Register(() => + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnReceivedLine -= onReceivedLineHandler; + tcs.SetCanceled(); + } + } + }); + } + + return tcs.Task; + } + + private async Task Run() + { + var buf = new char[8 * 1024]; + while (true) + { + var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length); + if (chunkLength == 0) + { + break; + } + + OnChunk(new ArraySegment(buf, 0, chunkLength)); + + var lineBreakPos = Array.IndexOf(buf, '\n', 0, chunkLength); + if (lineBreakPos < 0) + { + _linesBuffer.Append(buf, 0, chunkLength); + } + else + { + _linesBuffer.Append(buf, 0, lineBreakPos + 1); + OnCompleteLine(_linesBuffer.ToString()); + _linesBuffer.Clear(); + _linesBuffer.Append(buf, lineBreakPos + 1, chunkLength - (lineBreakPos + 1)); + } + } + } + + private void OnChunk(ArraySegment chunk) + { + var dlg = OnReceivedChunk; + dlg?.Invoke(chunk); + } + + private void OnCompleteLine(string line) + { + var dlg = OnReceivedLine; + dlg?.Invoke(line); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs new file mode 100644 index 00000000..8df55b38 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.NodeServices.Util; +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +// This is under the NodeServices namespace because post 2.1 it will be moved to that package +namespace Microsoft.AspNetCore.NodeServices.Npm +{ + internal class NpmScriptRunner + { + public EventedStreamReader StdOut { get; } + public EventedStreamReader StdErr { get; } + + public NpmScriptRunner(string workingDirectory, string scriptName, string arguments) + { + if (string.IsNullOrEmpty(workingDirectory)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(workingDirectory)); + } + + if (string.IsNullOrEmpty(scriptName)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(scriptName)); + } + + var npmExe = "npm"; + var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + npmExe = "cmd"; + completeArguments = $"/c npm {completeArguments}"; + } + + var process = LaunchNodeProcess(new ProcessStartInfo(npmExe) + { + Arguments = completeArguments, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = workingDirectory + }); + + StdOut = new EventedStreamReader(process.StandardOutput); + StdErr = new EventedStreamReader(process.StandardError); + } + + public void CopyOutputToLogger(ILogger logger) + { + StdOut.OnReceivedLine += line => + { + if (!string.IsNullOrWhiteSpace(line)) + { + logger.LogInformation(line); + } + }; + + StdErr.OnReceivedLine += line => + { + if (!string.IsNullOrWhiteSpace(line)) + { + logger.LogError(line); + } + }; + } + + private static Process LaunchNodeProcess(ProcessStartInfo startInfo) + { + try + { + var process = Process.Start(startInfo); + + // See equivalent comment in OutOfProcessNodeInstance.cs for why + process.EnableRaisingEvents = true; + + return process; + } + catch (Exception ex) + { + var message = $"Failed to start 'npm'. To resolve this:.\n\n" + + "[1] Ensure that 'npm' is installed and can be found in one of the PATH directories.\n" + + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n" + + " Make sure the executable is in one of those directories, or update your PATH.\n\n" + + "[2] See the InnerException for further details of the cause."; + throw new InvalidOperationException(message, ex); + } + } + } +} From 08dbc1537e9524783cad911ff600b1e6c55f8555 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Oct 2017 00:02:46 +0100 Subject: [PATCH 11/36] Add ISpaOptions concept so that AngularCliBuilder can be independent of AngularCliMiddleware --- .../AngularCli/AngularCliBuilder.cs | 45 +++++++++++----- .../AngularCli/AngularCliMiddleware.cs | 54 +++++-------------- .../AngularCliMiddlewareExtensions.cs | 19 ++++--- .../DefaultSpaOptions.cs | 47 ++++++++++++++++ .../ISpaOptions.cs | 35 ++++++++++++ .../Npm/NpmScriptRunner.cs | 15 +++++- .../Prerendering/SpaPrerenderingExtensions.cs | 6 +-- .../Proxying/ConditionalProxy.cs | 2 +- .../SpaApplicationBuilderExtensions.cs | 17 +++++- .../SpaDefaultPageMiddleware.cs | 45 ++++------------ 10 files changed, 179 insertions(+), 106 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs index aca369eb..b46a3055 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -2,8 +2,11 @@ // 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.NodeServices.Npm; using Microsoft.AspNetCore.SpaServices.Prerendering; +using Microsoft.Extensions.Logging; using System; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace Microsoft.AspNetCore.SpaServices.AngularCli @@ -14,34 +17,50 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli /// public class AngularCliBuilder : ISpaPrerendererBuilder { + private const int TimeoutMilliseconds = 50 * 1000; private readonly string _npmScriptName; /// /// Constructs an instance of . /// - /// The name of the script in your package.json file that builds the server-side bundle for your Angular application. - public AngularCliBuilder(string npmScriptName) + /// The name of the script in your package.json file that builds the server-side bundle for your Angular application. + public AngularCliBuilder(string npmScript) { - _npmScriptName = npmScriptName; + _npmScriptName = npmScript; } /// public Task Build(IApplicationBuilder app) { - // Locate the AngularCliMiddleware within the provided IApplicationBuilder - if (app.Properties.TryGetValue( - AngularCliMiddleware.AngularCliMiddlewareKey, - out var angularCliMiddleware)) + var spaOptions = DefaultSpaOptions.FindInPipeline(app); + if (spaOptions == null) { - return ((AngularCliMiddleware)angularCliMiddleware) - .StartAngularCliBuilderAsync(_npmScriptName); + throw new InvalidOperationException($"{nameof(AngularCliBuilder)} can only be used in an application configured with {nameof(SpaApplicationBuilderExtensions.UseSpa)}()."); } - else + + if (string.IsNullOrEmpty(spaOptions.SourcePath)) { - throw new Exception( - $"Cannot use {nameof(AngularCliBuilder)} unless you are also using" + - $" {nameof(AngularCliMiddlewareExtensions.UseAngularCliServer)}."); + throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } + + return StartAngularCliBuilderAsync( + _npmScriptName, + spaOptions.SourcePath, + AngularCliMiddleware.GetOrCreateLogger(app)); + } + + internal Task StartAngularCliBuilderAsync( + string npmScriptName, string sourcePath, ILogger logger) + { + var npmScriptRunner = new NpmScriptRunner( + sourcePath, + npmScriptName, + "--watch"); + npmScriptRunner.AttachToLogger(logger); + + return npmScriptRunner.StdOut.WaitForMatch( + new Regex("chunk"), + TimeoutMilliseconds); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index 53ca7bbf..6d024518 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -15,7 +15,6 @@ using Microsoft.Extensions.Logging.Console; using System.Net.Sockets; using System.Net; -using System.Linq; namespace Microsoft.AspNetCore.SpaServices.AngularCli { @@ -24,8 +23,6 @@ internal class AngularCliMiddleware private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; private const int TimeoutMilliseconds = 50 * 1000; - internal readonly static string AngularCliMiddlewareKey = Guid.NewGuid().ToString(); - private readonly string _sourcePath; private readonly ILogger _logger; private readonly HttpClient _neverTimeOutHttpClient = @@ -34,8 +31,7 @@ internal class AngularCliMiddleware public AngularCliMiddleware( IApplicationBuilder appBuilder, string sourcePath, - string npmScriptName, - SpaDefaultPageMiddleware defaultPageMiddleware) + string npmScriptName) { if (string.IsNullOrEmpty(sourcePath)) { @@ -48,12 +44,7 @@ public AngularCliMiddleware( } _sourcePath = sourcePath; - - // If the DI system gives us a logger, use it. Otherwise, set up a default one. - var loggerFactory = appBuilder.ApplicationServices.GetService(); - _logger = loggerFactory != null - ? loggerFactory.CreateLogger(LogCategoryName) - : new ConsoleLogger(LogCategoryName, null, false); + _logger = GetOrCreateLogger(appBuilder); // Start Angular CLI and attach to middleware pipeline var angularCliServerInfoTask = StartAngularCliServerAsync(npmScriptName); @@ -95,9 +86,16 @@ public AngularCliMiddleware( throw; } }); + } - // Advertise the availability of this feature to other SPA middleware - appBuilder.Properties.Add(AngularCliMiddlewareKey, this); + internal static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder) + { + // If the DI system gives us a logger, use it. Otherwise, set up a default one. + var loggerFactory = appBuilder.ApplicationServices.GetService(); + var logger = loggerFactory != null + ? loggerFactory.CreateLogger(LogCategoryName) + : new ConsoleLogger(LogCategoryName, null, false); + return logger; } private void ThrowIfTaskCancelled(Task task) @@ -111,16 +109,6 @@ private void ThrowIfTaskCancelled(Task task) } } - internal Task StartAngularCliBuilderAsync(string npmScriptName) - { - var npmScriptRunner = new NpmScriptRunner( - _sourcePath, npmScriptName, "--watch"); - AttachToLogger(_logger, npmScriptRunner); - - return npmScriptRunner.StdOut.WaitForMatch( - new Regex("chunk"), TimeoutMilliseconds); - } - private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder) { var applicationLifetime = appBuilder @@ -136,7 +124,7 @@ private async Task StartAngularCliServerAsync(string npmSc var npmScriptRunner = new NpmScriptRunner( _sourcePath, npmScriptName, $"--port {portNumber}"); - AttachToLogger(_logger, npmScriptRunner); + npmScriptRunner.AttachToLogger(_logger); var openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( new Regex("open your browser on (http\\S+)"), @@ -152,24 +140,6 @@ private async Task StartAngularCliServerAsync(string npmSc return serverInfo; } - private static void AttachToLogger(ILogger logger, NpmScriptRunner npmScriptRunner) - { - // When the NPM task emits complete lines, pass them through to the real logger - // But when it emits incomplete lines, assume this is progress information and - // hence just pass it through to StdOut regardless of logger config. - npmScriptRunner.CopyOutputToLogger(logger); - - npmScriptRunner.StdErr.OnReceivedChunk += chunk => - { - var containsNewline = Array.IndexOf( - chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; - if (!containsNewline) - { - Console.Write(chunk.Array, chunk.Offset, chunk.Count); - } - }; - } - private static int FindAvailablePort() { var listener = new TcpListener(IPAddress.Loopback, 0); diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs index ea53668e..81189ea5 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs @@ -20,20 +20,23 @@ 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 name of the script in your package.json file that launches the Angular CLI process. + /// The name of the script in your package.json file that launches the Angular CLI process. public static void UseAngularCliServer( this IApplicationBuilder app, - string sourcePath, - string npmScriptName) + string npmScript) { - var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(app); - if (defaultPageMiddleware == null) + var spaOptions = DefaultSpaOptions.FindInPipeline(app); + if (spaOptions == null) { - throw new Exception($"{nameof(UseAngularCliServer)} should be called inside the 'configue' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + throw new InvalidOperationException($"{nameof(UseAngularCliServer)} should be called inside the 'configure' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } - new AngularCliMiddleware(app, sourcePath, npmScriptName, defaultPageMiddleware); + if (string.IsNullOrEmpty(spaOptions.SourcePath)) + { + throw new InvalidOperationException($"To use {nameof(UseAngularCliServer)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + new AngularCliMiddleware(app, spaOptions.SourcePath, npmScript); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs new file mode 100644 index 00000000..894d4b54 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using System; + +namespace Microsoft.AspNetCore.SpaServices +{ + internal class DefaultSpaOptions : ISpaOptions + { + public string DefaultPage { get; set; } = "index.html"; + + public string SourcePath { get; } + + public string UrlPrefix { get; } + + private static readonly string _propertiesKey = Guid.NewGuid().ToString(); + + public DefaultSpaOptions(string sourcePath, string urlPrefix) + { + if (urlPrefix == null || !urlPrefix.StartsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException("The value must start with '/'", nameof(urlPrefix)); + } + + SourcePath = sourcePath; + UrlPrefix = urlPrefix; + } + + internal static ISpaOptions FindInPipeline(IApplicationBuilder app) + { + return app.Properties.TryGetValue(_propertiesKey, out var instance) + ? (ISpaOptions)instance + : null; + } + + internal 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; + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs new file mode 100644 index 00000000..455ca34a --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs @@ -0,0 +1,35 @@ +// 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 +{ + /// + /// Describes options for hosting a Single Page Application (SPA). + /// + public interface ISpaOptions + { + /// + /// Gets or sets the URL, relative to , + /// of the default page that hosts your SPA user interface. + /// The typical value is "index.html". + /// + string DefaultPage { get; set; } + + /// + /// Gets the path, relative to the application working directory, + /// of the directory that contains the SPA source files during + /// development. The directory may not exist in published applications. + /// + string SourcePath { get; } + + /// + /// Gets 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. + /// + string UrlPrefix { get; } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs index 8df55b38..51efbb2b 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs @@ -46,8 +46,9 @@ public NpmScriptRunner(string workingDirectory, string scriptName, string argume StdErr = new EventedStreamReader(process.StandardError); } - public void CopyOutputToLogger(ILogger logger) + public void AttachToLogger(ILogger logger) { + // When the NPM task emits complete lines, pass them through to the real logger StdOut.OnReceivedLine += line => { if (!string.IsNullOrWhiteSpace(line)) @@ -63,6 +64,18 @@ public void CopyOutputToLogger(ILogger logger) logger.LogError(line); } }; + + // But when it emits incomplete lines, assume this is progress information and + // hence just pass it through to StdOut regardless of logger config. + StdErr.OnReceivedChunk += chunk => + { + var containsNewline = Array.IndexOf( + chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; + if (!containsNewline) + { + Console.Write(chunk.Array, chunk.Offset, chunk.Count); + } + }; } private static Process LaunchNodeProcess(ProcessStartInfo startInfo) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index c4bd7413..5040a3da 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -43,9 +43,8 @@ public static void UseSpaPrerendering( 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(appBuilder)); + // If we're building on demand, start that process now + var buildOnDemandTask = buildOnDemand?.Build(appBuilder); // Get all the necessary context info that will be used for each prerendering call var serviceProvider = appBuilder.ApplicationServices; @@ -76,7 +75,6 @@ public static void UseSpaPrerendering( } // If we're building on demand, do that first - var buildOnDemandTask = lazyBuildOnDemandTask.Value; if (buildOnDemandTask != null) { await buildOnDemandTask; diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs index 9bcb1fe3..734a0945 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs @@ -265,7 +265,7 @@ private static async Task PumpWebSocket(WebSocket source, WebSocket destination, break; } - await Task.Delay(250); + await Task.Delay(100); } var result = resultTask.Result; // We know it's completed already diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs index b7efcb6c..758e744a 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs @@ -28,6 +28,11 @@ public static class SpaApplicationBuilderExtensions /// 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 the application working + /// directory) of the directory that holds the SPA source files during development. + /// The directory need not exist once the application is published. + /// /// /// Optional. If specified, configures the path (relative to ) /// of the default page that hosts your SPA user interface. @@ -40,10 +45,18 @@ public static class SpaApplicationBuilderExtensions public static void UseSpa( this IApplicationBuilder app, string urlPrefix, + string sourcePath = null, string defaultPage = null, - Action configure = null) + Action configure = null) { - new SpaDefaultPageMiddleware(app, urlPrefix, defaultPage, configure); + var spaOptions = new DefaultSpaOptions(sourcePath, urlPrefix); + spaOptions.RegisterSoleInstanceInPipeline(app); + + // Invoke 'configure' to give the developer a chance to insert extra + // middleware before the 'default page' pipeline entries + configure?.Invoke(spaOptions); + + SpaDefaultPageMiddleware.Attach(app, spaOptions); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs index 7d1f9fec..a549a54e 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Builder; +// 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.Hosting; using Microsoft.AspNetCore.Http; using System; @@ -7,52 +10,24 @@ 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) + public static void Attach(IApplicationBuilder app, ISpaOptions spaOptions) { 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)) + if (spaOptions == null) { - 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."); + throw new ArgumentNullException(nameof(spaOptions)); } - app.Properties[_propertiesKey] = this; - } + var defaultPageUrl = ConstructDefaultPageUrl(spaOptions.UrlPrefix, spaOptions.DefaultPage); - private void AttachMiddlewareToPipeline(IApplicationBuilder app) - { // Rewrite all requests to the default page app.Use((context, next) => { - context.Request.Path = DefaultPageUrl; + context.Request.Path = defaultPageUrl; return next(); }); @@ -63,7 +38,7 @@ private void AttachMiddlewareToPipeline(IApplicationBuilder app) // 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"; + 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 From ec21464cb8b61fc9fe1a14f19f37f79b0c100b9d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 12:06:37 +0000 Subject: [PATCH 12/36] Better logging if an NPM task exits with an error --- .../AngularCli/AngularCliBuilder.cs | 18 ++++++++-- .../AngularCli/AngularCliMiddleware.cs | 20 +++++++++-- .../Npm/EventedStreamReader.cs | 32 ++++++++++++++++- .../Npm/EventedStreamStringReader.cs | 36 +++++++++++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs index b46a3055..61f7b27f 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.NodeServices.Npm; +using Microsoft.AspNetCore.NodeServices.Util; using Microsoft.AspNetCore.SpaServices.Prerendering; using Microsoft.Extensions.Logging; using System; +using System.IO; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -58,9 +60,19 @@ internal Task StartAngularCliBuilderAsync( "--watch"); npmScriptRunner.AttachToLogger(logger); - return npmScriptRunner.StdOut.WaitForMatch( - new Regex("chunk"), - TimeoutMilliseconds); + using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) + { + try + { + return npmScriptRunner.StdOut.WaitForMatch( + new Regex("chunk"), + TimeoutMilliseconds); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException($"The NPM script '{npmScriptName}' exited without indicating success. Error output was: {stdErrReader.ReadAsString()}", ex); + } + } } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index 6d024518..0b0fde1e 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -15,6 +15,8 @@ using Microsoft.Extensions.Logging.Console; using System.Net.Sockets; using System.Net; +using System.IO; +using Microsoft.AspNetCore.NodeServices.Util; namespace Microsoft.AspNetCore.SpaServices.AngularCli { @@ -126,9 +128,21 @@ private async Task StartAngularCliServerAsync(string npmSc _sourcePath, npmScriptName, $"--port {portNumber}"); npmScriptRunner.AttachToLogger(_logger); - var openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( - new Regex("open your browser on (http\\S+)"), - TimeoutMilliseconds); + Match openBrowserLine; + using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) + { + try + { + openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( + new Regex("open your browser on (http\\S+)"), + TimeoutMilliseconds); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException($"The NPM script '{npmScriptName}' exited without indicating that the Angular CLI was listening for requests. The error output was: {stdErrReader.ReadAsString()}", ex); + } + } + var uri = new Uri(openBrowserLine.Groups[1].Value); var serverInfo = new AngularCliServerInfo { Port = uri.Port }; diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs index 2f5bb5f4..b520c2bf 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs @@ -1,4 +1,7 @@ -using System; +// 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 System.Text; using System.Text.RegularExpressions; @@ -11,9 +14,11 @@ class EventedStreamReader { public delegate void OnReceivedChunkHandler(ArraySegment chunk); public delegate void OnReceivedLineHandler(string line); + public delegate void OnStreamClosedHandler(); public event OnReceivedChunkHandler OnReceivedChunk; public event OnReceivedLineHandler OnReceivedLine; + public event OnStreamClosedHandler OnStreamClosed; private readonly StreamReader _streamReader; private readonly StringBuilder _linesBuffer; @@ -31,6 +36,8 @@ public Task WaitForMatch(Regex regex, int timeoutMilliseconds = 0) var completionLock = new object(); OnReceivedLineHandler onReceivedLineHandler = null; + OnStreamClosedHandler onStreamClosedHandler = null; + onReceivedLineHandler = line => { var match = regex.Match(line); @@ -41,13 +48,28 @@ public Task WaitForMatch(Regex regex, int timeoutMilliseconds = 0) if (!tcs.Task.IsCompleted) { OnReceivedLine -= onReceivedLineHandler; + OnStreamClosed -= onStreamClosedHandler; tcs.SetResult(match); } } } }; + onStreamClosedHandler = () => + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnReceivedLine -= onReceivedLineHandler; + OnStreamClosed -= onStreamClosedHandler; + tcs.SetException(new EndOfStreamException()); + } + } + }; + OnReceivedLine += onReceivedLineHandler; + OnStreamClosed += onStreamClosedHandler; if (timeoutMilliseconds > 0) { @@ -59,6 +81,7 @@ public Task WaitForMatch(Regex regex, int timeoutMilliseconds = 0) if (!tcs.Task.IsCompleted) { OnReceivedLine -= onReceivedLineHandler; + OnStreamClosed -= onStreamClosedHandler; tcs.SetCanceled(); } } @@ -76,6 +99,7 @@ private async Task Run() var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length); if (chunkLength == 0) { + OnClosed(); break; } @@ -107,5 +131,11 @@ private void OnCompleteLine(string line) var dlg = OnReceivedLine; dlg?.Invoke(line); } + + private void OnClosed() + { + var dlg = OnStreamClosed; + dlg?.Invoke(); + } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs new file mode 100644 index 00000000..4215126c --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs @@ -0,0 +1,36 @@ +// 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.Text; + +namespace Microsoft.AspNetCore.NodeServices.Util +{ + class EventedStreamStringReader : IDisposable + { + private EventedStreamReader _eventedStreamReader; + private bool _isDisposed; + private StringBuilder _stringBuilder = new StringBuilder(); + + public EventedStreamStringReader(EventedStreamReader eventedStreamReader) + { + _eventedStreamReader = eventedStreamReader + ?? throw new ArgumentNullException(nameof(eventedStreamReader)); + _eventedStreamReader.OnReceivedLine += OnReceivedLine; + + } + + public string ReadAsString() => _stringBuilder.ToString(); + + private void OnReceivedLine(string line) => _stringBuilder.AppendLine(line); + + public void Dispose() + { + if (!_isDisposed) + { + _eventedStreamReader.OnReceivedLine -= OnReceivedLine; + _isDisposed = true; + } + } + } +} From 272e609c2b190a43b5c570544b1ad5c25d7e06e2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 13:27:04 +0000 Subject: [PATCH 13/36] Simplify AngularCliMiddleware by reducing to a static class. Doesn't affect public API. --- .../AngularCli/AngularCliMiddleware.cs | 32 ++++++++----------- .../AngularCliMiddlewareExtensions.cs | 2 +- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index 0b0fde1e..b7887c30 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using System; -using System.Net.Http; using System.Threading.Tasks; using System.Threading; using Microsoft.AspNetCore.SpaServices.Extensions.Proxy; @@ -20,17 +19,12 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli { - internal class AngularCliMiddleware + internal static class AngularCliMiddleware { private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; private const int TimeoutMilliseconds = 50 * 1000; - private readonly string _sourcePath; - private readonly ILogger _logger; - private readonly HttpClient _neverTimeOutHttpClient = - ConditionalProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); - - public AngularCliMiddleware( + public static void Attach( IApplicationBuilder appBuilder, string sourcePath, string npmScriptName) @@ -45,11 +39,9 @@ public AngularCliMiddleware( throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName)); } - _sourcePath = sourcePath; - _logger = GetOrCreateLogger(appBuilder); - // Start Angular CLI and attach to middleware pipeline - var angularCliServerInfoTask = StartAngularCliServerAsync(npmScriptName); + var logger = GetOrCreateLogger(appBuilder); + var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, npmScriptName, logger); // Everything we proxy is hardcoded to target http://localhost because: // - the requests are always from the local machine (we're not accepting remote @@ -61,6 +53,9 @@ public AngularCliMiddleware( "http", "localhost", task.Result.Port.ToString())); var applicationStoppingToken = GetStoppingToken(appBuilder); + + var neverTimeOutHttpClient = + ConditionalProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); // Proxy all requests into the Angular CLI server appBuilder.Use(async (context, next) => @@ -68,7 +63,7 @@ public AngularCliMiddleware( try { var didProxyRequest = await ConditionalProxy.PerformProxyRequest( - context, _neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken); + context, neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken); // Since we are proxying everything, this is the end of the middleware pipeline. // We won't call next(). @@ -100,7 +95,7 @@ internal static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder) return logger; } - private void ThrowIfTaskCancelled(Task task) + private static void ThrowIfTaskCancelled(Task task) { if (task.IsCanceled) { @@ -119,14 +114,15 @@ private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder return ((IApplicationLifetime)applicationLifetime).ApplicationStopping; } - private async Task StartAngularCliServerAsync(string npmScriptName) + private static async Task StartAngularCliServerAsync( + string sourcePath, string npmScriptName, ILogger logger) { var portNumber = FindAvailablePort(); - _logger.LogInformation($"Starting @angular/cli on port {portNumber}..."); + logger.LogInformation($"Starting @angular/cli on port {portNumber}..."); var npmScriptRunner = new NpmScriptRunner( - _sourcePath, npmScriptName, $"--port {portNumber}"); - npmScriptRunner.AttachToLogger(_logger); + sourcePath, npmScriptName, $"--port {portNumber}"); + npmScriptRunner.AttachToLogger(logger); Match openBrowserLine; using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs index 81189ea5..1dcf23c9 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs @@ -36,7 +36,7 @@ public static void UseAngularCliServer( throw new InvalidOperationException($"To use {nameof(UseAngularCliServer)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } - new AngularCliMiddleware(app, spaOptions.SourcePath, npmScript); + AngularCliMiddleware.Attach(app, spaOptions.SourcePath, npmScript); } } } From ae2c456a1717876b404e2ad9005d45a4a852a820 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 14:24:08 +0000 Subject: [PATCH 14/36] Add standalone UseProxyToSpaDevelopmentServer API so it's not necessary to keep restarting Angular CLI etc. on C# changes --- .../AngularCli/AngularCliMiddleware.cs | 71 ++++------------- .../Prerendering/SpaPrerenderingExtensions.cs | 10 +-- .../Proxying/ConditionalProxy.cs | 22 ++++-- .../Proxying/ConditionalProxyMiddleware.cs | 8 +- .../ConditionalProxyMiddlewareTarget.cs | 19 ----- .../Proxying/SpaProxyingExtensions.cs | 76 +++++++++++++++++++ 6 files changed, 113 insertions(+), 93 deletions(-) delete mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddlewareTarget.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index b7887c30..41d825ef 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -2,11 +2,8 @@ // 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.Hosting; using System; using System.Threading.Tasks; -using System.Threading; -using Microsoft.AspNetCore.SpaServices.Extensions.Proxy; using Microsoft.AspNetCore.NodeServices.Npm; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; @@ -48,41 +45,10 @@ public static void Attach( // 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 = angularCliServerInfoTask.ContinueWith( - task => new ConditionalProxyMiddlewareTarget( - "http", "localhost", task.Result.Port.ToString())); + var targetUriTask = angularCliServerInfoTask.ContinueWith( + task => new UriBuilder("http", "localhost", task.Result.Port).Uri); - var applicationStoppingToken = GetStoppingToken(appBuilder); - - var neverTimeOutHttpClient = - ConditionalProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); - - // Proxy all requests into the Angular CLI server - appBuilder.Use(async (context, next) => - { - try - { - var didProxyRequest = await ConditionalProxy.PerformProxyRequest( - context, neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken); - - // Since we are proxying everything, this is the end of the middleware pipeline. - // We won't call next(). - if (!didProxyRequest) - { - context.Response.StatusCode = 404; - } - } - catch (AggregateException) - { - ThrowIfTaskCancelled(angularCliServerInfoTask); - throw; - } - catch (TaskCanceledException) - { - ThrowIfTaskCancelled(angularCliServerInfoTask); - throw; - } - }); + SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(appBuilder, targetUriTask); } internal static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder) @@ -95,25 +61,6 @@ internal static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder) return logger; } - private static void ThrowIfTaskCancelled(Task task) - { - if (task.IsCanceled) - { - throw new InvalidOperationException( - $"The Angular CLI process did not start listening for requests " + - $"within the timeout period of {TimeoutMilliseconds / 1000} seconds. " + - $"Check the log output for error information."); - } - } - - private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder) - { - var applicationLifetime = appBuilder - .ApplicationServices - .GetService(typeof(IApplicationLifetime)); - return ((IApplicationLifetime)applicationLifetime).ApplicationStopping; - } - private static async Task StartAngularCliServerAsync( string sourcePath, string npmScriptName, ILogger logger) { @@ -135,7 +82,17 @@ private static async Task StartAngularCliServerAsync( } catch (EndOfStreamException ex) { - throw new InvalidOperationException($"The NPM script '{npmScriptName}' exited without indicating that the Angular CLI was listening for requests. The error output was: {stdErrReader.ReadAsString()}", ex); + throw new InvalidOperationException( + $"The NPM script '{npmScriptName}' exited without indicating that the " + + $"Angular CLI was listening for requests. The error output was: " + + $"{stdErrReader.ReadAsString()}", ex); + } + catch (TaskCanceledException ex) + { + throw new InvalidOperationException( + $"The Angular CLI process did not start listening for requests " + + $"within the timeout period of {TimeoutMilliseconds / 1000} seconds. " + + $"Check the log output for error information.", ex); } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index 5040a3da..c4766032 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -26,13 +26,13 @@ public static class SpaPrerenderingExtensions /// /// 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. /// Optional. If specified, executes the supplied before looking for the file. This is only intended to be used during development. /// Optional. If specified, requests within these URL paths will bypass the prerenderer. /// Optional. If specified, this callback will be invoked during prerendering, allowing you to pass additional data to the prerendering entrypoint code. public static void UseSpaPrerendering( - this IApplicationBuilder appBuilder, + this IApplicationBuilder applicationBuilder, string entryPoint, ISpaPrerendererBuilder buildOnDemand = null, string[] excludeUrls = null, @@ -44,10 +44,10 @@ public static void UseSpaPrerendering( } // If we're building on demand, start that process now - var buildOnDemandTask = buildOnDemand?.Build(appBuilder); + var buildOnDemandTask = buildOnDemand?.Build(applicationBuilder); // Get all the necessary context info that will be used for each prerendering call - var serviceProvider = appBuilder.ApplicationServices; + var serviceProvider = applicationBuilder.ApplicationServices; var nodeServices = GetNodeServices(serviceProvider); var applicationStoppingToken = serviceProvider.GetRequiredService() .ApplicationStopping; @@ -62,7 +62,7 @@ public static void UseSpaPrerendering( // be returning the default SPA index.html page (because other resources will be // served statically from disk). We will use this as a template in which to inject // the prerendered output. - appBuilder.Use(async (context, next) => + applicationBuilder.Use(async (context, next) => { // If this URL is excluded, skip prerendering foreach (var excludePathString in excludePathStrings) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs index 734a0945..453f4038 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs @@ -42,7 +42,7 @@ public static HttpClient CreateHttpClientForProxy(TimeSpan requestTimeout) public static async Task PerformProxyRequest( HttpContext context, HttpClient httpClient, - Task targetTask, + Task baseUriTask, CancellationToken applicationStoppingToken) { // Stop proxying if either the server or client wants to disconnect @@ -54,13 +54,10 @@ public static async Task PerformProxyRequest( // 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 targetUri = new UriBuilder( - target.Scheme, - target.Host, - int.Parse(target.Port), - context.Request.Path, - context.Request.QueryString.Value).Uri; + var baseUri = await baseUriTask; + var targetUri = new Uri( + baseUri, + context.Request.Path + context.Request.QueryString); try { @@ -90,6 +87,15 @@ public static async Task PerformProxyRequest( // due to the process shutting down. return true; } + catch (HttpRequestException ex) + { + throw new HttpRequestException( + $"Failed to proxy the request to {targetUri.ToString()}, because the request to " + + $"the proxy target failed. Check that the proxy target server is running and " + + $"accepting requests to {baseUri.ToString()}.\n\n" + + $"The underlying exception message was '{ex.Message}'." + + $"Check the InnerException for more details.", ex); + } } private static HttpRequestMessage CreateProxyHttpRequest(HttpContext context, Uri uri) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs index e4aa3fa4..328b29e6 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs @@ -17,7 +17,7 @@ namespace Microsoft.AspNetCore.SpaServices.Extensions.Proxy internal class ConditionalProxyMiddleware { private readonly RequestDelegate _next; - private readonly Task _targetTask; + private readonly Task _baseUriTask; private readonly string _pathPrefix; private readonly bool _pathPrefixIsRoot; private readonly HttpClient _httpClient; @@ -27,7 +27,7 @@ public ConditionalProxyMiddleware( RequestDelegate next, string pathPrefix, TimeSpan requestTimeout, - Task targetTask, + Task baseUriTask, IApplicationLifetime applicationLifetime) { if (!pathPrefix.StartsWith("/")) @@ -38,7 +38,7 @@ public ConditionalProxyMiddleware( _next = next; _pathPrefix = pathPrefix; _pathPrefixIsRoot = string.Equals(_pathPrefix, "/", StringComparison.Ordinal); - _targetTask = targetTask; + _baseUriTask = baseUriTask; _httpClient = ConditionalProxy.CreateHttpClientForProxy(requestTimeout); _applicationStoppingToken = applicationLifetime.ApplicationStopping; } @@ -48,7 +48,7 @@ public async Task Invoke(HttpContext context) if (context.Request.Path.StartsWithSegments(_pathPrefix) || _pathPrefixIsRoot) { var didProxyRequest = await ConditionalProxy.PerformProxyRequest( - context, _httpClient, _targetTask, _applicationStoppingToken); + context, _httpClient, _baseUriTask, _applicationStoppingToken); if (didProxyRequest) { return; diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddlewareTarget.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddlewareTarget.cs deleted file mode 100644 index 28c54f68..00000000 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddlewareTarget.cs +++ /dev/null @@ -1,19 +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. - -namespace Microsoft.AspNetCore.SpaServices.Extensions.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.Extensions/Proxying/SpaProxyingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs new file mode 100644 index 00000000..9178de5f --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs @@ -0,0 +1,76 @@ +// 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.SpaServices.Extensions.Proxy; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for proxying requests to a local SPA development server during + /// development. Not for use in production applications. + /// + public static class SpaProxyingExtensions + { + /// + /// Configures the application to forward incoming requests to a local Single Page + /// Application (SPA) development server. This is only intended to be used during + /// development. Do not enable this middleware in production applications. + /// + /// The . + /// The target base URI to which requests should be proxied. + public static void UseProxyToSpaDevelopmentServer( + this IApplicationBuilder applicationBuilder, + Uri baseUri) + { + UseProxyToSpaDevelopmentServer( + applicationBuilder, + Task.FromResult(baseUri)); + } + + /// + /// Configures the application to forward incoming requests to a local Single Page + /// Application (SPA) development server. This is only intended to be used during + /// development. Do not enable this middleware in production applications. + /// + /// The . + /// A that resolves with the target base URI to which requests should be proxied. + public static void UseProxyToSpaDevelopmentServer( + this IApplicationBuilder applicationBuilder, + Task baseUriTask) + { + var applicationStoppingToken = GetStoppingToken(applicationBuilder); + + // It's important not to time out the requests, as some of them might be to + // server-sent event endpoints or similar, where it's expected that the response + // takes an unlimited time and never actually completes + var neverTimeOutHttpClient = + ConditionalProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); + + // Proxy all requests into the Angular CLI server + applicationBuilder.Use(async (context, next) => + { + var didProxyRequest = await ConditionalProxy.PerformProxyRequest( + context, neverTimeOutHttpClient, baseUriTask, applicationStoppingToken); + + // Since we are proxying everything, this is the end of the middleware pipeline. + // We won't call next(). + if (!didProxyRequest) + { + context.Response.StatusCode = 404; + } + }); + } + + private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder) + { + var applicationLifetime = appBuilder + .ApplicationServices + .GetService(typeof(IApplicationLifetime)); + return ((IApplicationLifetime)applicationLifetime).ApplicationStopping; + } + } +} From 10a114caa30778f19c167fb14c4f09244d4d68a9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 14:35:04 +0000 Subject: [PATCH 15/36] Simplify 404 handling in new SPA proxying code --- .../Proxying/ConditionalProxy.cs | 27 ++++++++++--------- .../Proxying/ConditionalProxyMiddleware.cs | 2 +- .../Proxying/SpaProxyingExtensions.cs | 10 ++----- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs index 453f4038..9ec52813 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs @@ -43,7 +43,8 @@ public static async Task PerformProxyRequest( HttpContext context, HttpClient httpClient, Task baseUriTask, - CancellationToken applicationStoppingToken) + CancellationToken applicationStoppingToken, + bool proxy404s) { // Stop proxying if either the server or client wants to disconnect var proxyCancellationToken = CancellationTokenSource.CreateLinkedTokenSource( @@ -71,7 +72,18 @@ public static async Task PerformProxyRequest( using (var requestMessage = CreateProxyHttpRequest(context, targetUri)) using (var responseMessage = await SendProxyHttpRequest(context, httpClient, requestMessage, proxyCancellationToken)) { - return await CopyProxyHttpResponse(context, responseMessage, proxyCancellationToken); + if (!proxy404s) + { + if (responseMessage.StatusCode == HttpStatusCode.NotFound) + { + // We're not proxying 404s, i.e., we want to resume the middleware pipeline + // and let some other middleware handle this. + return false; + } + } + + await CopyProxyHttpResponse(context, responseMessage, proxyCancellationToken); + return true; } } } @@ -139,15 +151,8 @@ private static Task SendProxyHttpRequest(HttpContext contex return httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); } - private static async Task CopyProxyHttpResponse(HttpContext context, HttpResponseMessage responseMessage, CancellationToken cancellationToken) + private static async Task CopyProxyHttpResponse(HttpContext context, HttpResponseMessage responseMessage, CancellationToken cancellationToken) { - if (responseMessage.StatusCode == HttpStatusCode.NotFound) - { - // Let some other middleware handle this - return false; - } - - // We can handle this context.Response.StatusCode = (int)responseMessage.StatusCode; foreach (var header in responseMessage.Headers) { @@ -166,8 +171,6 @@ private static async Task CopyProxyHttpResponse(HttpContext context, HttpR { await responseStream.CopyToAsync(context.Response.Body, StreamCopyBufferSize, cancellationToken); } - - return true; } private static Uri ToWebSocketScheme(Uri uri) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs index 328b29e6..db044213 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs @@ -48,7 +48,7 @@ public async Task Invoke(HttpContext context) if (context.Request.Path.StartsWithSegments(_pathPrefix) || _pathPrefixIsRoot) { var didProxyRequest = await ConditionalProxy.PerformProxyRequest( - context, _httpClient, _baseUriTask, _applicationStoppingToken); + context, _httpClient, _baseUriTask, _applicationStoppingToken, proxy404s: false); if (didProxyRequest) { return; diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs index 9178de5f..0bc64186 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs @@ -54,14 +54,8 @@ public static void UseProxyToSpaDevelopmentServer( applicationBuilder.Use(async (context, next) => { var didProxyRequest = await ConditionalProxy.PerformProxyRequest( - context, neverTimeOutHttpClient, baseUriTask, applicationStoppingToken); - - // Since we are proxying everything, this is the end of the middleware pipeline. - // We won't call next(). - if (!didProxyRequest) - { - context.Response.StatusCode = 404; - } + context, neverTimeOutHttpClient, baseUriTask, applicationStoppingToken, + proxy404s: true); }); } From 3c313ac4645a6f98cab53381576c50529a1a81ac Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 14:37:15 +0000 Subject: [PATCH 16/36] Rename the new ConditionalProxy to SpaProxy, since it's not always 'conditional' any more. This is internal, so the name change is fine. --- .../Proxying/ConditionalProxyMiddleware.cs | 4 ++-- .../Proxying/{ConditionalProxy.cs => SpaProxy.cs} | 6 +++--- .../Proxying/SpaProxyingExtensions.cs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/{ConditionalProxy.cs => SpaProxy.cs} (98%) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs index db044213..96f62ce7 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs @@ -39,7 +39,7 @@ public ConditionalProxyMiddleware( _pathPrefix = pathPrefix; _pathPrefixIsRoot = string.Equals(_pathPrefix, "/", StringComparison.Ordinal); _baseUriTask = baseUriTask; - _httpClient = ConditionalProxy.CreateHttpClientForProxy(requestTimeout); + _httpClient = SpaProxy.CreateHttpClientForProxy(requestTimeout); _applicationStoppingToken = applicationLifetime.ApplicationStopping; } @@ -47,7 +47,7 @@ public async Task Invoke(HttpContext context) { if (context.Request.Path.StartsWithSegments(_pathPrefix) || _pathPrefixIsRoot) { - var didProxyRequest = await ConditionalProxy.PerformProxyRequest( + var didProxyRequest = await SpaProxy.PerformProxyRequest( context, _httpClient, _baseUriTask, _applicationStoppingToken, proxy404s: false); if (didProxyRequest) { diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs similarity index 98% rename from src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs rename to src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs index 9ec52813..b5e97c20 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs @@ -15,9 +15,9 @@ namespace Microsoft.AspNetCore.SpaServices.Extensions.Proxy { // This duplicates and updates the proxying logic in SpaServices so that we can update // the project templates without waiting for 2.1 to ship. When 2.1 is ready to ship, - // merge the additional proxying features (e.g., proxying websocket connections) back - // into the SpaServices proxying code. It's all internal. - internal static class ConditionalProxy + // remove the old ConditionalProxy.cs from SpaServices and replace its usages with this. + // Doesn't affect public API surface - it's all internal. + internal static class SpaProxy { private const int DefaultWebSocketBufferSize = 4096; private const int StreamCopyBufferSize = 81920; diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs index 0bc64186..12981483 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs @@ -48,12 +48,12 @@ public static void UseProxyToSpaDevelopmentServer( // server-sent event endpoints or similar, where it's expected that the response // takes an unlimited time and never actually completes var neverTimeOutHttpClient = - ConditionalProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); + SpaProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); // Proxy all requests into the Angular CLI server applicationBuilder.Use(async (context, next) => { - var didProxyRequest = await ConditionalProxy.PerformProxyRequest( + var didProxyRequest = await SpaProxy.PerformProxyRequest( context, neverTimeOutHttpClient, baseUriTask, applicationStoppingToken, proxy404s: true); }); From f9f365b5729518872018519220c6f8416fcb05fb Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Nov 2017 12:02:25 +0000 Subject: [PATCH 17/36] Code clean-ups --- .../AngularCli/AngularCliBuilder.cs | 23 ++++---- .../AngularCliMiddlewareExtensions.cs | 5 ++ .../DefaultSpaOptions.cs | 8 ++- .../ISpaOptions.cs | 4 +- .../Npm/EventedStreamReader.cs | 49 ++++++++--------- .../Npm/EventedStreamStringReader.cs | 7 ++- .../Npm/NpmScriptRunner.cs | 14 ++++- .../Prerendering/ISpaPrerendererBuilder.cs | 4 +- .../Prerendering/SpaPrerenderingExtensions.cs | 52 +++++++++++-------- .../Proxying/SpaProxy.cs | 33 +++++------- .../SpaApplicationBuilderExtensions.cs | 4 +- .../SpaDefaultPageMiddleware.cs | 12 +++-- 12 files changed, 120 insertions(+), 95 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs index 61f7b27f..9663b59b 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -28,6 +28,11 @@ public class AngularCliBuilder : ISpaPrerendererBuilder /// The name of the script in your package.json file that builds the server-side bundle for your Angular application. public AngularCliBuilder(string npmScript) { + if (string.IsNullOrEmpty(npmScript)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(npmScript)); + } + _npmScriptName = npmScript; } @@ -45,18 +50,10 @@ public Task Build(IApplicationBuilder app) throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } - return StartAngularCliBuilderAsync( - _npmScriptName, - spaOptions.SourcePath, - AngularCliMiddleware.GetOrCreateLogger(app)); - } - - internal Task StartAngularCliBuilderAsync( - string npmScriptName, string sourcePath, ILogger logger) - { + var logger = AngularCliMiddleware.GetOrCreateLogger(app); var npmScriptRunner = new NpmScriptRunner( - sourcePath, - npmScriptName, + spaOptions.SourcePath, + _npmScriptName, "--watch"); npmScriptRunner.AttachToLogger(logger); @@ -70,7 +67,9 @@ internal Task StartAngularCliBuilderAsync( } catch (EndOfStreamException ex) { - throw new InvalidOperationException($"The NPM script '{npmScriptName}' exited without indicating success. Error output was: {stdErrReader.ReadAsString()}", ex); + throw new InvalidOperationException( + $"The NPM script '{_npmScriptName}' exited without indicating success. " + + $"Error output was: {stdErrReader.ReadAsString()}", ex); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs index 1dcf23c9..5949ed92 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs @@ -25,6 +25,11 @@ public static void UseAngularCliServer( this IApplicationBuilder app, string npmScript) { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + var spaOptions = DefaultSpaOptions.FindInPipeline(app); if (spaOptions == null) { diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs index 894d4b54..f7400cc2 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs @@ -8,7 +8,9 @@ namespace Microsoft.AspNetCore.SpaServices { internal class DefaultSpaOptions : ISpaOptions { - public string DefaultPage { get; set; } = "index.html"; + public const string DefaultDefaultPageValue = "index.html"; + + public string DefaultPage { get; set; } = DefaultDefaultPageValue; public string SourcePath { get; } @@ -38,7 +40,9 @@ internal 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."); + throw new InvalidOperationException($"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; diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs index 455ca34a..29dd2f4d 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs @@ -27,8 +27,10 @@ public interface ISpaOptions /// 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 + /// the value should usually be "/dist", because that is the URL prefix /// from which browsers can request those files. + /// + /// The value must begin with a '/' character. /// string UrlPrefix { get; } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs index b520c2bf..9e44e1b0 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs @@ -10,7 +10,11 @@ namespace Microsoft.AspNetCore.NodeServices.Util { - class EventedStreamReader + /// + /// Wraps a to expose an evented API, issuing notifications + /// when the stream emits partial lines, completed lines, or finally closes. + /// + internal class EventedStreamReader { public delegate void OnReceivedChunkHandler(ArraySegment chunk); public delegate void OnReceivedLineHandler(string line); @@ -38,34 +42,31 @@ public Task WaitForMatch(Regex regex, int timeoutMilliseconds = 0) OnReceivedLineHandler onReceivedLineHandler = null; OnStreamClosedHandler onStreamClosedHandler = null; + void ResolveIfStillPending(Action applyResolution) + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnReceivedLine -= onReceivedLineHandler; + OnStreamClosed -= onStreamClosedHandler; + applyResolution(); + } + } + } + onReceivedLineHandler = line => { var match = regex.Match(line); if (match.Success) { - lock (completionLock) - { - if (!tcs.Task.IsCompleted) - { - OnReceivedLine -= onReceivedLineHandler; - OnStreamClosed -= onStreamClosedHandler; - tcs.SetResult(match); - } - } + ResolveIfStillPending(() => tcs.SetResult(match)); } }; onStreamClosedHandler = () => { - lock (completionLock) - { - if (!tcs.Task.IsCompleted) - { - OnReceivedLine -= onReceivedLineHandler; - OnStreamClosed -= onStreamClosedHandler; - tcs.SetException(new EndOfStreamException()); - } - } + ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException())); }; OnReceivedLine += onReceivedLineHandler; @@ -76,15 +77,7 @@ public Task WaitForMatch(Regex regex, int timeoutMilliseconds = 0) var timeoutToken = new CancellationTokenSource(timeoutMilliseconds); timeoutToken.Token.Register(() => { - lock (completionLock) - { - if (!tcs.Task.IsCompleted) - { - OnReceivedLine -= onReceivedLineHandler; - OnStreamClosed -= onStreamClosedHandler; - tcs.SetCanceled(); - } - } + ResolveIfStillPending(() => tcs.SetCanceled()); }); } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs index 4215126c..efe7c72e 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs @@ -6,7 +6,11 @@ namespace Microsoft.AspNetCore.NodeServices.Util { - class EventedStreamStringReader : IDisposable + /// + /// Captures the completed-line notifications from a , + /// combining the data into a single . + /// + internal class EventedStreamStringReader : IDisposable { private EventedStreamReader _eventedStreamReader; private bool _isDisposed; @@ -17,7 +21,6 @@ public EventedStreamStringReader(EventedStreamReader eventedStreamReader) _eventedStreamReader = eventedStreamReader ?? throw new ArgumentNullException(nameof(eventedStreamReader)); _eventedStreamReader.OnReceivedLine += OnReceivedLine; - } public string ReadAsString() => _stringBuilder.ToString(); diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs index 51efbb2b..77a07e2d 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs @@ -1,12 +1,19 @@ -using Microsoft.AspNetCore.NodeServices.Util; +// 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.Extensions.Logging; +using Microsoft.AspNetCore.NodeServices.Util; using System; using System.Diagnostics; using System.Runtime.InteropServices; -using Microsoft.Extensions.Logging; // This is under the NodeServices namespace because post 2.1 it will be moved to that package namespace Microsoft.AspNetCore.NodeServices.Npm { + /// + /// Executes the script entries defined in a package.json file, + /// capturing any output written to stdio. + /// internal class NpmScriptRunner { public EventedStreamReader StdOut { get; } @@ -28,6 +35,9 @@ public NpmScriptRunner(string workingDirectory, string scriptName, string argume var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}"; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + // On Windows, the NPM executable is a .cmd file, so it can't be executed + // directly (except with UseShellExecute=true, but that's no good, because + // it prevents capturing stdio). So we need to invoke it via "cmd /c". npmExe = "cmd"; completeArguments = $"/c npm {completeArguments}"; } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs index ccff5069..800156d9 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs @@ -7,9 +7,9 @@ namespace Microsoft.AspNetCore.SpaServices.Prerendering { /// - /// Represents the ability to build a Single Page Application application on demand + /// Represents the ability to build a Single Page Application (SPA) 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. + /// time. In production, a SPA should already have been built during publishing. /// public interface ISpaPrerendererBuilder { diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index c4766032..05bd6f5e 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -38,12 +38,17 @@ public static void UseSpaPrerendering( string[] excludeUrls = null, Action> supplyData = null) { + if (applicationBuilder == null) + { + throw new ArgumentNullException(nameof(applicationBuilder)); + } + if (string.IsNullOrEmpty(entryPoint)) { throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); } - // If we're building on demand, start that process now + // If we're building on demand, start that process in the background now var buildOnDemandTask = buildOnDemand?.Build(applicationBuilder); // Get all the necessary context info that will be used for each prerendering call @@ -58,13 +63,12 @@ public static void UseSpaPrerendering( .Select(url => new PathString(url)) .ToArray(); - // Capture the non-prerendered responses, which in production will typically only - // be returning the default SPA index.html page (because other resources will be - // served statically from disk). We will use this as a template in which to inject - // the prerendered output. applicationBuilder.Use(async (context, next) => { - // If this URL is excluded, skip prerendering + // If this URL is excluded, skip prerendering. + // This is typically used to ensure that static client-side resources + // (e.g., /dist/*.css) are served normally or through SPA development + // middleware, and don't return the prerendered index.html page. foreach (var excludePathString in excludePathStrings) { if (context.Request.Path.StartsWithSegments(excludePathString)) @@ -74,7 +78,7 @@ public static void UseSpaPrerendering( } } - // If we're building on demand, do that first + // If we're building on demand, wait for that to finish, or raise any build errors if (buildOnDemandTask != null) { await buildOnDemandTask; @@ -84,6 +88,10 @@ public static void UseSpaPrerendering( // HTML content so it can be passed as a template to the prerenderer. RemoveConditionalRequestHeaders(context.Request); + // Capture the non-prerendered responses, which in production will typically only + // be returning the default SPA index.html page (because other resources will be + // served statically from disk). We will use this as a template in which to inject + // the prerendered output. using (var outputBuffer = new MemoryStream()) { var originalResponseStream = context.Response.Body; @@ -103,10 +111,14 @@ public static void UseSpaPrerendering( // to pass to the prerenderer. if (context.Response.StatusCode < 200 || context.Response.StatusCode >= 300) { - var message = $"Prerendering failed because no HTML template could be obtained. Check that your SPA is compiling without errors. The {nameof(SpaApplicationBuilderExtensions.UseSpa)}() middleware returned a response with status code {context.Response.StatusCode}"; + var message = $"Prerendering failed because no HTML template could be obtained. " + + $"Check that your SPA is compiling without errors. " + + $"The {nameof(SpaApplicationBuilderExtensions.UseSpa)}() middleware returned " + + $"a response with status code {context.Response.StatusCode}."; if (outputBuffer.Length > 0) { - message += " and the following content: " + Encoding.UTF8.GetString(outputBuffer.GetBuffer()); + message += " and the following content: " + + Encoding.UTF8.GetString(outputBuffer.GetBuffer()); } throw new InvalidOperationException(message); @@ -120,9 +132,12 @@ public static void UseSpaPrerendering( { "originalHtml", Encoding.UTF8.GetString(outputBuffer.GetBuffer()) } }; + // If the developer wants to use custom logic to pass arbitrary data to the + // prerendering JS code (e.g., to pass through cookie data), now's their chance supplyData?.Invoke(context, customData); - var (unencodedAbsoluteUrl, unencodedPathAndQuery) = GetUnencodedUrlAndPathQuery(context); + var (unencodedAbsoluteUrl, unencodedPathAndQuery) + = GetUnencodedUrlAndPathQuery(context); var renderResult = await Prerenderer.RenderToString( applicationBasePath, nodeServices, @@ -134,7 +149,7 @@ public static void UseSpaPrerendering( timeoutMilliseconds: 0, requestPathBase: context.Request.PathBase.ToString()); - await ApplyRenderResult(context, renderResult); + await ServePrerenderResult(context, renderResult); } }); } @@ -162,7 +177,7 @@ private static (string, string) GetUnencodedUrlAndPathQuery(HttpContext httpCont return (unencodedAbsoluteUrl, unencodedPathAndQuery); } - private static async Task ApplyRenderResult(HttpContext context, RenderToStringResult renderResult) + private static async Task ServePrerenderResult(HttpContext context, RenderToStringResult renderResult) { context.Response.Clear(); @@ -176,7 +191,10 @@ 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(UseSpaPrerendering)}(). 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 InvalidOperationException($"{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"; @@ -184,14 +202,6 @@ private static async Task ApplyRenderResult(HttpContext context, RenderToStringR } } - private static string GetDefaultFileAbsoluteUrl(HttpContext context, string defaultPageUrl) - { - var req = context.Request; - var defaultFileAbsoluteUrl = UriHelper.BuildAbsolute( - req.Scheme, req.Host, req.PathBase, defaultPageUrl); - return defaultFileAbsoluteUrl; - } - private static INodeServices GetNodeServices(IServiceProvider serviceProvider) { // Use the registered instance, or create a new private instance if none is registered diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs index b5e97c20..548ac6f9 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs @@ -70,7 +70,10 @@ public static async Task PerformProxyRequest( else { using (var requestMessage = CreateProxyHttpRequest(context, targetUri)) - using (var responseMessage = await SendProxyHttpRequest(context, httpClient, requestMessage, proxyCancellationToken)) + using (var responseMessage = await httpClient.SendAsync( + requestMessage, + HttpCompletionOption.ResponseHeadersRead, + proxyCancellationToken)) { if (!proxy404s) { @@ -141,16 +144,6 @@ private static HttpRequestMessage CreateProxyHttpRequest(HttpContext context, Ur return requestMessage; } - private static Task SendProxyHttpRequest(HttpContext context, HttpClient httpClient, HttpRequestMessage requestMessage, CancellationToken cancellationToken) - { - if (requestMessage == null) - { - throw new ArgumentNullException(nameof(requestMessage)); - } - - return httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - } - private static async Task CopyProxyHttpResponse(HttpContext context, HttpResponseMessage responseMessage, CancellationToken cancellationToken) { context.Response.StatusCode = (int)responseMessage.StatusCode; @@ -199,14 +192,11 @@ private static async Task AcceptProxyWebSocketRequest(HttpContext context, { throw new ArgumentNullException(nameof(context)); } + if (destinationUri == null) { throw new ArgumentNullException(nameof(destinationUri)); } - if (!context.WebSockets.IsWebSocketRequest) - { - throw new InvalidOperationException(); - } using (var client = new ClientWebSocket()) { @@ -222,11 +212,14 @@ private static async Task AcceptProxyWebSocketRequest(HttpContext context, { // Note that this is not really good enough to make Websockets work with // Angular CLI middleware. For some reason, ConnectAsync takes over 1 second, - // by which time the logic in SockJS has already timed out and made it fall - // back on some other transport (xhr_streaming, usually). This is not a problem, - // because the transport fallback logic works correctly and doesn't surface any - // errors, but it would be better if ConnectAsync was fast enough and the - // initial Websocket transport could actually be used. + // on Windows, by which time the logic in SockJS has already timed out and made + // it fall back on some other transport (xhr_streaming, usually). It's fine + // on Linux though, completing almost instantly. + // + // The slowness on Windows does not cause a problem though, because the transport + // fallback logic works correctly and doesn't surface any errors, but it would be + // better if ConnectAsync was fast enough and the initial Websocket transport + // could actually be used. await client.ConnectAsync(destinationUri, cancellationToken); } catch (WebSocketException) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs index 758e744a..579dab26 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs @@ -25,8 +25,10 @@ public static class SpaApplicationBuilderExtensions /// 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 + /// the value should usually be "/dist", because that is the URL prefix /// from which browsers can request those files. + /// + /// The value must begin with a '/' character. /// /// /// Optional. If specified, configures the path (relative to the application working diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs index a549a54e..63accfaf 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs @@ -38,7 +38,9 @@ public static void Attach(IApplicationBuilder app, ISpaOptions spaOptions) // 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"; + 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 @@ -46,10 +48,12 @@ public static void Attach(IApplicationBuilder app, ISpaOptions spaOptions) 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"; + 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); + throw new InvalidOperationException(message); }); } @@ -57,7 +61,7 @@ private static string ConstructDefaultPageUrl(string urlPrefix, string defaultPa { if (string.IsNullOrEmpty(defaultPage)) { - defaultPage = "index.html"; + defaultPage = DefaultSpaOptions.DefaultDefaultPageValue; } return new PathString(urlPrefix).Add(new PathString("/" + defaultPage)); From 99a56113c4512e72cae9f80d604e7dde9fefb92f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 3 Nov 2017 16:09:27 +0000 Subject: [PATCH 18/36] Following CR feedback, reintroduce ISpaBuilder concept --- .../AngularCli/AngularCliBuilder.cs | 18 +++---- .../AngularCli/AngularCliMiddleware.cs | 7 +-- .../AngularCliMiddlewareExtensions.cs | 18 +++---- .../DefaultSpaBuilder.cs | 22 ++++++++ .../DefaultSpaOptions.cs | 51 ------------------- .../ISpaBuilder.cs | 25 +++++++++ .../Prerendering/ISpaPrerendererBuilder.cs | 4 +- .../Prerendering/SpaPrerenderingExtensions.cs | 13 ++--- .../Proxying/SpaProxyingExtensions.cs | 12 +++-- .../SpaApplicationBuilderExtensions.cs | 11 ++-- .../SpaDefaultPageMiddleware.cs | 17 +++---- .../{ISpaOptions.cs => SpaOptions.cs} | 23 +++++++-- 12 files changed, 111 insertions(+), 110 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs delete mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaBuilder.cs rename src/Microsoft.AspNetCore.SpaServices.Extensions/{ISpaOptions.cs => SpaOptions.cs} (67%) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs index 9663b59b..018f2fc3 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.NodeServices.Npm; using Microsoft.AspNetCore.NodeServices.Util; using Microsoft.AspNetCore.SpaServices.Prerendering; -using Microsoft.Extensions.Logging; using System; using System.IO; using System.Text.RegularExpressions; @@ -37,22 +36,17 @@ public AngularCliBuilder(string npmScript) } /// - public Task Build(IApplicationBuilder app) + public Task Build(ISpaBuilder spaBuilder) { - var spaOptions = DefaultSpaOptions.FindInPipeline(app); - if (spaOptions == null) + var sourcePath = spaBuilder.Options.SourcePath; + if (string.IsNullOrEmpty(sourcePath)) { - throw new InvalidOperationException($"{nameof(AngularCliBuilder)} can only be used in an application configured with {nameof(SpaApplicationBuilderExtensions.UseSpa)}()."); + throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } - if (string.IsNullOrEmpty(spaOptions.SourcePath)) - { - throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); - } - - var logger = AngularCliMiddleware.GetOrCreateLogger(app); + var logger = AngularCliMiddleware.GetOrCreateLogger(spaBuilder.ApplicationBuilder); var npmScriptRunner = new NpmScriptRunner( - spaOptions.SourcePath, + sourcePath, _npmScriptName, "--watch"); npmScriptRunner.AttachToLogger(logger); diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index 41d825ef..058cd84c 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -22,10 +22,10 @@ internal static class AngularCliMiddleware private const int TimeoutMilliseconds = 50 * 1000; public static void Attach( - IApplicationBuilder appBuilder, - string sourcePath, + ISpaBuilder spaBuilder, string npmScriptName) { + var sourcePath = spaBuilder.Options.SourcePath; if (string.IsNullOrEmpty(sourcePath)) { throw new ArgumentException("Cannot be null or empty", nameof(sourcePath)); @@ -37,6 +37,7 @@ public static void Attach( } // Start Angular CLI and attach to middleware pipeline + var appBuilder = spaBuilder.ApplicationBuilder; var logger = GetOrCreateLogger(appBuilder); var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, npmScriptName, logger); @@ -48,7 +49,7 @@ public static void Attach( var targetUriTask = angularCliServerInfoTask.ContinueWith( task => new UriBuilder("http", "localhost", task.Result.Port).Uri); - SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(appBuilder, targetUriTask); + SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask); } internal static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs index 5949ed92..28e63c8e 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs @@ -19,29 +19,25 @@ public static class AngularCliMiddlewareExtensions /// This feature should only be used in development. For production deployments, be /// sure not to enable the Angular CLI server. /// - /// The . + /// The . /// The name of the script in your package.json file that launches the Angular CLI process. public static void UseAngularCliServer( - this IApplicationBuilder app, + this ISpaBuilder spaBuilder, string npmScript) { - if (app == null) + if (spaBuilder == null) { - throw new ArgumentNullException(nameof(app)); + throw new ArgumentNullException(nameof(spaBuilder)); } - var spaOptions = DefaultSpaOptions.FindInPipeline(app); - if (spaOptions == null) - { - throw new InvalidOperationException($"{nameof(UseAngularCliServer)} should be called inside the 'configure' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); - } + var spaOptions = spaBuilder.Options; if (string.IsNullOrEmpty(spaOptions.SourcePath)) { - throw new InvalidOperationException($"To use {nameof(UseAngularCliServer)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + throw new InvalidOperationException($"To use {nameof(UseAngularCliServer)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } - AngularCliMiddleware.Attach(app, spaOptions.SourcePath, npmScript); + AngularCliMiddleware.Attach(spaBuilder, npmScript); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs new file mode 100644 index 00000000..650c9c86 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs @@ -0,0 +1,22 @@ +// 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 +{ + internal class DefaultSpaBuilder : ISpaBuilder + { + public IApplicationBuilder ApplicationBuilder { get; } + + public SpaOptions Options { get; } + + public DefaultSpaBuilder(IApplicationBuilder applicationBuilder, string sourcePath, string urlPrefix) + { + ApplicationBuilder = applicationBuilder + ?? throw new System.ArgumentNullException(nameof(applicationBuilder)); + + Options = new SpaOptions(sourcePath, urlPrefix); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs deleted file mode 100644 index f7400cc2..00000000 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaOptions.cs +++ /dev/null @@ -1,51 +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 System; - -namespace Microsoft.AspNetCore.SpaServices -{ - internal class DefaultSpaOptions : ISpaOptions - { - public const string DefaultDefaultPageValue = "index.html"; - - public string DefaultPage { get; set; } = DefaultDefaultPageValue; - - public string SourcePath { get; } - - public string UrlPrefix { get; } - - private static readonly string _propertiesKey = Guid.NewGuid().ToString(); - - public DefaultSpaOptions(string sourcePath, string urlPrefix) - { - if (urlPrefix == null || !urlPrefix.StartsWith("/", StringComparison.Ordinal)) - { - throw new ArgumentException("The value must start with '/'", nameof(urlPrefix)); - } - - SourcePath = sourcePath; - UrlPrefix = urlPrefix; - } - - internal static ISpaOptions FindInPipeline(IApplicationBuilder app) - { - return app.Properties.TryGetValue(_propertiesKey, out var instance) - ? (ISpaOptions)instance - : null; - } - - internal void RegisterSoleInstanceInPipeline(IApplicationBuilder app) - { - if (app.Properties.ContainsKey(_propertiesKey)) - { - throw new InvalidOperationException($"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; - } - } -} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaBuilder.cs new file mode 100644 index 00000000..49117926 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaBuilder.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; + +namespace Microsoft.AspNetCore.SpaServices +{ + /// + /// Defines a class that provides mechanisms for configuring the hosting + /// of a Single Page Application (SPA) and attaching middleware. + /// + public interface ISpaBuilder + { + /// + /// The representing the middleware pipeline + /// in which the SPA is being hosted. + /// + IApplicationBuilder ApplicationBuilder { get; } + + /// + /// Describes configuration options for hosting a SPA. + /// + SpaOptions Options { get; } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs index 800156d9..9c3171de 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/ISpaPrerendererBuilder.cs @@ -18,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(IApplicationBuilder appBuilder); + Task Build(ISpaBuilder spaBuilder); } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index 05bd6f5e..2803e543 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -3,9 +3,9 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.NodeServices; +using Microsoft.AspNetCore.SpaServices; using Microsoft.AspNetCore.SpaServices.Prerendering; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; @@ -26,21 +26,21 @@ public static class SpaPrerenderingExtensions /// /// 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. /// Optional. If specified, executes the supplied before looking for the file. This is only intended to be used during development. /// Optional. If specified, requests within these URL paths will bypass the prerenderer. /// Optional. If specified, this callback will be invoked during prerendering, allowing you to pass additional data to the prerendering entrypoint code. public static void UseSpaPrerendering( - this IApplicationBuilder applicationBuilder, + this ISpaBuilder spaBuilder, string entryPoint, ISpaPrerendererBuilder buildOnDemand = null, string[] excludeUrls = null, Action> supplyData = null) { - if (applicationBuilder == null) + if (spaBuilder == null) { - throw new ArgumentNullException(nameof(applicationBuilder)); + throw new ArgumentNullException(nameof(spaBuilder)); } if (string.IsNullOrEmpty(entryPoint)) @@ -49,9 +49,10 @@ public static void UseSpaPrerendering( } // If we're building on demand, start that process in the background now - var buildOnDemandTask = buildOnDemand?.Build(applicationBuilder); + var buildOnDemandTask = buildOnDemand?.Build(spaBuilder); // Get all the necessary context info that will be used for each prerendering call + var applicationBuilder = spaBuilder.ApplicationBuilder; var serviceProvider = applicationBuilder.ApplicationServices; var nodeServices = GetNodeServices(serviceProvider); var applicationStoppingToken = serviceProvider.GetRequiredService() diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs index 12981483..b1741ba2 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.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.Hosting; +using Microsoft.AspNetCore.SpaServices; using Microsoft.AspNetCore.SpaServices.Extensions.Proxy; using System; using System.Threading; @@ -20,14 +21,14 @@ public static class SpaProxyingExtensions /// Application (SPA) development server. This is only intended to be used during /// development. Do not enable this middleware in production applications. /// - /// The . + /// The . /// The target base URI to which requests should be proxied. public static void UseProxyToSpaDevelopmentServer( - this IApplicationBuilder applicationBuilder, + this ISpaBuilder spaBuilder, Uri baseUri) { UseProxyToSpaDevelopmentServer( - applicationBuilder, + spaBuilder, Task.FromResult(baseUri)); } @@ -36,12 +37,13 @@ public static void UseProxyToSpaDevelopmentServer( /// Application (SPA) development server. This is only intended to be used during /// development. Do not enable this middleware in production applications. /// - /// The . + /// The . /// A that resolves with the target base URI to which requests should be proxied. public static void UseProxyToSpaDevelopmentServer( - this IApplicationBuilder applicationBuilder, + this ISpaBuilder spaBuilder, Task baseUriTask) { + var applicationBuilder = spaBuilder.ApplicationBuilder; var applicationStoppingToken = GetStoppingToken(applicationBuilder); // It's important not to time out the requests, as some of them might be to diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs index 579dab26..04970ab1 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs @@ -40,7 +40,7 @@ public static class SpaApplicationBuilderExtensions /// 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. /// @@ -49,16 +49,15 @@ public static void UseSpa( string urlPrefix, string sourcePath = null, string defaultPage = null, - Action configure = null) + Action configuration = null) { - var spaOptions = new DefaultSpaOptions(sourcePath, urlPrefix); - spaOptions.RegisterSoleInstanceInPipeline(app); + var spaBuilder = new DefaultSpaBuilder(app, sourcePath, urlPrefix); // Invoke 'configure' to give the developer a chance to insert extra // middleware before the 'default page' pipeline entries - configure?.Invoke(spaOptions); + configuration?.Invoke(spaBuilder); - SpaDefaultPageMiddleware.Attach(app, spaOptions); + SpaDefaultPageMiddleware.Attach(spaBuilder); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs index 63accfaf..29ef917c 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs @@ -10,19 +10,16 @@ namespace Microsoft.AspNetCore.SpaServices { internal class SpaDefaultPageMiddleware { - public static void Attach(IApplicationBuilder app, ISpaOptions spaOptions) + public static void Attach(ISpaBuilder spaBuilder) { - if (app == null) + if (spaBuilder == null) { - throw new ArgumentNullException(nameof(app)); + throw new ArgumentNullException(nameof(spaBuilder)); } - if (spaOptions == null) - { - throw new ArgumentNullException(nameof(spaOptions)); - } - - var defaultPageUrl = ConstructDefaultPageUrl(spaOptions.UrlPrefix, spaOptions.DefaultPage); + var app = spaBuilder.ApplicationBuilder; + var options = spaBuilder.Options; + var defaultPageUrl = ConstructDefaultPageUrl(options.UrlPrefix, options.DefaultPage); // Rewrite all requests to the default page app.Use((context, next) => @@ -61,7 +58,7 @@ private static string ConstructDefaultPageUrl(string urlPrefix, string defaultPa { if (string.IsNullOrEmpty(defaultPage)) { - defaultPage = DefaultSpaOptions.DefaultDefaultPageValue; + defaultPage = SpaOptions.DefaultDefaultPageValue; } return new PathString(urlPrefix).Add(new PathString("/" + defaultPage)); diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs similarity index 67% rename from src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs rename to src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs index 29dd2f4d..6182ce8f 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs @@ -1,26 +1,30 @@ // 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; + namespace Microsoft.AspNetCore.SpaServices { /// /// Describes options for hosting a Single Page Application (SPA). /// - public interface ISpaOptions + public class SpaOptions { + internal const string DefaultDefaultPageValue = "index.html"; + /// /// Gets or sets the URL, relative to , /// of the default page that hosts your SPA user interface. /// The typical value is "index.html". /// - string DefaultPage { get; set; } + public string DefaultPage { get; set; } = DefaultDefaultPageValue; /// /// Gets the path, relative to the application working directory, /// of the directory that contains the SPA source files during /// development. The directory may not exist in published applications. /// - string SourcePath { get; } + public string SourcePath { get; } /// /// Gets the URL path, relative to your application's PathBase, from which @@ -32,6 +36,17 @@ public interface ISpaOptions /// /// The value must begin with a '/' character. /// - string UrlPrefix { get; } + public string UrlPrefix { get; } + + internal SpaOptions(string sourcePath, string urlPrefix) + { + if (urlPrefix == null || !urlPrefix.StartsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException("The value must start with '/'", nameof(urlPrefix)); + } + + SourcePath = sourcePath; + UrlPrefix = urlPrefix; + } } } From 0849d4511f342dcabfbd0493bddf5a281d0245af Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 09:06:46 -0800 Subject: [PATCH 19/36] CR feedback: Clean up timeouts --- .../AngularCli/AngularCliBuilder.cs | 8 +++++--- .../AngularCli/AngularCliMiddleware.cs | 9 +++++---- .../Npm/EventedStreamReader.cs | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs index 018f2fc3..30dc2067 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -18,7 +18,9 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli /// public class AngularCliBuilder : ISpaPrerendererBuilder { - private const int TimeoutMilliseconds = 50 * 1000; + private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine + private static TimeSpan BuildTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter + private readonly string _npmScriptName; /// @@ -56,8 +58,8 @@ public Task Build(ISpaBuilder spaBuilder) try { return npmScriptRunner.StdOut.WaitForMatch( - new Regex("chunk"), - TimeoutMilliseconds); + new Regex("chunk", RegexOptions.None, RegexMatchTimeout), + BuildTimeout); } catch (EndOfStreamException ex) { diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index 058cd84c..23a89867 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -19,7 +19,8 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli internal static class AngularCliMiddleware { private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; - private const int TimeoutMilliseconds = 50 * 1000; + private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine + private static TimeSpan StartupTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter public static void Attach( ISpaBuilder spaBuilder, @@ -78,8 +79,8 @@ private static async Task StartAngularCliServerAsync( try { openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( - new Regex("open your browser on (http\\S+)"), - TimeoutMilliseconds); + new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout), + StartupTimeout); } catch (EndOfStreamException ex) { @@ -92,7 +93,7 @@ private static async Task StartAngularCliServerAsync( { throw new InvalidOperationException( $"The Angular CLI process did not start listening for requests " + - $"within the timeout period of {TimeoutMilliseconds / 1000} seconds. " + + $"within the timeout period of {StartupTimeout.Seconds} seconds. " + $"Check the log output for error information.", ex); } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs index 9e44e1b0..64a4f8cc 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs @@ -34,7 +34,7 @@ public EventedStreamReader(StreamReader streamReader) Task.Factory.StartNew(Run); } - public Task WaitForMatch(Regex regex, int timeoutMilliseconds = 0) + public Task WaitForMatch(Regex regex, TimeSpan timeout = default) { var tcs = new TaskCompletionSource(); var completionLock = new object(); @@ -72,9 +72,9 @@ void ResolveIfStillPending(Action applyResolution) OnReceivedLine += onReceivedLineHandler; OnStreamClosed += onStreamClosedHandler; - if (timeoutMilliseconds > 0) + if (timeout != default) { - var timeoutToken = new CancellationTokenSource(timeoutMilliseconds); + var timeoutToken = new CancellationTokenSource(timeout); timeoutToken.Token.Register(() => { ResolveIfStillPending(() => tcs.SetCanceled()); From d2f7f83dc57243e7f9252294fd99a0a5271c02c6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 09:11:28 -0800 Subject: [PATCH 20/36] Update project reference to match new KoreBuild requirements --- .../Microsoft.AspNetCore.SpaServices.Extensions.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj index 2a58bfcc..7c937f72 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj @@ -10,7 +10,7 @@ - + From e34c261e8f4e60d4ed2b05c05382f612d306079d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 09:14:33 -0800 Subject: [PATCH 21/36] Remove redundant warning suppression --- .../AngularCli/AngularCliMiddleware.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index 23a89867..349deb62 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -123,11 +123,9 @@ private static int FindAvailablePort() } } -#pragma warning disable CS0649 class AngularCliServerInfo { public int Port { get; set; } } } -#pragma warning restore CS0649 } From 82507eb948fa5ec4d1ff5aaf7cef189f1f3ae744 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 09:15:04 -0800 Subject: [PATCH 22/36] Add 'using' --- .../DefaultSpaBuilder.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs index 650c9c86..1c4b205e 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.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 { @@ -14,7 +15,7 @@ internal class DefaultSpaBuilder : ISpaBuilder public DefaultSpaBuilder(IApplicationBuilder applicationBuilder, string sourcePath, string urlPrefix) { ApplicationBuilder = applicationBuilder - ?? throw new System.ArgumentNullException(nameof(applicationBuilder)); + ?? throw new ArgumentNullException(nameof(applicationBuilder)); Options = new SpaOptions(sourcePath, urlPrefix); } From a93e1a1d0468231e6faa22524c7c5df2c3389836 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 09:29:18 -0800 Subject: [PATCH 23/36] CR feedback: Switch to configuration callback for UseSpaPrerendering instead of optional params --- .../Prerendering/SpaPrerenderingExtensions.cs | 17 ++++----- .../Prerendering/SpaPrerenderingOptions.cs | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index 2803e543..bd1137bf 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -28,15 +28,11 @@ public static class SpaPrerenderingExtensions /// /// 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. - /// Optional. If specified, requests within these URL paths will bypass the prerenderer. - /// Optional. If specified, this callback will be invoked during prerendering, allowing you to pass additional data to the prerendering entrypoint code. + /// If specified, supplies additional options for the prerendering middleware. public static void UseSpaPrerendering( this ISpaBuilder spaBuilder, string entryPoint, - ISpaPrerendererBuilder buildOnDemand = null, - string[] excludeUrls = null, - Action> supplyData = null) + Action configuration = null) { if (spaBuilder == null) { @@ -48,8 +44,11 @@ public static void UseSpaPrerendering( throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); } + var options = new SpaPrerenderingOptions(); + configuration?.Invoke(options); + // If we're building on demand, start that process in the background now - var buildOnDemandTask = buildOnDemand?.Build(spaBuilder); + var buildOnDemandTask = options.BuildOnDemand?.Build(spaBuilder); // Get all the necessary context info that will be used for each prerendering call var applicationBuilder = spaBuilder.ApplicationBuilder; @@ -60,7 +59,7 @@ public static void UseSpaPrerendering( var applicationBasePath = serviceProvider.GetRequiredService() .ContentRootPath; var moduleExport = new JavaScriptModuleExport(entryPoint); - var excludePathStrings = (excludeUrls ?? Array.Empty()) + var excludePathStrings = (options.ExcludeUrls ?? Array.Empty()) .Select(url => new PathString(url)) .ToArray(); @@ -135,7 +134,7 @@ public static void UseSpaPrerendering( // If the developer wants to use custom logic to pass arbitrary data to the // prerendering JS code (e.g., to pass through cookie data), now's their chance - supplyData?.Invoke(context, customData); + options.SupplyData?.Invoke(context, customData); var (unencodedAbsoluteUrl, unencodedPathAndQuery) = GetUnencodedUrlAndPathQuery(context); diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs new file mode 100644 index 00000000..c28c6a39 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.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.Http; +using Microsoft.AspNetCore.SpaServices.Prerendering; +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Represents options for the SPA prerendering middleware. + /// + public class SpaPrerenderingOptions + { + /// + /// Gets or sets an that the prerenderer will invoke before + /// looking for the entrypoint file. + /// + /// This is only intended to be used during development as a way of generating the JavaScript boot + /// file automatically when the application runs. This property should be left as null in + /// production applications. + /// + public ISpaPrerendererBuilder BuildOnDemand { get; set; } + + /// + /// Gets or sets an array of URL prefixes for which prerendering should not run. + /// + public string[] ExcludeUrls { get; set; } + + /// + /// Gets or sets a callback that will be invoked during prerendering, allowing you to pass additional + /// data to the prerendering entrypoint code. + /// + public Action> SupplyData { get; set; } + } +} From bb10e56d5a2de23f75c9391c0c133c55cadce684 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 09:30:32 -0800 Subject: [PATCH 24/36] CR feedback: Change comment from 'typical' to 'default' --- src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs index 6182ce8f..e5db882f 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs @@ -15,7 +15,7 @@ public class SpaOptions /// /// Gets or sets the URL, relative to , /// of the default page that hosts your SPA user interface. - /// The typical value is "index.html". + /// The default value is "index.html". /// public string DefaultPage { get; set; } = DefaultDefaultPageValue; From eb403ed1b5043533281267d2e632578659558443 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 09:31:57 -0800 Subject: [PATCH 25/36] CR feedback: Remove vestigal 'defaultPage' param --- .../SpaApplicationBuilderExtensions.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs index 04970ab1..d4bdc93a 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs @@ -35,11 +35,6 @@ public static class SpaApplicationBuilderExtensions /// directory) of the directory that holds the SPA source files during development. /// The directory need not exist once the application is published. /// - /// - /// 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. @@ -48,7 +43,6 @@ public static void UseSpa( this IApplicationBuilder app, string urlPrefix, string sourcePath = null, - string defaultPage = null, Action configuration = null) { var spaBuilder = new DefaultSpaBuilder(app, sourcePath, urlPrefix); From eb04c9837f0a00efa1613b358899fd0e68f41eb9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 10:08:51 -0800 Subject: [PATCH 26/36] On UseSpaPrerendering, make 'configuration' mandatory because you would basically always need to use it --- .../Prerendering/SpaPrerenderingExtensions.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index bd1137bf..d16a4743 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -28,11 +28,11 @@ public static class SpaPrerenderingExtensions /// /// The . /// The path, relative to your application root, of the JavaScript file containing prerendering logic. - /// If specified, supplies additional options for the prerendering middleware. + /// Supplies additional options for the prerendering middleware. public static void UseSpaPrerendering( this ISpaBuilder spaBuilder, string entryPoint, - Action configuration = null) + Action configuration) { if (spaBuilder == null) { @@ -44,8 +44,13 @@ public static void UseSpaPrerendering( throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); } + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + var options = new SpaPrerenderingOptions(); - configuration?.Invoke(options); + configuration.Invoke(options); // If we're building on demand, start that process in the background now var buildOnDemandTask = options.BuildOnDemand?.Build(spaBuilder); From 974ace4543a189bed7018ada3253d65b7eb8cc4a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 15:41:44 -0800 Subject: [PATCH 27/36] Make UseProxyToSpaDevelopmentServer responsible for enabling WebSockets support --- build/dependencies.props | 1 + .../Microsoft.AspNetCore.SpaServices.Extensions.csproj | 1 + .../Proxying/SpaProxyingExtensions.cs | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/build/dependencies.props b/build/dependencies.props index 8e679ad2..8a5dab00 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -13,6 +13,7 @@ 2.1.0-preview1-27478 2.1.0-preview1-27478 2.1.0-preview1-27478 + 2.1.0-preview1-27478 2.1.0-preview1-27478 2.1.0-preview1-27478 2.1.0-preview1-27478 diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj index 7c937f72..0388b514 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs index b1741ba2..2868812f 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs @@ -46,6 +46,10 @@ public static void UseProxyToSpaDevelopmentServer( var applicationBuilder = spaBuilder.ApplicationBuilder; var applicationStoppingToken = GetStoppingToken(applicationBuilder); + // Since we might want to proxy WebSockets requests (e.g., by default, AngularCliMiddleware + // requires it), enable it for the app + applicationBuilder.UseWebSockets(); + // It's important not to time out the requests, as some of them might be to // server-sent event endpoints or similar, where it's expected that the response // takes an unlimited time and never actually completes From dc7f14a2e54bc4977402d25f5723d63dc4fc315f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 16:11:08 -0800 Subject: [PATCH 28/36] CR feedback: Change UseSpa to take only an Action, and set the UrlPrefix/SourcePath from inside the callback --- .../DefaultSpaBuilder.cs | 4 +- .../SpaApplicationBuilderExtensions.cs | 36 ++++----------- .../SpaDefaultPageMiddleware.cs | 10 ++-- .../SpaOptions.cs | 46 +++++++++++++------ 4 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs index 1c4b205e..f859a216 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs @@ -12,12 +12,12 @@ internal class DefaultSpaBuilder : ISpaBuilder public SpaOptions Options { get; } - public DefaultSpaBuilder(IApplicationBuilder applicationBuilder, string sourcePath, string urlPrefix) + public DefaultSpaBuilder(IApplicationBuilder applicationBuilder) { ApplicationBuilder = applicationBuilder ?? throw new ArgumentNullException(nameof(applicationBuilder)); - Options = new SpaOptions(sourcePath, urlPrefix); + Options = new SpaOptions(); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs index d4bdc93a..a8c1b31b 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs @@ -20,37 +20,19 @@ public static class SpaApplicationBuilderExtensions /// 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. - /// - /// The value must begin with a '/' character. - /// - /// - /// Optional. If specified, configures the path (relative to the application working - /// directory) of the directory that holds the SPA source files during development. - /// The directory need not exist once the application is published. - /// /// - /// Optional. If specified, this callback will be invoked so that additional middleware - /// can be registered within the context of this SPA. + /// 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 sourcePath = null, - Action configuration = null) + public static void UseSpa(this IApplicationBuilder app, Action configuration) { - var spaBuilder = new DefaultSpaBuilder(app, sourcePath, urlPrefix); - - // Invoke 'configure' to give the developer a chance to insert extra - // middleware before the 'default page' pipeline entries - configuration?.Invoke(spaBuilder); + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + var spaBuilder = new DefaultSpaBuilder(app); + configuration.Invoke(spaBuilder); SpaDefaultPageMiddleware.Attach(spaBuilder); } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs index 29ef917c..de311f60 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs @@ -19,6 +19,11 @@ public static void Attach(ISpaBuilder spaBuilder) var app = spaBuilder.ApplicationBuilder; var options = spaBuilder.Options; + if (string.IsNullOrEmpty(options.UrlPrefix)) + { + throw new InvalidOperationException($"To use SPA default page middleware, you must supply a non-empty value for the {nameof(SpaOptions.UrlPrefix)} property on the {nameof(ISpaBuilder)}'s {nameof(ISpaBuilder.Options)}."); + } + var defaultPageUrl = ConstructDefaultPageUrl(options.UrlPrefix, options.DefaultPage); // Rewrite all requests to the default page @@ -56,11 +61,6 @@ public static void Attach(ISpaBuilder spaBuilder) private static string ConstructDefaultPageUrl(string urlPrefix, string defaultPage) { - if (string.IsNullOrEmpty(defaultPage)) - { - defaultPage = SpaOptions.DefaultDefaultPageValue; - } - return new PathString(urlPrefix).Add(new PathString("/" + defaultPage)); } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs index e5db882f..f99d7d64 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs @@ -10,25 +10,38 @@ namespace Microsoft.AspNetCore.SpaServices /// public class SpaOptions { - internal const string DefaultDefaultPageValue = "index.html"; + private string _urlPrefix; + private string _defaultPageUrl = "index.html"; /// /// Gets or sets the URL, relative to , /// of the default page that hosts your SPA user interface. /// The default value is "index.html". /// - public string DefaultPage { get; set; } = DefaultDefaultPageValue; + public string DefaultPage + { + get => _defaultPageUrl; + set + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException($"The value for {nameof(DefaultPage)} cannot be null or empty."); + } + + _defaultPageUrl = value; + } + } /// - /// Gets the path, relative to the application working directory, + /// Gets or sets the path, relative to the application working directory, /// of the directory that contains the SPA source files during /// development. The directory may not exist in published applications. /// - public string SourcePath { get; } + public string SourcePath { get; set; } /// - /// Gets the URL path, relative to your application's PathBase, from which - /// the SPA files are served. + /// Gets or sets 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 @@ -36,17 +49,24 @@ public class SpaOptions /// /// The value must begin with a '/' character. /// - public string UrlPrefix { get; } - - internal SpaOptions(string sourcePath, string urlPrefix) + public string UrlPrefix { - if (urlPrefix == null || !urlPrefix.StartsWith("/", StringComparison.Ordinal)) + get => _urlPrefix; + set { - throw new ArgumentException("The value must start with '/'", nameof(urlPrefix)); + if (value == null || !value.StartsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException($"The value for {nameof(UrlPrefix)} must start with '/'"); + } + + _urlPrefix = value; } + } - SourcePath = sourcePath; - UrlPrefix = urlPrefix; + // Currently there isn't a use case for constructing this in application code, but if that changes, + // this internal constructor will be removed. + internal SpaOptions() + { } } } From ca37e91a6c775d0a4253b98f167308c96b9ac551 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 16:22:19 -0800 Subject: [PATCH 29/36] CR feedback: Change UrlPrefix property to be a PathString --- .../SpaDefaultPageMiddleware.cs | 11 +++-------- .../SpaOptions.cs | 9 ++++----- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs index de311f60..7aaef286 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs @@ -19,12 +19,12 @@ public static void Attach(ISpaBuilder spaBuilder) var app = spaBuilder.ApplicationBuilder; var options = spaBuilder.Options; - if (string.IsNullOrEmpty(options.UrlPrefix)) + if (options.UrlPrefix == null) { - throw new InvalidOperationException($"To use SPA default page middleware, you must supply a non-empty value for the {nameof(SpaOptions.UrlPrefix)} property on the {nameof(ISpaBuilder)}'s {nameof(ISpaBuilder.Options)}."); + throw new InvalidOperationException($"To use SPA default page middleware, you must supply a value for the {nameof(SpaOptions.UrlPrefix)} property on the {nameof(ISpaBuilder)}'s {nameof(ISpaBuilder.Options)}."); } - var defaultPageUrl = ConstructDefaultPageUrl(options.UrlPrefix, options.DefaultPage); + var defaultPageUrl = options.UrlPrefix.Add(new PathString("/" + options.DefaultPage)); // Rewrite all requests to the default page app.Use((context, next) => @@ -58,10 +58,5 @@ public static void Attach(ISpaBuilder spaBuilder) throw new InvalidOperationException(message); }); } - - private static string ConstructDefaultPageUrl(string urlPrefix, string defaultPage) - { - return new PathString(urlPrefix).Add(new PathString("/" + defaultPage)); - } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs index f99d7d64..42084601 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.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.Http; using System; namespace Microsoft.AspNetCore.SpaServices @@ -46,17 +47,15 @@ public string DefaultPage /// 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. - /// - /// The value must begin with a '/' character. /// - public string UrlPrefix + public PathString UrlPrefix { get => _urlPrefix; set { - if (value == null || !value.StartsWith("/", StringComparison.Ordinal)) + if (string.IsNullOrEmpty(value.Value)) { - throw new ArgumentException($"The value for {nameof(UrlPrefix)} must start with '/'"); + throw new ArgumentNullException($"The value for {nameof(UrlPrefix)} cannot be null or empty."); } _urlPrefix = value; From 54cea3d88644c3fbff917b242bf176b73fb4bc56 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 16:24:27 -0800 Subject: [PATCH 30/36] CR feedback: Add UseProxyToSpaDevelopmentServer overload that takes a string for the URI --- .../Proxying/SpaProxyingExtensions.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs index 2868812f..066e5813 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs @@ -16,6 +16,22 @@ namespace Microsoft.AspNetCore.Builder /// public static class SpaProxyingExtensions { + /// + /// Configures the application to forward incoming requests to a local Single Page + /// Application (SPA) development server. This is only intended to be used during + /// development. Do not enable this middleware in production applications. + /// + /// The . + /// The target base URI to which requests should be proxied. + public static void UseProxyToSpaDevelopmentServer( + this ISpaBuilder spaBuilder, + string baseUri) + { + UseProxyToSpaDevelopmentServer( + spaBuilder, + new Uri(baseUri)); + } + /// /// Configures the application to forward incoming requests to a local Single Page /// Application (SPA) development server. This is only intended to be used during From ca7ae030142763ff5ae4683be7fd374fd34ef9c5 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 16:52:45 -0800 Subject: [PATCH 31/36] CR feedback: Use IOptions pattern --- .../DefaultSpaBuilder.cs | 5 ++-- .../SpaApplicationBuilderExtensions.cs | 9 ++++++- .../SpaOptions.cs | 24 ++++++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs index f859a216..b1517a71 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs @@ -12,12 +12,13 @@ internal class DefaultSpaBuilder : ISpaBuilder public SpaOptions Options { get; } - public DefaultSpaBuilder(IApplicationBuilder applicationBuilder) + public DefaultSpaBuilder(IApplicationBuilder applicationBuilder, SpaOptions options) { ApplicationBuilder = applicationBuilder ?? throw new ArgumentNullException(nameof(applicationBuilder)); - Options = new SpaOptions(); + Options = options + ?? throw new ArgumentNullException(nameof(options)); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs index a8c1b31b..f127e355 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.SpaServices; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using System; namespace Microsoft.AspNetCore.Builder @@ -31,7 +33,12 @@ public static void UseSpa(this IApplicationBuilder app, Action conf throw new ArgumentNullException(nameof(configuration)); } - var spaBuilder = new DefaultSpaBuilder(app); + // Use the options configured in DI (or blank if none was configured). We have to clone it + // otherwise if you have multiple UseSpa calls, their configurations would interfere with one another. + var optionsProvider = app.ApplicationServices.GetService>(); + var options = new SpaOptions(optionsProvider.Value); + + var spaBuilder = new DefaultSpaBuilder(app, options); configuration.Invoke(spaBuilder); SpaDefaultPageMiddleware.Attach(spaBuilder); } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs index 42084601..b6d75ac2 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs @@ -14,6 +14,24 @@ public class SpaOptions private string _urlPrefix; private string _defaultPageUrl = "index.html"; + /// + /// Constructs a new instance of . + /// + public SpaOptions() + { + } + + /// + /// Constructs a new instance of . + /// + /// An instance of from which values should be copied. + internal SpaOptions(SpaOptions copyFromOptions) + { + DefaultPage = copyFromOptions.DefaultPage; + SourcePath = copyFromOptions.SourcePath; + UrlPrefix = copyFromOptions.UrlPrefix; + } + /// /// Gets or sets the URL, relative to , /// of the default page that hosts your SPA user interface. @@ -61,11 +79,5 @@ public PathString UrlPrefix _urlPrefix = value; } } - - // Currently there isn't a use case for constructing this in application code, but if that changes, - // this internal constructor will be removed. - internal SpaOptions() - { - } } } From a5bd3208f99ccbd2960e2708e54feb85604879a2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 7 Nov 2017 17:11:02 -0800 Subject: [PATCH 32/36] Additional changes that should have been included in previous commit --- .../SpaOptions.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs index b6d75ac2..684d0995 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs @@ -11,8 +11,8 @@ namespace Microsoft.AspNetCore.SpaServices /// public class SpaOptions { - private string _urlPrefix; - private string _defaultPageUrl = "index.html"; + private PathString _urlPrefix; + private string _defaultPage = "index.html"; /// /// Constructs a new instance of . @@ -27,9 +27,9 @@ public SpaOptions() /// An instance of from which values should be copied. internal SpaOptions(SpaOptions copyFromOptions) { - DefaultPage = copyFromOptions.DefaultPage; + _defaultPage = copyFromOptions.DefaultPage; SourcePath = copyFromOptions.SourcePath; - UrlPrefix = copyFromOptions.UrlPrefix; + _urlPrefix = copyFromOptions.UrlPrefix; } /// @@ -39,7 +39,7 @@ internal SpaOptions(SpaOptions copyFromOptions) /// public string DefaultPage { - get => _defaultPageUrl; + get => _defaultPage; set { if (string.IsNullOrEmpty(value)) @@ -47,7 +47,7 @@ public string DefaultPage throw new ArgumentException($"The value for {nameof(DefaultPage)} cannot be null or empty."); } - _defaultPageUrl = value; + _defaultPage = value; } } From b5b0356f386ba326ca6e75b25e25d0074fd11263 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 8 Nov 2017 15:20:12 -0800 Subject: [PATCH 33/36] CR feedback: Strip ANSI colours when writing to ILogger --- .../Npm/NpmScriptRunner.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs index 77a07e2d..dfb88524 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs @@ -6,6 +6,7 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; // This is under the NodeServices namespace because post 2.1 it will be moved to that package namespace Microsoft.AspNetCore.NodeServices.Npm @@ -19,6 +20,8 @@ internal class NpmScriptRunner public EventedStreamReader StdOut { get; } public EventedStreamReader StdErr { get; } + private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1)); + public NpmScriptRunner(string workingDirectory, string scriptName, string arguments) { if (string.IsNullOrEmpty(workingDirectory)) @@ -63,7 +66,9 @@ public void AttachToLogger(ILogger logger) { if (!string.IsNullOrWhiteSpace(line)) { - logger.LogInformation(line); + // NPM tasks commonly emit ANSI colors, but it wouldn't make sense to forward + // those to loggers (because a logger isn't necessarily any kind of terminal) + logger.LogInformation(StripAnsiColors(line)); } }; @@ -71,7 +76,7 @@ public void AttachToLogger(ILogger logger) { if (!string.IsNullOrWhiteSpace(line)) { - logger.LogError(line); + logger.LogError(StripAnsiColors(line)); } }; @@ -88,6 +93,9 @@ public void AttachToLogger(ILogger logger) }; } + private static string StripAnsiColors(string line) + => AnsiColorRegex.Replace(line, string.Empty); + private static Process LaunchNodeProcess(ProcessStartInfo startInfo) { try From 31af70f57f0b215e66af671a3fa1f5eb6a232d4c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 8 Nov 2017 18:11:51 -0800 Subject: [PATCH 34/36] Eliminate UrlPrefix because it's no longer much used. Add DefaultPageFileProvider to support SPAs not served from wwwroot (e.g., React). --- .../SpaDefaultPageMiddleware.cs | 22 ++++----- .../SpaOptions.cs | 48 +++++++------------ 2 files changed, 27 insertions(+), 43 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs index 7aaef286..ed2ecb60 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using System; namespace Microsoft.AspNetCore.SpaServices @@ -19,29 +18,26 @@ public static void Attach(ISpaBuilder spaBuilder) var app = spaBuilder.ApplicationBuilder; var options = spaBuilder.Options; - if (options.UrlPrefix == null) - { - throw new InvalidOperationException($"To use SPA default page middleware, you must supply a value for the {nameof(SpaOptions.UrlPrefix)} property on the {nameof(ISpaBuilder)}'s {nameof(ISpaBuilder.Options)}."); - } - - var defaultPageUrl = options.UrlPrefix.Add(new PathString("/" + options.DefaultPage)); // Rewrite all requests to the default page app.Use((context, next) => { - context.Request.Path = defaultPageUrl; + context.Request.Path = options.DefaultPage; return next(); }); - // Serve it as file from disk - app.UseStaticFiles(); + // Serve it as file from wwwroot (by default), or any other configured file provider + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = options.DefaultPageFileProvider + }); - // 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. + // If the default file didn't get served as a static file (usually 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 " + + $"'{options.DefaultPage}' because it was not found, and no other middleware " + "handled the request.\n"; // Try to clarify the common scenario where someone runs an application in diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs index 684d0995..d1d71c97 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs @@ -1,7 +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.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; using System; namespace Microsoft.AspNetCore.SpaServices @@ -11,8 +13,7 @@ namespace Microsoft.AspNetCore.SpaServices /// public class SpaOptions { - private PathString _urlPrefix; - private string _defaultPage = "index.html"; + private PathString _defaultPage = "/index.html"; /// /// Constructs a new instance of . @@ -28,56 +29,43 @@ public SpaOptions() internal SpaOptions(SpaOptions copyFromOptions) { _defaultPage = copyFromOptions.DefaultPage; + DefaultPageFileProvider = copyFromOptions.DefaultPageFileProvider; SourcePath = copyFromOptions.SourcePath; - _urlPrefix = copyFromOptions.UrlPrefix; } /// - /// Gets or sets the URL, relative to , - /// of the default page that hosts your SPA user interface. + /// Gets or sets the URL of the default page that hosts your SPA user interface. /// The default value is "index.html". /// - public string DefaultPage + public PathString DefaultPage { get => _defaultPage; set { - if (string.IsNullOrEmpty(value)) + if (string.IsNullOrEmpty(value.Value)) { - throw new ArgumentException($"The value for {nameof(DefaultPage)} cannot be null or empty."); + throw new ArgumentNullException($"The value for {nameof(DefaultPage)} cannot be null or empty."); } _defaultPage = value; } } + /// + /// Gets or sets the that supplies content + /// for serving the SPA's default page. + /// + /// If not set, a default file provider will read files from the + /// , which by default is + /// the wwwroot directory. + /// + public IFileProvider DefaultPageFileProvider { get; set; } + /// /// Gets or sets the path, relative to the application working directory, /// of the directory that contains the SPA source files during /// development. The directory may not exist in published applications. /// public string SourcePath { get; set; } - - /// - /// Gets or sets 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. - /// - public PathString UrlPrefix - { - get => _urlPrefix; - set - { - if (string.IsNullOrEmpty(value.Value)) - { - throw new ArgumentNullException($"The value for {nameof(UrlPrefix)} cannot be null or empty."); - } - - _urlPrefix = value; - } - } } } From 9b2e279644266ccd85e700590de2c9cd3cd8ebf7 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 9 Nov 2017 08:58:18 -0800 Subject: [PATCH 35/36] Fix XML doc --- src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs index d1d71c97..65912e53 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs @@ -35,7 +35,7 @@ internal SpaOptions(SpaOptions copyFromOptions) /// /// Gets or sets the URL of the default page that hosts your SPA user interface. - /// The default value is "index.html". + /// The default value is "/index.html". /// public PathString DefaultPage { From 073fc7aeb0879e99bbb0bf470b60eaff12394a15 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 9 Nov 2017 09:31:06 -0800 Subject: [PATCH 36/36] Move UseSpaPrerendering entryPoint config onto SpaPrerenderingOptions --- .../Prerendering/SpaPrerenderingExtensions.cs | 19 ++++++++++--------- .../Prerendering/SpaPrerenderingOptions.cs | 8 +++++++- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs index d16a4743..286aa65c 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -27,11 +27,9 @@ public static class SpaPrerenderingExtensions /// Enables server-side prerendering middleware for a Single Page Application. /// /// The . - /// The path, relative to your application root, of the JavaScript file containing prerendering logic. - /// Supplies additional options for the prerendering middleware. + /// Supplies configuration for the prerendering middleware. public static void UseSpaPrerendering( this ISpaBuilder spaBuilder, - string entryPoint, Action configuration) { if (spaBuilder == null) @@ -39,11 +37,6 @@ public static void UseSpaPrerendering( throw new ArgumentNullException(nameof(spaBuilder)); } - if (string.IsNullOrEmpty(entryPoint)) - { - throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); - } - if (configuration == null) { throw new ArgumentNullException(nameof(configuration)); @@ -52,6 +45,14 @@ public static void UseSpaPrerendering( var options = new SpaPrerenderingOptions(); configuration.Invoke(options); + var capturedBootModulePath = options.BootModulePath; + if (string.IsNullOrEmpty(capturedBootModulePath)) + { + throw new InvalidOperationException($"To use {nameof(UseSpaPrerendering)}, you " + + $"must set a nonempty value on the ${nameof(SpaPrerenderingOptions.BootModulePath)} " + + $"property on the ${nameof(SpaPrerenderingOptions)}."); + } + // If we're building on demand, start that process in the background now var buildOnDemandTask = options.BuildOnDemand?.Build(spaBuilder); @@ -63,7 +64,7 @@ public static void UseSpaPrerendering( .ApplicationStopping; var applicationBasePath = serviceProvider.GetRequiredService() .ContentRootPath; - var moduleExport = new JavaScriptModuleExport(entryPoint); + var moduleExport = new JavaScriptModuleExport(capturedBootModulePath); var excludePathStrings = (options.ExcludeUrls ?? Array.Empty()) .Select(url => new PathString(url)) .ToArray(); diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs index c28c6a39..bf06e6a5 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs @@ -15,7 +15,7 @@ public class SpaPrerenderingOptions { /// /// Gets or sets an that the prerenderer will invoke before - /// looking for the entrypoint file. + /// looking for the boot module file. /// /// This is only intended to be used during development as a way of generating the JavaScript boot /// file automatically when the application runs. This property should be left as null in @@ -23,6 +23,12 @@ public class SpaPrerenderingOptions /// public ISpaPrerendererBuilder BuildOnDemand { get; set; } + /// + /// Gets or sets the path, relative to your application root, of the JavaScript file + /// containing prerendering logic. + /// + public string BootModulePath { get; set; } + /// /// Gets or sets an array of URL prefixes for which prerendering should not run. ///