I’ve recently been exploring the newest features of .NET 7 and came across a fascinating post on the Microsoft blogs titled “Use .NET 7 from any JavaScript app in .NET 7.” In the humble opinion of an internet crank, I believe WebAssembly (WASM) is the future, and .NET is one of the technologies leading the way to make it accessible to a new set of developers.
In this post, I go through my understanding of the sample project mentioned in the blog post and some things I’d like to improve in the JavaScript experience.
What is WebAssembly?
WebAssembly, also called WASM, is a new type of binary format that you can run in modern browsers and on the server. A WASM application depends on a host to execute the code within the wasm
artifact, and the host grants access to host features to the code. For .NET developers, think of WASM as a universally recognized .dll
.
Currently, your browser is the most fully featured host environment, with access to many things you’d be using if you wrote the solution in JavaScript.
The compiled nature of WASM gives your solution near-native performance and the option of choosing a non-web-native language such as C/C++, Rust, and in the case of .NET developers, C#.
The host is essential to WASM, as a contract between your implementation and host must be satisfied for your solution to work. You’ll see this in the sample as we look at C# and JavaScript code. Luckily, the .NET team has satisfied as many of the .NET runtime features as possible, so most C# code works. That said, multithreading support is coming in .NET 7, but still experimental. Lack of threading may be a roadblock if you’re writing async/await
code.
Getting Started
You’ll need to install the latest .NET 7 SDK to follow along. As of writing this post, the newest release was .NET 7 RC2, but I suspect the final release has already occurred.
Once you’ve installed the SDK, you’ll need two additional workloads. First, from a command line terminal, execute the following commands. macOS users will have to use sudo
.
dotnet workload install wasm-tools
dotnet workload install wasm-experimental
Next, clone the repository I have included on my GitHub, called HelloDotnetWasm.
Finally, once cloned, please run dotnet tool restore
. Restoring will install two tools locally to help run the project: dotnet-serve
and run-script
.
The WASM Targeted Project
The first thing I noticed during this exercise was the lack of dependencies in my project’s csproj
file.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
<WasmMainJSPath>main.js</WasmMainJSPath>
<OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<WasmExtraFilesToDeploy Include="index.html" />
<WasmExtraFilesToDeploy Include="main.js" />
<WasmExtraFilesToDeploy Include="favicon.ico" />
</ItemGroup>
</Project>
The sparseness is because the WASM project uses the SDK workloads functionality to pull in MSBuild targets. WASM is part of .NET, and that’s pretty cool.
The essential element in the csproj
file is that of RuntimeIdentifier
, which tells the .NET build process to target WASM specifically for the browser. I can imagine as WASI becomes more fleshed out, you’ll see a new value similar to server-wasm
, but that’s speculation on my part.
The next thing you’ll notice in the project is the Program.cs
file.
using System;
using System.Runtime.InteropServices.JavaScript;
// ReSharper disable MemberCanBePrivate.Global
Console.WriteLine("Hello, Browser!");
Console.WriteLine(string.Join(" ", args));
public partial class MyClass
{
[JSExport]
internal static string Greeting()
{
// language=html
var text =
$"""
<div>
<h1>Hello, World! Greetings from WASM!</h1>
<p>Listening at {GetHRef()}</p>
</div>
""";
Console.WriteLine(text);
return text;
}
[JSImport("window.location.href", "main.js")]
internal static partial string GetHRef();
}
This program uses top-level statements, so the first mentions of Console.WriteLine
will execute when we run our WASM module. The MyClass
type encapsulates both an exported and an imported method, with the import method’s functionality being implemented by our host. We’ll see how a host implements an import when looking at the main.js
JavaScript file.
The JsImportAttribute
expects two arguments. The first is the function name. The string should match the JavaScript object’s structure found on the host. The second value is the module’s name; in this case, you can find the implementation of window.location.href
in the main.js
module.
Let’s look at the main.js
file, where we load our module, and the dotnet.js
module that contains the .NET runtime. The .NET runtime implements the WASM host interface, giving us a translation between the code we write and the browser environment. The dotnet.js
file is part of the build process, so there’s no need to look at it, but you should know it exists.
import { dotnet } from './dotnet.js'
const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);
const { setModuleImports, getAssemblyExports, getConfig, runMainAndExit } = await dotnet
.withDiagnosticTracing(false)
.withApplicationArgumentsFromQuery()
.create();
setModuleImports("main.js", {
window: {
location: {
href: () => globalThis.window.location.href
}
}
});
const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
const html = exports.MyClass.Greeting();
console.log(html);
document.getElementById("out").innerHTML = `${html}`;
await runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]);
The file has many points of interest, so let’s work our way down from top to bottom.
The first step is to import the .NET runtime, which will allow us to get information about our WASM “assembly”. There’s some defensive code at the top on the off chance you are targeting a different WASM runtime other than a browser, but it’s almost unnecessary, in my opinion.
The following section is we start loading the .NET WASM runtime functionality that will allow us to introspect our WASM app. Here we can enable our host to trace our app, read application arguments from the query string, and more.
const { setModuleImports, getAssemblyExports, getConfig, runMainAndExit } = await dotnet
.withDiagnosticTracing(false)
.withApplicationArgumentsFromQuery()
.create();
The call to setModuleImports
allows us to create a module name and the implementation we saw in our C# Program.cs
file. Imports will enable us to proxy any JavaScript implementation into our app. Keep in mind our WASM app shares the same memory as the host, meaning we can share almost anything.
setModuleImports("main.js", {
window: {
location: {
href: () => globalThis.window.location.href
}
}
});
The following section asks the WASM implementation what interfaces are part of the export so that the host can utilize them. For example, in the implementation, we have a MyClass
implementation with a Greeting
method. To access it, you only need to remember the namespace, class, and method name.
const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
const html = exports.MyClass.Greeting();
Finally, we can call our main entry point, which, as you remember, invokes the Console.WriteLine
method. We can even pass in arguments to our entry point method.
document.getElementById("out").innerHTML = `${html}`;
await runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]);
All that’s left to do, is write an index.html
file to load all of our modules.
<!DOCTYPE html>
<!-- Licensed to the .NET Foundation under one or more agreements. -->
<!-- The .NET Foundation licenses this file to you under the MIT license. -->
<html>
<head>
<title>HelloDotnetWasm</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="modulepreload" href="./main.js" />
<link rel="modulepreload" href="./dotnet.js" />
</head>
<body>
<main id="out"></main>
<script type='module' src="./main.js"></script>
</body>
</html>
Building the WASM Project
When we build the WASM project, we get an AppBundle
folder that includes everything we need to run our WASM app in any browser without a .NET backend.
Note you’ll be pushing down all the .NET runtime assemblies with your WASM app. I think these payloads will reduce in future iterations as the integration between .NET and WASM matures.
Now, it’s only a matter of running an HTTP server. In this project, I’ve included a simple command.
dotnet r start
Or, if you’re using JetBrains Rider, use the Start run configuration I’ve created in the sample. Navigating to https://localhost:8080
, you’ll see the running application. Also, open the developer tools to see the messages our app is writing to the host console.
Congratulations! You’re running .NET in the browser using WASM!
Something That Could Be Better in JavaScript
Working with the main.js
file is a leap of faith. I mean that the build process will eventually create a dotnet.js
file which will be part of the AppBundle
folder, but as you write your main.js
file, you don’t get any tooling help. This Chicken and Egg issue can lead to mistakes that tooling could easily catch.
A potential solution is to generate the dotnet.js
file using source generators and have it be part of the solution as you work on your WASM project. That way, most modern tooling would see and process the file, giving you code completion and hints.
Another thing I would like to see improve is the generation of a better interface used in main.js
. Right now, the helper methods from dotnet.js
are low-level building blocks. I think the build process could abstract your code behind a project-specific wrapper.
For example, this could be the code you write using a generated program.js
file.
import { program } from './program.js'
program.imports["main.js"].window.location.href = () => globalThis.window.location.href;
const html = program.exports.MyClass.Greeting();
document.getElementById("out").innerHTML = `${html}`;
program.Main(["dotnet", "is", "great!"])
This change would also make it much easier to pass around your program across different modules.
Conclusion
The ability to target WASM with .NET 7 is terrific and one of the things I’m excited about with the future of C# and F# development. So please download the sample from my GitHub repository and try it. I know it might not look like a lot, but I’m convinced this is the future of development and is worth keeping in mind.
As always, thank you for reading and please remember to follow me on Twitter @buhakmeh and let me know your thoughts on WASM.