Running .NET 8 on Cloudflare Workers

Running .NET 8 on Cloudflare Workers

.NET is an open-source application framework from Microsoft which supports a wide range of platforms such as Windows, Linux and macOS. Meanwhile, Cloudflare Workers & Pages is a serverless platform for executing application code, like AWS Lambda and Azure Functions.

In .NET 8, support was added for compiling .NET apps to WebAssembly (Wasm). This allows .NET to run almost anywhere, including in the web browser. Cloudflare also announced that their Workers platform also supports executing Wasm modules and supports WebAssembly System Interface (WASI) too which allows code to run almost anywhere.

So, I began to wonder, would it be possible to run .NET on Cloudflare Workers?

.NET to WebAssemly (Wasm)

Before I could create the application, I was going to use to test with (the basic .NET console application), there were a few things are needed first:

  1. The WASI SDK.
  2. The .NET wasi-experimental workload.
  3. Install Wasmtime

The WASI SDK

The WASI SDK contains the tools necessary for the compilation process to be successful. You can download these from the WASI SDK release page on GitHub. After you have downloaded the correct architecture, you will need to extract the files. I chose to put the files in a C:\wasi-sdk\wasi-sdk-22 directory.

The final step is to set the WASI_SDK_PATH environment variable to that path so that the .NET tools know where to find the WASI SDK.

On Windows, this can be done by going to Settings, System, About, Advanced system settings, Advanced tab, Environment Variables.

Install the wasi-experimental workload

Now that the WASI SDK is in place, we need to install the wasi-experimental .NET workload. This can be done by running the following command in a terminal.

dotnet workload install wasi-experimental

After that has completed the installation process, the final thing to setup is a Wasm runtime.

Wasmtime

We will also need a Wasm runtime to be able to run our application after it has been compiled to Wasm. I chose Wasmtime from Bytecode Alliance, but any other should work too. I installed this using winget by running the following command in a terminal window and following the prompts.

winget install BytecodeAlliance.Wasmtime

Now that the tools we need are setup, we need to create the sample application.

Creating the .NET Application

There is a WASI Console App template already configured to build as a Wasm project with support for WASI. This is the wasiconsole template which can be found in Visual Studio by searching for “wasi” while creating a new project or using the following dotnet CLI command in a new empty directory.

dotnet new wasiconsole

Before the project was run, I added the <WasmSingleFileBundle>true</WasmSingleFileBundle> option to the csproj file so that the required parts of .NET and the app code are bundled into a single file.

The code I used for the console application was as follows:

using System;
using System.Runtime.InteropServices;
 
Console.WriteLine($"Hello from {RuntimeInformation.FrameworkDescription} on {RuntimeInformation.OSDescription}:{RuntimeInformation.OSArchitecture}.");

With all of this setup you should be able to run the new console app and get the following output:

Hello from .NET 8.0.6 on WASI:Wasm.

You should also find the generated wasm file in the project’s bin\Debug\net8.0\wasi-wasm\AppBundle directory.

Trimming the .NET Wasm Bundle

The initial build file size of the wasm file was 6,599 KB. However, Cloudflare Workers have a size limit of 1MB after gzip compression on the free plan. As a result, a way needed to be found to get that file size down.

Fortunately, .NET provides a range of trimming options which can be applied to a project to reduce the size of the build artifact by removing unused parts of the framework. After applying these options, the file size came down to 2,687 KB uncompressed and 935 KB after gzip compression.

However, the size still needed to be reduced further to have room for the Cloudflare WASI package and the worker code. This was done using wasm-opt which managed to optimise the binary even further.

npx wasm-opt .\src\dotnet-on-workers.wasm -o .\src\dotnet-on-workers.wasm --enable-bulk-memory

After running the above command, the file size was down to 2,524 KB uncompressed, and 920 KB after gzip compression.

Creating the Cloudflare Worker

The next step was to get this working from a Cloudflare Worker locally using the Cloudflare Workers WASI. However, the all of the articles I found explaining how to do this were relatively old with no new information, and the version of the wrangler package (wrangler@wasm) they made refence to was published over two years ago.

I decided to install Cloudflare Workers WASI into a new blank workers project created using the latest version of Wrangler with the npm create cloudflare command. The code was based on the example given in the Cloudflare Workers WASI announcement blog post.

The @cloudflare/workers-wasi package was installed using npm install @cloudflare/workers-wasi.

import { WASI } from '@cloudflare/workers-wasi';
import demoWasm from './dotnet-on-workers.wasm';

