diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/Microsoft.NET.Sdk.WebAssembly.Pack.pkgproj b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/Microsoft.NET.Sdk.WebAssembly.Pack.pkgproj index 32b0ded6b35fd..178b20ff6484c 100644 --- a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/Microsoft.NET.Sdk.WebAssembly.Pack.pkgproj +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/Microsoft.NET.Sdk.WebAssembly.Pack.pkgproj @@ -7,8 +7,20 @@ + + + + + + <_WasmAppHostFiles Include="$(WasmAppHostDir)\*" TargetPath="WasmAppHost" /> + + + + + + diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets index eb16d0272fa5c..be765da719e5b 100644 --- a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets @@ -11,6 +11,19 @@ Copyright (c) .NET Foundation. All rights reserved. --> + + <_UseBlazorDevServer>$(RunArguments.Contains('blazor-devserver.dll').ToString().ToLower()) + + + $(DOTNET_HOST_PATH) + dotnet + + $([MSBuild]::NormalizeDirectory($(MSBuildThisFileDirectory), '..', 'WasmAppHost')) + <_RuntimeConfigJsonPath>$([MSBuild]::NormalizePath($(OutputPath), '$(AssemblyName).runtimeconfig.json')) + exec "$([MSBuild]::NormalizePath($(WasmAppHostDir), 'WasmAppHost.dll'))" --use-staticwebassets --runtime-config "$(_RuntimeConfigJsonPath)" $(WasmHostArguments) + $(OutputPath) + + true @@ -61,7 +74,6 @@ Copyright (c) .NET Foundation. All rights reserved. false - false false false true diff --git a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/AppSettingsTests.cs b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/AppSettingsTests.cs index 965ae20558ec3..96f2c4ebd6a1b 100644 --- a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/AppSettingsTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/AppSettingsTests.cs @@ -30,7 +30,6 @@ public async Task LoadAppSettingsBasedOnApplicationEnvironment(string applicatio var result = await RunSdkStyleApp(new( Configuration: "Debug", - ForPublish: true, TestScenario: "AppSettingsTest", BrowserQueryString: new Dictionary { ["applicationEnvironment"] = applicationEnvironment } )); diff --git a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/AppTestBase.cs b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/AppTestBase.cs index 71ce260ea4910..31cbf007659d6 100644 --- a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/AppTestBase.cs +++ b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/AppTestBase.cs @@ -57,8 +57,8 @@ protected string GetBinLogFilePath(string suffix = null) protected async Task RunSdkStyleApp(RunOptions options) { - string runArgs = $"{s_xharnessRunnerCommand} wasm webserver --app=. --web-server-use-default-files"; - string workingDirectory = Path.GetFullPath(Path.Combine(FindBlazorBinFrameworkDir(options.Configuration, forPublish: options.ForPublish), "..")); + string runArgs = $"run -c {options.Configuration}"; + string workingDirectory = _projectDir; using var runCommand = new RunCommand(s_buildEnv, _testOutput) .WithWorkingDirectory(workingDirectory); @@ -123,7 +123,6 @@ protected record RunOptions( string Configuration, string TestScenario, Dictionary BrowserQueryString = null, - bool ForPublish = false, Action OnConsoleMessage = null, int? ExpectedExitCode = 0 ); diff --git a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/DownloadResourceProgressTests.cs b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/DownloadResourceProgressTests.cs index 0015476f92d08..70f9b4f1507d2 100644 --- a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/DownloadResourceProgressTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/DownloadResourceProgressTests.cs @@ -30,7 +30,6 @@ public async Task DownloadProgressFinishes(bool failAssemblyDownload) var result = await RunSdkStyleApp(new( Configuration: "Debug", - ForPublish: true, TestScenario: "DownloadResourceProgressTest", BrowserQueryString: new Dictionary { ["failAssemblyDownload"] = failAssemblyDownload.ToString().ToLowerInvariant() } )); diff --git a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/LazyLoadingTests.cs b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/LazyLoadingTests.cs index 022f700775ba9..d2219e9f9e50d 100644 --- a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/LazyLoadingTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/LazyLoadingTests.cs @@ -26,7 +26,7 @@ public async Task LoadLazyAssemblyBeforeItIsNeeded() CopyTestAsset("WasmBasicTestApp", "LazyLoadingTests"); PublishProject("Debug"); - var result = await RunSdkStyleApp(new(Configuration: "Debug", ForPublish: true, TestScenario: "LazyLoadingTest")); + var result = await RunSdkStyleApp(new(Configuration: "Debug", TestScenario: "LazyLoadingTest")); Assert.True(result.TestOutput.Any(m => m.Contains("FirstName")), "The lazy loading test didn't emit expected message with JSON"); } @@ -38,7 +38,6 @@ public async Task FailOnMissingLazyAssembly() var result = await RunSdkStyleApp(new( Configuration: "Debug", - ForPublish: true, TestScenario: "LazyLoadingTest", BrowserQueryString: new Dictionary { ["loadRequiredAssembly"] = "false" }, ExpectedExitCode: 1 diff --git a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/LibraryInitializerTests.cs b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/LibraryInitializerTests.cs index bd33d2b34cb84..6f68a96ad1d61 100644 --- a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/LibraryInitializerTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/LibraryInitializerTests.cs @@ -29,7 +29,7 @@ public async Task LoadLibraryInitializer() CopyTestAsset("WasmBasicTestApp", "LibraryInitializerTests_LoadLibraryInitializer"); PublishProject("Debug"); - var result = await RunSdkStyleApp(new(Configuration: "Debug", ForPublish: true, TestScenario: "LibraryInitializerTest")); + var result = await RunSdkStyleApp(new(Configuration: "Debug", TestScenario: "LibraryInitializerTest")); Assert.Collection( result.TestOutput, m => Assert.Equal("LIBRARY_INITIALIZER_TEST = 1", m) @@ -44,7 +44,6 @@ public async Task AbortStartupOnError() var result = await RunSdkStyleApp(new( Configuration: "Debug", - ForPublish: true, TestScenario: "LibraryInitializerTest", BrowserQueryString: new Dictionary { ["throwError"] = "true" }, ExpectedExitCode: 1 diff --git a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/SatelliteLoadingTests.cs b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/SatelliteLoadingTests.cs index 22b41ea798dbe..0156ab1a017c5 100644 --- a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/SatelliteLoadingTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/SatelliteLoadingTests.cs @@ -27,9 +27,8 @@ public SatelliteLoadingTests(ITestOutputHelper output, SharedBuildPerTestClassFi public async Task LoadSatelliteAssembly() { CopyTestAsset("WasmBasicTestApp", "SatelliteLoadingTests"); - PublishProject("Debug"); - var result = await RunSdkStyleApp(new(Configuration: "Debug", ForPublish: true, TestScenario: "SatelliteAssembliesTest")); + var result = await RunSdkStyleApp(new(Configuration: "Debug", TestScenario: "SatelliteAssembliesTest")); Assert.Collection( result.TestOutput, m => Assert.Equal("default: hello", m), diff --git a/src/mono/wasm/host/BrowserArguments.cs b/src/mono/wasm/host/BrowserArguments.cs index 27ee2fb36978e..eefd52a2529b9 100644 --- a/src/mono/wasm/host/BrowserArguments.cs +++ b/src/mono/wasm/host/BrowserArguments.cs @@ -4,6 +4,7 @@ #nullable enable using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using Mono.Options; @@ -37,8 +38,8 @@ public void ParseJsonProperties(IDictionary? properties) ForwardConsoleOutput = forwardConsoleElement.GetBoolean(); } + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to validate instance members")] public void Validate() { - CommonConfiguration.CheckPathOrInAppPath(CommonConfig.AppPath, HTMLPath, "html-path"); } } diff --git a/src/mono/wasm/host/BrowserHost.cs b/src/mono/wasm/host/BrowserHost.cs index ec39e008d712d..5c16a420e76de 100644 --- a/src/mono/wasm/host/BrowserHost.cs +++ b/src/mono/wasm/host/BrowserHost.cs @@ -4,7 +4,9 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Net.WebSockets; using System.Text; using System.Threading; @@ -12,6 +14,7 @@ using System.Web; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.WebAssembly.AppHost.DevServer; using Microsoft.WebAssembly.Diagnostics; #nullable enable @@ -44,7 +47,7 @@ public static async Task InvokeAsync(CommonConfiguration commonArgs, private async Task RunAsync(ILoggerFactory loggerFactory, CancellationToken token) { - if (_args.CommonConfig.Debugging) + if (_args.CommonConfig.Debugging && !_args.CommonConfig.UseStaticWebAssets) { ProxyOptions options = _args.CommonConfig.ToProxyOptions(); _ = Task.Run(() => DebugProxyHost.RunDebugProxyAsync(options, Array.Empty(), loggerFactory, token), token) @@ -75,8 +78,7 @@ private async Task RunAsync(ILoggerFactory loggerFactory, CancellationToken toke ? aspnetUrls.Split(';', StringSplitOptions.RemoveEmptyEntries) : new string[] { $"http://127.0.0.1:{_args.CommonConfig.HostProperties.WebServerPort}", "https://127.0.0.1:0" }; - (ServerURLs serverURLs, IWebHost host) = await StartWebServerAsync(_args.CommonConfig.AppPath, - _args.ForwardConsoleOutput ?? false, + (ServerURLs serverURLs, IWebHost host) = await StartWebServerAsync(_args, urls, token); @@ -85,32 +87,104 @@ private async Task RunAsync(ILoggerFactory loggerFactory, CancellationToken toke foreach (string url in fullUrls) Console.WriteLine($"App url: {url}"); + if (serverURLs.DebugPath != null) + { + Console.WriteLine($"Debug at url: {BuildUrl(serverURLs.Http, serverURLs.DebugPath, string.Empty)}"); + + if (serverURLs.Https != null) + Console.WriteLine($"Debug at url: {BuildUrl(serverURLs.Https, serverURLs.DebugPath, string.Empty)}"); + } + await host.WaitForShutdownAsync(token); } - private async Task<(ServerURLs, IWebHost)> StartWebServerAsync(string appPath, bool forwardConsole, string[] urls, CancellationToken token) + private async Task<(ServerURLs, IWebHost)> StartWebServerAsync(BrowserArguments args, string[] urls, CancellationToken token) { - WasmTestMessagesProcessor? logProcessor = null; - if (forwardConsole) + Func? onConsoleConnected = null; + if (args.ForwardConsoleOutput ?? false) { - logProcessor = new(_logger); + WasmTestMessagesProcessor logProcessor = new(_logger); + onConsoleConnected = socket => RunConsoleMessagesPump(socket, logProcessor!, token); } - WebServerOptions options = new - ( - OnConsoleConnected: forwardConsole - ? socket => RunConsoleMessagesPump(socket, logProcessor!, token) - : null, - ContentRootPath: Path.GetFullPath(appPath), - WebServerUseCors: true, - WebServerUseCrossOriginPolicy: true, - Urls: urls - ); - - (ServerURLs serverURLs, IWebHost host) = await WebServer.StartAsync(options, _logger, token); - return (serverURLs, host); + // If we are using new browser template, use dev server + if (args.CommonConfig.UseStaticWebAssets) + { + DevServerOptions devServerOptions = CreateDevServerOptions(args, urls, onConsoleConnected); + return await DevServer.DevServer.StartAsync(devServerOptions, _logger, token); + } + + // Otherwise for old template, use web server + WebServerOptions webServerOptions = CreateWebServerOptions(urls, args.CommonConfig.AppPath, onConsoleConnected); + return await WebServer.StartAsync(webServerOptions, _logger, token); } + private static WebServerOptions CreateWebServerOptions(string[] urls, string appPath, Func? onConsoleConnected) => new + ( + OnConsoleConnected: onConsoleConnected, + ContentRootPath: Path.GetFullPath(appPath), + WebServerUseCors: true, + WebServerUseCrossOriginPolicy: true, + Urls: urls + ); + + private static DevServerOptions CreateDevServerOptions(BrowserArguments args, string[] urls, Func? onConsoleConnected) + { + const string staticWebAssetsV1Extension = ".StaticWebAssets.xml"; + const string staticWebAssetsV2Extension = ".staticwebassets.runtime.json"; + + DevServerOptions? devServerOptions = null; + + string appPath = args.CommonConfig.AppPath; + if (args.CommonConfig.HostProperties.MainAssembly != null) + { + // If we have main assembly name, try to find static web assets manifest by precise name. + + var mainAssemblyPath = Path.Combine(appPath, args.CommonConfig.HostProperties.MainAssembly); + var staticWebAssetsPath = Path.ChangeExtension(mainAssemblyPath, staticWebAssetsV2Extension); + if (File.Exists(staticWebAssetsPath)) + { + devServerOptions = CreateDevServerOptions(urls, staticWebAssetsPath, onConsoleConnected); + } + else + { + staticWebAssetsPath = Path.ChangeExtension(mainAssemblyPath, staticWebAssetsV1Extension); + if (File.Exists(staticWebAssetsPath)) + devServerOptions = CreateDevServerOptions(urls, staticWebAssetsPath, onConsoleConnected); + } + + if (devServerOptions == null) + devServerOptions = CreateDevServerOptions(urls, mainAssemblyPath, onConsoleConnected); + } + else + { + // If we don't have main assembly name, try to find static web assets manifest by search in the directory. + + var staticWebAssetsPath = FindFirstFileWithExtension(appPath, staticWebAssetsV2Extension) + ?? FindFirstFileWithExtension(appPath, staticWebAssetsV1Extension); + + if (staticWebAssetsPath != null) + devServerOptions = CreateDevServerOptions(urls, staticWebAssetsPath, onConsoleConnected); + + if (devServerOptions == null) + throw new CommandLineException("Please, provide mainAssembly in hostProperties of runtimeconfig"); + } + + return devServerOptions; + } + + private static DevServerOptions CreateDevServerOptions(string[] urls, string staticWebAssetsPath, Func? onConsoleConnected) => new + ( + OnConsoleConnected: onConsoleConnected, + StaticWebAssetsPath: staticWebAssetsPath, + WebServerUseCors: true, + WebServerUseCrossOriginPolicy: true, + Urls: urls + ); + + private static string? FindFirstFileWithExtension(string directory, string extension) + => Directory.EnumerateFiles(directory, "*" + extension).First(); + private async Task RunConsoleMessagesPump(WebSocket socket, WasmTestMessagesProcessor messagesProcessor, CancellationToken token) { byte[] buff = new byte[4000]; @@ -169,7 +243,7 @@ private string[] BuildUrls(ServerURLs serverURLs, IEnumerable passThroug } string query = sb.ToString(); - string filename = Path.GetFileName(_args.HTMLPath!); + string? filename = _args.HTMLPath != null ? Path.GetFileName(_args.HTMLPath) : null; string httpUrl = BuildUrl(serverURLs.Http, filename, query); return string.IsNullOrEmpty(serverURLs.Https) @@ -179,12 +253,18 @@ private string[] BuildUrls(ServerURLs serverURLs, IEnumerable passThroug httpUrl, BuildUrl(serverURLs.Https!, filename, query) }); + } - static string BuildUrl(string baseUrl, string htmlFileName, string query) - => new UriBuilder(baseUrl) - { - Query = query, - Path = htmlFileName - }.ToString(); + private static string BuildUrl(string baseUrl, string? htmlFileName, string query) + { + var uriBuilder = new UriBuilder(baseUrl) + { + Query = query + }; + + if (htmlFileName != null) + uriBuilder.Path = htmlFileName; + + return uriBuilder.ToString(); } } diff --git a/src/mono/wasm/host/CommonConfiguration.cs b/src/mono/wasm/host/CommonConfiguration.cs index fc7e9eb8a4714..9e97bc4511930 100644 --- a/src/mono/wasm/host/CommonConfiguration.cs +++ b/src/mono/wasm/host/CommonConfiguration.cs @@ -22,9 +22,10 @@ internal sealed class CommonConfiguration public WasmHostProperties HostProperties { get; init; } public IEnumerable HostArguments { get; init; } public bool Silent { get; private set; } = true; + public bool UseStaticWebAssets { get; private set; } + public string? RuntimeConfigPath { get; private set; } private string? hostArg; - private string? _runtimeConfigPath; public static CommonConfiguration FromCommandLineArguments(string[] args) => new CommonConfiguration(args); @@ -35,13 +36,14 @@ private CommonConfiguration(string[] args) { { "debug|d", "Start debug server", _ => Debugging = true }, { "host|h=", "Host config name", v => hostArg = v }, - { "runtime-config|r=", "runtimeconfig.json path for the app", v => _runtimeConfigPath = v }, + { "runtime-config|r=", "runtimeconfig.json path for the app", v => RuntimeConfigPath = v }, { "extra-host-arg=", "Extra argument to be passed to the host", hostArgsList.Add }, - { "no-silent", "Verbose output from WasmAppHost", _ => Silent = false } + { "no-silent", "Verbose output from WasmAppHost", _ => Silent = false }, + { "use-staticwebassets", "Use static web assets, needed for projects targeting WebAssembly SDK", _ => UseStaticWebAssets = true } }; RemainingArgs = options.Parse(args); - if (string.IsNullOrEmpty(_runtimeConfigPath)) + if (string.IsNullOrEmpty(RuntimeConfigPath)) { string[] configs = Directory.EnumerateFiles(Environment.CurrentDirectory, "*.runtimeconfig.json").ToArray(); if (configs.Length == 0) @@ -50,16 +52,16 @@ private CommonConfiguration(string[] args) if (configs.Length > 1) throw new CommandLineException($"Found multiple runtimeconfig.json files: {string.Join(", ", configs)}. Use --runtime-config= to specify one"); - _runtimeConfigPath = Path.GetFullPath(configs[0]); + RuntimeConfigPath = Path.GetFullPath(configs[0]); } - AppPath = Path.GetDirectoryName(_runtimeConfigPath) ?? "."; + AppPath = Path.GetDirectoryName(RuntimeConfigPath) ?? "."; - if (string.IsNullOrEmpty(_runtimeConfigPath) || !File.Exists(_runtimeConfigPath)) - throw new CommandLineException($"Cannot find runtime config at {_runtimeConfigPath}"); + if (string.IsNullOrEmpty(RuntimeConfigPath) || !File.Exists(RuntimeConfigPath)) + throw new CommandLineException($"Cannot find runtime config at {RuntimeConfigPath}"); RuntimeConfig? rconfig = JsonSerializer.Deserialize( - File.ReadAllText(_runtimeConfigPath), + File.ReadAllText(RuntimeConfigPath), new JsonSerializerOptions(JsonSerializerDefaults.Web) { AllowTrailingCommas = true, @@ -67,14 +69,14 @@ private CommonConfiguration(string[] args) PropertyNameCaseInsensitive = true }); if (rconfig == null) - throw new CommandLineException($"Failed to deserialize {_runtimeConfigPath}"); + throw new CommandLineException($"Failed to deserialize {RuntimeConfigPath}"); if (rconfig.RuntimeOptions == null) - throw new CommandLineException($"Failed to deserialize {_runtimeConfigPath} - rconfig.RuntimeOptions"); + throw new CommandLineException($"Failed to deserialize {RuntimeConfigPath} - rconfig.RuntimeOptions"); HostProperties = rconfig.RuntimeOptions.WasmHostProperties; if (HostProperties == null) - throw new CommandLineException($"Could not find any {nameof(RuntimeOptions.WasmHostProperties)} in {_runtimeConfigPath}"); + throw new CommandLineException($"Could not find any {nameof(RuntimeOptions.WasmHostProperties)} in {RuntimeConfigPath}"); if (HostProperties.HostConfigs is null || HostProperties.HostConfigs.Count == 0) throw new CommandLineException($"no perHostConfigs found"); diff --git a/src/mono/wasm/host/DevServer/ComponentsWebAssemblyApplicationBuilderExtensions.cs b/src/mono/wasm/host/DevServer/ComponentsWebAssemblyApplicationBuilderExtensions.cs new file mode 100644 index 0000000000000..1409fa7b5868d --- /dev/null +++ b/src/mono/wasm/host/DevServer/ComponentsWebAssemblyApplicationBuilderExtensions.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Net.Http.Headers; +using System; +using System.IO; +using System.Net.Mime; + +namespace Microsoft.WebAssembly.AppHost.DevServer; + +internal static class ComponentsWebAssemblyApplicationBuilderExtensions +{ + private static readonly string? s_dotnetModifiableAssemblies = GetNonEmptyEnvironmentVariableValue("DOTNET_MODIFIABLE_ASSEMBLIES"); + private static readonly string? s_aspnetcoreBrowserTools = GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS"); + + private static string? GetNonEmptyEnvironmentVariableValue(string name) + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null; + + /// + /// Configures the application to serve Blazor WebAssembly framework files from the path . This path must correspond to a referenced Blazor WebAssembly application project. + /// + /// The . + /// The that indicates the prefix for the Blazor WebAssembly application. + /// The + public static IApplicationBuilder UseBlazorFrameworkFiles(this IApplicationBuilder builder, PathString pathPrefix) + { + ArgumentNullException.ThrowIfNull(builder); + + var webHostEnvironment = builder.ApplicationServices.GetRequiredService(); + + var options = CreateStaticFilesOptions(webHostEnvironment.WebRootFileProvider); + + builder.MapWhen( + ctx => ctx.Request.Path.StartsWithSegments(pathPrefix, out var rest) + && rest.StartsWithSegments("/_framework") + && !rest.StartsWithSegments("/_framework/blazor.server.js") + && !rest.StartsWithSegments("/_framework/blazor.web.js"), + subBuilder => + { + subBuilder.Use(async (context, next) => + { + context.Response.Headers.Append("DotNet-Environment", webHostEnvironment.EnvironmentName); + + // DOTNET_MODIFIABLE_ASSEMBLIES is used by the runtime to initialize hot-reload specific environment variables and is configured + // by the launching process (dotnet-watch / Visual Studio). + // Always add the header if the environment variable is set, regardless of the kind of environment. + if (s_dotnetModifiableAssemblies != null) + { + context.Response.Headers.Append("DOTNET-MODIFIABLE-ASSEMBLIES", s_dotnetModifiableAssemblies); + } + + // See https://github.com/dotnet/aspnetcore/issues/37357#issuecomment-941237000 + // Translate the _ASPNETCORE_BROWSER_TOOLS environment configured by the browser tools agent in to a HTTP response header. + if (s_aspnetcoreBrowserTools != null) + { + context.Response.Headers.Append("ASPNETCORE-BROWSER-TOOLS", s_aspnetcoreBrowserTools); + } + + await next(context); + }); + + subBuilder.UseMiddleware(); + + subBuilder.UseStaticFiles(options); + } + ); + + return builder; + } + + /// + /// Configures the application to serve Blazor WebAssembly framework files from the root path "/". + /// + /// The . + /// The + public static IApplicationBuilder UseBlazorFrameworkFiles(this IApplicationBuilder applicationBuilder) => + UseBlazorFrameworkFiles(applicationBuilder, default); + + private static StaticFileOptions CreateStaticFilesOptions(IFileProvider webRootFileProvider) + { + var options = new StaticFileOptions(); + options.FileProvider = webRootFileProvider; + var contentTypeProvider = new FileExtensionContentTypeProvider(); + AddMapping(contentTypeProvider, ".dll", MediaTypeNames.Application.Octet); + AddMapping(contentTypeProvider, ".webcil", MediaTypeNames.Application.Octet); + // We unconditionally map pdbs as there will be no pdbs in the output folder for + // release builds unless BlazorEnableDebugging is explicitly set to true. + AddMapping(contentTypeProvider, ".pdb", MediaTypeNames.Application.Octet); + AddMapping(contentTypeProvider, ".br", MediaTypeNames.Application.Octet); + AddMapping(contentTypeProvider, ".dat", MediaTypeNames.Application.Octet); + AddMapping(contentTypeProvider, ".blat", MediaTypeNames.Application.Octet); + + options.ContentTypeProvider = contentTypeProvider; + + // Static files middleware will try to use application/x-gzip as the content + // type when serving a file with a gz extension. We need to correct that before + // sending the file. + options.OnPrepareResponse = fileContext => + { + // At this point we mapped something from the /_framework + fileContext.Context.Response.Headers.Append(HeaderNames.CacheControl, "no-cache"); + + var requestPath = fileContext.Context.Request.Path; + var fileExtension = Path.GetExtension(requestPath.Value); + if (string.Equals(fileExtension, ".gz") || string.Equals(fileExtension, ".br")) + { + // When we are serving framework files (under _framework/ we perform content negotiation + // on the accept encoding and replace the path with <>.gz|br if we can serve gzip or brotli content + // respectively. + // Here we simply calculate the original content type by removing the extension and apply it + // again. + // When we revisit this, we should consider calculating the original content type and storing it + // in the request along with the original target path so that we don't have to calculate it here. + var originalPath = Path.GetFileNameWithoutExtension(requestPath.Value); + if (originalPath != null && contentTypeProvider.TryGetContentType(originalPath, out var originalContentType)) + { + fileContext.Context.Response.ContentType = originalContentType; + } + } + }; + + return options; + } + + private static void AddMapping(FileExtensionContentTypeProvider provider, string name, string mimeType) + { + if (!provider.Mappings.ContainsKey(name)) + { + provider.Mappings.Add(name, mimeType); + } + } +} diff --git a/src/mono/wasm/host/DevServer/ContentEncodingNegotiator.cs b/src/mono/wasm/host/DevServer/ContentEncodingNegotiator.cs new file mode 100644 index 0000000000000..7f701bc0a96c1 --- /dev/null +++ b/src/mono/wasm/host/DevServer/ContentEncodingNegotiator.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.WebAssembly.AppHost.DevServer; + +internal sealed class ContentEncodingNegotiator +{ + // List of encodings by preference order with their associated extension so that we can easily handle "*". + private static readonly StringSegment[] _preferredEncodings = + new StringSegment[] { "br", "gzip" }; + + private static readonly Dictionary _encodingExtensionMap = new Dictionary(StringSegmentComparer.OrdinalIgnoreCase) + { + ["br"] = ".br", + ["gzip"] = ".gz" + }; + + private readonly RequestDelegate _next; + private readonly IWebHostEnvironment _webHostEnvironment; + + public ContentEncodingNegotiator(RequestDelegate next, IWebHostEnvironment webHostEnvironment) + { + _next = next; + _webHostEnvironment = webHostEnvironment; + } + + public Task InvokeAsync(HttpContext context) + { + NegotiateEncoding(context); + return _next(context); + } + + private void NegotiateEncoding(HttpContext context) + { + var accept = context.Request.Headers.AcceptEncoding; + + if (StringValues.IsNullOrEmpty(accept)) + { + return; + } + + if (!StringWithQualityHeaderValue.TryParseList(accept, out var encodings) || encodings.Count == 0) + { + return; + } + + var selectedEncoding = StringSegment.Empty; + var selectedEncodingQuality = .0; + + foreach (var encoding in encodings) + { + var encodingName = encoding.Value; + var quality = encoding.Quality.GetValueOrDefault(1); + + if (quality >= double.Epsilon && quality >= selectedEncodingQuality) + { + if (quality == selectedEncodingQuality) + { + selectedEncoding = PickPreferredEncoding(context, selectedEncoding, encoding); + } + else if (_encodingExtensionMap.TryGetValue(encodingName, out var encodingExtension) && ResourceExists(context, encodingExtension)) + { + selectedEncoding = encodingName; + selectedEncodingQuality = quality; + } + + if (StringSegment.Equals("*", encodingName, StringComparison.Ordinal)) + { + // If we *, pick the first preferrent encoding for which a resource exists. + selectedEncoding = PickPreferredEncoding(context, default, encoding); + selectedEncodingQuality = quality; + } + + if (StringSegment.Equals("identity", encodingName, StringComparison.OrdinalIgnoreCase)) + { + selectedEncoding = StringSegment.Empty; + selectedEncodingQuality = quality; + } + } + } + + if (_encodingExtensionMap.TryGetValue(selectedEncoding, out var extension)) + { + context.Request.Path = context.Request.Path + extension; + context.Response.Headers.ContentEncoding = selectedEncoding.Value; + context.Response.Headers.Append(HeaderNames.Vary, HeaderNames.ContentEncoding); + } + + return; + + StringSegment PickPreferredEncoding(HttpContext context, StringSegment selectedEncoding, StringWithQualityHeaderValue encoding) + { + foreach (var preferredEncoding in _preferredEncodings) + { + if (preferredEncoding == selectedEncoding) + { + return selectedEncoding; + } + + if ((preferredEncoding == encoding.Value || encoding.Value == "*") && ResourceExists(context, _encodingExtensionMap[preferredEncoding])) + { + return preferredEncoding; + } + } + + return StringSegment.Empty; + } + } + + private bool ResourceExists(HttpContext context, string extension) => + _webHostEnvironment.WebRootFileProvider.GetFileInfo(context.Request.Path + extension).Exists; +} diff --git a/src/mono/wasm/host/DevServer/DebugProxyLauncher.cs b/src/mono/wasm/host/DevServer/DebugProxyLauncher.cs new file mode 100644 index 0000000000000..647ea29399168 --- /dev/null +++ b/src/mono/wasm/host/DevServer/DebugProxyLauncher.cs @@ -0,0 +1,216 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.WebAssembly.AppHost.DevServer; + +internal static class DebugProxyLauncher +{ + private static readonly object LaunchLock = new object(); + private static readonly TimeSpan DebugProxyLaunchTimeout = TimeSpan.FromSeconds(10); + private static Task? LaunchedDebugProxyUrl; + private static readonly Regex NowListeningRegex = new Regex(@"^\s*Now listening on: (?.*)$", 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://(?.*)\. 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 EnsureLaunchedAndGetUrl(IServiceProvider serviceProvider, string devToolsHost, bool isFirefox) + { + lock (LaunchLock) + { + LaunchedDebugProxyUrl ??= LaunchAndGetUrl(serviceProvider, devToolsHost, isFirefox); + + return LaunchedDebugProxyUrl; + } + } + + private static async Task LaunchAndGetUrl(IServiceProvider serviceProvider, string devToolsHost, bool isFirefox) + { + var tcs = new TaskCompletionSource(); + + var environment = serviceProvider.GetRequiredService(); + var executablePath = LocateDebugProxyExecutable(environment); + var ownerPid = Environment.ProcessId; + + var processStartInfo = new ProcessStartInfo + { + FileName = "dotnet" + (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""), + Arguments = $"exec \"{executablePath}\" --OwnerPid {ownerPid} --DevToolsUrl {devToolsHost} --IsFirefoxDebugging {isFirefox} --FirefoxProxyPort 6001", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + RemoveUnwantedEnvironmentVariables(processStartInfo.Environment); + + 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); + + 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 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); + } + } + + [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "Not published as a single file")] + 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)!, + "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 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://"); + } + } + } + } +} diff --git a/src/mono/wasm/host/DevServer/DevServer.cs b/src/mono/wasm/host/DevServer/DevServer.cs new file mode 100644 index 0000000000000..b1369deabcc0c --- /dev/null +++ b/src/mono/wasm/host/DevServer/DevServer.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.WebAssembly.AppHost; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.WebAssembly.AppHost.DevServer; + +internal static class DevServer +{ + internal static async Task<(ServerURLs, IWebHost)> StartAsync(DevServerOptions options, ILogger logger, CancellationToken token) + { + TaskCompletionSource realUrlsAvailableTcs = new(); + + IWebHostBuilder builder = new WebHostBuilder() + .UseConfiguration(ConfigureHostConfiguration(options)) + .UseKestrel() + .UseStaticWebAssets() + .UseStartup() + .ConfigureLogging(logging => + { + logging.AddConsole().AddFilter(null, LogLevel.Warning); + }) + .ConfigureServices((ctx, services) => + { + if (options.WebServerUseCors) + { + services.AddCors(o => o.AddPolicy("AnyCors", builder => + { + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() + .WithExposedHeaders("*"); + })); + } + services.AddSingleton(logger); + services.AddSingleton(Options.Create(options)); + services.AddSingleton(realUrlsAvailableTcs); + services.AddRouting(); + }); + + + IWebHost? host = builder.Build(); + await host.StartAsync(token); + + if (token.CanBeCanceled) + token.Register(async () => await host.StopAsync()); + + ServerURLs serverUrls = await realUrlsAvailableTcs.Task; + return (serverUrls, host); + } + + private static IConfiguration ConfigureHostConfiguration(DevServerOptions options) + { + var config = new ConfigurationBuilder(); + + var applicationDirectory = Path.GetDirectoryName(options.StaticWebAssetsPath)!; + + var inMemoryConfiguration = new Dictionary + { + [WebHostDefaults.EnvironmentKey] = "Development", + ["Logging:LogLevel:Microsoft"] = "Warning", + ["Logging:LogLevel:Microsoft.Hosting.Lifetime"] = "Information", + [WebHostDefaults.StaticWebAssetsKey] = options.StaticWebAssetsPath, + ["ApplyCopHeaders"] = options.WebServerUseCrossOriginPolicy.ToString() + }; + + config.AddInMemoryCollection(inMemoryConfiguration); + config.AddJsonFile(Path.Combine(applicationDirectory, "dotnet-devserversettings.json"), optional: true, reloadOnChange: true); + + return config.Build(); + } +} diff --git a/src/mono/wasm/host/DevServer/DevServerOptions.cs b/src/mono/wasm/host/DevServer/DevServerOptions.cs new file mode 100644 index 0000000000000..1b18deeba3269 --- /dev/null +++ b/src/mono/wasm/host/DevServer/DevServerOptions.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net.WebSockets; +using System.Threading.Tasks; + +namespace Microsoft.WebAssembly.AppHost.DevServer; + +internal sealed record DevServerOptions +( + Func? OnConsoleConnected, + string? StaticWebAssetsPath, + bool WebServerUseCors, + bool WebServerUseCrossOriginPolicy, + string[] Urls, + string DefaultFileName = "index.html" +); diff --git a/src/mono/wasm/host/DevServer/DevServerStartup.cs b/src/mono/wasm/host/DevServer/DevServerStartup.cs new file mode 100644 index 0000000000000..f438caf4b4b7a --- /dev/null +++ b/src/mono/wasm/host/DevServer/DevServerStartup.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.WebAssembly.AppHost; + +namespace Microsoft.WebAssembly.AppHost.DevServer; + +internal sealed class DevServerStartup +{ + public DevServerStartup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public static void ConfigureServices(IServiceCollection services) + { + services.AddRouting(); + } + + public static void Configure(IApplicationBuilder app, TaskCompletionSource realUrlsAvailableTcs, ILogger logger, IHostApplicationLifetime applicationLifetime, IConfiguration configuration) + { + app.UseDeveloperExceptionPage(); + EnableConfiguredPathbase(app, configuration); + + app.UseWebAssemblyDebugging(); + + bool applyCopHeaders = configuration.GetValue("ApplyCopHeaders"); + + if (applyCopHeaders) + { + app.Use(async (ctx, next) => + { + if (ctx.Request.Path.StartsWithSegments("/_framework") && !ctx.Request.Path.StartsWithSegments("/_framework/blazor.server.js") && !ctx.Request.Path.StartsWithSegments("/_framework/blazor.web.js")) + { + string fileExtension = Path.GetExtension(ctx.Request.Path); + if (string.Equals(fileExtension, ".js")) + { + // Browser multi-threaded runtime requires cross-origin policy headers to enable SharedArrayBuffer. + ApplyCrossOriginPolicyHeaders(ctx); + } + } + + await next(ctx); + }); + } + + app.UseBlazorFrameworkFiles(); + app.UseStaticFiles(new StaticFileOptions + { + // In development, serve everything, as there's no other way to configure it. + // In production, developers are responsible for configuring their own production server + ServeUnknownFileTypes = true, + }); + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToFile("index.html", new StaticFileOptions + { + OnPrepareResponse = fileContext => + { + if (applyCopHeaders) + { + // Browser multi-threaded runtime requires cross-origin policy headers to enable SharedArrayBuffer. + ApplyCrossOriginPolicyHeaders(fileContext.Context); + } + } + }); + }); + + ServerURLsProvider.ResolveServerUrlsOnApplicationStarted(app, logger, applicationLifetime, realUrlsAvailableTcs, "/_framework/debug"); + } + + private static void EnableConfiguredPathbase(IApplicationBuilder app, IConfiguration configuration) + { + var pathBase = configuration.GetValue("pathbase"); + if (!string.IsNullOrEmpty(pathBase)) + { + app.UsePathBase(pathBase); + + // To ensure consistency with a production environment, only handle requests + // that match the specified pathbase. + app.Use((context, next) => + { + if (context.Request.PathBase == pathBase) + { + return next(context); + } + else + { + context.Response.StatusCode = 404; + return context.Response.WriteAsync($"The server is configured only to " + + $"handle request URIs within the PathBase '{pathBase}'."); + } + }); + } + } + + private static void ApplyCrossOriginPolicyHeaders(HttpContext httpContext) + { + httpContext.Response.Headers["Cross-Origin-Embedder-Policy"] = "require-corp"; + httpContext.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin"; + } +} diff --git a/src/mono/wasm/host/DevServer/WebAssemblyNetDebugProxyAppBuilderExtensions.cs b/src/mono/wasm/host/DevServer/WebAssemblyNetDebugProxyAppBuilderExtensions.cs new file mode 100644 index 0000000000000..836ba2adc470e --- /dev/null +++ b/src/mono/wasm/host/DevServer/WebAssemblyNetDebugProxyAppBuilderExtensions.cs @@ -0,0 +1,499 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.WebAssembly.AppHost.DevServer; + +internal static class WebAssemblyNetDebugProxyAppBuilderExtensions +{ + /// + /// Adds middleware needed for debugging Blazor WebAssembly applications + /// inside Chromium dev tools. + /// + public static void UseWebAssemblyDebugging(this IApplicationBuilder app) + { + app.Map("/_framework/debug", app => + { + app.Run(async (context) => + { + var queryParams = HttpUtility.ParseQueryString(context.Request.QueryString.Value!); + var browserParam = queryParams.Get("browser"); + Uri? browserUrl = null; + var devToolsHost = "http://localhost:9222"; + if (browserParam != null) + { + browserUrl = new Uri(browserParam); + devToolsHost = $"http://{browserUrl.Host}:{browserUrl.Port}"; + } + var isFirefox = string.IsNullOrEmpty(queryParams.Get("isFirefox")) ? false : true; + if (isFirefox) + { + devToolsHost = "localhost:6000"; + } + var debugProxyBaseUrl = await DebugProxyLauncher.EnsureLaunchedAndGetUrl(context.RequestServices, devToolsHost, isFirefox); + var requestPath = context.Request.Path.ToString(); + if (requestPath == string.Empty) + { + requestPath = "/"; + } + + switch (requestPath) + { + case "/": + var targetPickerUi = new TargetPickerUi(debugProxyBaseUrl, devToolsHost); + if (isFirefox) + { + await targetPickerUi.DisplayFirefox(context); + } + else + { + await targetPickerUi.Display(context); + } + break; + case "/ws-proxy": + context.Response.Redirect($"{debugProxyBaseUrl}{browserUrl!.PathAndQuery}"); + break; + default: + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + break; + } + }); + }); + } +} + +internal sealed class TargetPickerUi +{ + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly string _browserHost; + private readonly string _debugProxyUrl; + + /// + /// Initialize a new instance of . + /// + /// The debug proxy url. + /// The dev tools host. + public TargetPickerUi([StringSyntax(StringSyntaxAttribute.Uri)] string debugProxyUrl, string devToolsHost) + { + _debugProxyUrl = debugProxyUrl; + _browserHost = devToolsHost; + } + + /// + /// Display the ui. + /// + /// The . + /// The . + public async Task DisplayFirefox(HttpContext context) + { + static async Task SendMessageToBrowser(NetworkStream toStream, ExpandoObject args, CancellationToken token) + { + var msg = JsonSerializer.Serialize(args); + var bytes = Encoding.UTF8.GetBytes(msg); + var bytesWithHeader = Encoding.UTF8.GetBytes($"{bytes.Length}:").Concat(bytes).ToArray(); + await toStream.WriteAsync(bytesWithHeader, token).AsTask(); + } +#pragma warning disable CA1835 + static async Task ReceiveMessageLoop(TcpClient browserDebugClientConnect, CancellationToken token) + { + var toStream = browserDebugClientConnect.GetStream(); + var bytesRead = 0; + var _lengthBuffer = new byte[10]; + while (bytesRead == 0 || Convert.ToChar(_lengthBuffer[bytesRead - 1]) != ':') + { + if (!browserDebugClientConnect.Connected) + { + return ""; + } + + if (bytesRead + 1 > _lengthBuffer.Length) + { + throw new IOException($"Protocol error: did not get the expected length preceding a message, " + + $"after reading {bytesRead} bytes. Instead got: {Encoding.UTF8.GetString(_lengthBuffer)}"); + } + + int readLen = await toStream.ReadAsync(_lengthBuffer, bytesRead, 1, token); + bytesRead += readLen; + } + string str = Encoding.UTF8.GetString(_lengthBuffer, 0, bytesRead - 1); + if (!int.TryParse(str, out int messageLen)) + { + return ""; + } + byte[] buffer = new byte[messageLen]; + bytesRead = await toStream.ReadAsync(buffer, 0, messageLen, token); + while (bytesRead != messageLen) + { + if (!browserDebugClientConnect.Connected) + { + return ""; + } + bytesRead += await toStream.ReadAsync(buffer, bytesRead, messageLen - bytesRead, token); + } + var messageReceived = Encoding.UTF8.GetString(buffer, 0, messageLen); + return messageReceived; + } + static async Task EvaluateOnBrowser(NetworkStream toStream, string? to, string text, CancellationToken token) + { + dynamic message = new ExpandoObject(); + dynamic options = new ExpandoObject(); + dynamic awaitObj = new ExpandoObject(); + awaitObj.@await = true; + options.eager = true; + options.mapped = awaitObj; + message.to = to; + message.type = "evaluateJSAsync"; + message.text = text; + message.options = options; + await SendMessageToBrowser(toStream, message, token); + } +#pragma warning restore CA1835 + + context.Response.ContentType = "text/html"; + var request = context.Request; + var targetApplicationUrl = request.Query["url"]; + var browserDebugClientConnect = new TcpClient(); + if (IPEndPoint.TryParse(_debugProxyUrl, out IPEndPoint? endpoint)) + { + try + { + await browserDebugClientConnect.ConnectAsync(endpoint.Address, 6000); + } + catch (Exception) + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync($@"WARNING: +Open about:config: +- enable devtools.debugger.remote-enabled +- enable devtools.chrome.enabled +- disable devtools.debugger.prompt-connection +Open firefox with remote debugging enabled on port 6000: +firefox --start-debugger-server 6000 -new-tab about:debugging"); + return; + } + var source = new CancellationTokenSource(); + var token = source.Token; + var toStream = browserDebugClientConnect.GetStream(); + dynamic messageListTabs = new ExpandoObject(); + messageListTabs.type = "listTabs"; + messageListTabs.to = "root"; + await SendMessageToBrowser(toStream, messageListTabs, token); + var tabToRedirect = -1; + var foundAboutDebugging = false; + string? consoleActorId = null; + string? toCmd = null; + while (browserDebugClientConnect.Connected) + { + var res = System.Text.Json.JsonDocument.Parse(await ReceiveMessageLoop(browserDebugClientConnect, token)).RootElement; + var hasTabs = res.TryGetProperty("tabs", out var tabs); + var hasType = res.TryGetProperty("type", out var type); + if (hasType && type.GetString()?.Equals("tabListChanged", StringComparison.Ordinal) == true) + { + await SendMessageToBrowser(toStream, messageListTabs, token); + } + else + { + if (hasTabs) + { + var tabsList = tabs.Deserialize(); + if (tabsList == null) + { + continue; + } + foreach (var tab in tabsList) + { + var hasUrl = tab.TryGetProperty("url", out var urlInTab); + var hasActor = tab.TryGetProperty("actor", out var actorInTab); + var hasBrowserId = tab.TryGetProperty("browserId", out var browserIdInTab); + if (string.IsNullOrEmpty(consoleActorId)) + { + if (hasUrl && urlInTab.GetString()?.StartsWith("about:debugging#", StringComparison.InvariantCultureIgnoreCase) == true) + { + foundAboutDebugging = true; + + toCmd = hasActor ? actorInTab.GetString() : ""; + if (tabToRedirect != -1) + { + break; + } + } + if (hasUrl && urlInTab.GetString()?.Equals(targetApplicationUrl, StringComparison.Ordinal) == true) + { + tabToRedirect = hasBrowserId ? browserIdInTab.GetInt32() : -1; + if (foundAboutDebugging) + { + break; + } + } + } + else if (hasUrl && urlInTab.GetString()?.StartsWith("about:devtools", StringComparison.InvariantCultureIgnoreCase) == true) + { + return; + } + } + if (!foundAboutDebugging) + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync("WARNING: Open about:debugging tab before pressing Debugging Hotkey"); + return; + } + if (string.IsNullOrEmpty(consoleActorId)) + { + await EvaluateOnBrowser(toStream, consoleActorId, $"if (AboutDebugging.store.getState().runtimes.networkRuntimes.find(element => element.id == \"{_debugProxyUrl}\").runtimeDetails !== null) {{ AboutDebugging.actions.selectPage(\"runtime\", \"{_debugProxyUrl}\"); if (AboutDebugging.store.getState().runtimes.selectedRuntimeId == \"{_debugProxyUrl}\") AboutDebugging.actions.inspectDebugTarget(\"tab\", {tabToRedirect})}};", token); + } + } + } + if (!string.IsNullOrEmpty(consoleActorId)) + { + var hasInput = res.TryGetProperty("input", out var input); + if (hasInput && input.GetString()?.StartsWith("AboutDebugging.actions.addNetworkLocation(", StringComparison.InvariantCultureIgnoreCase) == true) + { + await EvaluateOnBrowser(toStream, consoleActorId, $"if (AboutDebugging.store.getState().runtimes.networkRuntimes.find(element => element.id == \"{_debugProxyUrl}\").runtimeDetails !== null) {{ AboutDebugging.actions.selectPage(\"runtime\", \"{_debugProxyUrl}\"); if (AboutDebugging.store.getState().runtimes.selectedRuntimeId == \"{_debugProxyUrl}\") AboutDebugging.actions.inspectDebugTarget(\"tab\", {tabToRedirect})}};", token); + } + if (hasInput && input.GetString()?.StartsWith("if (AboutDebugging.store.getState()", StringComparison.InvariantCultureIgnoreCase) == true) + { + await EvaluateOnBrowser(toStream, consoleActorId, $"if (AboutDebugging.store.getState().runtimes.networkRuntimes.find(element => element.id == \"{_debugProxyUrl}\").runtimeDetails !== null) {{ AboutDebugging.actions.selectPage(\"runtime\", \"{_debugProxyUrl}\"); if (AboutDebugging.store.getState().runtimes.selectedRuntimeId == \"{_debugProxyUrl}\") AboutDebugging.actions.inspectDebugTarget(\"tab\", {tabToRedirect})}};", token); + } + } + else + { + var hasTarget = res.TryGetProperty("target", out var target); + JsonElement consoleActor = default; + var hasConsoleActor = hasTarget && target.TryGetProperty("consoleActor", out consoleActor); + var hasActor = res.TryGetProperty("actor", out var actor); + if (hasConsoleActor && !string.IsNullOrEmpty(consoleActor.GetString())) + { + consoleActorId = consoleActor.GetString(); + await EvaluateOnBrowser(toStream, consoleActorId, $"AboutDebugging.actions.addNetworkLocation(\"{_debugProxyUrl}\"); AboutDebugging.actions.connectRuntime(\"{_debugProxyUrl}\");", token); + } + else if (hasActor && !string.IsNullOrEmpty(actor.GetString())) + { + dynamic messageWatchTargets = new ExpandoObject(); + messageWatchTargets.type = "watchTargets"; + messageWatchTargets.targetType = "frame"; + messageWatchTargets.to = actor.GetString(); + await SendMessageToBrowser(toStream, messageWatchTargets, token); + dynamic messageWatchResources = new ExpandoObject(); + messageWatchResources.type = "watchResources"; + messageWatchResources.resourceTypes = new string[1] { "console-message" }; + messageWatchResources.to = actor.GetString(); + await SendMessageToBrowser(toStream, messageWatchResources, token); + } + else if (!string.IsNullOrEmpty(toCmd)) + { + dynamic messageGetWatcher = new ExpandoObject(); + messageGetWatcher.type = "getWatcher"; + messageGetWatcher.isServerTargetSwitchingEnabled = true; + messageGetWatcher.to = toCmd; + await SendMessageToBrowser(toStream, messageGetWatcher, token); + } + } + } + + } + return; + } + + /// + /// Display the ui. + /// + /// The . + /// The . + public async Task Display(HttpContext context) + { + context.Response.ContentType = "text/html"; + + var request = context.Request; + var targetApplicationUrl = request.Query["url"]; + + var debuggerTabsListUrl = $"{_browserHost}/json"; + IEnumerable availableTabs; + + try + { + availableTabs = await GetOpenedBrowserTabs(); + } + catch (Exception ex) + { + await context.Response.WriteAsync($@" +

Unable to find debuggable browser tab

+

+ Could not get a list of browser tabs from {debuggerTabsListUrl}. + Ensure your browser is running with debugging enabled. +

+

Resolution

+

+

If you are using Google Chrome or Chromium for your development, follow these instructions:

+ {GetLaunchChromeInstructions(targetApplicationUrl.ToString())} +

+

+

If you are using Microsoft Edge (80+) for your development, follow these instructions:

+ {GetLaunchEdgeInstructions(targetApplicationUrl.ToString())} +

+This should launch a new browser window with debugging enabled..

+

Underlying exception:

+
{ex}
+ "); + + 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(matchingTabs.Single()); + context.Response.Redirect(devToolsUrlWithProxy); + } + else if (matchingTabs.Count == 0) + { + await context.Response.WriteAsync("

No inspectable pages found

"); + + var suffix = string.IsNullOrEmpty(targetApplicationUrl) + ? string.Empty + : $" matching the URL {WebUtility.HtmlEncode(targetApplicationUrl)}"; + await context.Response.WriteAsync($"

The list of targets returned by {WebUtility.HtmlEncode(debuggerTabsListUrl)} contains no entries{suffix}.

"); + await context.Response.WriteAsync("

Make sure your browser is displaying the target application.

"); + } + else + { + await context.Response.WriteAsync("

Inspectable pages

"); + await context.Response.WriteAsync(@" + + "); + + foreach (var tab in matchingTabs) + { + var devToolsUrlWithProxy = GetDevToolsUrlWithProxy(tab); + await context.Response.WriteAsync( + $"" + + $"

{WebUtility.HtmlEncode(tab.Title)}

{WebUtility.HtmlEncode(tab.Url)}" + + $"
"); + } + } + } + + private string GetDevToolsUrlWithProxy(BrowserTab tabToDebug) + { + var underlyingV8Endpoint = new Uri(tabToDebug.WebSocketDebuggerUrl); + var proxyEndpoint = new Uri(_debugProxyUrl); + var devToolsUrlAbsolute = new Uri(_browserHost + tabToDebug.DevtoolsFrontendUrl); + var devToolsUrlWithProxy = $"{devToolsUrlAbsolute.Scheme}://{devToolsUrlAbsolute.Authority}{devToolsUrlAbsolute.AbsolutePath}?{underlyingV8Endpoint.Scheme}={proxyEndpoint.Authority}{underlyingV8Endpoint.PathAndQuery}"; + return devToolsUrlWithProxy; + } + + private string GetLaunchChromeInstructions(string targetApplicationUrl) + { + var profilePath = Path.Combine(Path.GetTempPath(), "blazor-chrome-debug"); + var debuggerPort = new Uri(_browserHost).Port; + + if (OperatingSystem.IsWindows()) + { + return $@"

Press Win+R and enter the following:

+

chrome --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" {targetApplicationUrl}

"; + } + else if (OperatingSystem.IsLinux()) + { + return $@"

In a terminal window execute the following:

+

google-chrome --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}

"; + } + else if (OperatingSystem.IsMacOS()) + { + return $@"

Execute the following:

+

open -n /Applications/Google\ Chrome.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}

"; + } + 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(_browserHost).Port; + + if (OperatingSystem.IsWindows()) + { + return $@"

Press Win+R and enter the following:

+

msedge --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" --no-first-run {targetApplicationUrl}

"; + } + else if (OperatingSystem.IsMacOS()) + { + return $@"

In a terminal window execute the following:

+

open -n /Applications/Microsoft\ Edge.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}

"; + } + else + { + return $@"

Edge is not current supported on your platform

"; + } + } + + private async Task> GetOpenedBrowserTabs() + { + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var jsonResponse = await httpClient.GetStringAsync($"{_browserHost}/json"); + return JsonSerializer.Deserialize(jsonResponse, JsonOptions)!; + } + + private sealed record BrowserTab + ( + string Id, + string Type, + string Url, + string Title, + string DevtoolsFrontendUrl, + string WebSocketDebuggerUrl + ); +} diff --git a/src/mono/wasm/host/WebServer.cs b/src/mono/wasm/host/WebServer.cs index 44d024320025c..22534567d2e3f 100644 --- a/src/mono/wasm/host/WebServer.cs +++ b/src/mono/wasm/host/WebServer.cs @@ -1,10 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -60,4 +67,44 @@ public class WebServer } // FIXME: can be simplified to string[] -public record ServerURLs(string Http, string? Https); +public record ServerURLs(string Http, string? Https, string? DebugPath = null); + +public static class ServerURLsProvider +{ + public static void ResolveServerUrlsOnApplicationStarted(IApplicationBuilder app, ILogger logger, IHostApplicationLifetime applicationLifetime, TaskCompletionSource realUrlsAvailableTcs, string? debugPath = null) + { + applicationLifetime.ApplicationStarted.Register(() => + { + TaskCompletionSource tcs = realUrlsAvailableTcs; + try + { + ICollection? addresses = app.ServerFeatures.Get()?.Addresses; + + string? ipAddress = null; + string? ipAddressSecure = null; + if (addresses is not null) + { + ipAddress = GetHttpServerAddress(addresses, secure: false); + ipAddressSecure = GetHttpServerAddress(addresses, secure: true); + } + + if (ipAddress == null) + tcs.SetException(new InvalidOperationException("Failed to determine web server's IP address or port")); + else + tcs.SetResult(new ServerURLs(ipAddress, ipAddressSecure, debugPath)); + } + catch (Exception ex) + { + logger?.LogError($"Failed to get urls for the webserver: {ex}"); + tcs.TrySetException(ex); + throw; + } + + static string? GetHttpServerAddress(ICollection addresses, bool secure) => addresses? + .Where(a => a.StartsWith(secure ? "https:" : "http:", StringComparison.InvariantCultureIgnoreCase)) + .Select(a => new Uri(a)) + .Select(uri => uri.ToString()) + .FirstOrDefault(); + }); + } +} diff --git a/src/mono/wasm/host/WebServerStartup.cs b/src/mono/wasm/host/WebServerStartup.cs index 07fdec475f205..c7f735f5ee8e0 100644 --- a/src/mono/wasm/host/WebServerStartup.cs +++ b/src/mono/wasm/host/WebServerStartup.cs @@ -168,41 +168,6 @@ public void Configure(IApplicationBuilder app, }); }); - applicationLifetime.ApplicationStarted.Register(() => - { - TaskCompletionSource tcs = realUrlsAvailableTcs; - try - { - ICollection? addresses = app.ServerFeatures - .Get() - ?.Addresses; - - string? ipAddress = null; - string? ipAddressSecure = null; - if (addresses is not null) - { - ipAddress = GetHttpServerAddress(addresses, secure: false); - ipAddressSecure = GetHttpServerAddress(addresses, secure: true); - } - - if (ipAddress == null) - tcs.SetException(new InvalidOperationException("Failed to determine web server's IP address or port")); - else - tcs.SetResult(new ServerURLs(ipAddress, ipAddressSecure)); - } - catch (Exception ex) - { - _logger?.LogError($"Failed to get urls for the webserver: {ex}"); - tcs.TrySetException(ex); - throw; - } - - static string? GetHttpServerAddress(ICollection addresses, bool secure) - => addresses? - .Where(a => a.StartsWith(secure ? "https:" : "http:", StringComparison.InvariantCultureIgnoreCase)) - .Select(a => new Uri(a)) - .Select(uri => uri.ToString()) - .FirstOrDefault(); - }); + ServerURLsProvider.ResolveServerUrlsOnApplicationStarted(app, logger, applicationLifetime, realUrlsAvailableTcs); } } diff --git a/src/mono/wasm/testassets/WasmBasicTestApp/runtimeconfig.template.json b/src/mono/wasm/testassets/WasmBasicTestApp/runtimeconfig.template.json new file mode 100644 index 0000000000000..b94cb7016d1ae --- /dev/null +++ b/src/mono/wasm/testassets/WasmBasicTestApp/runtimeconfig.template.json @@ -0,0 +1,11 @@ +{ + "wasmHostProperties": { + "perHostConfig": [ + { + "name": "browser", + "html-path": "index.html", + "Host": "browser" + } + ] + } +} \ No newline at end of file