From 251d8c3d258772d4f251d309f44fadd26ddb021c Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 19 Oct 2025 09:31:44 -0700 Subject: [PATCH 1/9] Add ValueTaskExtensions --- .../ValueTaskExtensions.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/Microsoft.VisualStudio.ProjectSystem.Managed/ValueTaskExtensions.cs diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ValueTaskExtensions.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ValueTaskExtensions.cs new file mode 100644 index 0000000000..b870fa0e08 --- /dev/null +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ValueTaskExtensions.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. See the LICENSE.md file in the project root for more information. +#pragma warning disable VSTHRD200 // Use "Async" suffix for async methods + +namespace System.Threading.Tasks; + +internal static partial class ValueTaskExtensions +{ +#if !NET + extension(ValueTask) + { + public static ValueTask FromResult(T result) + => new(result); + + public static ValueTask CompletedTask + => new(); + } +#endif +} From 6900875186acd726e91ea886d60cee19d7d67c4d Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 19 Oct 2025 09:36:22 -0700 Subject: [PATCH 2/9] Copy sources from SDK at commit 0235c63abd7d470d8701643be7fd7b003bdc0ae4 --- Directory.Packages.props | 3 +- ProjectSystem.sln | 18 +- .../ProjectSystemSetup.csproj | 2 + src/Directory.Build.props | 5 - src/HotReloadRuntimeDependencies.props | 37 ++ .../.editorconfig | 2 + .../ApplicationPaths.cs | 41 ++ .../BlazorHotReload.js | 23 + .../BlazorWasmHotReloadMiddleware.cs | 99 +++++ .../BrowserRefreshMiddleware.cs | 248 +++++++++++ .../BrowserScriptMiddleware.cs | 72 ++++ .../HostingStartup.cs | 59 +++ ...oft.AspNetCore.Watch.BrowserRefresh.csproj | 40 ++ .../ResponseStreamWrapper.cs | 192 +++++++++ .../ScriptInjectingStream.cs | 396 ++++++++++++++++++ .../StartupHook.cs | 12 + .../WebSocketScriptInjection.js | 390 +++++++++++++++++ ...osoft.Extensions.DotNetDeltaApplier.csproj | 36 ++ .../PipeListener.cs | 191 +++++++++ .../ProcessUtilities.cs | 78 ++++ .../StartupHook.cs | 131 ++++++ .../Microsoft.VisualStudio.AppDesigner.vbproj | 2 + .../Microsoft.VisualStudio.Editors.vbproj | 2 + ...sualStudio.ProjectSystem.Managed.VS.csproj | 2 + ....VisualStudio.ProjectSystem.Managed.csproj | 31 +- 25 files changed, 2077 insertions(+), 35 deletions(-) create mode 100644 src/HotReloadRuntimeDependencies.props create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/.editorconfig create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorHotReload.js create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.cs create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs create mode 100644 src/Microsoft.AspNetCore.Watch.BrowserRefresh/WebSocketScriptInjection.js create mode 100644 src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj create mode 100644 src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs create mode 100644 src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs create mode 100644 src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 449945ede6..f944f58688 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ true - 10.0.100-rc.2.25468.104 + 10.0.100-rtm.25518.102 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e1f92ab301..8871e37836 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -12,9 +12,4 @@ false - - - - - diff --git a/src/HotReloadRuntimeDependencies.props b/src/HotReloadRuntimeDependencies.props new file mode 100644 index 0000000000..13d5958298 --- /dev/null +++ b/src/HotReloadRuntimeDependencies.props @@ -0,0 +1,37 @@ + + + + + Content + true + false + TargetFramework;TargetFrameworks + HotReload\net6.0 + HotReload\net6.0\Microsoft.AspNetCore.Watch.BrowserRefresh.dll + PreserveNewest + + + + Content + true + false + TargetFramework=net10.0 + HotReload\net10.0 + HotReload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll + PreserveNewest + + + + Content + true + false + TargetFramework=net6.0 + HotReload\net6.0 + HotReload\net6.0\Microsoft.Extensions.DotNetDeltaApplier.dll + PreserveNewest + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/.editorconfig b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/.editorconfig new file mode 100644 index 0000000000..4ae90dd7a7 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/.editorconfig @@ -0,0 +1,2 @@ +[*.js] +indent_size = 2 diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs new file mode 100644 index 0000000000..1d8ea6069b --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs @@ -0,0 +1,41 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + internal static class ApplicationPaths + { + /// + /// The PathString all listening URLs must be registered in + /// + /// /_framework/ + public static PathString FrameworkRoot { get; } = "/_framework"; + + /// + /// An endpoint that responds with cache-clearing headers. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data#directives. + /// + /// /_framework/clear-browser-cache + public static PathString ClearSiteData { get; } = FrameworkRoot + "/clear-browser-cache"; + + /// + /// Returns a JS file that handles browser refresh and showing notifications. + /// + /// /_framework/aspnetcore-browser-refresh.js + public static PathString BrowserRefreshJS { get; } = FrameworkRoot + "/aspnetcore-browser-refresh.js"; + + /// + /// Hosts a middleware that can cache deltas sent by dotnet-watch. + /// + /// /_framework/blazor-hotreload + public static PathString BlazorHotReloadMiddleware { get; } = FrameworkRoot + "/blazor-hotreload"; + + /// + /// Returns a JS file imported by BlazorWebAssembly as part of it's initialization. Contains + /// scripts to apply deltas on app start. + /// + /// /_framework/blazor-hotreload.js + public static PathString BlazorHotReloadJS { get; } = FrameworkRoot + "/blazor-hotreload.js"; + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorHotReload.js b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorHotReload.js new file mode 100644 index 0000000000..87a203450f --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorHotReload.js @@ -0,0 +1,23 @@ +// Used by older versions of Microsoft.AspNetCore.Components.WebAssembly. +// For back compat only to support WASM packages older than the SDK. + +export function receiveHotReload() { + return BINDING.js_to_mono_obj(new Promise((resolve) => receiveHotReloadAsync().then(resolve(0)))); +} + +export async function receiveHotReloadAsync() { + const response = await fetch('/_framework/blazor-hotreload'); + if (response.status === 200) { + const updates = await response.json(); + if (updates) { + try { + updates.forEach(u => { + u.deltas.forEach(d => window.Blazor._internal.applyHotReload(d.moduleId, d.metadataDelta, d.ilDelta, d.pdbDelta, d.updatedTypes)); + }) + } catch (error) { + console.warn(error); + return; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs new file mode 100644 index 0000000000..6ccdd09f6b --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + /// + /// A middleware that manages receiving and sending deltas from a BlazorWebAssembly app. + /// This assembly is shared between Visual Studio and dotnet-watch. By putting some of the complexity + /// in here, we can avoid duplicating work in watch and VS. + /// + /// Mapped to . + /// + internal sealed class BlazorWasmHotReloadMiddleware + { + internal sealed class Update + { + public int Id { get; set; } + public Delta[] Deltas { get; set; } = default!; + } + + internal sealed class Delta + { + public string ModuleId { get; set; } = default!; + public string MetadataDelta { get; set; } = default!; + public string ILDelta { get; set; } = default!; + public string PdbDelta { get; set; } = default!; + public int[] UpdatedTypes { get; set; } = default!; + } + + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public BlazorWasmHotReloadMiddleware(RequestDelegate next, ILogger logger) + { + logger.LogDebug("Middleware loaded"); + } + + internal List Updates { get; } = []; + + public Task InvokeAsync(HttpContext context) + { + // Multiple instances of the BlazorWebAssembly app could be running (multiple tabs or multiple browsers). + // We want to avoid serialize reads and writes between then + lock (Updates) + { + if (HttpMethods.IsGet(context.Request.Method)) + { + return OnGet(context); + } + else if (HttpMethods.IsPost(context.Request.Method)) + { + return OnPost(context); + } + else + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return Task.CompletedTask; + } + } + + // Don't call next(). This middleware is terminal. + } + + private async Task OnGet(HttpContext context) + { + if (Updates.Count == 0) + { + context.Response.StatusCode = StatusCodes.Status204NoContent; + return; + } + + await JsonSerializer.SerializeAsync(context.Response.Body, Updates, s_jsonSerializerOptions); + } + + private async Task OnPost(HttpContext context) + { + var update = await JsonSerializer.DeserializeAsync(context.Request.Body, s_jsonSerializerOptions); + if (update == null) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + // It's possible that multiple instances of the BlazorWasm are simultaneously executing and could be posting the same deltas + // We'll use the sequence id to ensure that we're not recording duplicate entries. Replaying duplicated values would cause + // ApplyDelta to fail. + if (Updates is [] || Updates[^1].Id < update.Id) + { + Updates.Add(update); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs new file mode 100644 index 0000000000..13fac3ceae --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs @@ -0,0 +1,248 @@ +// 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.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + public sealed class BrowserRefreshMiddleware + { + private static readonly MediaTypeHeaderValue s_textHtmlMediaType = new("text/html"); + private static readonly MediaTypeHeaderValue s_applicationJsonMediaType = new("application/json"); + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private string? _dotnetModifiableAssemblies = GetNonEmptyEnvironmentVariableValue("DOTNET_MODIFIABLE_ASSEMBLIES"); + private string? _aspnetcoreBrowserTools = GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS"); + + public BrowserRefreshMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + + logger.LogDebug("Middleware loaded: DOTNET_MODIFIABLE_ASSEMBLIES={ModifiableAssemblies}, __ASPNETCORE_BROWSER_TOOLS={BrowserTools}", _dotnetModifiableAssemblies, _aspnetcoreBrowserTools); + } + + private static string? GetNonEmptyEnvironmentVariableValue(string name) + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null; + + public async Task InvokeAsync(HttpContext context) + { + if (IsWebAssemblyBootRequest(context)) + { + AttachWebAssemblyHeaders(context); + await _next(context); + } + else if (IsBrowserDocumentRequest(context)) + { + // Use a custom StreamWrapper to rewrite output on Write/WriteAsync + using var responseStreamWrapper = new ResponseStreamWrapper(context, _logger); + var originalBodyFeature = context.Features.Get(); + context.Features.Set(new StreamResponseBodyFeature(responseStreamWrapper)); + + try + { + await _next(context); + + // We complete the wrapper stream to ensure that any intermediate buffers + // get fully flushed to the response stream. This is also required to + // reliably determine whether script injection was performed. + await responseStreamWrapper.CompleteAsync(); + } + finally + { + context.Features.Set(originalBodyFeature); + } + + if (responseStreamWrapper.IsHtmlResponse) + { + if (responseStreamWrapper.ScriptInjectionPerformed) + { + Log.BrowserConfiguredForRefreshes(_logger); + } + else if (context.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var contentEncodings)) + { + Log.ResponseCompressionDetected(_logger, contentEncodings); + } + else + { + Log.FailedToConfiguredForRefreshes(_logger); + } + } + } + else + { + await _next(context); + } + } + + private void AttachWebAssemblyHeaders(HttpContext context) + { + context.Response.OnStarting(() => + { + if (!context.Response.Headers.ContainsKey("DOTNET-MODIFIABLE-ASSEMBLIES")) + { + if (_dotnetModifiableAssemblies != null) + { + context.Response.Headers.Append("DOTNET-MODIFIABLE-ASSEMBLIES", _dotnetModifiableAssemblies); + } + else + { + _logger.LogDebug("DOTNET_MODIFIABLE_ASSEMBLIES environment variable is not set, likely because hot reload is not enabled. The browser refresh feature may not work as expected."); + } + } + else + { + _logger.LogDebug("DOTNET-MODIFIABLE-ASSEMBLIES header is already set."); + } + + if (!context.Response.Headers.ContainsKey("ASPNETCORE-BROWSER-TOOLS")) + { + if (_aspnetcoreBrowserTools != null) + { + context.Response.Headers.Append("ASPNETCORE-BROWSER-TOOLS", _aspnetcoreBrowserTools); + } + else + { + _logger.LogDebug("__ASPNETCORE_BROWSER_TOOLS environment variable is not set. The browser refresh feature may not work as expected."); + } + } + else + { + _logger.LogDebug("ASPNETCORE-BROWSER-TOOLS header is already set."); + } + + return Task.CompletedTask; + }); + } + + internal static bool IsWebAssemblyBootRequest(HttpContext context) + { + var request = context.Request; + if (!HttpMethods.IsGet(request.Method)) + { + return false; + } + + if (request.Headers.TryGetValue("Sec-Fetch-Dest", out var values) && + !StringValues.IsNullOrEmpty(values) && + !string.Equals(values[0], "empty", StringComparison.OrdinalIgnoreCase)) + { + // See https://github.com/dotnet/aspnetcore/issues/37326. + // Only inject scripts that are destined for a browser page. + return false; + } + + if (!request.Path.HasValue || + !string.Equals(Path.GetFileName(request.Path.Value), "blazor.boot.json", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var typedHeaders = request.GetTypedHeaders(); + if (typedHeaders.Accept is not IList acceptHeaders) + { + return false; + } + + for (var i = 0; i < acceptHeaders.Count; i++) + { + if (acceptHeaders[i].MatchesAllTypes || acceptHeaders[i].IsSubsetOf(s_applicationJsonMediaType)) + { + return true; + } + } + + return false; + } + + internal static bool IsBrowserDocumentRequest(HttpContext context) + { + var request = context.Request; + if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsPost(request.Method)) + { + return false; + } + + if (request.Headers.TryGetValue("Sec-Fetch-Dest", out var values) && + !StringValues.IsNullOrEmpty(values) && + !string.Equals(values[0], "document", StringComparison.OrdinalIgnoreCase) && + !IsProgressivelyEnhancedNavigation(context.Request)) + { + // See https://github.com/dotnet/aspnetcore/issues/37326. + // Only inject scripts that are destined for a browser page. + return false; + } + + var typedHeaders = request.GetTypedHeaders(); + if (typedHeaders.Accept is not IList acceptHeaders) + { + return false; + } + + for (var i = 0; i < acceptHeaders.Count; i++) + { + if (acceptHeaders[i].IsSubsetOf(s_textHtmlMediaType)) + { + return true; + } + } + + return false; + } + + private static bool IsProgressivelyEnhancedNavigation(HttpRequest request) + { + // This is an exact copy from https://github.com/dotnet/aspnetcore/blob/bb2d778dc66aa998ea8e26db0e98e7e01423ff78/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs#L327-L332 + // For enhanced nav, the Blazor JS code controls the "accept" header precisely, so we can be very specific about the format + var accept = request.Headers.Accept; + return accept.Count == 1 && string.Equals(accept[0]!, "text/html; blazor-enhanced-nav=on", StringComparison.Ordinal); + } + + internal void Test_SetEnvironment(string dotnetModifiableAssemblies, string aspnetcoreBrowserTools) + { + _dotnetModifiableAssemblies = dotnetModifiableAssemblies; + _aspnetcoreBrowserTools = aspnetcoreBrowserTools; + } + + internal static class Log + { + private static readonly Action _setupResponseForBrowserRefresh = LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, "SetUpResponseForBrowserRefresh"), + "Response markup is scheduled to include browser refresh script injection."); + + private static readonly Action _browserConfiguredForRefreshes = LoggerMessage.Define( + LogLevel.Debug, + new EventId(2, "BrowserConfiguredForRefreshes"), + "Response markup was updated to include browser refresh script injection."); + + private static readonly Action _failedToConfigureForRefreshes = LoggerMessage.Define( + LogLevel.Warning, + new EventId(3, "FailedToConfiguredForRefreshes"), + "Unable to configure browser refresh script injection on the response. " + + $"Consider manually adding '{ScriptInjectingStream.InjectedScript}' to the body of the page."); + + private static readonly Action _responseCompressionDetected = LoggerMessage.Define( + LogLevel.Warning, + new EventId(4, "ResponseCompressionDetected"), + "Unable to configure browser refresh script injection on the response. " + + $"This may have been caused by the response's {HeaderNames.ContentEncoding}: '{{encoding}}'. " + + "Consider disabling response compression."); + + private static readonly Action _scriptInjectionSkipped = LoggerMessage.Define( + LogLevel.Debug, + new EventId(6, "ScriptInjectionSkipped"), + "Browser refresh script injection skipped. Status code: {StatusCode}, Content type: {ContentType}"); + + public static void SetupResponseForBrowserRefresh(ILogger logger) => _setupResponseForBrowserRefresh(logger, null); + public static void BrowserConfiguredForRefreshes(ILogger logger) => _browserConfiguredForRefreshes(logger, null); + public static void FailedToConfiguredForRefreshes(ILogger logger) => _failedToConfigureForRefreshes(logger, null); + public static void ResponseCompressionDetected(ILogger logger, StringValues encoding) => _responseCompressionDetected(logger, encoding, null); + public static void ScriptInjectionSkipped(ILogger logger, int statusCode, string? contentType) => _scriptInjectionSkipped(logger, statusCode, contentType, null); + } + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs new file mode 100644 index 0000000000..a9bffacdf9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + /// + /// Responds with the contents of WebSocketScriptInjection.js with the stub WebSocket url replaced by the + /// one specified by the launching app. + /// + public sealed class BrowserScriptMiddleware + { + private readonly PathString _scriptPath; + private readonly ReadOnlyMemory _scriptBytes; + private readonly ILogger _logger; + private readonly string _contentLength; + + public BrowserScriptMiddleware(RequestDelegate next, PathString scriptPath, ReadOnlyMemory scriptBytes, ILogger logger) + { + _scriptPath = scriptPath; + _scriptBytes = scriptBytes; + _logger = logger; + _contentLength = _scriptBytes.Length.ToString(CultureInfo.InvariantCulture); + + logger.LogDebug("Middleware loaded. Script {scriptPath} ({size} B).", scriptPath, _contentLength); + } + + public async Task InvokeAsync(HttpContext context) + { + context.Response.Headers["Cache-Control"] = "no-store"; + context.Response.Headers["Content-Length"] = _contentLength; + context.Response.Headers["Content-Type"] = "application/javascript; charset=utf-8"; + + await context.Response.Body.WriteAsync(_scriptBytes, context.RequestAborted); + + _logger.LogDebug("Script injected: {scriptPath}", _scriptPath); + } + + // for backwards compat only + internal static ReadOnlyMemory GetBlazorHotReloadJS() + { + var jsFileName = "Microsoft.AspNetCore.Watch.BrowserRefresh.BlazorHotReload.js"; + using var stream = new MemoryStream(); + var manifestStream = typeof(BrowserScriptMiddleware).Assembly.GetManifestResourceStream(jsFileName)!; + manifestStream.CopyTo(stream); + + return stream.ToArray(); + } + + internal static ReadOnlyMemory GetBrowserRefreshJS() + { + var endpoint = Environment.GetEnvironmentVariable("ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT")!; + var serverKey = Environment.GetEnvironmentVariable("ASPNETCORE_AUTO_RELOAD_WS_KEY") ?? string.Empty; + + return GetWebSocketClientJavaScript(endpoint, serverKey); + } + + internal static ReadOnlyMemory GetWebSocketClientJavaScript(string hostString, string serverKey) + { + var jsFileName = "Microsoft.AspNetCore.Watch.BrowserRefresh.WebSocketScriptInjection.js"; + using var reader = new StreamReader(typeof(BrowserScriptMiddleware).Assembly.GetManifestResourceStream(jsFileName)!); + var script = reader.ReadToEnd() + .Replace("{{hostString}}", hostString) + .Replace("{{ServerKey}}", serverKey); + + return Encoding.UTF8.GetBytes(script); + } + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs new file mode 100644 index 0000000000..369133ccbe --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs @@ -0,0 +1,59 @@ +// 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.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +[assembly: HostingStartup(typeof(Microsoft.AspNetCore.Watch.BrowserRefresh.HostingStartup))] + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + internal sealed class HostingStartup : IHostingStartup, IStartupFilter + { + public void Configure(IWebHostBuilder builder) + { + builder.ConfigureServices(services => services.TryAddEnumerable(ServiceDescriptor.Singleton(this))); + } + + public Action Configure(Action next) + { + return app => + { + app.MapWhen( + static (context) => + { + var path = context.Request.Path; + return path.StartsWithSegments(ApplicationPaths.FrameworkRoot) && + (path.StartsWithSegments(ApplicationPaths.ClearSiteData) || + path.StartsWithSegments(ApplicationPaths.BlazorHotReloadMiddleware) || + path.StartsWithSegments(ApplicationPaths.BrowserRefreshJS) || + path.StartsWithSegments(ApplicationPaths.BlazorHotReloadJS)); + }, + static app => + { + app.Map(ApplicationPaths.ClearSiteData, static app => app.Run(context => + { + // Scoped css files can contain links to other css files. We'll try clearing out the http caches to force the browser to re-download. + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data#directives + context.Response.Headers["Clear-Site-Data"] = "\"cache\""; + return Task.CompletedTask; + })); + + app.Map(ApplicationPaths.BlazorHotReloadMiddleware, static app => app.UseMiddleware()); + + app.Map(ApplicationPaths.BrowserRefreshJS, + static app => app.UseMiddleware(ApplicationPaths.BrowserRefreshJS, BrowserScriptMiddleware.GetBrowserRefreshJS())); + + // backwards compat only: + app.Map(ApplicationPaths.BlazorHotReloadJS, + static app => app.UseMiddleware(ApplicationPaths.BlazorHotReloadJS, BrowserScriptMiddleware.GetBlazorHotReloadJS())); + }); + + app.UseMiddleware(); + next(app); + }; + } + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj new file mode 100644 index 0000000000..b41242dbb0 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj @@ -0,0 +1,40 @@ + + + + net6.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %(NuGetPackageId)\%(Link) + + + diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.cs new file mode 100644 index 0000000000..16b2256e3f --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO.Compression; +using System.IO.Pipelines; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + /// + /// Wraps the Response Stream to inject the WebSocket HTML into + /// an HTML Page. + /// + public class ResponseStreamWrapper : Stream + { + private static readonly MediaTypeHeaderValue s_textHtmlMediaType = new("text/html"); + + private readonly HttpContext _context; + private readonly ILogger _logger; + private bool? _isHtmlResponse; + + private Stream _baseStream; + private ScriptInjectingStream? _scriptInjectingStream; + private Pipe? _pipe; + private Task? _gzipCopyTask; + private bool _disposed; + + public ResponseStreamWrapper(HttpContext context, ILogger logger) + { + _context = context; + _baseStream = context.Response.Body; + _logger = logger; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length { get; } + public override long Position { get; set; } + public bool ScriptInjectionPerformed => _scriptInjectingStream?.ScriptInjectionPerformed == true; + public bool IsHtmlResponse => _isHtmlResponse == true; + + public override void Flush() + { + OnWrite(); + _baseStream.Flush(); + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + OnWrite(); + await _baseStream.FlushAsync(cancellationToken); + } + + public override void Write(ReadOnlySpan buffer) + { + OnWrite(); + _baseStream.Write(buffer); + } + + public override void WriteByte(byte value) + { + OnWrite(); + _baseStream.WriteByte(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + OnWrite(); + _baseStream.Write(buffer.AsSpan(offset, count)); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + OnWrite(); + await _baseStream.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + OnWrite(); + await _baseStream.WriteAsync(buffer, cancellationToken); + } + + private void OnWrite() + { + if (_isHtmlResponse.HasValue) + { + return; + } + + var response = _context.Response; + + _isHtmlResponse = + (response.StatusCode == StatusCodes.Status200OK || + response.StatusCode == StatusCodes.Status404NotFound || + response.StatusCode == StatusCodes.Status500InternalServerError) && + MediaTypeHeaderValue.TryParse(response.ContentType, out var mediaType) && + mediaType.IsSubsetOf(s_textHtmlMediaType) && + (!mediaType.Charset.HasValue || mediaType.Charset.Equals("utf-8", StringComparison.OrdinalIgnoreCase)); + + if (!_isHtmlResponse.Value) + { + BrowserRefreshMiddleware.Log.ScriptInjectionSkipped(_logger, response.StatusCode, response.ContentType); + return; + } + + BrowserRefreshMiddleware.Log.SetupResponseForBrowserRefresh(_logger); + // Since we're changing the markup content, reset the content-length + response.Headers.ContentLength = null; + + _scriptInjectingStream = new ScriptInjectingStream(_baseStream); + + // By default, write directly to the script injection stream. + // We may change the base stream below if we detect that the response + // is compressed. + _baseStream = _scriptInjectingStream; + + // Check if the response has gzip Content-Encoding + if (response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var contentEncodingValues)) + { + var contentEncoding = contentEncodingValues.FirstOrDefault(); + if (string.Equals(contentEncoding, "gzip", StringComparison.OrdinalIgnoreCase)) + { + // Remove the Content-Encoding header since we'll be serving uncompressed content + response.Headers.Remove(HeaderNames.ContentEncoding); + + _pipe = new Pipe(); + var gzipStream = new GZipStream(_pipe.Reader.AsStream(leaveOpen: true), CompressionMode.Decompress, leaveOpen: true); + + _gzipCopyTask = gzipStream.CopyToAsync(_scriptInjectingStream); + _baseStream = _pipe.Writer.AsStream(leaveOpen: true); + } + } + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + + public ValueTask CompleteAsync() => DisposeAsync(); + + public override async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_pipe is not null) + { + await _pipe.Writer.CompleteAsync(); + } + + if (_gzipCopyTask is not null) + { + await _gzipCopyTask; + } + + if (_scriptInjectingStream is not null) + { + await _scriptInjectingStream.CompleteAsync(); + } + else + { + Debug.Assert(_isHtmlResponse != true); + await _baseStream.FlushAsync(); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs new file mode 100644 index 0000000000..7ba9114253 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs @@ -0,0 +1,396 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh; + +internal sealed class ScriptInjectingStream : Stream +{ + internal static string InjectedScript { get; } = $""; + + private static readonly ReadOnlyMemory s_bodyTagBytes = ""u8.ToArray(); + private static readonly ReadOnlyMemory s_injectedScriptBytes = Encoding.UTF8.GetBytes(InjectedScript); + + private readonly Stream _baseStream; + + private int _partialBodyTagLength; + private bool _isDisposed; + + public ScriptInjectingStream(Stream baseStream) + { + _baseStream = baseStream; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length { get; } + public override long Position { get; set; } + public bool ScriptInjectionPerformed { get; private set; } + + public override void Flush() + => _baseStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) + => _baseStream.FlushAsync(cancellationToken); + + public override void Write(ReadOnlySpan buffer) + { + if (!ScriptInjectionPerformed) + { + ScriptInjectionPerformed = TryInjectScript(buffer); + } + else + { + _baseStream.Write(buffer); + } + } + + public override void WriteByte(byte value) + { + _baseStream.WriteByte(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (!ScriptInjectionPerformed) + { + ScriptInjectionPerformed = TryInjectScript(buffer.AsSpan(offset, count)); + } + else + { + _baseStream.Write(buffer, offset, count); + } + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (!ScriptInjectionPerformed) + { + ScriptInjectionPerformed = await TryInjectScriptAsync(buffer.AsMemory(offset, count), cancellationToken); + } + else + { + await _baseStream.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + } + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (!ScriptInjectionPerformed) + { + ScriptInjectionPerformed = await TryInjectScriptAsync(buffer, cancellationToken); + } + else + { + await _baseStream.WriteAsync(buffer, cancellationToken); + } + } + + private bool TryInjectScript(ReadOnlySpan buffer) + { + var sourceBuffer = new SourceBuffer(buffer); + var writer = new SyncBaseStreamWriter(_baseStream); + return TryInjectScriptCore(ref writer, sourceBuffer); + } + + private async ValueTask TryInjectScriptAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + var sourceBuffer = new SourceBuffer(buffer.Span); + var writer = new AsyncBaseStreamWriter(_baseStream, buffer); + var result = TryInjectScriptCore(ref writer, sourceBuffer); + + await writer.WriteToBaseStreamAsync(cancellationToken); + + return result; + } + + // Implements the core script injection logic in a manner agnostic to whether writes + // are synchronous or asynchronous. + private bool TryInjectScriptCore(ref TWriter writer, SourceBuffer buffer) + where TWriter : struct, IBaseStreamWriter + { + if (_partialBodyTagLength != 0) + { + // We're in the middle of parsing a potential body tag, + // which means that the rest of the body tag must be + // at the start of the buffer. + + var restPartialTagLength = FindPartialTagLengthFromStart(currentBodyTagLength: _partialBodyTagLength, buffer.Span); + if (restPartialTagLength == -1) + { + // This wasn't a closing body tag. Flush what we've buffered so far and reset. + // We don't return here because we want to continue to process the buffer as if + // we weren't reading a partial body tag. + writer.Write(s_bodyTagBytes[.._partialBodyTagLength]); + _partialBodyTagLength = 0; + } + else + { + // This may still be a closing body tag. + _partialBodyTagLength += restPartialTagLength; + + Debug.Assert(_partialBodyTagLength <= s_bodyTagBytes.Length); + + if (_partialBodyTagLength == s_bodyTagBytes.Length) + { + // We've just read a full closing body tag, so we flush it to the stream. + // Then just write the rest of the stream normally as we've now finished searching + // for the script. + writer.Write(s_injectedScriptBytes); + writer.Write(s_bodyTagBytes); + writer.Write(buffer[restPartialTagLength..]); + _partialBodyTagLength = 0; + return true; + } + else + { + // We're still in the middle of reading the body tag, + // so there's nothing else to flush to the stream. + return false; + } + } + } + + // We now know we're not in the middle of processing a body tag. + Debug.Assert(_partialBodyTagLength == 0); + + var index = buffer.Span.LastIndexOf(s_bodyTagBytes.Span); + if (index == -1) + { + // We didn't find the full closing body tag in the buffer, but the end of the buffer + // might contain the start of a closing body tag. + + var partialBodyTagLength = FindPartialTagLengthFromEnd(buffer.Span); + if (partialBodyTagLength == -1) + { + // We know that the end of the buffer definitely does not + // represent a closing body tag. We'll just flush the buffer + // to the base stream. + writer.Write(buffer); + return false; + } + else + { + // We might have found a body tag at the end of the buffer. + // We'll write the buffer leading up to the start of the body + // tag candidate. + + writer.Write(buffer[..^partialBodyTagLength]); + _partialBodyTagLength = partialBodyTagLength; + return false; + } + } + + if (index > 0) + { + writer.Write(buffer[..index]); + buffer = buffer[index..]; + } + + // Write the injected script + writer.Write(s_injectedScriptBytes); + + // Write the rest of the buffer/HTML doc + writer.Write(buffer); + return true; + } + + private static int FindPartialTagLengthFromStart(int currentBodyTagLength, ReadOnlySpan buffer) + { + var remainingBodyTagBytes = s_bodyTagBytes.Span[currentBodyTagLength..]; + var minLength = Math.Min(buffer.Length, remainingBodyTagBytes.Length); + + return buffer[..minLength].SequenceEqual(remainingBodyTagBytes[..minLength]) + ? minLength + : -1; + } + + private static int FindPartialTagLengthFromEnd(ReadOnlySpan buffer) + { + var bufferLength = buffer.Length; + if (bufferLength == 0) + { + return -1; + } + + // Since each character within "" is unique, we can use the last byte + // in the buffer to determine the length of the partial body tag. + var lastByte = buffer[^1]; + var bodyMarkerIndexOfLastByte = BodyTagIndexOf(lastByte); + if (bodyMarkerIndexOfLastByte == -1) + { + // The last character does not appear "", so we know + // there's not a partial body tag. + return -1; + } + + var partialTagLength = bodyMarkerIndexOfLastByte + 1; + if (buffer.Length < partialTagLength) + { + // The buffer is shorter than the expected length of the partial + // body tag, so we know the buffer can't possibly contain it. + return -1; + } + + // Finally, we need to check that the content at the end of the buffer + // matches the expected partial body tag. + return buffer[^partialTagLength..].SequenceEqual(s_bodyTagBytes.Span[..partialTagLength]) + ? partialTagLength + : -1; + + // We can utilize the fact that each character is unique in "" + // to perform an efficient index lookup. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static int BodyTagIndexOf(byte c) + => c switch + { + (byte)'<' => 0, + (byte)'/' => 1, + (byte)'b' => 2, + (byte)'o' => 3, + (byte)'d' => 4, + (byte)'y' => 5, + (byte)'>' => 6, + _ => -1, + }; + } + + public ValueTask CompleteAsync() => DisposeAsync(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + + public override async ValueTask DisposeAsync() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + if (_partialBodyTagLength > 0) + { + // We might have buffered some data thinking that it could represent + // a body tag. We know at this point that there's no more data + // on its way, so we'll write the remaining data to the buffer. + await _baseStream.WriteAsync(s_bodyTagBytes[.._partialBodyTagLength]); + _partialBodyTagLength = 0; + } + + await FlushAsync(); + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + // A thin wrapper over ReadOnlySpan that keeps track of the current range relative + // to the originally-provided buffer. + // This enables the sharing of logic between scenarios where only a ReadOnlySpan + // is available (some synchronous writes) and scenarios where ReadOnlyMemory + // is required (all asynchronous writes). + private readonly ref struct SourceBuffer + { + private readonly int _offsetFromOriginal; + + public readonly ReadOnlySpan Span; + + public int Length => Span.Length; + + public Range RangeInOriginal => new(_offsetFromOriginal, _offsetFromOriginal + Span.Length); + + public SourceBuffer(ReadOnlySpan span) + : this(span, offsetFromOriginal: 0) + { + } + + private SourceBuffer(ReadOnlySpan span, int offsetFromOriginal) + { + Span = span; + _offsetFromOriginal = offsetFromOriginal; + } + + public SourceBuffer Slice(int start, int length) + => new(Span.Slice(start, length), offsetFromOriginal: _offsetFromOriginal + start); + } + + // Represents a writer to the base stream. + // Accepts arbitrary heap-allocated buffers and + // ranges of the source buffer. + private interface IBaseStreamWriter + { + void Write(in SourceBuffer buffer); + void Write(ReadOnlyMemory data); + } + + // A base stream writer that performs synchronous writes. + private struct SyncBaseStreamWriter(Stream baseStream) : IBaseStreamWriter + { + public readonly void Write(ReadOnlyMemory data) => baseStream.Write(data.Span); + public readonly void Write(in SourceBuffer buffer) => baseStream.Write(buffer.Span); + } + + // A base stream writer that enables buffering writes synchronously and applying + // them to the base stream when an async context is available. + private struct AsyncBaseStreamWriter(Stream baseStream, ReadOnlyMemory bufferMemory) : IBaseStreamWriter + { + private WriteBuffer _writes; + private int _writeCount; + + public void Write(ReadOnlyMemory data) => _writes[_writeCount++] = data; + public void Write(in SourceBuffer buffer) => _writes[_writeCount++] = bufferMemory[buffer.RangeInOriginal]; + + public readonly async ValueTask WriteToBaseStreamAsync(CancellationToken cancellationToken) + { + for (var i = 0; i < _writeCount; i++) + { + await baseStream.WriteAsync(_writes[i], cancellationToken); + } + } + + // We don't currently need than 4 writes, but we can bump this in the future if needed. + // If we ever target .NET 8+, we can use the [InlineArray] feature instead. + private struct WriteBuffer + { + ReadOnlyMemory _write0; + ReadOnlyMemory _write1; + ReadOnlyMemory _write2; + ReadOnlyMemory _write3; + + private static ref ReadOnlyMemory GetAt(ref WriteBuffer buffer, int index) + { + switch (index) + { + case 0: return ref buffer._write0; + case 1: return ref buffer._write1; + case 2: return ref buffer._write2; + case 3: return ref buffer._write3; + default: throw new IndexOutOfRangeException(nameof(index)); + } + } + + public ReadOnlyMemory this[int index] + { + get => GetAt(ref this, index); + set => GetAt(ref this, index) = value; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs new file mode 100644 index 0000000000..93be4f31fa --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +internal class StartupHook +{ + public static void Initialize() + { + // See https://github.com/dotnet/aspnetcore/issues/37357#issuecomment-941237000 + // We'll configure an environment variable that will indicate to blazor-wasm that the middleware is available. + Environment.SetEnvironmentVariable("__ASPNETCORE_BROWSER_TOOLS", "true"); + } +} diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/WebSocketScriptInjection.js b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/WebSocketScriptInjection.js new file mode 100644 index 0000000000..aa8f1fd60f --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/WebSocketScriptInjection.js @@ -0,0 +1,390 @@ +setTimeout(async function () { + const hotReloadActiveKey = '_dotnet_watch_hot_reload_active'; + // Ensure we only try to connect once, even if the script is both injected and manually inserted + const scriptInjectedSentinel = '_dotnet_watch_ws_injected'; + if (window.hasOwnProperty(scriptInjectedSentinel)) { + return; + } + window[scriptInjectedSentinel] = true; + + // dotnet-watch browser reload script + const webSocketUrls = '{{hostString}}'.split(','); + const sharedSecret = await getSecret('{{ServerKey}}'); + let connection; + for (const url of webSocketUrls) { + try { + connection = await getWebSocket(url); + break; + } catch (ex) { + console.debug(ex); + } + } + if (!connection) { + console.debug('Unable to establish a connection to the browser refresh server.'); + return; + } + + let waiting = false; + + connection.onmessage = function (message) { + if (message.data === 'Reload') { + console.debug('Server is ready. Reloading...'); + location.reload(); + } else if (message.data === 'Wait') { + if (waiting) { + return; + } + waiting = true; + console.debug('File changes detected. Waiting for application to rebuild.'); + const glyphs = ['☱', '☲', '☴']; + const title = document.title; + let i = 0; + setInterval(function () { document.title = glyphs[i++ % glyphs.length] + ' ' + title; }, 240); + } else { + const payload = JSON.parse(message.data); + const action = { + 'UpdateStaticFile': () => updateStaticFile(payload.path), + 'BlazorHotReloadDeltav1': () => applyBlazorDeltas_legacy(payload.sharedSecret, payload.deltas, false), + 'BlazorHotReloadDeltav2': () => applyBlazorDeltas_legacy(payload.sharedSecret, payload.deltas, true), + 'BlazorHotReloadDeltav3': () => applyBlazorDeltas(payload.sharedSecret, payload.updateId, payload.deltas, payload.responseLoggingLevel), + 'HotReloadDiagnosticsv1': () => displayDiagnostics(payload.diagnostics), + 'BlazorRequestApplyUpdateCapabilities': () => getBlazorWasmApplyUpdateCapabilities(false), + 'BlazorRequestApplyUpdateCapabilities2': () => getBlazorWasmApplyUpdateCapabilities(true), + 'AspNetCoreHotReloadApplied': () => aspnetCoreHotReloadApplied() + }; + + if (payload.type && action.hasOwnProperty(payload.type)) { + action[payload.type](); + } else { + console.error('Unknown payload:', message.data); + } + } + } + + connection.onerror = function (event) { console.debug('dotnet-watch reload socket error.', event) } + connection.onclose = function () { console.debug('dotnet-watch reload socket closed.') } + connection.onopen = function () { console.debug('dotnet-watch reload socket connected.') } + + function updateStaticFile(path) { + if (path && path.endsWith('.css')) { + updateCssByPath(path); + } else { + console.debug(`File change detected to file ${path}. Reloading page...`); + location.reload(); + return; + } + } + + async function updateCssByPath(path) { + const styleElement = document.querySelector(`link[href^="${path}"]`) || + document.querySelector(`link[href^="${document.baseURI}${path}"]`); + + // Receive a Clear-site-data header. + await fetch('/_framework/clear-browser-cache'); + + if (!styleElement || !styleElement.parentNode) { + console.debug('Unable to find a stylesheet to update. Updating all local css files.'); + updateAllLocalCss(); + } + + updateCssElement(styleElement); + } + + function updateAllLocalCss() { + [...document.querySelectorAll('link')] + .filter(l => l.baseURI === document.baseURI) + .forEach(e => updateCssElement(e)); + } + + function getMessageAndStack(error) { + const message = error.message || '' + let messageAndStack = error.stack || message + if (!messageAndStack.includes(message)) { + messageAndStack = message + "\n" + messageAndStack; + } + + return messageAndStack + } + + function getBlazorWasmApplyUpdateCapabilities(sendErrorToClient) { + let applyUpdateCapabilities; + try { + applyUpdateCapabilities = window.Blazor._internal.getApplyUpdateCapabilities(); + } catch (error) { + applyUpdateCapabilities = sendErrorToClient ? "!" + getMessageAndStack(error) : ''; + } + connection.send(applyUpdateCapabilities); + } + + function updateCssElement(styleElement) { + if (!styleElement || styleElement.loading) { + // A file change notification may be triggered for the same file before the browser + // finishes processing a previous update. In this case, it's easiest to ignore later updates + return; + } + + const newElement = styleElement.cloneNode(); + const href = styleElement.href; + newElement.href = href.split('?', 1)[0] + `?nonce=${Date.now()}`; + + styleElement.loading = true; + newElement.loading = true; + newElement.addEventListener('load', function () { + newElement.loading = false; + styleElement.remove(); + }); + + styleElement.parentNode.insertBefore(newElement, styleElement.nextSibling); + } + + async function applyBlazorDeltas_legacy(serverSecret, deltas, sendErrorToClient) { + if (sharedSecret && (serverSecret != sharedSecret.encodedSharedSecret)) { + // Validate the shared secret if it was specified. It might be unspecified in older versions of VS + // that do not support this feature as yet. + throw 'Unable to validate the server. Rejecting apply-update payload.'; + } + + let applyError = undefined; + + try { + applyDeltas_legacy(deltas) + } catch (error) { + console.warn(error); + applyError = error; + } + + const body = JSON.stringify({ + id: deltas[0].sequenceId, + deltas: deltas + }); + try { + await fetch('/_framework/blazor-hotreload', { method: 'post', headers: { 'content-type': 'application/json' }, body: body }); + } catch (error) { + console.warn(error); + applyError = error; + } + + if (applyError) { + sendDeltaNotApplied(sendErrorToClient ? applyError : undefined); + } else { + sendDeltaApplied(); + notifyHotReloadApplied(); + } + } + + function applyDeltas_legacy(deltas) { + let apply = window.Blazor?._internal?.applyHotReload + + // Only apply hot reload deltas if Blazor has been initialized. + // It's possible for Blazor to start after the initial page load, so we don't consider skipping this step + // to be a failure. These deltas will get applied later, when Blazor completes initialization. + if (apply) { + deltas.forEach(d => { + if (apply.length == 5) { + // WASM 8.0 + apply(d.moduleId, d.metadataDelta, d.ilDelta, d.pdbDelta, d.updatedTypes) + } else { + // WASM 9.0 + apply(d.moduleId, d.metadataDelta, d.ilDelta, d.pdbDelta) + } + }); + } + } + function sendDeltaApplied() { + connection.send(new Uint8Array([1]).buffer); + } + + function sendDeltaNotApplied(error) { + if (error) { + let encoder = new TextEncoder() + connection.send(encoder.encode("\0" + error.message + "\0" + error.stack)); + } else { + connection.send(new Uint8Array([0]).buffer); + } + } + + async function applyBlazorDeltas(serverSecret, updateId, deltas, responseLoggingLevel) { + if (sharedSecret && (serverSecret != sharedSecret.encodedSharedSecret)) { + // Validate the shared secret if it was specified. It might be unspecified in older versions of VS + // that do not support this feature as yet. + throw 'Unable to validate the server. Rejecting apply-update payload.'; + } + + const AgentMessageSeverity_Error = 2 + + let applyError = undefined; + let log = []; + try { + let applyDeltas = window.Blazor?._internal?.applyHotReloadDeltas + if (applyDeltas) { + // Only apply hot reload deltas if Blazor has been initialized. + // It's possible for Blazor to start after the initial page load, so we don't consider skipping this step + // to be a failure. These deltas will get applied later, when Blazor completes initialization. + + let wasmDeltas = deltas.map(delta => { + return { + "moduleId": delta.moduleId, + "metadataDelta": delta.metadataDelta, + "ilDelta": delta.ilDelta, + "pdbDelta": delta.pdbDelta, + "updatedTypes": delta.updatedTypes, + }; + }); + + log = applyDeltas(wasmDeltas, responseLoggingLevel); + } else { + // Try invoke older WASM API: + applyDeltas_legacy(deltas) + } + } catch (error) { + console.warn(error); + applyError = error; + log.push({ "message": getMessageAndStack(error), "severity": AgentMessageSeverity_Error }); + } + + try { + let body = JSON.stringify({ + "id": updateId, + "deltas": deltas + }); + + await fetch('/_framework/blazor-hotreload', { method: 'post', headers: { 'content-type': 'application/json' }, body: body }); + } catch (error) { + console.warn(error); + applyError = error; + log.push({ "message": getMessageAndStack(error), "severity": AgentMessageSeverity_Error }); + } + + connection.send(JSON.stringify({ + "success": !applyError, + "log": log + })); + + if (!applyError) { + notifyHotReloadApplied(); + } + } + + function displayDiagnostics(diagnostics) { + document.querySelectorAll('#dotnet-compile-error').forEach(el => el.remove()); + const el = document.body.appendChild(document.createElement('div')); + el.id = 'dotnet-compile-error'; + el.setAttribute('style', 'z-index:1000000; position:fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); color:black; overflow: scroll;'); + diagnostics.forEach(error => { + const item = el.appendChild(document.createElement('div')); + item.setAttribute('style', 'border: 2px solid red; padding: 8px; background-color: #faa;') + const message = item.appendChild(document.createElement('div')); + message.setAttribute('style', 'font-weight: bold'); + message.textContent = error.Message; + item.appendChild(document.createElement('div')).textContent = error; + }); + } + + function notifyHotReloadApplied() { + document.querySelectorAll('#dotnet-compile-error').forEach(el => el.remove()); + if (document.querySelector('#dotnet-hotreload-toast')) { + return; + } + if (!window[hotReloadActiveKey]) + { + return; + } + const el = document.createElement('div'); + el.id = 'dotnet-hotreload-toast'; + el.innerHTML = ""; + el.setAttribute('style', 'z-index: 1000000; width: 48px; height: 48px; position:fixed; top:5px; left: 5px'); + document.body.appendChild(el); + window[hotReloadActiveKey] = false; + setTimeout(() => el.remove(), 2000); + } + + function aspnetCoreHotReloadApplied() { + if (window.Blazor) { + window[hotReloadActiveKey] = true; + // hotReloadApplied triggers an enhanced navigation to + // refresh pages that have been statically rendered with + // Blazor SSR. + if (window.Blazor?._internal?.hotReloadApplied) + { + Blazor._internal.hotReloadApplied(); + } + else + { + notifyHotReloadApplied(); + } + } else { + location.reload(); + } + } + + async function getSecret(serverKeyString) { + if (!serverKeyString || !window.crypto || !window.crypto.subtle) { + return null; + } + + const secretBytes = window.crypto.getRandomValues(new Uint8Array(32)); // 32-bytes of entropy + + // Based on https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#subjectpublickeyinfo_import + const binaryServerKey = str2ab(atob(serverKeyString)); + const serverKey = await window.crypto.subtle.importKey('spki', binaryServerKey, { name: "RSA-OAEP", hash: "SHA-256" }, false, ['encrypt']); + const encrypted = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, serverKey, secretBytes); + return { + encryptedSharedSecret: btoa(String.fromCharCode(...new Uint8Array(encrypted))), + encodedSharedSecret: btoa(String.fromCharCode(...secretBytes)), + }; + + function str2ab(str) { + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i); + } + return buf; + } + } + + function getWebSocket(url) { + return new Promise((resolve, reject) => { + const encryptedSecret = sharedSecret && sharedSecret.encryptedSharedSecret; + const protocol = encryptedSecret ? encodeURIComponent(encryptedSecret) : []; + const webSocket = new WebSocket(url, protocol); + let opened = false; + + function onOpen() { + opened = true; + clearEventListeners(); + resolve(webSocket); + } + + function onClose(event) { + if (opened) { + // Open completed successfully. Nothing to do here. + return; + } + + let error = 'WebSocket failed to connect.'; + if (event instanceof ErrorEvent) { + error = event.error; + } + + clearEventListeners(); + reject(error); + } + + function clearEventListeners() { + webSocket.removeEventListener('open', onOpen); + // The error event isn't as reliable, but close is always called even during failures. + // If close is called without a corresponding open, we can reject the promise. + webSocket.removeEventListener('close', onClose); + } + + webSocket.addEventListener('open', onOpen); + webSocket.addEventListener('close', onClose); + if (window.Blazor?.removeEventListener && window.Blazor?.addEventListener) + { + webSocket.addEventListener('close', () => window.Blazor?.removeEventListener('enhancedload', notifyHotReloadApplied)); + window.Blazor?.addEventListener('enhancedload', notifyHotReloadApplied); + } + }); + } +}, 500); diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj b/src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj new file mode 100644 index 0000000000..d4808919f5 --- /dev/null +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj @@ -0,0 +1,36 @@ + + + + net6.0;net10.0 + + + true + + + + + + + + + + + + + + + + + + + + + + %(NuGetPackageId)\%(Link) + + + diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs b/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs new file mode 100644 index 0000000000..6110f0fa63 --- /dev/null +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs @@ -0,0 +1,191 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO.Pipes; +using System.Reflection; +using System.Runtime.Loader; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class PipeListener(string pipeName, IHotReloadAgent agent, Action log, int connectionTimeoutMS = 5000) +{ + /// + /// Messages to the client sent after the initial is sent + /// need to be sent while holding this lock in order to synchronize + /// 1) responses to requests received from the client (e.g. ) or + /// 2) notifications sent to the client that may be triggered at arbitrary times (e.g. ). + /// + private readonly SemaphoreSlim _messageToClientLock = new(initialCount: 1); + + // Not-null once initialized: + private NamedPipeClientStream? _pipeClient; + + public Task Listen(CancellationToken cancellationToken) + { + // Connect to the pipe synchronously. + // + // If a debugger is attached and there is a breakpoint in the startup code connecting asynchronously would + // set up a race between this code connecting to the server, and the breakpoint being hit. If the breakpoint + // hits first, applying changes will throw an error that the client is not connected. + // + // Updates made before the process is launched need to be applied before loading the affected modules. + + log($"Connecting to hot-reload server via pipe {pipeName}"); + + _pipeClient = new NamedPipeClientStream(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); + try + { + _pipeClient.Connect(connectionTimeoutMS); + log("Connected."); + } + catch (TimeoutException) + { + log($"Failed to connect in {connectionTimeoutMS}ms."); + _pipeClient.Dispose(); + return Task.CompletedTask; + } + + try + { + // block execution of the app until initial updates are applied: + InitializeAsync(cancellationToken).GetAwaiter().GetResult(); + } + catch (Exception e) + { + if (e is not OperationCanceledException) + { + log(e.Message); + } + + _pipeClient.Dispose(); + agent.Dispose(); + + return Task.CompletedTask; + } + + return Task.Run(async () => + { + try + { + await ReceiveAndApplyUpdatesAsync(initialUpdates: false, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + log(e.Message); + } + finally + { + _pipeClient.Dispose(); + agent.Dispose(); + } + }, cancellationToken); + } + + private async Task InitializeAsync(CancellationToken cancellationToken) + { + Debug.Assert(_pipeClient != null); + + agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose); + + var initPayload = new ClientInitializationResponse(agent.Capabilities); + await initPayload.WriteAsync(_pipeClient, cancellationToken); + + // Apply updates made before this process was launched to avoid executing unupdated versions of the affected modules. + + // We should only receive ManagedCodeUpdate when when the debugger isn't attached, + // otherwise the initialization should send InitialUpdatesCompleted immediately. + // The debugger itself applies these updates when launching process with the debugger attached. + await ReceiveAndApplyUpdatesAsync(initialUpdates: true, cancellationToken); + } + + private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, CancellationToken cancellationToken) + { + Debug.Assert(_pipeClient != null); + + while (_pipeClient.IsConnected) + { + var payloadType = (RequestType)await _pipeClient.ReadByteAsync(cancellationToken); + switch (payloadType) + { + case RequestType.ManagedCodeUpdate: + await ReadAndApplyManagedCodeUpdateAsync(cancellationToken); + break; + + case RequestType.StaticAssetUpdate: + await ReadAndApplyStaticAssetUpdateAsync(cancellationToken); + break; + + case RequestType.InitialUpdatesCompleted when initialUpdates: + return; + + default: + // can't continue, the pipe content is in an unknown state + throw new InvalidOperationException($"Unexpected payload type: {payloadType}"); + } + } + } + + private async ValueTask ReadAndApplyManagedCodeUpdateAsync(CancellationToken cancellationToken) + { + Debug.Assert(_pipeClient != null); + + var request = await ManagedCodeUpdateRequest.ReadAsync(_pipeClient, cancellationToken); + + bool success; + try + { + agent.ApplyManagedCodeUpdates(request.Updates); + success = true; + } + catch (Exception e) + { + agent.Reporter.Report($"The runtime failed to applying the change: {e.Message}", AgentMessageSeverity.Error); + agent.Reporter.Report("Further changes won't be applied to this process.", AgentMessageSeverity.Warning); + success = false; + } + + var logEntries = agent.Reporter.GetAndClearLogEntries(request.ResponseLoggingLevel); + + await SendResponseAsync(new UpdateResponse(logEntries, success), cancellationToken); + } + + private async ValueTask ReadAndApplyStaticAssetUpdateAsync(CancellationToken cancellationToken) + { + Debug.Assert(_pipeClient != null); + + var request = await StaticAssetUpdateRequest.ReadAsync(_pipeClient, cancellationToken); + + try + { + agent.ApplyStaticAssetUpdate(request.Update); + } + catch (Exception e) + { + agent.Reporter.Report($"Failed to apply static asset update: {e.Message}", AgentMessageSeverity.Error); + } + + var logEntries = agent.Reporter.GetAndClearLogEntries(request.ResponseLoggingLevel); + + // Updating static asset only invokes ContentUpdate metadata update handlers. + // Failures of these handlers are reported to the log and ignored. + // Therefore, this request always succeeds. + await SendResponseAsync(new UpdateResponse(logEntries, success: true), cancellationToken); + } + + internal async ValueTask SendResponseAsync(T response, CancellationToken cancellationToken) + where T : IResponse + { + Debug.Assert(_pipeClient != null); + try + { + await _messageToClientLock.WaitAsync(cancellationToken); + await _pipeClient.WriteAsync((byte)response.Type, cancellationToken); + await response.WriteAsync(_pipeClient, cancellationToken); + } + finally + { + _messageToClientLock.Release(); + } + } +} diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs b/src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs new file mode 100644 index 0000000000..bcc5ea0374 --- /dev/null +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.DotNet.Watch; + +internal static class ProcessUtilities +{ + public const int SIGKILL = 9; + public const int SIGTERM = 15; + + /// + /// Enables handling of Ctrl+C in a process where it was disabled. + /// + /// If a process is launched with CREATE_NEW_PROCESS_GROUP flag + /// it allows the parent process to send Ctrl+C event to the child process, + /// but also disables Ctrl+C handlers. + /// + public static void EnableWindowsCtrlCHandling(Action log) + { + Debug.Assert(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + + // "If the HandlerRoutine parameter is NULL, a TRUE value causes the calling process to ignore CTRL+C input, + // and a FALSE value restores normal processing of CTRL+C input. + // This attribute of ignoring or processing CTRL+C is inherited by child processes." + + if (SetConsoleCtrlHandler(null, false)) + { + log("Windows Ctrl+C handling enabled."); + } + else + { + log($"Failed to enable Ctrl+C handling: {GetLastPInvokeErrorMessage()}"); + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetConsoleCtrlHandler(Delegate? handler, bool add); + } + + public static string? SendWindowsCtrlCEvent(int processId) + { + const uint CTRL_C_EVENT = 0; + + // Doc: + // "The process identifier of the new process is also the process group identifier of a new process group. + // + // The process group includes all processes that are descendants of the root process. + // Only those processes in the group that share the same console as the calling process receive the signal. + // In other words, if a process in the group creates a new console, that process does not receive the signal, + // nor do its descendants. + // + // If this parameter is zero, the signal is generated in all processes that share the console of the calling process." + return GenerateConsoleCtrlEvent(CTRL_C_EVENT, (uint)processId) ? null : GetLastPInvokeErrorMessage(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); + } + + public static string? SendPosixSignal(int processId, int signal) + { + return sys_kill(processId, signal) == 0 ? null : GetLastPInvokeErrorMessage(); + + [DllImport("libc", SetLastError = true, EntryPoint = "kill")] + static extern int sys_kill(int pid, int sig); + } + + private static string GetLastPInvokeErrorMessage() + { + var error = Marshal.GetLastPInvokeError(); +#if NET10_0_OR_GREATER + return $"{Marshal.GetPInvokeErrorMessage(error)} (code {error})"; +#else + return $"error code {error}"; +#endif + } +} diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs b/src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs new file mode 100644 index 0000000000..03c7b04a4f --- /dev/null +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO.Pipes; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.DotNet.HotReload; +using Microsoft.DotNet.Watch; + +/// +/// The runtime startup hook looks for top-level type named "StartupHook". +/// +internal sealed class StartupHook +{ + private static readonly string? s_standardOutputLogPrefix = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.HotReloadDeltaClientLogMessages); + private static readonly string? s_namedPipeName = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName); + +#if NET10_0_OR_GREATER + private static PosixSignalRegistration? s_signalRegistration; +#endif + + /// + /// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS. + /// + public static void Initialize() + { + var processPath = Environment.GetCommandLineArgs().FirstOrDefault(); + var processDir = Path.GetDirectoryName(processPath)!; + + Log($"Loaded into process: {processPath} ({typeof(StartupHook).Assembly.Location})"); + + HotReloadAgent.ClearHotReloadEnvironmentVariables(typeof(StartupHook)); + + if (string.IsNullOrEmpty(s_namedPipeName)) + { + Log($"Environment variable {AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName} has no value"); + return; + } + + RegisterSignalHandlers(); + + PipeListener? listener = null; + + var agent = new HotReloadAgent( + assemblyResolvingHandler: (_, args) => + { + Log($"Resolving '{args.Name}, Version={args.Version}'"); + var path = Path.Combine(processDir, args.Name + ".dll"); + return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null; + }, + hotReloadExceptionCreateHandler: (code, message) => + { + // Continue executing the code if the debugger is attached. + // It will throw the exception and the debugger will handle it. + if (Debugger.IsAttached) + { + return; + } + + Debug.Assert(listener != null); + Log($"Runtime rude edit detected: '{message}'"); + + SendAndForgetAsync().Wait(); + + // Handle Ctrl+C to terminate gracefully: + Console.CancelKeyPress += (_, _) => Environment.Exit(0); + + // wait for the process to be terminated by the Hot Reload client (other threads might still execute): + Thread.Sleep(Timeout.Infinite); + + async Task SendAndForgetAsync() + { + try + { + await listener.SendResponseAsync(new HotReloadExceptionCreatedNotification(code, message), CancellationToken.None); + } + catch + { + // do not crash the app + } + } + }); + + listener = new PipeListener(s_namedPipeName, agent, Log); + + // fire and forget: + _ = listener.Listen(CancellationToken.None); + } + + private static void RegisterSignalHandlers() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + ProcessUtilities.EnableWindowsCtrlCHandling(Log); + } + else + { +#if NET10_0_OR_GREATER + // Register a handler for SIGTERM to allow graceful shutdown of the application on Unix. + // See https://github.com/dotnet/docs/issues/46226. + + // Note: registered handlers are executed in reverse order of their registration. + // Since the startup hook is executed before any code of the application, it is the first handler registered and thus the last to run. + + s_signalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => + { + Log($"SIGTERM received. Cancel={context.Cancel}"); + + if (!context.Cancel) + { + Environment.Exit(0); + } + }); + + Log("Posix signal handlers registered."); +#endif + } + } + + private static void Log(string message) + { + var prefix = s_standardOutputLogPrefix; + if (!string.IsNullOrEmpty(prefix)) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Error.WriteLine($"{prefix} {message}"); + Console.ResetColor(); + } + } +} diff --git a/src/Microsoft.VisualStudio.AppDesigner/Microsoft.VisualStudio.AppDesigner.vbproj b/src/Microsoft.VisualStudio.AppDesigner/Microsoft.VisualStudio.AppDesigner.vbproj index b683bbcea6..bfffde1db2 100644 --- a/src/Microsoft.VisualStudio.AppDesigner/Microsoft.VisualStudio.AppDesigner.vbproj +++ b/src/Microsoft.VisualStudio.AppDesigner/Microsoft.VisualStudio.AppDesigner.vbproj @@ -23,6 +23,8 @@ + + ManagedCodeMarkers.vb diff --git a/src/Microsoft.VisualStudio.Editors/Microsoft.VisualStudio.Editors.vbproj b/src/Microsoft.VisualStudio.Editors/Microsoft.VisualStudio.Editors.vbproj index 8205be8cf6..4f927508bc 100644 --- a/src/Microsoft.VisualStudio.Editors/Microsoft.VisualStudio.Editors.vbproj +++ b/src/Microsoft.VisualStudio.Editors/Microsoft.VisualStudio.Editors.vbproj @@ -31,6 +31,8 @@ + + Designer diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/Microsoft.VisualStudio.ProjectSystem.Managed.VS.csproj b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/Microsoft.VisualStudio.ProjectSystem.Managed.VS.csproj index 9b27766c12..b83bb25332 100644 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/Microsoft.VisualStudio.ProjectSystem.Managed.VS.csproj +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/Microsoft.VisualStudio.ProjectSystem.Managed.VS.csproj @@ -50,6 +50,8 @@ + + Component diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/Microsoft.VisualStudio.ProjectSystem.Managed.csproj b/src/Microsoft.VisualStudio.ProjectSystem.Managed/Microsoft.VisualStudio.ProjectSystem.Managed.csproj index 40485cddcb..7caf87fe51 100644 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed/Microsoft.VisualStudio.ProjectSystem.Managed.csproj +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed/Microsoft.VisualStudio.ProjectSystem.Managed.csproj @@ -51,9 +51,7 @@ - - @@ -71,33 +69,12 @@ - - - - PreserveNewest - false - HotReload\net6.0\Microsoft.AspNetCore.Watch.BrowserRefresh.dll - false - - - PreserveNewest - false - HotReload\net6.0\Microsoft.Extensions.DotNetDeltaApplier.dll - false - - - PreserveNewest - false - HotReload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll - false - - - + + + + AnalyzerReference.xaml From bbad09c49402645e19e1e9dea4d9d39c24dbfef4 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 19 Oct 2025 09:37:37 -0700 Subject: [PATCH 3/9] Update headers --- .../ApplicationPaths.cs | 3 +-- .../BlazorWasmHotReloadMiddleware.cs | 3 +-- .../BrowserRefreshMiddleware.cs | 3 +-- .../BrowserScriptMiddleware.cs | 3 +-- .../HostingStartup.cs | 3 +-- .../ResponseStreamWrapper.cs | 3 +-- .../ScriptInjectingStream.cs | 3 +-- src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs | 3 +-- src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs | 3 +-- .../ProcessUtilities.cs | 3 +-- src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs | 3 +-- 11 files changed, 11 insertions(+), 22 deletions(-) diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs index 1d8ea6069b..daafc25c55 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using Microsoft.AspNetCore.Http; diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs index 6ccdd09f6b..6baf9afc52 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Text.Json; using Microsoft.AspNetCore.Http; diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs index 13fac3ceae..daa5ee60a9 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs index a9bffacdf9..d55d445de5 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Globalization; using Microsoft.AspNetCore.Http; diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs index 369133ccbe..936458d44d 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.cs index 16b2256e3f..fd20e87ebb 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.cs +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Diagnostics; using System.IO.Compression; diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs index 7ba9114253..c45b5cd784 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Diagnostics; using System.Runtime.CompilerServices; diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs index 93be4f31fa..722213187c 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. internal class StartupHook { diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs b/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs index 6110f0fa63..6c4fa2ea3f 100644 --- a/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Diagnostics; using System.IO.Pipes; diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs b/src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs index bcc5ea0374..deb32e85cc 100644 --- a/src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Diagnostics; diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs b/src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs index 03c7b04a4f..a46c1198a9 100644 --- a/src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs @@ -1,5 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Diagnostics; using System.IO.Pipes; From a182e7a20ae8ab67b6e82a5634d38ce093811959 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 19 Oct 2025 10:25:40 -0700 Subject: [PATCH 4/9] Use .NET 10 --- eng/pipelines/templates/build-pull-request.yml | 5 ++--- eng/pipelines/templates/generate-localization.yml | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/eng/pipelines/templates/build-pull-request.yml b/eng/pipelines/templates/build-pull-request.yml index d80022ffe9..75d3c2a478 100644 --- a/eng/pipelines/templates/build-pull-request.yml +++ b/eng/pipelines/templates/build-pull-request.yml @@ -33,9 +33,8 @@ jobs: - task: UseDotNet@2 displayName: Install .NET Runtime inputs: - packageType: runtime - # This should match the target of our unit test projects. - version: 9.0.x + includePreviewVersions: true + version: 10.x # Allows for accessing the internal AzDO feed (vs-impl-internal) for project restore via Azure Artifacts Credential Provider. # See: https://github.com/microsoft/artifacts-credprovider#automatic-usage diff --git a/eng/pipelines/templates/generate-localization.yml b/eng/pipelines/templates/generate-localization.yml index 94c2cad18d..8f5a2c9b72 100644 --- a/eng/pipelines/templates/generate-localization.yml +++ b/eng/pipelines/templates/generate-localization.yml @@ -23,9 +23,8 @@ jobs: - task: UseDotNet@2 displayName: Install .NET Runtime inputs: - packageType: runtime - # This should match the target in OneLocBuildSetup.csproj. - version: 9.0.x + includePreviewVersions: true + version: 10.x # Creates the LocProject.json and perform some necessary file copying and renaming. - task: DotNetCoreCLI@2 From f1c7d3c661038def20b78bf9e153350a3a4a22b7 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 19 Oct 2025 10:52:18 -0700 Subject: [PATCH 5/9] Fix Build.proj --- eng/Build.proj | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/eng/Build.proj b/eng/Build.proj index af5934c661..d4c19dc39f 100644 --- a/eng/Build.proj +++ b/eng/Build.proj @@ -50,27 +50,11 @@ - - - $([System.IO.Path]::GetPathRoot('$(ArtifactsDir)')) - $([System.String]::Copy('$(ArtifactsDir)').Substring(3)) - - - - - - - $([System.String]::Copy('%(BuildProject.FullPath)').Substring(3)) - - - - + From 1cf00b07312c11eb497742b5892c27dca411d7e1 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 19 Oct 2025 10:58:29 -0700 Subject: [PATCH 6/9] sln --- eng/Build.proj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/Build.proj b/eng/Build.proj index d4c19dc39f..8279742f6b 100644 --- a/eng/Build.proj +++ b/eng/Build.proj @@ -51,7 +51,7 @@ - + From f05318e2f3f8656a781778289c7829a700fd0a6c Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 20 Oct 2025 10:48:47 -0700 Subject: [PATCH 7/9] .NET 9 for tests --- eng/pipelines/templates/build-pull-request.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/eng/pipelines/templates/build-pull-request.yml b/eng/pipelines/templates/build-pull-request.yml index 75d3c2a478..23bb47fd0f 100644 --- a/eng/pipelines/templates/build-pull-request.yml +++ b/eng/pipelines/templates/build-pull-request.yml @@ -31,11 +31,19 @@ jobs: # Ensure the .NET runtime needed by our unit tests is installed. - task: UseDotNet@2 - displayName: Install .NET Runtime + displayName: Install .NET 10.x Runtime inputs: includePreviewVersions: true version: 10.x + # Ensure the .NET runtime needed by our unit tests is installed. + - task: UseDotNet@2 + displayName: Install .NET 9.0.x Runtime + inputs: + packageType: runtime + # This should match the target of our unit test projects. + version: 9.0.x + # Allows for accessing the internal AzDO feed (vs-impl-internal) for project restore via Azure Artifacts Credential Provider. # See: https://github.com/microsoft/artifacts-credprovider#automatic-usage # YAML reference: https://docs.microsoft.com/azure/devops/pipelines/tasks/package/nuget-authenticate?view=azure-devops From 4dbfbc39f1cb5c1bc5c7655b629e262366e25dc1 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Tue, 21 Oct 2025 09:47:11 -0700 Subject: [PATCH 8/9] Workaround for PDB conversion issues --- Directory.Build.targets | 2 +- .../Microsoft.AspNetCore.Watch.BrowserRefresh.csproj | 4 ++++ .../Microsoft.Extensions.DotNetDeltaApplier.csproj | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index 0559899f4f..5583e2ba2b 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -2,7 +2,7 @@ - + diff --git a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj index b41242dbb0..63ef69de0d 100644 --- a/src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj @@ -5,6 +5,10 @@ When updating the TFM also update minimal supported version in VisualStudioBrowserRefreshServer. --> net6.0 + + + embedded + false diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj b/src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj index d4808919f5..e052587c12 100644 --- a/src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj @@ -9,6 +9,10 @@ true + + + embedded + false From feb80537ebb0ce6b5fd3b6eb69e68a584afdfb0b Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Tue, 21 Oct 2025 10:02:49 -0700 Subject: [PATCH 9/9] Suppress NU1510 --- Directory.Build.props | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 5caf712761..724303ae64 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -60,7 +60,9 @@ true true true - $(NoWarn);NU5125 + + $(NoWarn);NU1510 + $(NoWarn);NU5125; true true