FffStack.Client
0.1.1
dotnet add package FffStack.Client --version 0.1.1
NuGet\Install-Package FffStack.Client -Version 0.1.1
<PackageReference Include="FffStack.Client" Version="0.1.1" />
<PackageVersion Include="FffStack.Client" Version="0.1.1" />
<PackageReference Include="FffStack.Client" />
paket add FffStack.Client --version 0.1.1
#r "nuget: FffStack.Client, 0.1.1"
#:package FffStack.Client@0.1.1
#addin nuget:?package=FffStack.Client&version=0.1.1
#tool nuget:?package=FffStack.Client&version=0.1.1
FFF Stack
F# Fable Full-Stack — a fullstack framework for building web applications in F# on Cloudflare.
Client → F# class-based components + FJSX templates
Server → F# functional DDD + Cloudflare Workers
Database → Cloudflare D1 (SQLite)
Compile → Fable (F# → JavaScript)
Why FFF Stack?
Modern UI frameworks trend toward complexity — hooks, signals, reactive graphs. FFF Stack takes a different path:
- UI = OOP — components are classes with encapsulated state. No prop drilling, no state lifting.
- Domain = FP — server logic is pure functions and DDD workflows (Domain Modeling Made Functional style).
Result<'T, 'E>everywhere. - Templates = separate files —
.fjsxfiles compiled to direct DOM instructions at build time. No virtual DOM. - DDD enforced —
Domain/→Workflows/→Endpoints/withfff checkto validate layer dependencies in CI. - Prototype first —
fff prototype Widgetscaffolds a full working vertical slice (no DB) in seconds; upgrade to D1 when the model stabilises. - Security built-in — CSRF, rate limiting, secure cookies, password hashing, security headers out of the box.
- One language — F# across the full stack, compiled to JavaScript via Fable.
Quick Start
Prerequisites
| Tool | Install |
|---|---|
| .NET 9 SDK | Required for F# compilation |
| Bun | JS runtime + package manager |
| fff CLI | Scaffold + DDD tooling |
| Wrangler | Cloudflare local dev + deploy |
dotnet tool install -g FffStack.Cli
bun install -g wrangler
1 — Scaffold a new project
bunx create-fff-app my-app
cd my-app
bun install
dotnet tool restore # installs Fable 4.24.0 from .config/dotnet-tools.json
2 — Start the dev server
bun run demo # → http://localhost:3000
The dev server runs entirely in Bun — no Cloudflare account, no .NET required. It uses an in-memory mock for the database so you can iterate on the domain model immediately.
To proxy /api/* to a running wrangler dev instance:
WRANGLER_URL=http://localhost:8787 bun run demo
3 — Build your first feature
# Scaffold a full vertical slice (Domain + Workflow + Routes + FJSX) — no DB needed
fff prototype Orders
# When the model is stable, upgrade to D1
fff generate feature Orders
fff generate migration add_orders_table
Try the demo without installing anything
# Docker — no .NET, Bun, or Cloudflare account required
git clone https://github.com/alcogy/fff-stack.git
cd fff-stack
docker compose up # → http://localhost:3000
DDD Project Structure
Every FFF Stack server follows a three-layer architecture inspired by Domain Modeling Made Functional:
src/server/
├── Domain/ # Pure types — no side effects, no IO
│ └── Employees/
│ └── Employee.fs # EmployeeName, WorkEmail (value objects) + Employee (entity)
│
├── Workflows/ # Business logic — orchestrates Domain, accesses DB
│ └── Employees/
│ └── EmployeeWorkflows.fs # getAll, getById, create, delete (with D1)
│
├── Endpoints/ # HTTP layer — translates requests, calls Workflows
│ ├── Employees/
│ │ └── Routes.fs # GET/POST /api/employees, DELETE /api/employees/:id
│ ├── Ws/
│ │ └── Routes.fs # WebSocket endpoint
│ └── Pages/
│ ├── EmployeeList.fjsx # Server-rendered employee list (stat badges + data table)
│ ├── EmployeeNew.fjsx # Add employee form (department select, status radio)
│ ├── Templates.fs # [<ImportAll>] bindings + render helpers
│ └── Routes.fs # GET /, GET /employees/new, POST /employees (PRG)
│
└── Worker.fs # Entry point — registers all routes
Layer rules enforced by fff check:
Domain/— noopen FffStack.Server, no DB calls, no IODomain/<A>/— must not import fromDomain/<B>/(bounded context isolation)Workflows/— noopen App.Server.Endpoints, no HTTP typesEndpoints/— calls Workflows only; direct DB calls trigger a warning
fff CLI
The fff CLI is an F# dotnet tool that scaffolds code, enforces DDD structure, and manages the project.
Install
dotnet tool install -g FffStack.Cli
New project
fff new my-app
cd my-app && bun install && bun run demo
Tip:
bunx create-fff-app my-appdoes the same thing without requiring the fff CLI to be installed.
Prototype a feature (no DB required)
fff prototype Widget # full vertical slice, works immediately with bun dev
Creates Domain stub + in-memory Workflow + API Routes + Form/List FJSX + wires everything in one command. The in-memory store persists per dev-server process — iterate on the domain model without touching a database. When the model stabilises, replace the workflow with D1 queries and add a migration.
Generate code
fff generate feature Orders # Domain + Workflows + Endpoints (3 files, D1)
fff generate model Address # Domain type only
fff generate form Employee # FJSX form from Domain type (reads field types)
fff generate list Employee # FJSX list/table from Domain type
fff generate page Dashboard # Server FJSX page
fff generate component SearchBar # Client component + FJSX
fff generate migration add_tags # SQL migration file (auto-numbered)
fff generate form Employee reads type Employee = { ... } from Domain/ and generates:
✓ Endpoints/Pages/EmployeeForm.fjsx ← inputs inferred from field types
✓ Templates.fs updated ← import + renderEmployeeForm added
Field type inference: email → <input type="email">, body/description → <textarea>, bool → <input type="checkbox">, int/float → <input type="number">, XxxAt/date → <input type="date">, id/createdAt/slug → skipped automatically.
Inspect & validate
fff routes # Colour-coded list of all registered HTTP routes
fff check # Validate DDD layer dependencies + bounded context isolation (CI-ready)
fff context # Bounded context map — dependency graph + shared language terms
fff context --mermaid # Output Mermaid graph definition for docs
fff doctor # Project health: tools, fsproj, env vars, migrations
fff openapi # Generate openapi.yaml from route definitions
fff openapi -o api.yaml # Write to specific path
fff check example:
✗ Domain/Orders/Orders.fs:17 — forbidden import 'open FffStack.Server'
✗ Domain/Users/User.fs:12 — Domain/Users imports Domain/Posts — use Anti-Corruption Layer
2 violation(s) found.
fff context example:
fff context — Bounded Context Map
Contexts (2):
● Employees (3 types)
● Users (2 types)
✓ Domain boundaries are clean — no cross-context dependencies.
Run 'fff context --mermaid' for a Mermaid diagram you can paste into docs.
fff doctor checks:
- .NET SDK, Bun, Wrangler versions
- All
.fsfiles declared inServer.fsproj Env.require*bindings declared inwrangler.toml- Migration file count
- Domain layer dependency violations
Database
fff migrate # Apply pending migrations (local D1)
fff migrate --remote # Apply to production D1
fff migrate:status # Show pending/applied status
FJSX Templates
Templates live in separate .fjsx files compiled to direct DOM instructions at build time:
<div class="counter">
<p>Count: {this.Count}</p>
<button onclick={this.Decrement}>−</button>
<button onclick={this.Increment}>+</button>
</div>
<style>
.counter { display: flex; gap: 0.5rem; align-items: center; }
</style>
Directives
{#each this.Items as item}
<li>{item.name}</li>
{/each}
{#each this.Items as item (item.id)}
<li>{item.name}</li>
{/each}
{#if this.IsLoggedIn}
<span>Welcome, {this.UserName}</span>
{:else}
<a href="/login">Sign in</a>
{/if}
{@this.HtmlContent}
<input bind:value={this.Name} />
<input type="checkbox" bind:checked={this.Active} />
<select bind:value={this.Role}>...</select>
<form onsubmit|prevent={this.Submit}>...</form>
<button onclick|stop={this.Handle}>...</button>
<a onclick|prevent|stop={this.Navigate}>...</a>
<div class="card">
<slot />
</div>
<slot name="footer" />
Compilation output
export function init(component, container) { /* build DOM once */ }
export function hydrate(component, container) { /* attach to SSR HTML */ }
export function renderToHtml(component) { /* SSR */ }
export function renderToHydration(component) { /* SSR with hydration markers */ }
Error messages include source line and pointer:
Counter.fjsx:3:14: ParseError: Expected > or /> after <button ...>
<button onclik={this.Increment}>
^
Component Model
type Counter(containerId: string) =
inherit Component(containerId)
let mutable _count = 0
member val Count = _count with get, set
member this.Increment() =
_count <- _count + 1
this.Count <- _count
this.Render()
override this.Render() =
Template.load "Counter.fjsx" this
Lifecycle hooks
override this.OnMount() = // called once when mounted
override this.OnUpdate() = // called after every re-render
override this.OnDispose() = // called on unmount
True hydration (SSR → client)
// Server: render with hydration markers
let html = Ssr.renderHydration "Counter.fjsx" counter
// Client App.fs — Hydrate() when server content exists, Mount() otherwise
if el.childElementCount > 0 then comp.Hydrate()
else comp.Mount()
Shared state
let store = Store.create { Theme = "dark"; User = None }
let unsub = Store.bind store (fun s -> this.Theme <- s.Theme) this
override this.OnDispose() = unsub()
Server
Router
router
.Get("/api/users", fun ctx -> promise {
let db = Env.requireD1 ctx.Env "DB"
let! users = getAll db
return Response.json (users |> List.toArray)
})
.Get("/api/users/:id", fun ctx -> promise {
let id = ctx.Params.["id"]
match! getById (Env.requireD1 ctx.Env "DB") id with
| None -> return Response.notFound "Not found"
| Some u -> return Response.json u
})
|> ignore
Middleware
let handler =
SecurityHeaders.helmet // X-Content-Type-Options, X-Frame-Options, ...
>>> SecurityHeaders.csp [ // Content-Security-Policy
Csp.DefaultSrc [ Csp.Self ]
Csp.ScriptSrc [ Csp.Self; Csp.Nonce "abc" ]
]
>>> Middleware.cors ["https://myapp.com"]
>>> Middleware.requestSizeLimit (1 * 1024 * 1024) // 1 MB
>>> Middleware.logger
>>> Csrf.protect
|> Middleware.apply router.Fetch
Rate limiting
// KV-backed (production)
RateLimit.perIp 60 60 (Env.requireKv ctx.Env "RATE_LIMIT_KV")
// In-memory (development)
RateLimit.inMemory 100 60
CSRF protection
// In a form template — embed token
let token = Csrf.token ctx
// Middleware validates X-CSRF-Token header against cookie on mutations
Csrf.protect
Cache control
Cache.control 3600 // Cache-Control: public, max-age=3600
Cache.private' // Cache-Control: private, no-store
Cache.etag // ETag + 304 Not Modified
Cache.vary ["Accept-Encoding"]
Pagination
// Offset-based
let p = Pagination.offsetParams ctx // ?page=1&size=20
let! rows = queryAll<T> db (sql + Pagination.offsetSql p) [||]
return Pagination.offsetResponse rows p totalCount
// Cursor-based
let p = Pagination.cursorParams ctx // ?cursor=xxx&limit=20
let! rows = queryAll<T> db (sql + Pagination.cursorSql "id" p) [||]
return Pagination.cursorResponse rows p (fun r -> r.id)
WebSocket
router.Get("/ws", fun ctx ->
if not (Ws.isUpgrade ctx) then
promise { return Response.badRequest "Expected WebSocket upgrade" }
else
let ws, response = Ws.accept ctx
ws.OnMessage(fun msg -> ws.Send("Echo: " + msg))
ws.OnClose(fun code _ -> JS.console.log(sprintf "closed: %d" code))
promise { return response })
Authentication + Passwords
// Hash and verify passwords (PBKDF2-SHA256, 600k iterations)
let! hash = Password.hash "my-password"
let! valid = Password.verify "my-password" hash
// Issue access + refresh token pair
let! tokens = Auth.issueTokens secret userId Map.empty 900 2592000
// tokens.AccessToken, tokens.RefreshToken, tokens.ExpiresIn
// Rotate refresh token (invalidates old token)
match! Auth.rotateTokens secret oldRefreshToken 900 2592000 with
| None -> return Response.status 401 {| error = "Invalid refresh token" |}
| Some pair -> return Response.json pair
Cookies
// Build secure Set-Cookie header
let header = Cookie.secure "session" token 3600 // HttpOnly; Secure; SameSite=Lax
// Custom attributes
let header = Cookie.build "pref" "dark" [
Cookie.SameSite Cookie.Strict
Cookie.MaxAge 86400
Cookie.Path "/"
]
// Read
let value = Cookie.get "session" ctx.Request
let all = Cookie.getAll ctx.Request
Crypto utilities
let equal = Crypto.timingSafeEqual tokenA tokenB // constant-time comparison
let hex = Crypto.randomHex 32 // 64-char hex string
let! h = Crypto.sha256Hex "data"
Validation
let validateUser =
Validator.required "name"
>=> Validator.minLength "name" 2
>=> Validator.maxLength "name" 100
match validateUser rawName with
| Ok name -> // proceed
| Error err -> Response.badRequest (Validator.formatErrors err)
D1 Database
let! users = queryAll<User> db "SELECT * FROM users" [||]
let! user = queryOne<User> db "SELECT * FROM users WHERE id = ?" [| id |]
do! execute db "INSERT INTO users (id, name) VALUES (?, ?)" [| id; name |]
|> Promise.map ignore
R2 Object Storage
let! _ = R2.put bucket "avatars/user-123.jpg" body "image/jpeg"
match! R2.get bucket "avatars/user-123.jpg" with
| None -> return Response.notFound "Not found"
| Some obj -> return R2.toResponse obj
POST form + PRG pattern
router.Post("/users", fun ctx -> promise {
let! form = Form.parseForm ctx.Request
match Form.field "name" form with
| Error e -> return Flash.redirectWith "/" "error" e
| Ok rawName ->
match! User.create db rawName with
| Error e -> return Flash.redirectWith "/" "error" e
| Ok user -> return Flash.redirectWith "/" "success"
(sprintf "User '%s' added" user.name)
})
Local Development with D1 / R2
bun run demo uses an in-memory mock and requires no Cloudflare account.
When you're ready to develop against real D1/R2 bindings locally, use wrangler dev.
1 — Configure wrangler.toml
For local-only development, a placeholder database_id is sufficient — Wrangler emulates D1 with a local SQLite file and does not contact Cloudflare.
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "00000000-0000-0000-0000-000000000000" # dummy OK for local dev
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-app-bucket" # does not need to exist on Cloudflare
2 — Apply migrations locally
wrangler d1 execute DB --local --file=migrations/0001_init.sql
# repeat for each migration file
3 — Start the local Cloudflare Workers runtime
bun dev
# runs wrangler dev + FJSX watcher simultaneously
Or proxy the fff-stack dev server to wrangler dev:
# Terminal 1
wrangler dev
# Terminal 2
WRANGLER_URL=http://localhost:8787 bun run demo
Local D1 data is stored in .wrangler/state/d1/ and R2 in .wrangler/state/r2/.
Note:
wrangler login(free Cloudflare account) is required even for local development. No D1/R2 resources need to be created on Cloudflare until you deploy.
Deployment
# 1. Create D1 database on Cloudflare
wrangler d1 create my-app-db
# → copy the database_id into wrangler.toml
# 2. Apply migrations
fff migrate
# 3. Deploy to Cloudflare Workers
bun run deploy
UI Library
The template ships a Cloudflare-inspired design system in public/style.css — no build step, zero dependencies.
/* Design tokens */
--fff-accent: #F6821F; /* Cloudflare orange */
--fff-bg: #F3F4F6; /* page background */
--fff-surface: #FFFFFF; /* cards, inputs */
/* + success/warning/danger/info with -dim variants */
/* + --fff-sidebar-* for two-column admin panel layout */
Components included: .card, .form-group, .form-row, .form-actions, .form-checkbox, .radio-group, .data-table, .list-header, .stat-bar, .badge-{success|warning|danger|info|neutral}, .flash-{success|error|warning|info}, .empty-state, .app-shell (sidebar layout), .btn-{primary|secondary|danger|ghost}.
All classes used by fff generate form and fff generate list are pre-styled — prototypes look presentable immediately.
Packages
| Package | Description |
|---|---|
@fff-stack/fjsx-compiler |
FJSX → JS compiler + CLI (fjsx, fjsx-watcher) |
FffStack.Client |
F# component runtime (Component, Template, Store) |
FffStack.Server |
F# server runtime (Router, D1, R2, Auth, Validation, Security…) |
FffStack.Cli |
fff CLI (new, prototype, generate, check, context, routes, doctor, migrate, openapi) |
create-fff-app |
Project scaffold without .NET (bunx create-fff-app my-app) |
Contributing / Monorepo Development
To work on fff-stack itself (packages, compiler, runtime):
git clone https://github.com/alcogy/fff-stack.git
cd fff-stack
# Install JS workspace packages
bun install
# Build the FJSX compiler CLI
cd packages/fjsx-compiler && bun run build && cd ../..
# Install Fable (local tool — pinned version)
cd template && dotnet tool restore && cd ..
# Run the demo
bun dev # → http://localhost:3000
Build targets (inside template/):
bun run build:templates # compile *.fjsx → JS (client components)
bun run build:page-templates # compile *.fjsx → JS (server pages)
bun run build:server # Fable: F# server → dist/server/
bun run build:client # Fable: F# client → dist/client/
bun run build # all of the above + assets
bun run test:server # F# server-runtime tests
Publish:
# npm
cd packages/fjsx-compiler && npm publish
cd packages/create-fff-app && npm publish # bundles template/ automatically
# NuGet
cd packages/server-runtime && dotnet pack && dotnet nuget push **/*.nupkg ...
cd packages/client-runtime && dotnet pack && dotnet nuget push **/*.nupkg ...
cd packages/fff-cli && dotnet pack && dotnet nuget push **/*.nupkg ...
Roadmap
v1–v3 ✅ Complete
- FJSX compiler — lexer, parser, codegen, source maps,
{#each},{#if},{@expr} - CSS
<style>blocks, keyed{#each}diffing, true hydration - SSR —
renderToHtml/renderToHydration - Class-based component runtime with lifecycle hooks
- Cloudflare Workers router, D1, R2, Auth (JWT HS256), Middleware
- POST form + PRG pattern, flash messages, client-side navigation
- MPA page routing, Islands Architecture, Component-level HMR
- Type-safe template bindings, FJSX source maps
create-fff-appscaffold, VS Code extension
v4 ✅ Complete — DDD Enforcement
Domain/→Workflows/→Endpoints/structurefff generate feature/model/page/component/migrationfff check— CI-ready layer dependency validator- Dockerfile +
docker compose up
v5 ✅ Complete — Full-Stack Features
- FJSX:
bind:value, event modifiers (|prevent|stop),<slot>composition - WebSocket support —
Ws.accept,WebSocketHandle,Ws.broadcast - CSRF protection, rate limiting (KV + in-memory), cache control, pagination
fff new,fff routes
v6 ✅ Complete — Security + DX
- Security headers —
SecurityHeaders.helmet, typed CSP builder, HSTS - Secure cookies —
Cookie.build/secure/clear/getwith typed attributes - Crypto —
timingSafeEqual,randomHex,sha256Hex - Password hashing — PBKDF2-SHA256 (600k iterations)
- JWT refresh tokens —
Auth.issueTokens,Auth.rotateTokens - Request size limit middleware —
Middleware.requestSizeLimit fff migrate— D1 migration runnerfff doctor— project health check- FJSX error messages — line/col + source excerpt with
^pointer fff openapi— OpenAPI 3.0.3 spec generation
v7 ✅ Complete — FP-DDD Business App Focus
- Type-Driven Scaffolding —
fff generate form/listreads F# record types and generates FJSX - Bounded Context Tooling —
fff context/fff context --mermaid,fff checkboundary isolation - Prototyping support —
fff prototypefull vertical slice with in-memory workflow - Cloudflare-inspired UI kit — CSS custom properties, all scaffold classes pre-styled
- Template redesign — Employee Directory (business CRUD demo with PRG, validation, stat badges)
v8 ✅ Complete — OSS Packaging
ProjectReference→PackageReferencein template (NuGet packages)create-fff-appbundles template at publish time (prepublishOnly)dev-server.tsimports@fff-stack/fjsx-compilervia npm packagefff newresolves template from globally installedcreate-fff-app
Next
- PostgreSQL / MySQL support
- AWS Lambda / on-premises infrastructure adapter
- File-based routing
- OAuth2 / social login (GitHub, Google)
- RBAC middleware
fff prototypeupgrade path —fff upgrade <Name>converts in-memory to D1 workflow
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
- Fable.Browser.Dom (>= 2.20.0)
- Fable.Browser.Event (>= 1.7.0)
- Fable.Core (>= 4.5.0)
- FSharp.Core (>= 9.0.300)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|