| File: AngularCli\AngularCliMiddleware.cs | Web Access |
| Project: src\aspnetcore\src\Middleware\Spa\SpaServices.Extensions\src\Microsoft.AspNetCore.SpaServices.Extensions.csproj (Microsoft.AspNetCore.SpaServices.Extensions) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Net.Http; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.NodeServices.Npm; using Microsoft.AspNetCore.NodeServices.Util; using Microsoft.AspNetCore.SpaServices.Extensions.Util; using Microsoft.AspNetCore.SpaServices.Util; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.SpaServices.AngularCli; internal static class AngularCliMiddleware { private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; private static readonly 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("Property 'SourcePath' cannot be null or empty", nameof(spaBuilder)); } ArgumentException.ThrowIfNullOrEmpty(scriptName); // Start Angular CLI and attach to middleware pipeline var appBuilder = spaBuilder.ApplicationBuilder; var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping; var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService<DiagnosticSource>(); var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, scriptName, pkgManagerCommand, devServerPort, logger, diagnosticSource, applicationStoppingToken); 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 angularCliServerInfoTask.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<Uri> StartAngularCliServerAsync( string sourcePath, string scriptName, string pkgManagerCommand, int portNumber, ILogger logger, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken) { if (portNumber == default(int)) { portNumber = TcpPortFinder.FindAvailablePort(); } if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation($"Starting @angular/cli on port {portNumber}..."); } var scriptRunner = new NodeScriptRunner( sourcePath, scriptName, $"--port {portNumber}", null, pkgManagerCommand, diagnosticSource, applicationStoppingToken); 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); // 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 uri; } 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 using var cancellationTokenSource = new CancellationTokenSource(timeoutMilliseconds); await client.SendAsync( new HttpRequestMessage(HttpMethod.Head, cliServerUri), cancellationTokenSource.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; } } } } } }