Middleware

Firefly includes 15+ built-in middleware modules. A middleware is a function that wraps a handler:

type Middleware = Handler -> Handler
// Which expands to:
// (Request -> Task<Response>) -> (Request -> Task<Response>)

Apply middleware globally via App.middleware or per-route via Route.middleware.

CORS

Cross-Origin Resource Sharing support with configurable origins, methods, and headers.

// Allow all origins
App.defaults |> App.middleware Cors.allowAll
 
// Configured
let cors =
    Cors.defaults
    |> Cors.origins ["https://myapp.com"; "https://staging.myapp.com"]
    |> Cors.methods ["GET"; "POST"; "PUT"; "DELETE"]
    |> Cors.headers ["Authorization"; "Content-Type"]
    |> Cors.maxAge 3600
    |> Cors.build
 
App.defaults |> App.middleware cors

The middleware handles preflight OPTIONS requests automatically, returning 204 with the appropriate Access-Control-* headers. Non-preflight requests get Access-Control-Allow-Origin added to the response.

JWT Authentication

Validates JWT tokens from the Authorization: Bearer <token> header.

let jwtConfig =
    Jwt.defaults "your-256-bit-secret"
    |> Jwt.issuer "my-app"
    |> Jwt.audience "my-api"
 
// Apply to protected routes
Route.start
|> Route.group "/api" (fun t ->
    t
    |> Route.middleware (Jwt.validate jwtConfig)
    |> Route.get "/profile" (fun (req: Request) -> task {
        let claims = Jwt.claims req
        match claims with
        | Some c -> return Response.json {| sub = c.["sub"] |}
        | None -> return Response.unauthorized
    })
)

Supports optional encryption key for encrypted JWTs:

Jwt.defaults "signing-key"
|> Jwt.encryptionKey "encryption-key"

Returns 401 with { "error": "invalid token" } on validation failure and { "error": "missing or invalid authorization header" } when the header is absent.

Rate Limiting

Fixed-window rate limiting with configurable key function:

// 100 requests per minute, keyed by IP address
let rateLimiter =
    RateLimit.fixedWindow 100 (TimeSpan.FromMinutes 1.0) RateLimit.byIp
 
App.defaults |> App.middleware rateLimiter

Custom key function:

// Rate limit by API key header
let byApiKey (req: Request) =
    req.Header "X-Api-Key" |> Option.defaultValue "anonymous"
 
RateLimit.fixedWindow 1000 (TimeSpan.FromHours 1.0) byApiKey

Returns 429 with a Retry-After header when the limit is exceeded.

Timeout

Abort requests that exceed a time limit:

Timeout.after (TimeSpan.FromSeconds 30.0)

Returns 504 Gateway Timeout if the handler does not complete in time.

Compression

Response body compression with gzip, brotli, or auto-detection:

// Auto-select: brotli > gzip > none (based on Accept-Encoding)
App.defaults |> App.middleware Compress.auto
 
// Or choose specifically
App.defaults |> App.middleware Compress.gzip
App.defaults |> App.middleware Compress.brotli

The middleware inspects the Accept-Encoding header (with quality values) and compresses Text and Json response bodies. Stream bodies are passed through uncompressed.

Request ID

Generates or forwards a unique request identifier:

App.defaults |> App.middleware RequestId.middleware

If the incoming request has an X-Request-Id header, it is forwarded. Otherwise a new GUID is generated. The ID is available via req.RequestId and added to the response as X-Request-Id.

Correlation ID

Tracks requests across service boundaries:

App.defaults |> App.middleware CorrelationId.middleware

Works like Request ID but uses the X-Correlation-Id header. Available via req.CorrelationId.

Secure Headers

Adds security headers similar to Helmet.js:

// Default secure headers
App.defaults |> App.middleware SecureHeaders.middleware

Default headers added:

