This is my entry for the F# Advent Calendar 2022. Thank you Sergey Tihon for organizing it.

F# in strange places: Supabase edge functions

We're all big fans of F#, otherwise you wouldn't be here. In fact, we're such big fans of the language that we want to use it in every imaginable situation possible. We can already run it on many platforms, thanks to .Net Core and Xamarin, which allows us to target Windows, Linux, Mac, Android, and iOS. We can also target the browser thanks to the amazing Fable project. There are also several projects that allow targeting micro controllers. So realistically, we can use F# to target almost everything.

Additionally, there's been somewhat of a trend going around the cloud application circles: serverless functions. They provide a quick way to spin up an application without having to worry about the runtime and infrastructure by providing a simple function that gets invoked by a trigger of some kind (usually HTTP or cron). I personally don't see their use-cases as widespread as many will have you believe, even I admit that they're really cool.

They are many different cloud hosting providers that also offer serverless functions as part of their package, but there is one glaring problem. You are forced to use one of their provided runtimes. This usually also locks you into a specific programming language. We're going to take a look at a specific host, because their total offerings and tech stack are, in my opinion, a very good combination of being able to create many different kinds of applications quickly and robustly: Supabase.

Supabase

Supabase is an open-source backend-as-a-service, selling a hosted solution that allows developers to concentrate on building a frontend, and doing all the heavy lifting of infrastructure, persistence, and authentication for you. They also recently introduced serverless functions, which offers a Deno runtime to develop and run serverless applications on. (Speaking of Deno and serverless, have you seen Deno Deploy?) I personally like them because I agree with their focus on building on top of existing open-source projects such as their core offering of building on top of PostgREST to power their backend, as well as a very generous free tier.

The big problem with their serverless offering, as is the case with many serverless providers, is that the only runtime available is a JavaScript one, in this case via Deno. That means no .Net. We'd have to go to one of the big three cloud providers to find .Net serverless runtimes. But we want to support companies that champion and practice open-source software themselves. Your next thought would be to use Fable to transpile our F# into JavaScript. That would be a fine solution, but it suffers a similar problem to Typescript, in that all of the types are erased at runtime. This can become especially problematic when working with 3rd party libraries that were written with JavaScript's flexibility in mind and then finding unexpected types and properties on various objects that the fable compiler had no possibility of knowing about.

Another issue with using Fable is that you're no longer writing .Net code. What I mean by that is that you as the developer need to be familiar with the semantics of Javascript and how the language expects to be used with its constructs. For example, when using the .Net DateTime functions, they transpile to a combination of Javascript Date functions, as well as using functions from a fable runtime library. But the code itself looks like any old .Net code. This can lead to unexpected behavior. Additionally, we want to make use of the extensive NuGet catalog of libraries. There are numerous libraries there that provide excellent functionality. Of course there's also a plethora of Javscript libraries on NPM, but to use them from fable would require custom bindings. This leaves one option to enable the use of actual F# with the .Net runtime on Supabase edge functions: WebAssembly

WebAssembly

I'm not going to go into the specifics of what WebAssembly (WASM) is, as there are plenty of articles about that. The part that's relevant for us is that it's a compilation target meant to accompany Javascript for (usually) computationally heavy tasks. Previously, C# Blazor has made extensive use of WASM to bring compiled C# code to the browser for interactive web sites. But that was restricted to the specific Blazor framework.

With the recent release of .Net 7, there exists a new runtime identifier, browser-wasm, that allows compilation to target WASM. Despite it's name, it can be used from the browser, as well as Node.js and Deno. This is the key that allows us to compile arbitrary C# and F# code to be called from any Javascript program, as if it were any other library written for Javascript. The major issue with this runtime target is that it can only be used from a C# project AS USUAL BECAUSE MICROSOFT ALWAYS CHAMPIONS C# AND NEVER F# (sorry I'm done ranting). The specific reason in this case is that the toolchain makes use of the new C# source generators, and generates C# partial classes, which don't exist in F#. But as is usual in these cases, we can simply create a C# project to serve as the entry point, and then write all of our project in a separate F# project that is referenced by the entry project.

Show me the code

All code can be found either on my Codeberg here, or my GitHub here

Before we get into the code, you'll need a few prerequisites: .Net 7, the Supabase CLI, and the dotnet-wasm workload which you can install with dotnet workload install wasm-tools. From here, you're going to need a few things. To start off, create a new supabase edge function. You can follow the excellent guide here. Once you have the basic hello world edge function running we can leave that as is, and create a new .Net 7 console project in C#. Now, before we add any logic to the C# project, or create and F# project, we first need to modify the .csproj file a bit. Within the first property group of the .csproj file, add the following properties:

