Header menu logo FSharp.ATProto

XRPC Server

FSharp.ATProto.XrpcServer provides a framework for building AT Protocol-compliant XRPC servers with built-in authentication and rate limiting. It maps XRPC endpoints to ASP.NET minimal API routes, handling error formatting, bearer token verification, and sliding-window rate limiting automatically.

open FSharp.ATProto.XrpcServer
open FSharp.ATProto.Syntax
open Microsoft.AspNetCore.Http

Key Types

Each endpoint is described by an XrpcEndpoint record:

type XrpcEndpoint = {
    Nsid: Nsid
    Method: XrpcMethod       // XrpcMethod.Query (GET) or XrpcMethod.Procedure (POST)
    Handler: HttpContext -> Task<IResult>
    RateLimit: RateLimitConfig option
    RequireAuth: bool
}

The server configuration holds all registered endpoints, an optional token verifier, and global rate limits:

type XrpcServerConfig = {
    Endpoints: XrpcEndpoint list
    VerifyToken: (string -> Task<Result<ClaimsPrincipal, string>>) option
    GlobalRateLimit: RateLimitConfig option
    JsonOptions: JsonSerializerOptions
}

Defining Endpoints

Use XrpcServer.endpoint to create an endpoint, then pipe through withRateLimit and withAuth as needed:

let nsid = Nsid.parse "app.example.getProfile" |> Result.defaultWith failwith

let getProfile =
    XrpcServer.endpoint nsid XrpcMethod.Query (fun ctx ->
        task {
            match Middleware.requireQueryParam "actor" ctx with
            | Ok actor ->
                return Results.Json({| did = actor; handle = "example.com" |})
            | Error msg ->
                return Middleware.invalidRequest msg XrpcServerConfig.defaultJsonOptions
        })
    |> XrpcServer.withRateLimit { MaxRequests = 100; Window = System.TimeSpan.FromMinutes 1.0 }
    |> XrpcServer.withAuth

For POST endpoints, use XrpcMethod.Procedure and read the request body with Middleware.tryReadJsonBody:

let createItem =
    XrpcServer.endpoint createItemNsid XrpcMethod.Procedure (fun ctx ->
        task {
            let! body = Middleware.tryReadJsonBody<{| name: string |}> XrpcServerConfig.defaultJsonOptions ctx
            match body with
            | Ok item ->
                return Results.Json({| created = true; name = item.name |})
            | Error msg ->
                return Middleware.invalidRequest msg XrpcServerConfig.defaultJsonOptions
        })
    |> XrpcServer.withAuth

Authentication

The Auth module extracts and verifies bearer tokens. You provide a verifyToken function that validates the token string and returns a ClaimsPrincipal on success:

let myTokenVerifier (token: string) : Task<Result<ClaimsPrincipal, string>> =
    task {
        // Your JWT validation logic here
        // Return Ok principal or Error message
    }

Inside an endpoint handler, retrieve the authenticated principal or a specific claim:

let handler (ctx: HttpContext) =
    task {
        match Auth.getPrincipal ctx with
        | Some principal -> // authenticated
            let did = Auth.getClaim "sub" ctx // get the "sub" claim
            return Results.Json({| authed = true |})
        | None -> // not authenticated
            return Middleware.authRequired "Login required" XrpcServerConfig.defaultJsonOptions
    }

Endpoints marked with withAuth reject unauthenticated requests with a 401 error automatically. For endpoints without withAuth, the framework still attempts optional token verification if a bearer token is present, making the principal available but not requiring it.

Rate Limiting

RateLimiter implements a per-client sliding window rate limiter. Clients are identified by IP address. Configure rate limits per-endpoint or globally:

type RateLimitConfig = {
    MaxRequests: int
    Window: TimeSpan
}

Per-endpoint limits take precedence over the global limit. When a client exceeds their limit, the server returns a 429 response with a Retry-After header. Remaining request counts are sent in the RateLimit-Remaining header on successful requests.

Middleware Helpers

The Middleware module provides helpers for common request/response patterns:

Function

Purpose

requireQueryParam name ctx

Parse a required query param. Returns Result<string, string>

optionalQueryParam name ctx

Parse an optional query param. Returns string option

intQueryParam name default min max ctx

Parse an integer query param with bounds

tryReadJsonBody<'T> jsonOptions ctx

Deserialize the JSON request body

xrpcError statusCode error message jsonOptions

Format an AT Protocol error response

invalidRequest message jsonOptions

