Skip to main content

Fable.Electron.Remoting

The package that provides the Fable.Remoting experience for electron IPC.

This is heavily based off Fable.Remoting, using the same dependency for the reflection helpers - Fable.SimpleJson.

DotNet RPC vs JS IPC

Unlike Fable.Remoting, there is no communication between different languages/frameworks/runtimes. All processes in electron run on a javascript/node runtime.

This means we do not have to involve the serialisation/deserialisation steps of Fable.Remoting, and we are solely concerned with abstracting away the IPC boilerplate event handling/sending.

This also means we do not have to follow the same architecture of separating our API types into a separate Shared project.

The decision to do so is a matter of style/developer preference.

Main - Preload - Renderer

note

Please see the docs to understand the concepts and reasoning behind the Main and Renderer process, and the use of the Preload scripts.

The Main and Renderer processes have their own Fable.Electron.Remoting module; the Preload module pertains to the bridge that exposes the relevant API to the Renderer process.

Config

Each Process implements its own config, to allow each process to have exclusive fields.

At the moment, only the Main process utilises this.

/// <summary>
/// Configuration of Proxy routers
/// </summary>
type RemotingConfig =
{
/// <summary>
/// Used in the ApiNameMap to create the property name on the window that the proxy
/// is exposed through.
/// </summary>
ApiNameBase: string
/// <summary>
/// A map that takes the <c>ApiNameBase</c> and the <c>TypeName</c> of the implementation to
/// create the name of the property on the window that the proxy is exposed through.
/// </summary>
ApiNameMap: string -> string -> string
/// <summary>
/// A function that takes the <c>Type</c> name and the <c>Field</c> name to create a channel
/// name which messages are proxied through.
/// </summary>
ChannelNameMap: string -> string -> string
}

We provide uniform module functions to initialise and modify the configs.

For example only the main is provided - with the additional functions for its unique field.

Main - Remoting module
[<Erase>]
module Remoting =
let init =
{ ApiNameBase = "FABLE_REMOTING"
ApiNameMap = fun baseName typeName -> sprintf $"%s{baseName}_{typeName}"
ChannelNameMap = fun typeName fieldName -> sprintf $"%s{typeName}:%s{fieldName}"
Windows = [||] }

let withApiNameBase apiName config = { config with ApiNameBase = apiName }
let withApiNameMap func config = { config with ApiNameMap = func }
let withChannelNameMap func config = { config with ChannelNameMap = func }

/// <summary>
/// Adds a window to the array of windows for a config.
/// </summary>
/// <param name="window"><c>BrowserWindow</c></param>
/// <param name="config"></param>
let withWindow window config =
{ config with
Windows = config.Windows |> Array.insertAt 0 window }

let setWindows windows config = { config with Windows = windows }

Reflection

The entry point for the build functions MUST be an inline function/method so that we can utilise compiled reflection by Fable.

note

These methods are attached to a type named Remoting

    /// <summary>