export default {
  async fetch(request, env, ctx) {

    // Creates a TransformStream we can use to pipe our stdout to our response body.
    const stdout = new TransformStream();

    const wasi = new WASI({
      args: [],
      stdout: stdout.writable,
    });

    // Instantiate our WASM with our demo module and our configured WASI import.
    const instance = new WebAssembly.Instance(demoWasm, {
      wasi_snapshot_preview1: wasi.wasiImport,
    });

    // Keep our worker alive until the WASM has finished executing.
    ctx.waitUntil(wasi.start(instance));

    // Finally, let's reply with the WASM's output.
    return new Response(stdout.readable);
  },
};

sock_accept is missing

At this point, I thought I’d possibly have a functional worker running .NET, so I ran npx wrangler dev to see if it would work and respond to a request.

WebAssembly.Instance(): Import #0 "wasi_snapshot_preview1" "sock_accept": function import requires a callable

As you can see from the error message above it didn’t work. The error message indicated that this was due to support for sock_accept function being missing from the Cloudflare WASI implementation. According to the WASI docs, this was added to the WASI preview1 specification after the initial publication, and .NET implemented support for it which ends up in the compiled Wasm module even if it is not used.

Fortunately, it is possible to extend the Cloudflare WASI imports, so I added a function to support the required sock_accept method.

import { WASI } from '@cloudflare/workers-wasi';
import demoWasm from './dotnet-on-workers.wasm';

export default {
  async fetch(request, env, ctx) {

    const stdout = new TransformStream();

    const wasi = new WASI({
      args: [],
      stdout: stdout.writable,
    });

    // Custom WASI import to handle 'sock_accept'.
    const wasiImport = {
      ...wasi.wasiImport,
      sock_accept: () => {
        console.log('sock_accept called.');
      },
    };

    // Instantiate our WASM with our demo module and our configured WASI import.
    const instance = new WebAssembly.Instance(demoWasm, {
      wasi_snapshot_preview1: wasiImport,
    });

    ctx.waitUntil(wasi.start(instance));

    return new Response(stdout.readable);
  },
};

Required command line arguments

On the next attempt to run the worker, the following output was returned:

[MONO] critical: /__w/1/s/src/mono/mono/eglib/gmisc-unix.c:103: assertion 'filename != NULL' failed

[MONO] critical: /__w/1/s/src/mono/mono/eglib/gpath.c:134: assertion 'filename != NULL' failed

After finding the mentioned file in the Mono GitHub repository, it is possible to follow and where the arguments are being provided from. It turns out that .NET requires the first command line argument to be the file name of the executing program, host executable, or an empty string.

To satisfy this expectation, at least one command line argument had to be provided as a placeholder. In the following example, I just used an empty string.

import { WASI } from '@cloudflare/workers-wasi';
import demoWasm from './dotnet-on-workers.wasm';

export default {
  async fetch(request, env, ctx) {

    // Creates a TransformStream we can use to pipe our stdout to our response body.
    const stdout = new TransformStream();

    const wasi = new WASI({
      args: [''], // Empty string to satisfy .NET framework requirements.
      stdout: stdout.writable,
    });

    // Custom WASI import to handle 'sock_accept'.
    const wasiImport = {
      ...wasi.wasiImport,
      sock_accept: () => {
        console.log('sock_accept called.');
      },
    };

    // Instantiate our WASM with our demo module and our configured WASI import.
    const instance = new WebAssembly.Instance(demoWasm, {
      wasi_snapshot_preview1: wasiImport,
    });

    // Keep our worker alive until the WASM has finished executing.
    ctx.waitUntil(wasi.start(instance));

    // Finally, let's reply with the WASM's output.
    return new Response(stdout.readable);
  },
};

Deployment

After running npx wrangler dev again I finally received the output I was hoping for!

Hello from .NET 8.0.6 on WASI:Wasm.

Now that the worker seems to be working properly in a development environment, it was time to try it out on the real Cloudflare Workers platform. Fortunately, workers are easy to publish with a single command.

npx wrangler deploy --minify

After visiting the URL which was assigned to the deployed Cloudflare worker I received the same output, meaning the .NET code was being successfully executed on Cloudflare Workers.

Summary

The addition of Wasm as a .NET compilation target opens up more opportunities for .NET code to run a wide range of environments. At the moment WASI is in preview and support is experimental, but it has growing adoption.

The limitations of the Cloudflare Workers free plan make running a real .NET application a little impractical, however it may be useful for specific workloads, and the higher limits on the paid plans make this more realistic.

It is possible to dramatically reduce the initial .NET Wasm file size by using the trimming options available in .NET. This reduction can then be further optimised using the wasm-opt package.