Running a console application directly in your browser
This is the third post in a series on working with dotnet projects in your browser.
- Part 1 - Listing zip file content in Blazor
- Part 2 - Modifying the content of the zip file in Blazor using a rich text editor in a Flux/Redux architecture
- Part 3 - Running a console application directly in your browser
Introduction
In the pasts post of this series, our primary focus was to read, display on screen, and persist the state the C# code.
It’s now time to execute it in the browser. All without needing a back-end server.
Some of the benefits of compiling and running the code in the browser are:
- No need to install any software development kit (SDK) on the user workstation.
- There are no security requirements to run the code in an isolated sandbox somewhere in the cloud.
The solution
Here is an overview of the flow of the code explored in this post.
Compiling the source code
The first step is to compile the C# code. To achieve this, we’ll have to parse it and emit a dll.
But before staring we’ll need to download any required dependencies (dll) to the browser. Notice that we have to add references such as
System.Console.dll
as our Blazor application has no referenced to it. You’ll need to manually add missing dependencies you require.public interface IDependencyResolver { Task<List<MetadataReference>> GetAssemblies(); } public class BlazorDependencyResolver : IDependencyResolver { private readonly HttpClient _http; public BlazorDependencyResolver(HttpClient http) { _http = http; } public async Task<List<MetadataReference>> GetAssemblies() { var assemblies = AppDomain.CurrentDomain.GetAssemblies() .Where(x => !x.IsDynamic) .Select(x => x.GetName().Name) .Union(new[] { // Add any required dll that are not referenced in the Blazor application "System.Console", //"", //"" }) .Distinct() .Select(x => $"_framework/{x}.dll"); var references = new List<MetadataReference>(); foreach (var assembly in assemblies) { // Download the assembly references.Add( MetadataReference.CreateFromStream( await _http.GetStreamAsync(assembly))); } return references; } }
Now that we have downloaded the dependencies we can convert the C# files into syntax trees.
// Convert the C# files into syntax trees. var syntaxTrees = csharpFiles .Select(x => CSharpSyntaxTree .ParseText(x.Content, new CSharpParseOptions(LanguageVersion.CSharp9), x.Name));
With the source code (syntax trees) and the assembly references, create a new compilation object. Notice that we are creating a
ConsoleApplication
as we’ll be looking to invoke theMain
method.// Create a new compilation with the source code and the assembly references. var compilation = CSharpCompilation.Create( "ConsoleAppWasm.Demo", syntaxTrees, _references, new CSharpCompilationOptions(OutputKind.ConsoleApplication));
Emit the IL for the compiled source code.
await using var stream = new MemoryStream(); // Emit the IL for the compiled source code into the stream. var result = compilation.Emit(stream);
The last step is to load the newly created assembly into the current application domain and return the created assembly. Here is our complete
Compile
method.public async Task<Assembly> Compile(params ZipEntry[] csharpFiles) { // Make sure the needed assembly references are available. await Init(); // Convert the C# files into syntax trees. var syntaxTrees = csharpFiles .Select(x => CSharpSyntaxTree .ParseText(x.Content, new CSharpParseOptions(LanguageVersion.CSharp9), x.Name)); // Create a new compilation with the source code and the assembly references. var compilation = CSharpCompilation.Create( "ConsoleAppWasm.Demo", syntaxTrees, _references, new CSharpCompilationOptions(OutputKind.ConsoleApplication)); await using var stream = new MemoryStream(); // Emit the IL for the compiled source code into the stream. var result = compilation.Emit(stream); foreach (var diagnostic in result.Diagnostics) { Logs.Add(diagnostic.ToString()); } if (!result.Success) { Logs.Add(""); Logs.Add("Build FAILED."); throw new CSharpCompilationException(); } Logs.Add(""); Logs.Add("Build succeeded."); // Reset stream to beginning. stream.Seek(0, SeekOrigin.Begin); // Load the newly created assembly into the current application domain. var assembly = AppDomain.CurrentDomain.Load(stream.ToArray()); return assembly; }
Running the application
To run the code, we’ll use the Assembly.EntryPoint
property to find the Program.Main
method in the emitted dll and then invoke it.
Create a
Run
method that takes an assembly as a parameter, finds the applcation entrypoint and invoke it with an array of empty parameters. In addition, we’ll redirect the console output to aStringWriter
so that we can return any strings written to the console.public static string Run(Assembly assembly) { // Capture the Console outputs. using var sw = new StringWriter(); Console.SetOut(sw); var main = assembly.EntryPoint; var parameters = main.GetParameters().Any() ? new object[] { Array.Empty<string>() } : null; main.Invoke(null, parameters); return sw.ToString(); }
Then, create the page
Try.razor
that will display the console output and the compilation logs.@page "/try" @inherits Fluxor.Blazor.Web.Components.FluxorComponent <h3>Try</h3> @if (CSharpCompilationState.Value.IsRunning) { <button type="button" class="btn btn-secondary"> <div class="spinner-border spinner-border-sm" role="status"> <span class="sr-only">Running...</span> </div> Run </button> } else { <button type="button" class="btn btn-primary" @onclick="Run"> <span class="oi oi-media-play"></span> Run </button> } <div class="card"> <div class="card-body"> <pre>@CSharpCompilationState.Value.Output</pre> </div> </div> <div class="card"> <div class="card-body"> <pre>@CSharpCompilationState.Value.CompilationLogs</pre> </div> </div>
Here is the content of the code-behind file
Try.razor.cs
.public partial class Try { [Inject] private IDispatcher Dispatcher { get; set; } [Inject] private IState<ZipFileState> ZipFileState { get; set; } [Inject] private IState<CSharpCompilationState> CSharpCompilationState { get; set; } private void Run() { Dispatcher.Dispatch(new RunAction { Files = ZipFileState.Value.ZipEntries.Values }); } }
Handling the UI state
Here is the Flux/Redux architecture diagram for the C# compilation use case of our application.
In
Store\CSharpCompilationUseCase
add the code for the state, feature, actions, and reducers of this new use case.public record CSharpCompilationState { public bool IsRunning { get; init; } public string CompilationLogs { get; init; } = ""; public string Output { get; init; } = ""; } public class Feature : Feature<CSharpCompilationState> { public override string GetName() => "CSharpCompilation"; protected override CSharpCompilationState GetInitialState() => CSharpCompilationState { IsRunning = false }; } public record LoadAssembliesAction { } public record RunAction { public IEnumerable<ZipEntry> Files { get; init; } = Array.Empty<ZipEntry>(); } public record RunResultAction { public string CompilationLogs { get; init; } = ""; public string Output { get; init; } = ""; } public static class Reducers { [ReducerMethod] public static CSharpCompilationState ReduceRunAction(CSharpCompilationState state, RunAction action) { return new CSharpCompilationState { IsRunning = true }; } [ReducerMethod] public static CSharpCompilationState ReduceRunResultAction(CSharpCompilationState state, RunResultAction action) { return new CSharpCompilationState { IsRunning = false, CompilationLogs = action.CompilationLogs, Output = action.Output }; } [ReducerMethod] public static CSharpCompilationState ReduceLoadAssembliesAction(CSharpCompilationState state, LoadAssembliesAction action) { return new CSharpCompilationState { IsRunning = false }; } }
And lastly, in
Store\CSharpCompilationUseCase\Effects.cs
add the two effects we need. One to handle the loading of the assemblies onIndex.OnInitializedAsync
and the other to handle theTry.Run
action.public class Effects { private readonly CSharpCompilationService _compilerService; public Effects(CSharpCompilationService compilerService) { _compilerService = compilerService; } [EffectMethod] public async Task HandleLoadAssembliesAction(LoadAssembliesAction action, IDispatcher dispatcher) { await _compilerService.Init(); } [EffectMethod] public async Task HandleRunActionAction(RunAction action, IDispatcher dispatcher) { var resultAction = new RunResultAction { }; try { var csharpFiles = action.Files.ToArray(); var resultText = await _compilerService.CompileAndRun(csharpFiles); resultAction = resultAction with { Output = resultText }; } catch (CSharpCompilationException) { } catch (Exception ex) { _compilerService.Logs.Add(""); _compilerService.Logs.Add(ex.Message); _compilerService.Logs.Add(ex.ToString()); } finally { var compileText = string.Join("\r\n", _compilerService.Logs); resultAction = resultAction with { CompilationLogs = compileText }; } dispatcher.Dispatch(resultAction); } }
Remember to register your dependencies in
Program.cs
public class Program { public static async Task Main(string[] args) { var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add<App>("#app"); builder.Services.AddScoped( sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddScoped<ZipService>(); builder.Services.AddScoped<IDependencyResolver, BlazorDependencyResolver>(); builder.Services.AddScoped<CSharpCompilationService>(); builder.Services.AddFluxor(options => options .ScanAssemblies(typeof(Program).Assembly) ); await builder.Build().RunAsync(); } }
Considerations and possible improvements
- As Blazor WebAssembly is single threaded (until dotnet 6?) the UI can freeze while executing CPU intensive work such as when emitting the IL.
- The Monaco editor doesn’t have strong code completion as we haven’t linked it to a code analysis completion service.
- The force is
not
strong with this one.
- The force is
Conclusion
With this latest addition we are close to getting our mini IDE in the browser. Having two of the three main pillars of IDEs; a source code editor and a build automation tools albeit these are very rudimentary at this time. The last pillar that we are missing is a debugger. But before we get to the debugger, we’ll explore code completion to have some form of intellisense
.
Here is a demo of the finished result:
The code covered in this blog post is available here:
blog-examples/tree/master/running-a-console-application-directly-in-your-browser