This article was originally written and published on Medium on April 10, 2022. I'm migrating some of my favorite posts to dev.to to begin posting new content again.
While developing in PHP and Node/React in the past I often dreamily wished I was able to see the servers log output in the browser or JavaScript console.
As a newcomer to C#, Blazor and ASP.NET Core I have set myself the goal of familiarising myself with the stack by building a small prototype/feature each week and formalising that knowledge by doing a quick write up in a blog.
Yesterday I was hooking up NLog in Blazor and wondering what I should build for my first post and the idea to see if I could make good on my dream of getting server log output into the browser came back to me.
As the wise philosopher Mr. LaBeouf once said…
We will walk through one approach on how to achieve this. It is a Blazor WebAssembly & Blazor Server compatible approach using a custom NLog target and SignalR hub.
We will cover a v1 prototype version which streams log output from the moment a client connects with no guarantee of delivery. If the browser connection drops and reconnects minutes later, all the log output that occurred on the server during that time will not be sent to the browser.
Adding a method to guarantee delivery and have the client & server resync data since the connection was lost would be something to add to a v2. I'll likely cover this problem in a future post on building a SignalR chat app.
There is also an easier way to do this which is a Blazor Server only approach and utilises the existing SignalR connection established and managed by Blazor Server. I can do a post on this in the future if there is any interest.
In a real project you would implement your own authentication and authorization on the SignalR connections/hub, but, as this is a demo…
I'm walking through how I, personally, approached this problem. I can guarantee that there are better and more performant ways to achieve the same result. The reason I am writing this is to improve as a developer and I apologize in advance for any coding sins you are about to witness.
You can find the GitHub repo with commits at each step here: WasmLogToBrowser GitHub Repo
To get started we will create a new ASP.NET Core hosted Blazor WASM application.
dotnet new blazorwasm --hosted -o WasmLogToBrowser
Now add the packages we need and start the server:
cd .\WasmLogToBrowser\Client
dotnet add package Microsoft.AspNetCore.SignalR.Client
cd ..\Server
dotnet add package NLog
dotnet add package NLog.Web.AspNetCore
dotnet add package Microsoft.AspNetCore.SignalR.Client
Step 1: Hook Up NLog
Create Server\Logging\Logger.cs
and then update Server\Program.cs
:
// Logger.cs
using NLog;
using NLog.Config;
using NLog.Targets;
namespace WasmLogToBrowser.Server.Logging
{
public static class Logger
{
public static NLog.Logger Log = LogManager.GetCurrentClassLogger();
public static void Configure()
{
LoggingConfiguration config = new LoggingConfiguration();
string layout = "${longdate}|${level}|${callsite}|Line:${callsite-linenumber}|${message} ${all-event-properties} ${exception:format=tostring}";
// Log to console
ColoredConsoleTarget consoleTarget = new ColoredConsoleTarget()
{
UseDefaultRowHighlightingRules = true,
Layout = layout
};
config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: consoleTarget);
// Log to file (minLevel: Info)
FileTarget infoFileTarget = new FileTarget("info")
{
FileName = "${basedir}\\Logging\\${date:format=yyyy-MM-dd}.log",
Layout = layout
};
config.AddRule(minLevel: NLog.LogLevel.Info, maxLevel: NLog.LogLevel.Fatal, target: infoFileTarget);
// Log to file (minLevel: Trace)
FileTarget traceFileTarget = new FileTarget("trace")
{
FileName = "${basedir}\\Logging\\${date:format=yyyy-MM-dd}.trace.log",
Layout = layout
};
config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: traceFileTarget);
LogManager.Configuration = config;
Log = LogManager.GetCurrentClassLogger();
}
}
}
// Program.cs
using WasmLogToBrowser.Server.Logging;
Logger.Configure();
Logger.Log.Info("App starting");
// Lets log something every second to simulate legitimate log output
System.Timers.Timer timer = new System.Timers.Timer(1000);
timer.Elapsed += (source, e) =>
{
Logger.Log.Trace("tick");
};
timer.Enabled = true;
timer.Start();
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
We now have some simulated log output we can worry about getting into the browser:
Step 2: Create SignalR Hub
Create Server\Hubs\LoggingHub.cs
and hook it up in the Server\Program.cs
:
// LoggingHub.cs
using Microsoft.AspNetCore.SignalR;
using WasmLogToBrowser.Server.Logging;
namespace WasmLogToBrowser.Server.Hubs
{
public class LoggingHub : Hub
{
public async Task Log(string logMessage)
{
await Clients.All.SendAsync("Log", logMessage);
}
public override Task OnConnectedAsync()
{
Logger.Log.Trace("Top");
Logger.Log.Debug($"{Context.ConnectionId} connected");
return base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? e)
{
Logger.Log.Trace("Top");
Logger.Log.Debug($"Disconnected {e?.Message} {Context.ConnectionId}");
await base.OnDisconnectedAsync(e);
}
}
}
// Program.cs
using Microsoft.AspNetCore.ResponseCompression;
using WasmLogToBrowser.Server.Hubs;
using WasmLogToBrowser.Server.Logging;
Logger.Configure();
Logger.Log.Info("App starting");
// Lets log something every second to simulate legitimate log output
System.Timers.Timer timer = new System.Timers.Timer(1000);
timer.Elapsed += (source, e) =>
{
Logger.Log.Trace("tick");
};
timer.Enabled = true;
timer.Start();
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddSignalR();
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddResponseCompression(opts =>
{
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
new[] { "application/octet-stream" });
});
var app = builder.Build();
app.UseResponseCompression();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
app.MapControllers();
app.MapHub<LoggingHub>("/hubs/logging");
app.MapFallbackToFile("index.html");
app.Run();
Step 3: Create and connect a custom NLog Target to the LoggingHub
In order for our custom NLog target to log to the LoggingHub we will need to connect ourselves so we will create both a Server\Logging\LoggingHubTarget.cs
and a connection class at Server\Logging\LoggingHubConnection.cs
the target can use to establish the connection when the target is instantiated. Finally, we will add the target to Server\Logging\Logger.cs::Configure()
.
// Logger.cs
using NLog;
using NLog.Config;
using NLog.Targets;
namespace WasmLogToBrowser.Server.Logging
{
public static class Logger
{
public static NLog.Logger Log = LogManager.GetCurrentClassLogger();
public static void Configure()
{
LoggingConfiguration config = new LoggingConfiguration();
string layout = "${longdate}|${level}|${callsite}|Line:${callsite-linenumber}|${message} ${all-event-properties} ${exception:format=tostring}";
// Log to console
ColoredConsoleTarget consoleTarget = new ColoredConsoleTarget()
{
UseDefaultRowHighlightingRules = true,
Layout = layout
};
config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: consoleTarget);
// Log to file (minLevel: Info)
FileTarget infoFileTarget = new FileTarget("info")
{
FileName = "${basedir}\\Logging\\${date:format=yyyy-MM-dd}.log",
Layout = layout
};
config.AddRule(minLevel: NLog.LogLevel.Info, maxLevel: NLog.LogLevel.Fatal, target: infoFileTarget);
// Log to file (minLevel: Trace)
FileTarget traceFileTarget = new FileTarget("trace")
{
FileName = "${basedir}\\Logging\\${date:format=yyyy-MM-dd}.trace.log",
Layout = layout
};
config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: traceFileTarget);
// Log to SignalR LoggingHub
var loggingHubTarget = new LoggingHubTarget("https://localhost:7254/hubs/logging")
{
Layout = layout
};
config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: loggingHubTarget);
LogManager.Configuration = config;
Log = LogManager.GetCurrentClassLogger();
}
}
}
// LoggingHubConnection.cs
using Microsoft.AspNetCore.SignalR.Client;
namespace WasmLogToBrowser.Server.Logging
{
public class LoggingHubConnection : IAsyncDisposable
{
private HubConnection? _hubConnection;
private string _hubUrl;
public LoggingHubConnection(string hubUrl)
{
_hubUrl = hubUrl;
}
public async Task Log(string logMessage)
{
await EnsureConnection();
if (_hubConnection != null)
{
await _hubConnection.SendAsync("Log", logMessage);
}
}
public async Task EnsureConnection()
{
if (_hubConnection == null)
{
_hubConnection = new HubConnectionBuilder()
.WithUrl(_hubUrl)
.Build();
await _hubConnection.StartAsync();
}
else if (_hubConnection.State == HubConnectionState.Disconnected)
{
await _hubConnection.StartAsync();
}
}
public async ValueTask DisposeAsync()
{
if (_hubConnection != null)
{
try
{
await _hubConnection.StopAsync();
await _hubConnection.DisposeAsync();
}
catch (Exception ex)
{
NLog.Common.InternalLogger.Error(ex, "Exception in LoggingHubConnection.DisposeAsync");
}
finally
{
_hubConnection = null;
}
}
}
}
}
// LoggingHubTarget.cs
using NLog;
using NLog.Targets;
namespace WasmLogToBrowser.Server.Logging
{
public class LoggingHubTarget : AsyncTaskTarget
{
private LoggingHubConnection? _connection;
public LoggingHubTarget(string hubUrl)
{
_connection = new LoggingHubConnection(hubUrl);
OptimizeBufferReuse = true;
}
protected override async Task WriteAsyncTask(LogEventInfo logEvent, CancellationToken token)
{
string logMessage = this.Layout.Render(logEvent);
if (_connection != null)
{
await _connection.Log(logMessage);
}
}
protected override async void CloseTarget()
{
if (_connection != null)
{
await _connection.DisposeAsync();
_connection = null;
}
}
}
}
When we start the server now we should see a connection from the LoggingHubTarget come through in OnConnectedAsync:
The server is now broadcasting all NLog messages to clients connected to the SignalR hub at https://locahost:XXXX/hubs/logging.
Step 4: ServerConsole.razor, DebugService and hooking them up to the index page
First we need to create and hook up our Client\Services\DebugService.cs
which is going to handle storing the log messages and (in the next step) toggling the console connection on/off.
Razor components will be able to inject DebugService to get access to the log messages and subscribe to the OnChange event within the DebugService to have Blazor re-render.
I’m a big fan of this approach to state management compared to React + Redux with the boilerplate and having to pass the entire state down the component tree.
// _Imports.razor
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using WasmLogToBrowser.Client
@using WasmLogToBrowser.Client.Shared
@using WasmLogToBrowser.Client.Services
// DebugService.cs
namespace WasmLogToBrowser.Client.Services
{
public class DebugService
{
public event Func<Task>? OnChange;
public bool ConnectToServerConsole = true;
public List<string> LogMessages = new List<string>();
public void AddLogMessage(string logMessage)
{
LogMessages.Add(logMessage);
HandleOnChange();
}
private void HandleOnChange()
{
if (OnChange != null)
{
OnChange?.Invoke();
}
}
}
}
// Program.cs
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using WasmLogToBrowser.Client;
using WasmLogToBrowser.Client.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<DebugService>();
await builder. Build().RunAsync();
Now that is out of the way we can implement our Client\Shared\ServerConsole.razor
component that we will mount in Client\Shared\MainLayout.razor
if DebugService.ConnectToServerConsole is true.
// MainLayout.razor
@inherits LayoutComponentBase
@inject DebugService DebugService
@implements IDisposable
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
@if (DebugService.ConnectToServerConsole) {
<ServerConsole />
}
</main>
</div>
@code {
public async Task OnChange()
{
await InvokeAsync(() =>
{
StateHasChanged();
});
}
protected override void OnInitialized()
{
DebugService.OnChange += OnChange;
}
public void Dispose()
{
DebugService.OnChange -= OnChange;
}
}
// ServerConsole.razor
@using Microsoft.AspNetCore.SignalR.Client
@inject DebugService DebugService
@inject NavigationManager NavigationManager
@implements IDisposable
<div class="server-console">Server Console: @_hubConnection?.State</div>
@code {
private HubConnection? _hubConnection;
private void OnReceiveLogMessage(string logMessage)
{
DebugService.AddLogMessage(logMessage);
}
private async Task OnChange()
{
await InvokeAsync(() =>
{
StateHasChanged();
});
}
protected override async void OnInitialized()
{
DebugService.OnChange += OnChange;
_hubConnection = new HubConnectionBuilder()
.WithUrl($"{NavigationManager.BaseUri}hubs/logging")
.Build();
_hubConnection.On<string>("Log", OnReceiveLogMessage);
await _hubConnection.StartAsync();
}
public void Dispose()
{
DebugService.OnChange -= OnChange;
if (_hubConnection != null)
{
try
{
_hubConnection.StopAsync();
_hubConnection.DisposeAsync();
_hubConnection = null;
} catch (Exception ex)
{
Console.WriteLine(ex.Message);
} finally
{
_hubConnection = null;
}
}
}
}
Finally, some styling tweaks in Client\wwwroot\css\app.css and then we can display the DebugService LogMessages list on the Client\Pages\Index.razor page:
// app.css
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #0071c1;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.content {
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.server-console {
position: absolute;
bottom: 0;
right: 0;
z-index: 2;
margin-bottom: 0.33rem;
margin-right: 0.5rem;
color: #181818;
font-size: 12px;
}
.console {
font-family: Courier New, Courier, monospace;
font-size: 12px;
background-color: #303030;
color: #ffffffc7;
border-radius: 0.5rem;
padding: 1rem;
}
// Index.razor
@page "/"
@inject DebugService DebugService
@implements IDisposable
<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
@if (DebugService.LogMessages.Count > 0)
{
<hr />
<h3>Server Console</h3>
<div class="console">
@foreach (string s in DebugService.LogMessages)
{
<div>@s</div>
}
</div>
}
@code {
public async Task OnChange()
{
await InvokeAsync(() =>
{
StateHasChanged();
});
}
protected override void OnInitialized()
{
DebugService.OnChange += OnChange;
}
public void Dispose()
{
DebugService.OnChange -= OnChange;
}
}
Now when we run our app we get server console output!
Step 5: Make the console toggle-able and implement clearing the console
For the toggle we will clean up Client\Pages\Index.razor
and implement the toggle in Client\Services\DebugService.cs
. We will also set ConnectToServerConsole to false by default so that connecting to the server console is a manual thing like one might do in a debugging scenario.
// DebugService.cs
namespace WasmLogToBrowser.Client.Services
{
public class DebugService
{
public event Func<Task>? OnChange;
public bool ConnectToServerConsole = false;
public List<string> LogMessages = new List<string>();
public void AddLogMessage(string logMessage)
{
LogMessages.Add(logMessage);
HandleOnChange();
}
public void ClearLogMessages()
{
LogMessages.Clear();
HandleOnChange();
}
public void ToggleConnectToServerConsole()
{
ConnectToServerConsole = !ConnectToServerConsole;
HandleOnChange();
}
private void HandleOnChange()
{
if (OnChange != null)
{
OnChange?.Invoke();
}
}
}
}
// Index.razor
@page "/"
@inject DebugService DebugService
@implements IDisposable
<PageTitle>Debug</PageTitle>
<h1>Debug</h1>
<input name="logtojavascriptconsole" type="checkbox" checked=@DebugService.ConnectToServerConsole @onchange="LogToJavascriptConsoleChanged"/>
<label for="logtojavascriptconsole">Stream server logs to Javascript console</label>
@if (DebugService.LogMessages.Count > 0)
{
<hr />
<h3>Server Console</h3>
<button style="float: right;" class="btn-sm btn-danger m-2" @onclick="DebugService.ClearLogMessages">Clear console</button>
<div class="console">
@foreach (string s in DebugService.LogMessages)
{
<div>@s</div>
}
</div>
}
@code {
private void LogToJavascriptConsoleChanged(ChangeEventArgs e)
{
DebugService.ToggleConnectToServerConsole();
}
public async Task OnChange()
{
await InvokeAsync(() =>
{
StateHasChanged();
});
}
protected override void OnInitialized()
{
DebugService.OnChange += OnChange;
}
public void Dispose()
{
DebugService.OnChange -= OnChange;
}
}
Toggle and clear in action:
Note the ServerConsole.razor component unmounting/remounting in the bottom right on toggle
Step 6: Simultaneously output logs to the JavaScript console
It’s nice to be able to access the logs from anywhere in our application but, for practical purposes, we would likely open and detach a devtools console.
Lets create a console logging helper class at Client\Library\JsConsole.cs
, implement the functionality in Client\Services\DebugService.cs
and hook the JsConsole up in Client\Program.cs
.
We also need to change the OnReceiveLogMessage void in Client\Shared\ServerConsole.razor
to an async Task now that AddLogMessage in the DebugService is async.
// DebugService.cs
using WasmLogToBrowser.Client.Library;
namespace WasmLogToBrowser.Client.Services
{
public class DebugService
{
private readonly JsConsole _console;
public event Func<Task>? OnChange;
public bool ConnectToServerConsole = false;
public List<string> LogMessages = new List<string>();
public DebugService(JsConsole console)
{
_console = console;
}
public async Task AddLogMessage(string logMessage)
{
LogMessages.Add(logMessage);
await _console.LogAsync(logMessage);
HandleOnChange();
}
public async Task ClearLogMessages()
{
LogMessages.Clear();
await _console.ClearAsync();
HandleOnChange();
}
public void ToggleConnectToServerConsole()
{
ConnectToServerConsole = !ConnectToServerConsole;
HandleOnChange();
}
private void HandleOnChange()
{
if (OnChange != null)
{
OnChange?.Invoke();
}
}
}
}
// JsConsole.cs
using Microsoft.JSInterop;
namespace WasmLogToBrowser.Client.Library
{
public class JsConsole
{
private readonly IJSRuntime _jsRuntime;
public JsConsole(IJSRuntime jSRuntime)
{
_jsRuntime = jSRuntime;
}
public async Task LogAsync(string message)
{
await _jsRuntime.InvokeVoidAsync("console.log", message);
}
public async Task ClearAsync()
{
await _jsRuntime.InvokeVoidAsync("console.clear");
}
}
}
// Program.cs
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using WasmLogToBrowser.Client;
using WasmLogToBrowser.Client.Library;
using WasmLogToBrowser.Client.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<DebugService>();
builder.Services.AddScoped<JsConsole>();
await builder.Build().RunAsync();
// ServerConsole.razor
@using Microsoft.AspNetCore.SignalR.Client
@inject DebugService DebugService
@inject NavigationManager NavigationManager
@implements IDisposable
<div class="server-console">Server Console: @_hubConnection?.State</div>
@code {
private HubConnection? _hubConnection;
private async Task OnReceiveLogMessage(string logMessage)
{
await DebugService.AddLogMessage(logMessage);
}
private async Task OnChange()
{
await InvokeAsync(() =>
{
StateHasChanged();
});
}
protected override async void OnInitialized()
{
DebugService.OnChange += OnChange;
_hubConnection = new HubConnectionBuilder()
.WithUrl($"{NavigationManager.BaseUri}hubs/logging")
.Build();
_hubConnection.On<string>("Log", OnReceiveLogMessage);
await _hubConnection.StartAsync();
}
public void Dispose()
{
DebugService.OnChange -= OnChange;
if (_hubConnection != null)
{
try
{
_hubConnection.StopAsync();
_hubConnection.DisposeAsync();
_hubConnection = null;
} catch (Exception ex)
{
Console.WriteLine(ex.Message);
} finally
{
_hubConnection = null;
}
}
}
}
Now we have output in the console which we can view regardless of which page we’re on in the app:
This is much more useful for Blazor Server projects as everything is happening on the server already. I can see this being very useful while an app is in development but you’d either want to make sure your auth on the LoggingHub is solid or else you’d want to remove the feature completely for production.
You can find the GitHub repo with commits at each step here: WasmLogToBrowser GitHub Repo
Top comments (0)