File: DebugProxyLauncher.cs
Web Access
Project: src\src\Components\WebAssembly\Server\src\Microsoft.AspNetCore.Components.WebAssembly.Server.csproj (Microsoft.AspNetCore.Components.WebAssembly.Server)
// 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.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.CommandLineUtils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Microsoft.AspNetCore.Builder;
 
internal static class DebugProxyLauncher
{
    private static readonly object LaunchLock = new object();
    private static readonly TimeSpan DebugProxyLaunchTimeout = TimeSpan.FromSeconds(10);
    private static Task<string>? LaunchedDebugProxyUrl;
    private static readonly Regex NowListeningRegex = new Regex(@"^\s*Now listening on: (?<url>.*)$", RegexOptions.None, TimeSpan.FromSeconds(10));
    private static readonly Regex ApplicationStartedRegex = new Regex(@"^\s*Application started\. Press Ctrl\+C to shut down\.$", RegexOptions.None, TimeSpan.FromSeconds(10));
    private static readonly Regex NowListeningFirefoxRegex = new Regex(@"^\s*Debug proxy for firefox now listening on tcp://(?<url>.*)\. And expecting firefox at port 6000\.$", RegexOptions.None, TimeSpan.FromSeconds(10));
    private static readonly string[] MessageSuppressionPrefixes = new[]
    {
        "Hosting environment:",
        "Content root path:",
        "Now listening on:",
        "Application started. Press Ctrl+C to shut down.",
        "Debug proxy for firefox now",
    };
 
    public static Task<string> EnsureLaunchedAndGetUrl(IServiceProvider serviceProvider, string devToolsHost, bool isFirefox)
    {
        lock (LaunchLock)
        {
            LaunchedDebugProxyUrl ??= LaunchAndGetUrl(serviceProvider, devToolsHost, isFirefox);
 
            return LaunchedDebugProxyUrl;
        }
    }
 
    private static string GetIgnoreProxyForLocalAddress()
    {
        var noProxyEnvVar = Environment.GetEnvironmentVariable("NO_PROXY");
        if (noProxyEnvVar is not null)
        {
            var noProxyEnvVarValues = noProxyEnvVar.Split(",", StringSplitOptions.TrimEntries);
            if (noProxyEnvVarValues.Any(noProxyValue => noProxyValue.Equals("localhost") || noProxyValue.Equals("127.0.0.1")))
            {
                return "--IgnoreProxyForLocalAddress True";
            }
            Console.WriteLine($"Invalid value for NO_PROXY: {noProxyEnvVar} (Expected values: \"localhost\" or \"127.0.0.1\")");
        }
        return "";
    }
 
    private static async Task<string> LaunchAndGetUrl(IServiceProvider serviceProvider, string devToolsHost, bool isFirefox)
    {
        var tcs = new TaskCompletionSource<string>();
 
        var environment = serviceProvider.GetRequiredService<IWebHostEnvironment>();
        var executablePath = LocateDebugProxyExecutable(environment);
        var muxerPath = DotNetMuxer.MuxerPathOrDefault();
        var ownerPid = Environment.ProcessId;
        var ignoreProxyForLocalAddress = GetIgnoreProxyForLocalAddress();
        var processStartInfo = new ProcessStartInfo
        {
            FileName = muxerPath,
            Arguments = $"exec \"{executablePath}\" --OwnerPid {ownerPid} --DevToolsUrl {devToolsHost} --IsFirefoxDebugging {isFirefox} --FirefoxProxyPort 6001 {ignoreProxyForLocalAddress}",
            UseShellExecute = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
        };
        RemoveUnwantedEnvironmentVariables(processStartInfo.Environment);
 
        using var cts = new CancellationTokenSource(DebugProxyLaunchTimeout);
        var ctr = default(CancellationTokenRegistration);
        var debugProxyProcess = Process.Start(processStartInfo);
        if (debugProxyProcess is null)
        {
            tcs.TrySetException(new InvalidOperationException("Unable to start debug proxy process."));
        }
        else
        {
            PassThroughConsoleOutput(debugProxyProcess);
            CompleteTaskWhenServerIsReady(debugProxyProcess, isFirefox, tcs);
 
            ctr = cts.Token.Register(() =>
            {
                tcs.TrySetException(new TimeoutException($"Failed to start the debug proxy within the timeout period of {DebugProxyLaunchTimeout.TotalSeconds} seconds."));
            });
        }
 
        try
        {
            return await tcs.Task;
        }
        finally
        {
            ctr.Dispose();
        }
    }
 
