Skip to main content
Eric Gregory
Eric Gregory
Eric Gregory
||10 min read

Build native WebAssembly components with .NET and C#—and deploy on wasmCloud

Cross-posted from the wasmCloud blog.

Building WebAssembly components in .NET/C# is easier than ever before. In this post, we'll take a look at the landscape of .NET tooling for components, explore how to get started today, and find out how easy it is to compile .NET/C# code to a component and run it on wasmCloud.

The WASI P2 and .NET landscape

WebAssembly (Wasm) in general has a strong history of support in .NET, and today there is an increasingly mature ecosystem for building Wasm components with the .NET SDK. The NativeAOT-LLVM compiler supports WASI P2 (with both .NET 8 and 9), giving developers native WASI P2 support.

As in languages like Rust and TinyGo, native support means the ability to write "idiomatic" code—ordinary C# that compiles into a portable, interoperable component with no fuss and no need to learn any additional APIs or bindings.

In terms of developer experience, the most important piece of the toolchain is componentize-dotnet, an open source project sponsored by the Bytecode Alliance that consolidates all of the necessary tooling for building a component into a single NuGet package. While it's not strictly required to build a component, in this blog we're going to use componentize-dotnet the entire way, and I'd recommend that anyone experimenting with .NET and components use the package for two reasons:

  • Bundling: componentize-dotnet bundles parts of the toolchain that you're going to need anyway (like the WASI SDK and dependency management tooling), largely keeping them out of sight and automating the processes. You can simply add a package to your project, compile, and generate a .wasm binary that conforms to WASI P2 and the Component Model.
  • Interfaces: The ability to create custom, language-agnostic interfaces in WebAssembly Interface Type (WIT) is one of components' signature superpowers, and componentize-dotnet not only makes it straightforward, but delivers a streamlined, forward-looking experience in which WIT interfaces can be easily fetched from OCI registries. We'll see this workflow in action in a moment.

There is one significant limitation to note: componentize-dotnet is currently limited to Windows machines. Maintainers expect macOS and Linux support soon.

Prerequisites

For this blog, we'll use the .NET 9 Preview 7 SDK. If you're a Visual Studio Code user, you may also want to install the C# Dev Kit extension, but in this walkthrough, we'll stick to the command line.

Windows-only, for now

At the moment, componentize-dotnet only works on Windows. Maintainers expect macOS and Linux support to land soon.

In addition to the .NET 9 SDK, you'll need:

  • wasmtime: This is the standalone WebAssembly runtime that we'll use to test some components locally.
  • wasmCloud Shell (wash) v0.32.1: We'll use the wasmCloud CLI to inspect component interfaces and deploy a component toward the end of the tutorial. Make sure you have the latest version (v0.32.1 or higher).

Building a WebAssembly component from .NET code

First, let's try a simple hello world app like the one in Microsoft's .NET tutorial—but in this case, compiled to a WebAssembly component.

Our goal here is to get a feel for the "idiomatic" development experience that is possible when a language toolchain supports native WASI P2 compilation. The developer doesn't need to use language-specific bindings for the common APIs included in WASI P2—they can simply compile a component and move on.

To start, we'll create a new project:

dotnet new console --name hello-wasm
cd hello-wasm

The componentize-dotnet package depends on the NativeAOT-LLVM package, which resides at the dotnet-experimental package source, so you will need to make sure that NuGet is configured to refer to experimental packages. You can create a project-scoped NuGet configuration by running:

dotnet new nugetconfig

In nuget.config, add the following line under <clear /> to add the experimental package source:

<add key="dotnet-experimental" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json" />

Your nuget.config should look like so:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
 <packageSources>
    <!--To inherit the global NuGet package sources remove the <clear/> line below -->
    <clear />
    <add key="dotnet-experimental" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json" /> <!-- [!code ++] -->
    <add key="nuget" value="https://api.nuget.org/v3/index.json" />
 </packageSources>
</configuration>

Back on the command line, we'll add the Componentize.DotNet package:

dotnet add package BytecodeAlliance.Componentize.DotNet.Wasm.SDK --prerelease

Open Program.cs in your editor. We'll follow the example of Microsoft's tutorial and add a line to include the date and time in our new console app:

Console.WriteLine("Hello, World!");
Console.WriteLine("The current time is " + DateTime.Now); // [!code ++]

Add the following inside the <PropertyGroup> in your hello-wasm.csproj project file:

    <RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
    <UseAppHost>false</UseAppHost>
    <PublishTrimmed>true</PublishTrimmed>
    <InvariantGlobalization>true</InvariantGlobalization>
    <SelfContained>true</SelfContained>
    <MSBuildEnableWorkloadResolver>false</MSBuildEnableWorkloadResolver>

Now build the component:

dotnet build hello-wasm.csproj

We can run the component with wasmtime:

wasmtime bin\Debug\net9.0\wasi-wasm\native\hello-wasm.wasm
Hello, World!
The current time is 09/03/2024 19:49:03

We can also use wash inspect to view the WASI interfaces used by this component:

