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 32b0ded6b35fd7..178b20ff6484cf 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 eb16d0272fa5c3..be765da719e5b2 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 965ae20558ec3e..96f2c4ebd6a1bf 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 71ce260ea4910f..31cbf007659d63 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 0015476f92d082..70f9b4f1507d28 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 022f700775ba9d..d2219e9f9e50da 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 bd33d2b34cb84e..6f68a96ad1d61a 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 22b41ea798dbec..0156ab1a017c51 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 27ee2fb36978e2..eefd52a2529b91 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 ec39e008d712da..5c16a420e76deb 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 fc7e9eb8a4714b..9e97bc4511930f 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 00000000000000..1409fa7b5868d0
--- /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 00000000000000..7f701bc0a96c1e
--- /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 00000000000000..647ea293991687
--- /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 00000000000000..b1369deabcc0cc
--- /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 00000000000000..1b18deeba3269d
--- /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 00000000000000..f438caf4b4b7ae
--- /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 00000000000000..836ba2adc470e2
--- /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 44d024320025cf..22534567d2e3f8 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 07fdec475f2054..c7f735f5ee8e06 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 00000000000000..b94cb7016d1aef
--- /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