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

Commit 54ac222

Browse files
Add ISpaOptions concept so that AngularCliBuilder can be independent of AngularCliMiddleware
1 parent 892d3c0 commit 54ac222

File tree

10 files changed

+179
-106
lines changed

10 files changed

+179
-106
lines changed

src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.AspNetCore.NodeServices.Npm;
56
using Microsoft.AspNetCore.SpaServices.Prerendering;
7+
using Microsoft.Extensions.Logging;
68
using System;
9+
using System.Text.RegularExpressions;
710
using System.Threading.Tasks;
811

912
namespace Microsoft.AspNetCore.SpaServices.AngularCli
@@ -14,34 +17,50 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
1417
/// </summary>
1518
public class AngularCliBuilder : ISpaPrerendererBuilder
1619
{
20+
private const int TimeoutMilliseconds = 50 * 1000;
1721
private readonly string _npmScriptName;
1822

1923
/// <summary>
2024
/// Constructs an instance of <see cref="AngularCliBuilder"/>.
2125
/// </summary>
22-
/// <param name="npmScriptName">The name of the script in your package.json file that builds the server-side bundle for your Angular application.</param>
23-
public AngularCliBuilder(string npmScriptName)
26+
/// <param name="npmScript">The name of the script in your package.json file that builds the server-side bundle for your Angular application.</param>
27+
public AngularCliBuilder(string npmScript)
2428
{
25-
_npmScriptName = npmScriptName;
29+
_npmScriptName = npmScript;
2630
}
2731

2832
/// <inheritdoc />
2933
public Task Build(IApplicationBuilder app)
3034
{
31-
// Locate the AngularCliMiddleware within the provided IApplicationBuilder
32-
if (app.Properties.TryGetValue(
33-
AngularCliMiddleware.AngularCliMiddlewareKey,
34-
out var angularCliMiddleware))
35+
var spaOptions = DefaultSpaOptions.FindInPipeline(app);
36+
if (spaOptions == null)
3537
{
36-
return ((AngularCliMiddleware)angularCliMiddleware)
37-
.StartAngularCliBuilderAsync(_npmScriptName);
38+
throw new InvalidOperationException($"{nameof(AngularCliBuilder)} can only be used in an application configured with {nameof(SpaApplicationBuilderExtensions.UseSpa)}().");
3839
}
39-
else
40+
41+
if (string.IsNullOrEmpty(spaOptions.SourcePath))
4042
{
41-
throw new Exception(
42-
$"Cannot use {nameof(AngularCliBuilder)} unless you are also using" +
43-
$" {nameof(AngularCliMiddlewareExtensions.UseAngularCliServer)}.");
43+
throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
4444
}
45+
46+
return StartAngularCliBuilderAsync(
47+
_npmScriptName,
48+
spaOptions.SourcePath,
49+
AngularCliMiddleware.GetOrCreateLogger(app));
50+
}
51+
52+
internal Task StartAngularCliBuilderAsync(
53+
string npmScriptName, string sourcePath, ILogger logger)
54+
{
55+
var npmScriptRunner = new NpmScriptRunner(
56+
sourcePath,
57+
npmScriptName,
58+
"--watch");
59+
npmScriptRunner.AttachToLogger(logger);
60+
61+
return npmScriptRunner.StdOut.WaitForMatch(
62+
new Regex("chunk"),
63+
TimeoutMilliseconds);
4564
}
4665
}
4766
}

src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs

Lines changed: 12 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
using Microsoft.Extensions.Logging.Console;
1616
using System.Net.Sockets;
1717
using System.Net;
18-
using System.Linq;
1918

