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/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/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/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs new file mode 100644 index 00000000..61f7b27f --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -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. + +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; + +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 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 npmScript) + { + _npmScriptName = npmScript; + } + + /// + public Task Build(IApplicationBuilder app) + { + var spaOptions = DefaultSpaOptions.FindInPipeline(app); + if (spaOptions == null) + { + throw new InvalidOperationException($"{nameof(AngularCliBuilder)} can only be used in an application configured with {nameof(SpaApplicationBuilderExtensions.UseSpa)}()."); + } + + if (string.IsNullOrEmpty(spaOptions.SourcePath)) + { + throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + 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); + + 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 new file mode 100644 index 00000000..41d825ef --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -0,0 +1,131 @@ +// 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; +using System.Threading.Tasks; +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.IO; +using Microsoft.AspNetCore.NodeServices.Util; + +namespace Microsoft.AspNetCore.SpaServices.AngularCli +{ + internal static class AngularCliMiddleware + { + private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; + private const int TimeoutMilliseconds = 50 * 1000; + + public static void Attach( + IApplicationBuilder appBuilder, + string sourcePath, + string npmScriptName) + { + if (string.IsNullOrEmpty(sourcePath)) + { + throw new ArgumentException("Cannot be null or empty", nameof(sourcePath)); + } + + if (string.IsNullOrEmpty(npmScriptName)) + { + throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName)); + } + + // Start Angular CLI and attach to middleware pipeline + 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 + // 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 targetUriTask = angularCliServerInfoTask.ContinueWith( + task => new UriBuilder("http", "localhost", task.Result.Port).Uri); + + SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(appBuilder, targetUriTask); + } + + 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 static async Task StartAngularCliServerAsync( + string sourcePath, string npmScriptName, ILogger logger) + { + var portNumber = FindAvailablePort(); + logger.LogInformation($"Starting @angular/cli on port {portNumber}..."); + + var npmScriptRunner = new NpmScriptRunner( + sourcePath, npmScriptName, $"--port {portNumber}"); + npmScriptRunner.AttachToLogger(logger); + + 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); + } + 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); + } + } + + 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 serverInfo; + } + + 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 + 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..1dcf23c9 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs @@ -0,0 +1,42 @@ +// 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 name of the script in your package.json file that launches the Angular CLI process. + public static void UseAngularCliServer( + this IApplicationBuilder app, + string npmScript) + { + var spaOptions = DefaultSpaOptions.FindInPipeline(app); + if (spaOptions == null) + { + throw new InvalidOperationException($"{nameof(UseAngularCliServer)} should be called inside the 'configure' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + 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)}."); + } + + AngularCliMiddleware.Attach(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/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj new file mode 100644 index 00000000..7c937f72 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj @@ -0,0 +1,16 @@ + + + + Helpers for building single-page applications on ASP.NET MVC Core. + 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..b520c2bf --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs @@ -0,0 +1,141 @@ +// 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; +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 delegate void OnStreamClosedHandler(); + + public event OnReceivedChunkHandler OnReceivedChunk; + public event OnReceivedLineHandler OnReceivedLine; + public event OnStreamClosedHandler OnStreamClosed; + + 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; + OnStreamClosedHandler onStreamClosedHandler = null; + + onReceivedLineHandler = line => + { + var match = regex.Match(line); + if (match.Success) + { + lock (completionLock) + { + 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) + { + var timeoutToken = new CancellationTokenSource(timeoutMilliseconds); + timeoutToken.Token.Register(() => + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnReceivedLine -= onReceivedLineHandler; + OnStreamClosed -= onStreamClosedHandler; + 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) + { + OnClosed(); + 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); + } + + 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; + } + } + } +} 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..51efbb2b --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs @@ -0,0 +1,103 @@ +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 AttachToLogger(ILogger logger) + { + // When the NPM task emits complete lines, pass them through to the real logger + StdOut.OnReceivedLine += line => + { + if (!string.IsNullOrWhiteSpace(line)) + { + logger.LogInformation(line); + } + }; + + StdErr.OnReceivedLine += line => + { + if (!string.IsNullOrWhiteSpace(line)) + { + 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) + { + 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); + } + } + } +} 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..c4766032 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs @@ -0,0 +1,203 @@ +// 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.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 +{ + /// + /// 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. + /// Optional. If specified, requests within these URL paths will bypass the prerenderer. + /// Optional. If specified, this callback will be invoked during prerendering, allowing you to pass additional data to the prerendering entrypoint code. + public static void UseSpaPrerendering( + this IApplicationBuilder applicationBuilder, + string entryPoint, + ISpaPrerendererBuilder buildOnDemand = null, + string[] excludeUrls = null, + Action> supplyData = null) + { + if (string.IsNullOrEmpty(entryPoint)) + { + throw new ArgumentException("Cannot be null or empty", nameof(entryPoint)); + } + + // If we're building on demand, start that process now + var buildOnDemandTask = buildOnDemand?.Build(applicationBuilder); + + // Get all the necessary context info that will be used for each prerendering call + var serviceProvider = applicationBuilder.ApplicationServices; + var nodeServices = GetNodeServices(serviceProvider); + var applicationStoppingToken = serviceProvider.GetRequiredService() + .ApplicationStopping; + var applicationBasePath = serviceProvider.GetRequiredService() + .ContentRootPath; + var moduleExport = new JavaScriptModuleExport(entryPoint); + 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. + applicationBuilder.Use(async (context, next) => + { + // If this URL is excluded, skip prerendering + foreach (var excludePathString in excludePathStrings) + { + if (context.Request.Path.StartsWithSegments(excludePathString)) + { + await next(); + return; + } + } + + // If we're building on demand, do that first + 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); + + using (var outputBuffer = new MemoryStream()) + { + var originalResponseStream = context.Response.Body; + context.Response.Body = outputBuffer; + + try + { + await next(); + outputBuffer.Seek(0, SeekOrigin.Begin); + } + finally + { + context.Response.Body = originalResponseStream; + } + + // 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) + { + 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 + // 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()) } + }; + + supplyData?.Invoke(context, customData); + + 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. + // 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) + { + context.Response.Clear(); + + 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/ConditionalProxyMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxyMiddleware.cs new file mode 100644 index 00000000..96f62ce7 --- /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 _baseUriTask; + 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 baseUriTask, + IApplicationLifetime applicationLifetime) + { + if (!pathPrefix.StartsWith("/")) + { + pathPrefix = "/" + pathPrefix; + } + + _next = next; + _pathPrefix = pathPrefix; + _pathPrefixIsRoot = string.Equals(_pathPrefix, "/", StringComparison.Ordinal); + _baseUriTask = baseUriTask; + _httpClient = SpaProxy.CreateHttpClientForProxy(requestTimeout); + _applicationStoppingToken = applicationLifetime.ApplicationStopping; + } + + public async Task Invoke(HttpContext context) + { + if (context.Request.Path.StartsWithSegments(_pathPrefix) || _pathPrefixIsRoot) + { + var didProxyRequest = await SpaProxy.PerformProxyRequest( + context, _httpClient, _baseUriTask, _applicationStoppingToken, proxy404s: false); + if (didProxyRequest) + { + return; + } + } + + // Not a request we can proxy + await _next.Invoke(context); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs new file mode 100644 index 00000000..b5e97c20 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs @@ -0,0 +1,295 @@ +// 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, + // 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; + + 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 baseUriTask, + CancellationToken applicationStoppingToken, + bool proxy404s) + { + // 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 baseUri = await baseUriTask; + var targetUri = new Uri( + baseUri, + context.Request.Path + context.Request.QueryString); + + 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)) + { + 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; + } + } + } + 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; + } + 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) + { + 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) + { + 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); + } + } + + 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) + { + // 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(100); + } + + var result = resultTask.Result; // We know it's completed already + 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/SpaProxyingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs new file mode 100644 index 00000000..12981483 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs @@ -0,0 +1,70 @@ +// 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 = + SpaProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); + + // Proxy all requests into the Angular CLI server + applicationBuilder.Use(async (context, next) => + { + var didProxyRequest = await SpaProxy.PerformProxyRequest( + context, neverTimeOutHttpClient, baseUriTask, applicationStoppingToken, + proxy404s: true); + }); + } + + private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder) + { + var applicationLifetime = appBuilder + .ApplicationServices + .GetService(typeof(IApplicationLifetime)); + return ((IApplicationLifetime)applicationLifetime).ApplicationStopping; + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs new file mode 100644 index 00000000..758e744a --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.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.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 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. + /// 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 sourcePath = null, + string defaultPage = null, + Action configure = null) + { + 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 new file mode 100644 index 00000000..a549a54e --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs @@ -0,0 +1,66 @@ +// 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; + +namespace Microsoft.AspNetCore.SpaServices +{ + internal class SpaDefaultPageMiddleware + { + public static void Attach(IApplicationBuilder app, ISpaOptions spaOptions) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (spaOptions == null) + { + throw new ArgumentNullException(nameof(spaOptions)); + } + + var defaultPageUrl = ConstructDefaultPageUrl(spaOptions.UrlPrefix, spaOptions.DefaultPage); + + // 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)); + } + } +} 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 - -