This article explains how to run .NET C# code in Browser via WebAssembly. Several example are given - describing calling C# from JavaScript, calling JavaScript from C# and running C# Program.Main(...) method from the browser. Finally I provide an Avalonia sample showing ho to run Avalonia visuals built in C# in the browser.
Introduction
C# programs (as well as programs written in many other software languages) can now be run in almost any browser via WebAssembly - Wasm for short.
On top of being written in better languages, WebAssembly programs usually run faster than JavaScript/TypeScript programs, because they are compiled (at least to the intermediary language, some are even compiled to the native machine assembly language). Also as strongly typed languages they have considerably smaller and faster built-in types.
Avalonia
Avalonia is a great multiplatform framework for building UI applications. Avalonia can be used for building desktop applications for Windows, MacOS, Linux, Browser WebAssembly applications and mobile applications for iOS, Android and Tizen.
On top of everything else, Avalonia allows reusing most of the code between different platforms.
Here is a website demonstrating Avalonia controls built as Avalonia in-browser application: Avalonia Demo:
In this article we shall talk only about in-browser Avalonia via Wasm.
Why "Without Blazor"
Initial Microsoft's WebAssembly supporting product was called Blazor (client side version) and was released as part of ASP.NET. Blazor can be used for doing non-visual operations and also to modify the HTML tree in C#.
However, I saw some complaints about Blazor stability and performance when it comes to interaction with HTML/JavaScript. Here is one place on the web mentioning Blazor stability and performance problems. I remember seeing similar complaints on other websites as well.
Because of those problems, this article focuses on using System.Runtime.InteropServices.JavaScript package which became part of .NET's WebAssembly SDK. This package is reported to provide better performance and more stable interactions with JavaScript.
The latest version of Avalonia also uses this library.
The Main Problem with WebAssembly - Lack of good Samples and good Documentation
While C# via Wasm is ready for prime time, the main problem is it being a relatively new technology it has very few good samples and good documentation available on-line.
The main purpose of this article is to provide easy to understand samples and good documentation covering all or most of C#-in-browser functionality.
Article Outline
Here are the topics this article covers:
- Creating Wasm projects and embedding them into browser code.
- Calling Browser JavaScript methods from Wasm C# and vice versa - calling Wasm C# methods from in-browser JavaScript.
- Calling C# Program.Main method from JavaScript.
- Running Avalonia visual applications in browser.
Using ASP.NET Core for Samples
Browser based programming always implies a server - in all samples here I use ASP.NET due to the fact that ASP.NET is a great powerful, well tested, proven technology from Microsoft that works well with my favorite Visual Studio 2022 and allows keeping HTML/JavaScript client code together in the same project with the server code.
For the sake of speed and clarity I try avoiding ASP.NET code generation; instead I use ASP.NET as a Web and Data Server and the middle tier.
While ASP.NET is my choice for the server, exactly the same approach to deploying and running WebAssembly can be applied to any other server technology.
Samples Source Code Location
The samples' source code is located at Web Assembly Samples.
JavaScript calling C# Method Sample
Important Note
I'll go over the first sample with a great detail explaining almost everything with regards to the WebAssembly. This level of detail will not be maintained for the rest of the samples (since by that time you'll understand already how the WebAssembly works). So it is important to read this section, while the rest of the sample related sections you can read selectively depending on your needs.
Sample Location
This sample is located within JSCallingDotNetSample folder (its solution file has the same name).
Sample Code Overview
JSCallingDotNetSample solution has two projects in it:
- JSCallingDotNetSample - an ASP.NET 8.0 project. Please, make sure this is your start up project.
- Greeter - a C# .NET 8.0 library.
How the Solution and the Projects were Created
In order to create the solution and the main ASP.NET project, I started Visual Studio 2022, clicked "Create a new project" option and chose "ASP.NET Core Web APP (Razor Pages)":
Then I entered the name of the solution ("JSCallingDotNetSample") and made sure that the solution is created one directory above the project (and not in the same directory) by unselecting "Place solution and project in the same directory" check box.
Then to create the project containing C# code I right-clicked on the solution within the solution explorer, chose Add->New Project and then selected "Class Library" template:
Note, that while in all samples, below, the ASP.NET projects are created in the same fashion, some C#-only projects will be created differently. Sometimes we would have to choose C# Console project (instead of class library) template and for Avalonia samples it will be even more fun and I'll give details about creating Avalonia Wasm projects below when we get to the topic.
Note that there are no project dependencies - the ASP.NET main project does not depend on the C# project. Though later I'll show how to introduce a build dependency between the projects.
Running the Project
In order to run the project successfully - first build the Greeter project. Under the project's bin/Debug/net8.0-browser/wwwroot folder a subfolder _framework will be created:
Copy this _framework folder over under the wwwroot folder of the JSCollingDotNetSample ASP.NET project.
Note that the folder should not become part of the source code (even though it has been moved under a source code folder). If you are using git you have to add this folder with its content to the git .ignore file (this is what a small red STOP icon before the folder means).
Build and run the main JSCallingDotNetSample project - first for a second or two you'll see a message "Please wait while Web Assembly is Loading!" and then it will be overridden by a message generated by C# code:
C#-only Greeter Project
The C# Greeter project contains C# code called by JavaScript (from ASP.NET project). It has only one static class JSInteropCallsContainer
and the class has a single method Greet
that takes an array of strings (names) and returns a greeting string "Hello <name1>, <name2>, ... <name_N>!!!":
public static partial class JSInteropCallsContainer
{
[JSExport]
public static string Greet(params string[] names)
{
var resultStr = string.Join(", ", names);
return $"Hello {resultStr}!!!";
}
}
Note that the class is static and partial and the method Greet(...)
has JSExport
attribute - this allows the method to be called from JavaScript.
Take a look at the project file - Greeter.csproj:
<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
<PropertyGroup>
<TargetFramework>net8.0-browser</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<OutputType>Library</OutputType>
</PropertyGroup>
...
</Project>
Note the highlighted differences from a usual C# Library project:
- The Sdk is set to Microsoft.NET.Sdk.WebAssembly.
- TargetFramework is set to net8.0-browser.
- <AllowUnsafeBlocks>true</AllowUnsafeBlocks>.
This three changes allow the project to produce (as a result of a build) _framework folder containing .wasm and other files needed for deployment.
JSCallingDotNetSample Code
Here I explain the changes made to the files within JSCallingDotNetSample ASP.NET project after creating it using "ASP.NET Core Web App (Razor Pages)" template.
Minor Modifications
For the sake of simplification, I removed the wwwroot/lib folder - since I do not plan to use either bootstrapper or jQuery.
I also greatly simplified the _Layout.cshtml file located under Pages/Shared folder, removing its footer, header and CSS classes.
Modifications to Program.cs File
I removed some unneeded lines from Progam.cs file and added Mime Types required by WebAssembly
using Microsoft.AspNetCore.StaticFiles;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
var provider = new FileExtensionContentTypeProvider();
var dict = new Dictionary<string, string>
{
{".pdb" , "application/octet-stream" },
{".blat", "application/octet-stream" },
{".dll" , "application/octet-stream" },
{".dat" , "application/octet-stream" },
{".json", "application/json" },
{".wasm", "application/wasm" },
{".symbols", "application/octet-stream" }
};
foreach (var kvp in dict)
{
provider.Mappings[kvp.Key] = kvp.Value;
}
app.UseStaticFiles(new StaticFileOptions
{
ContentTypeProvider = provider
});
app.MapRazorPages();
app.Run();
The mime types are added to a FileExtensionContentTypeProvider
object which is then assigned to be the provider for the static files:
var provider = new FileExtensionContentTypeProvider();
var dict = new Dictionary<string, string>
{
{".pdb" , "application/octet-stream" },
{".blat", "application/octet-stream" },
{".dll" , "application/octet-stream" },
{".dat" , "application/octet-stream" },
{".json", "application/json" },
{".wasm", "application/wasm" },
{".symbols", "application/octet-stream" }
};
foreach (var kvp in dict)
{
provider.Mappings[kvp.Key] = kvp.Value;
}
app.UseStaticFiles(new StaticFileOptions
{
ContentTypeProvider = provider
});
Adding wasmRunner.js File
I added wasmRunner.js file to wwwroot folder (the same folder that contains copied _framework folder).
Here is the content of the file:
import { dotnet } from './_framework/dotnet.js'
const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);
const { getAssemblyExports, getConfig } =
await dotnet.create();
const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
const text = exports.Greeter.JSInteropCallsContainer.Greet(['Nick', 'Joe', 'Bob']);
console.log(text);
document.getElementById("out").innerText = text;
The file is well documented in-code. Here are the most important lines from that file:
- We load the
dotnet
object from ./_framework/dotnet.js file.
import { dotnet } from './_framework/dotnet.js'
- We create two methods from
dotnet
- one returns an object containing all the C# exports to JavaScript and the other returns configuration for the WebAssembly project and the web site.
const { getAssemblyExports, getConfig } = await dotnet.create();
- We call
getConfig()
method to get the config config object:
const config = getConfig();
- We use
getAssemblyExport(...)
method to get the export
object containing all C# exports:
const exports = await getAssemblyExports(config.mainAssemblyName);
- We call the exported C#
Greeter.JSInteropCallsContainer.Greet(...)
method and save the result in variable text
:
const text = exports.Greeter.JSInteropCallsContainer.Greet(['Nick', 'Joe', 'Bob']);
- Finally we assign the obtained text to be the inner text of our
div
element with id="out"
:
document.getElementById("out").innerText = text;
Modifying Index.cshtml File
The last file that I change is Pages/Index.cshtml file. I changed its content to be:
<div id="out"
style="font-size:50px">
<div style="font-size:30px">
Please wait while Web Assembly is Loading!
</div>
</div>
<script type="module" src="~/wasmRunner.js"></script>
Note that it has a div
element with id equals to "out", whose content will be replaced with the text.
Also note that it loads the module wamsRunner.js from wwwroot folder (this is what ~/ means).
Improving Performance of in-Browser C#
The in-browser performance of C# code can be improved in many ways. Most important is compiling C# AOT (ahead of time). This might increase the size of .wasm files but will greatly improve the performance.
Our sample demonstrates how to use AOT compilation in Release configuration.
You can create release AOT version of Greeter by going to the Greeter project folder on the command line and executing the following command:
dotnet publish -c Release
It will create the _framework folder under bin\Release\net8.0-browser\publish\wwwroot folder.
This folder _framework will contain the optimized version of .wasm files. Move or copy this folder under JSCallingDotNetSample/wwwroot folder in exactly the same fashion as before and now when you run the project it will load the optimized .wasm files.
Important Note: AOT compilation of projects can take a lot of time - even 10-15 minutes if the project is large enough. In our case, however, our project is very small and the AOT building should take only a couple of seconds.
Take another look at Greeter.csproj project file. The AOT instructions are contained within a PropertyGroup conditioned on Release Configuration:
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>
Creating Build Dependency and Copying _framework Folder using a Post Build Event
Note that in Debug configuration we can set the project Greeter to be build before the main JSCallingDotNetSample project without setting the project dependency.
In order to do it right, click on the solution JSCallingDotNetSample in the solution explorer and choose "Project Dependencies..." menu item:
In the open dialog choose JSColldingDotNetSample under Projects and within "Depends on" panel made sure the checkbox is checked. Then press "OK" button.
This will ensure that Greeter project builds before the main ASP.NET JSCallingDotNetSample project.
Now to copy the _framework folder automatically under Debug mode we add the following lines to the end of JSColldingDotNetSample.csproj file:
<Project Sdk="Microsoft.NET.Sdk.Web">
...
<Target Condition="'$(Configuration)'=='Debug'"
Name="PostBuild"
AfterTargets="PostBuildEvent">
<Exec Command="xcopy "$(SolutionDir)Greeter\bin\$(Configuration)\net8.0-browser\wwwroot\_framework" "$(ProjectDir)wwwroot\_framework" /E /R /Y /I" />
</Target>
</Project>
Note, that this can be done only for the Debug option, because Release would require a publish step on Greeter project. Of course it should be possible to automate it also, but at this point I do not want to spend time figuring it out.
Example of C# Calling JavaScript Program
This sample is located within DotNetCallingJSSample folder (its solution has the same name as the folder). It is built on top of the previous sample.
It modifies our exported method
[JSExport]
public static string Greet(params string[] names)
{
...
}
to depend on another method
[JSImport("getGreetingWord", "CSharpMethodsJSImplementationsModule")]
public static partial string GetGreetingWord();
whose implementation is provided within JavaScript.
JSInteropCallsContainer
class within Greeter project has two methods instead of one.
Greeter.GetGreetingWord()
is an extra method that's not implemented. It is marked as partial
and it has JSImport("getGreetingWord", "CSharpMethodsJSImplementationsModule")
attribute:
public static partial class JSInteropCallsContainer
{
[JSImport("getGreetingWord", "CSharpMethodsJSImplementationsModule")]
public static partial string GetGreetingWord();
...
}
The attribute parameters signify that the program expects GetGreetingWord()
method to be implemented by JavaScript getGreetingWord()
method within JavaScript module named "CSharpMethodsJSImplementationsModule". A bit of a forward reference, but not the end of the world.
The method Greet(params string[] names)
has been slightly modified to get the greeting word from GetGreetingWord()
method instead of it being hardcode to "Hello":
public static partial class JSInteropCallsContainer
{
[JSImport("getGreetingWord", "CSharpMethodsJSImplementationsModule")]
public static partial string GetGreetingWord();
[JSExport]
public static string Greet(params string[] names)
{
var resultStr = string.Join(", ", names);
return $"{GetGreetingWord()} {resultStr}!!!";
}
}
The only file changed within main DotNetCallingJSSample project is wwwroot/wasmRunner.js. It has one line modified and one method call inserted:
const { getAssemblyExports, getConfig, setModuleImports } =
await dotnet.create();
setModuleImports("CSharpMethodsJSImplementationsModule", {
getGreetingWord: () => { return "Hi"; }
});
Note that on top of the methods getAssemblyExport(...)
and getConfig(...)
(that we already used in the previous sample) we also obtain method setModuleImports(...)
from await dotnet.create()
call.
We then use setModuleImports(...)
method to set up getGreetingWord()
method within "CSharpMethodsJSImplementationsModule" module to always return "Hi".
Now, rebuild the main project DotNetCallingJSSample (to force also the rebuilding Greeter project and copying _framework folder) and run it. We shall see "Hi Nick, Joe, Bob!!!" - the greeting is "Hi" instead of "Hello":
Running C# Main Method in Web Assembly Sample
Next I'll show how run a C# Program.Main method from JavaScript. The corresponding sample is located under JSCallingCSharpMainMethodSample/JSCallingCSharpMainMethodSample.sln solution.
First of all rebuild and try running the main project JSCallingCSharpMainMethodSample. Press F12 to open the devtools in your browser. Click on the Console tab. You will see whatever is printed to the console:
Line "Welcome to WebAssembly Program.Main(string[] args)!!!", then line "Here are the arguments passed to Program.Main:" and finally "arg1", "arg2" and "arg3" are each printed on its own line.
The Greeter project in that solution has only one simple file C# Program.cs:
namespace Greeter;
public static partial class Program
{
public static void Main(string[] args)
{
Console.WriteLine("Welcome to WebAssembly Program.Main(string[] args)!!!");
if (args.Length > 0)
{
Console.WriteLine();
Console.WriteLine("Here are the arguments passed to Program.Main:");
foreach(string arg in args)
{
Console.WriteLine($"\t{arg}");
}
}
}
}
It will print to console "Welcome to WebAssembly Program.Main(string[] args)!!!" and then if there are some arguments passed to the main, it will aso print the line "Here are the arguments passed to Program.Main:" and then it will print each argument on its own line.
Now look at Greeter.csproj file:
<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
<PropertyGroup>
<TargetFramework>net8.0-browser</TargetFramework>
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<OutputType>Exe</OutputType>
<StartupObject>Greeter.Program</StartupObject>
</PropertyGroup>
...
</Project>
Note - we added <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
, we changed the OutputType to "Exe" and we added the StartupObject line
Within JSCallingCSharpMainMethodSample (main) project the only file changed is wasmRunner.js:
import { dotnet } from './_framework/dotnet.js'
const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);
const dotnetRuntime = await dotnet.create();
const config = dotnetRuntime.getConfig();
We get dotnetRuntime
from await dotnet.create()
and then call its dotnetRuntime.runMain(...)
method to call the Program.Main(...)
method of C#:
...
const dotnetRuntime = await dotnet.create();
...
await dotnetRuntime.runMain(config.mainAssemblyName, ["arg1", "arg2", "arg3"]);
Note that the second argument passed to dotnetRuntime.runMain(...)
should be an array of strings. These are the strings that will be passed over to Program.Main(string[] args)
as args
. This is why the program printed "arg1", "arg2" and "arg3" to the console. If you change the arguments within that array you shall see the corresponding changes in the program's output.
Run Avalonia in Browser via WebAssembly
Small Intro to Avalonia in Browser
Avalonia can run on many platforms including in-browser via WebAssembly.
Avalonia in Browser sample project is located under AvaInBrowserSample/AvaInBrowserSample.sln solution.
Open the solution, and make AvaInBrowserSample ASP.NET project to be your startup project.
Running the Project
Try rebuilding AvaInBrowserSample project and then run it in the debugger. After a couple of seconds Avalonia application is going to appear in the browser:
When you press button "Change Text" the first word of the phrase above toggles between "Hello" and "Hi" while the rest of the text stays the same.
Avalonia code is very simple - custom code is located only in MainView.xaml and MainView.xaml.cs files.
The button's callback triggers change of the text within the TextBox
:
private void ChangeTextButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (GreetingTextBlock.Text?.StartsWith("Hello") == true)
{
GreetingTextBlock.Text = "Hi from Avalonia in-Browser!!!";
}
else
{
GreetingTextBlock.Text = "Hello from Avalonia in-Browser!!!";
}
}
Creating Client C# Avalonia Project
Here I show how to create the Avalonia-in-Browser project using Avalonia Templates.
Note that I assume that we are inside the Solution Folder - AvaInBrowserSample.
To create an Avalonia WebAssembly project I use instructions from Creating Avalonia Web Assembly Project:
- I install wasm-tools (or make sure they are installed and up-to-date) by running
dotnet workload install wasm-tools
from a command line. - I update to the latest Avalonia dotnet templates by running command:
dotnet new install avalonia.templates
- I create folder AvaCode for the Avalonia projects and cd to it using the command line.
- From within that folder, I run from the command line:
dotnet new avalonia.xplat
- This will create the shared project AvaCode (within the same-named folder) and a number of platform specific projects.
- I remove most of the platform specific projects leaving only AvaCode.Browser (for building the Avalonia WebAssembly bundle) and AvaCode.Display (for debugging and faster prototyping if needed).
Then I add those three project to my AvaInBrowserSample solution using Visual Studio. I place those projects in a separate solution folder AvaCode:
Note that the 3 Avalonia projects are at the top of the image within AvaCode Solution folder.
Now I can build my Avalonia functionality (within AvaCode project) and test it by running it from AvaCode.Desktop project.
I can also test it in the browser by changing the directory to AvaCode.Browser project and executing command "dotnet run" on the command line.
Changes to the Main Project
To make my main ASP.NET project display Avalonia's MainView, I copied the app.css file from AvaCode.Browser/wwwroot folder into AvaInBrowserSample/wwwroot/css/ folder of the ASP.NET project.
Then I modified the _Layout.cshtml file to have a link to this app.css file, then I added the style that has some magic words: style="margin: 0px; overflow: hidden"
to be <body>
tag and simplified the area inside the <body>
tag to make sure that @RenderBody()
call is straight under the tag:
<body style="margin: 0px; overflow: hidden">
@RenderBody()
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
Now we need to modify wasmRunner.js file. It will look almost the same as the one from the previous section, but there will be some extra calls between dotnet.
and .create()
methods:
import { dotnet } from './_framework/dotnet.js'
const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);
const dotnetRuntime = await dotnet
.withDiagnosticTracing(false)
.withApplicationArgumentsFromQuery()
.create();
const config = dotnetRuntime.getConfig();
await dotnetRuntime.runMain(config.mainAssemblyName, [window.location.search]);
The last file to change is Index.cshtml. This file gets uses some CSS classes defined within app.css file that we copied from AvaCode.Browser project. Without those CSS classes, Avalonia does not take correct space (all space) of the browser:
<div id="out">
<div id="out">
<div id="avalonia-splash">
<div class="center">
<h2 class="purple">
Please wait while the Avalonia Application Loads
</h2>
</div>
</div>
</div>
</div>
<script type="module" src="~/wasmRunner.js"></script>
Conclusion
This article explains embedding C# .NET code into a browser and provides some easy to understand samples.