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:
Steve Sanderson 2017-10-23 15:38:32 +01:00
Родитель 7bf5516bb2
Коммит c8b337ebaa
20 изменённых файлов: 1520 добавлений и 1 удалений

Просмотреть файл

@ -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; }
}
}