With the recent release of the .NET Framework 7, I thought it might be a good excuse to check back in on the .NET WebAssembly ecosystem and see where things stand and what improvements have been made.
In nearly every case, whenever I talk to anyone about .NET and WebAssembly, the conversation shifts quickly to Blazor. Blazor, in a vacuum, doesn't interest me. Not because it isn't good, but because Blazor owns both ends of the pipeline: the producer and the consumer.
The practical impact of this is that they can pretty much do whatever they like. They are free of the burdens and constraints that inhibit most of us trying to work in the cloud native/backend space with WebAssembly. To see what this looks like and so you can feel the smoke and mirrors at work, let's take a look at what .NET 7 gives us for an out of the box WebAssembly application.
Type dotnet new list
and you'll see all the templates available for you to create things:
These templates matched your input:
Template Name Short Name Language Tags
---------------------------------- ------------------ ---------- --------------------------------
ASP.NET Core Empty web [C#],F# Web/Empty
ASP.NET Core gRPC Service grpc [C#] Web/gRPC
...
Blazor Server App blazorserver [C#] Web/Blazor
Blazor Server App Empty blazorserver-empty [C#] Web/Blazor/Empty
Blazor WebAssembly App blazorwasm [C#] Web/Blazor/WebAssembly/PWA
Blazor WebAssembly App Empty blazorwasm-empty [C#] Web/Blazor/WebAssembly/PWA/Empty
...
WebAssembly Browser App wasmbrowser [C#] Web/WebAssembly/Browser
WebAssembly Console App wasmconsole [C#] Web/WebAssembly/Console
...
I've trimmed out the templates that don't apply to this discussion. I was so excited when I saw the
wasmconsole
project template, I grabbed some coffee, dropped what I was doing, and started
tinkering. "Finally," I thought. "Freestanding Wasm support".
Let's create a new wasmconsole
via dotnet new wasmconsole
(note that unlike Rust, this creates
the project in your current directory, not a sub-directory).
dotnet new wasmconsole
The template "WebAssembly Console App" was created successfully.
At this point, I was practically jumping with joy. On a whim, I opened the .csproj
file that was
created and I saw this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<!-- an angel just lost its wings -->
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
<WasmMainJSPath>main.mjs</WasmMainJSPath>
<OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
The important parts here are the use of the browser-wasm
runtime and the main.mjs
file. You see,
what you're getting with the "console" target isn't actually a freestanding WebAssembly module.
Instead what we get is a WebAssembly module that can be hosted in JavaScript in Node.js in a
console.
This is definitely not what I'm looking for. Remember earlier when I said that Blazor can do
whatever it likes because it is both the producer and consumer of the module? This is fine for
Blazor, but not for the WebAssembly ecosystem. Whenever you do things like build a module that will
only ever function in the presence of JavaScript or (as I'll mention in a bit) even a specific
so-called freestanding runtime, WebAssembly's prime asset of portability goes out the window. Hot
take warning: tightly coupled WebAssembly modules are no better than .so
/.dll
files.
If you're curious, use one of the wabt tools to crack open
the dotnet.wasm
file that gets produced when you compile in bin/Debug/net7.0/browser-wasm
. Note
the number of syscall
imports that have nothing to do with the Wasm or WASI standards. You'll see
the same thing happen with TinyGo, where you can accidentally use functions from syscall/js
.
So what happens when we run the application we just created?
WasmAppHost --runtime-config \
/lab/Debug/net7.0/browser-wasm/AppBundle/wasi2.runtimeconfig.json
Running: node main.mjs
Using working directory: /Users/kevin/lab/dotnet/wasi2/bin/Debug/net7.0/browser-wasm/AppBundle
mono_wasm_runtime_ready fe00e07a-5519-4dfe-b35a-f867dbaf2e28
Hello, World! Greetings from node version: v18.9.0
Hello, Console!
It's a JavaScript host. 😭
To someone who spends most of his time pursuing the portability and interoperability of Wasm, this makes me cry a bit. However, things are not entirely hopeless, for not all heroes wear capes! Steve Sanderson from Microsoft has created a GitHub project that is quite possibly the only tenuous thread by which the .NET (non-Blazor) WebAssembly ecosystem is hanging on: the .NET WASI SDK.
Let's see what it's like to build a .wasm
file using Steve Sanderson's WASI SDK.
dotnet new console -o MyFirstWasiApp
cd MyFirstWasiApp
dotnet add package Wasi.Sdk --prerelease
dotnet build
The first important (and very promising) thing is that we're creating a regular console
application. We can't see Node.js or any JavaScript from where we're standing, so we're off to a
good start. It's worth pointing out that, at least on my Mac, the compiled output from hello world
is 9MB
. Compared to Rust's average of 1MB
and smaller, that seems positively enormous. I also
didn't see any size shrink between Release and Debug, but the WASI SDK is still experimental, so I
suspect this will shrink over time.
One more thing before moving on. It is important to note that the way this stuff works is that the
entire .NET Framework core and its runtime is embedded into the .wasm
module (you can actually see
the dll
s crammed into a data
section if you open the file up). So while we can expect things to
get smaller and more optimized over time, how small things ultimately end up has a lot to do with
how the garbage collection specification gets implemented in WebAssembly and where optimization
efforts are focused.
In the interest of exploration, I wonder what happens when we try and do dotnet run
(I'm assuming
it should run like any other console/stdio-based WASI module).
dotnet run
Unhandled exception: System.ComponentModel.Win32Exception (2):
An error occurred trying to start process 'wasmtime' with working directory
'/Users/kevin/lab/dotnet/MyFirstWasiApp'. No such file or directory
Well, that's unfortunate. Looks like the run command is tightly coupled to having the wasmtime
binary in your path. This is actually a good time for me to point out another area of high friction
that I've found with .NET (though the problem exists in TinyGo as well): lack of runtime
interoperability.
During a day of playing around with building all kinds of different freestanding WASI modules (yes,
you can actually do this with the .NET WASI SDK!), I ran into a pretty thorny problem. At times, I
would be able to get something to work when using a wasmtime
host, but it would not work when
using wasmer
or wasm3
hosts. Conversely, sometimes things would break via wasmtime
that didn't
break via wasmer
.
But what about wasmCloud? When will be able to build .NET actors in wasmCloud? Technically, it's feasible today, but with a giant asterisk ⚠️. It required me creating an elaborate Rube Goldberg machine made up of duct tape, straws, bailing wire, and a couple pieces of chewing gum. I had to hand-craft some free-range organic (antibiotic free!) C header files because you can't control import/export labels from inside a C# file (yet).
There is also absolutely no sign of work beginning in .NET on support for the component model (not
to be confused with the System.ComponentModel
namespace, which has been around since the
dinosaurs). Even when the .NET Wasm developer experience gets to a point that feels low-friction, it
will probably not support components yet. We're definitely going to have to wait (and put in some
effort and contribute!) to see wit
-style support in a .NET project. As mentioned in the wasmCloud
blog post, The Road to Ubiquity, we're actively
working on trying to advance the standards and make the component model become a real thing for
multiple languages.
We're almost to the first real MVP. I can feel it in these old bones that have been writing C# code since before the .NET Framework was even called .NET. My worry is that not enough people will be vocal enough about the high friction in building freestanding, interoperable, standards-compliant WASI modules because if all anyone is looking at is the production and consumption loop through Blazor, they'll think there's absolutely nothing wrong with .NET's WebAssembly support.
The only call to action I ask is that we continue to be vocal. Show up in community meetings and on pull requests and RFCs and subreddits and represent the voice of the community that demands we stay true to the original portability and interoperability goals of WebAssembly while still improving the developer experience and growing the community in an inclusive and welcoming way.