-
Notifications
You must be signed in to change notification settings - Fork 9.8k
/
AngularCliMiddleware.cs
150 lines (134 loc) · 7.05 KB
/
AngularCliMiddleware.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// 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.Extensions.Logging;
using Microsoft.AspNetCore.NodeServices.Npm;
using Microsoft.AspNetCore.NodeServices.Util;
using Microsoft.AspNetCore.SpaServices.Util;
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Threading;
using System.Net.Http;
using Microsoft.AspNetCore.SpaServices.Extensions.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
public static void Attach(
ISpaBuilder spaBuilder,
string scriptName)
{
var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
var sourcePath = spaBuilder.Options.SourcePath;
var devServerPort = spaBuilder.Options.DevServerPort;
if (string.IsNullOrEmpty(sourcePath))
{
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
}
if (string.IsNullOrEmpty(scriptName))
{
throw new ArgumentException("Cannot be null or empty", nameof(scriptName));
}
// Start Angular CLI and attach to middleware pipeline
var appBuilder = spaBuilder.ApplicationBuilder;
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, scriptName, pkgManagerCommand, devServerPort, 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, () =>
{
// On each request, we create a separate startup task with its own timeout. That way, even if
// the first request times out, subsequent requests could still work.
var timeout = spaBuilder.Options.StartupTimeout;
return targetUriTask.WithTimeout(timeout,
$"The Angular CLI process did not start listening for requests " +
$"within the timeout period of {timeout.TotalSeconds} seconds. " +
$"Check the log output for error information.");
});
}
private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
string sourcePath, string scriptName, string pkgManagerCommand, int portNumber, ILogger logger)
{
if (portNumber == default(int))
{
portNumber = TcpPortFinder.FindAvailablePort();
}
logger.LogInformation($"Starting @angular/cli on port {portNumber}...");
var scriptRunner = new NodeScriptRunner(
sourcePath, scriptName, $"--port {portNumber}", null, pkgManagerCommand);
scriptRunner.AttachToLogger(logger);
Match openBrowserLine;
using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))
{
try
{
openBrowserLine = await scriptRunner.StdOut.WaitForMatch(
new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout));
}
catch (EndOfStreamException ex)
{
throw new InvalidOperationException(
$"The {pkgManagerCommand} script '{scriptName}' exited without indicating that the " +
$"Angular CLI was listening for requests. The error output was: " +
$"{stdErrReader.ReadAsString()}", 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
await WaitForAngularCliServerToAcceptRequests(uri);
return serverInfo;
}
private static async Task WaitForAngularCliServerToAcceptRequests(Uri cliServerUri)
{
// To determine when it's actually ready, try making HEAD requests to '/'. If it
// produces any HTTP response (even if it's 404) then it's ready. If it rejects the
// connection then it's not ready. We keep trying forever because this is dev-mode
// only, and only a single startup attempt will be made, and there's a further level
// of timeouts enforced on a per-request basis.
var timeoutMilliseconds = 1000;
using (var client = new HttpClient())
{
while (true)
{
try
{
// If we get any HTTP response, the CLI server is ready
await client.SendAsync(
new HttpRequestMessage(HttpMethod.Head, cliServerUri),
new CancellationTokenSource(timeoutMilliseconds).Token);
return;
}
catch (Exception)
{
await Task.Delay(500);
// Depending on the host's networking configuration, the requests can take a while
// to go through, most likely due to the time spent resolving 'localhost'.
// Each time we have a failure, allow a bit longer next time (up to a maximum).
// This only influences the time until we regard the dev server as 'ready', so it
// doesn't affect the runtime perf (even in dev mode) once the first connection is made.
// Resolves https://github.com/aspnet/JavaScriptServices/issues/1611
if (timeoutMilliseconds < 10000)
{
timeoutMilliseconds += 3000;
}
}
}
}
}
class AngularCliServerInfo
{
public int Port { get; set; }
}
}
}