Skip to content

Commit 45a7a38

Browse files
marafCopilot
andauthored
[wasm] General HotReload agent for WebAssembly in browser (#49800)
Co-authored-by: Copilot <[email protected]>
1 parent 42ec2db commit 45a7a38

File tree

32 files changed

+11283
-900
lines changed

32 files changed

+11283
-900
lines changed

sdk.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
<Project Path="src/BuiltInTools/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.shproj" />
5353
<Project Path="src/BuiltInTools/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.Package.csproj" />
5454
<Project Path="src/BuiltInTools/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.shproj" />
55+
<Project Path="src/BuiltInTools/HotReloadAgent.WebAssembly.Browser/Microsoft.DotNet.HotReload.WebAssembly.Browser.csproj" />
5556
</Folder>
5657
<Folder Name="/src/Cli/">
5758
<Project Path="src/Cli/dotnet/dotnet.csproj" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Razor">
2+
3+
<PropertyGroup>
4+
<TargetFramework>$(SdkTargetFramework)</TargetFramework>
5+
<GenerateDocumentationFile>false</GenerateDocumentationFile>
6+
<GenerateDependencyFile>false</GenerateDependencyFile>
7+
<LangVersion>preview</LangVersion>
8+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
9+
10+
<!-- NuGet -->
11+
<IsPackable>true</IsPackable>
12+
<IsShipping>true</IsShipping>
13+
<IsShippingPackage>true</IsShippingPackage>
14+
<PackageId>Microsoft.DotNet.HotReload.WebAssembly.Browser</PackageId>
15+
<Description>HotReload package for WebAssembly</Description>
16+
<!-- Remove once https://github.com/NuGet/Home/issues/8583 is fixed -->
17+
<NoWarn>$(NoWarn);NU5128</NoWarn>
18+
</PropertyGroup>
19+
20+
<Import Project="..\HotReloadAgent\Microsoft.DotNet.HotReload.Agent.projitems" Label="Shared" />
21+
<Import Project="..\HotReloadAgent.Data\Microsoft.DotNet.HotReload.Agent.Data.projitems" Label="Shared" />
22+
23+
<ItemGroup>
24+
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" Key="$(MoqPublicKey)" />
25+
</ItemGroup>
26+
</Project>
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.ComponentModel;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Globalization;
7+
using System.Linq;
8+
using System.Net.Http;
9+
using System.Reflection.Metadata;
10+
using System.Runtime.InteropServices.JavaScript;
11+
using System.Runtime.Versioning;
12+
using System.Text.Json;
13+
using System.Text.Json.Serialization;
14+
15+
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
16+
17+
namespace Microsoft.DotNet.HotReload.WebAssembly.Browser;
18+
19+
/// <summary>
20+
/// Contains methods called by interop. Intended for framework use only, not supported for use in application
21+
/// code.
22+
/// </summary>
23+
[EditorBrowsable(EditorBrowsableState.Never)]
24+
[UnconditionalSuppressMessage(
25+
"Trimming",
26+
"IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code",
27+
Justification = "Hot Reload does not support trimming")]
28+
internal static partial class WebAssemblyHotReload
29+
{
30+
/// <summary>
31+
/// For framework use only.
32+
/// </summary>
33+
public readonly struct LogEntry
34+
{
35+
public string Message { get; init; }
36+
public int Severity { get; init; }
37+
}
38+
39+
/// <summary>
40+
/// For framework use only.
41+
/// </summary>
42+
internal sealed class Update
43+
{
44+
public int Id { get; set; }
45+
public Delta[] Deltas { get; set; } = default!;
46+
}
47+
48+
/// <summary>
49+
/// For framework use only.
50+
/// </summary>
51+
public readonly struct Delta
52+
{
53+
public string ModuleId { get; init; }
54+
public byte[] MetadataDelta { get; init; }
55+
public byte[] ILDelta { get; init; }
56+
public byte[] PdbDelta { get; init; }
57+
public int[] UpdatedTypes { get; init; }
58+
}
59+
60+
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web);
61+
62+
private static bool s_initialized;
63+
private static HotReloadAgent? s_hotReloadAgent;
64+
65+
[JSExport]
66+
[SupportedOSPlatform("browser")]
67+
public static async Task InitializeAsync(string baseUri)
68+
{
69+
if (MetadataUpdater.IsSupported && Environment.GetEnvironmentVariable("__ASPNETCORE_BROWSER_TOOLS") == "true" &&
70+
OperatingSystem.IsBrowser())
71+
{
72+
s_initialized = true;
73+
74+
var agent = new HotReloadAgent();
75+
76+
var existingAgent = Interlocked.CompareExchange(ref s_hotReloadAgent, agent, null);
77+
if (existingAgent != null)
78+
{
79+
throw new InvalidOperationException("Hot Reload agent already initialized");
80+
}
81+
82+
await ApplyPreviousDeltasAsync(agent, baseUri);
83+
}
84+
}
85+
86+
private static async ValueTask ApplyPreviousDeltasAsync(HotReloadAgent agent, string baseUri)
87+
{
88+
string errorMessage;
89+
90+
using var client = new HttpClient()
91+
{
92+
BaseAddress = new Uri(baseUri, UriKind.Absolute)
93+
};
94+
95+
try
96+
{
97+
var response = await client.GetAsync("/_framework/blazor-hotreload");
98+
if (response.IsSuccessStatusCode)
99+
{
100+
var deltasJson = await response.Content.ReadAsStringAsync();
101+
var updates = deltasJson != "" ? JsonSerializer.Deserialize<Update[]>(deltasJson, s_jsonSerializerOptions) : null;
102+
if (updates == null)
103+
{
104+
agent.Reporter.Report($"No previous updates to apply.", AgentMessageSeverity.Verbose);
105+
return;
106+
}
107+
108+
var i = 1;
109+
foreach (var update in updates)
110+
{
111+
agent.Reporter.Report($"Reapplying update {i}/{updates.Length}.", AgentMessageSeverity.Verbose);
112+
113+
agent.ApplyDeltas(
114+
update.Deltas.Select(d => new UpdateDelta(Guid.Parse(d.ModuleId, CultureInfo.InvariantCulture), d.MetadataDelta, d.ILDelta, d.PdbDelta, d.UpdatedTypes)));
115+
116+
i++;
117+
}
118+
119+
return;
120+
}
121+
122+
errorMessage = $"HTTP GET '/_framework/blazor-hotreload' returned {response.StatusCode}";
123+
}
124+
catch (Exception e)
125+
{
126+
errorMessage = e.ToString();
127+
}
128+
129+
agent.Reporter.Report($"Failed to retrieve and apply previous deltas from the server: {errorMessage}", AgentMessageSeverity.Error);
130+
}
131+
132+
private static HotReloadAgent? GetAgent()
133+
=> s_hotReloadAgent ?? (s_initialized ? throw new InvalidOperationException("Hot Reload agent not initialized") : null);
134+
135+
private static LogEntry[] ApplyHotReloadDeltas(Delta[] deltas, int loggingLevel)
136+
{
137+
var agent = GetAgent();
138+
if (agent == null)
139+
{
140+
return [];
141+
}
142+
143+
agent.ApplyDeltas(
144+
deltas.Select(d => new UpdateDelta(Guid.Parse(d.ModuleId, CultureInfo.InvariantCulture), d.MetadataDelta, d.ILDelta, d.PdbDelta, d.UpdatedTypes)));
145+
146+
return agent.Reporter.GetAndClearLogEntries((ResponseLoggingLevel)loggingLevel)
147+
.Select(log => new LogEntry() { Message = log.message, Severity = (int)log.severity }).ToArray();
148+
}
149+
150+
private static readonly WebAssemblyHotReloadJsonSerializerContext jsonContext = new(new(JsonSerializerDefaults.Web));
151+
152+
[JSExport]
153+
[SupportedOSPlatform("browser")]
154+
public static string GetApplyUpdateCapabilities()
155+
{
156+
return GetAgent()?.Capabilities ?? "";
157+
}
158+
159+
[JSExport]
160+
[SupportedOSPlatform("browser")]
161+
public static string? ApplyHotReloadDeltas(string deltasJson, int loggingLevel)
162+
{
163+
var deltas = JsonSerializer.Deserialize(deltasJson, jsonContext.DeltaArray);
164+
if (deltas == null)
165+
{
166+
return null;
167+
}
168+
169+
var result = ApplyHotReloadDeltas(deltas, loggingLevel);
170+
return result == null ? null : JsonSerializer.Serialize(result, jsonContext.LogEntryArray);
171+
}
172+
}
173+
174+
[JsonSerializable(typeof(WebAssemblyHotReload.Delta[]))]
175+
[JsonSerializable(typeof(WebAssemblyHotReload.LogEntry[]))]
176+
internal sealed partial class WebAssemblyHotReloadJsonSerializerContext : JsonSerializerContext
177+
{
178+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export async function onRuntimeConfigLoaded(config) {
2+
// If we have 'aspnetcore-browser-refresh', configure mono runtime for HotReload.
3+
if (config.debugLevel !== 0 && globalThis.window?.document?.querySelector("script[src*='aspnetcore-browser-refresh']")) {
4+
if (!config.environmentVariables["DOTNET_MODIFIABLE_ASSEMBLIES"]) {
5+
config.environmentVariables["DOTNET_MODIFIABLE_ASSEMBLIES"] = "debug";
6+
}
7+
if (!config.environmentVariables["__ASPNETCORE_BROWSER_TOOLS"]) {
8+
config.environmentVariables["__ASPNETCORE_BROWSER_TOOLS"] = "true";
9+
}
10+
}
11+
12+
// Disable HotReload built-into the Blazor WebAssembly runtime
13+
config.environmentVariables["__BLAZOR_WEBASSEMBLY_LEGACY_HOTRELOAD"] = "false";
14+
}
15+
16+
export async function onRuntimeReady({ getAssemblyExports }) {
17+
const exports = await getAssemblyExports("Microsoft.DotNet.HotReload.WebAssembly.Browser");
18+
await exports.Microsoft.DotNet.HotReload.WebAssembly.Browser.WebAssemblyHotReload.InitializeAsync(document.baseURI);
19+
20+
if (!window.Blazor) {
21+
window.Blazor = {};
22+
23+
if (!window.Blazor._internal) {
24+
window.Blazor._internal = {};
25+
}
26+
}
27+
28+
window.Blazor._internal.applyHotReloadDeltas = (deltas, loggingLevel) => {
29+
const result = exports.Microsoft.DotNet.HotReload.WebAssembly.Browser.WebAssemblyHotReload.ApplyHotReloadDeltas(JSON.stringify(deltas), loggingLevel);
30+
return result ? JSON.parse(result) : [];
31+
};
32+
33+
window.Blazor._internal.getApplyUpdateCapabilities = () => {
34+
return exports.Microsoft.DotNet.HotReload.WebAssembly.Browser.WebAssemblyHotReload.GetApplyUpdateCapabilities() ?? '';
35+
};
36+
}

src/BuiltInTools/dotnet-watch.slnf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"src\\BuiltInTools\\HotReloadAgent.Data\\Microsoft.DotNet.HotReload.Agent.Data.shproj",
1414
"src\\BuiltInTools\\HotReloadAgent.PipeRpc\\Microsoft.DotNet.HotReload.Agent.PipeRpc.Package.csproj",
1515
"src\\BuiltInTools\\HotReloadAgent.PipeRpc\\Microsoft.DotNet.HotReload.Agent.PipeRpc.shproj",
16+
"src\\BuiltInTools\\HotReloadAgent.WebAssembly.Browser\\Microsoft.DotNet.HotReload.WebAssembly.Browser.csproj",
1617
"src\\BuiltInTools\\dotnet-watch\\dotnet-watch.csproj",
1718
"test\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj",
1819
"test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj",

src/WasmSdk/Sdk/Sdk.targets

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ Copyright (c) .NET Foundation. All rights reserved.
1515
<Import Sdk="Microsoft.NET.Sdk.StaticWebAssets" Project="Sdk.targets" Condition="'$(RuntimeIdentifier)' != 'wasi-wasm' and '$(_WasmSdkImportsMicrosoftNETSdkStaticWebAssets)' == 'true'" />
1616
<Import Sdk="Microsoft.NET.Sdk" Project="Sdk.targets" Condition="'$(RuntimeIdentifier)' == 'wasi-wasm' and '$(_WasmSdkImportsMicrosoftNetSdk)' == 'true'" />
1717

18+
<PropertyGroup>
19+
<!-- Implicit HotReload package reference for .NET 10+ if not explicitly turned off -->
20+
<_WasmEnableHotReload>$(WasmEnableHotReload)</_WasmEnableHotReload>
21+
<_WasmEnableHotReload Condition="'$(_WasmEnableHotReload)' == '' and '$(Configuration)' != 'Debug'">false</_WasmEnableHotReload>
22+
<_WasmEnableHotReload Condition="'$(_WasmEnableHotReload)' == '' and '$(TargetFrameworkIdentifier)' == '.NETCoreApp' and $([MSBuild]::VersionGreaterThanOrEquals('$(TargetFrameworkVersion)', '10.0'))">true</_WasmEnableHotReload>
23+
</PropertyGroup>
24+
<Target Name="_ImplicitlyReferenceHotReload" BeforeTargets="ProcessFrameworkReferences">
25+
<ItemGroup Condition="'$(_WasmEnableHotReload)' == 'true'">
26+
<PackageReference Include="Microsoft.DotNet.HotReload.WebAssembly.Browser" Version="$(NETCoreSdkVersion)" IsImplicitlyDefined="true" />
27+
</ItemGroup>
28+
</Target>
29+
1830
<Import Sdk="Microsoft.NET.Sdk.Publish" Project="Sdk.targets" Condition="'$(_WasmSdkImportsMicrosoftNETSdkPublish)' == 'true'" />
1931
<Import Project="$(_WebAssemblyTargetsFile)" Condition="'$(_WebAssemblyTargetsFile)' != ''" />
2032
</Project>

test/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/BootJsonData.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,8 @@ public class AssetsData
302302
/// Used in .NET < 8
303303
/// </summary>
304304
[DataMember(EmitDefaultValue = false)]
305-
public List<JsAsset> libraryInitializers { get; set; }
305+
//public List<JsAsset> libraryInitializers { get; set; }
306+
public ResourceHashesByNameDictionary libraryInitializers { get; set; }
306307

307308
[DataMember(EmitDefaultValue = false)]
308309
public List<JsAsset> modulesAfterConfigLoaded { get; set; }
@@ -407,6 +408,7 @@ public static BootJsonData ParseBootData(string bootConfigPath)
407408
string jsonContent = GetJsonContent(bootConfigPath);
408409
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
409410
options.Converters.Add(new ResourcesConverter());
411+
410412
BootJsonData config = JsonSerializer.Deserialize<BootJsonData>(jsonContent, options);
411413
if (config.resourcesRaw is AssetsData assets)
412414
{
@@ -454,7 +456,8 @@ static Dictionary<string, ResourceHashesByNameDictionary> ConvertVfsAssets(List<
454456
pdb = assets.pdb?.ToDictionary(a => a.name, a => a.integrity),
455457
satelliteResources = ConvertSatelliteResources(assets.satelliteResources),
456458
lazyAssembly = assets.lazyAssembly?.ToDictionary(a => a.name, a => a.integrity),
457-
libraryInitializers = assets.libraryInitializers?.ToDictionary(a => a.name, a => (string)null),
459+
//libraryInitializers = assets.libraryInitializers?.ToDictionary(a => a.name, a => (string)null),
460+
libraryInitializers = assets.libraryInitializers,
458461
modulesAfterConfigLoaded = assets.modulesAfterConfigLoaded?.ToDictionary(a => a.name, a => (string)null),
459462
modulesAfterRuntimeReady = assets.modulesAfterRuntimeReady?.ToDictionary(a => a.name, a => (string)null),
460463
extensions = assets.extensions,

test/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/ServiceWorkerAssert.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ internal static void VerifyServiceWorkerFiles(TestAsset testAsset,
4040
// nor do we list the service worker itself or its assets manifest, as these don't need to be fetched in the same way
4141
if (IsCompressedFile(relativePath)
4242
|| string.Equals(relativePath, Path.Combine(staticWebAssetsBasePath, serviceWorkerPath), StringComparison.Ordinal)
43-
|| string.Equals(relativePath, Path.Combine(staticWebAssetsBasePath, assetsManifestPath), StringComparison.Ordinal))
43+
|| string.Equals(relativePath, Path.Combine(staticWebAssetsBasePath, assetsManifestPath), StringComparison.Ordinal)
44+
|| relativePath.EndsWith(".modules.json"))
4445
{
4546
continue;
4647
}

0 commit comments

Comments
 (0)