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

Commit 892d3c0

Browse files
Have AngularCliMiddleware run npm scripts directly (no longer needs to go via NodeServices)
1 parent 55f4bd2 commit 892d3c0

File tree

7 files changed

+315
-158
lines changed

7 files changed

+315
-158
lines changed

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
1414
/// </summary>
1515
public class AngularCliBuilder : ISpaPrerendererBuilder
1616
{
17-
private readonly string _cliAppName;
17+
private readonly string _npmScriptName;
1818

1919
/// <summary>
2020
/// Constructs an instance of <see cref="AngularCliBuilder"/>.
2121
/// </summary>
22-
/// <param name="cliAppName">The name of the application to be built. This must match an entry in your <c>.angular-cli.json</c> file.</param>
23-
public AngularCliBuilder(string cliAppName)
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)
2424
{
25-
_cliAppName = cliAppName;
25+
_npmScriptName = npmScriptName;
2626
}
2727

2828
/// <inheritdoc />
@@ -34,7 +34,7 @@ public Task Build(IApplicationBuilder app)
3434
out var angularCliMiddleware))
3535
{
3636
return ((AngularCliMiddleware)angularCliMiddleware)
37-
.StartAngularCliBuilderAsync(_cliAppName);
37+
.StartAngularCliBuilderAsync(_npmScriptName);
3838
}
3939
else
4040
{

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

Lines changed: 105 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,60 @@
33

44
using Microsoft.AspNetCore.Builder;
55
using Microsoft.AspNetCore.Hosting;
6-
using Microsoft.AspNetCore.NodeServices;
76
using System;
8-
using System.IO;
97
using System.Net.Http;
108
using System.Threading.Tasks;
119
using System.Threading;
1210
using Microsoft.AspNetCore.SpaServices.Extensions.Proxy;
11+
using Microsoft.AspNetCore.NodeServices.Npm;
12+
using System.Text.RegularExpressions;
13+
using Microsoft.Extensions.Logging;
14+
using Microsoft.Extensions.DependencyInjection;
15+
using Microsoft.Extensions.Logging.Console;
16+
using System.Net.Sockets;
17+
using System.Net;
18+
using System.Linq;
1319

1420
namespace Microsoft.AspNetCore.SpaServices.AngularCli
1521
{
1622
internal class AngularCliMiddleware
1723
{
18-
private const string _middlewareResourceName = "/Content/Node/angular-cli-middleware.js";
24+
private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices";
25+
private const int TimeoutMilliseconds = 50 * 1000;
1926

2027
internal readonly static string AngularCliMiddlewareKey = Guid.NewGuid().ToString();
2128

22-
private readonly INodeServices _nodeServices;
23-
private readonly string _middlewareScriptPath;
29+
private readonly string _sourcePath;
30+
private readonly ILogger _logger;
2431
private readonly HttpClient _neverTimeOutHttpClient =
2532
ConditionalProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan);
2633

2734
public AngularCliMiddleware(
2835
IApplicationBuilder appBuilder,
2936
string sourcePath,
37+
string npmScriptName,
3038
SpaDefaultPageMiddleware defaultPageMiddleware)
3139
{
3240
if (string.IsNullOrEmpty(sourcePath))
3341
{
3442
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
3543
}
3644

37-
// Prepare to make calls into Node
38-
_nodeServices = CreateNodeServicesInstance(appBuilder, sourcePath);
39-
_middlewareScriptPath = GetAngularCliMiddlewareScriptPath(appBuilder);
45+
if (string.IsNullOrEmpty(npmScriptName))
46+
{
47+
throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName));
48+
}
49+
50+
_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);
4057

4158
// Start Angular CLI and attach to middleware pipeline
42-
var angularCliServerInfoTask = StartAngularCliServerAsync();
59+
var angularCliServerInfoTask = StartAngularCliServerAsync(npmScriptName);
4360

4461
// Everything we proxy is hardcoded to target http://localhost because:
4562
// - the requests are always from the local machine (we're not accepting remote
@@ -55,54 +72,53 @@ public AngularCliMiddleware(
5572
// Proxy all requests into the Angular CLI server
5673
appBuilder.Use(async (context, next) =>
5774
{
58-
var didProxyRequest = await ConditionalProxy.PerformProxyRequest(
59-
context, _neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken);
60-
61-
// Since we are proxying everything, this is the end of the middleware pipeline.
62-
// We won't call next().
63-
if (!didProxyRequest)
75+
try
76+
{
77+
var didProxyRequest = await ConditionalProxy.PerformProxyRequest(
78+
context, _neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken);
79+
80+
// Since we are proxying everything, this is the end of the middleware pipeline.
81+
// We won't call next().
82+
if (!didProxyRequest)
83+
{
84+
context.Response.StatusCode = 404;
85+
}
86+
}
87+
catch (AggregateException)
88+
{
89+
ThrowIfTaskCancelled(angularCliServerInfoTask);
90+
throw;
91+
}
92+
catch (TaskCanceledException)
6493
{
65-
context.Response.StatusCode = 404;
94+
ThrowIfTaskCancelled(angularCliServerInfoTask);
95+
throw;
6696
}
6797
});
6898

6999
// Advertise the availability of this feature to other SPA middleware
70100
appBuilder.Properties.Add(AngularCliMiddlewareKey, this);
71101
}
72102

