Interop between WASM'd F# and JavaScript in .NET 6 and .NET 7

At Dark, we compile our F# backend to WebAssembly for use within our editor. (specifically, we use it to support "analysis" - when your Dark code runs within the editor rather than against Dark's backend/infra).

The official documentation around IO between WASM'd .NET code and JS is very geared towards folks using the visual components of Blazor, usually with C#. We don't do that - our backend is fully in F#, and we want to use our wasm'd backend just like a library. [1] Our basic workflow is that you edit some code in Dark's Editor, and then we evaluate that code client-side quickly by calling the wasm'd code from JS - the .NET code then 'returns' the analysis results. We then use these results to allow the Dark developer to debug their program.

We've had to work around these limitations in technology and documentation - here's what we've come up with.

Calling a wasm'd .NET 6 function from JS

This is when we send the 'analysis request'

First, expose a static method in F#, to be called from JS:

type EvalWorker =
  static member OnMessage(input : string) : Task<unit> =
    ... 

Then in JS land, create a binding to that function, and then call it:

// Create the binding to the static F# function
const messageHandler = Module.mono_bind_static_method("[Wasm]Wasm.Analysis.EvalWorker:OnMessage");

// call the function - the results will asyncronously post back messageHandler(msg.data); β€Œ

Calling a JS function from wasm'd .NET 6

This is when we return the results from the request.

β€Œ

First, define a function in JS land to call from F#. In our case, we're waiting for a .postMessage to come through, so we just wait with a onmessage:

self.onmessage = (response) => { ... console.log(response) }

The setup to call self.postMessage from wasm'd F# is a bit tricky:

type GetGlobalObjectDelegate = delegate of string -> obj

type InvokeDelegate = delegate of m : string * [<ParamArray>] ps : obj [] -> obj
    
type EvalWorker =
    static member GetGlobalObject(_globalObjectName : string) : unit = () 
    static member selfDelegate =
      let typ =
        let sourceAssembly : Assembly = Assembly.Load "System.Private.Runtime.InteropServices.JavaScript"
      sourceAssembly.GetType "System.Runtime.InteropServices.JavaScript.Runtime"
      let method = typ.GetMethod(nameof (EvalWorker.GetGlobalObject))
      let delegate_ = method.CreateDelegate<GetGlobalObjectDelegate>()
      let target = delegate_.Invoke("self")
      let typ = target.GetType()
      let invokeMethod = typ.GetMethod("Invoke") 
      System.Delegate.CreateDelegate(typeof<InvokeDelegate>, target, invokeMethod) :?> InvokeDelegate
    
    static member postMessage(message : string) : unit = 
      // this is where the `self.postMessage` actually occurs.
      let (_ : obj) = EvalWorker.selfDelegate.Invoke("postMessage", message)
      () 

This uses a Runtime object in a private/internal/experimental .NET assembly System.Private.Runtime.InteropServices.JavaScript.

Calling a wasm'd .NET 7 function from JS

This is when we send the 'analysis request'. This is much easier in .NET 7.

β€ŒIn .NET land, expose the function as invokable by JS:

type EvalWorker =
  [<Microsoft.JSInterop.JSInvokable>]
  static member OnMessage(input : string) : Task<unit> =
    ... 

Then call it - nice and easy!

DotNet.invokeMethod("Wasm", "OnMessage", 'message')

Calling a JS function from wasm'd .NET 7

This is when we return the results from the request.

The System.Private.Runtime.InteropServices.JavaScript assembly that we used in .NET 6 is no longer available - it seems that the equivalent functionality exists within the Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime type, and its static Instance field:

module WasmHelpers =
  open System.Reflection // this gets us ahold of `this`/`self`
  let getJsRuntimeThis () : IJSInProcessRuntime =
    let assemblyName = "Microsoft.AspNetCore.Components.WebAssembly"
    let typeName = "Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime"
    let assembly = Assembly.Load assemblyName
    let jsRuntimeType = assembly.GetType typeName
    let jsRuntimeTypeInstance =
      let flags = BindingFlags.NonPublic ||| BindingFlags.Static 
      jsRuntimeType.GetField("Instance", flags) 
    jsRuntimeTypeInstance.GetValue(null) :?> IJSInProcessRuntime
    
type EvalWorker = 
  ...
  static member postMessage(message : string) : unit =
    let jsRuntimeThis = WasmHelpers.getJsRuntimeThis ()
    let response = jsRuntimeThis.Invoke($"maybeCallback", message)
    () 
  ... 

[1] another wrinkle: some of the computation is non-trivial, so we need to run it in the background, via a web worker. For now, this post doesn't include details of how we support the WebWorker aspect of our usage - if you're curious, let me know and I'll expand the post.