HeaderDefault Value
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
X-XSS-Protection0
Referrer-Policystrict-origin-when-cross-origin
Content-Security-Policydefault-src 'self'
Strict-Transport-Securitymax-age=31536000; includeSubDomains
Permissions-Policycamera=(), microphone=(), geolocation=()

Configurable:

let headers =
    SecureHeaders.defaults
    |> SecureHeaders.contentSecurityPolicy "default-src 'self'; script-src 'self' https://cdn.example.com"
    |> SecureHeaders.frameOptions "SAMEORIGIN"
    |> SecureHeaders.referrerPolicy "no-referrer"
    |> SecureHeaders.noHsts  // disable HSTS (e.g. for dev)
    |> SecureHeaders.build
 
App.defaults |> App.middleware headers

CSRF Protection

Double-submit cookie pattern for CSRF protection:

App.defaults |> App.middleware Csrf.middleware

Safe methods (GET, HEAD, OPTIONS) pass through and may set the CSRF cookie. State-changing methods (POST, PUT, PATCH, DELETE) require the token via:

  • X-CSRF-Token header, or
  • _csrf form field

Generate tokens in your views:

// Hidden form input
let formView (req: Request) =
    form [] [
        Csrf.hiddenInput req  // <input type="hidden" name="_csrf" value="...">
        input [ Attr.Type "text"; Name "email" ]
        button [] [ str "Submit" ]
    ]
 
// Meta tag for AJAX
let layoutView (req: Request) =
    head [] [
        Csrf.metaTag req  // <meta name="csrf-token" content="...">
    ]

Session

In-memory session management using cookies:

App.defaults |> App.middleware Session.middleware

Read and write session data:

let handler (req: Request) = task {
    // Read
    let username = Session.get<string> "username" req
 
    // Write
    Session.set "username" "alice" req
 
    // Remove a key
    Session.remove "username" req
 
    // Clear entire session
    Session.clear req
 
    return Response.ok
}

For testing, use a custom store:

let testStore = Session.SessionStore()
App.defaults |> App.middleware (Session.withStore testStore)

Session values are stored as JSON, so any type passed to Session.set must be JSON-serializable (records, primitives, collections).

Distributed sessions

Session.middleware keeps sessions in process memory — fine for a single node, but they don't survive a restart or scale across instances. For a shared backend, use Session.distributed, which stores sessions in any registered IDistributedCache:

App.defaults
|> App.services [
    // In-memory IDistributedCache (single node) — both are in the shared framework:
    Service.raw (fun s -> s.AddDistributedMemoryCache() |> ignore)
]
|> App.middleware Session.distributed

To make sessions shared and durable, register a Redis-backed IDistributedCache instead. The Redis package lives in your app — Firefly never depends on it:

// dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
App.defaults
|> App.services [
    Service.raw (fun s ->
        s.AddStackExchangeRedisCache(fun o -> o.Configuration <- "localhost:6379") |> ignore)
]
|> App.middleware Session.distributed

Any IDistributedCache implementation works (Redis, SQL Server, Garnet, NCache, …) — Session.distributed resolves whatever is registered. The same Session.get/set/remove/clear API is used for both backends. Pass an explicit cache with Session.withCache cache, or custom expiration with Session.withCacheOptions cache options (the default is a 20-minute sliding expiration).

Idempotency

Cache responses for requests with an Idempotency-Key header to ensure safe retries:

let store = Idempotent.inMemory ()
let ttl = TimeSpan.FromMinutes 60.0
 
App.defaults |> App.middleware (Idempotent.middleware store ttl)

Only applies to POST, PUT, and PATCH. GET and DELETE pass through. When a duplicate key is detected, the cached response is returned with an Idempotency-Replayed: true header.

The IdempotencyStore is an interface you can implement for Redis or database backing:

type IdempotencyStore =
    abstract TryGet : key:string -> Task<CachedResponse option>
    abstract Set : key:string * response:CachedResponse * ttl:TimeSpan -> Task<unit>

Health Checks

