Skip to content
This repository has been archived by the owner on Apr 8, 2020. It is now read-only.

Feature/stevesa/spaservices extensions package #1367

Closed
Closed
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7682c74
Add new Microsoft.AspNetCore.SpaServices.Extensions package to host n…
SteveSandersonMS Oct 23, 2017
415aa54
SpaServices.Extensions will first ship to work with 2.0.0 dependencies
SteveSandersonMS Oct 23, 2017
77fcfc2
Revert "SpaServices.Extensions will first ship to work with 2.0.0 dep…
SteveSandersonMS Oct 23, 2017
257e095
Make ConditionalProxy shut down the WebSocket proxy much faster when …
SteveSandersonMS Oct 25, 2017
2485911
Make UseSpaPrerendering capture the non-prerendered response and supp…
SteveSandersonMS Oct 25, 2017
eff9bb9
Add API for supplying custom data to prerenderer
SteveSandersonMS Oct 25, 2017
2404cc2
Remove angular-cli-middleware's dependency on Promise
SteveSandersonMS Oct 26, 2017
e4e396e
Better handle errors during prerendering
SteveSandersonMS Oct 26, 2017
0e1bde4
Capture errors and timeouts that occur ing angular-cli-middleware.js
SteveSandersonMS Oct 26, 2017
9ee8cbc
Have AngularCliMiddleware run npm scripts directly (no longer needs t…
SteveSandersonMS Oct 26, 2017
08dbc15
Add ISpaOptions concept so that AngularCliBuilder can be independent …
SteveSandersonMS Oct 26, 2017
ec21464
Better logging if an NPM task exits with an error
SteveSandersonMS Oct 31, 2017
272e609
Simplify AngularCliMiddleware by reducing to a static class. Doesn't …
SteveSandersonMS Oct 31, 2017
ae2c456
Add standalone UseProxyToSpaDevelopmentServer API so it's not necessa…
SteveSandersonMS Oct 31, 2017
10a114c
Simplify 404 handling in new SPA proxying code
SteveSandersonMS Oct 31, 2017
3c313ac
Rename the new ConditionalProxy to SpaProxy, since it's not always 'c…
SteveSandersonMS Oct 31, 2017
f9f365b
Code clean-ups
SteveSandersonMS Nov 1, 2017
99a5611
Following CR feedback, reintroduce ISpaBuilder concept
SteveSandersonMS Nov 3, 2017
0849d45
CR feedback: Clean up timeouts
SteveSandersonMS Nov 7, 2017
d2f7f83
Update project reference to match new KoreBuild requirements
SteveSandersonMS Nov 7, 2017
e34c261
Remove redundant warning suppression
SteveSandersonMS Nov 7, 2017
82507eb
Add 'using'
SteveSandersonMS Nov 7, 2017
a93e1a1
CR feedback: Switch to configuration callback for UseSpaPrerendering …
SteveSandersonMS Nov 7, 2017
bb10e56
CR feedback: Change comment from 'typical' to 'default'
SteveSandersonMS Nov 7, 2017
eb403ed
CR feedback: Remove vestigal 'defaultPage' param
SteveSandersonMS Nov 7, 2017
eb04c98
On UseSpaPrerendering, make 'configuration' mandatory because you wou…
SteveSandersonMS Nov 7, 2017
974ace4
Make UseProxyToSpaDevelopmentServer responsible for enabling WebSocke…
SteveSandersonMS Nov 7, 2017
dc7f14a
CR feedback: Change UseSpa to take only an Action, and set the UrlPre…
SteveSandersonMS Nov 8, 2017
ca37e91
CR feedback: Change UrlPrefix property to be a PathString
SteveSandersonMS Nov 8, 2017
54cea3d
CR feedback: Add UseProxyToSpaDevelopmentServer overload that takes a…
SteveSandersonMS Nov 8, 2017
ca7ae03
CR feedback: Use IOptions pattern
SteveSandersonMS Nov 8, 2017
a5bd320
Additional changes that should have been included in previous commit
SteveSandersonMS Nov 8, 2017
b5b0356
CR feedback: Strip ANSI colours when writing to ILogger
SteveSandersonMS Nov 8, 2017
31af70f
Eliminate UrlPrefix because it's no longer much used. Add DefaultPage…
SteveSandersonMS Nov 9, 2017
9b2e279
Fix XML doc
SteveSandersonMS Nov 9, 2017
073fc7a
Move UseSpaPrerendering entryPoint config onto SpaPrerenderingOptions
SteveSandersonMS Nov 9, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 8 additions & 1 deletion JavaScriptServices.sln
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26730.0
VisualStudioVersion = 15.0.26730.16
MinimumVisualStudioVersion = 15.0.26730.03
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{27304DDE-AFB2-4F8B-B765-E3E2F11E886C}"
ProjectSection(SolutionItems) = preProject
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
1 change: 1 addition & 0 deletions build/dependencies.props
Expand Up @@ -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>
Expand Down
@@ -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));
}
}
}
25 changes: 25 additions & 0 deletions src/Microsoft.AspNetCore.SpaServices.Extensions/ISpaBuilder.cs
@@ -0,0 +1,25 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using Microsoft.AspNetCore.Builder;

namespace Microsoft.AspNetCore.SpaServices
{
/// <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>