diff --git a/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs b/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs index 8d139846350a..bc5cbc67d007 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs @@ -26,8 +26,7 @@ internal sealed class BrowserRefreshServer( ILoggerFactory loggerFactory, string middlewareAssemblyPath, string dotnetPath, - string? autoReloadWebSocketHostName, - int? autoReloadWebSocketPort, + WebSocketConfig webSocketConfig, bool suppressTimeouts) : AbstractBrowserRefreshServer(middlewareAssemblyPath, logger, loggerFactory) { @@ -36,21 +35,16 @@ protected override bool SuppressTimeouts protected override async ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken) { - var hostName = autoReloadWebSocketHostName ?? "127.0.0.1"; - var port = autoReloadWebSocketPort ?? 0; var supportsTls = await KestrelWebSocketServer.IsTlsSupportedAsync(dotnetPath, suppressTimeouts, cancellationToken); + if (!supportsTls) + { + webSocketConfig = webSocketConfig.WithSecurePort(null); + } - var server = new KestrelWebSocketServer(Logger, WebSocketRequestAsync); - await server.StartServerAsync(hostName, port, supportsTls ? 0 : null, cancellationToken); + var server = await KestrelWebSocketServer.StartServerAsync(webSocketConfig, WebSocketRequestAsync, cancellationToken); // URLs are only available after the server has started. - return new WebServerHost(server, GetServerUrls(server.ServerUrls), virtualDirectory: "/"); - } - - private ImmutableArray GetServerUrls(ImmutableArray serverUrls) - { - Debug.Assert(serverUrls.Length > 0); - return [.. serverUrls.Select(s => KestrelWebSocketServer.GetWebSocketUrl(s, autoReloadWebSocketHostName))]; + return new WebServerHost(server, server.ServerUrls, virtualDirectory: "/"); } private async Task WebSocketRequestAsync(HttpContext context) diff --git a/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs b/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs index 41d0522f5715..ec6033f648c6 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs @@ -26,55 +26,15 @@ namespace Microsoft.DotNet.HotReload; /// Sealed WebSocket server using Kestrel. /// Uses a request handler delegate for all WebSocket handling. /// -internal sealed class KestrelWebSocketServer : IDisposable +internal sealed class KestrelWebSocketServer(IHost host, ImmutableArray serverUrls) : IDisposable { - private readonly RequestDelegate _requestHandler; - private readonly ILogger _logger; - - private IHost? _host; - public ImmutableArray ServerUrls { get; private set; } = []; - - public KestrelWebSocketServer(ILogger logger, RequestDelegate requestHandler) - { - _logger = logger; - _requestHandler = requestHandler; - } + private static bool? s_lazyTlsSupported; public void Dispose() - { - _host?.Dispose(); - } + => host.Dispose(); - private static bool? s_lazyTlsSupported; - - /// - /// Checks whether TLS is supported by running dotnet dev-certs https --check --quiet. - /// - public static async ValueTask IsTlsSupportedAsync(string dotnetPath, bool suppressTimeouts, CancellationToken cancellationToken) - { - var result = s_lazyTlsSupported; - if (result.HasValue) - { - return result.Value; - } - - try - { - using var process = Process.Start(dotnetPath, "dev-certs https --check --quiet"); - await process - .WaitForExitAsync(cancellationToken) - .WaitAsync(suppressTimeouts ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), cancellationToken); - - result = process.ExitCode == 0; - } - catch - { - result = false; - } - - s_lazyTlsSupported = result; - return result.Value; - } + public ImmutableArray ServerUrls + => serverUrls; /// /// Starts the Kestrel WebSocket server. @@ -83,68 +43,80 @@ await process /// HTTP port to bind to (0 for auto-assign) /// HTTPS port to bind to in addition to HTTP port. Null to skip HTTPS. /// Cancellation token - public async ValueTask StartServerAsync(string hostName, int port, int? securePort, CancellationToken cancellationToken) + public static async ValueTask StartServerAsync(WebSocketConfig config, RequestDelegate requestHandler, CancellationToken cancellationToken) { - if (_host != null) - { - throw new InvalidOperationException("Server already started"); - } - - _host = new HostBuilder() + var host = new HostBuilder() .ConfigureWebHost(builder => { builder.UseKestrel(); - - if (securePort.HasValue) - { - builder.UseUrls($"http://{hostName}:{port}", $"https://{hostName}:{securePort.Value}"); - } - else - { - builder.UseUrls($"http://{hostName}:{port}"); - } + builder.UseUrls([.. config.GetHttpUrls()]); builder.Configure(app => { app.UseWebSockets(); - app.Run(_requestHandler); + app.Run(requestHandler); }); }) .Build(); - await _host.StartAsync(cancellationToken); + await host.StartAsync(cancellationToken); // URLs are only available after the server has started. - var addresses = _host.Services + var addresses = host.Services .GetRequiredService() .Features .Get()? - .Addresses; + .Addresses ?? []; + + return new KestrelWebSocketServer(host, serverUrls: [.. addresses.Select(GetWebSocketUrl)]); + } - if (addresses != null) + /// + /// Converts an HTTP(S) URL to a WebSocket URL and replaces 127.0.0.1 with localhost. + /// + internal static string GetWebSocketUrl(string httpUrl) + { + var uri = new Uri(httpUrl, UriKind.Absolute); + var builder = new UriBuilder(uri) + { + Scheme = uri.Scheme == "https" ? "wss" : "ws" + }; + + if (builder.Host == "127.0.0.1") { - ServerUrls = [.. addresses]; + builder.Host = "localhost"; } - _logger.LogDebug("WebSocket server started at: {Urls}", string.Join(", ", ServerUrls.Select(url => GetWebSocketUrl(url)))); + return builder.Uri.ToString().TrimEnd('/'); } /// - /// Converts an HTTP(S) URL to a WebSocket URL. - /// When is not specified, also replaces 127.0.0.1 with localhost. + /// Checks whether TLS is supported by running dotnet dev-certs https --check --quiet. /// - internal static string GetWebSocketUrl(string httpUrl, string? hostName = null) + public static async ValueTask IsTlsSupportedAsync(string dotnetPath, bool suppressTimeouts, CancellationToken cancellationToken) { - if (hostName is null) + var result = s_lazyTlsSupported; + if (result.HasValue) { - return httpUrl - .Replace("http://127.0.0.1", "ws://localhost", StringComparison.Ordinal) - .Replace("https://127.0.0.1", "wss://localhost", StringComparison.Ordinal); + return result.Value; } - return httpUrl - .Replace("https://", "wss://", StringComparison.Ordinal) - .Replace("http://", "ws://", StringComparison.Ordinal); + try + { + using var process = Process.Start(dotnetPath, "dev-certs https --check --quiet"); + await process + .WaitForExitAsync(cancellationToken) + .WaitAsync(suppressTimeouts ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), cancellationToken); + + result = process.ExitCode == 0; + } + catch + { + result = false; + } + + s_lazyTlsSupported = result; + return result.Value; } } diff --git a/src/Dotnet.Watch/HotReloadClient/Web/WebSocketConfig.cs b/src/Dotnet.Watch/HotReloadClient/Web/WebSocketConfig.cs new file mode 100644 index 000000000000..66887d28b348 --- /dev/null +++ b/src/Dotnet.Watch/HotReloadClient/Web/WebSocketConfig.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Collections.Generic; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct WebSocketConfig(int port, int? securePort, string? hostName) +{ + /// + /// 0 to auto-assign. + /// + public int Port => port; + + /// + /// 0 to auto-assign, null to disable HTTPS/WSS. + /// + public int? SecurePort => securePort; + + // Use 127.0.0.1 instead of "localhost" because Kestrel doesn't support dynamic port binding with "localhost". + // System.InvalidOperationException: Dynamic port binding is not supported when binding to localhost. + // You must either bind to 127.0.0.1:0 or [::1]:0, or both. + public string HostName => hostName ?? "127.0.0.1"; + + public IEnumerable GetHttpUrls() + { + yield return $"http://{HostName}:{Port}"; + + if (SecurePort.HasValue) + { + yield return $"https://{HostName}:{SecurePort.Value}"; + } + } + + public WebSocketConfig WithSecurePort(int? value) + => new(port, value, hostName); +} diff --git a/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs b/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs index b10f5aa63674..91bff33e95e0 100644 --- a/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs +++ b/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs @@ -28,178 +28,186 @@ namespace Microsoft.DotNet.HotReload; /// internal sealed class WebSocketClientTransport : ClientTransport { - private readonly ILogger _logger; private readonly KestrelWebSocketServer _server; - private readonly SharedSecretProvider _sharedSecretProvider = new(); - private readonly TaskCompletionSource _clientConnectedSource = new(); + private readonly RequestHandler _handler; - private WebSocket? _clientSocket; - - // Reused across WriteAsync calls to avoid allocations. - // WriteAsync is invoked under a semaphore in DefaultHotReloadClient. - private MemoryStream? _sendBuffer; + private WebSocketClientTransport(KestrelWebSocketServer server, RequestHandler handler) + { + _server = server; + _handler = handler; + } - private WebSocketClientTransport(ILogger logger) + public override void Dispose() { - _logger = logger; - _server = new KestrelWebSocketServer(logger, HandleRequestAsync); + _server.Dispose(); + _handler.Dispose(); } /// /// Creates and starts a new instance. /// - public static async Task CreateAsync(int port, int? securePort, ILogger logger, CancellationToken cancellationToken) + public static async Task CreateAsync(WebSocketConfig config, ILogger logger, CancellationToken cancellationToken) { - var transport = new WebSocketClientTransport(logger); - - // Start Kestrel server with WebSocket support. - // Use 127.0.0.1 instead of "localhost" because Kestrel doesn't support dynamic port binding with "localhost". - // System.InvalidOperationException: Dynamic port binding is not supported when binding to localhost. - // You must either bind to 127.0.0.1:0 or [::1]:0, or both. - await transport._server.StartServerAsync("127.0.0.1", port, securePort: securePort, cancellationToken); + var handler = new RequestHandler(logger); + var server = await KestrelWebSocketServer.StartServerAsync(config, handler.HandleRequestAsync, cancellationToken); + var transport = new WebSocketClientTransport(server, handler); + logger.LogDebug("WebSocket server started at: {Urls}", string.Join(", ", server.ServerUrls)); return transport; } - /// - /// The bound port number, for testing. Only valid after server has started. - /// - internal int Port => new Uri(_server.ServerUrls.Select(url => KestrelWebSocketServer.GetWebSocketUrl(url)).First()).Port; - public override void ConfigureEnvironment(IDictionary env) { // Set the WebSocket endpoint for the app to connect to. // Use the actual bound URL from the server (important when port 0 was requested). - env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint] = _server.ServerUrls.Select(url => KestrelWebSocketServer.GetWebSocketUrl(url)).First(); + env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint] = _server.ServerUrls.First(); // Set the RSA public key for the client to encrypt its shared secret. // This is the same authentication mechanism used by BrowserRefreshServer. - env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketKey] = _sharedSecretProvider.GetPublicKey(); + env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketKey] = _handler.SharedSecretProvider.GetPublicKey(); } - private async Task HandleRequestAsync(HttpContext context) + public override Task WaitForConnectionAsync(CancellationToken cancellationToken) + => _handler.ClientConnectedSource.Task.WaitAsync(cancellationToken); + + public override ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) + => _handler.WriteAsync(type, writePayload, cancellationToken); + + public override ValueTask ReadAsync(CancellationToken cancellationToken) + => _handler.ReadAsync(cancellationToken); + + private sealed class RequestHandler(ILogger logger) : IDisposable { - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } + public SharedSecretProvider SharedSecretProvider { get; } = new(); + public TaskCompletionSource ClientConnectedSource { get; } = new(); - // Validate the shared secret from the subprotocol - string? subProtocol = context.WebSockets.WebSocketRequestedProtocols is [var sp] ? sp : null; + private WebSocket? _clientSocket; - if (subProtocol == null) - { - _logger.LogWarning("WebSocket connection rejected: missing subprotocol (shared secret)"); - context.Response.StatusCode = 401; - return; - } + // Reused across WriteAsync calls to avoid allocations. + // WriteAsync is invoked under a semaphore in DefaultHotReloadClient. + private MemoryStream? _sendBuffer; - // Decrypt and validate the secret - try + public void Dispose() { - _sharedSecretProvider.DecryptSecret(WebUtility.UrlDecode(subProtocol)); + logger.LogDebug("Disposing agent websocket transport"); + + _sendBuffer?.Dispose(); + _clientSocket?.Dispose(); + SharedSecretProvider.Dispose(); } - catch (Exception ex) + + public async Task HandleRequestAsync(HttpContext context) { - _logger.LogWarning("WebSocket connection rejected: invalid shared secret - {Message}", ex.Message); - context.Response.StatusCode = 401; - return; - } + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } - var webSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); + // Validate the shared secret from the subprotocol + string? subProtocol = context.WebSockets.WebSocketRequestedProtocols is [var sp] ? sp : null; - _logger.LogDebug("WebSocket client connected"); + if (subProtocol == null) + { + logger.LogWarning("WebSocket connection rejected: missing subprotocol (shared secret)"); + context.Response.StatusCode = 401; + return; + } - _clientSocket = webSocket; - _clientConnectedSource.TrySetResult(webSocket); + // Decrypt and validate the secret + try + { + SharedSecretProvider.DecryptSecret(WebUtility.UrlDecode(subProtocol)); + } + catch (Exception ex) + { + logger.LogWarning("WebSocket connection rejected: invalid shared secret - {Message}", ex.Message); + context.Response.StatusCode = 401; + return; + } - // Keep the request alive until the connection is closed or aborted - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); - } - catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) - { - // Expected when the client disconnects or the request is aborted - } + var webSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); - _logger.LogDebug("WebSocket client disconnected"); - } + logger.LogDebug("WebSocket client connected"); - public override Task WaitForConnectionAsync(CancellationToken cancellationToken) => - _clientConnectedSource.Task.WaitAsync(cancellationToken); + _clientSocket = webSocket; + ClientConnectedSource.TrySetResult(webSocket); - public override async ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) - { - if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) - { - throw new InvalidOperationException("No active WebSocket connection from the client."); + // Keep the request alive until the connection is closed or aborted + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + // Expected when the client disconnects or the request is aborted + } + + logger.LogDebug("WebSocket client disconnected"); } - // Serialize the complete message to a reusable buffer, then send as a single WebSocket message - _sendBuffer ??= new MemoryStream(); - _sendBuffer.SetLength(0); + public async ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) + { + if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) + { + throw new InvalidOperationException("No active WebSocket connection from the client."); + } - await _sendBuffer.WriteAsync(type, cancellationToken); + // Serialize the complete message to a reusable buffer, then send as a single WebSocket message + _sendBuffer ??= new MemoryStream(); + _sendBuffer.SetLength(0); - if (writePayload != null) - { - await writePayload(_sendBuffer, cancellationToken); - } + await _sendBuffer.WriteAsync(type, cancellationToken); - await _clientSocket.SendAsync( - new ArraySegment(_sendBuffer.GetBuffer(), 0, (int)_sendBuffer.Length), - WebSocketMessageType.Binary, - endOfMessage: true, - cancellationToken); - } + if (writePayload != null) + { + await writePayload(_sendBuffer, cancellationToken); + } - public override async ValueTask ReadAsync(CancellationToken cancellationToken) - { - if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) - { - return null; + await _clientSocket.SendAsync( + new ArraySegment(_sendBuffer.GetBuffer(), 0, (int)_sendBuffer.Length), + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken); } - // Receive a complete WebSocket message - var buffer = ArrayPool.Shared.Rent(4096); - try + public async ValueTask ReadAsync(CancellationToken cancellationToken) { - var stream = new MemoryStream(); - WebSocketReceiveResult result; - do + if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) + { + return null; + } + + // Receive a complete WebSocket message + var buffer = ArrayPool.Shared.Rent(4096); + try { - result = await _clientSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); - if (result.MessageType == WebSocketMessageType.Close) + var stream = new MemoryStream(); + WebSocketReceiveResult result; + do { - stream.Dispose(); - return null; + result = await _clientSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + stream.Dispose(); + return null; + } + stream.Write(buffer, 0, result.Count); } - stream.Write(buffer, 0, result.Count); - } - while (!result.EndOfMessage); + while (!result.EndOfMessage); - stream.Position = 0; + stream.Position = 0; - // Read the response type byte from the message - var type = (ResponseType)await stream.ReadByteAsync(cancellationToken); - return new ClientTransportResponse(type, stream, disposeStream: true); - } - finally - { - ArrayPool.Shared.Return(buffer); + // Read the response type byte from the message + var type = (ResponseType)await stream.ReadByteAsync(cancellationToken); + return new ClientTransportResponse(type, stream, disposeStream: true); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } } - - public override void Dispose() - { - _logger.LogDebug("Disposing agent websocket transport"); - _sendBuffer?.Dispose(); - _clientSocket?.Dispose(); - _sharedSecretProvider.Dispose(); - _server.Dispose(); - } } #endif diff --git a/src/Dotnet.Watch/Watch/AppModels/MobileAppModel.cs b/src/Dotnet.Watch/Watch/AppModels/MobileAppModel.cs index fa3d16509151..198f06f6f20a 100644 --- a/src/Dotnet.Watch/Watch/AppModels/MobileAppModel.cs +++ b/src/Dotnet.Watch/Watch/AppModels/MobileAppModel.cs @@ -20,8 +20,7 @@ public override async ValueTask CreateClientsAsync(ILogger cli if (IsManagedAgentSupported(project, clientLogger)) { var transport = await WebSocketClientTransport.CreateAsync( - context.EnvironmentOptions.AgentWebSocketPort, - context.EnvironmentOptions.AgentWebSocketSecurePort, + context.EnvironmentOptions.AgentWebSocketConfig, clientLogger, cancellationToken); diff --git a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs index 2460d27a79e7..6258919dbdd0 100644 --- a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs +++ b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs @@ -58,8 +58,7 @@ private static string GetMiddlewareAssemblyPath() context.LoggerFactory, middlewareAssemblyPath: GetMiddlewareAssemblyPath(), dotnetPath: context.EnvironmentOptions.MuxerPath, - autoReloadWebSocketHostName: context.EnvironmentOptions.AutoReloadWebSocketHostName, - autoReloadWebSocketPort: context.EnvironmentOptions.AutoReloadWebSocketPort, + webSocketConfig: context.EnvironmentOptions.BrowserWebSocketConfig, suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None); } diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs index 016c774130bf..7a69972fac19 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch @@ -37,11 +38,9 @@ internal sealed record EnvironmentOptions( bool SuppressEmojis = false, bool RestartOnRudeEdit = false, LogLevel? CliLogLevel = null, - string? AutoReloadWebSocketHostName = null, - int? AutoReloadWebSocketPort = null, string? BrowserPath = null, - int AgentWebSocketPort = 0, - int? AgentWebSocketSecurePort = null, + WebSocketConfig BrowserWebSocketConfig = default, + WebSocketConfig AgentWebSocketConfig = default, TestFlags TestFlags = TestFlags.None, string TestOutput = "") { @@ -58,11 +57,9 @@ internal sealed record EnvironmentOptions( SuppressEmojis: EnvironmentVariables.SuppressEmojis, RestartOnRudeEdit: EnvironmentVariables.RestartOnRudeEdit, CliLogLevel: EnvironmentVariables.CliLogLevel, - AutoReloadWebSocketHostName: EnvironmentVariables.AutoReloadWSHostName, - AutoReloadWebSocketPort: EnvironmentVariables.AutoReloadWSPort, BrowserPath: EnvironmentVariables.BrowserPath, - AgentWebSocketPort: EnvironmentVariables.AgentWebSocketPort, - AgentWebSocketSecurePort: EnvironmentVariables.AgentWebSocketSecurePort, + BrowserWebSocketConfig: new(EnvironmentVariables.BrowserWebSocketPort, EnvironmentVariables.BrowserWebSocketSecurePort, EnvironmentVariables.BrowserWebSocketHostName), + AgentWebSocketConfig: new(EnvironmentVariables.AgentWebSocketPort, EnvironmentVariables.AgentWebSocketSecurePort, hostName: null), TestFlags: EnvironmentVariables.TestFlags, TestOutput: EnvironmentVariables.TestOutputDir ); diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs index d9dde6c02640..ac4dfa5db843 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs @@ -59,8 +59,19 @@ public static LogLevel? CliLogLevel public static TestFlags TestFlags => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS") is { } value ? Enum.Parse(value) : TestFlags.None; public static string TestOutputDir => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR") ?? ""; - public static string? AutoReloadWSHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); - public static int? AutoReloadWSPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WS_PORT"); + public static string? BrowserWebSocketHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); + + /// + /// Port used for browser WebSocket communication. Defaults to 0 (auto-assign) if not specified. + /// + public static int BrowserWebSocketPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WS_PORT") ?? 0; + + /// + /// Secure (HTTPS/WSS) port used for browser WebSocket communication. Defaults to 0 (auto-assign) if not specified. + /// Only used if TLS is supported and enabled. + /// + public static int BrowserWebSocketSecurePort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WSS_PORT") ?? 0; + public static string? BrowserPath => Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); /// diff --git a/test/dotnet-watch.Tests/Web/KestrelWebSocketServerTests.cs b/test/dotnet-watch.Tests/Web/KestrelWebSocketServerTests.cs new file mode 100644 index 000000000000..dd6993ac75c7 --- /dev/null +++ b/test/dotnet-watch.Tests/Web/KestrelWebSocketServerTests.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.HotReload; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class KestrelWebSocketServerTests +{ + [Theory] + [InlineData("http://contoso.com:10", "ws://contoso.com:10")] + [InlineData("https://contoso.com:10", "wss://contoso.com:10")] + [InlineData("http://127.0.0.10:10", "ws://127.0.0.10:10")] + [InlineData("https://127.0.0.10:10", "wss://127.0.0.10:10")] + [InlineData("http://127.0.0.1:10", "ws://localhost:10")] + [InlineData("https://127.0.0.1:10", "wss://localhost:10")] + public void Urls(string httpUrl, string wsUrl) + { + Assert.Equal(wsUrl, KestrelWebSocketServer.GetWebSocketUrl(httpUrl)); + } +}