73-
internal Task StartAngularCliBuilderAsync(string cliAppName)
103+
private void ThrowIfTaskCancelled(Task task)
74104
{
75-
return _nodeServices.InvokeExportAsync<AngularCliServerInfo>(
76-
_middlewareScriptPath,
77-
"startAngularCliBuilder",
78-
cliAppName);
79-
}
80-
81-
private static INodeServices CreateNodeServicesInstance(
82-
IApplicationBuilder appBuilder, string sourcePath)
83-
{
84-
// Unlike other consumers of NodeServices, AngularCliMiddleware dosen't share Node instances, nor does it
85-
// use your DI configuration. It's important for AngularCliMiddleware to have its own private Node instance
86-
// because it must *not* restart when files change (it's designed to watch for changes and rebuild).
87-
var nodeServicesOptions = new NodeServicesOptions(appBuilder.ApplicationServices)
88-
{
89-
WatchFileExtensions = new string[] { }, // Don't watch anything
90-
ProjectPath = Path.Combine(Directory.GetCurrentDirectory(), sourcePath),
91-
};
92-
93-
if (!Directory.Exists(nodeServicesOptions.ProjectPath))
105+
if (task.IsCanceled)
94106
{
95-
throw new DirectoryNotFoundException($"Directory not found: {nodeServicesOptions.ProjectPath}");
107+
throw new InvalidOperationException(
108+
$"The Angular CLI process did not start listening for requests " +
109+
$"within the timeout period of {TimeoutMilliseconds / 1000} seconds. " +
110+
$"Check the log output for error information.");
96111
}
97-
98-
return NodeServicesFactory.CreateNodeServices(nodeServicesOptions);
99112
}
100113

101-
private static string GetAngularCliMiddlewareScriptPath(IApplicationBuilder appBuilder)
114+
internal Task StartAngularCliBuilderAsync(string npmScriptName)
102115
{
103-
var script = EmbeddedResourceReader.Read(typeof(AngularCliMiddleware), _middlewareResourceName);
104-
var nodeScript = new StringAsTempFile(script, GetStoppingToken(appBuilder));
105-
return nodeScript.FileName;
116+
var npmScriptRunner = new NpmScriptRunner(
117+
_sourcePath, npmScriptName, "--watch");
118+
AttachToLogger(_logger, npmScriptRunner);
119+
120+
return npmScriptRunner.StdOut.WaitForMatch(
121+
new Regex("chunk"), TimeoutMilliseconds);
106122
}
107123

108124
private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder)
@@ -113,19 +129,59 @@ private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder
113129
return ((IApplicationLifetime)applicationLifetime).ApplicationStopping;
114130
}
115131

116-
private async Task<AngularCliServerInfo> StartAngularCliServerAsync()
132+
private async Task<AngularCliServerInfo> StartAngularCliServerAsync(string npmScriptName)
117133
{
118-
// Tell Node to start the server hosting the Angular CLI
119-
var angularCliServerInfo = await _nodeServices.InvokeExportAsync<AngularCliServerInfo>(
120-
_middlewareScriptPath,
121-
"startAngularCliServer");
134+
var portNumber = FindAvailablePort();
135+
_logger.LogInformation($"Starting @angular/cli on port {portNumber}...");
136+
137+
var npmScriptRunner = new NpmScriptRunner(
138+
_sourcePath, npmScriptName, $"--port {portNumber}");
139+
AttachToLogger(_logger, npmScriptRunner);
140+
141+
var openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
142+
new Regex("open your browser on (http\\S+)"),
143+
TimeoutMilliseconds);
144+
var uri = new Uri(openBrowserLine.Groups[1].Value);
145+
var serverInfo = new AngularCliServerInfo { Port = uri.Port };
122146

123147
// Even after the Angular CLI claims to be listening for requests, there's a short
124148
// period where it will give an error if you make a request too quickly. Give it
125149
// a moment to finish starting up.
126150
await Task.Delay(500);
127151

128-
return angularCliServerInfo;
152+
return serverInfo;
153+
}
154+
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+
173+
private static int FindAvailablePort()
174+
{
175+
var listener = new TcpListener(IPAddress.Loopback, 0);
176+
listener.Start();
177+
try
178+
{
179+
return ((IPEndPoint)listener.LocalEndpoint).Port;
180+
}
181+
finally
182+
{
183+
listener.Stop();
184+
}
129185
}
130186

131187
#pragma warning disable CS0649

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,19 @@ public static class AngularCliMiddlewareExtensions
2121
/// </summary>
2222
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
2323
/// <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>
2425
public static void UseAngularCliServer(
2526
this IApplicationBuilder app,
26-
string sourcePath)
27+
string sourcePath,
28+
string npmScriptName)
2729
{
2830
var defaultPageMiddleware = SpaDefaultPageMiddleware.FindInPipeline(app);
2931
if (defaultPageMiddleware == null)
3032
{
3133
throw new Exception($"{nameof(UseAngularCliServer)} should be called inside the 'configue' callback of a call to {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
3234
}
3335

34-
new AngularCliMiddleware(app, sourcePath, defaultPageMiddleware);
36+
new AngularCliMiddleware(app, sourcePath, npmScriptName, defaultPageMiddleware);
3537
}
3638
}
3739
}

src/Microsoft.AspNetCore.SpaServices.Extensions/Content/Node/angular-cli-middleware.js

Lines changed: 0 additions & 98 deletions
This file was deleted.

src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
<TargetFramework>netstandard2.0</TargetFramework>
66
</PropertyGroup>
77

8-
<ItemGroup>
9-
<EmbeddedResource Include="Content\**\*" />
10-
</ItemGroup>
11-
128
<ItemGroup>
139
<ProjectReference Include="..\Microsoft.AspNetCore.SpaServices\Microsoft.AspNetCore.SpaServices.csproj" />
1410
</ItemGroup>

0 commit comments

Comments
 (0)