Debug proxy as external tool (#19767)

This commit is contained in:
Steve Sanderson 2020-03-12 18:42:13 +00:00 committed by GitHub
parent 147c39289a
commit 2e9bb2ff5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 757 additions and 376 deletions

View File

@ -103,6 +103,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authentication.Msal", "Auth
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Authentication.WebAssembly.Msal", "WebAssembly\Authentication.Msal\src\Microsoft.Authentication.WebAssembly.Msal.csproj", "{2F105FA7-74DA-4855-9D8E-818DEE1F8D43}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DebugProxy", "DebugProxy", "{96DE9B14-D81F-422E-A33A-728BFB9C153A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Components.WebAssembly.DebugProxy", "WebAssembly\DebugProxy\src\Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj", "{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -521,6 +525,18 @@ Global
{2F105FA7-74DA-4855-9D8E-818DEE1F8D43}.Release|x64.Build.0 = Release|Any CPU
{2F105FA7-74DA-4855-9D8E-818DEE1F8D43}.Release|x86.ActiveCfg = Release|Any CPU
{2F105FA7-74DA-4855-9D8E-818DEE1F8D43}.Release|x86.Build.0 = Release|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|x64.ActiveCfg = Debug|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|x64.Build.0 = Debug|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|x86.ActiveCfg = Debug|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|x86.Build.0 = Debug|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|Any CPU.Build.0 = Release|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x64.ActiveCfg = Release|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x64.Build.0 = Release|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x86.ActiveCfg = Release|Any CPU
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -573,6 +589,8 @@ Global
{EAF50654-98ED-44BB-A120-0436EC0CD3E0} = {CBD2BB24-3EC3-4950-ABE4-8C521D258DCD}
{E4D756A7-A934-4D7F-BC6E-7B95FE4098AB} = {B29FB58D-FAE5-405E-9695-BCF93582BE9A}
{2F105FA7-74DA-4855-9D8E-818DEE1F8D43} = {E4D756A7-A934-4D7F-BC6E-7B95FE4098AB}
{96DE9B14-D81F-422E-A33A-728BFB9C153A} = {B29FB58D-FAE5-405E-9695-BCF93582BE9A}
{8BB1A8BE-F002-40A2-9B8E-439284B21C1C} = {96DE9B14-D81F-422E-A33A-728BFB9C153A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {27A36094-AA50-4FFD-ADE6-C055E391F741}

View File

@ -0,0 +1,10 @@
// 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.
namespace Microsoft.AspNetCore.Components.WebAssembly.DebugProxy
{
public class DebugProxyOptions
{
public string BrowserHost { get; set; }
}
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
<OutputType>Exe</OutputType>
<PackageId>Microsoft.AspNetCore.Components.WebAssembly.DebugProxy</PackageId>
<IsShippingPackage>true</IsShippingPackage>
<HasReferenceAssembly>false</HasReferenceAssembly>
<Description>Debug proxy for use when building Blazor applications.</Description>
<!-- Set this to false because assemblies should not reference this assembly directly, (except for tests, of course). -->
<IsProjectReferenceProvider>false</IsProjectReferenceProvider>
<!-- This is so that we add the FrameworkReference to Microsoft.AspNetCore.App -->
<UseLatestAspNetCoreReference>true</UseLatestAspNetCoreReference>
<MicrosoftAspNetCoreAppVersion>3.1.0</MicrosoftAspNetCoreAppVersion>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
<!-- Dependencies of ws-proxy sources -->
<Reference Include="Newtonsoft.Json" />
<Reference Include="Mono.Cecil" />
</ItemGroup>
</Project>

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;

View File

@ -0,0 +1,91 @@
// 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 System;
using System.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.CommandLineUtils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Microsoft.AspNetCore.Components.WebAssembly.DebugProxy
{
public class Program
{
static int Main(string[] args)
{
var app = new CommandLineApplication(throwOnUnexpectedArg: false)
{
Name = "webassembly-debugproxy"
};
app.HelpOption("-?|-h|--help");
var browserHostOption = new CommandOption("-b|--browser-host", CommandOptionType.SingleValue)
{
Description = "Host on which the browser is listening for debug connections. Example: http://localhost:9300"
};
var ownerPidOption = new CommandOption("-op|--owner-pid", CommandOptionType.SingleValue)
{
Description = "ID of the owner process. The debug proxy will shut down if this process exits."
};
app.Options.Add(browserHostOption);
app.Options.Add(ownerPidOption);
app.OnExecute(() =>
{
var host = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((hostingContext, config) =>
{
config.AddCommandLine(args);
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
// By default we bind to a dyamic port
// This can be overridden using an option like "--urls http://localhost:9500"
webBuilder.UseUrls("http://127.0.0.1:0");
})
.ConfigureServices(serviceCollection =>
{
serviceCollection.AddSingleton(new DebugProxyOptions
{
BrowserHost = browserHostOption.HasValue()
? browserHostOption.Value()
: "http://127.0.0.1:9222",
});
})
.Build();
if (ownerPidOption.HasValue())
{
var ownerProcess = Process.GetProcessById(int.Parse(ownerPidOption.Value()));
ownerProcess.EnableRaisingEvents = true;
ownerProcess.Exited += async (sender, eventArgs) =>
{
Console.WriteLine("Exiting because parent process has exited");
await host.StopAsync();
};
}
host.Run();
return 0;
});
try
{
return app.Execute(args);
}
catch (CommandParsingException cex)
{
app.Error.WriteLine(cex.Message);
app.ShowHelp();
return 1;
}
}
}
}

View File

@ -0,0 +1,45 @@
// 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 System;
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using WebAssembly.Net.Debugging;
namespace Microsoft.AspNetCore.Components.WebAssembly.DebugProxy
{
public class Startup
{
public void Configure(IApplicationBuilder app, DebugProxyOptions debugProxyOptions)
{
app.UseDeveloperExceptionPage();
app.UseWebSockets();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
// At the homepage, we check whether we can uniquely identify the target tab
// - If yes, we redirect directly to the debug tools, proxying to that tab
// - If no, we present a list of available tabs for the user to pick from
endpoints.MapGet("/", new TargetPickerUi(debugProxyOptions).Display);
// At this URL, we wire up the actual WebAssembly proxy
endpoints.MapGet("/ws-proxy", async (context) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
return;
}
var loggerFactory = context.RequestServices.GetRequiredService<ILoggerFactory>();
var browserUri = new Uri(context.Request.Query["browser"]);
var ideSocket = await context.WebSockets.AcceptWebSocketAsync();
await new MonoProxy(loggerFactory).Run(browserUri, ideSocket);
});
});
}
}
}

View File

@ -0,0 +1,226 @@
// 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 System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.Components.WebAssembly.DebugProxy
{
public class TargetPickerUi
{
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
IgnoreNullValues = true
};
private readonly DebugProxyOptions _options;
public TargetPickerUi(DebugProxyOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public async Task Display(HttpContext context)
{
context.Response.ContentType = "text/html";
var request = context.Request;
var targetApplicationUrl = request.Query["url"];
var debuggerTabsListUrl = $"{_options.BrowserHost}/json";
IEnumerable<BrowserTab> availableTabs;
try
{
availableTabs = await GetOpenedBrowserTabs();
}
catch (Exception ex)
{
await context.Response.WriteAsync($@"
<h1>Unable to find debuggable browser tab</h1>
<p>
Could not get a list of browser tabs from <code>{debuggerTabsListUrl}</code>.
Ensure your browser is running with debugging enabled.
</p>
<h2>Resolution</h2>
<p>
<h4>If you are using Google Chrome for your development, follow these instructions:</h4>
{GetLaunchChromeInstructions(targetApplicationUrl)}
</p>
<p>
<h4>If you are using Microsoft Edge (Chromium) for your development, follow these instructions:</h4>
{GetLaunchEdgeInstructions(targetApplicationUrl)}
</p>
<strong>This should launch a new browser window with debugging enabled..</p>
<h2>Underlying exception:</h2>
<pre>{ex}</pre>
");
return;
}
var matchingTabs = string.IsNullOrEmpty(targetApplicationUrl)
? availableTabs.ToList()
: availableTabs.Where(t => t.Url.Equals(targetApplicationUrl, StringComparison.Ordinal)).ToList();
if (matchingTabs.Count == 1)
{
// We know uniquely which tab to debug, so just redirect
var devToolsUrlWithProxy = GetDevToolsUrlWithProxy(request, matchingTabs.Single());
context.Response.Redirect(devToolsUrlWithProxy);
}
else if (matchingTabs.Count == 0)
{
await context.Response.WriteAsync("<h1>No inspectable pages found</h1>");
var suffix = string.IsNullOrEmpty(targetApplicationUrl)
? string.Empty
: $" matching the URL {WebUtility.HtmlEncode(targetApplicationUrl)}";
await context.Response.WriteAsync($"<p>The list of targets returned by {WebUtility.HtmlEncode(debuggerTabsListUrl)} contains no entries{suffix}.</p>");
await context.Response.WriteAsync("<p>Make sure your browser is displaying the target application.</p>");
}
else
{
await context.Response.WriteAsync("<h1>Inspectable pages</h1>");
await context.Response.WriteAsync(@"
<style type='text/css'>
body {
font-family: Helvetica, Arial, sans-serif;
margin: 2rem 3rem;
}
.inspectable-page {
display: block;
background-color: #eee;
padding: 1rem 1.2rem;
margin-bottom: 1rem;
border-radius: 0.5rem;
text-decoration: none;
color: #888;
}
.inspectable-page:hover {
background-color: #fed;
}
.inspectable-page h3 {
margin-top: 0px;
margin-bottom: 0.3rem;
color: black;
}
</style>
");
foreach (var tab in matchingTabs)
{
var devToolsUrlWithProxy = GetDevToolsUrlWithProxy(request, tab);
await context.Response.WriteAsync(
$"<a class='inspectable-page' href='{WebUtility.HtmlEncode(devToolsUrlWithProxy)}'>"
+ $"<h3>{WebUtility.HtmlEncode(tab.Title)}</h3>{WebUtility.HtmlEncode(tab.Url)}"
+ $"</a>");
}
}
}
private string GetDevToolsUrlWithProxy(HttpRequest request, BrowserTab tabToDebug)
{
var underlyingV8Endpoint = tabToDebug.WebSocketDebuggerUrl;
var proxyEndpoint = GetProxyEndpoint(request, underlyingV8Endpoint);
var devToolsUrlAbsolute = new Uri(_options.BrowserHost + tabToDebug.DevtoolsFrontendUrl);
var devToolsUrlWithProxy = $"{devToolsUrlAbsolute.Scheme}://{devToolsUrlAbsolute.Authority}{devToolsUrlAbsolute.AbsolutePath}?{proxyEndpoint.Scheme}={proxyEndpoint.Authority}{proxyEndpoint.PathAndQuery}";
return devToolsUrlWithProxy;
}
private string GetLaunchChromeInstructions(string targetApplicationUrl)
{
var profilePath = Path.Combine(Path.GetTempPath(), "blazor-chrome-debug");
var debuggerPort = new Uri(_options.BrowserHost).Port;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return $@"<p>Press Win+R and enter the following:</p>
<p><strong><code>chrome --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" {targetApplicationUrl}</code></strong></p>";
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return $@"<p>In a terminal window execute the following:</p>
<p><strong><code>google-chrome --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}</code></strong></p>";
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return $@"<p>Execute the following:</p>
<p><strong><code>open /Applications/Google\ Chrome.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}</code></strong></p>";
}
else
{
throw new InvalidOperationException("Unknown OS platform");
}
}
private string GetLaunchEdgeInstructions(string targetApplicationUrl)
{
var profilePath = Path.Combine(Path.GetTempPath(), "blazor-edge-debug");
var debuggerPort = new Uri(_options.BrowserHost).Port;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return $@"<p>Press Win+R and enter the following:</p>
<p><strong><code>msedge --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" --no-first-run {targetApplicationUrl}</code></strong></p>";
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return $@"<p>In a terminal window execute the following:</p>
<p><strong><code>open /Applications/Microsoft\ Edge\ Dev.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}</code></strong></p>";
}
else
{
return $@"<p>Edge is not current supported on your platform</p>";
}
}
private static Uri GetProxyEndpoint(HttpRequest incomingRequest, string browserEndpoint)
{
var builder = new UriBuilder(
schemeName: incomingRequest.IsHttps ? "wss" : "ws",
hostName: incomingRequest.Host.Host)
{
Path = $"{incomingRequest.PathBase}/ws-proxy",
Query = $"browser={WebUtility.UrlEncode(browserEndpoint)}"
};
if (incomingRequest.Host.Port.HasValue)
{
builder.Port = incomingRequest.Host.Port.Value;
}
return builder.Uri;
}
private async Task<IEnumerable<BrowserTab>> GetOpenedBrowserTabs()
{
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var jsonResponse = await httpClient.GetStringAsync($"{_options.BrowserHost}/json");
return JsonSerializer.Deserialize<BrowserTab[]>(jsonResponse, JsonOptions);
}
class BrowserTab
{
public string Id { get; set; }
public string Type { get; set; }
public string Url { get; set; }
public string Title { get; set; }
public string DevtoolsFrontendUrl { get; set; }
public string WebSocketDebuggerUrl { get; set; }
}
}
}

