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:
- The WASI SDK.
- The .NET wasi-experimental workload.
- 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.