33
44using Microsoft . AspNetCore . Builder ;
55using Microsoft . AspNetCore . Hosting ;
6- using Microsoft . AspNetCore . NodeServices ;
76using System ;
8- using System . IO ;
97using System . Net . Http ;
108using System . Threading . Tasks ;
119using System . Threading ;
1210using 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
1420namespace 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
0 commit comments