<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
<WasmMainJSPath>./relative/path/to/supabase/function/index.ts</WasmMainJSPath>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

These are required for the wasm-tools workload to correctly generate the necessary boilerplate to connect the project to the WASM runtime. After this step, you can create a new F# project that will hold all the logic and reference it from the C# project as per usual. The C# console app can still be run the normal way via dotnet run for local testing.

The actual interesting part of connecting .NET with Deno requires the use of a few attributes. The C# entry project needs to be a partial class as previously mentioned, and at least one method that you wish to expose via the WASM interop to Deno.

using System.Threading.Tasks;
using System.Runtime.InteropServices.JavaScript;

public partial class Program
{
    [JSExport]
    internal static async Task<string> Say(string name)
    {
        var hello = await Task.FromResult($"hello {name}");

        return $"{hello}";
    }
}

The JSExport attribute is the key that allows it to be called from Deno. Also note that it is an async/await function returning a Task<string>. It can also be a regular non-async function, but to do anything interesting these days, we want it to be async.

Now to actually use this function from Deno, we need to adjust the code within the supabase edge function entry point. Add the following:

import { dotnet } from './path/to/dotnet/build/output/AppBundle/dotnet.js'

const { setModuleImports, getAssemblyExports, getConfig } = await dotnet
    .withDiagnosticTracing(false)
    .create();

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);


// Inside the HTTP handler of the deno web server from the template, or whatever other javascript/typescript code you want

const hello = await exports.Program.Say("person");

// do something with hello

The key line being the call to await exports.Program.Say("person"). You'll note the use of the await keyword. Here the .NET WASM tools automatically convert the .NET task into a promise object for consumption from Deno. This process is known as marshaling. The exports object from the config setup also exposes a list of Class names that are exposed via the JSExport attribute. In our case we have a class name Program that has a method called Say.

With these things in place, you should be able to build you're .NET code, and run it from Deno via WASM. Go ahead and do a simple dotnet build, and then navigate to the build output directory, usually something like: ./projectRoot/src/ProjectName/bin/Debug/net7.0/browser-wasm. Within here you'll see the usual build output, but also an AppBundle directory. That's the one we want. So navigate into the AppBundle directory, and you should see the index.ts file that was referenced inside the .csproj, and a bunch of other files. From here you should be able to run deno run --allow-read index.ts and see you're output from the dotnet binary, but compiled to WASM and called by Deno.

But what about F#

Now that all the wiring is complete, throwing F# into the mix is easy. Nothing special needs to be done, expect for write F# code in an F# project and reference it from the C# project as if it were any normal .NET project. But this us to take full advantage of everything we love about F#, from the functional paradigm, to fancy language features and syntax, to all the various cool F# specific NuGet packages that exist. For this small demo, I'm using the excellent Pholly package, which is an F# wrapper around Polly.

So our F# project is going to setup a random dice roll, and continue rolling dice until either a 5 or 6 are rolled:

let rollDice (logger: string -> unit) =
    task {
        let mutable rolls: int list = []
        let random = Random((DateTime.UtcNow.Ticks % (Int32.MaxValue |> int64)) |> int32)
        let rollDice () =
            let roll = random.Next(1,7)
            rolls <- rolls @ [roll]
            roll
        let isSuccessfulDiceRoll diceRoll = if diceRoll >= 5 then diceRoll |> Ok else "Out of range" |> Error

        let retryPolicy = Policy.retry [
            retry (upto 10<times>)
            beforeEachRetry (fun _ retryAttempt _ -> $"Retrying attempt %d{retryAttempt}" |> logger)
        ]

        match (rollDice >> isSuccessfulDiceRoll) |> retryPolicy with
        | Ok value -> $"Success - returned %d{value}" |> logger
        | Error error -> $"ERROR: %s{error}" |> logger

        return rolls |> List.toArray
    }

There's nothing special going on here, except that it proves that we're using a NuGet library, which will also be callable from WASM and Deno. Of note is that we're using the task builder to interop with the .NET Tasks, and we're accepting a parameter is supposed to be a logging function. We're then returning a list of rolled numbers until we get the desired outcome, and converting it to an array. This is done for the C# interop as F# lists are not C# lists, however arrays are the same structure under the hood.

Now within the C# project, we can add another method to export, which in turn calls the F# function:

// Add this using:
using Microsoft.FSharp.Core;

