Deployment
A server-rendered app compiles one set of component files twice: on .NET for the server, and with Fable for the browser. This page assembles the counter from Getting started into three projects, an ASP.NET Core server that renders the page and serves the client bundle, and a Fable client that hydrates it.
For generated HTML and browser startup from a shared F# declaration, see Page declarations. The example below shows the lower-level rendering and hydration calls.
Projects
Shared/ Shared.fsproj Counter.fs, Shared.fs
Server/ Server.fsproj ServerPage.fs, Program.fs, wwwroot/main.js (built by the client)
Client/ Client.fsproj Main.fs, main.js, vite.config.js, package.json
| Project | Compiled by | References | Holds |
|---|---|---|---|
Shared | .NET and Fable | Ranvier.View, Ranvier.Wire | Counter.fs from Getting started and Shared.fs, the codec from State transfer. |
Server | .NET, Microsoft.NET.Sdk.Web | Shared | The page function and the endpoint. |
Client | Fable, then Vite | Shared, Ranvier.View.Dom | The hydration entry point. |
The Ranvier.View package holds Server and every template type; Ranvier.View.Dom holds Mounting and Hydration, which run under Fable. Shared.fs is the state-transfer sample under module Shared with open Counter, and exports counterProps and stateId.
<!-- Shared/Shared.fsproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Counter.fs" />
<Compile Include="Shared.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Ranvier.View" Version="0.1.0-alpha.1" />
<PackageReference Include="Ranvier.Wire" Version="0.1.0-preview.1" />
<PackageReference Update="FSharp.Core" Version="10.1.401" />
</ItemGroup>
</Project>
<!-- Server/Server.fsproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="ServerPage.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Shared/Shared.fsproj" />
<PackageReference Update="FSharp.Core" Version="10.1.401" />
</ItemGroup>
</Project>
<!-- Client/Client.fsproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Main.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Shared/Shared.fsproj" />
<PackageReference Include="Ranvier.View.Dom" Version="0.1.0-alpha.1" />
<PackageReference Update="FSharp.Core" Version="10.1.401" />
</ItemGroup>
</Project>
The view packages are on nuget.org; the app's nuget.config adds the temporary Xantham Cloudsmith feed for their Xantham.Fable.Core.TS dependency, as Installation shows.
The page
module ServerPage
open Ranvier.View
open Counter
open Shared
let options = { Server.Options.defaults with Policy = Omit }
let page (props: CounterProps) : string =
let document =
{ Server.DocumentShell.defaults with
Lang = Some "en"
State = [ stateId, counterProps.Encode props ]
Scripts = [ Server.ModuleScript.Src "/main.js" ] }
Server.renderDocument options document counter props
Server.renderDocument options document definition props renders the component as renderPage does and writes the whole document around it:
- the component's head entries inside
<head>, after<meta charset="utf-8">; - the host element,
<div id="app">, holding only the body, so hydration's root marker is its first child; - each state element after the host, outside it;
- each script after the state elements, so the client bundle loads after the body.
Server.DocumentShell field | Default | Writes |
|---|---|---|
Lang | None | The lang attribute of <html>. |
HostId | "app" | The id of the host element. |
State | [] | One stateScript element per id and JSON pair. |
Stylesheets | [] | One <link rel="stylesheet"> per URL, after the head entries. |
Scripts | [] | Server.ModuleScript.Src src writes <script type="module" src>; Server.ModuleScript.Inline text writes the text inside <script type="module">. |
Nonce | None | The nonce attribute of every script, state and stylesheet element, for a Content-Security-Policy of script-src or style-src 'nonce-...'. |
HtmlAttributes, BodyAttributes | [] | Further attributes of <html> and <body>. An id among them joins the document's ids. |
Every attribute value is escaped. Script and stylesheet URLs are trusted author URLs, written as given apart from escaping: pass only URLs the application controls. Some null for Lang or Nonce writes nothing, as None does.
renderDocument raises ArgumentException before the render for:
- an id that is null, empty or contains ASCII whitespace (space, tab, line feed, form feed, carriage return);
- an id written twice among the host, the state elements and the
idattributes of<html>and<body>; - a state element whose JSON is null;
- an attribute name that a template rejects, or one that an element repeats;
- a nonce that is empty or holds a character other than ASCII letters, digits and
+ / = - _; - inline module text containing
</scriptin any letter case, or<!--.
A failed render raises as renderPage does. Server.renderDocumentTo options document definition props write passes the same document to write in segments, after a successful render.
The endpoint
Server/Program.fs serves the page at / and the bundle from wwwroot:
module Program
open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open ServerPage
[<EntryPoint>]
let main args =
let app = WebApplication.CreateBuilder(args).Build ()
app.UseStaticFiles () |> ignore
app.MapGet ("/", Func<IResult> (fun () -> Results.Content (page { Start = 0; Label = "Clicks" }, "text/html")))
|> ignore
app.Run ()
0
Each Server.render, renderPage and renderDocument call creates and disposes its own graph, and concurrent requests each keep their own reactive state. State shared between requests (a cache, a configuration signal) is read as it is; see Rendering.
The client
Client/Main.fs is the hydration entry point under module Main, with open Counter and open Shared. main.js calls it:
// Client/main.js
import { start } from "./generated/Main.js";
start();
Vite bundles main.js into the server's wwwroot under a fixed name:
// Client/vite.config.js
export default {
build: {
outDir: "../Server/wwwroot",
emptyOutDir: true,
rollupOptions: {
input: "main.js",
output: { entryFileNames: "main.js" },
},
},
};
Build and run
cd Client
npm install --save-dev vite
dotnet fable Client.fsproj --outDir generated
npx vite build
cd ../Server
dotnet run
GET / returns the server-rendered counter with its props:
<!doctype html><html lang="en"><head><meta charset="utf-8"></head><body><div id="app"><!--[r--><div class="counter"><span class="label">Clicks</span><button type="button">0</button></div><!--]--></div><script type="application/json" id="counter-props">{"start":0,"label":"Clicks"}</script><script type="module" src="/main.js"></script></body></html>
/main.js hydrates the counter: the button the server wrote stays in the document and counts clicks.
Choosing a shape
| Shape | Use it when |
|---|---|
| Server render and hydrate | The first paint should come from the server, and the page then runs in the browser. |
| Client mount only | The page needs no server HTML: mount with Mounting.mount into an empty host. |
| Server render only | The page is static: render with Server.renderDocument and no Scripts. |
| Blazor Server | The UI runs on the server over a circuit, with Ranvier holding the circuit's state. |