Fable hybrid with Fable.Remoting and a normal SPA client
tldr: In this post we will see how to use Fable and Fable.Remoting to generate strongly typed functions that can be used to communicate with the backend in an RPC style. The client application will be a "normal" SPA (React, Vue, Svelte, vanilla JS/TS etc.) with just some parts being F# generated TypeScript.
Let's start with a little image showing the components to create:
- The Server is just a normal F# server, using ASP.NET Core / Fable.Remoting, implementing a simple counter.
- The F# Shared code project contains code that will be shared between the Fable Client and F# Server projects and also contains the abstract API definition. On the server this code will be referenced as is.
- The F# Fable client project contains the code that will be translated to TypeScript and used in the React application. The shared project will also be translated, as it is referenced and used by the F# Fable Client module.
- The "Native Client" project is in our case a normal TypeScript/React application that uses the generated TypeScript code from the F# Fable client. The native client uses the generated code to communicate with the server in a strongly typed fashion.
Setting up the projects
After executing the following commands we should have set up the 4 projects with correct dependencies:
npm create vite@latest Client -- --template react-ts
cd Client
npm install prettier --save-dev
npm install @tanstack/react-query@beta
cd ..
dotnet new sln --name fable-hybrid
dotnet new classlib -lang F# --name FableClient
dotnet new console -lang F# --name Server
dotnet new classlib -lang F# --name Shared
dotnet sln add ./Server/Server.fsproj
dotnet sln add ./Shared/Shared.fsproj
dotnet sln add ./FableClient/FableClient.fsproj
dotnet add ./Server/Server.fsproj reference ./Shared/Shared.fsproj
dotnet add ./FableClient/FableClient.fsproj reference ./Shared/Shared.fsproj
dotnet add ./FableClient/FableClient.fsproj package Fable.Remoting.Client
dotnet add ./Server/Server.fsproj package Fable.Remoting.AspNetCore
dotnet add ./Server/Server.fsproj package Microsoft.AspNetCore.SpaServices.Extensions
Implement shared API definition
In the shared project code that should be available in the server and in the client can be added. We will just define a simple API with two functions, one to get some server side state and one to modify it:
module Shared
type Server =
{ count: unit -> Async<int>
increment: unit -> Async<unit> }
Implement the server
In ./Server/Server.fsproj
set the SDK property to Microsoft.NET.Sdk.Web
so that ASP.NET core is implicitly included:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\Shared.fsproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Remoting.AspNetCore" Version="2.33.0" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.9" />
</ItemGroup>
</Project>
Then in Program.fs
we provide the implementation for the Api type, set up Fable Remoting and use the Spa middleware to serve the client files via our server.
open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
open Fable.Remoting.Server
open Fable.Remoting.AspNetCore
open Microsoft.AspNetCore.Hosting
open Server.GameServer
[<EntryPoint>]
let main args =
let mutable count = 0
let server: Server =
{ count = fun () -> async { return count }
increment = fun () -> async { count <- count + 1 } }
let builder = WebApplication.CreateBuilder(args)
let app = builder.Build()
let webApp = Remoting.createApi () |> Remoting.fromValue server
app.UseRemoting webApp
app.UseStaticFiles() |> ignore
app.UseSpa(fun o -> o.UseProxyToSpaDevelopmentServer("http://localhost:5173/"))
app.Run()
0 // Exit code
For production usage the client should be build upfront and served from the file system. For simplicity here we will just run the frontend with the vite dev webserver and proxy requests coming to our backend to this dev server (if they are not handled previously in the ASP.NET core middleware pipeline).
Implement the FableClient and generate TypeScript code
Now in the FableClient project we will instantiate an API proxy with Fable.Remoting. Additionally we will retype all available API functions and convert them to promises so that they can be used in normal JavaScript code.
module FableClient
open Shared
open Fable.Remoting.Client
open Fable.Core
let server: Server =
Remoting.createApi ()
|> Remoting.buildProxy<Server>
let count () = server.count () |> Async.StartAsPromise
let increment () = server.increment () |> Async.StartAsPromise
As our server count and server increment functions on the server proxy object are asynchronous (they have the type unit -> Async<int>
and unit -> Async<unit>
), we need to use the Async.StartAsPromise
function to convert them to a Promise that can be used in normal JavaScript code. Also this code will add TypeScript annotations to the functions, which somehow does not happen on the proxy object if we would try to use it directly.
All F# files must be explicitly added in the corresponding fsproj file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Api.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\Shared.fsproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Remoting.Client" Version="7.25.0" />
</ItemGroup>
</Project>
In the FableClient project we need to add Fable as a dotnet tool, and run it to compile the F# code to TypeScript:
cd FableClient
dotnet new tool-manifest
dotnet tool install fable
dotnet fable watch --lang ts -o ../Client/src/api/
When invoking dotnet fable
we will instruct fable to generate the client code. Here I start fable in watch mode, so any changes to the F# code would automatically trigger recompilation.
The language is specified via the --lang
argument and the -o
argument specifies that we want the generated code to be placed in our native Client project.
We don't necessarily need to split the Fable client and native client, however my experience with having both in the same project was not especially great. Somehow the generated filenames are different when not specifying the output folder. In my case the generated file for the Api was Api.fs.ts
(lying next to Api.fs
). When importing this file using the name without the ts prefix I needed to import Api.fs
which then really imported the F# file. Importing Api.fs.js
seems to work, but its akward as this file does not exist in the project. So, it works, but I did not really like it.
After runnign dotnet fable
the following code that was generated:
import { Remoting_buildProxy_64DC51C } from "./fable_modules/Fable.Remoting.Client.7.25.0/Remoting.fs.js"
import { RemotingModule_createApi } from "./fable_modules/Fable.Remoting.Client.7.25.0/Remoting.fs.js"
import { Server, Server_$reflection } from "./Shared/Library.js"
import { startAsPromise } from "./fable_modules/fable-library-ts/Async.js"
import { int32 } from "./fable_modules/fable-library-ts/Int32.js"
export const server: Server = Remoting_buildProxy_64DC51C<Server>(
RemotingModule_createApi(),
Server_$reflection()
)
export function count(): Promise<int32> {
return startAsPromise(server.count())
}
export function increment(): Promise<void> {
return startAsPromise(server.increment())
}
These functions have proper types and can be easily consumed in the rest of our client application.
Implement the native client
Let's prepare our native client so that we can use the state/query/mutation management library react-query
. We will also make use of React.Suspense
and ErrorBoundaries
to enable suspensfull data loading and error handling. In this way in our component we will only deal with the "happy path" (data was successfuylly loaded), and place loading and error handling one step up in the component tree:
import React from "react"
import ReactDOM from "react-dom/client"
import { App } from "./App.tsx"
import "./index.css"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ErrorBoundary } from "react-error-boundary"
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<React.Suspense fallback={<div>Loading...</div>}>
<App />
</React.Suspense>
</ErrorBoundary>
</QueryClientProvider>
</React.StrictMode>
)
Then in our App.tsx
we can use useSuspenseQuery
and useMutation
and combine them with our generated increment
and count
functions to fetch the data and mutate it:
import "./App.css"
import { useMutation, useSuspenseQuery } from "@tanstack/react-query"
import { increment, count } from "./api/Api"
export function App() {
// data has type number here
const { data, refetch } = useSuspenseQuery({
queryKey: ["count"],
queryFn: count,
})
const incrementMutation = useMutation({
mutationFn: increment,
onSuccess: () => refetch(),
})
return (
<div className="card">
<button onClick={() => incrementMutation.mutate()}>
count is {data}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
)
}
Run and test
Now its time to start our server and client
cd Server
dotnet watch run --project .\Server\Server.fsproj
and in another session
cd Client
npm run dev
Then in a browser you can open http://localhost:5000 where the backend is running, proxying the request to the fronted dev server. The counter button should be shown and should work now.
Closing thoughts
I think this approach could be used in projects where the frontend folks don't want to write F# or where the F# knowledge is not yet so strong to go the full F# way (i.e. doing with Feliz and Elmish). Also with this approach there is no need to write bindings, but we still have everything strongly typed!