[JSExport]
public static async Task RollDice()
{
    var logger = FuncConvert.FromAction(new Action<string>(Log));
    var rolls = await StrangePlaces.Library.rollDice(logger);

    SetDiceRolls(rolls);
}

And then withing the index.ts, we can call this method just like the other one: await exports.Program.RollDice();. You'll note a few things are happening here beyond just calling the F# function. First, we're converting some Log function to an F# func object, this is done for C# <-> F# interop, and then we're calling a SetDiceRolls method with the result. The reason I'm using a setter here is purely to show that we can also import function from the javascript/typescript file, and have our F# code compiled to WASM call into the host runtime. So to make this possible, we need to use the JSImport attribute in the C# code like this:

[JSImport("log", "index.ts")]
internal static partial void Log(string stuff);
    
[JSImport("setDiceRolls", "index.ts")]
internal static partial void SetDiceRolls([JSMarshalAs<JSType.Array<JSType.Number>>]int[] rolls);

Here, two methods are declared with the JSImport attribute, but are not given any bodies. They are also marked as partial, so that the toolchain can generate the appropriate glue code during compilation. The actual logic of these methods is declared over in index.ts

export function log (text: string) {
  console.log(text)
}

let diceRolls: Number[] = [];
export function setDiceRolls(rolls: Number []) {
  console.log('setting rolls')
  diceRolls = rolls
}

setModuleImports("index.ts", {
    log: log,
    setDiceRolls: setDiceRolls
})

Of note is that you need to export the functions, and then also call the setModuleImports function that we've imported from the dotnet.js file. The first parameter tells the toolchain in which file to find the functions, and then defined an object mapping to the functions as references. You'll also note the use of the JSMarshalAs<T> attribute. This tells the toolchain how to convert the various .NET types to javascript types. More information on that here. Also, the reason that the JS console.log is being imported highlights a caveat of this whole WASM interop thing. We can't use printfn or Console.WriteLine. They disappear into the ether, so we have to import the ability to log to the console from the deno runtime. After all this is done, test it out again by building the .NET project, and running the output index.ts in the build output directory with Deno as before.

Deployment to Supabase

Getting it all deployed to Supabase is relatively simple, with one major consideration. Supabase edge function don't have access to a file system, but the build artifacts require reading files from disk in order to work. However, Supabase does offer cloud storage. This is where we can "deploy" all of the compiled .NET files along with the WASM support files to. Essentially, upload all files in the build output AppBundle directory to a single bucket. An important setting is that the bucket needs to be publicly available (let me know if this is wrong).

SupabaseStorage.png

You then need to the publicly accessible URL of the dotnet.js file from your bucket. It looks something like this: https://bucket-id.supabase.co/storage/v1/object/public/build/dotnet.js. Take this URL, and replace the import of the dotnet object in your edge function with this URL: import { dotnet } from './path/to/dotnet/build/output/AppBundle/dotnet.js' becomes import { dotnet } from 'https://bucket-id.supabase.co/storage/v1/object/public/build/dotnet.js'. After this, you can deploy the edge function via the Supabase CLI by following the documentation here. From here you should be able to call the deployed edge function using your Supabase anon key. There should be instructions on the Edge Functions dashboard within Supabase for this. If all works as expected, you should then see a list of attempted dice rolls in the HTTP response, as well as the edge functions logs.

After all is said and done, we can use CURL to test the new edge function. In the details tab of the deployed edge function, you'll find a convenient test curl command. It will look something like this:

sampleInvocation.jpg

There you can see that it continually retried the dice throw until it got a 5 or 6.

Closing thoughts

Please for the love of all that good in this world, DON'T EVER DO THIS. I can't think of any use case where the combination of .NET compiled to WASM running on Deno is ever the right tool for the right job. There are so many awkward abstractions made, which add immense complexity and cause a significant loss of performance. In the vast number of cases, you should be able to freely choose the runtime stack in a cloud application, and even if you're required to use something like Supabase AND really want to use F# (who doesn't) then please use Fable instead. If you're in the browser, then you have projects like Blazor and Bolero which deliver this interop in a much cleaner way. The only usecase I can think of for this would be if you're in a constrained environment, such as Supabase, but you also have a massive legacy algorithm implemented in F# that you need to continue using and it isn't feasible to port it over to Typescript. In that very narrow usecase, I could see potential for this solution.

So why did I explore this topic? Simply out of curiosity and I thought it would be hilarious to be able to get this to work. While I was researching how to accomplish this, I learned a lot about Supabse and discovered Deno Deploy, which I will definitely be keeping an eye on. Have fun everybody :)