View File

@ -0,0 +1,131 @@
// 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 System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
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));
public static Task<string> EnsureLaunchedAndGetUrl(IServiceProvider serviceProvider)
{
lock (LaunchLock)
{
if (LaunchedDebugProxyUrl == null)
{
LaunchedDebugProxyUrl = LaunchAndGetUrl(serviceProvider);
}
return LaunchedDebugProxyUrl;
}
}
private static async Task<string> LaunchAndGetUrl(IServiceProvider serviceProvider)
{
var tcs = new TaskCompletionSource<string>();
var environment = serviceProvider.GetRequiredService<IWebHostEnvironment>();
var executablePath = LocateDebugProxyExecutable(environment);
var muxerPath = DotNetMuxer.MuxerPathOrDefault();
var ownerPid = Process.GetCurrentProcess().Id;
var processStartInfo = new ProcessStartInfo
{
FileName = muxerPath,
Arguments = $"exec \"{executablePath}\" --owner-pid {ownerPid}",
UseShellExecute = false,
RedirectStandardOutput = true,
};
RemoveUnwantedEnvironmentVariables(processStartInfo.Environment);
var debugProxyProcess = Process.Start(processStartInfo);
CompleteTaskWhenServerIsReady(debugProxyProcess, tcs);
new CancellationTokenSource(DebugProxyLaunchTimeout).Token.Register(() =>
{
tcs.TrySetException(new TimeoutException($"Failed to start the debug proxy within the timeout period of {DebugProxyLaunchTimeout.TotalSeconds} seconds."));
});
return await tcs.Task;
}
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.
var keysToRemove = environment.Keys.Where(key => key.StartsWith("ASPNETCORE_")).ToList();
foreach (var key in keysToRemove)
{
environment.Remove(key);
}
}
private static string LocateDebugProxyExecutable(IWebHostEnvironment environment)
{
var assembly = Assembly.Load(environment.ApplicationName);
var debugProxyPath = Path.Combine(
Path.GetDirectoryName(assembly.Location),
"BlazorDebugProxy",
"Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.dll");
if (!File.Exists(debugProxyPath))
{
throw new FileNotFoundException(
$"Cannot start debug proxy because it cannot be found at '{debugProxyPath}'");
}
return debugProxyPath;
}
private static void CompleteTaskWhenServerIsReady(Process aspNetProcess, TaskCompletionSource<string> taskCompletionSource)
{
string capturedUrl = null;
aspNetProcess.OutputDataReceived += OnOutputDataReceived;
aspNetProcess.BeginOutputReadLine();
void OnOutputDataReceived(object sender, DataReceivedEventArgs eventArgs)
{
if (ApplicationStartedRegex.IsMatch(eventArgs.Data))
{
aspNetProcess.OutputDataReceived -= OnOutputDataReceived;
if (!string.IsNullOrEmpty(capturedUrl))
{
taskCompletionSource.TrySetResult(capturedUrl);
}
else
{
taskCompletionSource.TrySetException(new InvalidOperationException(
"The application started listening without first advertising a URL"));
}
}
else
{
var match = NowListeningRegex.Match(eventArgs.Data);
if (match.Success)
{
capturedUrl = match.Groups["url"].Value;
}
}
}
}
}
}

