Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions src/BuiltInTools/HotReloadClient/Web/BrowserRefreshServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -36,21 +35,16 @@ protected override bool SuppressTimeouts

protected override async ValueTask<WebServerHost> 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<string> GetServerUrls(ImmutableArray<string> 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)
Expand Down
125 changes: 44 additions & 81 deletions src/BuiltInTools/HotReloadClient/Web/KestrelWebSocketServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,55 +26,15 @@ namespace Microsoft.DotNet.HotReload;
/// Sealed WebSocket server using Kestrel.
/// Uses a request handler delegate for all WebSocket handling.
/// </summary>
internal sealed class KestrelWebSocketServer : IDisposable
internal sealed class KestrelWebSocketServer(IHost host, ImmutableArray<string> serverUrls) : IDisposable
{
private readonly RequestDelegate _requestHandler;
private readonly ILogger _logger;

private IHost? _host;
public ImmutableArray<string> ServerUrls { get; private set; } = [];

public KestrelWebSocketServer(ILogger logger, RequestDelegate requestHandler)
{
_logger = logger;
_requestHandler = requestHandler;
}

public void Dispose()
{
_host?.Dispose();
}

private static bool? s_lazyTlsSupported;

/// <summary>
/// Checks whether TLS is supported by running <c>dotnet dev-certs https --check --quiet</c>.
/// </summary>
public static async ValueTask<bool> 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;
}
public void Dispose()
=> host.Dispose();

s_lazyTlsSupported = result;
return result.Value;
}
public ImmutableArray<string> ServerUrls
=> serverUrls;

/// <summary>
/// Starts the Kestrel WebSocket server.
Expand All @@ -83,68 +43,71 @@ await process
/// <param name="port">HTTP port to bind to (0 for auto-assign)</param>
/// <param name="securePort">HTTPS port to bind to in addition to HTTP port. Null to skip HTTPS.</param>
/// <param name="cancellationToken">Cancellation token</param>
public async ValueTask StartServerAsync(string hostName, int port, int? securePort, CancellationToken cancellationToken)
public static async ValueTask<KestrelWebSocketServer> 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<IServer>()
.Features
.Get<IServerAddressesFeature>()?
.Addresses;
.Addresses ?? [];

if (addresses != null)
{
ServerUrls = [.. addresses];
}

_logger.LogDebug("WebSocket server started at: {Urls}", string.Join(", ", ServerUrls.Select(url => GetWebSocketUrl(url))));
return new KestrelWebSocketServer(host, serverUrls: [.. addresses.Select(GetWebSocketUrl)]);
}

/// <summary>
/// Converts an HTTP(S) URL to a WebSocket URL.
/// When <paramref name="hostName"/> is not specified, also replaces 127.0.0.1 with localhost.
/// Converts an HTTP(S) URL to a WebSocket URL and replaces 127.0.0.1 with localhost.
/// </summary>
internal static string GetWebSocketUrl(string httpUrl, string? hostName = null)
internal static string GetWebSocketUrl(string httpUrl)
=> httpUrl
.Replace("http://127.0.0.1:", "ws://localhost:", StringComparison.Ordinal)
.Replace("https://127.0.0.1:", "wss://localhost:", StringComparison.Ordinal)
Comment on lines +79 to +80
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks intentional, but is the : an issue if this was ever missing the port?

Kestrel should always set it, just wondering.

Copy link
Member Author

@tmat tmat Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming the port is always set.

The current code produces incorrect URL if host name was specified as 127.0.0.100, in theory.
Perhaps we should just use Uri.Parse here instead of string replacement and replace the scheme.

.Replace("https://", "wss://", StringComparison.Ordinal)
.Replace("http://", "ws://", StringComparison.Ordinal);

/// <summary>
/// Checks whether TLS is supported by running <c>dotnet dev-certs https --check --quiet</c>.
/// </summary>
public static async ValueTask<bool> 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;
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/BuiltInTools/HotReloadClient/Web/WebSocketConfig.cs
Original file line number Diff line number Diff line change
@@ -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)
{
/// <summary>
/// 0 to auto-assign.
/// </summary>
public int Port => port;

/// <summary>
/// 0 to auto-assign, null to disable HTTPS/WSS.
/// </summary>
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<string> GetHttpUrls()
{
yield return $"http://{HostName}:{Port}";

if (SecurePort.HasValue)
{
yield return $"https://{HostName}:{SecurePort.Value}";
}
}

public WebSocketConfig WithSecurePort(int? value)
=> new(port, value, hostName);
}
Comment on lines +27 to +39
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new WebSocketConfig struct lacks test coverage for its public methods GetHttpUrls() and WithSecurePort(). Consider adding unit tests to verify:

  1. GetHttpUrls() correctly yields HTTP URL when SecurePort is null
  2. GetHttpUrls() correctly yields both HTTP and HTTPS URLs when SecurePort has a value
  3. GetHttpUrls() correctly uses the provided hostName or defaults to "127.0.0.1"
  4. WithSecurePort() correctly creates a new config with the updated SecurePort value while preserving other properties

Copilot uses AI. Check for mistakes.
Loading