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 |
|---|---|
|
Parse a required query param. Returns |
|
Parse an optional query param. Returns |
|
Parse an integer query param with bounds |
|
Deserialize the JSON request body |
|
Format an AT Protocol error response |
|
Format a 400 InvalidRequest error |
|
Format a 401 AuthenticationRequired error |
|
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.
namespace FSharp
--------------------
namespace Microsoft.FSharp
val string: value: 'T -> string
--------------------
type string = System.String
module Result from Microsoft.FSharp.Core
--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
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>
<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 -> printfn "Authority: %s, Name: %s" (Nsid.authority nsid) (Nsid.name nsid) | Error e -> printfn "Invalid: %s" e </code></example>
<summary> XRPC server builder and configuration. </summary>
<summary> The HTTP method an XRPC endpoint expects. </summary>
<summary> XRPC query -- mapped to HTTP GET. </summary>
<summary> XRPC middleware for error formatting, rate limiting, and request validation. </summary>
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>
<summary> Default JSON options matching AT Protocol conventions. </summary>
<summary> Set a rate limit on an endpoint. </summary>
[<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(value: float) : System.TimeSpan
System.TimeSpan.FromMinutes(minutes: int64, ?seconds: int64, ?milliseconds: int64, ?microseconds: int64) : System.TimeSpan
<summary> Mark an endpoint as requiring authentication. </summary>
<summary> XRPC procedure -- mapped to HTTP POST. </summary>
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
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
<summary> Create a default config with no endpoints or auth. </summary>
<summary> Add an endpoint to the server config. </summary>
<summary> Set the token verification function. </summary>
<summary> Set a global rate limit for endpoints without per-endpoint limits. </summary>