View File

@ -7,17 +7,43 @@
<HasReferenceAssembly>false</HasReferenceAssembly>
<!-- This is so that we add the FrameworkReference to Microsoft.AspNetCore.App -->
<UseLatestAspNetCoreReference>true</UseLatestAspNetCoreReference>
<!-- We're deliberately bundling assemblies as content files. Suppress the warning. -->
<NoWarn>$(NoWarn);NU5100</NoWarn>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(ComponentsSharedSourceRoot)\src\CacheHeaderSettings.cs" Link="Shared\CacheHeaderSettings.cs" />
<Compile Include="$(SharedSourceRoot)\CommandLineUtils\Utilities\DotNetMuxer.cs" Link="Shared\DotNetMuxer.cs" />
<!-- Ensure debug proxy is built first, but don't create an actual reference, since we don't want its transitive dependencies. -->
<ProjectReference
Include="..\..\DebugProxy\src\Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj"
ReferenceOutputAssembly="false" />
<Content Include="build\**" Pack="true" PackagePath="build\%(RecursiveDir)%(FileName)%(Extension)" />
</ItemGroup>
<ItemGroup>
<Reference Include="Newtonsoft.Json" />
<!-- Emit debug proxy binaries to output directory. This lets us launch it as a separate process while keeping the build output self-contained. -->
<Target Name="IncludeDebugProxyBinariesAsContent" BeforeTargets="AssignTargetPaths">
<ItemGroup>
<DebugProxyBinaries Include="..\..\DebugProxy\src\bin\$(Configuration)\$(DefaultNetCoreTargetFramework)\**" />
<!-- Used by ws-proxy sources only. Remove this once we're able to consume ws-proxy as a NuGet package. -->
<Reference Include="Mono.Cecil" />
</ItemGroup>
<!--
For when we're building a package, we use Pack and PackagePath to bundle the debug proxy binaries into 'tools'.
Then we have a custom build target that converts these items into content files in consuming projects. We *don't*
use PackageCopyToOutput because that creates entries that show up in Solution Explorer in consuming projects.
For when we're consuming this from source in this repo, we use Link and CopyToOutputDirectory to produce the
same effect without having the custom build target.
-->
<Content
Include="@(DebugProxyBinaries)"
Pack="true"
PackagePath="tools\BlazorDebugProxy\%(RecursiveDir)%(FileName)%(Extension)"
Link="BlazorDebugProxy\%(RecursiveDir)%(FileName)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Target>
</Project>

