Add new Microsoft.AspNetCore.SpaServices.Extensions package to host new runtime functionality needed for updated templates until 2.1 ships
This commit is contained in:
Родитель
7bf5516bb2
Коммит
c8b337ebaa
|
@ -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}
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
<MicrosoftAspNetCoreServerIISIntegrationPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreServerIISIntegrationPackageVersion>
|
||||
<MicrosoftAspNetCoreServerKestrelPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreServerKestrelPackageVersion>
|
||||
<MicrosoftAspNetCoreStaticFilesPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreStaticFilesPackageVersion>
|
||||
<MicrosoftAspNetCoreWebSocketsPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreWebSocketsPackageVersion>
|
||||
<MicrosoftExtensionsDependencyInjectionPackageVersion>2.1.0-preview1-27478</MicrosoftExtensionsDependencyInjectionPackageVersion>
|
||||
<MicrosoftExtensionsLoggingConsolePackageVersion>2.1.0-preview1-27478</MicrosoftExtensionsLoggingConsolePackageVersion>
|
||||
<MicrosoftExtensionsLoggingDebugPackageVersion>2.1.0-preview1-27478</MicrosoftExtensionsLoggingDebugPackageVersion>
|
||||
|
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides an implementation of <see cref="ISpaPrerendererBuilder"/> that can build
|
||||
/// an Angular application by invoking the Angular CLI.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs an instance of <see cref="AngularCliBuilder"/>.
|
||||
/// </summary>
|
||||
/// <param name="npmScript">The name of the script in your package.json file that builds the server-side bundle for your Angular application.</param>
|
||||
public AngularCliBuilder(string npmScript)
|
||||
{
|
||||
if (string.IsNullOrEmpty(npmScript))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or empty.", nameof(npmScript));
|
||||
}
|
||||
|
||||
_npmScriptName = npmScript;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<ILoggerFactory>();
|
||||
var logger = loggerFactory != null
|
||||
? loggerFactory.CreateLogger(LogCategoryName)
|
||||
: new ConsoleLogger(LogCategoryName, null, false);
|
||||
return logger;
|
||||
}
|
||||
|
||||
private static async Task<AngularCliServerInfo> 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; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for enabling Angular CLI middleware support.
|
||||
/// </summary>
|
||||
public static class AngularCliMiddlewareExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
|
||||
/// <param name="npmScript">The name of the script in your package.json file that launches the Angular CLI process.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a class that provides mechanisms for configuring the hosting
|
||||
/// of a Single Page Application (SPA) and attaching middleware.
|
||||
/// </summary>
|
||||
public interface ISpaBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="IApplicationBuilder"/> representing the middleware pipeline
|
||||
/// in which the SPA is being hosted.
|
||||
/// </summary>
|
||||
IApplicationBuilder ApplicationBuilder { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Describes configuration options for hosting a SPA.
|
||||
/// </summary>
|
||||
SpaOptions Options { get; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>Helpers for building single-page applications on ASP.NET MVC Core.</Description>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.AspNetCore.SpaServices\Microsoft.AspNetCore.SpaServices.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="$(MicrosoftAspNetCoreStaticFilesPackageVersion)" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="$(MicrosoftAspNetCoreWebSocketsPackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="StreamReader"/> to expose an evented API, issuing notifications
|
||||
/// when the stream emits partial lines, completed lines, or finally closes.
|
||||
/// </summary>
|
||||
internal class EventedStreamReader
|
||||
{
|
||||
public delegate void OnReceivedChunkHandler(ArraySegment<char> 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<Match> WaitForMatch(Regex regex, TimeSpan timeout = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<Match>();
|
||||
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<char>(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<char> 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures the completed-line notifications from a <see cref="EventedStreamReader"/>,
|
||||
/// combining the data into a single <see cref="string"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the <c>script</c> entries defined in a <c>package.json</c> file,
|
||||
/// capturing any output written to stdio.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface ISpaPrerendererBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
|
||||
/// <returns>A <see cref="Task"/> representing completion of the build process.</returns>
|
||||
Task Build(ISpaBuilder spaBuilder);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for configuring prerendering of a Single Page Application.
|
||||
/// </summary>
|
||||
public static class SpaPrerenderingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables server-side prerendering middleware for a Single Page Application.
|
||||
/// </summary>
|
||||
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
|
||||
/// <param name="configuration">Supplies configuration for the prerendering middleware.</param>
|
||||
public static void UseSpaPrerendering(
|
||||
this ISpaBuilder spaBuilder,
|
||||
Action<SpaPrerenderingOptions> 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<IApplicationLifetime>()
|
||||
.ApplicationStopping;
|
||||
var applicationBasePath = serviceProvider.GetRequiredService<IHostingEnvironment>()
|
||||
.ContentRootPath;
|
||||
var moduleExport = new JavaScriptModuleExport(capturedBootModulePath);
|
||||
var excludePathStrings = (options.ExcludeUrls ?? Array.Empty<string>())
|
||||
.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<string, object>
|
||||
{
|
||||
{ "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<IHttpRequestFeature>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents options for the SPA prerendering middleware.
|
||||
/// </summary>
|
||||
public class SpaPrerenderingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets an <see cref="ISpaPrerendererBuilder"/> 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 <c>null</c> in
|
||||
/// production applications.
|
||||
/// </summary>
|
||||
public ISpaPrerendererBuilder BuildOnDemand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path, relative to your application root, of the JavaScript file
|
||||
/// containing prerendering logic.
|
||||
/// </summary>
|
||||
public string BootModulePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an array of URL prefixes for which prerendering should not run.
|
||||
/// </summary>
|
||||
public string[] ExcludeUrls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a callback that will be invoked during prerendering, allowing you to pass additional
|
||||
/// data to the prerendering entrypoint code.
|
||||
/// </summary>
|
||||
public Action<HttpContext, IDictionary<string, object>> SupplyData { get; set; }
|
||||
}
|
||||
}
|
|
@ -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<Uri> _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<Uri> 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<bool> PerformProxyRequest(
|
||||
HttpContext context,
|
||||
HttpClient httpClient,
|
||||
Task<Uri> 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<bool> 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<byte>(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<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for proxying requests to a local SPA development server during
|
||||
/// development. Not for use in production applications.
|
||||
/// </summary>
|
||||
public static class SpaProxyingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
|
||||
/// <param name="baseUri">The target base URI to which requests should be proxied.</param>
|
||||
public static void UseProxyToSpaDevelopmentServer(
|
||||
this ISpaBuilder spaBuilder,
|
||||
string baseUri)
|
||||
{
|
||||
UseProxyToSpaDevelopmentServer(
|
||||
spaBuilder,
|
||||
new Uri(baseUri));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
|
||||
/// <param name="baseUri">The target base URI to which requests should be proxied.</param>
|
||||
public static void UseProxyToSpaDevelopmentServer(
|
||||
this ISpaBuilder spaBuilder,
|
||||
Uri baseUri)
|
||||
{
|
||||
UseProxyToSpaDevelopmentServer(
|
||||
spaBuilder,
|
||||
Task.FromResult(baseUri));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
|
||||
/// <param name="baseUriTask">A <see cref="Task"/> that resolves with the target base URI to which requests should be proxied.</param>
|
||||
public static void UseProxyToSpaDevelopmentServer(
|
||||
this ISpaBuilder spaBuilder,
|
||||
Task<Uri> 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods used for configuring an application to
|
||||
/// host a client-side Single Page Application (SPA).
|
||||
/// </summary>
|
||||
public static class SpaApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
|
||||
/// <param name="configuration">
|
||||
/// This callback will be invoked so that additional middleware can be registered within
|
||||
/// the context of this SPA.
|
||||
/// </param>
|
||||
public static void UseSpa(this IApplicationBuilder app, Action<ISpaBuilder> 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<IOptions<SpaOptions>>();
|
||||
var options = new SpaOptions(optionsProvider.Value);
|
||||
|
||||
var spaBuilder = new DefaultSpaBuilder(app, options);
|
||||
configuration.Invoke(spaBuilder);
|
||||
SpaDefaultPageMiddleware.Attach(spaBuilder);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Describes options for hosting a Single Page Application (SPA).
|
||||
/// </summary>
|
||||
public class SpaOptions
|
||||
{
|
||||
private PathString _defaultPage = "/index.html";
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new instance of <see cref="SpaOptions"/>.
|
||||
/// </summary>
|
||||
public SpaOptions()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new instance of <see cref="SpaOptions"/>.
|
||||
/// </summary>
|
||||
/// <param name="copyFromOptions">An instance of <see cref="SpaOptions"/> from which values should be copied.</param>
|
||||
internal SpaOptions(SpaOptions copyFromOptions)
|
||||
{
|
||||
_defaultPage = copyFromOptions.DefaultPage;
|
||||
DefaultPageFileProvider = copyFromOptions.DefaultPageFileProvider;
|
||||
SourcePath = copyFromOptions.SourcePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL of the default page that hosts your SPA user interface.
|
||||
/// The default value is <c>"/index.html"</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="IFileProvider"/> that supplies content
|
||||
/// for serving the SPA's default page.
|
||||
///
|
||||
/// If not set, a default file provider will read files from the
|
||||
/// <see cref="IHostingEnvironment.WebRootPath"/>, which by default is
|
||||
/// the <c>wwwroot</c> directory.
|
||||
/// </summary>
|
||||
public IFileProvider DefaultPageFileProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string SourcePath { get; set; }
|
||||
}
|
||||
}
|
Загрузка…
Ссылка в новой задаче