From 3fd1b4033aa52f1a43f080acbbc52b5d2ea0d0d6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Oct 2017 15:38:32 +0100 Subject: [PATCH 01/17] 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 105422ba6e32aa711584a2b8b1f91a8e5f29e29e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Oct 2017 15:49:17 +0100 Subject: [PATCH 02/17] 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 ae7ae656289bf654ff8c0c085a55eb992ff4aa21 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Oct 2017 20:12:23 +0100 Subject: [PATCH 03/17] 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 b2c1062dd19012172c7ab124cde85d2b0e38c576 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Oct 2017 15:56:08 +0100 Subject: [PATCH 04/17] 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 7e92413bf28c192014d6ace9a8bb3c629b994ab8 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Oct 2017 15:57:25 +0100 Subject: [PATCH 05/17] 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 e3d1f07105e6dce7e8f99c928e80119ccb0fec78 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Oct 2017 16:06:14 +0100 Subject: [PATCH 06/17] 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 de3fd3089966f9695321d1794a9b867fbfccc07d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 26 Oct 2017 12:06:39 +0100 Subject: [PATCH 07/17] 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 fbe41f4ddb2d912f0e07da18aa5307246caa54e8 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 26 Oct 2017 12:47:54 +0100 Subject: [PATCH 08/17] 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 55f4bd29de44e7cbb44732fbd36aacd2d29b219a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 26 Oct 2017 13:00:53 +0100 Subject: [PATCH 09/17] 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 892d3c04202c67fc06fe89ff3580f6e99612b55b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 26 Oct 2017 16:20:18 +0100 Subject: [PATCH 10/17] 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 54ac22283281cfe2eaa57845c026472f0d863527 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Oct 2017 00:02:46 +0100 Subject: [PATCH 11/17] 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 be0d6306646fa37ee981ed8105944e31243d2883 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 12:06:37 +0000 Subject: [PATCH 12/17] 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 8e8a1ec11fd6770696e7249c18635194321544e4 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 13:27:04 +0000 Subject: [PATCH 13/17] 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 3aa1451ae689007f7f8f7b076bef21b51334a733 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 14:24:08 +0000 Subject: [PATCH 14/17] 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 1f35e545da40cfb957b0b9ab15b97ccf3729e5ba Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 14:35:04 +0000 Subject: [PATCH 15/17] 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 9d5ac48a97e654856c5f653a6ff67c7ca7d17768 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Oct 2017 14:37:15 +0000 Subject: [PATCH 16/17] 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 413d68bd2ce101e998a91da01dde32cb961d6a47 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Tue, 31 Oct 2017 14:57:40 -0700 Subject: [PATCH 17/17] Pin tool and package versions to make builds more repeatable --- .gitignore | 4 ---- Directory.Build.props | 6 ++--- Directory.Build.targets | 17 ++++--------- NuGet.config | 1 + build/dependencies.props | 24 +++++++++++++++++++ build/repo.props | 9 +++---- korebuild-lock.txt | 2 ++ korebuild.json | 4 ++++ samples/misc/LatencyTest/LatencyTest.csproj | 2 +- .../NodeServicesExamples.csproj | 14 +++++------ samples/misc/Webpack/Webpack.csproj | 14 +++++------ src/Directory.Build.props | 4 ++-- ...oft.AspNetCore.NodeServices.Sockets.csproj | 2 +- .../Microsoft.AspNetCore.NodeServices.csproj | 6 ++--- ...t.AspNetCore.SpaServices.Extensions.csproj | 2 +- .../Microsoft.AspNetCore.SpaServices.csproj | 4 ++-- version.props | 10 ++++++++ version.xml | 8 ------- 18 files changed, 77 insertions(+), 56 deletions(-) create mode 100644 build/dependencies.props create mode 100644 korebuild-lock.txt create mode 100644 korebuild.json create mode 100644 version.props delete mode 100644 version.xml diff --git a/.gitignore b/.gitignore index 3f3bbd7b..79812ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,12 +24,8 @@ nuget.exe *.ncrunchsolution *.*sdf *.ipch - .vs/ npm-debug.log /.build/ - .vscode/ - global.json -korebuild-lock.txt diff --git a/Directory.Build.props b/Directory.Build.props index 90441f78..4bade682 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,6 @@ - - + + + Microsoft ASP.NET Core @@ -9,7 +10,6 @@ $(MSBuildThisFileDirectory)build\Key.snk true true - $(VersionSuffix)-$(BuildNumber) true diff --git a/Directory.Build.targets b/Directory.Build.targets index bc118fd9..e83ff95e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,14 +1,5 @@ - - - - <_BootstrapperFile Condition=" $([MSBuild]::IsOSUnixLike()) ">build.sh - <_BootstrapperFile Condition="! $([MSBuild]::IsOSUnixLike()) ">build.cmd - <_BootstrapperError> - Package references have not been pinned. Run './$(_BootstrapperFile) /t:Pin'. - Also, you can run './$(_BootstrapperFile) /t:Restore' which will pin *and* restore packages. '$(_BootstrapperFile)' can be found in '$(MSBuildThisFileDirectory)'. - - - - - + + + $(MicrosoftNETCoreApp20PackageVersion) + diff --git a/NuGet.config b/NuGet.config index 20060c93..4e8a1f6d 100644 --- a/NuGet.config +++ b/NuGet.config @@ -3,6 +3,7 @@ + diff --git a/build/dependencies.props b/build/dependencies.props new file mode 100644 index 00000000..8e679ad2 --- /dev/null +++ b/build/dependencies.props @@ -0,0 +1,24 @@ + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + + + 2.1.0-preview1-15549 + 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 + 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.0.0 + 10.0.1 + 4.8.0 + + + diff --git a/build/repo.props b/build/repo.props index 13fe1c29..b55e651b 100644 --- a/build/repo.props +++ b/build/repo.props @@ -1,6 +1,7 @@  - - - - + + + Internal.AspNetCore.Universe.Lineup + https://dotnet.myget.org/F/aspnetcore-ci-dev/api/v3/index.json + diff --git a/korebuild-lock.txt b/korebuild-lock.txt new file mode 100644 index 00000000..45463cc7 --- /dev/null +++ b/korebuild-lock.txt @@ -0,0 +1,2 @@ +version:2.1.0-preview1-15549 +commithash:f570e08585fec510dd60cd4bfe8795388b757a95 diff --git a/korebuild.json b/korebuild.json new file mode 100644 index 00000000..bd5d51a5 --- /dev/null +++ b/korebuild.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://raw.githubusercontent.com/aspnet/BuildTools/dev/tools/korebuild.schema.json", + "channel": "dev" +} diff --git a/samples/misc/LatencyTest/LatencyTest.csproj b/samples/misc/LatencyTest/LatencyTest.csproj index 8ad37df1..af95fb2f 100644 --- a/samples/misc/LatencyTest/LatencyTest.csproj +++ b/samples/misc/LatencyTest/LatencyTest.csproj @@ -12,7 +12,7 @@ - + diff --git a/samples/misc/NodeServicesExamples/NodeServicesExamples.csproj b/samples/misc/NodeServicesExamples/NodeServicesExamples.csproj index d85540c6..62569166 100644 --- a/samples/misc/NodeServicesExamples/NodeServicesExamples.csproj +++ b/samples/misc/NodeServicesExamples/NodeServicesExamples.csproj @@ -11,13 +11,13 @@ - - - - - - - + + + + + + + diff --git a/samples/misc/Webpack/Webpack.csproj b/samples/misc/Webpack/Webpack.csproj index d85540c6..62569166 100644 --- a/samples/misc/Webpack/Webpack.csproj +++ b/samples/misc/Webpack/Webpack.csproj @@ -11,13 +11,13 @@ - - - - - - - + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 55417710..272b8171 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,4 +1,4 @@ - + @@ -8,6 +8,6 @@ - + diff --git a/src/Microsoft.AspNetCore.NodeServices.Sockets/Microsoft.AspNetCore.NodeServices.Sockets.csproj b/src/Microsoft.AspNetCore.NodeServices.Sockets/Microsoft.AspNetCore.NodeServices.Sockets.csproj index 535a9d78..9dab54a9 100644 --- a/src/Microsoft.AspNetCore.NodeServices.Sockets/Microsoft.AspNetCore.NodeServices.Sockets.csproj +++ b/src/Microsoft.AspNetCore.NodeServices.Sockets/Microsoft.AspNetCore.NodeServices.Sockets.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Microsoft.AspNetCore.NodeServices/Microsoft.AspNetCore.NodeServices.csproj b/src/Microsoft.AspNetCore.NodeServices/Microsoft.AspNetCore.NodeServices.csproj index 28b9ab33..6d64797f 100644 --- a/src/Microsoft.AspNetCore.NodeServices/Microsoft.AspNetCore.NodeServices.csproj +++ b/src/Microsoft.AspNetCore.NodeServices/Microsoft.AspNetCore.NodeServices.csproj @@ -11,9 +11,9 @@ - - - + + + 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 @@ - + diff --git a/src/Microsoft.AspNetCore.SpaServices/Microsoft.AspNetCore.SpaServices.csproj b/src/Microsoft.AspNetCore.SpaServices/Microsoft.AspNetCore.SpaServices.csproj index 5c856e99..15cf8129 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Microsoft.AspNetCore.SpaServices.csproj +++ b/src/Microsoft.AspNetCore.SpaServices/Microsoft.AspNetCore.SpaServices.csproj @@ -15,8 +15,8 @@ - - + + diff --git a/version.props b/version.props new file mode 100644 index 00000000..5c4a7c32 --- /dev/null +++ b/version.props @@ -0,0 +1,10 @@ + + + 2.1.0 + preview1 + $(VersionPrefix) + $(VersionPrefix)-$(VersionSuffix)-final + t000 + $(VersionSuffix)-$(BuildNumber) + + diff --git a/version.xml b/version.xml deleted file mode 100644 index 3c05022b..00000000 --- a/version.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - dev - 2.1.0 - preview1 - -