View File

@ -1,370 +0,0 @@
// 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 System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using WebAssembly.Net.Debugging;
namespace Microsoft.AspNetCore.Builder
{
/// <summary>
/// Provides infrastructure for debugging Blazor WebAssembly applications.
/// </summary>
public static class WebAssemblyNetDebugProxyAppBuilderExtensions
{
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
IgnoreNullValues = true
};
private static readonly string DefaultDebuggerHost = "http://localhost:9222";
/// <summary>
/// Adds middleware for needed for debugging Blazor WebAssembly applications
/// inside Chromium dev tools.
/// </summary>
public static void UseWebAssemblyDebugging(this IApplicationBuilder app)
{
app.UseWebSockets();
app.UseVisualStudioDebuggerConnectionRequestHandlers();
app.Use((context, next) =>
{
var requestPath = context.Request.Path;
if (!requestPath.StartsWithSegments("/_framework/debug"))
{
return next();
}
if (requestPath.Equals("/_framework/debug/ws-proxy", StringComparison.OrdinalIgnoreCase))
{
var loggerFactory = app.ApplicationServices.GetRequiredService<ILoggerFactory>();
return DebugWebSocketProxyRequest(loggerFactory, context);
}
if (requestPath.Equals("/_framework/debug", StringComparison.OrdinalIgnoreCase))
{
return DebugHome(context);
}
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
return Task.CompletedTask;
});
}
private static string GetDebuggerHost()
{
var envVar = Environment.GetEnvironmentVariable("ASPNETCORE_WEBASSEMBLYDEBUGHOST");
if (string.IsNullOrEmpty(envVar))
{
return DefaultDebuggerHost;
}
else
{
return envVar;
}
}
private static int GetDebuggerPort()
{
var host = GetDebuggerHost();
return new Uri(host).Port;
}
private static void UseVisualStudioDebuggerConnectionRequestHandlers(this IApplicationBuilder app)
{
// Unfortunately VS doesn't send any deliberately distinguishing information so we know it's
// not a regular browser or API client. The closest we can do is look for the *absence* of a
// User-Agent header. In the future, we should try to get VS to send a special header to indicate
// this is a debugger metadata request.
app.Use(async (context, next) =>
{
var request = context.Request;
var requestPath = request.Path;
if (requestPath.StartsWithSegments("/json")
&& !request.Headers.ContainsKey("User-Agent"))
{
if (requestPath.Equals("/json", StringComparison.OrdinalIgnoreCase) || requestPath.Equals("/json/list", StringComparison.OrdinalIgnoreCase))
{
var availableTabs = await GetOpenedBrowserTabs();
// Filter the list to only include tabs displaying the requested app,
// but only during the "choose application to debug" phase. We can't apply
// the same filter during the "connecting" phase (/json/list), nor do we need to.
if (requestPath.Equals("/json"))
{
availableTabs = availableTabs.Where(tab => tab.Url.StartsWith($"{request.Scheme}://{request.Host}{request.PathBase}/"));
}
var proxiedTabInfos = availableTabs.Select(tab =>
{
var underlyingV8Endpoint = tab.WebSocketDebuggerUrl;
var proxiedScheme = request.IsHttps ? "wss" : "ws";
var proxiedV8Endpoint = $"{proxiedScheme}://{request.Host}{request.PathBase}/_framework/debug/ws-proxy?browser={WebUtility.UrlEncode(underlyingV8Endpoint)}";
return new
{
description = "",
devtoolsFrontendUrl = "",
id = tab.Id,
title = tab.Title,
type = tab.Type,
url = tab.Url,
webSocketDebuggerUrl = proxiedV8Endpoint
};
});
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(JsonSerializer.Serialize(proxiedTabInfos));
}
else if (requestPath.Equals("/json/version", StringComparison.OrdinalIgnoreCase))
{
// VS Code's "js-debug" nightly extension, when configured to use the "pwa-chrome"
// debug type, uses the /json/version endpoint to find the websocket endpoint for
// debugging the browser that listens on a user-specified port.
//
// To make this flow work with the Mono debug proxy, we pass the request through
// to the underlying browser (to get its actual version info) but then overwrite
// the "webSocketDebuggerUrl" with the URL to the proxy.
//
// This whole connection flow isn't very good because it doesn't have any way
// to specify the debug port for the underlying browser. So, we end up assuming
// the default port 9222 in all cases. This is good enough for a manual "attach"
// but isn't good enough if the IDE is responsible for launching the browser,
// as it will be on a random port. So,
//
// - VS isn't going to use this. Instead it will use a configured "debugEndpoint"
// property from which it can construct the proxy URL directly (including adding
// a "browser" querystring value to specify the underlying endpoint), bypassing
// /json/version altogether
// - We will need to update the VS Code debug adapter to make it do the same as VS
// if there is a "debugEndpoint" property configured
//
// Once both VS and VS Code support the "debugEndpoint" flow, we should be able to
// remove this /json/version code altogether. We should check that in-browser
// debugging still works at that point.
var browserVersionJsonStream = await GetBrowserVersionInfoAsync();
var browserVersion = await JsonSerializer.DeserializeAsync<Dictionary<string, object>>(browserVersionJsonStream);
if (browserVersion.TryGetValue("webSocketDebuggerUrl", out var browserEndpoint))
{
var proxyEndpoint = GetProxyEndpoint(request, ((JsonElement)browserEndpoint).GetString());
browserVersion["webSocketDebuggerUrl"] = proxyEndpoint;
}
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, browserVersion);
}
}
else
{
await next();
}
});
}
private static async Task DebugWebSocketProxyRequest(ILoggerFactory loggerFactory, HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
var browserUri = new Uri(context.Request.Query["browser"]);
var ideSocket = await context.WebSockets.AcceptWebSocketAsync();
await new MonoProxy(loggerFactory).Run(browserUri, ideSocket);
}
private static async Task DebugHome(HttpContext context)
{
context.Response.ContentType = "text/html";
var request = context.Request;
var appRootUrl = $"{request.Scheme}://{request.Host}{request.PathBase}/";
var targetTabUrl = request.Query["url"];
if (string.IsNullOrEmpty(targetTabUrl))
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync("No value specified for 'url'");
return;
}
// TODO: Allow overriding port (but not hostname, as we're connecting to the
// local browser, not to the webserver serving the app)
var debuggerHost = GetDebuggerHost();
var debuggerTabsListUrl = $"{debuggerHost}/json";
IEnumerable<BrowserTab> availableTabs;
try
{
availableTabs = await GetOpenedBrowserTabs();
}
catch (Exception ex)
{
await context.Response.WriteAsync($@"
<h1>Unable to find debuggable browser tab</h1>
<p>
Could not get a list of browser tabs from <code>{debuggerTabsListUrl}</code>.
Ensure your browser is running with debugging enabled.
</p>
<h2>Resolution</h2>
<p>
<h4>If you are using Google Chrome for your development, follow these instructions:</h4>
{GetLaunchChromeInstructions(appRootUrl)}
</p>
<p>
<h4>If you are using Microsoft Edge (Chromium) for your development, follow these instructions:</h4>
{GetLaunchEdgeInstructions(appRootUrl)}
</p>
<strong>This should launch a new browser window with debugging enabled..</p>
<h2>Underlying exception:</h2>
<pre>{ex}</pre>
");
return;
}
var matchingTabs = availableTabs
.Where(t => t.Url.Equals(targetTabUrl, StringComparison.Ordinal))
.ToList();
if (matchingTabs.Count == 0)
{
await context.Response.WriteAsync($@"
<h1>Unable to find debuggable browser tab</h1>
<p>
The response from <code>{debuggerTabsListUrl}</code> does not include
any entry for <code>{targetTabUrl}</code>.
</p>");
return;
}
else if (matchingTabs.Count > 1)
{
// TODO: Automatically disambiguate by adding a GUID to the page title
// when you press the debugger hotkey, include it in the querystring passed
// here, then remove it once the debugger connects.
await context.Response.WriteAsync($@"
<h1>Multiple matching tabs are open</h1>
<p>
There is more than one browser tab at <code>{targetTabUrl}</code>.
Close the ones you do not wish to debug, then refresh this page.
</p>");
return;
}
// Now we know uniquely which tab to debug, construct the URL to the debug
// page and redirect there
var tabToDebug = matchingTabs.Single();
var underlyingV8Endpoint = tabToDebug.WebSocketDebuggerUrl;
var proxyEndpoint = GetProxyEndpoint(request, underlyingV8Endpoint);
var devToolsUrlAbsolute = new Uri(debuggerHost + tabToDebug.DevtoolsFrontendUrl);
var devToolsUrlWithProxy = $"{devToolsUrlAbsolute.Scheme}://{devToolsUrlAbsolute.Authority}{devToolsUrlAbsolute.AbsolutePath}?{proxyEndpoint.Scheme}={proxyEndpoint.Authority}{proxyEndpoint.PathAndQuery}";
context.Response.Redirect(devToolsUrlWithProxy);
}
private static Uri GetProxyEndpoint(HttpRequest incomingRequest, string browserEndpoint)
{
var builder = new UriBuilder(
schemeName: incomingRequest.IsHttps ? "wss" : "ws",
hostName: incomingRequest.Host.Host)
{
Path = $"{incomingRequest.PathBase}/_framework/debug/ws-proxy",
Query = $"browser={WebUtility.UrlEncode(browserEndpoint)}"
};
if (incomingRequest.Host.Port.HasValue)
{
builder.Port = incomingRequest.Host.Port.Value;
}
return builder.Uri;
}
private static string GetLaunchChromeInstructions(string appRootUrl)
{
var profilePath = Path.Combine(Path.GetTempPath(), "blazor-chrome-debug");
var debuggerPort = GetDebuggerPort();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return $@"<p>Press Win+R and enter the following:</p>
<p><strong><code>chrome --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" {appRootUrl}</code></strong></p>";
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return $@"<p>In a terminal window execute the following:</p>
<p><strong><code>google-chrome --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return $@"<p>Execute the following:</p>
<p><strong><code>open /Applications/Google\ Chrome.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
}
else
{
throw new InvalidOperationException("Unknown OS platform");
}
}
private static string GetLaunchEdgeInstructions(string appRootUrl)
{
var profilePath = Path.Combine(Path.GetTempPath(), "blazor-edge-debug");
var debugggerPort = GetDebuggerPort();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return $@"<p>Press Win+R and enter the following:</p>
<p><strong><code>msedge --remote-debugging-port={debugggerPort} --user-data-dir=""{profilePath}"" --no-first-run {appRootUrl}</code></strong></p>";
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return $@"<p>In a terminal window execute the following:</p>
<p><strong><code>open /Applications/Microsoft\ Edge\ Dev.app --args --remote-debugging-port={debugggerPort} --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
}
else
{
return $@"<p>Edge is not current supported on your platform</p>";
}
}
private static async Task<Stream> GetBrowserVersionInfoAsync()
{
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var debuggerHost = GetDebuggerHost();
var response = await httpClient.GetAsync($"{debuggerHost}/json/version");
return await response.Content.ReadAsStreamAsync();
}
private static async Task<IEnumerable<BrowserTab>> GetOpenedBrowserTabs()
{
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var debuggerHost = GetDebuggerHost();
var jsonResponse = await httpClient.GetStringAsync($"{debuggerHost}/json");
return JsonSerializer.Deserialize<BrowserTab[]>(jsonResponse, JsonOptions);
}
class BrowserTab
{
public string Id { get; set; }
public string Type { get; set; }
public string Url { get; set; }
public string Title { get; set; }
public string DevtoolsFrontendUrl { get; set; }
public string WebSocketDebuggerUrl { get; set; }
}
}
}

