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
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
Mainprocess utilises this.
- Preload
- Main
- Renderer
/// <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
}
/// <summary>
/// Configuration for a Remoting proxy.
/// </summary>
type RemotingConfig =
{
/// <summary>
/// No effect for Main Process. Kept for uniformity.
/// </summary>
ApiNameBase: string
/// <summary>
/// No effect for Main Process. Kept for uniformity.
/// </summary>
ApiNameMap: string -> string -> string
/// <summary>
/// A function that creates the name of the channel that messages are sent over/received from.
/// The first parameter is the name of the type, while the second is the name of the field.
/// </summary>
/// <remarks>Defaults to <code>fun typeName fieldName -> sprintf "%s{typeName}:%s{fieldName}</code></remarks>
ChannelNameMap: string -> string -> string
/// <summary>
/// Required when building a <c>Main -> Renderer</c> Proxy router. The array of windows that the
/// messages are sent to.
/// </summary>
Windows: BrowserWindow array
}
/// <summary>
/// Config for a proxy IPC router.
/// </summary>
type RemotingConfig =
{
/// <summary>
/// An argument to the <c>ApiNameMap</c> that creates the name of the property on the
/// <c>window</c> object that the proxy/api/IPC is exposed through.
/// </summary>
ApiNameBase: string
/// <summary>
/// <c>ApiNameMap</c> takes the <c>ApiNameBase</c> and <c>Type</c> name of the
/// implementation to create the name of the property on the
/// <c>window</c> object that the proxy/api/IPC is exposed through.
/// </summary>
ApiNameMap: string -> string -> string
/// <summary>
/// No effect on Renderer process.
/// </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.
[<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.
These methods are attached to a type named Remoting
- Entry
- Implementation
/// <summary>
/// Builds the receiver for the two way <c>Main <-> 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>)
The implementation methods hide these methods from compatible IDEs using
the System.Component.EditorBrowsable attribute.
The methods CANNOT be private/internal since they are being referenced by publicly available inline methods.
However, we CAN refer to private bindings within the implementation methods.
[<EditorBrowsable(EditorBrowsableState.Never)>]
static member buildSenderProxy(config: RemotingConfig, resolvedType: Type) =
let schemaType = createTypeInfo resolvedType
match schemaType with
| TypeInfo.Record getFields ->
let fields, recordType = getFields ()
let makeChannelName = config.ChannelNameMap
let windows = config.Windows
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 |]
let proxy = FSharpValue.MakeRecord(recordType, recordFields)
unbox proxy
| _ ->
failwithf
$"Cannot build proxy. Expected type %s{resolvedType.FullName} to be \
a valid protocol definition which is a record of functions"
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
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.
{
click: () => mainWindow.webContents.send('update-counter', 1),
label: 'Increment'
},
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 :)