Format a 400 InvalidRequest error

authRequired message jsonOptions

Format a 401 AuthenticationRequired error

rateLimitExceeded retryAfter jsonOptions

Format a 429 RateLimitExceeded error

Building and Running

Compose the server configuration with the builder functions, then call XrpcServer.configure or XrpcServer.configureWithPort:

let myTokenVerifier (token: string) =
    task { return Ok (System.Security.Claims.ClaimsPrincipal()) }

let config =
    XrpcServerConfig.defaults
    |> XrpcServer.addEndpoint getProfile
    |> XrpcServer.addEndpoint createItem
    |> XrpcServer.withTokenVerifier myTokenVerifier
    |> XrpcServer.withGlobalRateLimit { MaxRequests = 1000; Window = System.TimeSpan.FromMinutes 5.0 }
let app = XrpcServer.configureWithPort 3000 config
app.Run()

configure returns a WebApplication without binding a port (useful when you want to control hosting yourself). configureWithPort binds to the specified port.

The server automatically registers a GET /_health endpoint that returns { "version": "1.0.0" } for health checks.

Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.ATProto
namespace FSharp.ATProto.Syntax
namespace FSharp.ATProto.XrpcServer
namespace System
namespace System.Security
namespace System.Security.Claims
namespace Microsoft
type XrpcEndpoint = { Nsid: obj Method: obj Handler: (obj -> obj) RateLimit: obj RequireAuth: bool }
type Handler<'T> = delegate of objnull * 'T -> unit
type 'T option = Option<'T>
type bool = System.Boolean
type XrpcServerConfig = { Endpoints: XrpcEndpoint list VerifyToken: (string -> obj) option GlobalRateLimit: obj JsonOptions: obj }
type 'T list = List<'T>
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
val nsid: Nsid
Multiple items
module Nsid from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="Nsid" /> values. </summary>

--------------------
type Nsid = private | Nsid of string override ToString: unit -> string
<summary> A Namespaced Identifier (NSID) used to reference Lexicon schemas in the AT Protocol. NSIDs follow the pattern <c>authority.name</c> where the authority is a reversed domain name and the name identifies the specific schema (e.g. <c>app.bsky.feed.post</c>). Maximum length is 317 characters. </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/nsid </remarks>
val parse: s: string -> Result<Nsid,string>
<summary> Parse and validate an NSID string. </summary>
<param name="s"> An NSID string in the format <c>domain.segments.name</c> (e.g. <c>"app.bsky.feed.post"</c>). Must have at least three segments, where the final segment (name) starts with a letter and the preceding segments form a valid reversed domain authority. </param>
<returns><c>Ok</c> with a validated <see cref="Nsid" />, or <c>Error</c> with a message describing the validation failure. Validation failures include: null input, exceeding the 317-character limit, or invalid NSID syntax. </returns>
<example><code> match Nsid.parse "app.bsky.feed.post" with | Ok nsid -&gt; printfn "Authority: %s, Name: %s" (Nsid.authority nsid) (Nsid.name nsid) | Error e -&gt; printfn "Invalid: %s" e </code></example>
val defaultWith: defThunk: ('Error -> 'T) -> result: Result<'T,'Error> -> 'T
val failwith: message: string -> 'T
val getProfile: XrpcEndpoint
module XrpcServer from FSharp.ATProto.XrpcServer
<summary> XRPC server builder and configuration. </summary>
type XrpcMethod = | Query | Procedure
<summary> The HTTP method an XRPC endpoint expects. </summary>
union case XrpcMethod.Query: XrpcMethod
<summary> XRPC query -- mapped to HTTP GET. </summary>
val task: TaskBuilder
module Middleware from FSharp.ATProto.XrpcServer
<summary> XRPC middleware for error formatting, rate limiting, and request validation. </summary>
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
Multiple items
module XrpcServerConfig from FSharp.ATProto.XrpcServer