View File

@ -0,0 +1,46 @@
// 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 System.Net;
namespace Microsoft.AspNetCore.Builder
{
/// <summary>
/// Provides infrastructure for debugging Blazor WebAssembly applications.
/// </summary>
public static class WebAssemblyNetDebugProxyAppBuilderExtensions
{
/// <summary>
/// Adds middleware for needed for debugging Blazor WebAssembly applications
/// inside Chromium dev tools.
/// </summary>
public static void UseWebAssemblyDebugging(this IApplicationBuilder app)
{
app.Map("/_framework/debug", app =>
{
app.Use(async (context, next) =>
{
var debugProxyBaseUrl = await DebugProxyLauncher.EnsureLaunchedAndGetUrl(context.RequestServices);
var requestPath = context.Request.Path.ToString();
if (requestPath == string.Empty)
{
requestPath = "/";
}
// Although we could redirect for every URL we see here, we filter the allowed set
// to ensure this doesn't get misused as some kind of more general redirector
switch (requestPath)
{
case "/":
case "/ws-proxy":
context.Response.Redirect($"{debugProxyBaseUrl}{requestPath}{context.Request.QueryString}");
break;
default:
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
break;
}
});
});
}
}
}

View File

@ -0,0 +1,16 @@
<Project>
<Target Name="_IncludeDebugProxyBinariesAsContent" BeforeTargets="AssignTargetPaths"
Condition="'$(BlazorWebAssemblyOmitDebugProxyOutput)' != 'true'">
<ItemGroup>
<_DebugProxyBinaries Include="$(MSBuildThisFileDirectory)..\tools\BlazorDebugProxy\**" />
<Content
Include="@(_DebugProxyBinaries)"
Link="BlazorDebugProxy\%(RecursiveDir)%(FileName)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Target>
</Project>