/// Builds the receiver for the two way <c>Main &lt;-> Renderer</c> IPC proxy router.
/// </summary>
/// <param name="implementation">The record of functions which respond to received messages.</param>
/// <param name="config"></param>
static member inline buildHandler<'t> (implementation: 't) (config: RemotingConfig) : unit =
Remoting.buildReceiverProxy (config, implementation, typeof<'t>)

/// <summary>
/// Builds a client for <c>Main -> Renderer</c> IPC proxy router.
/// </summary>
/// <param name="config"></param>
static member inline buildClient<'T>(config: RemotingConfig) : 'T =
if config.Windows.Length = 0 then
console.error
"Building a Main -> Renderer remoting client \
with no browser windows will do nothing or cause errors. \
Please add windows to the config before building the proxy."

Remoting.buildSenderProxy (config, typeof<'T>)

Implications for Usage

You must ensure that each of the processes/scripts refer to their individual module while building the proxies, as their internal implementations are different and incompatible.

We essentially build an object of functions with the same names as the fields of the record when we are building a client. The functions send whatever arguments are passed through the channel determined by the field name and the type name. The handlers create an abstraction over our API/handler implementations, which handle the message receival, and reroute the arguments to our implementation based on the field name.

We can rely on the safety of FCS, and dispose of the checks and validation of argument counts etc, as these are not openly accessible APIs outside of our application.

Main

important

This documentation process can laden the source code with tags.

This is the cost of binding the source to the documentation so it is always up to date.

Only the Main One-Way will have a step by step explanation.

One-Way Main-Renderer

The Client build process receives the resolved type and config from the inlined entry point, and uses a Fable.SimpleJson utility function to get a DU for its type.

    static member buildSenderProxy(config: RemotingConfig, resolvedType: Type) =
let schemaType = createTypeInfo resolvedType

We pattern match and ensure the type is TypeInfo.Record; the field value is a function which, on evaluation, provides us details of the fields.

        match schemaType with
| TypeInfo.Record getFields ->
let fields, recordType = getFields ()
let makeChannelName = config.ChannelNameMap
let windows = config.Windows

We then map the fields, and check:

  • That their types are functions
  • That their functions return unit (since it is a one way communication).
            let recordFields =
[| for field in fields do
let returnType = Proxy.getReturnType field.PropertyInfo.PropertyType

match createTypeInfo returnType with
| TypeInfo.Unit -> ()
| _ ->
failwith
$"Cannot build proxy. Expected type %s{resolvedType.FullName} to \
be a valid protocol definition which is a record of callback-functions."

match field.FieldType with
| TypeInfo.Func _ -> ()
| _ ->
failwith
$"Cannot build proxy. Expected type %s{resolvedType.FullName} to \
be a valid protocol definition which is a record of functions."

let channelName = makeChannelName recordType.Name field.FieldName

let func =
emitJsExpr
(windows, channelName)
"(...args) => { return $0.forEach((window) => window.webContents.send($1, ...args)) }"
|> box

func |]

The value (or return value) is the replacement function that will fill the field.

In the case of the Main Client, it follows the signature prescribed by the IPC documentation - just mapped to all the browser windows that were passed to the config.

Source JS one way IPC
{
click: () => mainWindow.webContents.send('update-counter', 1),
label: 'Increment'
},
important

Since we have FCS to provide type safety, we simply collect ALL arguments passed via ...args, and then spread the array into the send call using ...args again.

This will allows the receiving function to access the parameters in standard curried form, mirroring the input function signature.

Two-Way

Code
    [<EditorBrowsable(EditorBrowsableState.Never)>]
static member buildReceiverProxy(config: RemotingConfig, impl, resolvedType: Type) =
let schemaType = createTypeInfo resolvedType

match schemaType with
| TypeInfo.Record getFields ->
let fields, recordType = getFields ()
let makeChannelName = config.ChannelNameMap

for field in fields do
let returnType =
Proxy.getReturnType field.PropertyInfo.PropertyType |> createTypeInfo
// Check if we need to await the implementation call
let isPromiseOrAsyncReturn =
match returnType with
| TypeInfo.Async _ -> true
| TypeInfo.Promise _ -> true
| _ -> false
// Check if we pass the first natural argument of the ipc arguments (the IpcMainEvent)
let handlesIpcMainEvent =
field.FieldType
|> function
| TypeInfo.Func _ ->
field.PropertyInfo.PropertyType
|> FSharpType.GetFunctionElements
|> fst
|> _.Name
|> (=) typeof<IpcMainEvent>.Name
| _ -> false

let channelName = makeChannelName recordType.Name field.FieldName

match isPromiseOrAsyncReturn, handlesIpcMainEvent with
| true, true ->
// If async and handles the IpcEvent, then we pass the ipc event
// (index 0) and then spread the args and await the promise
ipcMain.handle (
channelName,
emitJsExpr
(impl.Item(field.FieldName))
// The first arg passed by the renderer will be the nonsensical filler value.
"async (...args) => { return await $0(args[0], ...(args[1].slice(1))) }"
)
| false, true ->
// If not async and handles the IpcEvent, then we pass the ipc event
// (index 0) and then spread the args, but there is no need to await the promise
ipcMain.handle (
channelName,
emitJsExpr
(impl.Item(field.FieldName))
// The first arg passed by the renderer will be the nonsensical filler value.
"async (...args) => { return $0(args[0], ...(args[1].slice(1))) }"
)
| true, false ->
// If we dont handle the IpcMainEvent, then we do not pass it to the proxy (index 0).
ipcMain.handle (
channelName,
emitJsExpr (impl.Item(field.FieldName)) "async (...args) => { return await $0(...(args[1])) }"
)
| false, false ->
ipcMain.handle (
channelName,
emitJsExpr (impl.Item(field.FieldName)) "async (...args) => { return $0(...(args[1])) }"
)
| _ ->
failwithf
$"Cannot build proxy. Expected type %s{resolvedType.FullName} to be \
a valid protocol definition which is a record of functions"

For the Two-Way Main Handler, we have to account for an additional problem.

The type signature of the handler, is not the same as the client signature, in the electron-js implementation. The handler also receives the renderer process that the message was sent from - of type IpcMainEvent.

To mitigate this, we use a lambda as a middle man. We either emit a lambda that passes all the arguments to the record field function, or only the slice of arguments from index 1.

The Main handler checks the type of the first parameter. If it IS of type IpcMainEvent, then we can safely just pass all the arguments. If it is not, then we pass the slice from index 1.

The Main handler no longer has to manually send the renderer process the message back (as this is abstracted away by electrons handle) so there is no longer a necessity to handle the IpcMainEvent (ie don't feel bad about disposing of the poor thing).

Renderer & Preload

Hopefully, the above explanation of the Main remoting handler/client provides the context necessary to understand the Preload and Renderer implementations.

If you have further questions, feel free to raise an issue and we can answer/explain further :)