wash inspect --wit bin\Debug\net9.0\wasi-wasm\native\hello-wasm.wasm
package root:component;

world root {
  import wasi:cli/environment@0.2.0;
  import wasi:cli/exit@0.2.0;
  import wasi:io/error@0.2.0;
  import wasi:io/poll@0.2.0;
  import wasi:io/streams@0.2.0;
  import wasi:cli/stdin@0.2.0;
  import wasi:cli/stdout@0.2.0;
  import wasi:cli/stderr@0.2.0;
  import wasi:cli/terminal-input@0.2.0;
  import wasi:cli/terminal-output@0.2.0;
  import wasi:cli/terminal-stdin@0.2.0;
  import wasi:cli/terminal-stdout@0.2.0;
  import wasi:cli/terminal-stderr@0.2.0;
  import wasi:clocks/monotonic-clock@0.2.0;
  import wasi:clocks/wall-clock@0.2.0;
  import wasi:filesystem/types@0.2.0;
  import wasi:filesystem/preopens@0.2.0;
  import wasi:sockets/network@0.2.0;
  import wasi:sockets/udp@0.2.0;
  import wasi:sockets/tcp@0.2.0;
  import wasi:random/random@0.2.0;

  export wasi:cli/run@0.2.0;
}

Our only export is on wasi:cli/run, since this is a command line executable—this is the exposed interface that wasmtime calls. We have a bunch of imports that are satisfied by the runtime, including terminal-output and clocks. But we never had to learn those interfaces or use them directly; it all happened under the hood.

Using WIT interfaces with .NET

In the example above, we didn't use any APIs defined in WebAssembly Interface Type (WIT)—at least not consciously. We stuck entirely to native .NET, but under the hood, the compiler recognized where .NET APIs corresponded to the standard interfaces in WASI P2, utilizing APIs like wasi:cli and wasi:clocks. Now we have a component that can speak the "common language" of WASI P2 with any other component, regardless of the language those components were written in.

In practice, there will be many situations where you will want to consciously use WIT interfaces, whether because you've written your own or you want to use one that isn't among the standard interfaces in WASI. For example, you might want to use one of the interfaces like Postgres or Couchbase listed in our Capability Catalog. This is sort of like teaching your components a new common language, with WIT acting as the Rosetta Stone.

Fortunately, it's incredibly easy to use WIT interfaces with .NET—in your project file, simply refer to the WIT interface in an OCI registry. James Sturtevant, componentize-dotnet maintainer and Principal Software Engineering Lead at Microsoft, created an excellent demo that showcases WIT via OCI, using the wasi:http interface by grabbing it from the official GHCR registry.

To start, let's go ahead and replace the hello world lines in Program.cs with the following:

using System.Text;
using ProxyWorld.wit.imports.wasi.http.v0_2_0;

namespace ProxyWorld.wit.exports.wasi.http.v0_2_0;

public class IncomingHandlerImpl: IIncomingHandler {
    public static void Handle(ITypes.IncomingRequest request, ITypes.ResponseOutparam responseOut) {
	var content = Encoding.ASCII.GetBytes("Hello, World!");
	var headers = new List<(string, byte[])> {
	    ("content-type", Encoding.ASCII.GetBytes("text/plain")),
	    ("content-length", Encoding.ASCII.GetBytes(content.Count().ToString()))
	};
	var response = new ITypes.OutgoingResponse(ITypes.Fields.FromList(headers));
	var body = response.Body();
	ITypes.ResponseOutparam.Set(responseOut, Result<ITypes.OutgoingResponse, ITypes.ErrorCode>.ok(response));
	using (var stream = body.Write()) {
	    stream.BlockingWriteAndFlush(content);
	}
	ITypes.OutgoingBody.Finish(body, null);
    }
}

We'll come back in a moment and break down what's happening here, but first, let's update hello-wasm.csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType> // [!code --]
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
    <UseAppHost>false</UseAppHost>
    <PublishTrimmed>true</PublishTrimmed>
    <InvariantGlobalization>true</InvariantGlobalization>
    <SelfContained>true</SelfContained>
    <MSBuildEnableWorkloadResolver>false</MSBuildEnableWorkloadResolver>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BytecodeAlliance.Componentize.DotNet.Wasm.SDK" Version="0.2.0-preview00004" />
  </ItemGroup>

  <ItemGroup> <!-- [!code ++] -->
    <Wit Include="wit/wit.wasm" World="proxy" Registry="ghcr.io/webassembly/wasi/http:0.2.0" /> <!-- [!code ++] -->
  </ItemGroup> <!-- [!code ++] -->
</Project>
  • Up top, we remove the line configuring this as an executable, since we're no longer building a console app.
  • Down in the second <ItemGroup>, we're defining the WIT file and world we want to use, and then the address for the official OCI package hosted by the WebAssembly project.

Now we'll build the component:

dotnet build hello-wasm.csproj