View File

@ -0,0 +1,29 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:56500/",
"sslPort": 44347
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"HostedInAspNet.Server": {
"commandName": "Project",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
}
}
}

View File

@ -0,0 +1,29 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:56502/",
"sslPort": 44332
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"StandaloneApp": {
"commandName": "Project",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
}
}
}

View File

@ -0,0 +1,58 @@
// 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.
// System.AppContext.GetData is not available in these frameworks
#if !NET451 && !NET452 && !NET46 && !NET461
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
namespace Microsoft.Extensions.CommandLineUtils
{
/// <summary>
/// Utilities for finding the "dotnet.exe" file from the currently running .NET Core application
/// </summary>
internal static class DotNetMuxer
{
private const string MuxerName = "dotnet";
static DotNetMuxer()
{
MuxerPath = TryFindMuxerPath();
}
/// <summary>
/// The full filepath to the .NET Core muxer.
/// </summary>
public static string MuxerPath { get; }
/// <summary>
/// Finds the full filepath to the .NET Core muxer,
/// or returns a string containing the default name of the .NET Core muxer ('dotnet').
/// </summary>
/// <returns>The path or a string named 'dotnet'.</returns>
public static string MuxerPathOrDefault()
=> MuxerPath ?? MuxerName;
private static string TryFindMuxerPath()
{
var fileName = MuxerName;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
fileName += ".exe";
}
var mainModule = Process.GetCurrentProcess().MainModule;
if (!string.IsNullOrEmpty(mainModule?.FileName)
&& Path.GetFileName(mainModule.FileName).Equals(fileName, StringComparison.OrdinalIgnoreCase))
{
return mainModule.FileName;
}
return null;
}
}
}
#endif