From 9caffcd613b6bb2027c1df1524c980303da28a68 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 13 Oct 2025 12:54:29 -0700 Subject: [PATCH 1/2] Clean up browser communication protocol --- .../WebSocketScriptInjection.js | 139 +++++++----------- .../Web/AbstractBrowserRefreshServer.cs | 40 ++--- .../Web/WebAssemblyHotReloadClient.cs | 8 +- .../Browser/BrowserTests.cs | 8 +- 4 files changed, 82 insertions(+), 113 deletions(-) diff --git a/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js b/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js index aa8f1fd60f98..44d6ae3250ad 100644 --- a/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js +++ b/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js @@ -26,38 +26,22 @@ setTimeout(async function () { 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); + connection.onmessage = function (message) { + const payload = JSON.parse(message.data); + const action = { + 'Reload': () => reload(), + 'Wait': () => wait(), + 'UpdateStaticFile': () => updateStaticFile(payload.path), + 'ApplyManagedCodeUpdates': () => applyManagedCodeUpdates(payload.sharedSecret, payload.updateId, payload.deltas, payload.responseLoggingLevel), + 'ReportDiagnostics': () => reportDiagnostics(payload.diagnostics), + 'GetApplyUpdateCapabilities': () => getApplyUpdateCapabilities(), + 'RefreshBrowser': () => refreshBrowser() + }; + + if (payload.type && action.hasOwnProperty(payload.type)) { + action[payload.type](); } 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); - } + console.error('Unknown payload:', message.data); } } @@ -106,12 +90,12 @@ setTimeout(async function () { return messageAndStack } - function getBlazorWasmApplyUpdateCapabilities(sendErrorToClient) { + function getApplyUpdateCapabilities() { let applyUpdateCapabilities; try { applyUpdateCapabilities = window.Blazor._internal.getApplyUpdateCapabilities(); } catch (error) { - applyUpdateCapabilities = sendErrorToClient ? "!" + getMessageAndStack(error) : ''; + applyUpdateCapabilities = "!" + getMessageAndStack(error); } connection.send(applyUpdateCapabilities); } @@ -137,41 +121,6 @@ setTimeout(async function () { 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 @@ -190,26 +139,16 @@ setTimeout(async function () { }); } } - 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) { + async function applyManagedCodeUpdates(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.'; } + console.debug('Applying managed code updates.'); + const AgentMessageSeverity_Error = 2 let applyError = undefined; @@ -261,11 +200,13 @@ setTimeout(async function () { })); if (!applyError) { - notifyHotReloadApplied(); + displayChangesAppliedToast(); } } - function displayDiagnostics(diagnostics) { + function reportDiagnostics(diagnostics) { + console.debug('Reporting Hot Reload diagnostics.'); + document.querySelectorAll('#dotnet-compile-error').forEach(el => el.remove()); const el = document.body.appendChild(document.createElement('div')); el.id = 'dotnet-compile-error'; @@ -280,7 +221,7 @@ setTimeout(async function () { }); } - function notifyHotReloadApplied() { + function displayChangesAppliedToast() { document.querySelectorAll('#dotnet-compile-error').forEach(el => el.remove()); if (document.querySelector('#dotnet-hotreload-toast')) { return; @@ -298,7 +239,7 @@ setTimeout(async function () { setTimeout(() => el.remove(), 2000); } - function aspnetCoreHotReloadApplied() { + function refreshBrowser() { if (window.Blazor) { window[hotReloadActiveKey] = true; // hotReloadApplied triggers an enhanced navigation to @@ -306,17 +247,39 @@ setTimeout(async function () { // Blazor SSR. if (window.Blazor?._internal?.hotReloadApplied) { + console.debug('Refreshing browser: WASM.'); Blazor._internal.hotReloadApplied(); } else { - notifyHotReloadApplied(); + console.debug('Refreshing browser.'); + displayChangesAppliedToast(); } } else { + console.debug('Refreshing browser: Reloading.'); location.reload(); } } + function reload() { + console.debug('Reloading.'); + location.reload(); + } + + function wait() { + console.debug('Waiting for application to rebuild.'); + + if (waiting) { + return; + } + + waiting = true; + const glyphs = ['☱', '☲', '☴']; + const title = document.title; + let i = 0; + setInterval(function () { document.title = glyphs[i++ % glyphs.length] + ' ' + title; }, 240); + } + async function getSecret(serverKeyString) { if (!serverKeyString || !window.crypto || !window.crypto.subtle) { return null; @@ -382,8 +345,8 @@ setTimeout(async function () { webSocket.addEventListener('close', onClose); if (window.Blazor?.removeEventListener && window.Blazor?.addEventListener) { - webSocket.addEventListener('close', () => window.Blazor?.removeEventListener('enhancedload', notifyHotReloadApplied)); - window.Blazor?.addEventListener('enhancedload', notifyHotReloadApplied); + webSocket.addEventListener('close', () => window.Blazor?.removeEventListener('enhancedload', displayChangesAppliedToast)); + window.Blazor?.addEventListener('enhancedload', displayChangesAppliedToast); } }); } diff --git a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs index d3608901433e..d39ea6c0f519 100644 --- a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs +++ b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs @@ -29,9 +29,6 @@ internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPa { public const string ServerLogComponentName = "BrowserRefreshServer"; - private static readonly ReadOnlyMemory s_reloadMessage = Encoding.UTF8.GetBytes("Reload"); - private static readonly ReadOnlyMemory s_waitMessage = Encoding.UTF8.GetBytes("Wait"); - private static readonly ReadOnlyMemory s_pingMessage = Encoding.UTF8.GetBytes("""{ "type" : "Ping" }"""); private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); private readonly List _activeConnections = []; @@ -233,19 +230,15 @@ public ValueTask SendJsonMessageAsync(TValue value, CancellationToken ca public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken) { logger.Log(LogEvents.ReloadingBrowser); - return SendAsync(s_reloadMessage, cancellationToken); + return SendAsync(JsonReloadRequest.Message, cancellationToken); } public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) { logger.Log(LogEvents.SendingWaitMessage); - return SendAsync(s_waitMessage, cancellationToken); + return SendAsync(JsonWaitRequest.Message, cancellationToken); } - // obsolete: to be removed - public ValueTask SendPingMessageAsync(CancellationToken cancellationToken) - => SendAsync(s_pingMessage, cancellationToken); - private ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) => SendAndReceiveAsync(request: _ => messageBytes, response: null, cancellationToken); @@ -293,13 +286,13 @@ public async ValueTask SendAndReceiveAsync( public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken) { logger.Log(LogEvents.RefreshingBrowser); - return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); + return SendAsync(JsonRefreshBrowserRequest.Message, cancellationToken); } public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) { logger.Log(LogEvents.UpdatingDiagnostics); - return SendJsonMessageAsync(new HotReloadDiagnostics { Diagnostics = compilationErrors }, cancellationToken); + return SendJsonMessageAsync(new JsonReportDiagnosticsRequest { Diagnostics = compilationErrors }, cancellationToken); } public async ValueTask UpdateStaticAssetsAsync(IEnumerable relativeUrls, CancellationToken cancellationToken) @@ -308,24 +301,37 @@ public async ValueTask UpdateStaticAssetsAsync(IEnumerable relativeUrls, foreach (var relativeUrl in relativeUrls) { logger.Log(LogEvents.SendingStaticAssetUpdateRequest, relativeUrl); - var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = relativeUrl }, s_jsonSerializerOptions); + var message = JsonSerializer.SerializeToUtf8Bytes(new JasonUpdateStaticFileRequest { Path = relativeUrl }, s_jsonSerializerOptions); await SendAsync(message, cancellationToken); } } - private readonly struct AspNetCoreHotReloadApplied + private readonly struct JsonWaitRequest + { + public string Type => "Wait"; + public static readonly ReadOnlyMemory Message = JsonSerializer.SerializeToUtf8Bytes(new JsonWaitRequest(), s_jsonSerializerOptions); + } + + private readonly struct JsonReloadRequest + { + public string Type => "Reload"; + public static readonly ReadOnlyMemory Message = JsonSerializer.SerializeToUtf8Bytes(new JsonReloadRequest(), s_jsonSerializerOptions); + } + + private readonly struct JsonRefreshBrowserRequest { - public string Type => "AspNetCoreHotReloadApplied"; + public string Type => "RefreshBrowser"; + public static readonly ReadOnlyMemory Message = JsonSerializer.SerializeToUtf8Bytes(new JsonRefreshBrowserRequest(), s_jsonSerializerOptions); } - private readonly struct HotReloadDiagnostics + private readonly struct JsonReportDiagnosticsRequest { - public string Type => "HotReloadDiagnosticsv1"; + public string Type => "ReportDiagnostics"; public IEnumerable Diagnostics { get; init; } } - private readonly struct UpdateStaticFileMessage + private readonly struct JasonUpdateStaticFileRequest { public string Type => "UpdateStaticFile"; public string Path { get; init; } diff --git a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs index 2a45bfb07370..f47d6eb3a5ba 100644 --- a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs @@ -133,7 +133,7 @@ public override async Task ApplyManagedCodeUpdatesAsync(ImmutableAr var anyFailure = false; await browserRefreshServer.SendAndReceiveAsync( - request: sharedSecret => new JsonApplyHotReloadDeltasRequest + request: sharedSecret => new JsonApplyManagedCodeUpdatesRequest { SharedSecret = sharedSecret, UpdateId = batchId, @@ -178,9 +178,9 @@ private static bool ReceiveUpdateResponseAsync(ReadOnlySpan value, ILogger public override Task InitialUpdatesAppliedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - private readonly struct JsonApplyHotReloadDeltasRequest + private readonly struct JsonApplyManagedCodeUpdatesRequest { - public string Type => "BlazorHotReloadDeltav3"; + public string Type => "ApplyManagedCodeUpdates"; public string? SharedSecret { get; init; } public int UpdateId { get; init; } @@ -211,7 +211,7 @@ private readonly struct JsonLogEntry private readonly struct JsonGetApplyUpdateCapabilitiesRequest { - public string Type => "BlazorRequestApplyUpdateCapabilities2"; + public string Type => "GetApplyUpdateCapabilities"; } } } diff --git a/test/dotnet-watch.Tests/Browser/BrowserTests.cs b/test/dotnet-watch.Tests/Browser/BrowserTests.cs index d1c302337036..cb4488010bf4 100644 --- a/test/dotnet-watch.Tests/Browser/BrowserTests.cs +++ b/test/dotnet-watch.Tests/Browser/BrowserTests.cs @@ -65,7 +65,7 @@ public async Task BrowserDiagnostics() await App.WaitForOutputLineContaining("Do you want to restart your app?"); await App.WaitUntilOutputContains($$""" - 🧪 Received: {"type":"HotReloadDiagnosticsv1","diagnostics":[{{jsonErrorMessage}}]} + 🧪 Received: {"type":"ReportDiagnostics","diagnostics":[{{jsonErrorMessage}}]} """); // auto restart next time: @@ -93,7 +93,7 @@ await App.WaitUntilOutputContains(""" await App.WaitForOutputLineContaining("[auto-restart] " + errorMessage); await App.WaitUntilOutputContains($$""" - 🧪 Received: {"type":"HotReloadDiagnosticsv1","diagnostics":["Restarting application to apply changes ..."]} + 🧪 Received: {"type":"ReportDiagnostics","diagnostics":["Restarting application to apply changes ..."]} """); await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); @@ -114,11 +114,11 @@ await App.WaitUntilOutputContains(""" await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadSucceeded); await App.WaitUntilOutputContains($$""" - 🧪 Received: {"type":"HotReloadDiagnosticsv1","diagnostics":[]} + 🧪 Received: {"type":"ReportDiagnostics","diagnostics":[]} """); await App.WaitUntilOutputContains($$""" - 🧪 Received: {"type":"AspNetCoreHotReloadApplied"} + 🧪 Received: {"type":"RefreshBrowser"} """); // no other browser message sent: From d47e3dcb87fb8c2b87302667160d0018ae7001a9 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Tue, 14 Oct 2025 12:48:28 -0700 Subject: [PATCH 2/2] Fix --- test/dotnet-watch.Tests/Browser/BrowserTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/dotnet-watch.Tests/Browser/BrowserTests.cs b/test/dotnet-watch.Tests/Browser/BrowserTests.cs index cb4488010bf4..17e21166bf3f 100644 --- a/test/dotnet-watch.Tests/Browser/BrowserTests.cs +++ b/test/dotnet-watch.Tests/Browser/BrowserTests.cs @@ -76,7 +76,7 @@ await App.WaitUntilOutputContains($$""" // browser page was reloaded after the app restarted: await App.WaitUntilOutputContains(""" - 🧪 Received: Reload + 🧪 Received: {"type":"Reload"} """); // no other browser message sent: @@ -100,7 +100,7 @@ await App.WaitUntilOutputContains($$""" // browser page was reloaded after the app restarted: await App.WaitUntilOutputContains(""" - 🧪 Received: Reload + 🧪 Received: {"type":"Reload"} """); // no other browser message sent: