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
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio 15
|
# Visual Studio 15
|
||||||
VisualStudioVersion = 15.0.26730.0
|
VisualStudioVersion = 15.0.26730.16
|
||||||
MinimumVisualStudioVersion = 15.0.26730.03
|
MinimumVisualStudioVersion = 15.0.26730.03
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{27304DDE-AFB2-4F8B-B765-E3E2F11E886C}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{27304DDE-AFB2-4F8B-B765-E3E2F11E886C}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
@ -37,6 +37,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||||
Directory.Build.targets = Directory.Build.targets
|
Directory.Build.targets = Directory.Build.targets
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{93EFCC5F-C6EE-4623-894F-A42B22C0B6FE}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
@ -79,6 +85,7 @@ Global
|
||||||
{1931B19A-EC42-4D56-B2D0-FB06D17244DA} = {E6A161EA-646C-4033-9090-95BE809AB8D9}
|
{1931B19A-EC42-4D56-B2D0-FB06D17244DA} = {E6A161EA-646C-4033-9090-95BE809AB8D9}
|
||||||
{DE479DC3-1461-4EAD-A188-4AF7AA4AE344} = {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}
|
{93EFCC5F-C6EE-4623-894F-A42B22C0B6FE} = {E6A161EA-646C-4033-9090-95BE809AB8D9}
|
||||||
|
{D40BD1C4-6A6F-4213-8535-1057F3EB3400} = {27304DDE-AFB2-4F8B-B765-E3E2F11E886C}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {DDF59B0D-2DEC-45D6-8667-DCB767487101}
|
SolutionGuid = {DDF59B0D-2DEC-45D6-8667-DCB767487101}
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
<MicrosoftAspNetCoreServerIISIntegrationPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreServerIISIntegrationPackageVersion>
|
<MicrosoftAspNetCoreServerIISIntegrationPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreServerIISIntegrationPackageVersion>
|
||||||
<MicrosoftAspNetCoreServerKestrelPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreServerKestrelPackageVersion>
|
<MicrosoftAspNetCoreServerKestrelPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreServerKestrelPackageVersion>
|
||||||
<MicrosoftAspNetCoreStaticFilesPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreStaticFilesPackageVersion>
|
<MicrosoftAspNetCoreStaticFilesPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreStaticFilesPackageVersion>
|
||||||
|
<MicrosoftAspNetCoreWebSocketsPackageVersion>2.1.0-preview1-27478</MicrosoftAspNetCoreWebSocketsPackageVersion>
|
||||||
<MicrosoftExtensionsDependencyInjectionPackageVersion>2.1.0-preview1-27478</MicrosoftExtensionsDependencyInjectionPackageVersion>
|
<MicrosoftExtensionsDependencyInjectionPackageVersion>2.1.0-preview1-27478</MicrosoftExtensionsDependencyInjectionPackageVersion>
|
||||||
<MicrosoftExtensionsLoggingConsolePackageVersion>2.1.0-preview1-27478</MicrosoftExtensionsLoggingConsolePackageVersion>
|
<MicrosoftExtensionsLoggingConsolePackageVersion>2.1.0-preview1-27478</MicrosoftExtensionsLoggingConsolePackageVersion>
|
||||||
<MicrosoftExtensionsLoggingDebugPackageVersion>2.1.0-preview1-27478</MicrosoftExtensionsLoggingDebugPackageVersion>
|
<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; }
|
||||||
|
}
|
||||||
|
}
|
Загрузка…
Ссылка в новой задаче