Running a console application directly in your browser

Pier-Luc Bonneville
Pier-Luc Bonneville
Being technical is important at the leadership level in a world where IT is eating the world.
Nov 22, 2020 7 min read
thumbnail for this post
Photo by Ray Hennessy

This is the third post in a series on working with dotnet projects 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:

  1. No need to install any software development kit (SDK) on the user workstation.
  2. 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.

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.

  1. 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;
        }
    }
    
  2. 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));
    
  3. 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 the Main 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));
    
  4. 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);
    
  5. 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.

  1. 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 a StringWriter 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();
    }
    
  2. 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>
    
  3. 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.

Flux/Redux architecture diagram for our application

  1. 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 };
        }
    }
    
  2. And lastly, in Store\CSharpCompilationUseCase\Effects.cs add the two effects we need. One to handle the loading of the assemblies on Index.OnInitializedAsync and the other to handle the Try.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);
        }
    }
    
  3. 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

  1. As Blazor WebAssembly is single threaded (until dotnet 6?) the UI can freeze while executing CPU intensive work such as when emitting the IL.
  2. The Monaco editor doesn’t have strong code completion as we haven’t linked it to a code analysis completion service.
    1. The force is not strong with this one.

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:

demo

The code covered in this blog post is available here:

blog-examples/tree/master/running-a-console-application-directly-in-your-browser