--------------------
type XrpcServerConfig = { Endpoints: XrpcEndpoint list VerifyToken: (string -> Task<Result<ClaimsPrincipal,string>>) option GlobalRateLimit: RateLimitConfig option JsonOptions: JsonSerializerOptions }
<summary> Configuration for the XRPC server. </summary>
val defaultJsonOptions: System.Text.Json.JsonSerializerOptions
<summary> Default JSON options matching AT Protocol conventions. </summary>
val withRateLimit: config: RateLimitConfig -> ep: XrpcEndpoint -> XrpcEndpoint
<summary> Set a rate limit on an endpoint. </summary>
Multiple items
[<Struct>] type TimeSpan = new: hours: int * minutes: int * seconds: int -> unit + 4 overloads member Add: ts: TimeSpan -> TimeSpan member CompareTo: value: obj -> int + 1 overload member Divide: divisor: float -> TimeSpan + 1 overload member Duration: unit -> TimeSpan member Equals: value: obj -> bool + 2 overloads member GetHashCode: unit -> int member Multiply: factor: float -> TimeSpan member Negate: unit -> TimeSpan member Subtract: ts: TimeSpan -> TimeSpan ...
<summary>Represents a time interval.</summary>

--------------------
System.TimeSpan ()
System.TimeSpan(ticks: int64) : System.TimeSpan
System.TimeSpan(hours: int, minutes: int, seconds: int) : System.TimeSpan
System.TimeSpan(days: int, hours: int, minutes: int, seconds: int) : System.TimeSpan
System.TimeSpan(days: int, hours: int, minutes: int, seconds: int, milliseconds: int) : System.TimeSpan
System.TimeSpan(days: int, hours: int, minutes: int, seconds: int, milliseconds: int, microseconds: int) : System.TimeSpan
System.TimeSpan.FromMinutes(minutes: int64) : System.TimeSpan
System.TimeSpan.FromMinutes(value: float) : System.TimeSpan
System.TimeSpan.FromMinutes(minutes: int64, ?seconds: int64, ?milliseconds: int64, ?microseconds: int64) : System.TimeSpan
val withAuth: ep: XrpcEndpoint -> XrpcEndpoint
<summary> Mark an endpoint as requiring authentication. </summary>
val createItemNsid: Nsid
val createItem: XrpcEndpoint
union case XrpcMethod.Procedure: XrpcMethod
<summary> XRPC procedure -- mapped to HTTP POST. </summary>
val myTokenVerifier: token: string -> System.Threading.Tasks.Task<unit>
val token: string
val handler: ctx: 'a -> System.Threading.Tasks.Task<'b>
val ctx: 'a
union case Option.Some: Value: 'T -> Option<'T>
val principal: obj
val did: obj
union case Option.None: Option<'T>
type RateLimitConfig = { MaxRequests: int Window: obj }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
val myTokenVerifier: token: string -> System.Threading.Tasks.Task<Result<ClaimsPrincipal,'a>>
Multiple items
type ClaimsPrincipal = interface IPrincipal new: unit -> unit + 4 overloads member AddIdentities: identities: IEnumerable<ClaimsIdentity> -> unit member AddIdentity: identity: ClaimsIdentity -> unit member Clone: unit -> ClaimsPrincipal member FindAll: ``match`` : Predicate<Claim> -> IEnumerable<Claim> + 1 overload member FindFirst: ``match`` : Predicate<Claim> -> Claim + 1 overload member HasClaim: ``match`` : Predicate<Claim> -> bool + 1 overload member IsInRole: role: string -> bool member WriteTo: writer: BinaryWriter -> unit ...
<summary>An <see cref="T:System.Security.Principal.IPrincipal" /> implementation that supports multiple claims-based identities.</summary>

--------------------
ClaimsPrincipal() : ClaimsPrincipal
ClaimsPrincipal(identities: System.Collections.Generic.IEnumerable<ClaimsIdentity>) : ClaimsPrincipal
ClaimsPrincipal(reader: System.IO.BinaryReader) : ClaimsPrincipal
ClaimsPrincipal(identity: System.Security.Principal.IIdentity) : ClaimsPrincipal
ClaimsPrincipal(principal: System.Security.Principal.IPrincipal) : ClaimsPrincipal
val config: XrpcServerConfig
val defaults: XrpcServerConfig
<summary> Create a default config with no endpoints or auth. </summary>
val addEndpoint: ep: XrpcEndpoint -> config: XrpcServerConfig -> XrpcServerConfig
<summary> Add an endpoint to the server config. </summary>
val withTokenVerifier: verifyToken: (string -> System.Threading.Tasks.Task<Result<ClaimsPrincipal,string>>) -> config: XrpcServerConfig -> XrpcServerConfig
<summary> Set the token verification function. </summary>
val withGlobalRateLimit: rateLimit: RateLimitConfig -> config: XrpcServerConfig -> XrpcServerConfig
<summary> Set a global rate limit for endpoints without per-endpoint limits. </summary>
val app: obj

Type something to start searching.