diff --git a/Directory.Build.props b/Directory.Build.props index 5caf7127614..724303ae64c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -60,7 +60,9 @@ true true true - $(NoWarn);NU5125 + + $(NoWarn);NU1510 + $(NoWarn);NU5125; true true diff --git a/Directory.Build.targets b/Directory.Build.targets index 0559899f4f6..5583e2ba2bf 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -2,7 +2,7 @@ - + diff --git a/Directory.Packages.props b/Directory.Packages.props index 449945ede63..f944f586880 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 - - $([System.IO.Path]::GetPathRoot('$(ArtifactsDir)')) - $([System.String]::Copy('$(ArtifactsDir)').Substring(3)) - - - - - - - - - $([System.String]::Copy('%(BuildProject.FullPath)').Substring(3)) - - + - + diff --git a/eng/pipelines/templates/build-pull-request.yml b/eng/pipelines/templates/build-pull-request.yml index d80022ffe9e..23bb47fd0f6 100644 --- a/eng/pipelines/templates/build-pull-request.yml +++ b/eng/pipelines/templates/build-pull-request.yml @@ -31,7 +31,14 @@ 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. diff --git a/eng/pipelines/templates/generate-localization.yml b/eng/pipelines/templates/generate-localization.yml index 94c2cad18d1..8f5a2c9b728 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 diff --git a/setup/ProjectSystemSetup/ProjectSystemSetup.csproj b/setup/ProjectSystemSetup/ProjectSystemSetup.csproj index 5f59f9cf79f..0f987cc108a 100644 --- a/setup/ProjectSystemSetup/ProjectSystemSetup.csproj +++ b/setup/ProjectSystemSetup/ProjectSystemSetup.csproj @@ -55,6 +55,8 @@ + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e1f92ab301e..8871e37836c 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 00000000000..13d5958298b --- /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 00000000000..4ae90dd7a7a --- /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 00000000000..daafc25c559 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ApplicationPaths.cs @@ -0,0 +1,40 @@ +// 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; + +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 00000000000..87a203450f0 --- /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 00000000000..6baf9afc528 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BlazorWasmHotReloadMiddleware.cs @@ -0,0 +1,98 @@ +// 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; +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 00000000000..daa5ee60a98 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserRefreshMiddleware.cs @@ -0,0 +1,247 @@ +// 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; +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 00000000000..d55d445de5e --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/BrowserScriptMiddleware.cs @@ -0,0 +1,71 @@ +// 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; +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 00000000000..936458d44d5 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/HostingStartup.cs @@ -0,0 +1,58 @@ +// 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; +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 00000000000..63ef69de0dc --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj @@ -0,0 +1,44 @@ + + + + net6.0 + + + embedded + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %(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 00000000000..fd20e87ebb1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ResponseStreamWrapper.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. See the LICENSE.md file in the project root for more information. + +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 00000000000..c45b5cd7844 --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/ScriptInjectingStream.cs @@ -0,0 +1,395 @@ +// 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; + +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 00000000000..722213187cc --- /dev/null +++ b/src/Microsoft.AspNetCore.Watch.BrowserRefresh/StartupHook.cs @@ -0,0 +1,11 @@ +// 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 +{ + 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 00000000000..aa8f1fd60f9 --- /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 00000000000..e052587c12b --- /dev/null +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj @@ -0,0 +1,40 @@ + + + + net6.0;net10.0 + + + true + + + embedded + false + + + + + + + + + + + + + + + + + + + + + + %(NuGetPackageId)\%(Link) + + + diff --git a/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs b/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs new file mode 100644 index 00000000000..6c4fa2ea3f7 --- /dev/null +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/PipeListener.cs @@ -0,0 +1,190 @@ +// 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; +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 00000000000..deb32e85cc4 --- /dev/null +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/ProcessUtilities.cs @@ -0,0 +1,77 @@ +// 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; + +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 00000000000..a46c1198a98 --- /dev/null +++ b/src/Microsoft.Extensions.DotNetDeltaApplier/StartupHook.cs @@ -0,0 +1,130 @@ +// 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; +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 b683bbcea6c..bfffde1db26 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 8205be8cf62..4f927508bc3 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 9b27766c128..b83bb25332d 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 40485cddcb2..7caf87fe51a 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 diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ValueTaskExtensions.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ValueTaskExtensions.cs new file mode 100644 index 00000000000..b870fa0e081 --- /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 +}