Register health check endpoints with customizable checks:

let healthHandler =
    Health.handler [
        Health.ping  // always healthy
        Health.check "database" (fun () -> task {
            // Check DB connectivity
            do! db.PingAsync()
        })
        Health.check "redis" (fun () -> task {
            do! redis.PingAsync()
        })
    ]
 
Route.start
|> Route.get "/health" healthHandler

Returns 200 when all checks are healthy, 503 when any check fails. Response body includes per-check status, duration, and error details.

Response Caching

Multiple caching strategies:

// Public cache with max-age
Route.middleware (Cache.maxAge 3600)
 
// Private (user-specific) cache
Route.middleware (Cache.privateMaxAge 60)
 
// Disable caching
Route.middleware Cache.noStore
 
// Vary by headers
Route.middleware (Cache.varyBy ["Accept"; "Accept-Encoding"])
 
// Auto ETag with 304 Not Modified
Route.middleware Cache.etag
 
// Combined: max-age + ETag + Vary
Route.middleware (Cache.standard 3600 ["Accept"])

The Cache.etag middleware computes a SHA-256 hash of the response body, sets an ETag header, and returns 304 Not Modified when the client sends a matching If-None-Match header. Only applies to GET and HEAD requests with 2xx status codes.

AutoETag.middleware is an alias for Cache.etag.

Telemetry

OpenTelemetry-compatible tracing and metrics:

App.defaults |> App.middleware Telemetry.middleware

Creates an Activity (span) per request with tags:

  • http.request.method
  • url.path
  • url.scheme
  • http.request_id (if present)
  • http.response.status_code

Records metrics:

MetricTypeDescription
firefly.http.requestsCounterTotal HTTP requests
firefly.http.durationHistogramRequest duration in ms
firefly.http.active_requestsUpDownCounterCurrently active requests

Export over OTLP in one line. Telemetry.otlp reads the endpoint, headers, and protocol from the standard OTEL_EXPORTER_OTLP_* environment variables (default endpoint http://localhost:4317):

App.defaults
|> App.services [ Telemetry.otlp "my-service" ]
|> App.middleware Telemetry.middleware

For a custom exporter, wire it yourself using the source and meter names:

Telemetry.sourceName  // "Firefly"
Telemetry.meterName   // "Firefly"

See Deployment → OpenTelemetry for the full setup.

Live Metrics Dashboard

Serve a built-in, real-time dashboard in one line:

App.defaults |> App.dashboard "/dashboard"

This mounts a live page at /dashboard and an SSE stream at /dashboard/stream. The page (no build step, no external assets) updates once per second with:

  • Requests/sec with a live sparkline, plus total requests
  • Error rate (5xx) and current in-flight requests
  • Latency p50 / p95 / p99 and average (ms)
  • System: managed memory, GC collection counts (gen 0/1/2), thread-pool threads, and uptime

Metrics are collected in-process by the middleware itself, so it works with or without Telemetry.middleware. Add it early in the pipeline so it measures the full request path. The dashboard requests themselves are not counted as application traffic.

Protect the route in production — put it behind your own auth/network controls, since it exposes operational metrics:

App.defaults
|> App.middleware myAdminGuard   // your own middleware that rejects non-admins
|> App.dashboard "/dashboard"

Upload Size Limit

Restrict request body size:

// 10 MB limit
Route.middleware (Upload.maxSize (10L * 1024L * 1024L))

Returns 413 Payload Too Large if the Content-Length exceeds the limit.

Writing Custom Middleware

A middleware is any function matching Handler -> Handler:

let timing : Middleware =
    fun next req -> task {
        let sw = System.Diagnostics.Stopwatch.StartNew()
        let! response = next req
        sw.Stop()
        return response |> Response.header "X-Response-Time" $"{sw.ElapsedMilliseconds}ms"
    }

Apply it like any built-in middleware:

App.defaults |> App.middleware timing
// or
Route.middleware timing