From 3fd1b4033aa52f1a43f080acbbc52b5d2ea0d0d6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Oct 2017 15:38:32 +0100 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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 @@ - - - + + + + +