2019
namespace Microsoft.AspNetCore.SpaServices.AngularCli
2120
{
@@ -24,8 +23,6 @@ internal class AngularCliMiddleware
2423
private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices";
2524
private const int TimeoutMilliseconds = 50 * 1000;
2625

27-
internal readonly static string AngularCliMiddlewareKey = Guid.NewGuid().ToString();
28-
2926
private readonly string _sourcePath;
3027
private readonly ILogger _logger;
3128
private readonly HttpClient _neverTimeOutHttpClient =
@@ -34,8 +31,7 @@ internal class AngularCliMiddleware
3431
public AngularCliMiddleware(
3532
IApplicationBuilder appBuilder,
3633
string sourcePath,
37-
string npmScriptName,
38-
SpaDefaultPageMiddleware defaultPageMiddleware)
34+
string npmScriptName)
3935
{
4036
if (string.IsNullOrEmpty(sourcePath))
4137
{
@@ -48,12 +44,7 @@ public AngularCliMiddleware(
4844
}
4945

5046
_sourcePath = sourcePath;
51-
52-
// If the DI system gives us a logger, use it. Otherwise, set up a default one.
53-
var loggerFactory = appBuilder.ApplicationServices.GetService<ILoggerFactory>();
54-
_logger = loggerFactory != null
55-
? loggerFactory.CreateLogger(LogCategoryName)
56-
: new ConsoleLogger(LogCategoryName, null, false);
47+
_logger = GetOrCreateLogger(appBuilder);
5748

5849
// Start Angular CLI and attach to middleware pipeline
5950
var angularCliServerInfoTask = StartAngularCliServerAsync(npmScriptName);
@@ -95,9 +86,16 @@ public AngularCliMiddleware(
9586
throw;
9687
}
9788
});
89+
}
9890

99-
// Advertise the availability of this feature to other SPA middleware
100-
appBuilder.Properties.Add(AngularCliMiddlewareKey, this);
91+
internal static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder)
92+
{
93+
// If the DI system gives us a logger, use it. Otherwise, set up a default one.
94+
var loggerFactory = appBuilder.ApplicationServices.GetService<ILoggerFactory>();
95+
var logger = loggerFactory != null
96+
? loggerFactory.CreateLogger(LogCategoryName)
97+
: new ConsoleLogger(LogCategoryName, null, false);
98+
return logger;
10199
}
102100

103101
private void ThrowIfTaskCancelled(Task task)
@@ -111,16 +109,6 @@ private void ThrowIfTaskCancelled(Task task)
111109
}
112110
}
113111

