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 = "