When we build (or when we save if we're using VS Code and the C# Dev Kit extension), componentize-dotnet pulls down the specified package and generates WIT bindings at .\obj\Debug\net9.0\wasi-wasm\wit_bindgen\. Let's take a look at an excerpt of one of those binding files, ProxyWorld.wit.imports.wasi.http.v0_2_0.ITypes.cs:

...
    /**
    * Represents an outgoing HTTP Response.
    */

    public class OutgoingResponse: IDisposable {
        internal int Handle { get; set; }

        public readonly record struct THandle(int Handle);

        public OutgoingResponse(THandle handle) {
            Handle = handle.Handle;
        }

        public void Dispose() {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        [DllImport("wasi:http/types@0.2.0", EntryPoint = "[resource-drop]outgoing-response"), WasmImportLinkage]
        private static extern void wasmImportResourceDrop(int p0);
...

In this file we see the types for wasi:http bound to C# with documentation from the original WIT included throughout the binding file.

If we look back at Program.cs, we see some of those types in use, including OutgoingResponse and ResponseOutparam. Consulting the binding files, we can make use of any WIT interface in our code.

All right—we've already built our component. Let's test it out.

Since this component uses HTTP, we'll use the wasmtime serve subcommand to run the component at localhost:3000:

wasmtime serve -S cli  .\bin\Debug\net9.0\wasi-wasm\native\hello-wasm.wasm --addr 127.0.0.1:3000
Hello, World!

You can CTRL+C to stop the serve. When we inspect the component with wash this time, we'll see that our component has a different export:

wash inspect --wit bin\Debug\net9.0\wasi-wasm\native\hello-wasm.wasm
package root:component;

world root {
  import wasi:cli/environment@0.2.0;
  import wasi:cli/exit@0.2.0;
  import wasi:io/error@0.2.0;
  import wasi:io/poll@0.2.0;
  import wasi:io/streams@0.2.0;
  import wasi:cli/stdin@0.2.0;
  import wasi:cli/stdout@0.2.0;
  import wasi:cli/stderr@0.2.0;
  import wasi:cli/terminal-input@0.2.0;
  import wasi:cli/terminal-output@0.2.0;
  import wasi:cli/terminal-stdin@0.2.0;
  import wasi:cli/terminal-stdout@0.2.0;
  import wasi:cli/terminal-stderr@0.2.0;
  import wasi:clocks/monotonic-clock@0.2.0;
  import wasi:clocks/wall-clock@0.2.0;
  import wasi:filesystem/types@0.2.0;
  import wasi:filesystem/preopens@0.2.0;
  import wasi:sockets/udp@0.2.0;
  import wasi:sockets/tcp@0.2.0;
  import wasi:random/random@0.2.0;
  import wasi:http/types@0.2.0;

  export wasi:http/incoming-handler@0.2.0;
}

This example is using a WIT interface, wasi:http, that is part of WASI P2, but it's a great demonstration of the process for using WIT with .NET-based components. (For more information, check out the README for componentize-dotnet.)

Since this example uses HTTP, it's also a great candidate to run on wasmCloud. So let's imagine we want to deploy our component to an environment such as a cloud, edge, or Kubernetes cluster using wasmCloud. How might that work?

Deploying a .NET-based component to wasmCloud

To run our component on wasmCloud, all we need is a deployment manifest. Create a new file called wadm.yaml at the root of the project directory. The contents of the file should be as follows:

# Metadata
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
  name: hello-wasm
  annotations:
    description: 'HTTP hello wasm demo'
spec:
  components:
    - name: http-component
      type: component
      properties:
        # This manifest deploys from your local .wasm file, but you can also use OCR registries
        image: file://./bin/Debug/net9.0/wasi-wasm/native/hello-wasm.wasm
      traits:
        # One replica of this component will run
        - type: spreadscaler
          properties:
            replicas: 1
    # The httpserver capability provider, started from the official wasmCloud OCI artifact
    - name: httpserver
      type: capability
      properties:
        image: ghcr.io/wasmcloud/http-server:0.22.0
      traits:
        # Link the HTTP server and set it to listen on the local machine's port 8080
        - type: link
          properties:
            target: http-component
            namespace: wasi
            package: http
            interfaces: [incoming-handler]
            source_config:
              - name: default-http
                properties:
                  ADDRESS: 127.0.0.1:8080

Use wash to run wasmCloud locally. We'll use the -d flag to run in "detached" mode.

wash up -d

Now we can deploy to wasmCloud using the manifest:

wash app deploy wadm.yaml

You can check that the application is deployed with wash app list. Once the status reads as Deployed, you can curl it for a response:

curl localhost:8080
Hello, World!

Congratulations! You've deployed your first .NET-based component to wasmCloud. Once you're ready to clean up:

wash app undeploy wadm.yaml
wash app delete wadm.yaml
wash down

Get involved

Join us on the wasmCloud Slack to connect with the community, keep up with or get involved in the project, and chat with other developers building components across language ecosystems. You can also join us in our weekly livestreamed community meeting—we'd love to hear about what you're building with .NET/C#!