114-
internal Task StartAngularCliBuilderAsync(string npmScriptName)
115-
{
116-
var npmScriptRunner = new NpmScriptRunner(
117-
_sourcePath, npmScriptName, "--watch");
118-
AttachToLogger(_logger, npmScriptRunner);
119-
120-
return npmScriptRunner.StdOut.WaitForMatch(
121-
new Regex("chunk"), TimeoutMilliseconds);
122-
}
123-
124112
private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder)
125113
{
126114
var applicationLifetime = appBuilder
@@ -136,7 +124,7 @@ private async Task<AngularCliServerInfo> StartAngularCliServerAsync(string npmSc
136124

137125
var npmScriptRunner = new NpmScriptRunner(
138126
_sourcePath, npmScriptName, $"--port {portNumber}");
139-
AttachToLogger(_logger, npmScriptRunner);
127+
npmScriptRunner.AttachToLogger(_logger);
140128

141129
var openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
142130
new Regex("open your browser on (http\\S+)"),
@@ -152,24 +140,6 @@ private async Task<AngularCliServerInfo> StartAngularCliServerAsync(string npmSc
152140
return serverInfo;
153141
}
154142

155-
private static void AttachToLogger(ILogger logger, NpmScriptRunner npmScriptRunner)
156-
{
157-
// When the NPM task emits complete lines, pass them through to the real logger
158-
// But when it emits incomplete lines, assume this is progress information and
159-
// hence just pass it through to StdOut regardless of logger config.
160-
npmScriptRunner.CopyOutputToLogger(logger);
161-
162-
npmScriptRunner.StdErr.OnReceivedChunk += chunk =>
163-
{
164-
var containsNewline = Array.IndexOf(
165-
chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0;
166-
if (!containsNewline)
167-
{
168-
Console.Write(chunk.Array, chunk.Offset, chunk.Count);
169-
}
170-
};
171-
}
172-
173143
private static int FindAvailablePort()
174144
{
175145
var listener = new TcpListener(IPAddress.Loopback, 0);

src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddlewareExtensions.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,23 @@ public static class AngularCliMiddlewareExtensions
2020
/// sure not to enable the Angular CLI server.
2121
/// </summary>
2222
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
23-
/// <param name="sourcePath">The disk path, relative to the current directory, of the directory containing the SPA source files. When Angular CLI executes, this will be its working directory.</param>
24-
/// <param name="npmScriptName">The name of the script in your package.json file that launches the Angular CLI process.</param>
23+
/// <param name="npmScript">The name of the script in your package.json file that launches the Angular CLI process.</param>
2524
public static void UseAngularCliServer(
2625
this IApplicationBuilder app,
27-
string sourcePath,
28-
string npmScriptName)
26+
string npmScript)
2927
{
30-
var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(app);
31-
if (defaultPageMiddleware == null)
28+
var spaOptions = DefaultSpaOptions.FindInPipeline(app);
29+
if (spaOptions == null)
3230
{
33-
throw new Exception($"{nameof(UseAngularCliServer)} should be called inside the 'configue' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
31+
throw new InvalidOperationException($"{nameof(UseAngularCliServer)} should be called inside the 'configure' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
3432
}
3533

36-
new AngularCliMiddleware(app, sourcePath, npmScriptName, defaultPageMiddleware);
34+
if (string.IsNullOrEmpty(spaOptions.SourcePath))
35+
{
36+
throw new InvalidOperationException($"To use {nameof(UseAngularCliServer)}, you must supply a non-empty value for the {nameof(ISpaOptions.SourcePath)} property of {nameof(ISpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
37+
}
38+
39+
new AngularCliMiddleware(app, spaOptions.SourcePath, npmScript);
3740
}
3841
}
3942
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using System;
6+
7+
namespace Microsoft.AspNetCore.SpaServices
8+
{
9+
internal class DefaultSpaOptions : ISpaOptions
10+
{
11+
public string DefaultPage { get; set; } = "index.html";
12+
13+
public string SourcePath { get; }
14+
15+
public string UrlPrefix { get; }
16+
17+
private static readonly string _propertiesKey = Guid.NewGuid().ToString();
18+
19+
public DefaultSpaOptions(string sourcePath, string urlPrefix)
20+
{
21+
if (urlPrefix == null || !urlPrefix.StartsWith("/", StringComparison.Ordinal))
22+
{
23+
throw new ArgumentException("The value must start with '/'", nameof(urlPrefix));
24+
}
25+
26+
SourcePath = sourcePath;
27+
UrlPrefix = urlPrefix;
28+
}
29+
30+
internal static ISpaOptions FindInPipeline(IApplicationBuilder app)
31+
{
32+
return app.Properties.TryGetValue(_propertiesKey, out var instance)
33+
? (ISpaOptions)instance
34+
: null;
35+
}
36+
37+
internal void RegisterSoleInstanceInPipeline(IApplicationBuilder app)
38+
{
39+
if (app.Properties.ContainsKey(_propertiesKey))
40+
{
41+
throw new Exception($"Only one usage of {nameof(SpaApplicationBuilderExtensions.UseSpa)} is allowed in any single branch of the middleware pipeline. This is because one instance would handle all requests.");
42+
}
43+
44+
app.Properties[_propertiesKey] = this;
45+
}
46+
}
47+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.AspNetCore.SpaServices
5+
{
6+
/// <summary>
7+
/// Describes options for hosting a Single Page Application (SPA).
8+
/// </summary>
9+
public interface ISpaOptions
10+
{
11+
/// <summary>
12+
/// Gets or sets the URL, relative to <see cref="UrlPrefix"/>,
13+
/// of the default page that hosts your SPA user interface.
14+
/// The typical value is <c>"index.html"</c>.
15+
/// </summary>
16+
string DefaultPage { get; set; }
17+
18+
/// <summary>
19+
/// Gets the path, relative to the application working directory,
20+
/// of the directory that contains the SPA source files during
21+
/// development. The directory may not exist in published applications.
22+
/// </summary>
23+
string SourcePath { get; }
24+
25+
/// <summary>
26+
/// Gets the URL path, relative to your application's <c>PathBase</c>, from which
27+
/// the SPA files are served.
28+
///
29+
/// For example, if your SPA files are located in <c>wwwroot/dist</c>, then
30+
/// the value should usually be <c>"dist"</c>, because that is the URL prefix
31+
/// from which browsers can request those files.
32+
/// </summary>
33+
string UrlPrefix { get; }
34+
}
35+
}

src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ public NpmScriptRunner(string workingDirectory, string scriptName, string argume
4646
StdErr = new EventedStreamReader(process.StandardError);
4747
}
4848

49-
public void CopyOutputToLogger(ILogger logger)
49+
public void AttachToLogger(ILogger logger)
5050
{
51+
// When the NPM task emits complete lines, pass them through to the real logger
5152
StdOut.OnReceivedLine += line =>
5253
{
5354
if (!string.IsNullOrWhiteSpace(line))
@@ -63,6 +64,18 @@ public void CopyOutputToLogger(ILogger logger)
6364
logger.LogError(line);
6465
}
6566
};
67+
68+
// But when it emits incomplete lines, assume this is progress information and
69+
// hence just pass it through to StdOut regardless of logger config.
70+
StdErr.OnReceivedChunk += chunk =>
71+
{
72+
var containsNewline = Array.IndexOf(
73+
chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0;
74+
if (!containsNewline)
75+
{
76+
Console.Write(chunk.Array, chunk.Offset, chunk.Count);
77+
}
78+
};
6679
}
6780

6881
private static Process LaunchNodeProcess(ProcessStartInfo startInfo)

src/Microsoft.AspNetCore.SpaServices.Extensions/Prerendering/SpaPrerenderingExtensions.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,8 @@ public static void UseSpaPrerendering(
4343
throw new ArgumentException("Cannot be null or empty", nameof(entryPoint));
4444
}
4545

46-
// We only want to start one build-on-demand task, but it can't commence until
47-
// a request comes in (because we need to wait for all middleware to be configured)
48-
var lazyBuildOnDemandTask = new Lazy<Task>(() => buildOnDemand?.Build(appBuilder));
46+
// If we're building on demand, start that process now
47+
var buildOnDemandTask = buildOnDemand?.Build(appBuilder);
4948

5049
// Get all the necessary context info that will be used for each prerendering call
5150
var serviceProvider = appBuilder.ApplicationServices;
@@ -76,7 +75,6 @@ public static void UseSpaPrerendering(
7675
}
7776

7877
// If we're building on demand, do that first
79-
var buildOnDemandTask = lazyBuildOnDemandTask.Value;
8078
if (buildOnDemandTask != null)
8179
{
8280
await buildOnDemandTask;

src/Microsoft.AspNetCore.SpaServices.Extensions/Proxying/ConditionalProxy.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ private static async Task PumpWebSocket(WebSocket source, WebSocket destination,
265265
break;
266266
}
267267

268-
await Task.Delay(250);
268+
await Task.Delay(100);
269269
}
270270

271271
var result = resultTask.Result; // We know it's completed already

src/Microsoft.AspNetCore.SpaServices.Extensions/SpaApplicationBuilderExtensions.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ public static class SpaApplicationBuilderExtensions
2828
/// the value should usually be <c>"dist"</c>, because that is the URL prefix
2929
/// from which browsers can request those files.
3030
/// </param>
31+
/// <param name="sourcePath">
32+
/// Optional. If specified, configures the path (relative to the application working
33+
/// directory) of the directory that holds the SPA source files during development.
34+
/// The directory need not exist once the application is published.
35+
/// </param>
3136
/// <param name="defaultPage">
3237
/// Optional. If specified, configures the path (relative to <paramref name="urlPrefix"/>)
3338
/// of the default page that hosts your SPA user interface.
@@ -40,10 +45,18 @@ public static class SpaApplicationBuilderExtensions
4045
public static void UseSpa(
4146
this IApplicationBuilder app,
4247
string urlPrefix,
48+
string sourcePath = null,
4349
string defaultPage = null,
44-
Action configure = null)
50+
Action<ISpaOptions> configure = null)
4551
{
46-
new SpaDefaultPageMiddleware(app, urlPrefix, defaultPage, configure);
52+
var spaOptions = new DefaultSpaOptions(sourcePath, urlPrefix);
53+
spaOptions.RegisterSoleInstanceInPipeline(app);
54+
55+
// Invoke 'configure' to give the developer a chance to insert extra
56+
// middleware before the 'default page' pipeline entries
57+
configure?.Invoke(spaOptions);
58+
59+
SpaDefaultPageMiddleware.Attach(app, spaOptions);
4760
}
4861
}
4962
}

0 commit comments

Comments
 (0)