Some time ago I blogged about using the experimental dotnet WASI SDK on ARM Macs. Today we are going to explore building dotnet based WASI-WASM applications with that SDK, with the goal of deploying them to the cloud.
Possible options π
There are currently two easy options to run a WASI-WASM program as a cloud service. The first one is to use Cloudflare Workers. This is the simpler variant as the workers allow deployment of simple console applications, and Cloudflare streams stdin and stdout to/from the Worker using the HTTP request and response bodies.
The second option is to use Fermyon Spin, which recently released an experimental dotnet SDK.
Cloudflare Workers π
Let’s start by building a simple WASI application. To do that, we will need the aforementioned dotnet WASI SDK, which is currently available in version 0.1.2-preview.10061 on Nuget. Note that this is highly experimental and many things do not work there - including the fact that there is no garbage collection wired in, so the app will just cotinue to fill up memory.
Our project file, WasiDemo.csproj, will look like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<WasiTrim>true</WasiTrim>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Wasi.Sdk" Version="0.1.2-preview.10061" />
</ItemGroup>
</Project>
WasiTrim is enabled to help reduce the size of the produced wasm file. Becauase there are currently no wasi32-wasm targets in the dotnet SDK, the Wasi.Sdk itself takes care of caopying out the necessary bits out of the packages used by browser-wasm, including the Mono driver. It also downloads WASI native libraries and creates the C entry point for the wasm file, which then wires in the Mono VM and calls into our code.
That’s a lot of machinery, but it all happens behind the scenes. All we need to do is to write our simple program:
using System;
namespace WasiDemo
{
public class Program
{
public static int Main()
{
Console.WriteLine("I'm alive in C#!");
return 0;
}
}
}
We can now build our app with the regular:
dotnet build -c Release
This builds a trimmed WASI-based WASM program WasiDemo.wasm into bin/Release/net7.0/. What is cool is that no matter how many projects and dependencies we reference, the application is always going to be represented by the single wasm file.
You can now get one of the popular WASM runtimes, for example:
And you should be able to run the compiled program by executing
wasmtime WasiDemo.wasm
or
wasmer WasiDemo.wasm
This should output
I'm alive in C#!
Both of these runtimes are JIT based; while wasm3 is actually an interpreter. In order to run on wasm3, you need to additionally execute wasm-opt (which you can get by downloading binaryen - wasm-opt is in the bin folder), which optimizes and rewrites the raw WASM code.
wasm-opt -Oz --enable-bulk-memory WasiDemo.wasm -o WasiDemoOpt.wasm
wasm3 WasiDemoOpt.wasm
We can of course write more sophisticated code than just hello world, but in principle it is the host that defines what a WASI application is allowed to do. For example, if the host disallows filesystem access, that will not work. In addition, networking is not part of the WASI spec yet, so there is no way to make outgoing network bound calls, and attempting to use, for example, an HttpClient, would crash.
Next, we should be ready to delpoy to Cloudflare. Cloudflare’s free plan only allows WASM sizes up to 1MB, which is something that we will not be able to match with this solution. The paid plan ($5 dollars a month) allows for sizes of up to 5MB, and even that will be hard to fit for bigger apps - though we can use the optimized version which should fit comfortably.
Once you have a Clouflare account set up, you can execute the following steps:
(if needed) Install Wrangler:
npm install -g wrangler
Authenticate
wrangler login
Deploy
npx wrangler@wasm publish --name demo1 --compatibility-date=2022-07-07 WasiDemoOpt.wasm
Call
curl https://demo1.{your-subdomain}.workers.dev -X POST
Output
I'm alive in C#!
Not much - but cool nevertheless!
Spin workers π
Contrary to the bare-bones approach of Cloudflare, Spin provides an actual dotnet SDK, which we need to use to build the application. The approach there is slightly different, in that Spin SDK provides a C-based polyfill of the networking features that are missing in WASI. This way, the application can surface HTTP handlers - implemented with Spin SDK types, not native .NET types - and once deployed to the Spin Cloud, Spin is able to route HTTP traffic there.
Before we begin, the following steps are needed:
- Install spin and put it on your PATH
- Install wizer by running cargo install wizer -all-features (or any other method)
Spin will allow us to run the app locally and deploy them to the cloud, while wizer is the pre-initializer, which can make the startup time fast. We will enable it in the build process.
In this case our project code, SpinWasiDemo.csproj would look as follows:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWizer>true</UseWizer>
<WasiTrim>false</WasiTrim> <!-- this would trim some Spin code and fail -->
</PropertyGroup>
<ItemGroup>
<!-- 0.1.2 uses bulk memory which is currently incompatible with wizer-->
<PackageReference Include="Wasi.Sdk" Version="0.1.1" />
<PackageReference Include="Fermyon.Spin.Sdk" Version="0.1.0-alpha2" />
<PackageReference Include="Net.Codecrete.QrCodeGenerator" Version="2.0.3" />
</ItemGroup>
</Project>
There are few things worth mentioning here. First of all, trimming is disabled because it seems to be causing problems with the Spin SDK. Secondly, we do not use the latest dotnet Wasi.SDK (0.1.2-preview.10061) but instead the earlier version 0.1.1. The reason is the newest one uses bulk memory operations which are not compatible with wizer at the moment, and we would like to use wizer as part of our build process.
For demo purposes, we also reference the nice Nuget package Net.Codecrete.QrCodeGenerator, which we will use to generate a QR code with our application. This is to illustrate that while many things are still not supported in dotnet on WASI, you can still take advantage of certain Nuget packages.
With that in place, we can write our Spin handlers, starting with the request router first.
public static class App
{
private static readonly Dictionary<string, Func<HttpRequest, HttpResponse>> _routes = new()
{
{ Warmup.DefaultWarmupUrl, Init},
{ "/hello", Hello },
{ "/qr", Qr }
};
[HttpHandler]
public static HttpResponse Router(HttpRequest request)
{
var requestUrl = request.Url.ToLowerInvariant();
var routeFound = _routes.TryGetValue(requestUrl, out var handler);
if (routeFound) return handler(request);
return new HttpResponse
{
StatusCode = HttpStatusCode.NotFound
};
}
This simple logic registers a spin HTTP handler via the HttpHandlerAttribute, and matches the incoming request routes to static internal methods which we will write next. This is of course very rudimentary, but illustrates the idea well enough.
The Init one is only called by wizer and can be used to initialize static variables or any other operation that is needed at your worker startup, to reduce it’s cold startup time later. In our case it does not need to do anything:
private static HttpResponse Init(HttpRequest httpRequest)
{
return new HttpResponse
{
StatusCode = HttpStatusCode.OK
};
}
The Hello handler will simply return a hello world message as plain text:
private static HttpResponse Hello(HttpRequest httpRequest)
{
return new HttpResponse
{
StatusCode = HttpStatusCode.OK,
Headers = new Dictionary<string, string>
{
["Content-Type"] = "text/plain"
},
BodyAsString = "Hello from C#"
};
}
The Qr worker will instead deserialize the request body, read out a URL from it and produce a corresponding QR code - returning it as binary data.
private static HttpResponse Qr(HttpRequest httpRequest)
{
var raw = httpRequest.Body.AsBytes();
if (raw != null && raw.Length > 0)
{
var input = JsonSerializer.Deserialize<QrPayload>(raw);
if (input != null && input.Url != null)
{
var qr = QrCode.EncodeText(input.Url.ToLowerInvariant(), QrCode.Ecc.Medium);
string svg = qr.ToSvgString(4);
return new HttpResponse
{
StatusCode = HttpStatusCode.OK,
Headers = new Dictionary<string, string>
{
["Content-Type"] = "application/octet-stream"
},
BodyAsString = svg
};
}
}
return new HttpResponse
{
StatusCode = HttpStatusCode.BadRequest,
Headers = new Dictionary<string, string>
{
["Content-Type"] = "text/json"
},
BodyAsBytes = JsonSerializer.SerializeToUtf8Bytes(new
{ error = "Invalid request" })
};
}
record QrPayload
{
public string Url { get; set; }
}
And that’s basically it. We can now run our application using
spin up
This starts a web server at http://127.0.0.1:3000:
Serving http://127.0.0.1:3000
Available Routes:
spin-wasi-demo: http://127.0.0.1:3000 (wildcard)
We can now call
GET /hello HTTP/1.1
Host: 127.0.0.1:3000
Which should result in:
Content-Type: text/plain
"Hello from C#!"
The other endpoint requires a request body, so we can supply that too:
POST /qr HTTP/1.1
Host: 127.0.0.1:3000
{
"Url": "https://www.strathweb.com"
}
And the response should be a valid QR code. For example for https://www.strathweb.com, it should produce:
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 33 33" stroke="none">
<rect width="100%" height="100%" fill="#ffffff"/>
<path d="M4,4h7v1h-7z M12,4h1v3h-1z M16,4h1v2h-1z M18,4h1v2h-1z M22,4h7v1h-7z M4,5h1v6h-1z M10,5h1v6h-1z M15,5h1v1h-1z M17,5h1v3h-1z M22,5h1v6h-1z M28,5h1v6h-1z M6,6h3v3h-3z M13,6h1v2h-1z M19,6h1v1h-1z M24,6h3v3h-3z M16,7h1v1h-1z M12,8h1v1h-1z M14,8h2v2h-2z M20,8h1v1h-1z M13,9h1v1h-1z M16,9h3v1h-3z M5,10h5v1h-5z M12,10h1v1h-1z M14,10h1v1h-1z M16,10h1v1h-1z M18,10h1v1h-1z M20,10h1v1h-1z M23,10h5v1h-5z M13,11h1v2h-1z M15,11h1v2h-1z M17,11h1v1h-1z M4,12h1v4h-1z M7,12h6v1h-6z M14,12h1v5h-1z M16,12h1v3h-1z M18,12h4v1h-4z M24,12h1v2h-1z M26,12h2v2h-2z M28,12h1v1h-1z M5,13h4v1h-4z M18,13h2v1h-2z M21,13h1v1h-1z M23,13h1v3h-1z M25,13h1v3h-1z M9,14h1v3h-1z M10,14h1v1h-1z M15,14h1v2h-1z M19,14h1v5h-1z M22,14h1v1h-1z M28,14h1v3h-1z M6,15h2v1h-2z M12,15h1v2h-1z M18,15h1v1h-1z M20,15h1v1h-1z M26,15h2v1h-2z M7,16h2v1h-2z M10,16h2v1h-2z M16,16h1v1h-1z M22,16h1v1h-1z M4,17h1v4h-1z M6,17h1v1h-1z M13,17h1v1h-1z M17,17h2v1h-2z M20,17h2v2h-2z M24,17h1v2h-1z M27,17h1v2h-1z M5,18h1v1h-1z M8,18h1v1h-1z M10,18h2v1h-2z M14,18h2v2h-2z M16,18h1v1h-1z M22,18h1v1h-1z M25,18h2v2h-2z M28,18h1v2h-1z M6,19h2v1h-2z M9,19h1v2h-1z M11,19h2v2h-2z M18,19h1v2h-1z M21,19h1v2h-1z M23,19h1v2h-1z M6,20h1v1h-1z M8,20h1v1h-1z M10,20h1v1h-1z M13,20h1v3h-1z M16,20h2v2h-2z M19,20h2v2h-2z M22,20h1v1h-1z M24,20h1v5h-1z M26,20h2v2h-2z M12,21h1v5h-1z M15,21h1v2h-1z M4,22h7v1h-7z M14,22h1v1h-1z M20,22h1v3h-1z M22,22h1v1h-1z M28,22h1v2h-1z M4,23h1v6h-1z M10,23h1v6h-1z M17,23h3v1h-3z M27,23h1v1h-1z M6,24h3v3h-3z M15,24h1v3h-1z M16,24h1v1h-1z M18,24h2v4h-2z M21,24h3v1h-3z M13,25h2v1h-2z M22,25h1v1h-1z M27,25h2v3h-2z M13,26h1v1h-1z M17,26h1v1h-1z M21,26h1v1h-1z M24,26h3v1h-3z M23,27h2v1h-2z M26,27h1v1h-1z M5,28h5v1h-5z M12,28h3v1h-3z M16,28h1v1h-1z M20,28h2v1h-2z M25,28h1v1h-1z M28,28h1v1h-1z" fill="#000000"/>
</svg>
As a final step, you can deploy the application to Fermyon Cloud using the official instructions. It really boils down to setting up an account in the Fermyon Cloud, and then calling:
spin login
followed by
spin deploy
This should give you the following output
Uploading spin-wasi-demo version 1.0.0+XXXXXXXX...
Deploying...
Waiting for application to become ready... ready
Available Routes:
spin-wasi-demo: https://spin-wasi-demo-XXXXXXXX.fermyon.app (wildcard)
Where the final URL is the one you can call now.
Code π
All the code from this blog post can be found on Github.