Skip to content

Adding a base npm middleware and making it public #7046

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,145 +1,23 @@
// 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;
using Microsoft.AspNetCore.SpaServices.Npm;

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
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 npmScriptName)
{
var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath))
{
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
}

if (string.IsNullOrEmpty(npmScriptName))
{
throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName));
}

// Start Angular CLI and attach to middleware pipeline
var appBuilder = spaBuilder.ApplicationBuilder;
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, npmScriptName, 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.Seconds} seconds. " +
$"Check the log output for error information.");
});
}

private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
string sourcePath, string npmScriptName, ILogger logger)
{
var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting @angular/cli on port {portNumber}...");

var npmScriptRunner = new NpmScriptRunner(
sourcePath, npmScriptName, $"--port {portNumber}", null);
npmScriptRunner.AttachToLogger(logger);

Match openBrowserLine;
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))
{
try
{
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout));
}
catch (EndOfStreamException ex)
{
throw new InvalidOperationException(
$"The NPM script '{npmScriptName}' 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; }
NpmMiddleware.Attach(spaBuilder, npmScriptName, port => $"--port {port}", port => null,
new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout),
(match, port) => new Uri(match.Groups[1].Value));
}
}
}
136 changes: 136 additions & 0 deletions src/Middleware/SpaServices.Extensions/src/Npm/NpmMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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.Collections.Generic;
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.Npm
{
public static class NpmMiddleware
{
private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices";

public static void Attach(ISpaBuilder spaBuilder,
string npmScriptName,
Func<int, string> npmParameters,
Func<int, IDictionary<string, string>> envVars,
Regex outputMatch, Func<Match, int, Uri> listeningUri)
{
var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath))
{
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
}

if (string.IsNullOrEmpty(npmScriptName))
{
throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName));
}

// Start the provided npm script and attach to middleware pipeline
var appBuilder = spaBuilder.ApplicationBuilder;
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
var npmServerPortTask =
StartNpmServerAsync(sourcePath, npmScriptName, npmParameters, envVars, outputMatch, listeningUri, logger);

var targetUriTask = npmServerPortTask.ContinueWith(task => task.Result);

spaBuilder.UseProxyToSpaDevelopmentServer(() =>
{
// 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 npm process did not start listening for requests " +
$"within the timeout period of {timeout.Seconds} seconds. " +
$"Check the log output for error information.");
});
}

private static async Task<Uri> StartNpmServerAsync(
string sourcePath, string npmScriptName,
Func<int, string> npmParameters, Func<int, IDictionary<string, string>> envVars,
Regex outputMatch, Func<Match, int, Uri> listeningUri,
ILogger logger)
{
var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting {npmScriptName} on port {portNumber}...");

var npmScriptRunner = new NpmScriptRunner(
sourcePath, npmScriptName, npmParameters(portNumber), envVars(portNumber));
npmScriptRunner.AttachToLogger(logger);

Uri uri;
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))
{
try
{
uri = listeningUri(await npmScriptRunner.StdOut.WaitForMatch(outputMatch), portNumber);
}
catch (EndOfStreamException ex)
{
throw new InvalidOperationException(
$"The NPM script '{npmScriptName}' exited without indicating that the " +
$"server was listening for requests. The error output was: " +
$"{stdErrReader.ReadAsString()}", ex);
}
}

// Even after the CLI tool 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 WaitForNpmServerToAcceptRequests(uri);

return uri;
}

private static async Task WaitForNpmServerToAcceptRequests(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;
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ private static Process LaunchNodeProcess(ProcessStartInfo startInfo)
{
var message = $"Failed to start 'npm'. To resolve this:.\n\n"
+ "[1] Ensure that 'npm' is installed and can be found in one of the PATH directories.\n"
+ $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n"
+ $" Current PATH environment variable is: { Environment.GetEnvironmentVariable("PATH") }\n"
+ " Make sure the executable is in one of those directories, or update your PATH.\n\n"
+ "[2] See the InnerException for further details of the cause.";
throw new InvalidOperationException(message, ex);
Expand Down
Loading