    private static void RemoveUnwantedEnvironmentVariables(IDictionary<string, string?> environment)
    {
        // Generally we expect to pass through most environment variables, since dotnet might
        // need them for arbitrary reasons to function correctly. However, we specifically don't
        // want to pass through any ASP.NET Core hosting related ones, since the child process
        // shouldn't be trying to use the same port numbers, etc. In particular we need to break
        // the association with IISExpress and the MS-ASPNETCORE-TOKEN check.
        // For more context on this, see https://github.com/dotnet/aspnetcore/issues/20308.
        var keysToRemove = environment.Keys.Where(key => key.StartsWith("ASPNETCORE_", StringComparison.Ordinal)).ToList();
        foreach (var key in keysToRemove)
        {
            environment.Remove(key);
        }
    }
 
    private static string LocateDebugProxyExecutable(IWebHostEnvironment environment)
    {
        if (string.IsNullOrEmpty(environment.ApplicationName))
        {
            throw new InvalidOperationException("IWebHostEnvironment.ApplicationName is required to be set in order to start the debug proxy.");
        }
        var assembly = Assembly.Load(environment.ApplicationName);
        var debugProxyPath = Path.Combine(
            Path.GetDirectoryName(assembly.Location)!,
            "BlazorDebugProxy",
            "BrowserDebugHost.dll");
 
        if (!File.Exists(debugProxyPath))
        {
            throw new FileNotFoundException(
                $"Cannot start debug proxy because it cannot be found at '{debugProxyPath}'");
        }
 
        return debugProxyPath;
    }
 
    private static void PassThroughConsoleOutput(Process process)
    {
        process.OutputDataReceived += (sender, eventArgs) =>
        {
            // It's confusing if the debug proxy emits its own startup status messages, because the developer
            // may think the ports/environment/paths refer to their actual application. So we want to suppress
            // them, but we can't stop the debug proxy app from emitting the messages entirely (e.g., via
            // SuppressStatusMessages) because we need the "Now listening on" one to detect the chosen port.
            // Instead, we'll filter out known strings from the passthrough logic. It's legit to hardcode these
            // strings because they are also hardcoded like this inside WebHostExtensions.cs and can't vary
            // according to culture.
            if (eventArgs.Data is not null)
            {
                foreach (var prefix in MessageSuppressionPrefixes)
                {
                    if (eventArgs.Data.StartsWith(prefix, StringComparison.Ordinal))
                    {
                        return;
                    }
                }
            }
 
            Console.WriteLine(eventArgs.Data);
        };
    }
 
    private static void CompleteTaskWhenServerIsReady(Process aspNetProcess, bool isFirefox, TaskCompletionSource<string> taskCompletionSource)
    {
        string? capturedUrl = null;
        var errorEncountered = false;
 
        aspNetProcess.ErrorDataReceived += OnErrorDataReceived;
        aspNetProcess.BeginErrorReadLine();
 
        aspNetProcess.OutputDataReceived += OnOutputDataReceived;
        aspNetProcess.BeginOutputReadLine();
 
        void OnErrorDataReceived(object sender, DataReceivedEventArgs eventArgs)
        {
            if (!string.IsNullOrEmpty(eventArgs.Data))
            {
                taskCompletionSource.TrySetException(new InvalidOperationException(
                    eventArgs.Data));
                errorEncountered = true;
            }
        }
 
        void OnOutputDataReceived(object sender, DataReceivedEventArgs eventArgs)
        {
            if (string.IsNullOrEmpty(eventArgs.Data))
            {
                if (!errorEncountered)
                {
                    taskCompletionSource.TrySetException(new InvalidOperationException(
                        "Expected output has not been received from the application."));
                }
                return;
            }
 
            if (ApplicationStartedRegex.IsMatch(eventArgs.Data) && !isFirefox)
            {
                aspNetProcess.OutputDataReceived -= OnOutputDataReceived;
                aspNetProcess.ErrorDataReceived -= OnErrorDataReceived;
                if (!string.IsNullOrEmpty(capturedUrl))
                {
                    taskCompletionSource.TrySetResult(capturedUrl);
                }
                else
                {
                    taskCompletionSource.TrySetException(new InvalidOperationException(
                        "The application started listening without first advertising a URL"));
                }
            }
            else
            {
                var matchFirefox = NowListeningFirefoxRegex.Match(eventArgs.Data);
                if (matchFirefox.Success && isFirefox)
                {
                    aspNetProcess.OutputDataReceived -= OnOutputDataReceived;
                    aspNetProcess.ErrorDataReceived -= OnErrorDataReceived;
                    capturedUrl = matchFirefox.Groups["url"].Value;
                    taskCompletionSource.TrySetResult(capturedUrl);
                    return;
                }
                var match = NowListeningRegex.Match(eventArgs.Data);
                if (match.Success)
                {
                    capturedUrl = match.Groups["url"].Value;
                    capturedUrl = capturedUrl.Replace("http://", "ws://");
                    capturedUrl = capturedUrl.Replace("https://", "wss://");
                }
            }
        }
    }
}