diff --git a/JavaScriptServices.sln b/JavaScriptServices.sln
index 1c05a05..f01aa1a 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/build/dependencies.props b/build/dependencies.props
index 8e679ad..8a5dab0 100644
--- a/build/dependencies.props
+++ b/build/dependencies.props
@@ -13,6 +13,7 @@
2.1.0-preview1-27478
2.1.0-preview1-27478
2.1.0-preview1-27478
+ 2.1.0-preview1-27478
2.1.0-preview1-27478
2.1.0-preview1-27478
2.1.0-preview1-27478
diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs
new file mode 100644
index 0000000..30dc206
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs
@@ -0,0 +1,73 @@
+// 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 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 static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
+ private static TimeSpan BuildTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter
+
+ private readonly string _npmScriptName;
+
+ ///
+ /// 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)
+ {
+ if (string.IsNullOrEmpty(npmScript))
+ {
+ throw new ArgumentException("Cannot be null or empty.", nameof(npmScript));
+ }
+
+ _npmScriptName = npmScript;
+ }
+
+ ///
+ public Task Build(ISpaBuilder spaBuilder)
+ {
+ var sourcePath = spaBuilder.Options.SourcePath;
+ if (string.IsNullOrEmpty(sourcePath))
+ {
+ throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
+ }
+
+ var logger = AngularCliMiddleware.GetOrCreateLogger(spaBuilder.ApplicationBuilder);
+ 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", RegexOptions.None, RegexMatchTimeout),
+ BuildTimeout);
+ }
+ 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 0000000..349deb6
--- /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 static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
+ private static TimeSpan StartupTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter
+
+ public static void Attach(
+ ISpaBuilder spaBuilder,
+ string npmScriptName)
+ {
+ var sourcePath = spaBuilder.Options.SourcePath;
+ 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 appBuilder = spaBuilder.ApplicationBuilder;
+ 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(spaBuilder, 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+)", RegexOptions.None, RegexMatchTimeout),
+ StartupTimeout);
+ }
+ 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 {StartupTimeout.Seconds} 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();
+ }
+ }
+
+ class AngularCliServerInfo
+ {
+ public int Port { get; set; }
+ }
+ }
+}
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 0000000..28e63c8
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs
@@ -0,0 +1,43 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.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 ISpaBuilder spaBuilder,
+ string npmScript)
+ {
+ if (spaBuilder == null)
+ {
+ throw new ArgumentNullException(nameof(spaBuilder));
+ }
+
+ var spaOptions = spaBuilder.Options;
+
+ if (string.IsNullOrEmpty(spaOptions.SourcePath))
+ {
+ throw new InvalidOperationException($"To use {nameof(UseAngularCliServer)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
+ }
+
+ AngularCliMiddleware.Attach(spaBuilder, npmScript);
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs
new file mode 100644
index 0000000..b1517a7
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/DefaultSpaBuilder.cs
@@ -0,0 +1,24 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Builder;
+using System;
+
+namespace Microsoft.AspNetCore.SpaServices
+{
+ internal class DefaultSpaBuilder : ISpaBuilder
+ {
+ public IApplicationBuilder ApplicationBuilder { get; }
+
+ public SpaOptions Options { get; }
+
+ public DefaultSpaBuilder(IApplicationBuilder applicationBuilder, SpaOptions options)
+ {
+ ApplicationBuilder = applicationBuilder
+ ?? throw new ArgumentNullException(nameof(applicationBuilder));
+
+ Options = options
+ ?? throw new ArgumentNullException(nameof(options));
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaBuilder.cs
new file mode 100644
index 0000000..4911792
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaBuilder.cs
@@ -0,0 +1,25 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Builder;
+
+namespace Microsoft.AspNetCore.SpaServices
+{
+ ///
+ /// Defines a class that provides mechanisms for configuring the hosting
+ /// of a Single Page Application (SPA) and attaching middleware.
+ ///
+ public interface ISpaBuilder
+ {
+ ///
+ /// The representing the middleware pipeline
+ /// in which the SPA is being hosted.
+ ///
+ IApplicationBuilder ApplicationBuilder { get; }
+
+ ///
+ /// Describes configuration options for hosting a SPA.
+ ///
+ SpaOptions Options { get; }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj
new file mode 100644
index 0000000..0388b51
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj
@@ -0,0 +1,17 @@
+
+
+
+ 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 0000000..64a4f8c
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs
@@ -0,0 +1,134 @@
+// 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
+{
+ ///
+ /// Wraps a to expose an evented API, issuing notifications
+ /// when the stream emits partial lines, completed lines, or finally closes.
+ ///
+ internal class EventedStreamReader
+ {
+ public delegate void OnReceivedChunkHandler(ArraySegment chunk);
+ public delegate void OnReceivedLineHandler(string line);
+ 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, TimeSpan timeout = default)
+ {
+ var tcs = new TaskCompletionSource();
+ var completionLock = new object();
+
+ OnReceivedLineHandler onReceivedLineHandler = null;
+ OnStreamClosedHandler onStreamClosedHandler = null;
+
+ void ResolveIfStillPending(Action applyResolution)
+ {
+ lock (completionLock)
+ {
+ if (!tcs.Task.IsCompleted)
+ {
+ OnReceivedLine -= onReceivedLineHandler;
+ OnStreamClosed -= onStreamClosedHandler;
+ applyResolution();
+ }
+ }
+ }
+
+ onReceivedLineHandler = line =>
+ {
+ var match = regex.Match(line);
+ if (match.Success)
+ {
+ ResolveIfStillPending(() => tcs.SetResult(match));
+ }
+ };
+
+ onStreamClosedHandler = () =>
+ {
+ ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException()));
+ };
+
+ OnReceivedLine += onReceivedLineHandler;
+ OnStreamClosed += onStreamClosedHandler;
+
+ if (timeout != default)
+ {
+ var timeoutToken = new CancellationTokenSource(timeout);
+ timeoutToken.Token.Register(() =>
+ {
+ ResolveIfStillPending(() => 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 0000000..efe7c72
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs
@@ -0,0 +1,39 @@
+// 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
+{
+ ///
+ /// Captures the completed-line notifications from a ,
+ /// combining the data into a single .
+ ///
+ internal 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 0000000..dfb8852
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs
@@ -0,0 +1,121 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.NodeServices.Util;
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+
+// This is under the NodeServices namespace because post 2.1 it will be moved to that package
+namespace Microsoft.AspNetCore.NodeServices.Npm
+{
+ ///
+ /// Executes the script entries defined in a package.json file,
+ /// capturing any output written to stdio.
+ ///
+ internal class NpmScriptRunner
+ {
+ public EventedStreamReader StdOut { get; }
+ public EventedStreamReader StdErr { get; }
+
+ private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1));
+
+ public NpmScriptRunner(string workingDirectory, string scriptName, string arguments)
+ {
+ if (string.IsNullOrEmpty(workingDirectory))
+ {
+ 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))
+ {
+ // On Windows, the NPM executable is a .cmd file, so it can't be executed
+ // directly (except with UseShellExecute=true, but that's no good, because
+ // it prevents capturing stdio). So we need to invoke it via "cmd /c".
+ npmExe = "cmd";
+ completeArguments = $"/c npm {completeArguments}";
+ }
+
+ 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))
+ {
+ // NPM tasks commonly emit ANSI colors, but it wouldn't make sense to forward
+ // those to loggers (because a logger isn't necessarily any kind of terminal)
+ logger.LogInformation(StripAnsiColors(line));
+ }
+ };
+
+ StdErr.OnReceivedLine += line =>
+ {
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ logger.LogError(StripAnsiColors(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 string StripAnsiColors(string line)
+ => AnsiColorRegex.Replace(line, string.Empty);
+
+ 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 0000000..9c3171d
--- /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 (SPA) on demand
+ /// so that it can be prerendered. This is only intended to be used at development
+ /// time. In production, a SPA should already have been built during publishing.
+ ///
+ public interface ISpaPrerendererBuilder
+ {
+ ///
+ /// Builds the Single Page Application so that a JavaScript entrypoint file
+ /// exists on disk. Prerendering middleware can then execute that file in
+ /// a Node environment.
+ ///
+ /// The .
+ /// A representing completion of the build process.
+ Task Build(ISpaBuilder spaBuilder);
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs
new file mode 100644
index 0000000..286aa65
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs
@@ -0,0 +1,219 @@
+// 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.Features;
+using Microsoft.AspNetCore.NodeServices;
+using Microsoft.AspNetCore.SpaServices;
+using Microsoft.AspNetCore.SpaServices.Prerendering;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Net.Http.Headers;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.Builder
+{
+ ///
+ /// 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 .
+ /// Supplies configuration for the prerendering middleware.
+ public static void UseSpaPrerendering(
+ this ISpaBuilder spaBuilder,
+ Action configuration)
+ {
+ if (spaBuilder == null)
+ {
+ throw new ArgumentNullException(nameof(spaBuilder));
+ }
+
+ if (configuration == null)
+ {
+ throw new ArgumentNullException(nameof(configuration));
+ }
+
+ var options = new SpaPrerenderingOptions();
+ configuration.Invoke(options);
+
+ var capturedBootModulePath = options.BootModulePath;
+ if (string.IsNullOrEmpty(capturedBootModulePath))
+ {
+ throw new InvalidOperationException($"To use {nameof(UseSpaPrerendering)}, you " +
+ $"must set a nonempty value on the ${nameof(SpaPrerenderingOptions.BootModulePath)} " +
+ $"property on the ${nameof(SpaPrerenderingOptions)}.");
+ }
+
+ // If we're building on demand, start that process in the background now
+ var buildOnDemandTask = options.BuildOnDemand?.Build(spaBuilder);
+
+ // Get all the necessary context info that will be used for each prerendering call
+ var applicationBuilder = spaBuilder.ApplicationBuilder;
+ var serviceProvider = applicationBuilder.ApplicationServices;
+ var nodeServices = GetNodeServices(serviceProvider);
+ var applicationStoppingToken = serviceProvider.GetRequiredService()
+ .ApplicationStopping;
+ var applicationBasePath = serviceProvider.GetRequiredService()
+ .ContentRootPath;
+ var moduleExport = new JavaScriptModuleExport(capturedBootModulePath);
+ var excludePathStrings = (options.ExcludeUrls ?? Array.Empty())
+ .Select(url => new PathString(url))
+ .ToArray();
+
+ applicationBuilder.Use(async (context, next) =>
+ {
+ // If this URL is excluded, skip prerendering.
+ // This is typically used to ensure that static client-side resources
+ // (e.g., /dist/*.css) are served normally or through SPA development
+ // middleware, and don't return the prerendered index.html page.
+ foreach (var excludePathString in excludePathStrings)
+ {
+ if (context.Request.Path.StartsWithSegments(excludePathString))
+ {
+ await next();
+ return;
+ }
+ }
+
+ // If we're building on demand, wait for that to finish, or raise any build errors
+ 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);
+
+ // Capture the non-prerendered responses, which in production will typically only
+ // be returning the default SPA index.html page (because other resources will be
+ // served statically from disk). We will use this as a template in which to inject
+ // the prerendered output.
+ using (var outputBuffer = new MemoryStream())
+ {
+ var originalResponseStream = context.Response.Body;
+ 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()) }
+ };
+
+ // If the developer wants to use custom logic to pass arbitrary data to the
+ // prerendering JS code (e.g., to pass through cookie data), now's their chance
+ options.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 ServePrerenderResult(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 ServePrerenderResult(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 InvalidOperationException($"{nameof(renderResult.Globals)} is not " +
+ $"supported when prerendering via {nameof(UseSpaPrerendering)}(). Instead, " +
+ $"your prerendering logic should return a complete HTML page, in which you " +
+ $"embed any information you wish to return to the client.");
+ }
+
+ context.Response.ContentType = "text/html";
+ await context.Response.WriteAsync(renderResult.Html);
+ }
+ }
+
+ 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/Prerendering/SpaPrerenderingOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs
new file mode 100644
index 0000000..bf06e6a
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingOptions.cs
@@ -0,0 +1,43 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.SpaServices.Prerendering;
+using System;
+using System.Collections.Generic;
+
+namespace Microsoft.AspNetCore.Builder
+{
+ ///
+ /// Represents options for the SPA prerendering middleware.
+ ///
+ public class SpaPrerenderingOptions
+ {
+ ///
+ /// Gets or sets an that the prerenderer will invoke before
+ /// looking for the boot module file.
+ ///
+ /// This is only intended to be used during development as a way of generating the JavaScript boot
+ /// file automatically when the application runs. This property should be left as null in
+ /// production applications.
+ ///
+ public ISpaPrerendererBuilder BuildOnDemand { get; set; }
+
+ ///
+ /// Gets or sets the path, relative to your application root, of the JavaScript file
+ /// containing prerendering logic.
+ ///
+ public string BootModulePath { get; set; }
+
+ ///
+ /// Gets or sets an array of URL prefixes for which prerendering should not run.
+ ///
+ public string[] ExcludeUrls { get; set; }
+
+ ///
+ /// Gets or sets a callback that will be invoked during prerendering, allowing you to pass additional
+ /// data to the prerendering entrypoint code.
+ ///
+ public Action> SupplyData { get; set; }
+ }
+}
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 0000000..96f62ce
--- /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 0000000..548ac6f
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxy.cs
@@ -0,0 +1,288 @@
+// 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 httpClient.SendAsync(
+ requestMessage,
+ HttpCompletionOption.ResponseHeadersRead,
+ 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 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));
+ }
+
+ 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,
+ // on Windows, by which time the logic in SockJS has already timed out and made
+ // it fall back on some other transport (xhr_streaming, usually). It's fine
+ // on Linux though, completing almost instantly.
+ //
+ // The slowness on Windows does not cause a problem though, because the transport
+ // fallback logic works correctly and doesn't surface any errors, but it would be
+ // better if ConnectAsync was fast enough and the initial Websocket transport
+ // could actually be used.
+ await client.ConnectAsync(destinationUri, cancellationToken);
+ }
+ catch (WebSocketException)
+ {
+ 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 0000000..066e581
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/SpaProxyingExtensions.cs
@@ -0,0 +1,92 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.SpaServices;
+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 ISpaBuilder spaBuilder,
+ string baseUri)
+ {
+ UseProxyToSpaDevelopmentServer(
+ spaBuilder,
+ new Uri(baseUri));
+ }
+
+ ///
+ /// Configures the application to forward incoming requests to a local Single Page
+ /// Application (SPA) development server. This is only intended to be used during
+ /// development. Do not enable this middleware in production applications.
+ ///
+ /// The .
+ /// The target base URI to which requests should be proxied.
+ public static void UseProxyToSpaDevelopmentServer(
+ this ISpaBuilder spaBuilder,
+ Uri baseUri)
+ {
+ UseProxyToSpaDevelopmentServer(
+ spaBuilder,
+ 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 ISpaBuilder spaBuilder,
+ Task baseUriTask)
+ {
+ var applicationBuilder = spaBuilder.ApplicationBuilder;
+ var applicationStoppingToken = GetStoppingToken(applicationBuilder);
+
+ // Since we might want to proxy WebSockets requests (e.g., by default, AngularCliMiddleware
+ // requires it), enable it for the app
+ applicationBuilder.UseWebSockets();
+
+ // It's important not to time out the requests, as some of them might be to
+ // server-sent event endpoints or similar, where it's expected that the response
+ // takes an unlimited time and never actually completes
+ 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 0000000..f127e35
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs
@@ -0,0 +1,46 @@
+// 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 Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+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 .
+ ///
+ /// 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, Action configuration)
+ {
+ if (configuration == null)
+ {
+ throw new ArgumentNullException(nameof(configuration));
+ }
+
+ // Use the options configured in DI (or blank if none was configured). We have to clone it
+ // otherwise if you have multiple UseSpa calls, their configurations would interfere with one another.
+ var optionsProvider = app.ApplicationServices.GetService>();
+ var options = new SpaOptions(optionsProvider.Value);
+
+ var spaBuilder = new DefaultSpaBuilder(app, options);
+ configuration.Invoke(spaBuilder);
+ SpaDefaultPageMiddleware.Attach(spaBuilder);
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs
new file mode 100644
index 0000000..ed2ecb6
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaDefaultPageMiddleware.cs
@@ -0,0 +1,58 @@
+// 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 System;
+
+namespace Microsoft.AspNetCore.SpaServices
+{
+ internal class SpaDefaultPageMiddleware
+ {
+ public static void Attach(ISpaBuilder spaBuilder)
+ {
+ if (spaBuilder == null)
+ {
+ throw new ArgumentNullException(nameof(spaBuilder));
+ }
+
+ var app = spaBuilder.ApplicationBuilder;
+ var options = spaBuilder.Options;
+
+ // Rewrite all requests to the default page
+ app.Use((context, next) =>
+ {
+ context.Request.Path = options.DefaultPage;
+ return next();
+ });
+
+ // Serve it as file from wwwroot (by default), or any other configured file provider
+ app.UseStaticFiles(new StaticFileOptions
+ {
+ FileProvider = options.DefaultPageFileProvider
+ });
+
+ // If the default file didn't get served as a static file (usually because it was not
+ // present on disk), the SPA is definitely not going to work.
+ app.Use((context, next) =>
+ {
+ var message = "The SPA default page middleware could not return the default page " +
+ $"'{options.DefaultPage}' because it was not found, and no other middleware " +
+ "handled the request.\n";
+
+ // Try to clarify the common scenario where someone runs an application in
+ // 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 InvalidOperationException(message);
+ });
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs
new file mode 100644
index 0000000..65912e5
--- /dev/null
+++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/SpaOptions.cs
@@ -0,0 +1,71 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.FileProviders;
+using System;
+
+namespace Microsoft.AspNetCore.SpaServices
+{
+ ///
+ /// Describes options for hosting a Single Page Application (SPA).
+ ///
+ public class SpaOptions
+ {
+ private PathString _defaultPage = "/index.html";
+
+ ///
+ /// Constructs a new instance of .
+ ///
+ public SpaOptions()
+ {
+ }
+
+ ///
+ /// Constructs a new instance of .
+ ///
+ /// An instance of from which values should be copied.
+ internal SpaOptions(SpaOptions copyFromOptions)
+ {
+ _defaultPage = copyFromOptions.DefaultPage;
+ DefaultPageFileProvider = copyFromOptions.DefaultPageFileProvider;
+ SourcePath = copyFromOptions.SourcePath;
+ }
+
+ ///
+ /// Gets or sets the URL of the default page that hosts your SPA user interface.
+ /// The default value is "/index.html".
+ ///
+ public PathString DefaultPage
+ {
+ get => _defaultPage;
+ set
+ {
+ if (string.IsNullOrEmpty(value.Value))
+ {
+ throw new ArgumentNullException($"The value for {nameof(DefaultPage)} cannot be null or empty.");
+ }
+
+ _defaultPage = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the that supplies content
+ /// for serving the SPA's default page.
+ ///
+ /// If not set, a default file provider will read files from the
+ /// , which by default is
+ /// the wwwroot directory.
+ ///
+ public IFileProvider DefaultPageFileProvider { get; set; }
+
+ ///
+ /// Gets or sets the path, relative to the application working directory,
+ /// of the directory that contains the SPA source files during
+ /// development. The directory may not exist in published applications.
+ ///
+ public string SourcePath { get; set; }
+ }
+}